20260110.用 PySpark + Doris Stream Load 2PC 做一套“通用表格解析入库代码”

4 阅读5分钟

20260110.用 PySpark + Doris Stream Load 2PC 做一套“通用表格解析入库代码”

场景:一个目录里不断落 Excel/CSV(不同店铺、不同站点、不同模板),我们要在 Spark(YARN) 上批量解析、归一化表头、写入 Doris 2.1.11,并且要做到:

  • • 每个文件独立可追溯(批次 id、run_uuid、原始文件名、sheet、映射版本)
  • • 单个文件导入失败可回滚(2PC)
  • • 表头归一化完全配置化(不写死映射)
  • • 文件归档(成功按类型/日期归档,失败入 failed)
  • • 全量运行日志落表(方便之后靠 batch_id/run_uuid 回溯)

这篇文章以我们落地的一套实现为主线,讲清楚设计、关键实现点、以及在 Doris + Py2.7 + urllib2 + 2PC 组合下踩过的坑和最终解法。


1. 目标与约束

目标

  1. 1. 遍历目录:扫描 $BASE_DIR/update 下所有 xlsx/xls/csv(本文先聚焦 xlsx/xls)。
  2. 2. 文件名解析元数据:文件名约定 <shop_prefix>_<file_type>_<ts>.xlsx,例如:
    WLM-YECAYE-YILIKEJI-US_ORDER-WFSFulfilled_20260109151331.xlsx
    • • shop_prefix:WLM-YECAYE-YILIKEJI-US
    • • file_type:ORDER-WFSFulfilled
    • • ts:20260109151331(用于归档目录 yyyymmdd)
  3. 3. 批次表记录:每个文件处理前先插入 wlm_batch 一条记录,拿到 batch_id
  4. 4. 表头映射配置化:只从 wlm_header_mapping 读取 source_header -> target_header 以及 target_table
    • • 若某 file_type 没配置映射:直接报错走失败流程(不兜底)。
  5. 5. 2PC Stream Load:单文件导入要么全部成功、要么失败回滚。
  6. 6. 归档
    • • 成功或 NO_DATA:移动到 $BASE_DIR/<file_type>/<yyyymmdd>/
    • • 失败:移动到 $BASE_DIR/failed/
  7. 7. 运行日志:把一次 main.py 的输出全文落到 wlm_rpa_logs,并记录本次 run 的 batch_id 集合。

约束

  • Spark 2.4.0-cdh6.3.2Python 2.7.18
  • • Doris 版本 2.1.11,支持 MySQL 协议(16003)+ HTTP Stream Load(16001)。
  • • 生产环境 requests 不一定可用,所以要有 urllib2 完整链路(包括 307 重定向、编码、鉴权)。

2. 数据库与表设计:把“溯源”做成一等公民

2.1 wlm_order:业务数据 + 溯源字段

订单数据最终落到 wlm_order。为了实现可追溯,我们在业务字段之外引入:

  • batch_id:对应 wlm_batch.id(一对多)
  • run_uuid:一次程序运行的唯一 id(全链路贯穿)
  • file_type:例如 ORDER-WFSFulfilled
  • source_file:原始文件名
  • remark:记录映射 key、sheet、批次等上下文(后面 QA 详解)

2.2 wlm_batch:每文件一条,记录全过程

wlm_batch 是“文件级”事实表。它记录:

  • • 文件元信息(file_name、file_path、file_type、site、shop_prefix、sheet_name)
  • • 映射信息(mapping_key)
  • • Doris 写入信息(label、txn_id、rows、bytes、耗时、error_url)
  • • 状态机(RUNNING / SUCCESS / FAILED / NO_DATA)
  • • remark(失败堆栈、原因)

这样你后续排查时只要拿 batch_id 就能定位一切。

2.3 wlm_header_mapping:归一化的配置中心

表头归一化完全落到 wlm_header_mapping

  • mapping_scope:ALL / US / CA / shop_prefix(可覆盖)
  • file_type:如 ORDER-WFSFulfilled
  • target_table:本套映射写入哪个表(彻底解耦)
  • source_headertarget_header
  • • remark:记录“为什么这列是 US only / CA only”等信息

后续新增类型只需要插 mapping 行,不改代码。

2.4 wlm_rpa_logs:一次运行的全量日志

