当一个操作执行多次后系统所处的状态与只执行一次完全相同时,这个操作就是幂等的。同一个请求发送两次,不会产生任何额外后果:不会出现第二笔订单、第二次扣款,也不会创建第二个账户。
这个词通常出现在两种场景中:生产环境里的一次重复扣款,或者某个支付 API 要求你传入 Idempotency-Key 请求头,却对服务端拿这个 key 做了什么语焉不详。本文将覆盖这份契约的两个方面:客户端应当如何使用这个 key,以及服务端要做什么才能让重复请求真正无害,而不是”通常无害”。
核心要点
- 幂等键有三种状态,而非两种:不存在(absent)、执行中(in flight)和已完成(complete)。当一个请求发现 key 处于执行中状态时,应当收到 409 Conflict,而不是被再执行一次。
- 先检查 key 是否存在、之后再写入,这正是竞态条件的根源;唯一安全的做法是在任何业务处理开始之前,通过单次原子插入完成占位。
- 存储的执行结果必须与业务变更在同一个数据库事务中提交;分别提交只是把竞态条件挪了个位置。
- 客户端在首次尝试之前就生成 key,在每一次重试中复用它,并且绝不通过对请求体做哈希来派生 key。
- 测试时要在同一时刻并发地发出两个完全相同的请求。如果重复请求测试是一前一后串行执行的,即便代码存在竞态,测试也会通过。
幂等性能防止什么?
幂等性保护你免受重复请求的影响,而重复请求是家常便饭,并不罕见。用户在页面响应之前双击了提交按钮。客户端库等待响应超时后重新发送。代理或服务网格在连接中断时自动重试,而应用层对此一无所知。在上述每一种情况下,第一个请求都可能已经成功了,因此第二个请求若被天真地处理,就会生成第二笔订单或者把钱转两次。重复提交类 bug 的会话回放通常呈现的都是最平淡无奇的版本:加载动画还在屏幕上转,用户又按了一次提交——这正是幂等键在服务端所解决的同一问题在客户端的另一面。
为什么重试总会到来
重试并不是故障。HTTP 客户端、移动应用,以及夹在中间的各类基础设施,在超时后重发请求都是有意为之的设计,因为响应丢失和请求丢失在外部看来是无法区分的。你无法阻止重试到达;你只能让自己的接口在收到两次相同请求时依然安全。
RFC 9110 将 PUT 和 DELETE 定义为幂等方法,而 POST 不是;在 RFC 5789 中定义的 PATCH 同样不是幂等的。方法名本身永远不能让你的处理逻辑变得安全;幂等性是你的实现所具备的属性,而不是那个动词自带的。
契约中客户端的一半
客户端在首次尝试之前生成幂等键,在该操作的每一次重试中都发送完全相同的 key,只有在发起一个确实全新的操作时才使用新的 key。高熵随机字符串(例如 UUID)即可胜任。Stripe 的建议是使用 V4 UUID、长度上限 255 个字符,并且 key 本身不得包含任何敏感信息——不要放邮箱地址或其他个人标识,因为 key 会出现在日志里。
绝对不要通过对请求体做哈希来派生 key。两笔恰好完全相同的订单——比如同一个客户连续两次购买同一件商品——会哈希成同一个值,从而被合并成一笔。哈希告诉你的是两个载荷是否一致;而 key 告诉你的是调用方指的是哪一次操作。这是两件不同的事。从用户正在操作的某个稳定对象派生 key(例如购物车 ID)则完全可行,因为购物车本身就代表了那次操作。
需要注意的是,Idempotency-Key 请求头是行业惯例,而非已批准的标准。IETF httpapi 工作组的草案在修订版 07 时过期,并未成为 RFC,因此各家服务商都自行定义其语义。
契约中服务端的一半:原子化地占用 key
幂等键有三种状态,而不是两种:不存在、执行中、已完成。大多数有缺陷的实现只建模了两种。它们先检查 key 是否存在,再执行业务处理,然后保存结果。这中间留下了一个窗口:两个并发的重试都发现 key 不存在,于是都执行了一遍。修复办法是在任何业务处理开始之前,用一次原子插入来占用这个 key:
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
在 PostgreSQL 中,对于冲突的 key,ON CONFLICT DO NOTHING 会跳过插入,RETURNING 也不会返回任何行,因此返回零行就意味着这个 key 已被另一个请求占用。此时读取已存在的那一行:如果其状态是 complete,就重放已存储的结果;如果仍是 in_flight,则返回 409 Conflict,而不是再执行一次。这与服务商已公开的行为一致:当第一个请求仍在运行时复用同一个 key,Stripe 会返回 409 Conflict,并且不会把这次冲突记录到该 key 上,因此客户端可以稍后再来。Stripe 还会给重放的响应打上 Idempotent-Replayed: true 请求头——这是一个成本极低、值得照搬的贴心设计。
让执行结果与业务变更一起提交
存储的执行结果与业务变更必须在同一个数据库事务中提交。分开写入并不能消除竞态,只是把它挪到了两次提交之间的空隙里。如果进程在扣款完成之后、key 更新之前崩溃,那么钱已经划走了,而那一行记录却仍显示 in_flight。
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
要么两行都存在,要么一行都没有——这正是关键所在。
应该针对 key 存储什么,以及存多久?
存储处理逻辑产生的一切,包括失败结果。根据 Stripe 的幂等性规则,首次尝试的状态码和响应体会被保留,并在复用时原样返回,错误响应和 500 也不例外。重放一个真实的错误,比悄悄地把操作再执行一遍要诚实得多。真正的分界线在于那些在处理逻辑运行之前就被拒绝的请求。限流和身份认证位于幂等层之前,因此这类响应不会附着到 key 上,仍然可以重试。
三种存储方案,简要说明:
- 完整响应。 最容易做到精确重放;存储量随载荷大小增长。
- 资源引用。 存储所创建订单的 ID 并重新构建响应;更轻量,但需要额外一次查询。
- 标记加请求指纹。 存储开销最小;仅在响应可重新计算时可行,而且此时指纹从可选项变成了必选项。
无论选择哪种方案,都有四条规则适用。把唯一约束建在 (tenant_id, key) 上,而不是只建在 key 上,这样一个租户就不会与另一个租户的 key 发生冲突,也无法借此探测别人的 key。设置过期时间:Stripe 会在 key 超过 24 小时后清除它们,原则是存活时间要超过重试窗口,同时又不让表无限膨胀。给处于执行中状态的行加上租约(即上文的 locked_until 列),这样一个在请求中途崩溃的进程就不会永久阻塞后续重试。最后,拒绝任何指纹与已存储值不匹配的重试。相同的 key 配上不同的请求体说明客户端存在 bug,而把一个毫不相干的响应返回回去,后果只会更糟。
如何正确地测试幂等性?
在同一时刻用同一个 key 发出两个完全相同的请求,然后断言恰好只存在一个资源。一前一后地跑这两个重复请求什么也证明不了,因为第一个请求在第二个请求开始查看之前就已经完成了,存在竞态的代码照样能通过。
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
断言 orders 表中针对该购物车只有一行记录,并且两个状态码是一个 201 加上一个重放的 201 或 409。如果两次都返回 201 且订单 ID 不同,那你就撞上了”先检查后写入”的竞态。
需要做对的三件事
请求头只是给两个系统提供了一个指代同一次操作的共同名称。真正的安全性来自数据库中的三样东西:唯一约束、原子占用,以及事务边界。把这三点做对,你的接口就能扛住任何会重试的客户端——也就是所有客户端。同样的思路也适用于消息消费者,在那里,至少一次投递(at-least-once delivery)意味着有一个去重键在换个名字做同样的工作。从你最危险的那个 POST 接口开始,加上 key 表,并在信任它之前先写好并发测试。
常见问题
GET 和 PUT 请求需要幂等键吗?
通常不需要。RFC 9110 将 GET 定义为安全方法,将 PUT 和 DELETE 定义为幂等方法,因此一个完整替换资源的 PUT 即便被重试,不用 key 也会留下相同的状态。key 对 POST 才重要,因为每个 POST 请求都会创建新东西。例外情况是带有副作用的 PUT 或 DELETE 处理逻辑,比如发送邮件或触发 webhook,这类仍然需要服务端去重。
幂等键和请求 ID 有什么区别?
在重试过程中,两者的走向恰好相反。请求 ID(或关联 ID)用于标识单次 HTTP 尝试,服务于日志与链路追踪,因此每次重试都会拿到一个新的。幂等键标识的是一次预期中的操作,因此每次重试都复用同一个。如果客户端为每次重试都生成新的幂等键,就彻底破坏了去重机制,服务端会把操作执行两遍。
我可以把幂等键存在 Redis 而不是 PostgreSQL 里吗?
就原子占用而言,可以:带 NX 标志的 SET 能在一步原子操作中占用一个 key,与 insert-on-conflict 模式相当。Redis 无法提供的,是用单个事务把 key 的执行结果与存放在别处的业务数据行一起提交。如果在 Redis 写入与数据库提交之间发生崩溃,竞态就又回来了,因此把 key 保存在业务数据库中更安全。
如果客户端在幂等键过期之后才重试,会发生什么?
服务端会把这次重试当作全新请求再执行一遍,从而可能产生重复数据。例如 Stripe 会在 key 超过 24 小时后清除它,因此在此窗口之后复用同一个 key 会导致操作被执行第二次。请把保留期设置得比任何客户端、队列或批处理任务可能产生的最长重试延迟还要长。