Skip to main content

参考:幂等与 Outbox 模式

一份关于端到端幂等键与事务性 outbox 的参考——让重试无害、原子地占用幂等键,并在没有双写问题的前提下发布事件。

它是什么

一个操作是幂等的,指的是做两次与做一次产生相同的可观察效果。在分布式系统里,重试不是边缘情况:网络会丢包、移动客户端会重发、消息队列在崩溃后会重新投递、用户会双击。既然 exactly-once 投递做不到,正确性就来自让重复的处理无害。Outbox 模式则解决与之相邻的问题:在更新数据库的同时发布一条消息,既不丢失其一,也不丢失其二。

Important

核心要点: 幂等必须在重试可能经过的每一层都成立:客户端到网关、网关到服务、服务到每个下游调用。一个重试穿过三个服务、而这三个服务各用一种不同的机制,最终必然会产生一次重复。

何时需要它

任何涉及金钱、订单或消息的流程都会被问”如果请求被重试了怎么办?“最便宜的强回答是一个幂等键,而最强的追问是解释它如何传播。

Note

面试信号: 订单处理是电商里幂等面最糟的场景:一次重复的重试就是一次重复扣款,一次重复扣款的客服成本超过了整个幂等层的成本。这样说,并指出这套基础设施很便宜,是资深的表述方式。

各种模式

模式机制成本
天然幂等的操作一个 set 或 upsert(status = shipped),而不是自增免费;优先使用
幂等键客户端为每个逻辑操作发送一个唯一键;服务端把结果记录在该键下,重复请求时直接返回一个 TTL 至少与重试窗口一样长的去重存储
条件写入UPDATE ... WHERE version = n(compare-and-set)由数据库去重;行需要带版本
唯一约束数据库拒绝插入两次的键最便宜的去重,前提是有天然的键
事务性 outbox效果和”已处理”标记在一个本地事务里提交一次数据库事务

端到端的幂等键

键在用户意图形成的地方生成,并在每一跳中携带下去。

跳键说明
客户端到网关客户端生成的 UUID,每次下单尝试一个,作为 Idempotency-Key header 发送每次尝试只生成一次,这次尝试的每次重试都复用它
网关到订单服务同一个键不要新生成
订单服务到工作流订单 id,由该键确定性地推导一个请求对应一个订单
工作流到每个下游调用由 (order_id, step_name) 推导出的键重试一个步骤三次发送的是同一个键,所以支付提供方返回的是同一次授权
每个下游服务保存它见过的键约 24 小时,遇到重复时返回缓存的响应窗口必须超过最长的重试时间范围

算例:处理一次重复。 服务端收到带键 abc-123 的 POST /orders。它查找 abc-123:如果有已完成的结果,就返回它;如果它正在处理中,就返回冲突或等待(两个并发的重试不能都执行);如果没见过,就原子地记录 abc-123 -> 处理中(一次唯一插入),然后做事、存储结果、返回。第一步是原子的,这就是关键:没有它,两个同时到达的重试都会通过查找,都去执行。

同一个键配了不同的载荷(误用)应该被拒绝,而不是悄悄地返回旧结果。

Caution

常见错误: 先用一次读检查键,再用另一次写把它写进去。这个两步检查是个竞态。要让”占用这个键”成为单个原子操作(唯一约束或条件 put),并把失败的一方当作重复。

双写问题与 outbox

一个服务经常既要更新自己的数据库,又要发布一个事件(“订单已创建”)。分别做这两件事是不安全的:先写数据库再发布,两者之间崩溃会丢失事件;先发布再写,崩溃会留下一个对应从未发生之事的事件。不存在能同时跨越数据库和消息 broker 的事务。

事务性 outbox 消除了双写:

  1. 在一个本地数据库事务里,写业务行并且向一张 outbox 表插入一行事件。
  2. 一个单独的 relay 读取新的 outbox 行并把它们发布到 broker。只有在 broker 确认之后,它才删除或标记该行。
  3. 消费者**至少一次**地收到事件(relay 可能在发布之后、标记之前崩溃),所以消费者要幂等。

实现 relay 有两种方式:在一个间隔上轮询 outbox 表(简单,但增加延迟和查询负载),或者用变更数据捕获去跟读数据库的事务日志(低延迟,但基础设施更多)。无论哪种,事件被保证发布,当且仅当事务已经提交。

消费者侧的对应物:inbox。 消费者在与其效果相同的事务里记录已处理的消息 id,所以处理一条被重新投递的消息就是一次空操作。发送方的 outbox 加上接收方的 inbox,合起来在 at-least-once 的投递之上提供了效果上的 exactly-once 处理。

Warning

生产实践: 幂等存储需要一个存活时间,而这个存活时间是一个承诺:一个在键过期之后才到达的重试会被再次处理。要把它设得比最长的现实重试时间范围(包括队列积压和死信重放)更长,并按这么多个键来规划存储的大小。

如何选择

情形选择
一个简单的状态更新让它天然幂等(set,不是自增)
创建东西或转移金钱的 API一个幂等键,原子地占用
行本来就有天然的唯一键唯一约束
更新数据库并发布一个事件事务性 outbox
消费有副作用的 at-least-once 消息已处理 id 的 inbox,或天然幂等
调用不支持幂等的外部 API不存在 exactly-once;如果对方提供键就用,并事后对账

相关