「已发送」标记撒了几周的谎:一次静默数据丢失的解剖
我的告警平台每天发日报、每周发周报、每月发月报——运营了几个月,一直”正常”。直到某天我在排查另一个问题(一个失效的兜底 webhook token)时顺手问了一句:这个死掉的 token 还影响了什么?
答案让我愣了一会儿:过去几周的周报和月报,一份都没有真正到达过。 而系统的每一个可观察面——日志、指标、状态标记——都说一切正常。
结论先放前面:这次静默丢失由三个”各自合理”的实现共谋而成:报告走直发而不是主投递管道(辅路径裸奔)、发送失败也盖”已发送”标记(标记语义漂移成了”尝试过”)、完成日志把失败状态打在 INFO 级(完成不等于成功)。修复只有一行原则:状态标记必须是事实断言——“已发送”只能在真的送达后落下,失败就让它空着,把这次机会留给补偿机制。
案发现场
这套平台的告警投递是千锤百炼的:事务性 Outbox、指数退避重试、死信队列、单条/批量/全量回放,仪表盘上每一次投递的生死都有账可查。
但周报不是告警。它是一个定时任务:到点、聚合数据、渲染卡片、直接 POST 给群机器人 webhook。没走 Outbox——当初的理由很正当:报告是一次性的低频发送,为它走一遍重投递管道显得小题大做。
发送完成后,任务在 Redis 里记一个标记:
1 | periodic-report:last-sent:weekly = 2026-08-17T01:00:00+00:00 |
这个标记服务于补发机制:进程重启时检查”最近一次应该发送的时刻”有没有对应的标记,没有就补发一次——防止调度器恰好在发送时刻宕机导致漏发。设计得挺周到。
然后,报告的目标 webhook 走一条兜底链:周报专用地址 or 通用地址 or 深度分析通知地址。前两个没配,于是所有报告都落到第三个——而第三个的机器人 token 在某个时间点被吊销了。
从那天起,每次发送都收到飞书的明确拒绝:code=19001: token invalid。然后——
三个共谋
共谋一:失败也盖”已发送”标记。 发送函数返回后,记录标记的那行代码无条件执行。于是每个失败的场次都留下了一个新鲜的、健康的 last-sent 时间戳。补发机制醒来一看:标记比最近的发送时刻还新,一切正常,收手。为失败兜底的机制,被失败本身缴了械。
共谋二:完成日志打在 INFO。 任务结束时打一行日志:weekly completed events=364 ... status=failed。注意——status=failed 就写在这行 INFO 日志里。它诚实地记录了失败,用的却是”一切如常”的音量。没有 ERROR,就没有日志告警;没有告警,就没有人看。”completed” 和”succeeded” 是两个词,而这行日志只保证了前者。
共谋三:辅路径没有账本。 告警投递失败会进死信队列、会在运维行动队列里亮红灯——因为它走 Outbox,每次尝试都是一行持久记录。报告是直发的,失败之后不留任何持久痕迹:没有失败表、没有队列、没有指标。同一个系统里,主路径的可靠性工程做到了牙齿,辅路径连一张收据都没有。
三个共谋叠加的效果:数据丢了几周,而系统的自我报告是三个”正常”。
修复:标记必须是事实断言
修复出奇地小——把”记录标记”移进成功分支:
- 成功:盖标记(它现在真正意味着”这一场次到达了群里”);
- 失败:日志升到 ERROR,不盖标记——这个场次在账面上保持”未发送”,下次进程启动时补发机制会看到缺口,重试它。
这个改动最妙的副作用是追溯补发。修好 token 之后,删掉那两个撒谎的旧标记、重启一次进程——补发机制立刻发现周报和月报各缺一个场次,当场补发了两份,全部成功。丢失的报告找回来了,用的还是系统原有的机制:它一直都会补发,只是一直被假标记蒙着眼。
顺带修正一个措辞:那个 Redis 键名叫 last-sent,而它这几周实际记录的是 last-attempted。当一个字段的名字和它的写入条件不一致时,名字撒谎只是时间问题。
这类 bug 为什么值得专门写一篇
因为它的发现方式很有代表性:不是被监控发现的,是被”顺藤摸瓜”发现的。 我当时在修另一个 bug(那个死 token 影响的事故通知),修完多问了一句”这个 token 还有谁在用”,才顺出报告这条线。如果没有那次追问,它还会继续”正常”下去——静默失败不会自己浮出水面,它需要被狩猎。
几条可以直接拿去自查的问题,都是这次的教训换的:
- 你的系统里有多少”已XX”标记,写入条件其实是”尝试过XX”?(
last-sent、processed_at、synced=true……逐个查它们的写入分支。) - 有没有日志把失败状态包在 INFO/success 形状的消息里?
grep "completed" | grep "failed"是个不错的开始。 - 主路径之外的发送(报告、通知、回调、导出),失败后留下什么持久痕迹?如果答案是”一行日志”,再问一句:那行日志有人订阅吗?
- 你的补偿机制(catch-up、对账、重试扫描)依赖的状态,是事实还是意图?为失败兜底的机制,必须建立在只有成功才能写入的状态上——否则失败会亲手关掉自己的救援。
这套平台是开源的:WebhookWise。这次修复带着一个回归测试进了主干:失败的发送断言”零标记落盘”,成功的发送断言”恰好一个”。测试名起得直白——失败不许再穿”已发送”的衣服。