外观
一文搞懂幂等设计全景:防重Token、唯一约束与状态机的三层组合拳
幂等是一个被讲烂但总出事故的话题。烂熟的原因是方案本身不复杂——唯一约束、防重令牌、状态机,老三样;总出事的原因是没人把这些方案当成一个体系来用:只在入口加了防重令牌,回调接口却裸奔;数据库加了唯一索引,MQ 消费逻辑却先查后插留出了竞态窗口。我们复盘过的幂等相关线上问题里,八成不是「没想到方案」,而是「方案没盖住完整的攻击面」。重复请求的来源比想象中多:用户连击、网络重试、网关超时重发、MQ 至少一次投递、定时任务重复触发。这篇文章按「入口 → 业务 → 数据」三层构建完整的幂等防御体系,每一层讲清楚它防什么、不防什么。
攻击面分析:重复请求从哪里来
设计幂等之前先明确对手。按到达时机分两类:
| 来源 | 特征 | 典型场景 |
|---|---|---|
| 并发重复 | 毫秒级窗口内同时到达 | 用户连击、网关重试、上游超时重发 |
| 时序重复 | 间隔较长、先后到达 | MQ 重投、失败重试、回调补发 |
并发重复考验的是「互斥」能力,时序重复考验的是「识别已处理」能力。单一方案通常只覆盖一类:防重令牌擅长挡并发连击,但令牌消费完就失效,迟到的重试请求拿不到新令牌反而被误拒;唯一约束擅长拦时序重复,但两个并发请求在插入前的检查窗口里都能通过。分层组合,让每一层处理自己擅长的重复形态,是体系化幂等的核心思路。
第一层:入口防重 Token——把「连击」挡在业务之外
前端进入页面时先领令牌,提交时携带,服务端原子消费:
java
// 领取:预生成 ID 存入 Redis,5 分钟有效
public String issueToken(String userId, String scene) {
String token = UUID.randomUUID().toString();
redis.setex("idem:token:" + userId + ":" + scene, 300, token);
return token;
}
// 消费:Lua 脚本保证「比对 + 删除」原子性,防止比对与删除之间被并发钻空
private static final String CONSUME_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";两个工程细节决定成败:其一,消费动作要放在业务事务成功之后提交语义,或者接受「令牌已消费但业务失败」时由前端重新领令牌重试,两害取其轻时优先防资金类重复;其二,Token 是一次性用品,拒绝重复提交时给用户明确提示「请刷新后重试」,别让用户卡死在按钮上。
第二层:业务互斥锁——压缩并发窗口
以业务键为粒度的分布式锁,把同一业务键的并发请求变成串行。它的定位要摆正:锁负责让请求排队,不负责判断该不该执行,判断交给数据层:
java
// 锁内仍然要做状态检查:锁只保证同一时刻只有一个请求在处理
String lockKey = "idem:lock:refund:" + refundNo;
boolean locked = redis.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (!locked) {
return Result.processing(); // 排队中,客户端稍后查询结果
}
try {
return refundService.execute(refundNo);
} finally {
releaseWithLua(lockKey, requestId); // 只释放自己持有的锁
}注意锁过期时间要大于业务最大耗时,或者引入看门狗自动续期(如 Redisson 默认机制),否则「锁先过期、业务还没跑完、重试请求进来」会击穿整层防线。锁被释放后到达的重试请求会重新拿锁,此时第一层的令牌已消费、第三层的状态机还在,防线不断层。
第二层讲完互斥,还要补一条容易被忽视的纪律:幂等键必须由业务语义决定,而不是由请求通道决定。同一笔支付,渠道回调、对账补偿、人工重推三条路径到达时,必须命中同一个幂等键(渠道流水号);如果各通道各自用请求 ID 做键,防重复就形同虚设。设计幂等方案的第一步永远是画出「同一笔业务的所有到达路径」,再从中选出全局唯一且跨通道稳定的标识作为幂等键,这一步做错,后面三层防线全部白建。
第三层:唯一约束与状态机——数据层兜底
这是最后一道也是最可靠的一道。插入类业务用唯一索引:
sql
-- 重复插入直接被数据库拒绝,不依赖任何中间件的可用性
ALTER TABLE refund_order ADD UNIQUE KEY uk_refund_no (refund_no);状态流转类业务用条件更新实现状态机幂等:
sql
-- 只有「处理中」才能流转到「成功」,重复的回调无法二次变更
UPDATE refund_order
SET status = 'SUCCESS', finish_time = NOW()
WHERE refund_no = 'RF20260001' AND status = 'PROCESSING';
-- 影响行数为 0:要么已成功(幂等返回),要么状态不符(走告警分支)数据层方案的杀手锏是不依赖额外组件——Redis 挂了、锁服务超时了,数据库的约束依然在。这也是为什么资金类业务的幂等必须以数据层为终点。
MQ 消费者的幂等模板
至少一次投递语义下,重复消费是常态而非异常。消费者幂等的标准模板是「消息表 + 条件更新」:
sql
-- 消费前先登记:唯一索引保证同一 msgId 只登记一次
INSERT INTO consume_record (msg_id, biz_type, consume_time)
VALUES ('MSG9001', 'REFUND_RESULT', NOW());
-- 唯一键冲突 = 已消费过,直接 ACK 跳过;登记成功才执行业务登记与业务在同一事务内提交,避免「登记了但业务失败」或「业务成功但没登记」两个方向的错位。消费失败要重试的场景,登记表加状态列,重试时条件更新推进状态。
组合框架与验证方法
三层方案的组合决策表:
| 层 | 方案 | 防什么 | 失效场景 |
|---|---|---|---|
| 入口 | 防重 Token | 并发连击 | 非浏览器调用方 |
| 业务 | 分布式锁 | 并发窗口 | 锁过期/服务宕机 |
| 数据 | 唯一约束/状态机 | 时序重复+最终兜底 | 无唯一业务键的业务 |
所有写接口至少覆盖数据层,资金类业务三层全上——这是预算最低的通用原则。最后别忘了验证:用 JMeter 或 wrk 对同一业务键并发打 100 个请求,断言数据库只有一条记录、下游只调用一次;幂等逻辑写进单测的并发用例,才能真正防住回归。
避坑总结
- 幂等键必须先于方案存在:每个写接口设计时就要回答「业务唯一键是什么」,没有唯一键的业务要先造键(如客户端请求 ID)。
- 先查后插的检查方式永远有竞态窗口,唯一约束是唯一可靠的插入防线,查询仅用于友好提示。
- 分布式锁不等于幂等:锁释放后到达的重试会再次执行,锁必须与状态机或唯一约束组合。
- MQ 幂等的登记与业务必须同事务,拆成两步就会出现状态错位。
- 幂等接口的重复请求应返回首次结果或明确的处理中状态,而不是报错——对调用方而言重复即成功。
幂等设计的本质,是把「只执行一次」的语义从对调用方的信任,收回到服务端分层可控的保障。三层防线各司其职、层层兜底,重复请求无论以何种形态到达,都只能在数据层留下一条记录。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。