给 POST 加幂等,真正要定死的是四件事:键的作用域(scope)、并发同键怎么裁决、响应快照(response snapshot)存什么、键能活多久。第一件决定会不会跨租户串数据,第二件决定高并发下会不会双写,第三件决定重试拿回的是不是同一个答案,第四件决定客户端的重试预算够不够用。至于 Idempotency-Key 这个头本身,先说结论:IETF 的标准化尝试已经停摆——draft-ietf-httpapi-idempotency-key-header-07 于 2025 年 10 月 15 日发布,2026 年 4 月 18 日到期,datatracker 上现在的状态是 Expired(已归档),没有成为 RFC。所以今天做实现,参照物不是标准,是 Stripe 和 Adyen 的文档,而这两家彼此并不一致。

标准状态:这份草案过期了,而且短期内不会回来

判断它是「停摆」而不是「正常的续期节奏」,有四条独立证据:

  1. 它在 2024-01-08 进入 WG Last Call,2024-06-07 又退回 "WG Document" 状态——没能走出工作组最后征求意见。
  2. GitHub 上 ietf-wg-httpapi/idempotency 仓库最后一次实质提交停在 2025 年 2 月(draft-06 的收尾)。
  3. httpapi 工作组当前章程的里程碑里没有这份文档。工作组眼下仍在推进的 I-D 包括 patch-byterange、ratelimit-headers、rest-api-mediatypes 等。
  4. 草案的规范性引用已经烂了:它引 RFC 8941 定义 Structured Fields,而 RFC 8941 已被 2024 年 9 月的 RFC 9651 废弃;它引 RFC 7807 定义 problem details,而 RFC 7807 已被 2023 年 7 月的 RFC 9457 废弃。一份跟得上进度的文档不会同时挂着两个过期引用。

还有一个容易被忽略的事实:Idempotency-Key 至今没有进入 IANA 的 HTTP Field Name Registry。草案第 3 节只是「提议」添加。撰稿时该注册表的 257 条字段名记录里,没有任何 idempotency 相关条目。这意味着中间层(CDN、WAF、API 网关)对这个头没有任何先验认知,你不能假设它一定会被透传——上线前实测一遍。

这不等于草案没用。它把业界做法归纳得相当准,当「设计检查单」读是合适的。但不要在你的 API 文档里写「遵循 IETF 标准」,那句话是错的。

一个直接影响互操作的细节

草案 2.1 节规定 Idempotency-Key 是 Item Structured Header,值 MUST 是 String——按 structured fields 的规矩,String 要带双引号:

Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

而草案自己在「实现状态」一节里列为参考实现的 Stripe,官方文档给的例子是不带引号的:

Idempotency-Key: KG5LxwFBepaKHyUD

现实中几乎没人加引号。服务端解析时两种都要收:去掉首尾双引号后再比较,否则同一个客户端换个 SDK 就会被判成不同的键。

作用域:键永远是复合键

草案只说「唯一性 MUST 由资源所有者定义」,把最危险的部分留给了你。真正该存的不是 key,是一个元组:

(tenant_id, operation, idempotency_key)

三段都不能省,理由各不相同。

tenant_id 不能省,这是安全边界。 草案自己的安全考量点名了这个风险:如果实现允许低熵的键,攻击者可以猜出别人的键,把别人的幂等缓存条目取出来。Adyen 的文档说得更直白——它建议每个请求用 v4 UUID,理由是「防止同一账户下的两套 API 凭据读到对方的响应」。注意攻击面不只是「猜」:只要你的存储不带租户维度,一个客户端用了 order-1001 这种可预测的键,另一个客户端撞上同样的字符串就直接拿到了对方的响应体。用 UUID 只是降低概率,带上 tenant_id 才是消除。

operation 不能省,否则语义会串。 客户端很可能对一次业务流程复用同一个键去打不同端点(先创建订单、再发起支付)。不带 operation,第二个调用会被当成第一个的重放,直接返回订单创建的响应体。

但 operation 的粒度要想清楚。 如果你的意图是「同一个键在整个账户范围内只允许一次操作」,那么把 operation 放进主键反而给了客户端跨端点复用同一个键的空间。取决于你防的是什么:防误用,选路由级主键;防跨端点复用,就把 operation 从主键拿掉、改成一个额外的校验字段,不匹配即报错。

