# 幂等键设计指南：支付与写操作 API 的安全重试

> 从超时重试导致重复扣款讲起，覆盖幂等键契约、UUID 与 ULID 生成、请求头约定、存储表与唯一约束、请求指纹、响应重放、TTL 和失败语义。

**快速答案:** 幂等键是客户端为一次逻辑操作生成的唯一标识，每次重试都携带同一个键，服务端据此返回首次结果而不是再次执行。服务端需要把键、请求指纹和响应一起持久化：相同载荷的重复请求重放原响应，不同载荷则拒绝；键的有效期必须长于任何重试窗口。

## 核心要点

- 一次逻辑操作只生成一个键，先持久化再发送，所有重试复用同一个键。
- 租户、端点和键上的唯一索引才是防止重复执行的并发控制。
- 对规范化后的请求做指纹，键相同但内容不同时返回 422。
- 已完成的键原样重放响应，仍在处理中的重复请求返回 409。
- TTL 要长于重试窗口；结果未知时与支付服务商对账，不要重新执行。

---

一笔支付在卡组织已经授权之后超时。客户端只看到网络错误，于是重试，用户被扣了两次款。传输层无法告诉任何一方到底发生了什么，唯一可靠的解法是让同一次业务操作在多次尝试中拥有同一个身份。这个身份就是幂等键。

## 为什么重试会重复写入

每一次网络调用都有三种可能：请求根本没到服务端；到了服务端但在提交前失败；已经提交但响应丢失。超时在三种情况下长得一模一样。重试因此无法避免：移动网络会重连，负载均衡在上游超时后返回 502 或 504，SDK 会自动重试 5xx，SQS 这类队列至少投递一次。

一个负责插入扣款记录、扣减库存或发送确认邮件的处理函数，无法判断第二次尝试和第一次属于同一个业务事件。用金额、客户和时间戳去重也不行，因为同一位客户完全可能在一分钟内合法地买两次同样的商品。重复写入不是客户端 bug，而是至少一次投递遇到非幂等处理函数时的默认结果。重试还会放大问题：五个网关副本各重试三次，一次点击就变成十五次写入。

## 幂等键契约

幂等键是客户端生成的、用来标识一次逻辑操作的值，它标识的不是一次 HTTP 尝试。契约包含四部分：客户端在首次请求前生成键，并在该操作的所有重试中携带同一个键；服务端把键、请求指纹和执行结果一起持久化；相同键和相同指纹的重复请求直接返回已存结果，不再执行处理函数；相同键但不同指纹的请求必须拒绝。

作用域也是契约的一部分。键在租户和端点内唯一，而不是在整个互联网唯一。两个客户可能生成同一个 UUID，它们不能在存储中冲突，更不能拿到对方的响应。

```http
POST /v1/payments HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_...
Idempotency-Key: 4f8c2a1e-6b0d-4c7e-9a51-2f3d8c7b1e90
Content-Type: application/json

{"amount":4200,"currency":"usd","customer":"cus_8812","description":"Order 1042"}

# 响应到达前连接断开，用同一个键重试：
POST /v1/payments HTTP/1.1
Idempotency-Key: 4f8c2a1e-6b0d-4c7e-9a51-2f3d8c7b1e90
Content-Type: application/json

{"amount":4200,"currency":"usd","customer":"cus_8812","description":"Order 1042"}

# 响应：201 Created
# Idempotent-Replayed: true
# {"id":"pay_9F2Kx","status":"succeeded","amount":4200}
```

第一次请求已经提交，只是响应丢了；重试返回存下来的 201 和同一个支付 ID，客户端可以把两次尝试当作一次操作。判断键是否见过的是服务端，不是客户端；客户端“尽量小心”不构成契约。

## 哪些请求需要幂等键

HTTP 幂等描述的是对资源的预期效果，而不是两次响应是否逐字节相同。重复的 DELETE 第二次返回 404，仍然是幂等的。

