重发是常态
一句话总结
渠道一旦超时,就会用同一个 GUID 重新发送。如果账户系统不具备幂等性,防止重复的责任就落在中继层,而它的工具,就是以 GUID 为主键的状态账本。账本不能只记“已处理”,还必须区分“发送了但不知道”和“没能发送”,重处理只有在这种区分之上才是安全的。
为什么需要它
第 4 模块的中继是收到什么就调用什么。网络正常时没有问题,但如果渠道与枢纽之间有一个响应延迟到达,渠道就会认为失败并重发——枢纽也会把第二个请求传给账户系统。本课程的账户系统夹具是故意做成不幂等的。同一笔转账收到两次,就扣两次。现实中的账本系统也往往无法自己判断“是不是同一个请求”,即使能判断,那个依据(交易唯一编号)也需要有人一路搬运到底。
回想消息传递的交付保证,重复不是例外,而是默认值。要确保请求送达,就必须在没有响应时重新发送(至少一次),而一旦重新发送,接收的一方就可能收到同样的内容两次。Enterprise Integration Patterns 把在接收方解决这个问题的方法总结为 Idempotent Receiver——让同一条消息收到多次,结果也与只收到一次相同。在本模块中,由中继层在账户系统前面担当这个角色。
工作原理
账本一行 = 一个 GUID。把 guid 设为主键,“同一个 GUID 有两行”这种状态本身就不可能出现。账本中放置状态、响应码、请求原文、响应原文、尝试次数和更新时刻。保存请求原文是为了重处理,保存响应原文是为了在重发时返回与第一次一模一样的答复。
状态有五个。
| 状态 | 含义 | 同一个 GUID 再次到来时 |
|---|---|---|
| SENT | 正在发往账户系统(已先占) | E903——处理中,不等待,立刻答复 |
| DONE | 已有确定的答复(0000、B2xx、E500) | 原样返回保存的响应 |
| UNKNOWN | 发送了但没有收到答复(E901) | E901——不再调用账户系统 |
| FAILED | 确定没有发送出去(E902) | 可以重新发送 |
| RECEIVED | 已收到但还没有发送 | 视实现而定,与 SENT 同样处理 |
发送之前先写。关键是顺序。如果在调用账户系统之后才写入账本,那么在这期间枢纽一旦死掉,账本里就没有任何痕迹,渠道的重发就会被当作新交易处理。所以要先以 SENT 先占一行,再调用,然后记录结果。先占必须是原子的。两个线程同时收到同一个 GUID,如果二者都判断“没有,我来处理”,那么即使有账本也没有用。SQLite 的 INSERT ... ON CONFLICT DO NOTHING(UPSERT 文档)在主键冲突时什么都不插入,并通过受影响的行数告知“是不是我先占到了”。不是先查询再插入的两个步骤,而是通过一次插入来判断。
UNKNOWN 只能通过查询来解决。重发结果未知的交易是在赌博。如果账户系统已经处理过,就会变成重复转账。所以要改为通过账户系统的查询 API 询问该 GUID 是否已被处理。如果已处理,就以该结果转为 DONE,如果没有记录,就转为 FAILED(确定未处理)。这里有一个陷阱。刚刚超时的交易,账户系统可能仍在处理中。如果现在查询,得到“没有”并转为 FAILED 后重处理,1 秒之后账户系统把第一个请求处理完,就扣了两次。所以只对比目标的最大处理时间更旧的 UNKNOWN 进行查询(--min-age)。
DLQ 不是丢弃的地方,而是等待的地方。没能发送的交易(E902),在原因解除之后重新发送就行。Dead Letter Channel 是把无法处理的消息单独收集起来的通道。本实验中,FAILED/E902 的行就是这个位置。重处理的原则有三条——① 用同一个 GUID 发送(如果重新生成编号,账本就看不出是重复)② 为尝试次数设置上限(避免永远失败的交易在每个周期都去敲账户系统的门)③ UNKNOWN 绝不属于重处理对象。
账本也要清理。账本不可能无限增长。但是,清理的规则就是防重复的界限。规定 DONE 在 7 天后删除,意味着“8 天后到来的重发就拦不住了”,所以保留期限必须比渠道可能重发的最长时间更长。而未确定的交易(UNKNOWN、FAILED),则不论期限一律保留——删掉的瞬间,这笔交易就失去了调查的依据。
在现场相遇的样子
最常见的事故,是重处理批处理把“失败的全部”都重新发送。失败清单里混有超时的笔,其中一部分是账户系统已经处理过的。第二天早上,客服中心就会被重复扣款的咨询淹没。第二种是重处理时重新生成 GUID——账本就没有办法认出这是同一笔交易。第三种是把账本放在内存(字典)里。枢纽一重启,就忘了“发送过什么”。第四种是账本查询与插入之间的竞争。在负载较低的开发环境中永远不会暴露,只有在渠道间隔很短地重发的故障场景下才会出现重复处理。
下一项实验要做什么
编写账本的 schema,复制夹具中的中继(relay_base.py,没有防重复)并接上账本——依次处理已结束交易的重发、处理中的重发(E903)、超时(UNKNOWN)和连接失败(FAILED)。然后制作通过查询确定 UNKNOWN 的 resolve.py、只对确定未发送的交易用同一个 GUID 重新发送的 reprocess.py,以及保留期限清理 purge.py。评分器通过临时账本和账户系统的调用统计,抓出重复调用。