20260429

2 阅读13分钟

20260429 一次看似简单、实则绕了很多弯的 Multiorder 清洗排障记录

最近在处理 Walmart Multiorder 清洗链路时,踩了一个很典型、也很容易被忽略的坑。

表面上看,只是两条订单数据没有落到 cleaned 表里。
但越查越发现,这个问题根本不是“少了两条数据”这么简单,而是一个涉及 快照同步逻辑、Doris Unique Key 语义、sequence/version 控制、partial update 回写边界、以及关系清洗补丁 job 写入策略 的组合问题。

这篇文章就按排障过程原样记录一下。


一、问题开始:明明 raw 里有,cleaned 里却没了

最开始注意到的是两条数据没有保留下来:

  • 脱敏sku1
  • 脱敏sku2

它们属于同一个业务订单:

  • customer_order_number = InteractiveVideo-20260123-NO1
  • multichannel_order_id = 脱敏订单号1
  • shop_name = 脱敏店铺
  • order_date = Jan 23 2026

而我当时的同步逻辑很明确:

  1. 1. 按 shop_name + source_file 一份文件一份文件处理
  2. 2. 每个文件先算出自己涉及到的 order_days
  3. 3. 再去 cleaned 表里捞出“同店铺 + 这些 order_days”的旧数据
  4. 4. 用
    key = sha256 + seq + order_date
    做差集:
    • raw - cleaned 插入
    • cleaned - raw 删除

从设计上看,我想要的效果也很直接:

对于某个业务日期的数据,以最新文件为准;如果后续文件里某条订单行消失了,那 cleaned 里也应该删掉。

所以一开始,我是带着一种非常强的直觉去看的:

是不是某个后续文件里数据变少了,所以把前面那两条删掉了?


二、第一反应:怀疑“同店铺 + 同业务日”删除范围过大

我先把怀疑点放在了这段逻辑上:

  • • 当前文件只按 source_file 遍历
  • • 但比较对象却不是“这个文件之前落过的记录”
  • • 而是“同店铺 + 同 order_day 的整天数据”

所以第一感觉是:

会不会某个后来的文件不完整,只带了 3 条,于是把另外 2 条删掉了?

这个怀疑非常顺手,也很符合很多增量快照场景里常见的问题:
处理粒度是按文件,但删除粒度却扩大到了“日期窗口”。

如果真实数据真是那样,那逻辑就很清楚了:

  • • 前一份文件有 5 条
  • • 后一份文件只有 3 条
  • • 代码按当天快照重算
  • • 那 2 条自然就会被删掉

听起来很合理。

但问题在于,数据不支持这个结论


三、翻 raw 全量数据后,发现这个猜想站不住

后面我把 Jan 23 2026脱敏店铺 这一天所有 raw 数据全部拉出来看了,结果发现:

20260129011016.xlsx
20260131010953.xlsx
20260202101433.xlsx
20260203011215.xlsx
再到 20260428121025.xlsx

这个订单下面的 5 条数据其实一直都在:

  • 脱敏sku5
  • 脱敏sku4
  • 脱敏sku3
  • 脱敏sku2
  • 脱敏sku1

也就是说,并不存在什么“后面来了一个只剩 3 条的文件”。

这一步非常关键。

因为它把我一开始最顺手、最符合经验的那个解释直接推翻了。

到这里,问题就变成了:

raw 里同一个订单、同一个业务日、跨多个 source_file 一直都是 5 条,为什么 cleaned 最后只剩 3 条?

这时候我才意识到,真正的问题可能不在“文件差集删除范围”,而在 cleaned 这张表的写入和可见版本控制机制


四、继续往下看,发现 cleaned 里的 3 条本身都不对劲

接着我去看 cleaned 里现有的 3 条记录。
本来以为只是“少了两条”,结果越看越不对。

因为这 3 条里,有一些字段值,并不像原始 raw -> cleaned 透传逻辑能生成出来的结果

举个最明显的现象:

某些 raw 行里:

  • po_number = null
  • delivered_by = null

按我那段 PySpark 清洗代码,如果原始值是空,就应该继续落空。
但在 cleaned 里,对应行却出现了:

  • po_number 被填成了别的值
  • delivered_by 被补成了别的日期

更关键的是,这些值并不是凭空冒出来的,它们看起来像是 同一个订单下其他 sibling 行的字段值被串过来了

这时候问题已经不是“为什么少两条”了,而是:

cleaned 这张表里,某些行上的基础字段已经发生了串值。

一旦出现这种现象,就说明至少有一条事实成立:

  • • 要么线上实际跑的不是我以为的那份同步代码
  • • 要么 cleaned 后面还有别的 job 在改数据
  • • 要么 patch/update 的粒度和主键粒度不一致,导致了同单内多行互相污染

后来回头一想,确实如此。

因为 cleaned 表后面还有一个 关系字段清洗 job


