这篇不讲某个 bug,讲一件我认为比 bug 更值钱的习惯:每一次实验结论都要能回答“证据在哪一次运行里”。

01唯一一次真跑通的批流对账

流式那条链路我做过一次完整验证:历史订单转成 JSON 事件按固定速率回放进 Kafka,Structured Streaming 消费并按分钟窗口聚合,最后拿批侧算出的同一指标和流侧比。结果是两边指标和相等、差异率为 0,判据是"差异率不超过阈值 0.005 就算一致"。

那次里最难的不是代码,是首轮失败值:两边和差得很远。根因是粒度——批侧比的是 item 粒度的单价,流侧比的是分钟窗口的聚合值。修法是两侧各用自己那列(--batch-metric price --streaming-metric gmv),并把比较范围限死。

这是这个项目里流式方向唯一一次拿到正向结果的运行。后面写的所有“批流一致”,都必须回到这一次。

02后面两轮没跑,但服务库里仍然摆着结果

再往后的两轮集群复现,环境一个是内存不够、一个根本起了 Kafka 之外的组件(有一轮集群里没有 YARN、没有 Hive、也没有 Kafka)。流式回放没重跑,一致性作业也没跑。

问题是:Doris 里那张批流对账表是有数据的,冒烟查询也能查出“一致 = true”。因为它读的是上一次成功运行输出的历史文件,被当成本轮结果同步进去了。而本轮的 ADS 层对应的视图里,这张表是空的。

同一件事在两个库里一个“有结果”、一个“没结果”,我在复现文档里当时的原话是:批流一致性已通过 Doris 中的历史数据验证,但没有本次样本的新数据。

这句话后来成了我写这类文档的模板,因为它把三件事一次说清:结论成立(历史那次)、结论本次未复现、以及为什么你屏幕上看到的东西会让你误以为本次也成立。

03卡住不等于失败,也不等于还在跑

有一轮回放是在最后一个 action 上停住的——所有上游阶段都完成了,count() 那一步不动。当时我的记录方式是“执行时间很长,看起来卡住”,而问题汇总里同类描述出现在三个不同条目上,其中两条最后是“其实已经跑完了”。

“慢”和“卡死”和“已完成但输出没落地”这三种状态,在人的观察里长得一模一样。 区分它们只能靠外部证据:应用的写出目录有没有产物、Spark 的 stage 进度是否还在推进、这台机器的可用内存还剩多少。那次是真卡住——同机还有 NameNode、Metastore、Kafka、FE 和 BE,内存已经被挤干净。

诚实的记法是“本次未跑完,卡在最后一步聚合,原因是同机内存不足”,而不是“执行较慢”。前者能被下一轮验证或推翻,后者不能。

04粒度不同的两侧,怎么算公平比较

这条链路上我踩过最隐蔽的口径问题:不能拿批侧全量去比流侧部分重放。所以一致性检查的公平性靠一份重放清单:回放作业把"实际发出去的那些 (order_id, order_item_id)"落成 replay_manifest,批侧读入后与清单做 inner join,只保留被真正回放过的 item。

两个容易漏的细节:

scala
// 清单只覆盖部分 item 时,同一订单的兄弟 item 不能被计入
batchRaw.join(manifestItems, Seq("order_id", "order_item_id"), "inner")

一是兄弟 item 污染:一个订单有五个 item,只回放了其中两个,批侧若按订单粒度 join 就会把五个都算进去。我为这条写了回归测试,专门断言“只回放部分 item 时兄弟 item 不进入比较范围”。

二是行数差不作为判据。两侧粒度不同,行数一定不相等,拿它当失败信号是误读;所以结论里明确写“行数差不是主要判据”,只比指标和与差异率。还有一条更朴素的坑:--max-events 是按事件类型生效的,不是全局上限,我一开始按全局理解过,于是重放量比预期大了几倍。

05我给“没做”立的四种写法

把上面几段的经验抽象出来,现在任何实验性结论我都归进四类之一,写文档时不许含糊:

text
1. 设计完成、执行过、结果可引用          → 写成结论,附输入输出路径与参数
2. 设计完成、本次没跑(结果来自历史一次)  → 写成"历史结果",并写明本轮为何未复现
3. 设计完成、从未实现                    → 写成"未实现",不给结论
4. 环境不支持、无法验证                  → 写成"未验证",保留可复现命令与产物

按这个分类,我项目里的真实分布是这样的:MLlib 那组实验属于从未实现(仓库里连提交入口都没有,实验结果目录是空的);Doris 的查询性能对比(响应时间分布、优化前后差值)属于没有结果文件,所以只有设计意图;一批扩展 ADS 实验表没建;三节点 Kafka 是配置互相冲突所以退回单节点,不是“做了但没验证”;前端大屏那一层,两份文档一个写“已上线”一个写“未部署”,这种状态冲突必须靠重跑或重新核对来消解,不能靠挑一份信。

正式复跑还有一条:主链路那次交付用的是本地多线程模式而不是 YARN,这属于有意的取舍(集群资源太小、单机 driver 更稳),但取舍必须写明原因,并且保留 YARN 模式的重跑入口,否则读的人无法判断“这是没做还是做不了”。

06数字基准自己会过期

这一条是我最近才真正当回事的。

交付物用哈希判等价,但本地只要重编一次,文档里钉的旧哈希就作废了。10 月那次复现我撞到的情况是:新构建产物的哈希和上一轮记录不同,而文档定稿之后 JAR 又重建过。处置方式是写下“以本次为准”,同时保留旧哈希和差异原因。这比悄悄改掉旧值诚实得多——悄悄改掉之后,没人能判断上一轮结论到底是不是同一份代码跑出来的。

同一指标在不同文档里数值不一致的情况我也撞了几处:评审用的事实表行数、清洗前后的评价行数、图算法的迭代次数。有一处更难看:表结构说明里被批量重命名污染出一句读不通的残留文本,而设计文档整片描述的还是早已被替换掉的旧链路,没有标注作废。

结论很朴素:报告不会自动更新,过期是默认状态。凡是“结果类”文档,要么绑定一次运行(输入路径、参数、产物哈希),要么在文件头写明它记录的是哪一天的哪一次运行。

07为什么值得这么麻烦

这是论文项目,评审关心的不是链路多复杂,而是你报的数字能不能被别人复现。一个把“未验证”写成“已验证”的段落,会连带让所有其它段落都可疑。所以我把这条写进项目规范:无法集群实跑必须写明原因,并保留提交命令和构建产物——保留“能跑”的证据,而不是只留下“跑过”的说法。

08还没做到的

  • 没有一份统一的“实验状态表”(哪个实验属于四类中的哪一类、产物在哪),现状是每个报告自己写,读者要跨文件拼。
  • 历史结果搬运进服务库时没有加“来源批次”字段,所以 Doris 里那些行现在无法自证是哪次跑的。这条应该改:任何同步进服务层的结果都必须带上产生它的批次标识。
  • 参数没有版本化。迭代次数、阈值、分区数散落在文档和脚本默认值里,不一致只能靠人比对。

09相关文章