门禁最危险的状态不是 BLOCK,是一条规则在返回 PASS,但它什么都没比较。复核自己的规则集之后我找到四类这种情况,而且每一类的成因都不一样。

01规则集是两套引擎拼起来的

单表列级约束交给 Deequ——完整性、唯一性、非负、枚举合法、评分区间,这些是它的 Check / Constraint 能表达的语义:

scala
new Check(CheckLevel.Error, "U1")
  .hasUniqueness(Seq("order_id", "order_item_id"), _ == 1.0)

跨表的就表达不了了。我要判的是“事实表 GMV 是否等于三张汇总表各自的 GMV”“修正后的粒度是否发生膨胀”“本批指标相对基线是否漂移”——这些都需要两个 DataFrame 加一个外部基线,算完是个标量比较,而 Deequ 的 Check 是单表语义,套不进去。所以剩下几条用手写 DataFrame 规则,结果里用一个 source 字段标 "deequ" 还是 "dataframe",两条轨各自产状态、门禁只做聚合。

这个分工本身没问题。问题出在我给手写规则留的“便利分支”。

02第一类:没有基线就恒过的漂移规则

漂移检测要拿本批指标和基线比,基线是另一次跑批的产物。逻辑写成:

scala
case None =>
  RuleCheckResult(passed = true, status = "PASS", ..., message = "no baseline, skip")

当时觉得理所当然:首轮没有基线,总不能让门禁 BLOCK 掉整条链路。但它现在的形态是永远返回 PASS——只要谁忘了准备基线目录,这两条规则就静默变成空规则,而报表上它们仍然记作“通过”。

诚实的写法是让它返回一个不计入分母的第三态,或者干脆把规则从这次运行里摘掉、并在结果里写明摘了几条。我没改成失败,那会让首轮必红;但“skip 计入 PASS”是我留下的一个读数陷阱。

03第二类:被喂了常量的膨胀检查

膨胀率这个指标本身是必要的。旧方案把支付、评论两张表 left join 进 item 事实,一个订单有多笔支付和多条评论时会产生 N×M×K 行,商品 GMV 被重复计。量化器我写在 DWD 校验作业里:

scala
// 天真三表 left join 的行数 ÷ 修正后 fact_order_item 的行数
val joinInflationRate = legacyLeftJoinCount.toDouble / factItemCount

但门禁里那条 BLOCK 级的膨胀规则,我传进去的是常量:

scala
val diff = math.abs(fixedRate - 1.0)   // 调用处:fixedRateCheck(1.0)

diff 恒为 0,规则恒过。真实膨胀率只在 DWD 校验作业里算,从没喂给门禁。也就是说“膨胀检测已进门禁”这句话在一段时间里是空的——报表上有这一行,判据是假的。

04第三类:按定义每次都要报警的规则

另一条遗留膨胀诊断规则的阈值是 inflationRate <= 1.02,违规只给 WARN。问题是它测的是旧口径,而旧口径天然超阈值——它会在每一个正常批次上报警。

我的处置是把它移出默认管道(代码保留,文档写明 "available but not in the default pipeline“)。这条恰好是三类里唯一”我当时就知道它有问题"的,而它的价值是反证:一条按定义必报警的规则不是规则,是噪声源,要么改阈值要么改判据,不能留着让告警变成习惯。

同类还有一条一致性规则被直接删掉,原因是它需要的原始行数只有另一个输入才提供,而默认管道里没有那个输入。删掉比留一条永远取不到数的规则诚实。

05状态模型里没有 FAIL,于是两个轴容易混

我刻意只留 PASS / WARN / BLOCK,把 FAIL 统一映射成 BLOCK——“失败”和“阻断”在我这里不该是两个概念。

但得分和结论用的是两个互不相干的轴,读报表时极易误判:

scala
val score = if (total > 0) passed.toDouble / total else 1.0   // 只数 PASS

分数按“是否 PASS”算,所以 WARN 一样扣分;门禁结论按“是否存在 BLOCK”算,所以 WARN 不阻断。一条规则报警,你会看到分数掉了、批次仍然通过。我既没有在字段上把这两个轴分开标注,也没在文档里强调,这是读者要自己记住的坑。

06BLOCK 默认也不会让作业失败

门禁作业是无条件先写完全部结果,再决定退出码:

scala
val failOnBlock = cliArgs.getOrElse("fail-on-block", "false").toBoolean
if (QualityGate.shouldFail(batchResult.status, failOnBlock)) sys.exit(1)

默认 false——即使 BLOCK,作业也以 0 退出。这不是疏忽,我要的是“先拿到完整规则明细,再由编排决定断不断”。但它意味着**“门禁”的真身不在作业里,在编排脚本有没有传这个开关、有没有看退出码**。这条我后来写进交付规范:任何串这条链路的脚本必须显式传 --fail-on-block true,否则门禁只是个记账员。

07检测能力怎么量化

异常注入实验的判据是 rule_id 集合匹配,不是行级匹配:注入作业落一份期望清单(该命中哪几条规则),门禁跑完后取状态为 BLOCK / WARN 的 rule_id 集合,两边求交并算 Precision / Recall / F1。

这里有个读数陷阱,正好和第一类规则对称:基线批次不喂期望清单,于是 P/R/F1 记 0。这个 0 不是“检不出来”,是“没有 ground truth 可比”。我第一次在自己报表上看到 0 时以为注入实验失败了,其实那次跑的是干净基线。

08把事故固化成回归测试

第二类的膨胀事故,最后落成一个用真实数据跑的测试:它显式构造旧的天真 left join,断言 inflationRate > 1.0,再断言修正后的 fact_order_item 行数等于原始明细行数。

scala
assert(inflationRate > 1.0, "legacy approach inflates rows")

断言方向值得说清:它要求旧方案是错的。测试钉住的不是“我现在对了”,而是“这条退化路径可复现,且新链路不再走它”。批流侧同理,有一条测试专门保证“只回放部分 item 时,同一订单的兄弟 item 不会被计入”——那是另一种隐性膨胀。

09两处没收口

  • 容差有两套。 Scala 侧跨表对账用绝对差 < 0.01,Doris 冒烟脚本里写的是浮点精度 < 1.0。同一件事在两层用两个判据,我暂时没统一——两边求和的执行引擎与舍入路径本来就不同;但没统一就得并排列出来,不能只在一处写。
  • 门禁开销从没测过。 计划里给质量环节定了占 ETL 总耗时的上限,实际只记录了质量作业自身的耗时,从没和链路总耗时对齐。这条按未完成处理。

10相关文章