用规格文档设计幂等性工作流
幂等性就是那种在规格里看起来只值一行字、一旦写错就能吞掉你一整周调试时间的特性。下面是我写"幂等性"章节的方式——让重试、崩溃、用户连点两下在生产环境里全都表现出同一种行为。
现场笔记:幂等也是客服规则
很多团队把幂等只写成 API 规则,却忘了客服后台。规格里应该写清请求 pending 时,人能做什么,尤其是涉及钱或库存的时候。
客服约束: Given 退款 R123 正在等待支付渠道确认 When 客服打开订单后台 Then 退款按钮不可用 And 页面显示已有 refund id And 客服可以添加备注,但不能创建第二笔退款
规格里的"幂等"到底必须表达什么
教科书的定义是"可以安全地重复"。对规格来说这远远不够——它把太多选择留给了写代码的人。我会强制要求规格先回答三个问题,其他事情都往后排:
- 什么样的请求算"同一个"?客户端传来的一个 key?规范化之后的 payload 哈希?还是(用户、资源、时间窗口)三元组?
- 结果要缓存多久?24 小时?直到实体状态发生转移?还是永远?
- 第二次调用看到的是什么?字节级别完全一致的原始响应?同样效果的新响应?还是错误?
如果这三个问题没有白纸黑字写下来,两个工程师实现同一个端点会有两种写法,而客户端团队要等到 PagerDuty 告警响起才会发现。
Idempotency-Key 契约
我的默认模式是:客户端提供一个 Idempotency-Key 请求头,推荐使用 UUIDv4,作用域限定在已认证的主体内。规格必须把这个 key 的六件事钉死:
- 必填还是可选。对于类支付操作,我会把它设为必填,缺失时直接 400 拒绝。对于"天然幂等"的操作(对完整资源做 PUT),可选就够了。
- 最大长度和字符集。255 字符,ASCII 可打印。其他一律在入口处拒掉。
- 唯一性作用域。按 API key?按用户?还是全局?我默认选"按 API 凭据"——既隔离了租户,又不强迫客户端做跨节点协调。
- 保留窗口。我会写一个确切的数字。"至少 24 小时"是我愿意接受的下限;涉及资金流动时我用"7 天"。
- 冲突语义。如果同一个 key 带着不同的请求体再次到达,返回 422 并附上
conflict_with_stored_request。绝不能悄无声息地成功。 - 并发重复处理。同一个 key 的两个请求并行到达时,一个胜出;另一个要么拿到 409,要么在行锁上阻塞然后返回缓存响应。
状态机转移,而不是布尔开关
我调过的大部分幂等性 bug,根源都是把一个操作当成"做了/没做"的布尔量,而不是状态机。一笔支付不是从 false 翻成 true——它会经过 pending → authorized → captured → settled,或者 pending → failed。每一个转移本身都得是幂等的。
把状态机写进规格。给每个状态命名,列出允许的转移,并且对每个转移写清楚"重试会发生什么":
pending → authorized : retry returns the same auth_id authorized → captured : retry checks existing capture_id, returns it authorized → voided : retry is a no-op if already voided pending → failed : retry returns the original failure_reason
这样评审者就可以问:"当状态是 authorized、但 capture worker 正在半路时,如果收到一个重试会怎么样?"——答案必须能在图里查到,而不是躲在实现者的脑子里。
去重窗口 vs. 幂等性保证
这两个概念经常被混为一谈,规格里值得把它们明确区分开。去重窗口是一个有时限的缓存:我们把这个 key 记 24 小时。幂等性保证是一条正确性属性:在这个窗口内,操作只会恰好发生一次。
窗口之外,行为会变。带着同一个 key 的新请求,变成了一次全新的操作。规格必须说明这对下游副作用意味着什么——用户 25 小时后重试,会被扣两次钱吗?有时候这可以接受(比如信用卡 token 保存);有时候则是合规问题(比如支付扣款)。必须把话说明白。
我强制要求规格覆盖的那些棘手场景
- 副作用中途部分失败。DB 写成功了,Webhook 没发出去。重试应该做什么?我的默认答案:幂等层直接返回缓存好的成功响应,由独立的 outbox 去重试那个 Webhook。
- 时钟偏移和乱序重试。由于网络重排,带 key K 的请求 B 比带同一个 K 的请求 A 先到。规格必须说清楚谁"胜出"(以提交先后为准,不是发送先后)。
- 中间件篡改请求。如果代理加了请求头或剥离了字段,body 哈希就会变。规格要说明 key 是针对原始 body、规范化后的 body,还是仅针对 key 字符串本身。
- 缓存响应过期。窗口一关,这个 key 立刻可以复用,还是存在一段宽限期在此期间返回 410 Gone?
- 跨区域复制延迟。如果重试落到了一个还没同步到原始请求的区域,它应该阻塞、失败,还是再跑一遍操作?给出答案。
能抓住真 bug 的验收标准
泛泛的"它是幂等的"这种验收标准什么都抓不住。下面这种才行:
- Given a POST /payments request with Idempotency-Key "abc" When the client retries with the same key and identical body Then the response is byte-for-byte identical to the original And no second charge appears in the payment processor - Given a POST /payments request with Idempotency-Key "abc" When the client retries with the same key but a different amount Then the response is 422 with code "idempotency_key_conflict" And the body contains the stored request summary - Given two concurrent POST /payments requests with the same key When both reach the server within 100ms Then exactly one charge is created And both requests receive the same response body
这些可以直接翻译成集成测试。如果实现者让这些用例跑不过,那就是设计错了——不是测试错了。
规格必须要求的可观测性
幂等性 bug 在普通日志里是隐形的。你把客户多扣了一次,而请求日志里显示两次 200 都成功了。规格必须要求这些让"重复"可见的遥测:
- 一个指标,区分"从缓存回放的幂等请求"与"新操作"。
- 一个计数器,统计 idempotency-key 冲突(同 key,不同 body)。
- 结构化日志,每次副作用都打上这个 key,这样你可以直接 grep 重复写入。
- 一条告警,针对某客户异常的重放率——通常意味着他们的重试逻辑坏了。
我从规格里砍掉的东西
我不会指定存储后端。"Redis 加 24 小时 TTL"是实现注记,不是契约。规格里写的是"系统必须在至少 24 小时内返回原始响应"——至于是 Redis、DB 表还是分布式缓存,是团队自己的选择,改了也不用动契约。
我也不会指定 body 比对用的哈希算法,除非这个哈希值本身会暴露给客户端。如果它完全是内部用途,团队愿意用 SHA-256 还是 xxhash 随他们。
给这一章节的"一句话测试"
我在签字批准幂等性章节之前,会重读一遍然后问自己:另一个团队的新工程师能不能不问我一个问题就把它实现出来?如果答案是不能,这一章节就还没写完。
评审时看什么
这篇文章适合用在幂等工作流设计评审时。别从“原则”聊起,直接拿一条真实改动来对照,看看规格里还缺什么。
- 定义幂等键从哪里来、存多久。
- 重复请求返回旧结果还是重新执行。
- 超时后客户端能否安全重试。
- 日志里能否区分首次执行和重复命中。
幂等不是“防重复”四个字。它是一组可测试的状态转移,必须写到规格里。
幂等规格要规定“第二次请求返回什么”
只写“使用 idempotency key”不够。第二次请求是返回第一次的成功结果、返回处理中状态,还是拒绝参数不一致?这些都要写进契约,否则客户端会自己猜重试策略。
Idempotency behavior: - same key + same payload + completed: return original 200 and refund_id - same key + same payload + pending: return 202 with retry_after - same key + different payload: return 409 idempotency_key_conflict - key retention: 48 hours - storage: hash of normalized payload, response status, response body - evidence: concurrent request test proves only one provider call
边界:幂等不是去重所有重复请求。它只保护同一意图的重试。不同 payload 共用同一个 key 必须报错。
把参数不一致当成正式错误
幂等键复用但 payload 不同,不能悄悄按第一次处理,也不能覆盖旧结果。规格要给出正式 error code、HTTP 状态、日志字段和报警规则。这个错误通常意味着客户端 bug,应该尽早暴露。
可以附在规格里的幂等评审表
只要一个流程可能被浏览器、定时任务、Webhook 提供方、客服或 AI 工具重试,我都会要求补这张表。任何一列空着,都说明实现还没到可评审状态。
| 重试来源 | 相同 key 行为 | 新 key 行为 | 证据 |
|---|---|---|---|
| 用户重复点击提交 | 返回原始结果,不创建第二条记录。 | 如果第一次操作仍在 pending,拒绝新操作。 | UI 测试和审计日志查询。 |
| Webhook 提供方重放事件 | 保存 event_id,跳过已应用副作用。 | 只有 provider id 不同时才当作新事件。 | 重复投递 replay 测试。 |
| 客服手动重试 | 先展示已有操作状态,再允许动作。 | 不可逆操作需要主管 override。 | 操作手册截图和权限测试。 |
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
二次审阅记录:幂等要覆盖重试和人工操作
这次补了一个运营角度。重复防护只有在自动重试和人工操作都遵守同一套状态模型时才算完整。
幂等评审: - 什么 key 定义“同一个请求”? - key 保留多久? - replay 返回什么? - 原请求未结束时,客服能做什么?
编辑说明
本文面向软件交付团队,介绍用规格文档设计幂等性工作流。示例均为工程场景说明,不构成法律、税务或投资建议。
- 作者信息:Daniel Marsh
- 编辑政策:文章审阅与更新方式
- 纠错:联系编辑