五、补充背景:cleaned 后面还有一个关系补丁 job

这个 job 的目的,原本是为了做关系补全:

  • • 补 erp_sku
  • • 匹配 WFS 发货单 / 销售单
  • • 匹配 Seller 销售单
  • • 回填各种 match 状态
  • • 回填 ship / sales relation 字段

从业务目标上看,这个 job 没问题。

而且它的实现方式也挺常见:

  • INSERT INTO ...
  • • 开启 enable_unique_key_partial_update = true
  • • 走 Doris Unique Key 表的 partial update 能力
  • • 顺便带上 version

看到这里的时候,我一下子就意识到,前面 cleaned 里那些“不像 raw 同步生成出来的值”,大概率就不是 raw job 造成的,而是这个 patch job 后面补进去的。

问题于是开始收敛成两个方向:

方向一:version 是不是冲突了?

因为 cleaned 表本身配置了:

  • UNIQUE KEY(sha256, seq, order_date)
  • function_column.sequence_col = version

而 raw 清洗同步 job 里,version 是固定写死成 1 的。

与此同时,关系 patch job 又在不断递增 version

这就意味着:

  • • raw job 写入一条行,version = 1
  • • patch job 补一次关系字段,version 变大
  • • 再后面 raw job 即便想重新写回同一 key 的数据,如果还是 version = 1
  • • 那么在 Doris 看来,它并不一定比现有版本“更新”

这就很危险了。

方向二:patch job 的更新边界是不是越界了?

因为这个 job 本来应该补的是“关系字段”,但我看到它连下面这些字段也在更新:

  • po_number
  • delivered_by

这两个字段严格说并不是“关系结果字段”,而是更接近 源数据字段 / 原始业务字段

如果关系 patch job 连这些字段都参与写回,那它的职责边界就模糊了。

这时候一旦 patch merge 的粒度不够细,就非常容易出现:

同一个订单下,不同行之间的值被错误合并、覆盖、串写。


六、这时候再回头看 raw 同步 job,发现 version = 1 真的是个坑

raw 同步 job 里有一段很显眼:

    
    
    
  .withColumn("version", F.lit(1).cast("bigint"))

刚开始写的时候,这么写看上去很自然,因为我当时只是想表达:

新清洗写入,初始 version 就从 1 开始。

但问题在 Doris Unique Key + sequence 列语义下,version 已经不是一个普通字段了。
它其实承担的是:

同一个 key 下,不同版本记录谁更“新”的裁决权。

而我的系统里偏偏又有两个 job 在写同一张表:

raw job 的 version 语义

想表达的是:

这是业务快照同步结果

patch job 的 version 语义

想表达的是:

这是关系清洗补丁更新结果

如果两个 job 都去写同一个 sequence 列,但又没有共享统一的版本策略,那就一定会乱。

最直接的后果就是:

  • • raw job 后跑,但 version 小
  • • patch job 先跑或后跑,但 version 大
  • • 最终 Doris 可见版本不再按“业务快照时间”决定
  • • 而是按“谁写的 version 大”决定

这就会让“快照同步”和“补丁更新”两个完全不同职责的 job,混到同一个版本竞争体系里去。

从结果上看,就是:

  • • 某些行该回来没回来
  • • 某些字段该保持原始值却被补丁写坏了
  • • 某些记录明明 raw 里有,但 cleaned 可见结果不稳定

七、再往业务代码里看,开始怀疑 patch merge 粒度过粗

后面继续看关系 job 的 Java 代码,最让我警觉的不是匹配算法本身,而是这一段:

  • • 构造 MultiorderPatchDto
  • • 放入 multiPatchMap
  • • 然后执行 mergeMultiPatch

问题就来了:

这个 merge 的 key 到底是什么?

如果它严格按 cleaned 表主键来合并,即:

  • sha256
  • seq
  • order_date

那没问题,它只会处理同一行。

但如果它不是按这个粒度,而是按更粗的粒度,例如:

  • customer_order_number
  • multichannel_order_id
  • tracking_number
  • • 或某个订单级 key

那同一个订单下的多条 SKU 行,就很可能在 patch merge 阶段互相覆盖。

这正好又和前面 observed 到的现象对上了:

  • • 行数未必立刻减少
  • • 但基础字段可能串值
  • • 关系字段可能覆盖到不该覆盖的行
  • • 最后再叠加 Doris sequence/version 机制,表现就更诡异了

也就是说,这个问题到了后面,已经不是“某一行少了两条”这么简单,而是链路里至少存在以下几个结构性风险:


八、到这里,问题终于从“现象排查”变成“最小修复”

查到这一步之后,其实已经不用继续发散了。
因为要修的点已经非常清楚。

我最后把修复思路收敛成了几条“最小改动”方案。

1)raw 同步 job 不再写死 version = 1

应该改成 单调递增 的 version。