| 方法 | HTTP 幂等 | 是否需要键 | 典型场景 |
| --- | --- | --- | --- |
| GET、HEAD | 安全 | 否 | 没有副作用的读取 |
| PUT | 是 | 通常不需要 | 完整替换，重复执行会收敛 |
| DELETE | 是 | 通常不需要 | 删除，第二次可能返回 404 |
| PATCH | 不保证 | 视情况 | 绝对值收敛，增量不收敛 |
| POST | 否 | 写操作需要 | 支付、订单、退款、消息 |

PUT 替换完整表示，重复执行会收敛。但一个在 PUT 处理函数里追加审计行、发送邮件或累加计数器的实现，效果并不幂等，尽管方法本身幂等。PATCH 使用绝对值（例如状态字段）会收敛，使用相对值（例如余额增减）不会。

凡是涉及资金的 POST 都应该要求键：支付、退款、打款、转账；创建订单或订阅、发放额度、发送一次性消息、开通资源也一样。内部服务调用同样需要。网关在边缘去重，保护不了内部 worker 对同一任务的再次投递，也看不见服务之间的重试。要求必须在服务端执行：缺少键或格式错误返回 400，而不是静默放行。

## 键的生成：UUIDv4、ULID 还是随机令牌

格式不如属性重要：至少 122 位来自密码学随机源的熵；在首次请求前生成；绝不从请求体推导；跨重试保持不变；每次逻辑操作唯一。

| 格式 | 熵 | 文本长度 | 可按时间排序 | 适合场景 |
| --- | --- | --- | --- | --- |
| UUIDv4 | 122 位随机 | 36 位含连字符 | 否 | 默认选择 |
| UUIDv7 | 48 位时间加随机 | 36 位含连字符 | 是 | 高写入表 |
| ULID | 48 位时间加 80 位随机 | 26 位 | 是 | 日志与工单关联 |
| 随机令牌 | 128 位随机 | base64url 22 位 | 否 | 紧凑请求头 |

UUIDv4 是最稳妥的默认值：所有语言和数据库都支持，随机性足以让碰撞可以忽略。[UUID / ULID 生成器](/tools/uuid-ulid) 可以同时生成 UUIDv4 和 ULID，方便测试完整流程。

ULID 把毫秒时间戳和 80 位随机数编码成 26 位 Crockford Base32。它按创建时间排序，扫描键表、关联客服工单时很有用，但也会泄露大致的创建时间。对支付键来说通常可以接受，但仍然应该是有意识的选择。不要把时间戳部分当成唯一性保证。

16 字节随机数用 base64url 编码是 22 个字符，放在请求头里很合适；十六进制编码会让长度翻倍。[随机令牌生成器](/tools/random-token-generator) 适合生成测试键，也适合验证服务端是否接受文档声明的字符集。无论用哪种格式，都要使用平台的密码学随机源，而不是可预测的 PRNG 或 `Math.random()`。

不要从请求体推导键。两笔合法的相同支付会合并成一笔，任何小改动又会悄悄变成第二次操作。不要用数据库自增序列、时间戳或进程内计数器。最后，在首次发送前把键和待处理操作一起持久化，可以是一条 outbox 记录、一条结算单，或者一个 worker 任务。SDK 重试时重新生成键，是让整套设计失效最常见的方式。

## 键放在哪里：请求头与命名约定

`Idempotency-Key` 是事实上的请求头名称：Stripe 使用它，IETF 的 httpapi 草案也把它标准化。有些 API 使用 `X-Idempotency-Key`；选定一个规范名称，写进文档，并在边缘按大小写不敏感处理。迁移期如果需要同时支持，就都转发，并以标准名称为准。

身份标识要和链路追踪分开。`X-Request-Id` 和 `traceparent` 标识一次 HTTP 尝试，每次重试都应该变化；幂等键标识操作，绝不能变化。拿 trace ID 当幂等键会让去重失效，拿幂等键当 trace ID 会隐藏重试链路。两个都发。

不要把键放进 URL 或查询参数。URL 会进入访问日志、浏览器历史、代理缓存和 Referer，还很容易在截图里泄露。请求头才是正确的载体。服务端要校验：长度 1 到 255，字符集限制为 UUID、base32 或 base64url，在分配任何存储行之前就拒绝超长值。

