跳到主内容

一文搞懂幂等设计全景:防重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 个请求,断言数据库只有一条记录、下游只调用一次;幂等逻辑写进单测的并发用例,才能真正防住回归。

避坑总结 ​

  1. 幂等键必须先于方案存在:每个写接口设计时就要回答「业务唯一键是什么」,没有唯一键的业务要先造键(如客户端请求 ID)。
  2. 先查后插的检查方式永远有竞态窗口,唯一约束是唯一可靠的插入防线,查询仅用于友好提示。
  3. 分布式锁不等于幂等:锁释放后到达的重试会再次执行,锁必须与状态机或唯一约束组合。
  4. MQ 幂等的登记与业务必须同事务,拆成两步就会出现状态错位。
  5. 幂等接口的重复请求应返回首次结果或明确的处理中状态,而不是报错——对调用方而言重复即成功。

幂等设计的本质,是把「只执行一次」的语义从对调用方的信任,收回到服务端分层可控的保障。三层防线各司其职、层层兜底,重复请求无论以何种形态到达,都只能在数据层留下一条记录。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。