幂等性设计:唯一ID、状态机与乐观锁的三层防线

重复提交、乱序回调、并发覆盖——三类故障各需要一层防线。用四组 Go 实测数据拆解唯一ID、状态机、乐观锁三层防线,以及层间配合真正的坑。

封面

导读:用户点了一次支付,扣了两次款。这是幂等性缺失。本文拆解三层防线——唯一ID、状态机、乐观锁,各防一类故障,以及层间配合的坑。

做支付模块联调那阵子,一次压测里前端超时重试,同一个下单请求连着打进来三遍。翻日志的时候我盯着订单表愣了两秒——三笔订单,金额一模一样,时间戳间隔都在几十毫秒内。

这是幂等性问题:同一个操作被执行了多次,产生了多个副作用。支付、下单、退款、转账,任何一个写操作在分布式系统里都可能被重试,如果系统不防重复,轻则数据脏,重则用户的钱被扣两次。

市面上的文章讲幂等,多数只讲一个技巧:加个幂等键、建个状态机、或者用乐观锁。但真到了生产环境,你往往发现单靠一层根本挡不住。这篇文章把幂等拆成三层防线,每层防一类不同的故障。三层防线配齐,再加上层间配合与对账兜底,才能说你的写操作是幂等的。

1. 一笔订单的三次事故

先看一个贯穿全文的例子。一笔支付订单,从创建到完成,会经历三类故障。

1.1 事故一:重复提交

用户下单,前端提交创建订单请求。网络超时,用户以为没成功,又点了一次。两次请求内容完全一样——就是开场那笔 998 的订单,订单表里躺着两笔,用户被扣了两次款。

这是重复问题:同一个请求到达了多次。

用户重复点击支付导致重复扣款

1.2 事故二:乱序回调

订单创建后,支付渠道会回调通知支付结果。支付成功回调先发出,但由于网络原因晚到了;发货系统的回调反而先到(回调来自内部发货系统,不是支付渠道)。如果系统直接按回调内容改状态,订单会先变成"已发货",再被支付回调改回"已支付"。状态来回跳,中间出现"未支付却已发货"的错误状态。

这是乱序问题:请求到达的顺序和业务期望的顺序不一致。

1.3 事故三:并发改单

运营和用户同时操作同一笔订单。运营要改金额,用户要取消。两个请求同时读到订单当前状态,各自计算,然后各自写回。后写的覆盖先写的,其中一个操作静默丢失。

并发:多个请求同时修改同一份数据,后写覆盖先写。

1.4 三层防线总览

三类故障,三个本质不同的成因,需要三层不同的防线:

防线 防什么 核心机制
唯一ID 重复 每个请求带唯一标识,服务端只处理一次
状态机 乱序 状态只能按合法路径迁移,非法迁移拒绝
乐观锁 并发 带版本号的更新,版本不符则失败

三层防线总览——三道闸门各防一类故障

三层分工清晰:重复、乱序、并发,各管一类故障。你只上第一层,防住了重复,但防不住乱序和并发。这也是为什么说幂等是一个体系。

澄清一个常见混淆:幂等和事务不是一回事。事务保证"要么全做要么全不做"(ACID 的原子性),解决的是"执行到一半失败"的问题;幂等保证"做多次和做一次效果相同",解决的是"同一个操作被重复执行"的问题。两者互补——一个写操作应该既在事务里(原子性),又带幂等键(可重试)。单靠事务的 rollback 无法解决重复请求:两个请求各自开事务、各自成功提交,结果就是两份数据,事务并不知道这两个请求是同一个操作。另外按 HTTP 语义(RFC 9110),POST 天然不幂等——幂等键正是给 POST 类操作补上幂等语义;GET/PUT/DELETE 则天然幂等,不需要幂等键。

2. 第一层:唯一ID 防重复

2.1 什么是唯一ID

唯一ID(也叫幂等键、Idempotency Key)是客户端在每次请求时生成的一个全局唯一标识。服务端看到同一个唯一ID,就知道这是同一个请求的重试,而不是新请求。Google Cloud 也采用类似机制:Cloud Tasks 对每个任务分配唯一 name,重复投递由服务端去重。