比如用当前批次时间戳,至少保证:

  • • 后跑的文件 version 更大
  • • raw 快照同步结果在 Doris 看来是“新的”

这样才能让“最新快照”真正具备覆盖旧结果的能力。


2)关系 patch job 不再主动推进 version

patch job 的职责,本来只是:

在已有 cleaned 行上补充关系信息

它不应该参与“谁更新谁更新”的竞争。
更不应该去和 raw job 争 sequence 控制权。

所以更合理的方式是:

  • • partial update 可以做
  • • 但不要自己传 version
  • • 让 Doris 沿用已有行的 sequence 语义

这样 patch job 就只是“补字段”,而不是“抢版本”。


3)patch job 暂时不要再更新 po_numberdelivered_by

这是一个特别典型的越权更新。

关系 patch job 应该关注的是:

  • • 匹配状态
  • • 关联单号
  • • 关系结果字段
  • • erp 映射字段

而不是去碰基础源字段。

如果基础字段需要补齐,那也应该是另一个独立 backfill 逻辑,并且要满足非常严格的条件:

  • • 只能在当前字段为空时补
  • • 且补值来源必须是同一业务行可证明正确的数据
  • • 绝不能靠同订单其他 sibling 行“顺手补”

否则,串值是迟早的事。


4)patch merge 的 key 必须和 cleaned 主键完全一致

也就是:

  • sha256 + seq + order_date

这点特别关键。

因为 cleaned 本质上是一张 订单行表,不是订单头表。
同一个 customer_order_number 下面可能有多条 SKU 行,它们在业务上相关,但在存储上绝不是同一条数据。

如果 patch merge 用的是订单级 key,而不是行级主键,那么后续所有 partial update 都可能在同单多行间产生污染。


九、最后回头看,这次排障真正让我难受的,不是 bug 本身

说实话,这次最麻烦的地方,并不是 Doris 难用,也不是 SQL 难写,更不是 PySpark 差集难理解。

真正麻烦的是:

系统里每一个局部逻辑单看都像是合理的,但一旦组合起来,含义就变了。

比如:

  • • 按业务日做快照删除,单看合理
  • • Unique Key + sequence_col,单看合理
  • • raw job 初始化 version,单看合理
  • • patch job partial update 回写,单看合理
  • • 关系补齐时顺便补源字段,写的时候也很顺手
  • • merge patch 做聚合,看起来也是为了性能和去重

但这些“局部合理”的决策,一旦放到同一条数据链路里,它们就开始互相干扰:

  • • 谁是快照真相?
  • • 谁有资格推进版本?
  • • 哪些字段属于源数据,哪些字段属于补丁结果?
  • • 哪个 job 负责覆盖,哪个 job 负责增补?
  • • 行级主键和业务单号,到底哪个才是 merge 粒度?

如果这些边界没有提前设计清楚,那问题就不会是“明显报错”,而是像这次这样:

  • • 少两条
  • • 多三条
  • • 字段串值
  • • 可见版本异常
  • • 重跑结果还不稳定

这种问题最折磨人,因为它不是“系统炸了”,而是“系统还能跑,但结果不可信”。


最后,一个比较深的反思

这次问题让我最深的感受是:

技术系统里最危险的,不是复杂,而是“语义混用”

version 就是最典型的例子。

一开始我只是把它当成一个普通字段,后来又把它当成更新版本号,再后来 Doris 又把它当成 sequence 列。
到最后,raw job、patch job、数据库引擎,三方都在用这个字段表达自己的意思。

问题就在这里:

同一个字段被多个层次赋予了不同语义,但系统并不会提醒你“你已经越界了”。

数据库不会提醒你:
“这个 version 其实已经不只是 version 了,它还是可见版本裁决器。”

代码不会提醒你:
“这个 patch job 虽然叫 patch,但它已经在修改原始业务字段了。”

业务逻辑也不会提醒你:
“你现在是在处理订单行,但你 merge 的粒度已经退化成订单头了。”

所以很多时候,真正的 bug 不是某一行代码写错了,而是:

我们以为自己在维护一个逻辑,其实已经悄悄跨进了另一个逻辑的边界里。

回头看,这次问题并不是单纯的实现失误,而更像是一次提醒:

在数据链路里,最重要的不是“代码能跑”,而是先把语义边界划清楚。

比如:

  • • 哪个 job 决定源数据真相
  • • 哪个 job 只允许补充,不允许覆盖
  • • 哪些字段属于原始事实
  • • 哪些字段属于推导结果
  • • 哪个字段控制版本竞争
  • • 哪个字段只是普通业务属性

这些边界一旦清楚,很多问题其实在设计阶段就能避免。

而如果边界不清楚,那么即便每段代码都写得不差,最后整个系统仍然可能在你最意想不到的地方,长出一堆看起来很玄学、其实完全符合内部混乱逻辑的 bug。