跳到主要内容
凌晨 3 点,400 封错误催缴发出去:不是 bug,是判断被吞了

凌晨 3 点,400 封错误催缴发出去:不是 bug,是判断被吞了

破壁人
破壁人

· 阅读约 8 分钟

那篇 dev.to 复盘我先注意到的不是事故本身,是标签——ai、automation、devops——十月初发的,讨论区热闹,作者没删帖也没编辑成一段免责声明。他就把一件挺难堪的凌晨 3 点事故原原本本摆出来:每日支付对账任务自动跑了,给 400 名实际已经付过款的客户发了“扣款失败”。

事故本身不复杂。他有个常规编排:每天凌晨 3 点,任务拉当天付款,对比支付处理器记录,找出失败扣款,在账户失效前通知客户。设计没毛病,账户真要失效了,提前催一下总比断供强。那天晚上任务到点醒了,去读账本——账本还没写完。webhook 已经确认了一部分付款,但本地记录晚了几秒才落库。任务不知道什么叫“晚几秒”,它只知道本地没这几条,没几条就等于没收到。于是“暂时缺失”被判成“支付失败”,起草、发送、400 封邮件出去。发的时候没人在。上午 9 点作者才看到,客服队列已经 200 张工单。

作者后来写了一句很关键的话:代码按设计执行。这五个字是整件事的承重墙。不是条件写反,不是死锁,不是空指针。是任务在“它读到的数据就是那样”的前提之下,做出了一个合乎逻辑的推断。真正的洞不是“我写了 bug”,而是“我把一个面向客户的不可逆动作放进了无人值守流程”。这比 bug 难修得多——bug 有测试能抓、有 review 能拦,这种没报错、没异常堆栈、单测全绿,生产日志干净得像什么都没发生。

帖子拆到这儿,核心 idea 其实已经立住了:作者把工作拆成重复劳动和判断。拉取、匹配、对账、起草是重复劳动;发现异常、阻止发送是判断。自动化该吃的是前一块,后一块不该挂进 cron。他原话更重:设好就忘会让重复劳动和必要判断一起被自动化掉。说白了你省掉的不是操作,是最后那个还能停一下的人。

这一点点破了一个我们这行天天在用、但很少公开承认的幻觉:总觉得自动化在减少失败模式。不是。失败模式一个没少,只是发生时间从上午 9 点挪到了凌晨 3 点,旁边还没人。凌晨 3 点的问题不会更少,只会更晚被发现。你拿“无人值守”换省事,换来的是同一件事在没人看的时候出错。

时序图摆出来更清楚:

凌晨 3:00 前几秒  支付处理器 webhook 确认了付款
凌晨 3:00       定时任务读本地账本 → 这几条还没落库
               任务按"缺失=失败"判断 → 触发面向客户的发送
凌晨 3:00 后几秒  本地记录落到账本(发送已经出去了)
上午 9:00       作者看到 400 封已发出,客服队列 200 张工单

从“账本晚落库几秒”到“400 封错误通知”之间,唯一的连接点就是:发送这个不可逆动作刚好卡在数据不一致的那几秒里,当时没有任何一道闸。这个窗口非常小,但不可逆动作一旦跟这种窗口对上,再小的窗口也是大事故。图收到这儿就够了,再往细走就是分布式一致性,不是这次的重点。重点是窗口小不等于概率为 0。

我现在回头看,判断这一步不适合无人值守,不是因为它需要多强的智能,而是它的正确性依赖数据的新鲜度。可逆操作——查一次没查到,还能再查;不可逆操作——发给客户之后收不回来了。拿几秒前成立、几分钟后就不成立的数据去做不可逆副作用,问题不在逻辑,在时间。凌晨 3 点那个任务拿的是几秒前的世界吗?不,它拿的是一份已经在变旧、而且它不知道在变旧的账本。它甚至不算错——按它读到的数据,缺失就是失败。问题恰恰在这:任务没犯错,错在它被安排在一个“数据可能还没写完”的时刻去行使判断。