响应里回显键，并在返回已存响应时加上 `Idempotent-Replayed: true`，让客户端和日志能区分重放与新执行。用 [HTTP 请求工作台](/tools/http-request) 验证具体行为：发送一个 POST，复制键，修改金额后重发，确认拿到 422 而不是第二笔扣款。同时确认网关或服务网格会转发这个请求头；SDK 或边车重试时如果剥掉未知请求头，保护就静默消失了。

## 服务端存储：表结构与唯一约束

使用持久化数据库表。只靠缓存不能当幂等存储，因为缓存淘汰会把重放变成第二次执行。唯一约束就是并发控制：两个相同键的请求竞争插入同一行，只有一个能赢。

```sql
CREATE TABLE idempotency_keys (
  id               bigserial PRIMARY KEY,
  tenant_id        uuid        NOT NULL,
  endpoint         text        NOT NULL,        -- 例如 'POST /v1/payments'
  idem_key         text        NOT NULL,
  fingerprint      bytea       NOT NULL,        -- 规范化请求的 SHA-256
  state            text        NOT NULL,        -- in_progress | completed | failed | unknown
  response_status  int,
  response_body    bytea,
  response_headers jsonb,
  locked_until     timestamptz,
  created_at       timestamptz NOT NULL DEFAULT now(),
  expires_at       timestamptz NOT NULL,
  UNIQUE (tenant_id, endpoint, idem_key)
);

-- 占用、执行、完成（伪代码）
row = INSERT INTO idempotency_keys (tenant_id, endpoint, idem_key, fingerprint, state, expires_at)
      VALUES (:tenant, :endpoint, :key, :sha256, 'in_progress', now() + interval '24 hours')
      ON CONFLICT (tenant_id, endpoint, idem_key) DO NOTHING
      RETURNING *;

if row was inserted:
    try:
        result = execute_once(payload)            -- 唯一允许产生副作用的位置
        UPDATE idempotency_keys
           SET state = 'completed', response_status = result.status,
               response_body = result.body, response_headers = result.headers
         WHERE id = row.id;
        return result;
    catch error before any side effect:
        DELETE FROM idempotency_keys WHERE id = row.id;   -- 释放键
        raise;

existing = SELECT * FROM idempotency_keys
            WHERE tenant_id = :tenant AND endpoint = :endpoint AND idem_key = :key;

if existing.fingerprint != :sha256:
    return 422 {"error":"idempotency_key_reuse"};
if existing.state == 'completed':
    return replay(existing);                      -- 原样返回状态码、正文、请求头
if existing.state == 'in_progress':
    return 409 {"error":"request_in_progress"} with Retry-After: 1;
if existing.state in ('failed', 'unknown'):
    return 409 {"error":"outcome_pending"} and route to reconciliation;
```

唯一键必须包含租户和端点。只对客户端原始值做全局唯一索引，会让一个租户的键和另一个租户冲突；更糟的是，查询可能返回属于别人的响应。即使键在构造上全局唯一，每次读取也要按租户限定。

尽可能让业务写入和幂等记录完成处于同一个事务。如果副作用是外部调用，例如卡授权或发邮件，记录必须标明副作用可能已经发生；下一节会讲这种情况。存储的响应要限长，保留状态码、内容类型和请求头，正文控制在 64 到 256 KB，更大的正文放对象存储并保留指针。把 5 MB 的发票 PDF 塞进键表，这张表很快就会变成数据库里最大的表。按 `created_at` 分区，为 `expires_at` 建索引用于清理，只为你真正会跑的对账查询添加二级索引。

## 请求指纹：识别同键不同载荷

每次重复请求都要比较指纹：把最初占用该键的请求与当前请求做摘要对比，目的是在返回错误操作的响应之前发现客户端 bug 和意外碰撞。如果客户端把金额从 10 美元改成 100 美元却继续用旧键，重放 10 美元的响应会静默丢掉新的意图。

哈希之前先规范化：HTTP 方法、路径、租户，以及请求体中的语义字段。解析 JSON 后按排序的键重新序列化，统一数字格式，去掉文档中声明为易变的字段，例如客户端时间戳、随机数和 trace ID。对规范字节做 SHA-256，只存摘要，不存原始请求体。

