用规格文档设计幂等性工作流

用规格文档设计幂等性工作流
Daniel Marsh · Spec-First 工程笔记

幂等性就是那种在规格里看起来只值一行字、一旦写错就能吞掉你一整周调试时间的特性。下面是我写"幂等性"章节的方式——让重试、崩溃、用户连点两下在生产环境里全都表现出同一种行为。

发布于 2026-03-15 · ✓ 已更新 2026-05-11 · 阅读约 7 分钟 · 作者:Daniel Marsh · 审校:编辑政策

现场笔记:幂等也是客服规则

很多团队把幂等只写成 API 规则,却忘了客服后台。规格里应该写清请求 pending 时,人能做什么,尤其是涉及钱或库存的时候。

客服约束:
Given 退款 R123 正在等待支付渠道确认
When 客服打开订单后台
Then 退款按钮不可用
And 页面显示已有 refund id
And 客服可以添加备注,但不能创建第二笔退款

规格里的"幂等"到底必须表达什么

教科书的定义是"可以安全地重复"。对规格来说这远远不够——它把太多选择留给了写代码的人。我会强制要求规格先回答三个问题,其他事情都往后排:

如果这三个问题没有白纸黑字写下来,两个工程师实现同一个端点会有两种写法,而客户端团队要等到 PagerDuty 告警响起才会发现。

Idempotency-Key 契约

我的默认模式是:客户端提供一个 Idempotency-Key 请求头,推荐使用 UUIDv4,作用域限定在已认证的主体内。规格必须把这个 key 的六件事钉死:

状态机转移,而不是布尔开关

我调过的大部分幂等性 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 保存);有时候则是合规问题(比如支付扣款)。必须把话说明白。

我强制要求规格覆盖的那些棘手场景

能抓住真 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 都成功了。规格必须要求这些让"重复"可见的遥测:

我从规格里砍掉的东西

我不会指定存储后端。"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。操作手册截图和权限测试。
关键词:Idempotency-Key · 去重窗口 · 状态机规格 · 重试语义 · API 契约

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

交互式生成规格
填写表单,生成完整的功能规格 Markdown——免费使用,无需注册。
试用规格生成器

二次审阅记录:幂等要覆盖重试和人工操作

这次补了一个运营角度。重复防护只有在自动重试和人工操作都遵守同一套状态模型时才算完整。

幂等评审:
- 什么 key 定义“同一个请求”?
- key 保留多久?
- replay 返回什么?
- 原请求未结束时,客服能做什么?

编辑说明

本文面向软件交付团队,介绍用规格文档设计幂等性工作流。示例均为工程场景说明,不构成法律、税务或投资建议。