推演一个每个支付系统都躲不开的场景:用户点了「支付」,页面转圈,他等不及又点了一次;与此同时,网关在 3 秒前那次请求超时后按重试策略补发了请求;几十毫秒后,支付渠道的异步回调也到了——第一次其实已经成功。三次触发,指向同一笔订单,而订单只能创建一次、账户只能扣一次款。重复不是异常,是分布式系统的常态:超时重试、负载均衡切换、消息至少一次投递、用户手抖,每一层都在制造重复。幂等设计不是加分项,是这类系统的地板。
幂等到底承诺了什么
HTTP 规范给了幂等最正式的定义。RFC 9110 §9.2.2:如果一个方法发出多次完全相同的请求,对服务器的「预期效果」与只发一次相同,该方法就是幂等的——PUT、DELETE 与所有安全方法(GET/HEAD 等)在此列,POST 不在。两个常被忽略的细节:其一,幂等只约束「预期效果」,服务器照样可以把每次请求都写进日志(非幂等的副作用),语义承诺不等于实现保证;其二,RFC 同时规定了重试纪律——客户端不应自动重试非幂等方法,除非确知语义幂等,而代理不得自动重试非幂等请求。这条纪律的反面就是现实:客户端与网关到处都在重试,所以把 POST 变得「可安全重试」成了 API 设计者的责任。
为什么非重试不可?Stripe 工程博客把网络失败拆成三种:连接根本没建立、调用中途失败、调用成功了但响应没能送回客户端。第三种无解——客户端无法区分「没执行」和「执行了但没告诉你」,唯一理性的策略就是重试,并用机制保证重试无害。
消息队列这边是同一个故事的另一半。Kafka 官方文档写得直白:许多系统宣称 exactly-once 投递,「重要的是读细则,因为这些宣称经常有误导性」;对下游外部系统,exactly-once 需要对方的配合,否则默认语义就是 at-least-once。RocketMQ 的官方 FAQ 同样只承诺 at-least-once,去重是消费端的责任。社区里那句流传很广的「你不可能拥有 exactly-once 投递」(Tyler Treat,2015)说的就是这件事:投递层最多做到不丢,「恰好一次」只能在业务效果层用幂等达成——Kafka 文档自己也写:消息常带主键,更新因此天然幂等。
工业界的标准答案:幂等键
把 POST 变得可安全重试的主流方案是幂等键(Idempotency Key):客户端每次业务操作生成一个唯一 key 放进请求头,服务端按 key 去重。IETF 为此制定了 Idempotency-Key 头草案(HTTPAPI 工作组,最新版 -07 发布于 2025-10)——截至 2026-10 它仍是草案且文本已过期、未成为 RFC,但 Stripe、Adyen 等一线支付平台早已按它落地,是典型的事实标准。
草案给出了一个精确的行为矩阵,值得背下来:
flowchart TD
A["请求携带 Idempotency-Key"] --> B{"key 是否已存在?"}
B -- "新 key" --> C["正常执行,保存响应"]
B -- "已存在且已完成" --> D["重放首次响应<br>(成功与错误都重放)"]
B -- "已存在且执行中" --> E["409 Conflict"]
B -- "已存在但参数指纹不同" --> F["422 Unprocessable Entity"]
G["要求幂等但缺 key 头"] --> H["400 Bad Request"]
三个配套约束:key 必须唯一且不得换 payload 复用(所以服务端要保存请求指纹,指纹建议对规范化后的参数计算);key 应做格式校验并与客户端属性组成复合键,防注入与跨租户串数据;去重窗口必须显式设定并写进文档——窗口长度没有行业标准,后文的对比表会看到从 5 分钟到 90 天的巨大跨度。
照着 Stripe 抄作业:魔鬼在细节
Stripe 的实现是这套机制的参照系,但真正值得抄的是它文档里那些「少有人写」的细节:
- 错误响应也重放。首个请求的状态码与响应体(含
500和已开始执行的400)都会被保存,同 key 重试原样拿回,响应头带Idempotent-Replayed: true标识。重试拿到同一个 400 不是 bug,是幂等的本意——你重放的是「那次执行的结果」,不是「再执行一次」。 - 但幂等层之前的失败不缓存。
429限流、缺 API key 的401、参数校验失败发生在端点开始执行之前,没产生副作用,不占幂等记录,可以放心换 key 重试。Stripe 对 4xx 的建议干脆利落:换新 key。 - 500 视为「结果不确定」。这是最反直觉的一条:执行已经开始但中途失败,副作用可能有——此时不要换 key 重试(新 key 会造成第二次扣款),而应该用原 key 等待或走对账(Stripe 自己做 reconcile:能向前推进就 roll forward,否则回滚并补发 webhook)。网络错误则同 key 同参数重试,配合指数退避加抖动。
- key 保留 24 小时,过期清除后同 key 再来会被当作新请求执行。
Stripe 工程师 Brandur Leach 给出的 Postgres 参考实现把这套语义落成了表结构:idempotency_keys 表存 key、生命周期状态(started → finished)与响应快照;请求开始时加锁,锁存活期间的同 key 并发请求直接返回 409,两个事务抢同一个 key 时靠 SERIALIZABLE 隔离级别 abort 掉一个;唯一性按 (user_id, key) 作用域化——不同账号允许出现同一个 key;后台 reaper 清理旧 key,他建议保留约 72 小时,理由很务实:得覆盖「周五上线的坏部署,到周一还能修完」的最长修复周期。去重窗口该多长,这个梗就是答案:取业务能容忍的最长重试周期。
一张表看全工业界
| 系统 | 机制 | 去重窗口 | 并发同 key 行为 |
|---|---|---|---|
| Stripe | Idempotency-Key 头(POST) |
24 小时 | 执行中 → 409;成功与错误响应都重放 |
| Adyen | Idempotency-Key 头(≤64 字符,建议 UUID v4) |
7–14 天 | 一方处理,另一方返回可重试的瞬态错误;未完成时 422/409 |
| PayPal | PayPal-Request-Id 头(POST) |
未公开精读细节 | 官方称可安全重复调用 |
| AWS SQS FIFO | MessageDeduplicationId(或对消息体取 SHA-256) |
5 分钟,固定不可配;消息删除后仍占窗口 | 同 ID 后续消息接受但不投递 |
| AWS Step Functions | Standard 工作流以执行名幂等 | 90 天(执行关闭后名字才可复用) | 重名调用报 ExecutionAlreadyExists |
| Kafka 幂等生产者 | enable.idempotence(3.0 起默认开启):broker 按 PID + 序列号去重 |
单生产者会话内 | broker 拒绝重复与乱序序列号 |
| RocketMQ | 无内置去重,官方仅承诺 at-least-once | — | 去重是消费端责任 |
同一件事,窗口从 5 分钟(队列消息,重投递来得快)到 90 天(工作流执行名,重触发可能来得很晚)——窗口长度跟着「重复请求最晚什么时候会到」走,这是选型时唯一重要的问题。
存储层三板斧与各自的坑
幂等键解决「API 层」的问题,落到存储还有三板斧,各有明确的坑:
唯一索引/去重表是最后的防线:给业务流水号(支付渠道回调的第三方流水号)建唯一索引,重复插入直接报错。关键纪律是去重记录必须与业务写库在同一个事务——两步走的话,回滚后会出现「标记已处理但业务没完成」的脏记录。
状态机 + 乐观锁管更新类操作:订单只允许 UNPAID → PAID 的合法流转,用 update ... where status = 'UNPAID' 的条件更新天然防重复推进,比「先查再改」安全(查询与写入之间有时间窗)。
Redis 防重 token是入口闸门,坑最密集:SETNX 与 EXPIRE 分两步执行会在进程崩溃后留下永不过期的锁,必须用 SET key value NX EX ttl 一条命令;释放时要校验 value 是自己的随机串,且「校验 + 删除」用 Lua 保证原子;消费 token 用 GETDEL(Redis 6.2+)而不是先 GET 再 DEL——非原子的两步在并发下会让两个请求同时通过校验。最后记住一条:分布式锁不是幂等。锁只保证同一时刻只有一个请求进临界区,不保证「这事历史上没干过」——进锁之后照样要查状态、查流水。
反模式清单与验收
踩坑合集压缩成八条:只靠前端按钮置灰防重(幂等边界必须在服务端);幂等 key 用低熵值(时间戳、自增数);key 不按租户作用域化;去重表永不过期清理;token 消费非原子;去重记录与业务不同事务;同 key 重试已失败的 400 永远拿到同样的失败(该换 key 的不换、不该换的乱换);无退避无抖动的立即重试放大成重试风暴。
验收可以对着行为矩阵出用例:同一 key 并发打 50–100 个请求,业务只执行一次;模拟网关重试、回调第三次重发、MQ 重复投递;注入半程失败(状态已更新、流水未写入)后恢复,交叉核对订单/流水/库存/去重表四张表是否对齐。幂等做得好不好,不看你写了多少去重代码,看这四张表在故障注入后还能不能对上。
结语
幂等设计的全部要点可以收成三句话:承认重复是常态(RFC 的重试纪律与网络三态失败决定了你拦不住重试);用幂等键把「重试」变成「重放」(保存首次结果,行为矩阵处理四种撞车情况,错误响应也算结果);窗口跟着最晚的重复请求走(Stripe 的 24 小时、SQS 的 5 分钟、Step Functions 的 90 天,没有对错,只有业务重试周期的匹配)。下次设计下单接口时,先问自己一个问题:这个请求被原样发三次,世界会发生几次变化?答案应该永远是「一次」。
参考资料
- RFC 9110: HTTP Semantics §9.2.2 — IETF(2022-06):幂等方法定义与重试纪律的规范原文。
- The Idempotency-Key HTTP Header Field(草案 -07)— IETF Datatracker:行为矩阵(重放/409/422/400)与 key 约束的一手来源。
- Idempotent Requests — Stripe API 文档:24 小时窗口、错误重放与并发 409 的官方说明。
- Advanced Error Handling — Stripe 文档:「500 视为结果不确定、4xx 换新 key」这些细节的出处。
- Designing robust and predictable APIs with idempotency — Stripe 工程博客(2017-02):网络三态失败模型与整体设计思路。
- Implementing Stripe-like Idempotency Keys in Postgres — brandur.org(2017-10):Stripe 工程师的 Postgres 参考实现,锁、作用域与 72 小时 reaper。
- API idempotency — Adyen 文档:7–14 天窗口与并发瞬态错误语义。
- Apache Kafka Documentation — Message Delivery Semantics:exactly-once「读细则」原文与幂等生产者机制。
- Using the message deduplication ID — AWS SQS 开发者指南:5 分钟去重窗口的官方定义。
- 接口幂等性设计 — JavaGuide:唯一索引、状态机、Redis token 与支付回调实践的中文综述。
读者留言
COMMENTS 暂无还没有留言,来说第一句?