直接比较原始字节在实践中会出错：`{"a":1,"b":2}` 和 `{"b":2,"a":1}` 是同一个请求，`4200` 和 `4200.0` 是同一个金额，请求头顺序更没有任何含义。规范化能消除这些误判，同时不削弱校验。

语义上，仍在处理中的重复请求返回 409 Conflict，稍后重试是合理的；已完成的键被不同指纹复用时返回 422 Unprocessable Content，这是永久错误。也有 API 两种都返回 409，Stripe 则返回 400 加机器可读的幂等错误码。具体数字不如三个属性重要：它是 4xx；它有独立的错误码；客户端能区分可重试与永久失败。永远不要覆盖原始指纹或响应，也永远不要让不匹配的请求落到正常执行路径。

## 并发重复、响应重放与部分失败

重复请求可能落在三种状态，每种都需要不同的响应。已完成的键重放已存结果；处理中的键返回 409 和 `Retry-After`，或者由服务端短暂等待后重放；失败或未知状态则需要判断上一次尝试是否可能已经提交。

并发重复由唯一插入裁决，输的一方不能执行。它可以每 50 毫秒轮询该行、最多等一秒后重放，也可以立刻返回 409。高负载下无界等待会耗尽连接池；409 是合法且诚实的答案。

响应重放应原样返回已存的状态码、正文和相关请求头，并加上 `Idempotent-Replayed: true`。在透露已存响应之前仍要认证和授权，因为键不是能力令牌。要小心包含短期签名 URL 或一次性令牌的响应：重放过期 URL 比重新执行更糟。这类端点应存储可再生成的表示，或者干脆不缓存响应。

只有在上一次尝试可以证明没有提交时，重新执行才安全。事务回滚会释放键；支付服务商已经扣款之后才超时的调用不会。部分失败是最难的情况，通常有三个窗口。

第一个窗口：业务写入已提交，但幂等行还没标记完成就崩溃。行会停在处理中。恢复靠租约：后台 worker 用幂等键查业务记录（键也要存在扣款或订单行上），找到就用真实结果把键标记完成，找不到就标记失败。

第二个窗口：调用支付服务商之后、记录结果之前崩溃。状态是未知，不是失败。不要重新执行。用确定性的下游幂等键（例如你的键与步骤名的哈希）与服务商对账，让对方去重，然后记录对方的答案。

第三个窗口：响应已经存好之后崩溃，重放是安全的。如果一行处理中状态超过租约、又找不到对应业务记录，那是事故，不是缓存未命中。要告警，绝不能为了“让客户端继续”而伪造成功响应。

## TTL、清理与保护边界

TTL 就是去重窗口，必须长于你能见到的最长重试周期。如果键一小时过期，而队列六小时后才重新投递，那次重试就会造成重复。Stripe 的键至少保留 24 小时；支付 API 通常保留 24 小时到 7 天，让客户端重试、队列重投、服务商超时和客服触发的重放都落在窗口内。

过期不是旧操作的正确性边界。键过期后，同一个键原则上可以再次使用，所以生成器要让这种事实际上不可能发生，客户端也必须在过期前很久就停止重试。业务记录要保留操作时间，这样即使键行已经删除，对账仍能发现很久以前的重复。

清理可以按 `expires_at` 分批删除、按天分区后丢弃旧分区，或者使用原生 TTL。原生 TTL 通常是惰性的：DynamoDB 和 MongoDB 可能让过期行在几分钟甚至几小时内仍可读到，所以读取时要自己过滤 `expires_at`。Redis 的 TTL 不够持久，不能作为唯一存储；它适合放在数据库前面做缓存。

提前估算表大小。每次写操作一行，每天一百万次写入、响应 1 KB，索引之前大约每天 1 GB，30 天窗口就是 30 GB。压缩正文、限制大小，并把已完成的行归档到热去重表之外。

## 客户端重试策略、审计日志与常见错误

