12k
All articles

幂等性详解及其对 API 的意义

解释幂等键:防止重复 API 请求,修复竞态条件,并通过原子性占用和事务安全重试 POST。

OpenReplay Team
OpenReplay Team
幂等性详解及其对 API 的意义

当一个操作执行多次后系统所处的状态与只执行一次完全相同时,这个操作就是幂等的。同一个请求发送两次,不会产生任何额外后果:不会出现第二笔订单、第二次扣款,也不会创建第二个账户。

这个词通常出现在两种场景中:生产环境里的一次重复扣款,或者某个支付 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 会导致操作被执行第二次。请把保留期设置得比任何客户端、队列或批处理任务可能产生的最长重试延迟还要长。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.