幂等性难题:第二次请求不同时如何应对?
幂等性直到第二次请求不同时才变得复杂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 - AfterIN_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 CreatedpaymentId pay_789。第二个相同请求返回相同存储结果不创建新支付和发布新事件。相同键不同规范命令两个请求键相同但金额不同预期以稳定机器可读幂等冲突拒绝请求并记录统计。两个并发的相同请求同时启动两个相同键和命令的请求一个请求获执行权另一个等待或返回稍后重试响应副作用只执行一次。下游成功后超时模拟提供商成功后客户端崩溃重试请求不应使用新操作标识调用提供商应找到本地状态或进入恢复流程。队列中的重复消息两次传递 PaymentCreated(pay_789) 事件只创建一条分类账条目等重试完成未完成工作。过期或陈旧状态在幂等记录过期、处于陈旧 IN_PROGRESS 状态、响应架构更改后或从另一个区域重试这些是网络重试常见边界情况。发布前的检查清单拒绝使用相同作用域键但规范命令不同的请求。对作用域键使用唯一约束或原子插入。对经过验证的命令进行哈希而不是对原始 JSON 字节进行哈希。