大多数问题出在客户端。每次逻辑操作只生成一个键，首次发送前持久化，所有尝试复用它，应用重启后也一样。只在结果不明确或暂时性错误时重试：网络错误、超时、408、425、429、500、502、503、504。使用带完整抖动的指数退避，遵守 `Retry-After`，限制总次数。绝不重试 422，指纹不匹配说明这个键属于另一个请求；为新操作生成新键，并修掉客户端 bug。收到 409 就等待后用同一个键重试；收到 401 就刷新凭据后用同一个键重试。只有拿到确定状态才算操作完成，超时不等于失败。

```text
情况                              重试？  键的处理
2xx 成功                          否      标记操作完成
网络错误或超时                    是      同一个键，指数退避加抖动
408、425、429                     是      同一个键，遵守 Retry-After
500 且确认没有已提交副作用        是      退避后用同一个键
502、503、504                     是      同一个键，上游可能已执行
409 键正在处理中                  是      按 Retry-After 后用同一个键
422 指纹不匹配                    否      为新操作生成新键
400 缺少键或格式错误              否      修正请求，键可能还能复用
401 或 403                        否*     刷新凭据后用同一个键
404                               否      对本次操作是确定结果
```

可观测性方面，每次状态转换记录一条结构化事件：租户、端点、键或其哈希、指纹、状态、是否重放、响应状态和耗时。监控重放比例、409 比例、422 比例、处理中行的年龄，以及未知状态的数量。422 突然上升说明客户端在循环使用键；409 上升说明有竞争或处理函数太慢。支付操作要保留只追加的审计记录，保留期与财务账本一致。

安全从作用域开始。键不是认证凭据，绝不能单独授予访问权限。查询必须限定在已认证的租户和端点内，否则观察到键的攻击者可能读到另一位客户的已存响应。写入前校验长度和字符集，限制键创建速率，静态加密存储的响应正文，日志可能被广泛读取时不要记录原始键。足够的熵让键难以猜测，但授权仍然来自令牌，而不是键。

常见错误按危害从大到小大致是：每次重试都生成新键；用请求 ID 或 trace ID 当幂等键；只存“见过”标记却不存响应；缺少唯一约束；比较原始请求字节；重复请求返回 500；键在重试窗口结束前过期；只在网关去重；不把幂等键传给支付服务商；以及以为 PUT 或 DELETE 能让所有副作用都安全。

## 常见问题

### 什么是幂等键？

幂等键是客户端生成的值，用来在多次重试中标识同一次逻辑操作。服务端把键、请求指纹和执行结果一起保存，重复请求携带相同键时直接返回已存响应，而不是再次执行写操作。

### 幂等键应该由客户端还是服务端生成？

必须由客户端生成，因为只有客户端知道哪些重试属于同一次操作。服务端生成的键无法保护首次请求，代理也无法区分一次重试和一次正文完全相同的新请求。

### 同一个键配上不同载荷会发生什么？

应返回 422 Unprocessable Content 和独立错误码。重放旧响应会把另一个操作的结果套用过来，重新执行又会造成第二次写入。客户端必须为新操作生成新键。

### 幂等键应该保留多久？

多数 API 至少 24 小时，支付类通常保留 24 小时到 7 天，并且一定长于客户端重试和队列重投的最长窗口。键过期后不再去重，客户端必须在过期前停止重试。

### 有了幂等键，POST 就可以随便重试了吗？

不能。只有当服务端持久化并强制执行幂等键时，特定的 POST 才能安全重复。没有持久存储、唯一约束和重放逻辑，重试的 POST 仍然会写入第二次。

## 参考资料

- [Stripe API 文档 - 幂等请求](https://docs.stripe.com/api/idempotent_requests)
- [IETF 草案 - Idempotency-Key HTTP 请求头](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
- [RFC 9110 - HTTP 语义](https://www.rfc-editor.org/rfc/rfc9110)
- [Adyen - API 幂等性](https://docs.adyen.com/development-resources/api-idempotency/)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/idempotency-key-api
- Locale: zh-CN
- Published: 2026-11-11
- Updated: 2026-11-11
- Tools: uuid-ulid, random-token-generator, http-request