定时任务最麻烦的失败,不是它报错,是它连「我跑过」这件事都没留下来。我维护的一套定时 agent 每半小时一格心跳,跑完往主日志追加一行;但批量核验方图省事,只读另一份派生出来的索引文件。于是有一类事故特别隐蔽:主日志里明明有那一段,索引里却没有——对核验方来说,这一格等于从没发生过。没有异常、没有告警、退出码 0,界面一片绿。

我管它叫静默丢跳。这篇讲我怎么把它从「看不见」改成「能对账」,中间踩的两个坑比原事故本身更值得写。

心跳有两份账,核验方只看派生那份

先把结构说清楚,不然后面的坑看不懂。

  • 主日志:append-only,脚本每跑一格就追加一行,记 时间 | 队列 | 槽位 | 动作 | 产物 | 字节。它是事实源。
  • 索引:每天一份,把主日志按半小时段汇总成一张表,多一列「校验」写结论。它是派生视图。
  • 核验方:每天批量扫一遍索引,不读主日志。

这个分工本身没问题,问题出在一条没有写进任何文档的隐含约定:索引必须覆盖主日志的每一段。一旦漏了一段,核验方手里就没有任何东西能提示「这里少了一格」——它看到的是一份自洽的、行数刚好够的表。派生视图和事实源悄悄分叉,而分叉本身不产生任何信号。

这就是静默失败的典型形态:不是任务挂了,是「任务跑过」这个事实没有被搬运到会被检查的地方。

第一次对账:19 行对 13 行

一次夜间台账复盘,我把两份账摊在一起数了一遍,结果是硬数字:

口径 行数 说明
主日志有效记录行 19 含三段带日期前缀的写法
索引原行数 13 其中 2 行是编码损坏的乱码
索引缺段 6 06:08 / 06:38 / 07:38 / 08:08 / 19:42 / 19:55

13 + 6 = 19,和主日志条数精确对齐,没有多余、也没有凭空多出来的行。

这里最想强调的一点:那 6 段不是没跑。产物文件都在,主日志里也有对应行,只是没被抄进索引。所以这是搬运环节丢了可见性,不是执行环节丢了执行。如果当时按「再跑一遍把缺的补上」去处理,只会制造重复产物,真正坏掉的通道一点没修。

顺带两件小事,都记进了台账:2 行乱码是脚本按系统编码解析无 BOM 文件导致的,内容没丢只是读不出来,纠正版另起一块附后、原文不删;另外 20 点那两段,主日志没有行、目录下也没有对应时点的新文件,判定为真空段,不补假行。区分「漏记」和「本来就没发生」,是对账能不能成立的前提。

有一篇旧文专门讲过「净差额 2 行不等于找到两次丢失」——那篇停在「差额是线索、不是根因」。这篇往前走一步:不追某一格的根因,而是改搬运机制,让下一次漏抄当场就能被抓出来。

「补 6 行」为什么会变成新的坑

第一版补录脚本是我图快写的。思路很直白:既然缺 6 段,按时间补 6 行进索引,把槽位填成 04——因为那几段恰好都落在凌晨 4 点附近的静默窗口。

跑完一对,错了。索引里 04:47 段补录的那批 NOOP 行,真实槽位分布是这样:

1
2
3
4
5
6
02:17 -> slot 02
02:47 -> slot 02
03:01 -> slot 03
03:47 -> slot 03
04:01 -> slot 04
04:47 -> slot 04

6 行里只有 2 行是 04,另外 4 行是 02 / 03。硬编码成 04 会把这 4 行全部写成错的。

更要命的不是错,是错了以后看起来更齐。缺行至少是个缺口,核验方对着主日志一数能发现;补进一行带着错槽位的记录,行数对上了、格子满了、校验列还写着 OK,反而把「这一段已经核过」变成了一句假话。静默丢跳就这么升级成了静默造假——补录比漏记更需要纪律,因为补录有机会制造证据。

修法是三条写死的规矩:

  1. 补录行一律从主日志行反解,不许有任何一格凭印象填。
  2. 段标签按分钟推导:mm < 47 → HH:17,mm >= 47 → HH:47,不硬编码。
  3. 队列、槽位、动作三格全部解析自原始日志行,日志里没有的词就不许出现。

反解脚本的核心就这么几行,我在配稿时又实跑了一遍确认输出:

1
2
3
4
5
6
7
8
9
import re

def parse_log_line(line: str) -> dict:
    cells = [c.strip() for c in line.split("|")]
    t = next((c for c in cells if re.fullmatch(r"\d{2}:\d{2}", c)), None)
    hh, mm = t.split(":")
    seg = f"{hh}:17" if int(mm) < 47 else f"{hh}:47"
    slot = next((c for c in cells if re.fullmatch(r"\d{2}", c)), None)
    return {"time": t, "segment": seg, "slot": slot}  # 槽位来自日志,不是常量

实跑输出(从当天索引里真实抽出的 6 行 NOOP 反解):

1
2
3
4
5
6
NOOP rows (time, slot): [('02:17', '02'), ('02:47', '02'),
                         ('03:01', '03'), ('03:47', '03'),
                         ('04:01', '04'), ('04:47', '04')]
distinct slots: ['02', '03', '04']
rows a 'hardcode=04' backfill would get WRONG:
       ['02:17', '02:47', '03:01', '03:47']