它的经典实现是 Stripe 的 Idempotency-Key 模式:客户端生成一个 UUID 放进请求头。服务端首次收到时执行业务并保存结果(状态码 + 响应体);后续收到相同 key 的请求,直接返回保存的结果,不再执行。

唯一ID 的生成有两种选择:客户端生成和服务端生成。客户端生成(Stripe 模式)零额外请求,天然支持重试——客户端重发时带上同一个 key 即可。风险是客户端可能伪造或误用 key(拿 A 的 key 发 B),所以服务端要校验 key + 请求内容的一致性(Stripe 保存首次请求参数哈希,后续参数不同直接报错)。服务端生成(下发 token 模式)可控性更好,key 由服务端签发,客户端只负责透传;代价是创建订单前多一次"获取 token"的调用,且 token 过期后客户端没法重试同一个操作。支付类场景我倾向客户端生成 + 参数校验:重试能力是幂等的核心价值。

2.2 唯一ID 怎么做

服务端需要一张幂等表,key 是唯一ID,加唯一约束:

CREATE TABLE idempotency (
  idempotency_key VARCHAR(255) PRIMARY KEY,
  request_hash   VARCHAR(64)  NOT NULL,
  response_body  TEXT         NOT NULL,
  created_at     TIMESTAMP    DEFAULT NOW()
);

处理逻辑:

收到请求 → 查幂等表(按 key)
  ├─ 存在 → 返回首次结果(不再执行)
  └─ 不存在 → 执行业务 → 写入幂等表(无论成败都记录首次结果;key 唯一约束保证并发只成功一个)→ 返回结果

关键点:唯一约束是并发安全的保证。两个重复请求同时到达,都查不到记录,都执行业务,但写表时唯一约束会让一个成功一个失败——失败的要么返回成功者的结果,要么重试查表。

幂等键处理流程——登记处盖章去重

失败也写入(记录失败结果),重试返回同样的失败,避免重试重新执行业务产生部分副作用。这一层把"重复执行"拦在了幂等表 + 数据库唯一约束的双保险上。

2.3 实测数据

我跑了一组 Go 实验验证这个机制([实测 Go 1.26.2]):模拟同一请求并发重发 100 次,对照组无幂等键,实验组有幂等键表(唯一约束 + 首次结果缓存)。

对照组A(无幂等键): 重发 100 次 → 订单数 100(重复入账 99 笔)
实验组B(有幂等键): 重发 100 次 → 订单数 1(重复入账 0 笔)
第 101 次重试同 key 同参数: 返回订单 order-1, 新建=false, 状态=REPLAY
同 key 不同金额: PARAM_MISMATCH: 同 key 不同参数,服务端拒绝

结论很直接:无幂等键,重发 100 次产生 100 个订单;有幂等键,100 次只有 1 个。另外注意最后一行——同 key 不同参数会被拒绝,这是 Stripe 也强调的防误用机制:key 必须绑定请求内容,防止客户端拿一个 key 发不同请求。

E1 实测数据——100 笔 vs 1 笔

2.4 幂等键的存储与过期

幂等表放哪?两个选择:

  • 数据库(强一致):金融级场景,可靠但稍慢;幂等表在高峰期是高频读写热表,需规划清理任务(定时批删或惰性删除)并监控表膨胀
  • Redis(高速缓存):性能好,但有过期窗口

Stripe 的官方做法是 key 至少保留 24 小时,之后可移除。窗口怎么定?看你的业务重试窗口——我认为幂等键的保留时间必须大于所有可能的重试间隔(比如客户端最坏 3 天后重试,就要设 72 小时);窗口内 key 被清理,重试会被当成新请求重复执行。

2.5 第一层还差什么

唯一ID 防住了"重复",但防不住"乱序"和"并发"。

乱序回调到达时,唯一ID 只能保证"同一个回调只处理一次",但两个不同的回调(支付成功、发货)谁先到谁后到,唯一ID 管不了。回调侧也没有业务方幂等键可用(回调请求由渠道生成,key 由渠道决定),所以状态机是回调幂等的兜底。并发改单时,两个不同的请求(改金额、取消)用的是不同的 key,唯一ID 也拦不住。

所以需要第二层。

3. 第二层:状态机防乱序

3.1 什么是状态机

