JsonMind
API 与接口12 分钟阅读更新于 2026年11月11日

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

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

JM作者:JsonMind Engineering最后复核: 2026年11月11日依据权威规范与引用来源复核。
快速答案

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

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

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

为什么重试会重复写入

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

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

幂等键契约

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

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

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 位来自密码学随机源的熵;在首次请求前生成;绝不从请求体推导;跨重试保持不变;每次逻辑操作唯一。

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

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

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

16 字节随机数用 base64url 编码是 22 个字符,放在请求头里很合适;十六进制编码会让长度翻倍。随机令牌生成器 适合生成测试键,也适合验证服务端是否接受文档声明的字符集。无论用哪种格式,都要使用平台的密码学随机源,而不是可预测的 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 请求工作台 验证具体行为:发送一个 POST,复制键,修改金额后重发,确认拿到 422 而不是第二笔扣款。同时确认网关或服务网格会转发这个请求头;SDK 或边车重试时如果剥掉未知请求头,保护就静默消失了。

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

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

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 就刷新凭据后用同一个键重试。只有拿到确定状态才算操作完成,超时不等于失败。

情况                              重试?  键的处理
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 仍然会写入第二次。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南