先说读到后面最容易丢掉的那一点:没有扣任何钱。没有钱包被重复扣款,没有银行卡被扣费,也没有任何订阅额度被花在从未发生过的工作上。
坏掉的是屏幕上的一个数字。在部分账户上,你自己账户页面里的消费明细读数偏高——我们测到最严重的一个,是它本应汇总的账本的 2.4 倍。账本自始至终都是对的。每月上限也是对的,那是从完全不同的地方读取的。这是一个显示缺陷,现已修复,成因如下。
一个在找没人写过的东西的键
由你的操作引发的工作——更新每日消费汇总、入账一笔推荐奖励、扇出一条通知——并不在你的请求内部完成。请求写下一条消息,后台消费者稍后取走它;正是这一点让缓慢的副作用不会拖慢一个快速的页面。
这种安排的危险在于同一份工作被做两次,因为消息中间件在出现小故障后会毫不犹豫地重新投递同一条消息。防线是一个收件箱:在处理之前,消费者写入一行携带该消息键的记录来认领这条消息,而唯一索引使第二次认领成为不可能。
我们的认领基于一个二元组——消息所属的租户,加上消息键——并用消息信封中携带的租户标识去查找这个二元组,而信封里的那个值是有内容的。
认领记录里根本没有租户标识。每一行都是空的,而且从一开始就是空的。实体自身的文档原话就写着:框架会在插入时盖上这个值。它从来没有盖过。在我们发现问题当天对生产环境的测量:收件箱的 3,344 行全部为空,而生产端的 1,451 行中有 1,174 行携带真实租户——这恰恰是没人怀疑读取端的原因。
于是查找要的是(租户,键),存下来的行却是(无,键),永远不可能匹配。
什么都没有失败,所以它才跑了几周
一个永远不可能匹配的查找,听起来应该在第一次尝试时就抛出错误。它做的恰恰相反。
认领判定这条消息是新的。随后插入撞上唯一索引,被当作重复拒绝——这是正确的,因为那一行本来就在。一个兜底分支捕获了这次拒绝,并作了善意的解读:一定是另一次投递认领了它,这里没什么要做的。信封停留在"仍待处理"的状态,而既然仍待处理,下一次投递就把它取走并再做了一遍。
每个消费者都在大约每四十五秒重新处理自己的积压,永无止境。有二十六个在这样做。一条生产记录在到达 333 小时后才完成,而在这段时间的大部分里它都在一遍遍地"完成"。
它实际破坏了什么
那二十六个消费者中,有四个是累加而非覆盖。这四个正是消费页面所读取的计费投影,问题也正是在那里浮出水面。
它们本来有一道专门针对这种情况的保护,写于八月那次单个事件被重复应用九次到仪表盘的事故之后。这道保护会在处理之前把记录标记为成功,这样重投递就能找到标记并停下。标记每次都被正确写入。而下一次认领用同一个坏掉的键去找它,找不到。这条保证从发布那天起就是空的。
这件事没有波及到什么,值得精确列出来,因为"计费投影"听上去令人警觉,而事实并不:
- 账本是对的。 每一次 AI 调用都恰好写入一条带有自身价格快照的计费记录,而这条路径上没有任何东西会写入它们。
- 没有钱包变动。 余额来自账本,从不来自汇总。
- 没有上限变动。 预算守卫读取的是一个独立计数器,那才是你本月花了多少的唯一事实来源。没有人因为一个虚高的数字被提前切断,也没有不该通过的人通过。
唯一读取受影响汇总的,是你自己账户页面上的消费历史——你的总额,以及这些花费的去向明细。这就是全部影响范围,而它所做的就是读数偏高。
没人盯着的那一半
同一个坏掉的查找还坐在第二个地方:消息失败时运行的路径。在那里它从未找到本该更新的那一行,而紧挨着它下面是一个带着"实践中不可达"注释的分支——在每一次失败时都返回"此失败为终态"。
于是重试计数从未前进,也没有任何毒消息进入过死信队列。整整一层错误处理是关闭的,藏在一条告诉每个读到它的人"这里没什么要检查"的注释后面。
修复
两处查找现在都以消费者组内的消息键为键,这本来就是唯一索引所强制的内容。消息键派生自产生它们的对象标识,因此在结构上不可能跨租户冲突。
那段声称会盖值的文档被更正而不是删除。那句话本身就是陷阱,删掉它就什么也留不下来,没法表明终于有人核对过。
九个测试把这个行为钉住。其中六个在旧代码上失败,我们把旧代码放回去验证过。
事后的清理
虚高的记录会自我修复。有一个工作进程每小时从账本重建每日汇总,覆盖两天的窗口。它一直在运行,也一直在输:每小时跑一次,对上每四十五秒重新应用一次的消费者,这不是一场公平的赛跑。
消费者不再重跑之后,它就赢了。仍在那个窗口内的两天,在修复上线后立刻从账本重建完成。更早的两天已经落在窗口之外,那上面剩下的是我们自己的一个账户,外加别处一处可忽略的差异——所以我们把它们留在那里,而不是为了追它而在生产环境里放宽窗口。把这件事写下来,好过暗示这次清扫是彻底的。
我们从中学到的
去重键必须映射索引实际强制的内容,而不是代码以为自己写下的内容。这个键的两半由不同字段供给,而且从来没有任何一半被拿去和另一半核对过。现在两边都核对了,而证明这一点的测试,在那个错误的版本上会变红。