exit 0 也会撒谎:一个"绿灯盲跑"了一个月的定时任务,尸检报告
我有一套每月自动跑的维护脚本。状态监控显示 ok,绿了一个多月。昨天盘点时对了下数字,发现它从上线第二个月起就什么都没干——扫到 0 条数据,照样 exit 0,照样报成功。这篇是完整尸检:它怎么死的、为什么没人发现、我给它加了哪三道门。
一、这个任务是干嘛的
我给自己一个个人项目写了 30 条规则文档(就是些 markdown,记录这个项目的纪律和约定)。规则会越积越多,得有淘汰机制,所以我写了个"规则蒸馏器",每月 1 号凌晨自动跑:
- 扫描规则文档,解析出所有规则
- 统计最近 30 天日志里每条规则被引用的频率
- 高频的标记保留,零引用的进淘汰候选
- 生成一份 JSON 报告,等我人工签字才真删
设计上是安全的:脚本只产出报告,不做删除。但我漏想了一层——报告如果是空的,它连"等我签字"这个环节都是空的。
二、它是怎么死的
8 月底我做了一次文档重构:30 条规则原来写在一个主文档里,每条是个 ### 第12条 命名规范 格式的标题;重构后拆成了"主文档只留索引表格 + 每条规则一个独立文件"。
蒸馏器的解析函数还留在旧世界:
# 旧正则:匹配 "### 第12条 标题" 这种 markdown 标题
pattern = re.compile(r'^#{1,4}\s*第?(\d+)[条\s—-]*([^\n]*)', re.MULTILINE)
重构后的主文档里,规则变成了表格行:
| 12 | 命名规范 | 变量统一小写下划线 |
正则匹配表格行?匹配 0 条。
接下来是连锁反应,每一环都"正常":
- parse_rules() 返回空列表 → 不报错,空列表是合法返回值
- 频率统计对空列表做循环 → 循环 0 次,不报错
- 保留/淘汰名单生成 → keep=[], drop=[],不报错
- 报告写盘 → 一个字段都"合法"的 JSON,不报错
- exit 0
9 月 1 号它准时跑了。产物长这样:
{"rule_total": 0, "keep": [], "drop": {}, "draft": null}
状态监控看到 exit 0,记一笔 ok。绿灯。
三、为什么一个月没人发现
复盘时我列了三条原因,每条都不好受:
1. cron 只看 exit code。 这是所有定时系统的通病:进程正常退出 = 任务成功。但"正常退出"和"干了活"是两回事。我的脚本没有任何一行代码问自己"我扫出来的数量合理吗"。
2. 报告没有自证字段。 JSON 里 rule_total: 0 其实已经把真相写在脸上了——但它混在十几个字段里,没人读。如果报告里有个字段叫 parse_sources: {"主文档": 0, "规则目录": 不存在},我第一眼就会炸。
3. 依赖的数据源也在悄悄死。 更难看的是,尸检时我还发现频率统计依赖的日志目录停在半个多月前就没人写了——就算正则没坏,频率也全是 0,淘汰名单照样是垃圾。两个死因叠在一起,互相打掩护。
一句话总结:这个脚本从头到尾没有一处会因为"没干活"而失败。它只会因为"干活时崩了"而失败。而没干活,恰好是它最需要的失败。
四、怎么发现的
不是报错发现的,是对数发现的。
昨天盘点这套系统,我习惯把每个环节的产物数字和现实核一遍:规则文档明明有 30 条,最新报告里 rule_total 是 0——对不上。按我给自己立的规矩,对不上的地方必须查到底,不许"大概是缓存吧"糊弄过去。一查就查到正则那一层。
这里有个反直觉的点:这个 bug 用任何常规手段都发现不了——单元测试不会测"文档结构变了",监控只看 exit code,报告没人读。唯一的发现路径,是拿产物数字和现实世界对账。 这个动作不自动化、不优雅,但它是最后一道防线。
五、修的三道门
给所有"批处理型"脚本的通用配方,我这次全加上了:
门 1:空结果 = 显性失败
rules = parse_rules()
if len(rules) == 0:
log("❌ 扫到 0 条规则 — 上游文档结构变了?拒绝盲跑")
sys.exit(2)
扫描类脚本的输出为空,几乎永远意味着上游变了或路径错了,而不是"世界真的是空的"。让 exit code 替你说话。
门 2:报告带自证字段
"parse_sources": {"规则目录": 28, "主文档表格": 30},
"freq_source_files": 0,
"freq_degraded": true
读报告的人(包括三个月后的你)第一眼就能看到数据从哪来、各来源扫到几条。哪个是 0,一眼炸出来。
门 3:降级必须声明,不许装
if not fresh_files:
log("⚠️ 频率源降级:日志 30 天无更新,本轮全保留、不淘汰")
report["freq_degraded"] = True
数据源死了就大声说死了,然后诚实降级(比如放弃淘汰、全部保留)。最坏的选择是假装数据源活着,拿着一堆 0 频率生成"看起来很科学"的淘汰名单。
六、更大的教训
这次事故让我把自己所有定时任务翻了一遍,发现"绿灯盲跑"的候选不止一个。共性都是:任务的产出是"报告/清单/统计"这类没人每天看的东西,而失败模式是"输入为空/过期"这类不会抛异常的东西。
三条通用检查,建议你对自己的 cron / CI 也过一遍:
- 你的脚本在"输入为空"时会 exit 几?如果是 0,改。
- 你的产物里能不能一眼看出"数据从哪来、扫到几条"?不能,加。
- 你的脚本依赖的数据源,最后一次更新是什么时候?这个数你上次人工核是多久以前?
exit 0 是个承诺,不是个事实。别让你的自动化系统学会撒谎——它撒谎的时候,比崩溃的时候贵得多。