bigywater.tech
分布式事务 · 28

幂等接口的设计:同一请求重复提交

客户端超时了就重试,这是网络世界的常态。怎么设计 HTTP 接口,让同一个请求重复提交不会重复执行?Idempotency-Key 怎么用、同时到达怎么办、key 被拿去干别的怎么办、key 能保留多久、有没有不用 key 的办法?这篇用真实的 HTTP 服务和 MySQL 逐个验证。

136 个
没有幂等处理时,200 个请求因超时重试在库里多出的重复订单数(用 Idempotency-Key 后是 0)
201 + 409 × 100
100 对同时到达的相同请求的结果:只有一个执行,另一个得到“处理中”
422
同一个 Idempotency-Key 带不同内容时的响应:拒绝,不把旧响应还给新请求

没有幂等处理

实测 · 本机执行输出
  客户端:{'client 最终拿到订单号': 132, 'client 放弃了(没拿到响应)': 68};库里的订单数 336(逻辑请求 200 个,多出 136 个重复订单)
  各种请求序列(每次尝试的结果)的个数:201:132;timeout→timeout→timeout:68
  超时的那次其实已经(或者马上)在服务端建好了订单,客户端不知道,重试又建一个

超时的请求其实已经在服务端处理,客户端不知道,重试又建一个,慢请求每次尝试都产生一个订单。

Idempotency-Key

实测 · 本机执行输出
  客户端:{'client 最终拿到订单号': 200, 'client 放弃了(没拿到响应)': 0};库里的订单数 200(多出 0 个);idem 表 200 行(DONE 200 行)
  各种请求序列的个数:201:132;timeout→409→201(重放):68
  慢请求:第一次超时(服务端还在处理)→ 第二次遇到 PROCESSING 返回 409 → 等 0.2 s 后第三次拿到保存的响应(带 Idempotent-Replayed: true);服务端从头到尾只执行了一次

每个逻辑请求一个 key,重试沿用;服务端先占位,处理中返回 409,处理完保存响应,之后重放。

并发占位

实测 · 本机执行输出
  结果组合:201 + 409:100;库里订单数 100(应为 100)
  两个同时到达的相同请求里只有一个抢到 PROCESSING 占位去执行,另一个拿到 409(正在处理中);订单只建一个

同一个 key 同时到达,靠唯一键只让一个请求执行,另一个得到 409。

请求指纹

实测 · 本机执行输出
  第 1 次(amount=100):201 {'order_id': 637};第 2 次(同样内容):201 {'order_id': 637} Idempotent-Replayed=true;第 3 次(amount=999,同一个 key):422 {'error': '同一个 Idempotency-Key 对应了不同的请求内容'};库里订单数 1

同一个 key 对应不同内容要拒绝(422),不能把旧响应还给新请求。

保留期

实测 · 本机执行输出
  第 1 次:201 {'order_id': 638};1 秒后用同一个 key 重试:201 {'order_id': 639} Idempotent-Replayed=None;库里订单数 2(同一个请求被执行了两次)
  key 的保留期必须比客户端可能重试的最长窗口(包括离线队列、人工重发)长

key 记录要保留得比客户端最长的重试窗口久,否则过期后重试会再执行一次。

PUT 与外部订单号

实测 · 本机执行输出
  客户端:{'client 最终拿到订单号': 132, 'client 放弃了(没拿到响应)': 68};库里的订单数 200(多出 0 个);idem 表 0 行
  各种请求序列的个数:200:132;timeout→timeout→timeout:68
  PUT 本身就是幂等的:同一个外部订单号重复提交只会覆盖同一行,不需要额外的 key 表;但它要求客户端能提前生成稳定的订单号,而且“两次 PUT 之间别人改过”的情形要靠版本 / 条件请求(If-Match)另外处理

客户端选稳定的资源 id,用 PUT 覆盖同一行,天然幂等,不需要 key 表。

分步演示

幂等接口
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用 Python http.server(线程化)写的小服务和 MySQL 26.7.0(orders、idem 两张表),客户端用 http.client,超时 100 ms、最多 3 次;“慢请求”由请求头 X-Slow-Ms 让服务端故意多处理 250 ms;幂等键的占位 / 响应保存 / 指纹比对是我自己写的实现(约几十行),不是某个框架;第五节把 TTL 设成 0.5 秒,第三节两个并发请求用线程 + 屏障
    • 没有验证:反向代理 / 网关层的重试(比如 Nginx 对上游的自动重试);HTTP/2 与连接复用对超时行为的影响;对外的支付网关(Stripe 之类)的 Idempotency-Key 具体语义;在分布式部署(多个服务实例、读写分离)下 idem 表的一致性;响应体很大时保存响应的成本;服务端 stderr 里有若干 ConnectionResetError(客户端超时后关闭连接引起的),不影响结果
    京ICP备2026067502号-1京公网安备11010502063487号