Architecture

按会来两次的 webhook 来设计

支付事件的第二份不是新工作。一把唯一的键、一个发件箱,以及一笔不改过去的作废。

Design for the webhook that arrives twice

危险的请求不是第一次。是同一个请求又来了,因为发送方没听到第一次的回答,或者听到了仍然重试。支付方会这样。队列会这样。人连点两次按钮时,浏览器也会这样。如果系统把第二份当成新工作,您会扣两次款、发两次货,或为一笔赌注写下两行账。

默认会有一份重复

我把处理做成第二次送达是一次什么都不做的成功。第一次做事并存下一个键:提供方的事件 id,或客户端发来的键,在数据库里唯一。第二次找到这个键,返回同样的结果。没有第二次扣款。没有第二封邮件。没有第二行。

我信任的是唯一约束。插入前一瞬间在应用代码里做的检查,会在两个 worker 同时醒来时输掉这场竞态。数据库不会输掉。失败者拿到冲突,并把它变成「已经做过」。

第一次到达第二次到达
查询键是新的键已经存下了
写一行,一个副作用没有新行
回复已接受同一个、已经接受过的
如果进程死在半路发件箱还欠着这条消息它不欠第二份

消息和这次写入一起离开,否则就不离开

在请求里、commit 之前就发出邮件或队列消息,会让一笔已经回滚的事务仍然发出消息。我把待发消息和业务行写在同一笔事务里,由另一个 worker 去发布。进程死掉时,行和消息要么都在,要么都不在。worker 可以重试发布。处理方不得重试扣款。

作废是一笔新分录,不是修改

需要撤销时,我不覆盖原来的那一行。一笔抵消分录写出变了什么,并让第一行仍然可读。一周之后,您仍然能回答 14:02 当时相信的是什么。改掉那一行只回答现在相信什么,而且没有证人。

如果一次重试能让操作发生两遍,这个操作就还没做完。

写处理程序之前先给键起名

除非您查过发送方在重试之间改了什么,键就不是「整个请求的哈希」。支付方会用同一个事件 id 再发一次,只换一个新的送达时间戳。如果时间戳在键里面,第二次送达看起来像新的,您就造出了本来想防的故障。我用发送方承诺稳定的那个 id。如果对方不承诺,我就要求调用方自己发一个,并且拒绝没有它的调用。

然后我把失败写在成功旁边。对发送方来说,冲突不是错误页。它是对重试的预期回答。发送方应当能够停下来。如果我回 500,他们会试第三次、第四次,事件就变成一队相同的工作。

我不会因为顺利路径返回了 200 就认为做完了。测试再次提交同一份载荷,并断言只有一个副作用。然后在 commit 和发布之间杀掉 worker,并断言消息仍然发出,而且只一次。这两个测试比它们替代的事故更短。我从这里开始。