订单状态不是随便改的,只能按定义好的方向走。状态机就是给数据定义一套合法的状态迁移路径:

pending(待支付)→ paid(已支付)→ shipped(已发货)
pending → canceled(已取消)

paid → pending 是非法迁移,已支付不能退回待支付。shipped → paid 也是非法迁移,已发货不能改回已支付。状态机的作用就是:任何状态变更都先校验迁移合法性,非法的一律拒绝。

3.2 状态机怎么做

var legalTransitions = map[State]map[State]bool{
	Pending:   {Paid: true, Canceled: true},
	Paid:      {Shipped: true, Refunding: true},
	Refunding: {Refunded: true},
	Shipped:   {},
	Canceled:  {},
	Refunded:  {},
}

func valid(from, to State) bool {
	return legalTransitions[from][to]
}

每次状态更新前,先跑 valid(current, target),不合法直接拒绝。

3.3 实测数据

我模拟了支付回调乱序到达的场景([实测 Go 1.26.2]):业务顺序是"支付成功→发货",但网络让发货回调先到了。

对照组A(无状态机,直接改状态):
  收到发货回调→shipped: pending → shipped
  收到支付成功回调→paid: shipped → paid
  >>> 订单先被改成了已发货(支付还没成功),
  >>> 随后支付回调又把状态改回已支付——状态来回跳,
  >>> 中间出现过"未支付却已发货"的错误状态。

实验组B(有状态机,只允许合法迁移):
  收到发货回调→shipped: pending → shipped ❌ 非法迁移被拒(订单保持 pending)
  收到支付成功回调→paid: pending → paid ✅ 合法迁移
  >>> 发货回调被拒(尚未支付不能发货),状态回到正确轨道

对照组的状态来回跳,最终状态虽然回到了 paid,但中间出现过"未支付却已发货"。如果发货动作在状态变更的瞬间已经被触发(比如状态变更后立刻通知仓库),这个错误状态就是真实事故。实验组把非法迁移直接拒绝,乱序回调被消化,不会造成错误状态。

状态机还有一个隐藏好处:重复回调天然幂等。同一个 paid 回调到达三次,第一次 pending→paid 合法,后两次 paid→paid 不合法被忽略,重复被状态机顺带防住了。 订单状态机流转——单向轨道上的交通指挥

3.4 第二层还差什么

状态机防住了"乱序",但防不住"并发"。

两个并发请求同时读到订单状态是 paid,都想迁移到 refunding。状态机校验时都通过(paid→refunding 合法),然后两个都执行更新——后写覆盖先写,最终只生效一个,另一个静默丢失。状态机管的是"迁移路径合法",管不了"两个请求同时迁移"。

第二层管不住并发,所以需要第三层。

4. 第三层:乐观锁防并发

4.1 什么是乐观锁

乐观锁的思路是:更新时带上版本号,版本号对不上就拒绝。数据表加一个 version 字段,每次更新 WHERE version = ?SET version = version + 1。如果影响行数是 0,说明版本号已变,有别人改过了——你的更新基于过期数据,拒绝。

有个常见质疑:乐观锁冲突后要重试,失败率高,不如悲观锁(SELECT ... FOR UPDATE)锁住行来得直接。这个质疑混淆了"冲突"和"失败"。乐观锁的冲突不是失败——是系统在告诉你"有并发",重试是正常的业务路径;悲观锁是把并发串行化,靠等待消除冲突,代价是持有行锁期间所有其他操作排队。高并发下悲观锁的锁等待会放大延迟,乐观锁的失败重试通常是更优解。但重试不是免费的:冲突频繁(如热点行)时,重试会消耗连接与 CPU,此时悲观锁反而更简单——所以乐观锁适合"冲突偶尔发生",悲观锁适合"冲突几乎必然"(比如两个请求必然改同一行)。

4.2 乐观锁怎么做

-- newVal 由应用侧基于旧快照计算(CAS 参数化写回)
UPDATE accounts
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?;
// 读
old, ver := acc.balance, acc.version
// 算
newVal := old - deduct
// 写(UPDATE ... WHERE version = ?)
if acc.version == ver {
	acc.balance = newVal
	acc.version++
} // 版本不符 → 0 行更新 → 冲突

