它是什么
一个操作是幂等的,指的是做两次与做一次产生相同的可观察效果。在分布式系统里,重试不是边缘情况:网络会丢包、移动客户端会重发、消息队列在崩溃后会重新投递、用户会双击。既然 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 消除了双写:
- 在一个本地数据库事务里,写业务行并且向一张 outbox 表插入一行事件。
- 一个单独的 relay 读取新的 outbox 行并把它们发布到 broker。只有在 broker 确认之后,它才删除或标记该行。
- 消费者**至少一次**地收到事件(relay 可能在发布之后、标记之前崩溃),所以消费者要幂等。
实现 relay 有两种方式:在一个间隔上轮询 outbox 表(简单,但增加延迟和查询负载),或者用变更数据捕获去跟读数据库的事务日志(低延迟,但基础设施更多)。无论哪种,事件被保证发布,当且仅当事务已经提交。
消费者侧的对应物:inbox。 消费者在与其效果相同的事务里记录已处理的消息 id,所以处理一条被重新投递的消息就是一次空操作。发送方的 outbox 加上接收方的 inbox,合起来在 at-least-once 的投递之上提供了效果上的 exactly-once 处理。
Warning
生产实践: 幂等存储需要一个存活时间,而这个存活时间是一个承诺:一个在键过期之后才到达的重试会被再次处理。要把它设得比最长的现实重试时间范围(包括队列积压和死信重放)更长,并按这么多个键来规划存储的大小。
如何选择
| 情形 | 选择 |
|---|---|
| 一个简单的状态更新 | 让它天然幂等(set,不是自增) |
| 创建东西或转移金钱的 API | 一个幂等键,原子地占用 |
| 行本来就有天然的唯一键 | 唯一约束 |
| 更新数据库并发布一个事件 | 事务性 outbox |
| 消费有副作用的 at-least-once 消息 | 已处理 id 的 inbox,或天然幂等 |
| 调用不支持幂等的外部 API | 不存在 exactly-once;如果对方提供键就用,并事后对账 |
相关
- 演练:设计订单处理系统 — 从客户端一路贯穿每个 saga 步骤的幂等键。
- 参考:分布式事务模式 — saga 与补偿,它们依赖每一步都能安全地重试。
- 参考:队列投递语义 — 为什么至少一次投递是默认,而幂等是补上缺口的东西。