wlm_rpa_logs 保存:

  • • run_uuid
  • • 状态(SUCCESS / PARTIAL / FAILED)
  • • batch_ids(逗号拼接)
  • • 文件统计(总数、成功、失败、无数据)
  • • log_text(main.py 全量输出)

这样你能从日志表直接回放一次任务的执行细节。


3. 程序架构:把“目录任务”拆成清晰的流水线

一份通用 ETL 主程序,不要做成“巨型函数”。我们按职责拆成模块:

3.1 目录扫描与文件名解析

  • • 扫描 update/ 目录
  • • 通过 _ 拆文件名拿 shop_prefix / file_type / file_ts
  • • 支持跳过不支持类型(比如 MULTIORDER

3.2 映射加载与选择(只初始化一次)

启动时用 Spark JDBC 把 wlm_header_mapping 读入 Driver 侧,构建字典结构:

  • • 优先级:shop_prefix > site > ALL
  • • 合并规则:越具体覆盖越通用
  • • 若找不到:直接抛异常(不兜底)

3.3 批次表写入(获取 batch_id)

每个文件处理前,先插入 wlm_batch,拿到 batch_id,后续:

  • • 注入到 df 中(batch_id/version)
  • • 写入 wlm_order
  • • 用 batch_id 反查该文件是否成功、失败原因

3.4 Excel 读取与 NO_DATA 判断

  • • Sheet 优先级:Po DetailsPO Update Response
  • • 若第一列是 Error:判定 NO_DATA(不入 wlm_order,但更新批次状态并归档)

3.5 归一化(Rename + 补列 + cast)

  • • 先应用 mapping rename
  • • 然后按 target_table 的 schema(DESC 表)拿 target_cols(排除 auto id)
  • • 对缺失列补 NULL
  • • 全列 cast string(先保证能导入,后续再逐步做类型治理)

3.6 TSV 生成(Driver 侧)

toLocalIterator() 把 DataFrame 写入 TSV 临时文件:

  • \t 分隔,\\N 表示 null
  • • 清洗 \t \n \r,避免行结构破坏
  • • 打印 rows/bytes,便于监控吞吐

3.7 Doris Stream Load 2PC:单文件原子

对每个文件生成一个唯一 label:

  • • 第一步:PUT _stream_load,Header two_phase_commit:true
  • • 第二步:PUT _stream_load_2pc commit
    • • 若失败:PUT _stream_load_2pc abort

3.8 文件归档

  • • 成功:$BASE_DIR/<file_type>/<yyyymmdd>/
  • • NO_DATA:同样归档到上述目录(保持 update 目录干净)
  • • 失败:$BASE_DIR/failed/

归档逻辑会自动 mkdir,并打印日志。


4. 关键实现细节:三个高频坑的正确姿势

4.1 Doris 的 307 重定向:不仅发生在 _stream_load,也发生在 _stream_load_2pc

Doris FE 会把请求重定向到 BE(coordinator),常见返回 307。
我们必须在 urllib2 场景下做到:

  • • 307/302/301/308 都跟随
  • • 跟随后仍保持 PUT 方法
  • • 不丢失 Authorization
  • • 对 _stream_load_2pc 的 commit/abort 同样处理 307

如果只处理 _stream_load,就会出现“load 成功但 commit 失败”的假失败现象:数据写进去了,任务却失败并把文件移到 failed。

4.2 Python2 的编码混拼:header 必须 bytes 化

Python2 默认 ASCII,urllib2/httplib 在拼 request 时若 headers 里有 unicode,body 是 bytes,会触发隐式 ascii decode 崩溃。
解决策略:

  • • 明确把 header key/value 转成 utf-8 bytes
  • • body 始终是 bytes
  • • 对 \r\n 做清洗,避免 header 注入

4.3 自增主键回显:不要依赖 GeneratedKeys / LAST_INSERT_ID

Doris 的 MySQL 协议在某些模型/版本下不会可靠返回自增 id:
所以批次表插入后的 batch_id 获取改为:

  • • insert
  • • 用 run_uuid + file_name + file_path 回查 ORDER BY id DESC LIMIT 1

这是最稳的“后置回查”策略。


5. 一个文件的完整状态机:从 RUNNING 到 SUCCESS/FAILED/NO_DATA

以单个文件为例:

  1. 1. insert wlm_batch (RUNNING) → 获取 batch_id
  2. 2. 读取 excel
    • • NO_DATA:更新 batch=NO_DATA,归档成功目录,结束
  3. 3. 归一化 + tsv
  4. 4. Stream Load 2PC
    • • load success → commit success → batch=SUCCESS → 归档成功目录
    • • load/commit 任一步失败 → abort → batch=FAILED → 移动 failed

所有步骤都写日志,并把关键指标(rows/bytes/load_ms)写回批次表 remark 字段。


6. 运行与运维:如何定位问题、如何复盘一次任务

你可以用三种方式复盘:

  • wlm_rpa_logs:按 run_uuid 看这次任务的完整输出
  • wlm_batch:按 batch_id 看文件级过程、失败原因、streamload 指标
  • wlm_order:按 run_uuid / batch_id 查本次写入的数据行(溯源字段全在)

这套结构最大价值就是:一旦线上出错,你不需要复现,只要查表就能定位。


QA

一、 数据库架构与主键自增问题

Question 1: 为什么在 Doris 2.1 中使用 INSERT 后无法获取自增 ID?
核心原因:Doris 的 UNIQUE KEY 模型与 AUTO_INCREMENT 在分布式环境下通过 JDBC 返回生成键(Generated Keys)存在兼容性限制。

Question 2: 为什么执行 SELECT LAST_INSERT_ID() 会报 No matching function 错误?
核心原因:Doris 不支持将其作为全局函数调用。它是一个 Session 变量,且在某些版本或非 DUPLICATE KEY 模型下不会被触发赋值。

Question 3: 如何在不支持自动回显 ID 的情况下获取插入记录的 batch_id?
解决方案:采用“后置回查”策略。插入数据后,利用全局唯一的 run_uuid 和 file_name 作为过滤条件重新查询数据库以获取生成的 ID。

二、 Python 2.7 编码与特殊字符冲突

Question 4: 为什么处理 Excel 时会报 UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3?
核心原因:Python 2.7 默认使用 ASCII 编码。当 urllib2 尝试拼接包含非 ASCII 字符(如西语重音符、中文)的字节流(str)与 Unicode 对象时,会触发隐式的 ASCII 解码导致崩溃。

Question 5: 如何在 Python 2.7 环境下彻底解决编码报错?
解决方案:在脚本头部执行 reload(sys) 并设置 sys.setdefaultencoding('utf-8'),同时在处理数据流时遵循“先解码为 Unicode,再统一编码为 UTF-8 字节”的原则。

三、 网络协议与 HTTP 重定向陷阱

Question 6: 为什么 Stream Load 会报 HTTP Error 307: Temporary Redirect?
核心原因:Doris FE(前端)会将写入请求重定向到具体的 BE(后端)。Python 的 urllib2 默认无法自动处理带有 PUT 方法和数据体的 307 重定向。

Question 7: 如何让 urllib2 支持 Doris 的 307 重定向并保留认证信息?
解决方案:必须自定义 HTTPRedirectHandler(如例程中的 PreserveAuthRedirectHandler),显式重写 redirect_request 方法以确保跳转后依然使用 PUT 且不丢失数据流。

四、 数据溯源与字段逻辑

Question 8: 订单表中的 version 和 batch_id 字段是如何关联的?
逻辑说明:通常将 wlm_batch 表生成的 ID 作为“版本标识”注入到订单数据中,实现数据行与处理批次的一对多关联。

Question 9: remark 字段中复杂的字符串(如 mapping=...;sheet=...)有什么作用?
逻辑说明:这是 ETL 溯源元数据。它记录了数据产生的原始上下文(哪个 Sheet、哪套映射规则、哪个批次),是后期排查数据质量问题的唯一线索。

Question 10: 为什么 run_uuid 在整个日志中保持一致?
逻辑说明:run_uuid 代表一次主程序运行实例。通过它可以在海量订单中一次性提取出某次自动化任务处理的所有 16 个文件的所有记录。

.preview-wrapper pre::before { position: absolute; top: 0; right: 0; color: #ccc; text-align: center; font-size: 0.8em; padding: 5px 10px 0; line-height: 15px; height: 15px; font-weight: 600; } .hljs.code__pre > .mac-sign { display: flex; } .code__pre { padding: 0 !important; } .hljs.code__pre code { display: -webkit-box; padding: 0.5em 1em 1em; overflow-x: auto; text-indent: 0; } h2 strong { color: inherit !important; }

本文使用 文章同步助手 同步