4.3 实测数据

我模拟了两组并发扣款([实测 Go 1.26.2])。对照组不做并发控制,read-modify-write 后直接写回;实验组用乐观锁,版本校验,冲突就重试:

场景1(2笔200元并发扣款,余额1000,期望600):
  对照组A: 最终余额 800,丢了一笔扣款(本应 600)
  实验组B: 最终余额 600,冲突重试 1 次,两笔都成功

场景2(10笔并发扣款,余额2000,期望0):
  对照组A: 最终余额 1800,只生效 1 笔,9 笔丢失
  实验组B: 最终余额 0,冲突重试 45 次,10 笔全部生效

对照组的数据很扎眼:10 笔并发扣款,9 笔静默丢失,每个请求都读到旧值,各自算完写回,后写覆盖先写。乐观锁把冲突显式暴露出来,全部扣款最终生效(实验中重试了 45 次)。生产环境必须设重试上限和退避策略(指数退避 + 抖动):45 次是实验语义下"直到全部成功"的理想化结果,真实系统里超限应返回失败并告警,而不是无限重试。 并发扣款冲突——两个请求抢写一本账

乐观锁的失败语义同样重要。冲突时不重试,直接失败:

事务1 成功=true;事务2 成功=false(版本冲突,0 行更新)
>>> 失败语义:返回 409/更新 0 行,上层决定重试或提示用户——不会静默丢扣款

乐观锁失败只是并发信号:收到失败信号,上层可以重试(如果业务允许)、可以提示用户、可以记日志。数据不会丢,只是需要处理。

4.5 第三层还差什么

乐观锁防住了"并发改同一行",但有个它管不了的窗口:先调外部渠道、后落库

真实支付系统里,退款有"先渠道后落库"和"先落库后渠道"两种做法。先渠道后落库是朴素方案:先调用支付渠道(打钱),成功后才更新本地状态。如果渠道调用成功、本地更新前进程崩溃,状态还是 paid——上层重试会再次调用渠道,重复退款。这个方案有缺陷,但恰好能暴露崩溃窗口;先落库后渠道(Saga 式状态标记)才是正确姿势,第 5.4 节会展开。乐观锁此时毫无办法,因为并发本身没有发生,是崩溃 + 重试。

乐观锁的边界在外部调用上。

5. 层间配合:真正的坑

5.1 乐观锁 × 重试 = 重复退款

上面说的崩溃窗口,我专门做了实验验证([实测 Go 1.26.2]):

场景:并发退款请求,第一个在"调渠道后、落库前"崩溃
对照组(无幂等键,盲目重试): 渠道退款调用次数: 2
实验组(幂等键拦截重试)    : 渠道退款调用次数: 1

对照组把渠道退款调了两次——用户收到两笔退款,钱直接打错。这不是并发问题,是"崩溃后盲目重试"问题。乐观锁拦不住,因为数据没并发修改。这一层必须由幂等键(或状态机拒绝重复迁移)兜住。

正确的重试姿势:重试时带上首次请求的唯一ID,幂等键层直接返回首次结果,渠道只被调一次。 层间冲突——崩溃窗口前拦住盲目重试

5.2 状态机 × 幂等键 = 谁先谁后

幂等键和状态机配合时有个顺序问题。同一笔退款请求重试,幂等键先查——已处理,直接返回首次结果。这个没问题。但反过来:先落库更新了状态,还没写幂等表,进程崩溃了。重试到达,幂等键查不到,状态机校验发现订单已是 refunding,拒绝——结果正确(没有重复退款),但请求返回了"失败",客户端会困惑。

处理方式:状态机迁移和幂等表写入必须在同一个事务里,要么都成功,要么都回滚。层与层之间要在事务边界上咬合。

5.3 幂等键过期窗口

第 2 章说过幂等键会过期清理。过期窗口内的重试是安全的(幂等键还在,直接返回首次结果)。窗口外的重试是危险的——幂等键被清掉了,重试到达被当成新请求,重复执行。

