架构研发样本 02 — 参照:企业集成层
失败安全的事件队列:持久接收、有界重试、幂等效果
架构研发样本:工作室在本代码库中构建并实测的研发工作。它不是客户作品,也不描述任何客户。本页的每一个数字都是实测或计算得出,并说明了方法。
设计问题
当链条中的一个系统停滞时,事件既不能消失,也不能被应用两次。一个小而无依赖的状态机,能否提供“至少一次”的投递与“至多一次”的效果,经受住重启和死亡的通道,并在确定性的故障场景上证明这一点?
证据层级 B:由可执行测试验证的参考实现。它未接入任何客户的系统;规模、凭据和法律背景都不在本样本所声明的范围之内。
I
约束与公差
三种故障模式,每一种都有书面的公差。
丢失:已向其生产者确认的事件必须经受住重启和死亡的通道。接收是在确认之前完成的持久写入。
重复:网络会重复。投递是至少一次,因此接收端对一个事件标识只应用一次,并丢弃重复,无论是谁再次发送。
无界:任何事件都不得无休止地重试或无休止地滞留。尝试次数、存留时长以及每个上限处的结果(送入死信、保留、绝不丢弃)都作为策略写明。
II
架构
一个没有定时器、也没有自身输入输出的状态机。
调用方提供时钟、持久存储与通道,因此同一份代码既可以在确定性仿真中运行,也可以在浏览器中运行,并且在配上真实的存储与通道后,在项目中运行。
状态:待处理、重试、搁置、已投递、已死亡。失败的发送按指数退避重试。在连续失败并且健康探测失败之后,熔断器打开:事件被搁置在存储中,由一个廉价的探测不断检查通道,直到它恢复,然后按顺序清空积压。
毒性事件隔离:在健康通道上失败的事件是坏事件,而不是死亡的通道。它在自己的预算内重试,随后送入死信,熔断器保持关闭;因此一个坏事件拖住其后队列的时间不会超过该预算。
III
验证
确定性的场景与一组固定的不变量。
场景包括:通道中断二十秒、通道时断时续、30% 的发送丢失确认、一个毒性事件、一个顽固事件、故障期间的重启、重复发送的生产者,以及所有情况同时发生。每个场景都使用虚拟时钟和带种子的生成器:没有定时器,没有网络。
在每个场景中,契约测试都断言:已接收的事件没有丢失,没有被应用两次,接收顺序得以保持,且没有任何事件的发送次数超过策略允许的上限。
文档中的结果表由代码生成,并由测试与代码比对,因此证据就是代码今天所做的事。
各层级的适用范围:本队列属于证据层级 B(由测试验证的参考实现)。本站在生产环境中运行的、带幂等投递的签名 webhook 属于层级 A。
IV
实测结果与局限
由测试所运行的代码计算得出。
实测
这些数字是在绘制本页时通过运行契约测试所运行的场景计算得出的。它们是确定性的:每次请求都相同。
- 运行的场景
- 9
- 9 个中有 9 个守住了全部不变量
- 已接收的事件
- 370
- 所有场景合计
- 丢失的事件
- 0
- 每个已接收的事件都已应用、送入死信或仍在队列中
- 被应用两次的事件
- 0
- 已丢弃 56 次重复
在绘制本页时,由契约测试所运行的仿真器根据 lib/arch/scenarios.ts 计算得出。5 个事件被送入死信(保留,绝不丢弃):按设计均为毒性或顽固事件。
取舍与局限
这个模式的代价,以及它在哪里需要客户。
至少一次意味着会有重复
重复是设计使然,因此接收端必须应对它,并且记住标识的时间要长于队列可能持续重试的最长时间。
顺序要以可用性为代价
在严格顺序下,一个失败的事件会拖延其后的事件,直到它成功或被送入死信。对相互独立的事件可以关闭此项。
存储是一项依赖
持久性只和交给队列的存储一样强。存储的选择(数据库、托管队列)及其自身的保证属于项目。
测量环境
- TypeScript · no dependencies
- Virtual clock · seeded generator
- Node · browser