幂等性看似简单,直到第二次请求出现差异

Hacker News Top 工具

摘要

本文探讨了在API中实现幂等性的复杂性,指出处理并发请求和内容不匹配等边缘情况,比简单的重放缓存更为困难。

暂无内容
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 2026/05/10 09:41

# 幂等性:直到第二个请求不同时,它才显得容易 | Dochia CLI 博客 来源:https://blog.dochia.dev/blog/idempotency/ --- 人们谈论幂等性时,仿佛这是一个已经解决的问题: > 在请求上放置一个 `Idempotency-Key`。存储响应。在重试时重放它。 是的,这确实可行。对于理想路径(happy path),它甚至相当简单。 客户端发送: ``` POST /payments Idempotency-Key: abc-123 Content-Type: application/json ``` ```json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" } ``` 服务器检查是否见过 `abc-123`。如果没有,它创建支付。如果有,它返回之前的响应。 这种版本能熬过演示环节。 我反驳的是“这才是难点”这一观点。并非如此。难点始于第二个请求,因为第二个请求并不总是第一个请求的干净重放。 也许它是一个完成的重放。没问题。返回存储的结果。 也许它在第一个请求仍在运行时到达。现在你的幂等层成了并发控制的一部分。 也许第一个请求创建了本地支付,但在发布事件之前崩溃了。现在本地记录与外部副作用不同步。 也许第一个请求调用了支付提供商,提供商接受了它,但你的进程在记录结果之前死亡了。现在你的数据库无法推断资金是否已转移。 或者,也许第二个请求拥有相同的键但内容不同: ```json { "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" } ``` 相同的键。不同的金额。 正是这种情况让幂等性变得有趣。这是重试吗?是客户端 bug 吗?是新操作吗?服务器应该重放旧响应、拒绝请求,还是将 `(key + content)` 视为新的身份? 如果你能清晰记录文档,你可以选择其中任何策略。但服务器应该有自己的“意见”。不一定是我的意见,但必须明确。 对于具有副作用的 API,我的倾向是:相同作用域的键加上不同的规范命令应该是一个硬错误。这能尽早捕获客户端 bug。如果一个客户端认为它正在安全地重试 10 欧元的支付,服务器不应该默默地将第二个请求解释为其他东西。 真正重要的情况是那些重放缓存无法解释的情况: - 已完成的重放 - 并发重试 - 部分本地成功 - 下游未知状态 - 相同键但具有不同的规范命令 - 没有键的重复操作 - 过期后的重试 - 部署、模式变更、服务跳转或区域故障切换后的重试 如果你的设计只处理已完成的相同命令重试,那它只是一个重放缓存。这对于某些端点可能足够了。但这并不是问题的全部。 ## 幂等性关乎影响 如果一个操作应用一次或多次产生相同的预期影响,那么它是幂等的。 这个定义很简单。做所有工作的词是“影响”。 HTTP 提供了方法级别的语义。`PUT /users/123/email` 可以是幂等的,如果反复发送相同的表示使资源保持相同状态。`DELETE /sessions/456` 可以是幂等的,如果删除已删除的会话仍然意味着“会话不存在”。重复 `DELETE` 可能会返回 `404`;影响仍然可以是幂等的。 但是,你的处理器仍然可能产生业务关心的重复副作用:重复的审计记录、重复的领域事件、重复的电子邮件、重复的提供商调用,或影响计费或欺诈逻辑的重复指标。 `POST` 通常默认不是幂等的,但如果服务器存储并强制执行正确的行为,它可以变得幂等。键标识一个声明的操作。它不定义请求等价性、重放策略或下游去重。 唯一性约束可以防止一类重复。但它本身并不能给客户端提供正确的重试结果。 例如,`unique(account_id, merchant_reference)` 可能防止两个支付行,但如果重试收到通用的 `500`,客户端仍然不知道支付是否成功。如果行存在但响应不同,或者事件发布两次,或者分类账条目重复,那么对于调用者关心的方式来说,操作并不是幂等的。 ## 你需要记住什么 对于 `POST /payments`,持久的幂等记录需要回答三个问题: 1. 谁拥有这个键? 2. 第一个命令是什么意思? 3. 可以重放什么结果? 在类似 PostgreSQL 的 SQL 中,一个最小的表可能看起来像这样: ```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) ); ``` 除非你故意使其全局唯一,否则键不是全局唯一的。通常它不应该这样。一个生成 `abc-123` 的损坏客户端应该只与自身冲突,而不是与其他租户冲突。 作用域可能是租户、用户、账户、商户、API 客户端或某些组合。请刻意选择它。 操作名称防止在不同操作之间意外重用。用于 `create_payment` 的键不应该自动意味着对 `create_refund` 具有相同的含义。 `request_hash` 是服务器对第一个命令的记忆。没有它,相同键加不同正文就变得模棱两可。你要么为重播不同的命令重放第一个响应,要么在旧键下执行新操作。如果客户端认为它在重试,这两者都是不好的。 `IN_PROGRESS` 不是一个内部细节。当第一个请求仍然拥有执行权时,重试可能会到达。 行为需要明确: | 现有记录 | 相同规范命令? | 建议行为 | | :--- | :--- | :--- | | 无 | 是 | 插入 `IN_PROGRESS` 并执行 | | `COMPLETED` | 是 | 重放存储的响应或文档规定的等效响应 | | 任何现有记录 | 否 | 拒绝并返回幂等性冲突 | | `IN_PROGRESS`,新鲜 | 是 | 等待,返回 `202`,或返回 `409` + `Retry-After` | | `IN_PROGRESS`,陈旧 | 是 | 恢复所有权;不要盲目再次执行 | | `FAILED_REPLAYABLE` | 是 | 重放存储的失败 | | `FAILED_RETRYABLE` | 是 | 根据策略允许重试 | | `UNKNOWN_REQUIRES_RECOVERY` | 是 | 触发协调或返回待定/恢复状态 | | 过期/已删除 | 未知 | 遵循文档规定的过期行为 | 响应字段存在是因为幂等性不仅仅是为了防止重复写入。客户端需要一个答案。 你可以存储完整的响应正文,或者存储对创建资源的引用并重建响应。这两种选择在方式上都令人烦恼。 存储完整响应可以实现忠实的重放。它也可以保留 PII(个人身份信息)、签名 URL、一次性令牌、持卡相关数据,或你从未打算在重试表中保留的字段。 从资源引用重建可以节省空间,但如果资源在创建后发生变化,它可能会返回不同的表示。 这是一个契约决策。“重放创建响应”和“返回当前支付”都是有效的 API 设计。它们不是相同的设计。 ## 相同键,不同命令 这是幂等层应该大声捕获的 bug。 第一个请求: ```json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "merchantReference": "invoice-7781" } ``` 第二个请求: ```json { "accountId": "acc_1", "amount": "100.00", "currency": "EUR", "merchantReference": "invoice-7781" } ``` 相同的 `Idempotency-Key: abc-123`。不同的金额。 无论如何返回原始响应很简单。它也隐藏了一个严重的客户端 bug。客户端请求了 100 欧元的支付,却收到了 10 欧元的支付。如果调用者没有仔细比较响应,它可能会认为 100 欧元的支付成功了。 这不是幂等性。这是重新解释。 对于具有副作用的 API,无论第一个操作是完成、失败还是仍在运行,用不同规范命令重用的作用域键应该是一个硬错误。 ``` HTTP/1.1 409 Conflict Content-Type: application/json ``` ```json { "errorCode": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST", "message": "This idempotency key was already used with a different request." } ``` `409 Conflict` 是一个合理的默认值,因为请求与该作用域键的服务器记忆含义冲突。有些 API 使用 `400` 或 `422`;重要的是稳定的机器可读错误,以及没有对不同命令的静默重放。 一个常见的客户端 bug 如下所示: ```text bad: idempotencyKey = cartId POST /payments amount=10.00 key=cart_123 POST /payments amount=15.00 key=cart_123 better: idempotencyKey = paymentAttemptId ``` 服务器不应该猜测购物车键应该代表哪个支付。 你可以设计一个 API,其中 `(key + content hash)` 定义操作身份。这是一个有效的策略。但是,那么键就不再是通常重试意义上的幂等键。它是复合操作标识符的一部分。这对客户端来说需要很明显。 危险的版本是中间地带,客户端认为它正在安全地重试一个操作,而服务器默默地将第二个请求解释为另一个操作。 ## 哈希命令,而不是字节 对于 JSON API,原始字节比较通常过于严格。这两个正文通常应该是等价的: ```json { "amount": "10.00", "currency": "EUR" } ``` ```json { "currency": "EUR", "amount": "10.00" } ``` 字段顺序和空格不应该重要。 默认值不太明显: ```json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR" } ``` 对比: ```json { "accountId": "acc_1", "amount": "10.00", "currency": "EUR", "channel": "web" } ``` 如果 `channel: "web"` 是服务器默认值,这些是相同的逻辑命令吗?也许吧。在哈希之前决定。 未知字段是另一个陷阱。假设你的 API 忽略未知的 JSON 字段。如果第一个请求包括 `"foo": "bar"` 而第二个不包括,你认为它们是相同的吗?如果未知字段真正被忽略,也许是的。如果它们在部署后可能变得有意义,也许不是。 实用的规则是:哈希经过验证的命令,而不是原始 HTTP 正文。 合理的流程是: 1. 将请求解析为版本化的请求 DTO 或命令。 2. 规范化你的 API 视为等价的值:金额、枚举大小写、默认字段、时间戳精度。 3. 排除仅传输的元数据。 4. 包括路径参数和操作名称。 5. 如果它们影响操作,包括语义标题,例如 API 版本。 6. 如果标题仅影响响应形状,例如 `Prefer: return=minimal`,决定它是否属于命令哈希、重放契约或两者皆非。 7. 排除 `Authorization` 和幂等键本身。 8. 规范序列化。 9. 使用稳定的算法进行哈希。 对于支付示例,指纹可能包括: ```text 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 ``` 相同的规范命令。相同的租户。相同的端点。 即使每个单线程测试都通过,此实现也是损坏的: ```python existing = find_by_key(key) if existing does not exist: create_payment() insert_idempotency_record() ``` 两个请求都可以观察到没有现有行。两者都可以执行副作用。 如果没有针对作用域键的原子插入或唯一约束,两个实例都可以决定它们拥有执行权。 先插入的形状是: ```sql 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; ``` 确切的语法取决于数据库。重要的属性是 `(tenant_id, operation_name, idempotency_key)` 的原子所有权获取。 然后: ```python 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 ``` 恢复所有权也必须原子地获取。否则,两个重试都可以决定旧所有者已死,并且都开始恢复。 在简单的本地情况下,所有者可以在一个事务中创建支付并完成幂等记录: ```sql begin transaction insert idempotency row as IN_PROGRESS insert payment row pay_789 insert outbox event PaymentCreated(pay_789) update idempotency row: status = COMPLETED resource_type = payment resource_id = pay_789 response_status = 201 response_body = {...} commit ``` 这是美好的一版:一个数据库事务覆盖幂等行、业务行和出站事件。 外部副作用改变了形状。在调用提供商时保持数据库事务打开通常是个坏主意。在提供商调用之前提交意味着你的本地状态可能在事务外部继续执行时显示 `IN_PROGRESS`。如果进程在那里崩溃,重试必须恢复。这就是你需要操作状态机和恢复工作器,而不仅仅是请求表的地方。 Redis `SET NX EX` 经常被提议为整体解决方案。充其量,它是一个执行守卫: ```bash SET idempotency:tenant_1:create_payment:abc-123 value NX EX 30 ``` 它可以减少重复的并发执行。它不是操作结果的持久内存。如果 Redis 锁在提供商调用仍在运行时过期,另一个请求可以进入。如果进程在提供商成功后但在存储响应之前死亡,锁无助于重试了解发生了什么。Redis 锁如果保护下游资源,也需要围栏或持久所有权。 Redis 可能有用。它不能替代记住操作结果。 ## 提供商超时是保证结束的地方 重要的失败路径并不奇特: 1. API 接收 `POST /payments`。 2. 它将幂等行插入为 `IN_PROGRESS`。 3. 它创建本地支付 `pay_789`。 4. 它调用下游支付提供商。 5. 提供商接收请求并成功。 6. API 超时、崩溃或丢失...