窗口怎么定?大于最长重试间隔,外加业务兜底。Stripe 用 24 小时,你可以更长。但光靠窗口还不够——依赖幂等键的业务(比如退款)需要有兜底。对账的常见做法:定时任务按幂等键/订单号比对支付渠道流水与本地记录,发现两侧金额不一致即标记待查;自动冲正仅用于语义明确的场景(如确定是重复退款),否则转人工。幂等键是第一道闸,对账脚本挂在每天凌晨跑,比对两侧流水。

5.4 三层配合的正确姿势

一个完整的写操作,三层是这样咬合的:

  1. 唯一ID:请求到达 → 查幂等表 → 已处理则返回首次结果
  2. 状态机:未处理 → 校验当前状态 → 非法迁移拒绝
  3. 乐观锁:合法迁移 → 版本校验更新(WHERE version = ?)→ 冲突则重试或失败
  4. 事务咬合:状态迁移 + 幂等表写入在同一事务,防"半成功"

每一层解决自己那一类故障,又通过事务边界和重试策略互相兜底。这才是"三层防线"的完整含义。

三层配合的正确姿势——装配线检查员

再补一个细节:事务边界怎么划。状态迁移 + 幂等表写入放一个事务,是对的。但调用外部渠道(退款、发短信)不能放事务里——渠道调用是网络 IO,事务里做网络 IO 意味着持锁等网络,吞吐直接崩。正确姿势是:渠道调用放在事务外,用"状态标记 + 异步确认"模式:先落库"退款中"状态(事务内),再调渠道,渠道返回后更新为"已退款"(另一个事务)。这个模式下的崩溃窗口(调渠道后、更新状态前崩溃)正是第 5.1 节实验演示的场景,只能靠幂等键兜底。层间配合的功夫,一半在事务边界的划分上——事务划错了,三层叠在一起也照样出事。

6. 落地清单:你的系统该上几层

不是所有系统都需要三层。判断标准很简单:你的写操作会遇到哪类故障?

场景 会遇到 需要
支付下单 重复✅ 乱序✅ 并发✅ 三层
订单状态流转 重复✅ 乱序✅ 并发✅ 三层
余额扣减 重复✅ 并发✅ 唯一ID + 乐观锁
消息消费(at-least-once) 重复✅ 乱序△* 唯一ID + 业务侧版本判断
纯读操作 不需要

* 消息消费按分区保序(Kafka 同 key 消息在分区内有序),乱序只在跨分区场景出现,由业务侧版本号判断承担,不需要完整状态机。

几个落地要点:

  1. 重复是标配:任何写操作都要防重复,幂等键 + 唯一约束是底线
  2. 有状态流转就要状态机:状态是数据的一部分,状态乱序会污染数据
  3. 有并发写就上乐观锁:版本号字段成本极低,收益是"不静默丢更新"
  4. 外部调用 + 本地落库,必须有层间咬合:崩溃窗口只能靠幂等键 + 事务兜底
  5. 幂等键窗口 > 最长重试间隔,并配备对账兜底

落地清单——你的系统该上几层

7. 结语

三层防线,各管一类故障:唯一ID 防重复、状态机防乱序、乐观锁防并发。

下次遇到"幂等"这两个字,问自己三个问题:我的操作会被重复吗?我的状态会乱序吗?我的数据会被并发覆盖吗?三个问题都回答了,再加上层间配合和对账兜底,你的系统才敢说自己是幂等的。


你的系统里,哪个写操作还没上幂等?

结语——三层防线,各管一类故障


附录:实验代码和原始数据

本文 4 组实验的代码和运行输出已开源:

GitHub:zhiyulab-evidence/idempotency-design

实验 目录 验证什么
E1 幂等键防重复 e1-idempotency-key/ 同请求重发 100 次,无幂等键 99 笔重复入账,有幂等键 0 笔
E2 状态机防乱序 e2-state-machine/ 乱序回调被拒、重复回调幂等
E3 乐观锁防并发 e3-optimistic-lock/ 10 笔并发扣款无锁丢 9 笔,乐观锁全生效(冲突重试 45 次)
E4 层间冲突 e4-layer-conflict/ 崩溃后盲目重试=渠道退款 2 次,幂等键拦截=1 次

每个子目录都有独立 README 说明如何复现(cd code/e{N}-{name}/ && go run main.go,纯标准库无外部依赖)。二进制编译产物不入库,跑实验前自己 go build

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →