幂等性设计
幂等指同一个操作执行多次,产生的效果与执行一次完全相同。这个词常被挂在消息队列的场景里,但重复执行是分布式系统的通病,不只消息一条链路。把幂等想成「消息去重的技术」,是把这个概念看窄了。
1. 重复从哪来
幂等的必要性,根子在「重复无法根除」这个事实上。系统里没有任何一个环节能保证一次操作恰好送达一次,只能保证「至少一次」。
重复有三个来源,各自对应不同的防御层。
第一是网络重试。客户端发出请求后超时,它无法判断服务端到底收没收到,只能重发。重发的那一次,服务端可能已经处理过第一次了。
第二是消息队列的投递语义。主流消息队列都是 at-least-once,消息从投递到确认之间消费者崩溃,Broker 会把这条消息重新投递。这不是缺陷,是可靠性的代价。要保证不丢,就得容忍重复。
第三是人为的重复。用户双击下单按钮、页面被刷新、重试脚本被误跑,都会产生一模一样的请求。
这三条来源的共性在于,重复不是异常,而是常态。指望上游「别发重」是不现实的,正确的应对是把「重复执行结果不变」这个责任扛在下游自己身上。
2. 幂等的本质
幂等的定义只有一句话,但理解它的关键是分清「执行」和「结果」两件事。
幂等不要求「第二次不执行」,它要求「第二次执行不改变结果」。一个查询执行一万次结果都一样,它幂等,但没人会专门为查询做幂等设计。真正需要设计的是那些「执行本身有副作用」的操作,比如插入一条订单、扣一次库存、发一条短信。
判断一个操作要不要做幂等,只问一个问题,这个操作的副作用能不能叠加。查询、删除(删一个已删除的返回成功)、覆盖式赋值,这些操作重复执行副作用不叠加,天然幂等。自增、扣减、插入、创建,这些操作每执行一次副作用就多一份,天然不幂等,一旦重复就是事故。
所以幂等的本质是,把一个「天然不幂等」的操作,改造成「重复执行副作用不叠加」。
这里有一个容易被混淆的边界,幂等和分布式锁不是一回事。锁解决的是「同一时刻只能有一个在执行」,解决的是并发。幂等解决的是「同一操作被执行多次结果不变」,解决的是重复。一个操作加了锁,仍然可能因为重试被执行多次,锁只保证这几次不并发,不保证结果不变。反过来,一个操作幂等,也挡不住两个不同的请求同时进来。两者正交,缺一不可。
3. 五种实现手段
手段不止一种,它们解决的重复来源各不相同。按防御位置从数据层往接口层排,一共五类。
3.1 唯一约束
最彻底的一类,把幂等责任交给数据库。
CREATE UNIQUE INDEX uk_order_id ON orders(order_id);重复插入同一条订单时,唯一索引直接抛 DuplicateKeyException,第二次写入物理上不可能成功。它的因果链很简单,数据库强制「同一个键只能存在一条」,重复自然被挡住。
但唯一约束有边界。它只对「插入」生效,对「更新」「扣减」这类不产生新记录的操作无能为力。而且它把幂等键压进了业务表,如果幂等键不是业务主键,还得额外维护一列。
3.2 状态机守卫
针对「更新」类操作的幂等。思路是把「当前状态」写进更新的 WHERE 条件,让判断和更新合成一条原子语句。
UPDATE orders SET status = 'PAID', paid_at = now()
WHERE order_id = ? AND status = 'PENDING';第一次执行,订单处于 PENDING,条件成立,更新生效。重复执行时订单已经是 PAID,status = 'PENDING' 不成立,受影响行数为 0,操作自然失效。这里的幂等不是靠「记住已经做过」,而是靠「业务状态本身保证重复执行结果不变」。
状态机守卫和唯一约束的区别在于,它不依赖插入冲突,而是依赖业务状态的有向流转。订单从 PENDING 到 PAID 只能发生一次,这条路径本身就把重复挡住了。这是库存扣减、订单状态流转两章的核心手法。
3.3 乐观锁版本号
状态机守卫的变体,把「状态」泛化为「版本号」。
UPDATE sku SET stock = stock - 1, version = version + 1
WHERE sku_id = ? AND version = ?;它防的不是「重复执行」,而是「并发覆盖」。重复提交一个携带旧版本号的请求,第二次执行时版本号已经变了,条件不成立,更新失败。乐观锁本身是并发控制手段,但用在幂等场景里,效果是同一个请求只能生效一次。
3.4 Token 机制
前面三类都在数据层,Token 机制在接口层,防的是「重复提交」这个更靠前的场景。
// 进入表单页时下发 token,提交时校验并删除
String token = UUID.randomUUID().toString();
redis.set("submit:token:" + token, "1", 60, TimeUnit.SECONDS);
// 提交时,删除成功才继续,删除失败说明 token 已被用过
boolean first = redis.delete("submit:token:" + token);
if (!first) {
throw new BizException("请勿重复提交");
}Token 机制的本质是给一次提交发一张一次性凭证,凭证用一次就作废。它防的是「用户手滑点了两次」,而不是「消息重投」这种系统级重复。因为 token 存在 Redis 里,有独立的生命周期,和业务数据是两套东西。
3.5 去重表
把「已经处理过」这个事实单独记一张表,处理前先插。
INSERT INTO processed_message(message_id) VALUES (?);重复处理时插入冲突,直接跳过。它和唯一约束的区别在于,幂等键不混进业务表,而是独立成表。好处是业务表和幂等逻辑解耦,坏处是多一张表、多一次写。适合消息消费这类「幂等键是消息 ID,和业务主键无关」的场景。
4. 五类手段怎么选
选手段不是看喜好,是看重复从哪一层来。
| 重复来源 | 对应手段 | 防御位置 |
|---|---|---|
| 用户重复提交 | Token 机制 | 接口层 |
| 消息重复投递 | 去重表、消息 ID | 消息层 |
| 插入类操作重复 | 唯一约束 | 数据层 |
| 更新类操作重复 | 状态机守卫、乐观锁 | 数据层 |
| 调用外部服务重复 | 传递幂等键 | 服务间 |
判断顺序自上而下。先看重复是从哪来的,再选对应层的手段。一个订单链路里,接口层用 Token 防用户手滑,消息层用消息 ID 防重投,数据层用唯一约束防重复插入,三层各司其职,缺一层就有一种重复漏进来。
5. 常见陷阱
幂等设计翻车,几乎都栽在「检查和标记不是原子操作」这一个点上。
第一个陷阱是先 ack 后处理。消费端先确认消息、再处理业务,处理时崩溃,这条消息已经从队列删除了,后续也不会再投递,业务就丢了。正确顺序是先处理、后 ack,处理失败不确认,让消息重投。
第二个陷阱是只检查不标记。查一遍去重表判断「没处理过」,然后处理,然后标记。检查和标记之间的窗口里,另一个请求也通过检查,两边都处理了。正确的做法是把检查本身做成一个有副作用的动作,比如 INSERT 的插入冲突,而不是先 SELECT 再 INSERT。
第三个陷阱是去重窗口太短。去重记录的 TTL 设得比消息的最大重试时间还短,过期之后重试消息又被当成新消息处理。去重窗口要覆盖「最大重试时间 + 处理时间 + 缓冲」。
第四个陷阱是外部调用不传幂等键。自己服务内部做了幂等,调用支付、扣款这类外部服务时却不传幂等键,外部服务重试时重复扣款。幂等键要贯穿整个调用链,不能只在自己家里做。
6. 与其他章节的关系
幂等是一个横切概念,落在不同场景有不同的落地形态。消息场景下的落地,比如消费者端怎么用消息 ID 去重、去重窗口怎么定,见 消息去重。库存扣减里乐观锁防超卖的具体写法,见 库存扣减并发控制。状态机守卫在订单状态流转里的完整应用,见 订单状态流转。