这个元组要落在一个唯一索引上。不是「先查有没有、没有就插」,是数据库层的 unique constraint。原因下面说。

指纹:防的是「同键不同体」

键回答「这是不是同一次意图」,指纹(fingerprint)回答「这次意图的内容有没有被改过」。两者缺一不可。

草案 2.4 节给了几种生成方式:整个 payload 的校验和、选定字段的校验和、逐字段比对、请求签名(签名怎么算、重放窗口怎么定,见Webhook 签名验证与重放防护生产检查清单)。实践中的坑不在算法,在规范化(canonicalization):

  • 直接哈希原始请求字节最简单,但只要客户端换个 JSON 库导致键序或空白变化,合法重试就会被判成篡改。
  • 哈希解析后的规范化 JSON(键排序、数字统一表示)更稳,但浮点序列化差异会咬人——金额字段务必用字符串或整数分。
  • 不要把会合法变化的东西算进去:User-Agent、trace id、客户端本地时间戳。只哈希业务语义字段。

不匹配时返回什么?草案说 422 Unprocessable Content(引 RFC 9110 §15.5.21)。这个选择合理,照做。响应体用 RFC 9457 的 application/problem+json(草案引的 RFC 7807 已废弃,别跟着引)。

反过来提醒一句:不要用 payload 哈希直接当幂等键。键代表客户端意图,两笔金额和商品完全相同的合法订单是两次意图,用内容哈希会把它们合并成一次。这是个真会赔钱的设计错误。

并发同键:唯一正确的解是原子条件写入

这是整套机制里唯一有真难度的地方,也是最多实现写错的地方。错的写法长这样:

row = db.get(key) # 没查到
if row is None:
 result = do_work() # 两个请求同时走到这里
 db.put(key, result)

查询和写入之间有窗口,并发同键会双写。加应用层锁也不行——多进程、多实例下无效。必须让「占位」这一步本身是原子的。

Postgres:

INSERT INTO idempotency (tenant_id, operation, key, state, lease_until)
VALUES ($1, $2, $3, 'in_flight', now() + interval '60 seconds')
ON CONFLICT (tenant_id, operation, key) DO NOTHING
RETURNING id;

返回了行 = 你抢到了执行权;没返回行 = 已经有人在做或已做完,去读那一行。Redis 上等价的是 SET k in_flight NX PX 60000。抢到之后是个两态机:in_flightcompleted

没抢到时该回什么? 草案说并发重试回 409 Conflict,Stripe 的状态码表也把 409 定义为「请求与另一个请求冲突(可能因为用了同一个幂等键)」。这是对的。但注意 Adyen 在同样场景下返回的是 HTTP 422 或 409 配 error code 704——同一个语义,两家两个码。跨供应商的客户端代码不能只认 409。回 409 时带上 Retry-After

一个明确的反面意见:不要让第二个请求阻塞等待第一个完成。它看起来「体验更好」(客户端拿到的是最终结果而不是错误),实际是在把客户端的重试风暴转成你的连接池耗尽——上游超时重试的场景下,等待中的请求数会随重试次数堆积,而它们全都占着连接和线程。快速失败加 Retry-After,把等待放在客户端。

租约与崩溃恢复

占位时写 lease_until,是为了防止执行到一半进程挂掉、把这个键永久锁死。租约过期后,这条记录处于状态不明:可能什么都没做,也可能副作用已经发出去了。

这里要做的是业务决策,别指望技术兜底:

  • 下游本身幂等(你调的外部 API 也接受幂等键,或写操作在同一个事务里)→ 可以让新请求接管、重新执行。
  • 下游不幂等,且涉及资金或发货 → 失败关闭:把记录标成 indeterminate,对客户端返回 409 或 503,转人工对账。宁可挂起,不要重复扣款。

租约时长要覆盖 P99 处理时间加安全余量,不是平均值。

响应快照:哪些失败该存,哪些绝对不能存

重放要返回和第一次完全一样的结果,所以要存的是响应快照:状态码 + 精选响应头 + 响应体

「精选」是重点。不要整包存 headers 原样回放:Date 会变旧、Set-Cookie 会串会话、trace/request id 回放会污染你自己的链路追踪、RateLimit-* 这类实时配额头会给出错误信息。白名单式地存 Content-Type 和你的业务头,其余重新生成。

