幂等设计实战:从重复下单说起

推演一个每个支付系统都躲不开的场景:用户点了「支付」,页面转圈,他等不及又点了一次;与此同时,网关在 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 天,没有对错,只有业务重试周期的匹配)。下次设计下单接口时,先问自己一个问题:这个请求被原样发三次,世界会发生几次变化?答案应该永远是「一次」。

参考资料

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?