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. 遍历目录:扫描
$BASE_DIR/update下所有xlsx/xls/csv(本文先聚焦 xlsx/xls)。 - 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)
- • shop_prefix:
- 3. 批次表记录:每个文件处理前先插入
wlm_batch一条记录,拿到batch_id。 - 4. 表头映射配置化:只从
wlm_header_mapping读取source_header -> target_header以及target_table。- • 若某
file_type没配置映射:直接报错走失败流程(不兜底)。
- • 若某
- 5. 2PC Stream Load:单文件导入要么全部成功、要么失败回滚。
- 6. 归档:
- • 成功或 NO_DATA:移动到
$BASE_DIR/<file_type>/<yyyymmdd>/ - • 失败:移动到
$BASE_DIR/failed/
- • 成功或 NO_DATA:移动到
- 7. 运行日志:把一次 main.py 的输出全文落到
wlm_rpa_logs,并记录本次 run 的 batch_id 集合。
约束
- • Spark 2.4.0-cdh6.3.2,Python 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_header、target_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 Details→PO 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,Headertwo_phase_commit:true - • 第二步:PUT
_stream_load_2pccommit- • 若失败:PUT
_stream_load_2pcabort
- • 若失败:PUT
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. insert wlm_batch (RUNNING) → 获取 batch_id
- 2. 读取 excel
- • NO_DATA:更新 batch=NO_DATA,归档成功目录,结束
- 3. 归一化 + tsv
- 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; }
本文使用 文章同步助手 同步