半路手记 · 踩坑实录
一份给人看,一份给机器读
我这边跑着一批定时任务,几十个,各自在不同的钟点上醒来干活:抓取、同步、备份、巡检。它们出错的方式很安静——不是崩,是不来了。所以有一份“健康面板”,十行表格,标着每个任务几点跑、上次什么时候成功、现在什么状态。我隔几天扫一眼,绿的就放心。
面板旁边还有一份台账,JSON,每个任务一条,带 id、带调度表达式、带验证命令。这份是给脚本读的,人不太看。
两边本该是一回事。可那天我并排放着看,数字对不上:面板十行,台账四十四条。
第一反应很自然:四十四减十,面板漏了三十四条。
这个反应是错的。而且错得很有代表性——它把一个“我不知道”直接翻译成了一个“缺了”。
第一层:按名字对账,四条“缺件”
面板上没有 id 那一列。台账有 id,面板没有。所以两份东西之间唯一能拿来对的东西,只剩名字。
我写了个只读脚本,把两边按名字归一后取交集:
pidx = {norm(r["name"]): r for r in panel_rows} # 面板十行
lidx = {norm(t["name"]): t for t in ledger["tasks"]} # 台账四十四条
both = set(pidx) & set(lidx)
跑出来:
| 档 | 条数 |
|---|---|
| 面板行确定对上 | 5 |
| 疑似同一(名字互相包含) | 1 |
| 面板有、台账无 | 4 |
| 台账有、面板无 | 38 |
| 可见率 | 11%(5/44) |
先看那个三十八。它比心算的三十四多了四条——多出来的正是那四条没对上的面板行:同一条任务,在天平两头各落了一次账,面板那头记“台账里没有”,台账那头记“面板里没有”。一条对不上,会变成两条误报。
再看那四条。它们一列出来,我第一眼就觉得不对——名字和台账里某条长得太像了,像到不像巧合:
- 一条叫“周报”,台账里那条叫“周报(两个地区)”;
- 一条叫“邮箱清理”,台账里是“邮箱定期清理”;
- 一条叫“月报”,台账里那条后面跟了一长串括号,写着它具体扫哪些仓库。
还有一条更冤:两边名字肉眼看着一模一样,但中间那个 emoji 里,一个藏着不可见的连接符、一个没有,字符数都对不上。
第二层:那不是缺件,是改名
我补了一张十行的对照表,把面板那一行指到台账的 id 上,重跑:
| 档 | 按名字 | 补别名表后 |
|---|---|---|
| 确定对上 | 5 | 10 |
| 疑似同一 | 1 | 0 |
| 面板有、台账无 | 4 | 0 |
| 台账有、面板无 | 38 | 34 |
| 可见率 | 11% | 23% |
那四条全部落地,一条都不是缺件,全是改名。
这里有个不对称,值得单独记一笔:
- 漏掉的三十四条,我扫一眼名字,大概能感觉出哪些是自己认识的、哪些是陌生系统;
- 误判的四条,每一行都长得像真的缺件,得逐条去核。核完发现没事,等于白干,但这四条的代价不是时间,是信任——如果我真的信了那三十八条,接下来做的事里就有四件是在修不存在的洞。
误报比漏报更耗人。 漏报让你少看见东西,误报让你去做错事。
名字不是键
问题不在那张对照表补得晚,在于一开始就不该拿名字当键。
名字是给人看的标签。给人看的东西会被改得更好看:加 emoji、加括号说明范围、把长名字缩短、改标点。每一次改动,对这个用途都是改进。
但名字一改,两条账之间的连接就断了——而且断得毫无声响。没有报错,没有异常,脚本照常跑完,只是从“对上”变成“没对上”。台账那边根本不知道有人在改面板上的字。
键不一样。键的唯一用途就是连接,所以键不许改。要改名就改名字,键留着。
这两套账的问题很朴素:它们没有共同的键。 台账有 id,面板没有 id 列。所有 join 都是在猜,猜对了看不出侥幸,猜错了也不告警。
补别名表这件事,本质上不是“维护一张表”,是给其中一边补上键——把面板那一行绑到台账的 id 上,从此名字随便改。
别名表不是一张两列的表
真要落地,这张表得能说三种话,不能只有“同”和“不同”。
强判据:有 id。 面板行指向台账的 id,id 在台账里存在就算同一个,名字只作展示。这条不看名字,所以名字改了也不会失效。
弱判据:只有名字。 名字归一后能对上,也能判同,但这个机制本身不可复跑——下次谁改了名字,它就不灵了。所以这种命中要单独计数,不能和有 id 的混在一起报。
待判:对不上、也没 id。 这一档最关键:不许算成“有”,也不许算成“没有”。要人去对一眼,确认了再写进表里。一旦把这一档悄悄归到任何一边,报告立刻变成瞎猜——而且猜得很自信。
这三档分开之后,“别名表命中”就不是一个数字了,是三个数字:带 id 的多少、只靠名字的多少、卡在待判的多少。能把“不知道”单独立一栏的表,才配叫对账表。
负控响了,结论没变
有强判据就得验强判据。我做了个负控:把别名表里某一条的 id 末位从 1 改成 0,只改一位。
结果脚本报 id失效 1。看着挺对。
但我顺手看了一眼“确定对上”那栏——还是 10。
原因是代码里这一句:
if lid not in byid:
alias_bad.append(e)
continue # 不重映射,面板行保持原名
id 失效了,这条映射就跳过,面板行仍然用原名去 join。而这一条的名字恰好和台账里一字不差,于是回退到弱判据,结果一样。
也就是说:这个负控确实响了,但它的响动只存在于一个专门的计数格里。如果我只盯“对上几条”这个总数——那正是我平时唯一会看的数——我完全看不出它已经响了。
一个负控,如果它失败时只会回退到一条同样能给出“正确”答案的路径上,那它等于没在做检验。 负控必须有自己独立的输出位,而且那个位得有人真的去看。
对上了,才看得见字段的错
补完别名表,能比对的行从 5 条涨到 10 条。我原以为这一步只是把数字修好看了,结果它翻出了另一层东西:
| 指标 | 按名字 | 补别名表后 |
|---|---|---|
| 能比对的行 | 5 | 10 |
| 时刻/频率漂移 | 5 | 9 |
十行里九行对不上。具体是:
- 六行的时刻对不上,差得最少十分钟,最多一小时四十分钟;
- 两行连频率种类都不一样——一行面板写“每日”,台账是“每周”,这行的时刻也跟着错;另一行面板写“每日”,台账是“每隔几天”;
- 一行面板压根没写时刻。
这些错一直在那儿。不是补了别名表才出现的,是补了别名表才看得见的。
对不上的时候,任何字段级的比对都无从谈起——你连“这一行和那一行是不是同一个任务”都不确定,比时刻就是比两个不相干的钟。所以“对上几条”这个数不是终点,它是分母。
而且方向要说清楚:面板那一格是错的。 台账里存的是调度表达式的原文,机器怎么读就怎么跑;面板那一格是人手敲的,敲的时候大概是记错了。如果哪天我想省事,从面板反推一份新的台账,那等于把错的抄进新账——错话一旦有了第二个副本,就再也没法靠“两边一致”来发现它了。
还有一档:不可判,不是缺件
同一份面板文件里还有第二张表,九行。那批任务的注册表不在我这边,脚本读不到。
我第一次跑完,差点把它们写成“另外九条也对不上”。这跟开头那个错误是同一个错误:读不到,被写成了没有。
缺件和缺键必须分开写:
- 缺件:账上有、面板上没有。这是事实,可以去补。
- 缺键:两边都有,但没有能对上的依据。这是“不可判”,只能挂起来等人。
- 缺源:连一边的账都没有。这连“判”都谈不上。
三种混在一个数字里,那个数字就废了。所以报告里这一栏我单独占一行,写着九行,不可判,不加进任何分子分母。
两周过去,缺口只多不少
这份报告上一次跑是两周前,台账四十条,缺口三十,可见率 25%。
今天是四十四条,缺口三十四,可见率 23%。
台账涨了四条,面板一行没动。
这不是谁的疏忽。人手工维护的表,天然跟不上自动增长的账——两边是两套时钟,没有人在中间做同步,差距只会单调变坏。所以“面板准不准”这个问题,不该靠定期觉悟来回答,得靠一条随时能重跑、只读、不发脾气的命令来回答。
为什么这个脚本一行都不写面板
从头到尾,脚本只读两个文本:面板的表格,和台账的 JSON。它输出一份差异报告,从不写回面板。
不是没想过让它顺手把面板刷新一下。但那会让两边互相污染:一旦脚本既读又写,“真值”和“现状”就变成一个东西,下次再比就是自己跟自己比。一个永远给自己打满分的校验,比没有校验更危险。
只读还有一个好处:它可以随时跑。改动前后跑,凌晨三点跑,被人质疑的时候当场跑。报告里每一格都是现算的,没有一个数字是上一次的残留。
如果你也要把两套账对起来
- 两边有没有共同的键?如果唯一能拿来对的是名字,那所有结论都只是“看起来”。
- “对不上”的条目,逐条核过是缺件还是改名?前者要补,后者只需补一张映射。核之前别写进结论。
- 名字被改过几次?每改一次,靠名字做的关联就断一次,而且不报错。
- 判不了的条目,有没有单独立一栏?混进任何一边,报告就开始骗人。
- 有没有跑过负控——故意把一条映射改错,看它报不报?报了之后,那个数变了没有?
- 负控的响动在哪个格子里?如果它和一个“正确”的回退路径结果相同,你的总数看不出它响过。
- 对上之后,字段级比过吗?“几条能对上”是分母,不是成绩。
- 现在这个数,两周后会更好还是更坏?如果是更坏,靠什么机制让它变好?
- 校验脚本写不写被校验的东西?写的话,它迟早会证明自己永远正确。
一句话
“面板漏了三十八条”和“面板和台账没有共同的键”,是同一堆数据给出的两个结论;只有后一个能修。前者让你去补一个洞,后者告诉你洞在哪——而且补法完全不同。