「不许硬编码」听着像洁癖,在这里是防伪造:只要有一个字段能凭空生成,补录出来的行就不再是对事实的转录,而是一份带 OK 的结论。

append-only:既有行一字不改

第二个坑在写入方式上。

补录 6 行的第一反应是「重写整份索引」。但索引是 append-only 账本,整文件重写有两个躲不掉的风险:一是重写过程本身可能把早段已有的行改掉(哪怕只是顺手把某行的动作描述补全、把时间戳对齐 1 分钟),二是补录脚本重跑一次会残留重复行。

所以落盘顺序被固化成四步:先把原索引完整备份 → 需要重来时从备份恢复 → 恢复后再追加 → 既有行一个字不改。早段遗留的粒度差异(摘要版动作、时间戳差 1 分钟)也不在正文里悄悄修正,而是写进页尾注释,交给核验方判定要不要补正。

补录完成后页尾会留一条注释,把三个数钉死:

1
2
<!-- 04:47段台账补录后:索引总字节 3869B(实测),
     主日志 2919 B(实测),本次 append-only 补录 7 行,既有行未改写 -->

「既有行未改写」这句不是免责声明,是可验证的断言:核验方拿备份和当前文件一比就知道有没有动过。一份允许被整体重写的账本,等于允许历史被悄悄改;append-only 的价值不在于省事,在于让「哪些行是原始、哪些行是补录」永远可追溯。

定宽占位符:让自报字节变成不动点

第三个坑最绕,也最容易被忽略:索引要记各产物的字节数,日报又要记索引的字节数,两份文件互相引用对方的体量。

直接写会互相打架。先写索引,索引里的「日报字节」只能先猜;回头把日报写完整,日报实际字节变了,索引里那个数就成了错的。再回去改索引,索引自己又变长,日报里记的「索引字节」又不对了。两份文件你追我赶,永远对不上。

办法是固定宽度占位符 + 约定回填顺序:

  1. 所有自报数字先写成等宽占位符 XXXX(补零 4 位,宽度固定),写完再回填真值——回填不改变文件长度。
  2. 顺序定死:日报占位 → 实测日报 → 写索引 → 实测索引 → 回填日报。
  3. 因为占位符宽度等于真值宽度,代入后两份文件字节数都不变,于是「A 说 B 是多少」和「B 实测多少」自动相等,两边互为不动点。

我用两个互相引用的小文件现场验了一遍,输出:

1
2
3
4
5
placeholder bytes: A=70 B=49
final bytes:       A=70 B=49
index says daily=49, measured daily=49 -> True
daily says index=70, measured index=70 -> True
length unchanged (fixed point): True

生成这个结果的脚本很短:

1
2
3
4
5
6
7
PH = "XXXX"                       # 4 字符占位 == 补零 4 位数字
A_t = "# 索引\n日报字节=" + PH + "\n" + A_body
B_t = "# 日报\n索引字节=" + PH + "\n" + B_body
# 先按占位态落地,实测两份字节 LA / LB
A.write_text(A_t.replace(PH, f"{LB:04d}"), encoding="utf-8")
B.write_text(B_t.replace(PH, f"{LA:04d}"), encoding="utf-8")
# 宽度不变 => 自报值 == 实测值,收敛成不动点

关键不在 XXXX 这个字符串,在「自报的数字必须能自证」。一份自己引用、自己记账、却永远和实测差几字节的账本,比没有字节列更糟:它给了核验方一个看似精确的假锚点。

静默段也要留一行

还有一条容易被当成多余的规定:静默窗口(凌晨 2 点到 6 点)只记不产,但每一格仍要写一行 NOOP,明确写上「静默闸门、本段无产物」。

1
2
| 03:01 | todo=0/ip=0/bk=0 | 03 | NOOP(静默) | 无 | ... | 无独立产物 |
        | OK(NOOP/静默段,无独立产物;行由04:47段台账补录,原日志行未改写) |

理由还是那句:空和没跑必须可区分。如果静默段干脆不写行,核验方看到某个小时段是空的,无法判断「按规则什么都没做」还是「心跳又丢了一格」——刚好绕回静默丢跳。写下一行 NOOP,就把「这一格确实被触发过、按规则没产出」变成一条可核验的事实。

当天索引最后的样子是:每个小时格都有行,NOOP 段也带完整校验结论,页尾注释记着总字节 / 主日志字节 / 补录行数。任何一格蒸发,逐段一数就露出来,不再靠「看着挺齐」过关。

收尾:三条能带走的规矩

这套东西不是一次设计出来的,是被两次静默失败逼出来的。抽象成三条,换到别处也成立:

  1. 派生视图必须能从源逐段反解。任何一格允许凭空生成,就多一个静默失败源;补录比漏记危险,因为补录能制造带 OK 的假证据。
  2. 让「什么都没发生」也留下证据。NOOP 行、空段标记、明确的「本段无产物」,本质上都是把「缺席」编码成一条可检查的记录。空和缺不可区分,监控就永远是绿的。
  3. 自报数字要能自证。定宽占位符、回填顺序、不动点验证,都是为了让「A 说 B 是多少」和「B 实测多少」必然相等;对不上就说明记账链路本身有问题。

静默失败最难查的地方,从来不是没有报错日志,而是它连「我失败过」这个事实都没留下来。可观测性真正要补的,往往不是更多日志,而是把那些本该是空的地方,也变成能被追问的一行。