再给重放打个标记。Stripe 用的是 Idempotent-Replayed: true照抄这个做法——它是客户端和你自己的监控唯一能区分「真执行」和「重放」的手段。没有它,你的接口 QPS 和成功率指标全是虚的。

哪些失败进快照,这是最需要判断力的一条。分三类:

业务确定性失败(存)。 卡被拒、余额不足、业务校验不过——这类错误重试多少次结果都一样,存进快照,重放返回同样的错误。Stripe 的文档说得很清楚:只要 API 方法开始执行,结果就会被缓存;一个返回 400 的请求,用同一个键再打还是同样的 400,要改请求就必须换新键。

基础设施失败(绝对不能存)。 429 限流、401 认证失败、502/503/504。这些是暂时性的,把它们钉进快照等于让客户端永远重试不回来。Stripe 的实现细节正好印证:限流器跑在幂等层之前,所以同一个键遇到 429 时结果可以不同,缺 API key 的 401 同理。

5xx(最难,必须区分)。 Stripe 会缓存 500,逻辑是:既然不知道副作用有没有发生,让重试稳定拿到同一个 500,比让它重新执行安全。这个取舍是对的,但前提是你能区分「业务逻辑主动返回的 500」和「进程崩在半路」。前者可以存;后者根本没机会写快照,只会留下一条租约过期的 in_flight 记录,走上一节的失败关闭流程。如果你的实现分不清这两者,就不要缓存 5xx。

还有两个边界:

  • 响应体过大或流式响应的端点,不要做幂等。 快照存储会变成事实上的对象存储。设一个字节上限,超了就在文档里明确该端点不支持幂等键。
  • 202 + 异步任务:快照存那个 202 和 job id 就够了,不要试图在同一个键上存最终态。客户端拿 job id 去轮询。

中间件的位置

上面那条「不能缓存 429」其实是个位置约束,值得单独点出来:幂等中间件必须放在认证之后(要拿 tenant_id)、限流之后(不能缓存 429)、业务逻辑之前。 放在认证之前,你就没有租户维度,直接落回本文第一个安全问题;放在限流之前,你会把 429 写进快照(限流层本身的算法选型和 RateLimit-* 响应头见API 限流实战)。位置错了,前面所有设计都白搭。

键的生命周期:TTL 必须大于客户端的重试预算

草案在这里只说「资源 MAY 要求带时效的键,SHOULD 把过期策略写进文档」——等于没约束。现实中的跨度很大:

保留期 长度上限 作用域
Stripe 至少 24 小时后可清除 255 字符 账户内
Adyen 首次提交后至少 7 天 64 字符 公司账户级

保留期差 7 倍,长度上限差 4 倍。如果你的服务聚合了多家供应商,你的有效重试窗口是所有下游里最短的那个,不是你自己配的那个;键长上限同理,取最小值。

对自建服务的建议:TTL 不能小于「客户端最大重试次数 × 最大退避间隔」,再留出人工重试的余量。24 小时是能用的下限,涉及资金的场景往 7 天靠。另外注意 Adyen 明确说了跨区域端点之间不做去重——多活架构下,幂等存储的复制延迟就是你的双写窗口,要么把同一个键路由到固定分区,要么接受这个窗口并在对账里兜住。

最后,别在 GET / DELETE 上收幂等键。这两个方法按 RFC 9110 本来就是幂等的,加了只会让客户端误以为有额外保证。Stripe 文档直接写了:不要在 GET 和 DELETE 上发这个头,没有效果。

落地检查单

  • 存储主键是 (tenant_id, operation, key) 上的唯一约束,不是应用层的查-改-写
  • 占位用 INSERT ... ON CONFLICT DO NOTHINGSET NX,带租约过期时间
  • 并发同键返回 409 + Retry-After,不阻塞等待
  • 指纹只覆盖业务语义字段;不匹配返回 422 + RFC 9457 problem+json
  • 快照按白名单存响应头,重放打 Idempotent-Replayed: true
  • 缓存业务性 4xx,绝不缓存 429/401/503
  • 中间件位置:认证之后、限流之后、业务逻辑之前
  • 键值解析同时接受带引号和不带引号两种形式
  • TTL 大于客户端总重试预算;在文档里写明具体数字和作用域
  • API 文档写「兼容业界通行的 Idempotency-Key 惯例」,不写「遵循 IETF 标准」