作者后来给的补丁是在不可逆边界拆分自动化。准备、对账、起草可以夜间无人跑,发送、付款、部署、删除要人工批准。方向对,听起来也成熟。如果帖子停在这,最多是一篇诚实的复盘,值得转但不会想拆。让我觉得这篇复盘不拆不行的,是评论区。

有一条留言把补丁又捅破了一层。Hayrullah Kar 说:你上午 9 点批准的是凌晨 3 点选出的批次,你此时看的可能是过期数据;建议保存查询,在放行时重跑一遍,显示中间消失的行数。我第一次读这条的时候停了一下,它确实比原文那套“人工批准”更锋利。如果你只是批准一个已经起草好的列表,你批的不是此刻的事实,是凌晨 3 点那个快照的事实。你的眼睛在 9 点,数据在 3 点。中间消失的行、新出现的行、状态改掉的行,在一张静态列表里根本不可见。

作者回应得干脆,说应该重新验证动作背后的数据,而不是批准动作本身。这个态度比大多数人强。但这个区分一旦成立,很多“加个审批就安全了”的方案就都露馅了。它意味着人工审批本身并不自动构成安全网。除非审批会触发一次重新验证,否则你站在队列前点“通过”,只是替六个小时前那个系统决定背书。

再拆一层:审批是安全网,处理器复核才是真正的缺陷修复。这两个不能混。后面有评论者问发送前能否逐笔向支付处理器复核,作者说已经加上——这才是修根,修“本地账本滞后于 webhook”。安全网防的是下一次同类错漏,缺陷修复解决的是具体那道裂缝。把安全网当成修复是老毛病,哪个团队都容易犯。人工不是防火墙,人只是最后一道确认它没跑偏。把人放进去,不等于问题解决。

能直接抄进生产的东西不多,但讨论里确实沉淀出一个可落地的形态。批准界面要展示的不是动作,而是影响范围:涉及多少条、样例长什么样、依据查询是什么、跟正常范围差多少。400 封对平常的 10 到 15 封——这个数字本身就是最便宜的警报,不用模型、不用算法,一个计数器加一个阈值就够。评论区还有个自称 AI agent 的 Vera 说,batch size 可以成为无人值守的硬性限制条件,这话是对的。Outricher 给的真实案例更夸张:一个客户自动化三个小时发起 103 次相同查询,约 11 万次请求,命中率只有 0.9%。这种批量层面的异常比任何花哨的逐条异常检测都直接,因为它对“这件事该不该继续”说话,不陷在单条样本里。

队列的正确形态到这儿其实已经拼出来了:阈值拦截 → 重跑查询 → 差异显示 → 人工放行。人在里面不是最后防线,是最后一道确认。这个顺序不能反,反了,你就只是在 UI 上点了个按钮。我这一段承认讲得有点细,但它是唯一能直接抄进生产的东西,值得展开。

把这件事拉回地面,对正在碰 agent 的人尤其刺耳。agent 干的事,本质上就是把这套无人值守再往前推一大段——动作更多、批量更大、出错更晚才有人知道。凌晨 3 点的 cron 你还能关;一个 agent 在生产环境里循环几十步,有些动作还不可逆,你连那个明确的“凌晨 3 点”都没有。它可能就在你吃午饭的时候卡在某个低命中率的循环里,把错误动作重复到足够大。缺的从来不是更聪明的模型,是一道和模型无关的硬边界:什么步骤能做,什么步骤必须停,什么信号一出现直接断。

别让“没报错”替你下结论。运行没报错,最多证明它没违反代码假设,不等于结果正确。它只是说系统做了它以为对的事。而系统以为对的事,跟真实世界发生的事之间,隔着一个谁都没在看的窗口。凌晨 3 点到上午 9 点,数据会变,事实会变。你批准什么、验证什么、拦什么,如果全用旧数据,那道人工闸就是个摆设。这堵墙我拆到这儿。剩下的路是你回头翻一遍原文,看看自己的 cron 里还有多少个不可逆动作,正挂在凌晨 3 点没人看的地方。

破壁人
破壁人

把高冷论文拆成中文开发者能懂的:核心 idea、公式推导、示意图、工程联系。

查看主页 →