学完这篇,你能针对不同业务场景挑出合适的 Java 接口幂等方案,并知道每种方案的代码入口和坑在哪。
第一步:先明确幂等键从哪来
幂等的本质是:同一个业务请求执行 1 次和执行 N 次,对数据的影响完全一致。落地的前提是先有一个幂等键——能唯一标识"这一次业务意图"的字符串。
常见来源有三种:
- 前端在提交前生成 UUID,放进请求头,例如
X-Request-Id: 8f3c...
- 业务天然唯一键,如订单号、支付流水号
- 服务端颁发的一次性 token(下面第五种方案会用到)
注意:千万别用「用户 ID + 接口名」当幂等键,那样同一用户第二次正常下单也会被拦截。幂等键必须精确到"一次操作"。
另外要区分:查询、UPDATE t SET a=1 WHERE id=1、DELETE WHERE id=1 这类天然幂等,不需要额外处理;真正要防的是插入、扣减、状态流转这三类。
第二步:方案一 —— 数据库唯一索引
最省事、最可靠的一种。给业务表加唯一键:
ALTER TABLE t_order ADD UNIQUE KEY uk_request_id (request_id);
代码里直接 insert,捕获重复键异常:
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
return orderMapper.selectByRequestId(order.getRequestId());
}
优点:靠数据库保证,多实例部署也绝对安全,无额外中间件。缺点:只能拦插入,拦不住扣款这类更新;异常分支要写清楚,否则会把真实错误也当成重复请求吞掉。
第三步:方案二 —— 乐观锁版本号
适合"读-改-写"的更新场景,比如余额扣减:
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = #{id} AND version = #{version};
返回值是受影响行数,为 0 说明期间被别人改过,直接返回失败或有限次重试。
注意:重试次数别超过 3 次,且要加随机退避。高并发下重试风暴比失败更可怕。
第四步:方案三 —— 悲观锁(SELECT ... FOR UPDATE)
SELECT * FROM account WHERE id = 1 FOR UPDATE;
在事务里先锁住行再判断状态、再更新,天然串行。适合并发量不大、逻辑复杂的场景。
注意:一定要在事务内使用,且事务要短。锁住之后再调远程接口,会把连接池和锁一起拖死。
第五步:方案四 —— Redis 分布式锁
跨服务、跨库场景的首选。用 SET key value NX PX 抢占:
Boolean ok = redis.opsForValue()
.setIfAbsent("idem:" + requestId, "1", 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(ok)) {
throw new BizException("请求正在处理中,请勿重复提交");
}
释放锁时用 Lua 脚本比对 value,防止误删别人的锁。
注意:TTL 必须大于业务最长处理时间。如果业务可能跑 60 秒,锁设 30 秒,第二个请求进来照样重复执行。更稳妥的做法是用 Redisson 的看门狗自动续期。
第六步:方案五 —— Token 令牌机制
流程是「先领票、后消费」:
- 进入下单页时调
/api/token,服务端生成 UUID 存入 Redis(如 SET token:xxx 1 EX 600)
- 提交订单时把 token 带上
- 服务端执行
DEL token:xxx,返回 1 才继续处理,返回 0 说明票据已被用过
因为 Redis 的 DEL 是原子的,多实例并发下只有一个请求能拿到 1。
注意:token 不能和用户绑定得太松,建议 key 里带上用户 ID,防止 A 用户的票被 B 用掉。
第七步:方案六 —— 状态机 + 幂等去重表
两个动作配合:一是状态机限定流转方向,二是把每次请求的结果落库。
UPDATE t_order SET status = 'PAID'
WHERE order_no = #{orderNo} AND status = 'UNPAID';
配合一张幂等表:
CREATE TABLE t_idempotent (
request_id VARCHAR(64) PRIMARY KEY,
result_json TEXT,
create_time DATETIME
);
第一个到达的请求先 insert 幂等表(成功者才有资格执行),执行完把业务结果写进 result_json;后来者查到记录就直接返回缓存结果。这样不仅防重复执行,还保证返回值也一致。
第八步:六种方案横向对比
| 方案 | 实现成本 | 适用场景 | 并发性能 | 主要短板 |
|---|
| 唯一索引 | 极低 | 插入类、防重下单 | 高 | 拦不住更新;异常语义要区分 |
| 乐观锁 | 低 | 余额、库存扣减 | 高(冲突高时下降) | 高并发需重试,失败率高 |
| 悲观锁 | 低 | 并发低、逻辑复杂 | 低 | 锁等待、易拖垮连接池 |
| Redis 分布式锁 | 中 | 跨服务、跨库 | 高 | TTL 与续期要处理 |
| Token 令牌 | 中 | 表单提交、开放 API | 高 | 需前后端配合,多一次请求 |
| 状态机+幂等表 | 中高 | 订单、支付、审批流 | 高 | 要建表、要设计结果结构 |
第九步:生产环境一般怎么组合
真实项目很少只用一种,常见搭配是:
- 入口层:注解 + AOP 做统一拦截。自定义
@Idempotent(key = "#orderNo", expire = 60),切面里用 Redis setIfAbsent 占位,幂等键默认取「用户 ID + 方法名 + 参数 MD5」。
- 数据层:唯一索引 + 状态机兜底。中间件可能故障、切面可能漏配,数据库约束是最后一道防线。
- 返回层:处理中返回明确错误码(如 409),而不是沉默失败,否则前端会一直重试。
注意:幂等键的 TTL 建议覆盖"最长业务处理时间 + 用户可能的重复点击窗口",一般 5~30 分钟。设太短,防不住;设太长,占内存且会误伤合法的重复业务(比如同一天两次真实下单)。
注意:AOP 拦截失败时,要删掉 Redis 里的占位键,否则用户重试永远被判定为重复提交。用 try/finally 保证释放。
小结
- 幂等的前提是有一个精确到"单次操作"的幂等键,UUID 或业务唯一号都行。
- 插入类优先唯一索引;更新类优先乐观锁或状态机;跨服务用 Redis 分布式锁;表单场景用 Token。
- 状态机 + 幂等表是唯一能同时保证"不重复执行"和"返回值一致"的方案。
- 线上一律做多层组合,数据库约束是不可省略的兜底。
- TTL、异常释放、重试退避这三个细节,是幂等方案最常见的翻车点。