幂等性难题:第二次请求不同时如何应对?
幂等性:直到第二次请求不同时才变得复杂
2026 年 5 月 7 日,阅读时长 25 分钟。涉及标签有 [api]、[http]、[idempotency]、[backend]、[distributed - systems]、[databases]、[microservices]、[architecture]、[payments]。人们常认为幂等性问题已解决,方法是在请求中添加 `Idempotency - Key`,存储响应,重试时重放该响应。理想情况下,实现简单。客户端发送请求示例如下:
POST /payments Idempotency - Key: abc - 123 Content - Type: application/json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice - 7781" }服务器会检查是否处理过 `abc - 123` 键,未处理则创建支付记录,已处理则返回之前响应。但真正挑战从第二次请求开始,因为第二次请求并非总是第一次的简单重放。
第二次请求可能是完整重放,直接返回存储结果;可能在第一次请求处理时到达,幂等层成并发控制一部分;可能第一次请求创建本地支付记录后崩溃,导致本地记录和外部副作用不同步;可能第一次请求调用支付提供商,进程挂掉后数据库无法推断资金是否转移;还可能第二次请求用相同键但内容不同,如:
{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice - 7781" }这种情况让幂等性变得复杂,服务器需明确策略,对于有副作用的 API,使用相同作用域键但规范命令不同时,应视为严重错误。以下情况重放缓存无法解释:完整重放、并发重试、部分本地成功、下游未知状态、相同键但规范命令不同、无键重复操作、过期后重试、部署等变更后重试。若设计只处理完整相同命令重试,只是重放缓存,不能解决所有问题。
幂等性关乎效果
若一个操作执行一次或多次预期效果相同,该操作就是幂等的。关键在于“效果”一词。HTTP 提供方法级语义,如 `PUT /users/123/email` 重复发送相同表示能使资源保持相同状态,就是幂等的;`DELETE /sessions/456` 删除已删除会话仍意味着“会话不存在”,也是幂等的,重复执行 `DELETE` 可能返回 `404`,但效果幂等。然而,处理程序仍可能产生业务关心的重复副作用。`POST` 请求通常默认不幂等,但服务器存储并强制执行正确行为,也可使其幂等。键标识操作,但不能定义请求等价性等。唯一性约束可防止一类重复,但不能为客户端提供正确重试结果。
你需要记住的要点
对于 `POST /payments` 请求,持久的幂等记录需回答三个问题:谁拥有这个键?第一个命令的含义是什么?可以重放的结果是什么?用类似 PostgreSQL 的 SQL 语言,最小的表可能如下:
create table idempotency_requests ( tenant_id text not null, operation_name text not null, idempotency_key text not null, request_hash text not null, status text not null, response_status int, response_body jsonb, resource_type text, resource_id text, error_code text, created_at timestamptz not null, updated_at timestamptz not null, expires_at timestamptz not null, locked_until timestamptz, primary key (tenant_id, operation_name, idempotency_key) );键通常不是全局唯一,作用域可多种选择,操作名称防止键意外重用,`request_hash` 是服务器对第一个命令的记忆。`IN_PROGRESS` 不是内部细节,行为需明确,如下表:
| 现有记录 | 规范命令是否相同? | 建议行为 |
|---|---|---|
| 无 | 是 | 插入 `IN_PROGRESS` 并执行 |
| `COMPLETED` | 是 | 重放存储的响应或文档规定的等效响应 |
| 任何现有记录 | 否 | 以幂等冲突拒绝 |
| `IN_PROGRESS`,新记录 | 是 | 等待,返回 `202`,或返回 `409` + `Retry - After` |
| `IN_PROGRESS`,旧记录 | 是 | 恢复所有权;不要盲目再次执行 |
| `FAILED_REPLAYABLE` | 是 | 重放存储的失败响应 |
| `FAILED_RETRYABLE` | 是 | 根据策略允许重试 |
| `UNKNOWN_REQUIRES_RECOVERY` | 是 | 触发协调或返回待处理/恢复状态 |
| 过期/已删除 | 未知 | 遵循文档规定的过期行为 |
响应字段存在是因为幂等性不仅防止重复写入,客户端需要答案。存储完整响应体或存储资源引用各有优缺点。
相同键,不同命令
这是幂等层应明确捕捉的错误。示例如下:
第一个请求:
{ "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice - 7781" }第二个请求:
{ "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice - 7781" }相同 `Idempotency - Key: abc - 123`,不同金额。返回原始响应会掩盖客户端错误,对于有副作用的 API,应视为严重错误,返回 `409 Conflict` 等。常见客户端错误示例及更好做法如下:
错误示例: idempotencyKey = cartId POST /payments amount = 10.00 key = cart_123 POST /payments amount = 15.00 key = cart_123 更好的做法: idempotencyKey = paymentAttemptId服务器不应猜测购物车键代表的支付。也可设计 `(key + content hash)` 定义操作标识,但要让客户端清楚。
对命令进行哈希,而不是对字节进行哈希
对于 JSON API,原始字节比较通常过严,如以下两个请求体通常应等效:
{ "amount": "10.00", "currency": "EUR" } { "currency": "EUR", "amount": "10.00" }默认值和未知字段情况需在哈希前决定。实际规则是对经过验证的命令进行哈希,合理流程如下:
- 将请求解析为版本化的请求 DTO 或命令。
- 对 API 视为等效的值进行规范化。
- 排除仅用于传输的元数据。
- 包含路径参数和操作名称。
- 若语义头影响操作,将其包含在内。
- 确定影响响应形状的头属于命令哈希、重放契约还是两者都不属于。
- 排除 `Authorization` 和幂等键本身。
- 进行规范序列化。
- 使用稳定的算法进行哈希。
支付示例指纹可能包括:
operation: create_payment accountId: acc_1 amount: 10.00 currency: EUR merchantReference: invoice - 7781 channel: web apiVersion: 2026 - 05 - 01要注意金额等因素,请求哈希是契约,改变计算方式旧重试可能不同。
首次插入决定谁拥有执行权
两个相同请求几乎同时到达两个 API 实例:
POST /payments Idempotency - Key: abc - 123以下实现有问题:
existing = find_by_key(key) if existing does not exist: create_payment() insert_idempotency_record()正确插入方式如下:
insert into idempotency_requests (tenant_id, operation_name, idempotency_key, request_hash, status, created_at, updated_at, expires_at, locked_until) values (:tenant_id, 'create_payment', :idempotency_key, :request_hash, 'IN_PROGRESS', now(), now(), now() + interval '24 hours', now() + interval '30 seconds') on conflict do nothing;然后根据不同情况处理:
if rows_inserted == 1: this request owns execution else: existing = load idempotency row if existing.request_hash != request_hash: return 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST if existing.status == COMPLETED: return replay(existing.response_status, existing.response_body) if existing.status == IN_PROGRESS and existing.locked_until > now(): return 202 or 409 + Retry - After if existing.status == IN_PROGRESS and existing.locked_until <= now(): attempt recovery ownership # this must be atomic too if existing.status == UNKNOWN_REQUIRES_RECOVERY: trigger reconciliation or return pending/recovery response恢复所有权必须原子操作。在简单本地情况,所有者可在事务中完成幂等记录和支付记录等。外部副作用会改变情况,Redis 的 `SET NX EX` 只是执行保护,不能替代对操作结果的记忆。
提供商超时意味着你的保证结束
重要失败情况如下:
- API 接收到 `POST /payments` 请求。
- 插入幂等记录,状态为 `IN_PROGRESS`。
- 创建本地支付记录 `pay_789`。
- 调用下游支付提供商。
- 提供商接收请求并处理成功。
- API 超时、崩溃或丢失提供商响应。
- 客户端使用相同的键重试。
本地状态机可能如下:
RECEIVED LOCAL_PAYMENT_CREATED PROVIDER_REQUEST_SENT PROVIDER_CONFIRMED COMPLETED UNKNOWN_REQUIRES_RECOVERY重试行为取决于状态。若提供商无幂等键和查询 API,系统存在操作漏洞。下游调用需稳定标识。除非 API 有特定原因,应避免使用 `425 Too Early`。
重放是一种契约,而非便利
对于已完成的幂等请求,重放相同状态和响应体最不易混淆,如:
HTTP/1.1 201 Created Idempotent - Replayed: true Content - Type: application/json { "paymentId": "pay_789", "status": "PENDING", "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice - 7781" }从当前资源状态重新构建响应可能有问题,架构变更会使情况更糟。常见折衷方案是存储相关信息,只对需要精确重放的端点存储完整响应体。
队列消费者也有同样的问题
HTTP 请求受关注多,但很多重复副作用发生在消费者等中。假设支付服务发布事件,消费者接收到两次不应产生重复副作用。去重键可多种选择,消费者收件箱表可能如下:
consumer_inbox - consumer_name - message_id - status - processed_at - error_code unique(consumer_name, message_id)标记消息已处理不简单,通常做法是在发送副作用前持久化。分类账条目有自然幂等键。从消费者角度,生产队列集成多是至少一次交付,精确一次交付不意味着精确一次业务效果。外发/内联模式如下:
在同一个数据库事务中: insert payment row pay_789 insert outbox event PaymentCreated(pay_789) 发布者: 读取未发布的外发事件 发布带有 eventId 的事件 标记外发事件已发布 消费者: 根据 eventId 或业务操作键去重 在唯一约束后写入副作用幂等性可防止一些重复,但不能消除有害消息等问题。
过期是 API 契约的一部分
幂等记录通常不能永久存在,重放窗口是产品/API 决策。已完成记录过期后可删除响应体保留元数据。旧的 `IN_PROGRESS` 状态需单独处理,清理作业不应盲目删除正在进行中的记录。重放次数用于容量规划,相关指标可发现问题。
失败重放是一个策略决策
不能将每个失败都视为“可以安全重试”或“已完成”。纯粹语法验证失败通常无需幂等存储,业务拒绝需决定第一个决策是否对幂等键有约束力。确定性拒绝可能可重放,但账户余额变化时可能不适用。身份验证失败不应创建幂等记录,授权失败需谨慎。速率限制通常不应记录为已完成幂等结果。产生副作用前的服务器错误通常可重试,之后的错误危险。实用内部状态集如下:
IN_PROGRESS COMPLETED FAILED_REPLAYABLE FAILED_RETRYABLE UNKNOWN_REQUIRES_RECOVERY EXPIRED不要直接暴露每个内部状态,将失败简单分类会使恢复困难。
当一个事务无法涵盖操作时
有用区别是一个持久事务能否涵盖操作。若可以,本地部分简单;当副作用跨越边界,每个边界需有重复抑制规则。更好模型是维护稳定操作标识。主动 - 主动多区域部署中,区域本地幂等表有局限。高吞吐量 API 中,幂等表可能成热点路径,需分区处理。
何时不构建通用幂等层
成本在于持久记忆和恢复行为。对于重复操作无害且易发现的管理操作、只读操作、重复分析事件成本低且可下游纠正的情况,无需构建支付级幂等层。某些操作使用业务键更好,可改变资源模型。客户端能识别重试时,客户端生成的键有用。根据重复副作用危害等决定机制。
值得测试的失败模式
以下测试比理想情况单元测试更有价值:
相同键,相同规范命令,已完成
第一个请求创建支付,返回 `201 Created`,`paymentId = pay_789`。第二个相同请求返回相同存储结果,不创建新支付和发布新事件。
相同键,不同规范命令
两个请求键相同但金额不同,预期以稳定机器可读幂等冲突拒绝请求并记录统计。
两个并发的相同请求
同时启动两个相同键和命令的请求,一个请求获执行权,另一个等待或返回稍后重试响应,副作用只执行一次。
下游成功后超时
模拟提供商成功后客户端崩溃,重试请求不应使用新操作标识调用提供商,应找到本地状态或进入恢复流程。
队列中的重复消息
两次传递 `PaymentCreated(pay_789)` 事件,只创建一条分类账条目等,重试完成未完成工作。
过期或陈旧状态
在幂等记录过期、处于陈旧 `IN_PROGRESS` 状态、响应架构更改后或从另一个区域重试,这些是网络重试常见边界情况。
发布前的检查清单
- 拒绝使用相同作用域键但规范命令不同的请求。
- 对作用域键使用唯一约束或原子插入。
- 对经过验证的命令进行哈希,而不是对原始 JSON 字节进行哈希。