相似文章

异步编程的承诺与现实

Hacker News Top

深入剖析异步编程模型的演进——从回调到 Promise——揭示每一轮迭代如何解决先前的资源与性能问题,同时带来新的易用性挑战。

AI推理遵循着截然不同的规则(9分钟阅读)

TLDR AI

文章指出AI推理对云数据基础设施提出了独特挑战,其需求更接近高并发OLTP系统,而非传统面向人类速度的应用。文章强调需要优化存储和数据访问层,以应对自主智能体驱动的"AI数据海啸"。

Gemini API 中平衡成本与可靠性的新途径

Google AI Blog

Google 为 Gemini API 推出了 Flex 和 Priority 推理层,为开发者提供了对同步请求成本与可靠性的精细控制。Flex 可为对延迟不敏感的任务节省 50% 的成本,而 Priority 则可确保关键应用的高可靠性。

面向大语言模型/视觉语言模型强化学习的鲜度感知优先经验回放

arXiv cs.CL

# 面向大语言模型/视觉语言模型强化学习的鲜度感知优先经验回放 来源:[https://arxiv.org/html/2604.16918](https://arxiv.org/html/2604.16918) Weiyu Ma1 Yongcheng Zeng2 Yan Song3 Xinyu Cui2 Jian Zhao4 Xuhui Liu1 Mohamed Elhoseiny1 1 阿卜杜拉国王科技大学 (KAUST) 2 中国科学院自动化研究所 (CASIA) 3 伦敦大学学院计算机科学系人工智能中心 4 中关村人工智能研究院 weiyu\.