先把问题说清
幂等这个词,很多系统都在说,但真正落到代码里,经常只剩一句“加个唯一索引就好了”。
唯一索引当然有用,但它解决的是一部分问题,不是全部问题。
业务里更常见的场景是下面这几类:
1、前端重复点击,接口被重复提交
2、网关超时重试,请求被打了多次
3、消息重复投递,消费逻辑重复执行
4、补偿任务重跑,同一笔数据被再次处理
这篇只讲 Java 服务里常见的幂等落地方式,不展开讲概念定义。
先分清防重和幂等
很多接口把这两个概念混在一起,最后实现出来就会很别扭。
防重复提交,重点是短时间内不要让同一个动作进来两次。
业务幂等,重点是同一笔业务就算来两次,最终结果也只能落一次。
例如创建订单:
1 2 3 4
| @PostMapping("/order/create") public OrderCreateResponse create(@RequestBody OrderCreateRequest request) { return orderAppService.create(request); }
|
如果只是前端双击,你可以先用 token 或 Redis 锁挡一下;
如果是服务间重试、MQ 重投、补偿重跑,那就必须靠业务主键、状态机、唯一约束来兜底。
很多系统幂等失效,不是不会写,而是一开始就没分清这两层。
入口防重可以用 Redis
入口防重最常见的写法是基于 Redis setIfAbsent。
1 2 3 4
| public boolean submitOnce(String key, Duration duration) { Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", duration); return Boolean.TRUE.equals(success); }
|
在控制器里使用:
1 2 3 4 5 6 7 8
| public OrderCreateResponse create(OrderCreateRequest request) { String idempotentKey = "order:create:" + request.getUserId() + ":" + request.getRequestId(); boolean accepted = idempotentService.submitOnce(idempotentKey, Duration.ofSeconds(5)); if (!accepted) { throw new BizException("请求重复提交"); } return orderAppService.doCreate(request); }
|
这种方式适合:
1、用户连续点击提交按钮
2、网关极短时间内重试
3、一次请求只需要挡掉瞬时重复
但要注意:
1、过期时间太短,慢请求还没处理完,重复请求又进来了
2、过期时间太长,用户真实重试也会被拦住
3、只靠 Redis 防重,不等于业务已经幂等
幂等键还是要落库
真正稳定的幂等,最终还是要回到持久层。
比如发券、发奖、创建支付单这类动作,最好都要有一份明确的业务幂等键。
1 2 3 4 5 6 7 8 9
| @TableName("award_record") public class AwardRecord {
private Long id; private String bizNo; private Long userId; private Integer awardType; private Integer status; }
|
数据库加唯一索引:
1 2
| ALTER TABLE award_record ADD UNIQUE KEY uk_biz_no (biz_no);
|
落库逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Transactional(rollbackFor = Exception.class) public AwardResponse send(AwardRequest request) { AwardRecord existed = awardRecordMapper.selectByBizNo(request.getBizNo()); if (existed != null) { return buildRepeatResponse(existed); }
AwardRecord record = new AwardRecord(); record.setBizNo(request.getBizNo()); record.setUserId(request.getUserId()); record.setAwardType(request.getAwardType()); record.setStatus(0); awardRecordMapper.insert(record);
doSendAward(request, record); return AwardResponse.success(record.getId()); }
|
更稳一点的写法,还要兜住并发插入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Transactional(rollbackFor = Exception.class) public AwardResponse send(AwardRequest request) { try { AwardRecord record = new AwardRecord(); record.setBizNo(request.getBizNo()); record.setUserId(request.getUserId()); record.setAwardType(request.getAwardType()); record.setStatus(0); awardRecordMapper.insert(record); doSendAward(request, record); return AwardResponse.success(record.getId()); } catch (DuplicateKeyException e) { AwardRecord existed = awardRecordMapper.selectByBizNo(request.getBizNo()); return buildRepeatResponse(existed); } }
|
数据库唯一索引不是全部幂等方案,但它通常是最后一道稳妥的底线。
状态机比查记录更稳
只判断记录是否存在,很多时候不够。
因为真实业务里,一笔数据可能处于:待处理、处理中、成功、失败待补偿、已取消。
1 2 3 4 5 6
| public enum AwardStatus { INIT, PROCESSING, SUCCESS, FAIL }
|
处理逻辑里可以这样收口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Transactional(rollbackFor = Exception.class) public AwardResponse process(AwardRequest request) { AwardRecord record = awardRecordMapper.selectByBizNo(request.getBizNo()); if (record == null) { throw new BizException("record not found"); }
if (Objects.equals(record.getStatus(), AwardStatus.SUCCESS.ordinal())) { return AwardResponse.success(record.getId()); }
if (Objects.equals(record.getStatus(), AwardStatus.PROCESSING.ordinal())) { throw new BizException("处理中,请勿重复提交"); }
awardRecordMapper.updateStatus(record.getId(), AwardStatus.PROCESSING.ordinal()); doSendAward(request, record); awardRecordMapper.updateStatus(record.getId(), AwardStatus.SUCCESS.ordinal()); return AwardResponse.success(record.getId()); }
|
状态机的价值在于:
1、重复请求时不只是“挡住”,还能给出正确结果
2、失败重试时能判断该继续还是该拒绝
3、补偿任务能接着上次状态往下走
重复请求最好返回旧结果
很多接口做了防重,但重复请求来了以后直接报错。
这种写法不一定错,但对于创建型接口,更稳的做法通常是:第一次成功后,后续同一幂等键重复请求,直接返回第一次的处理结果。
1 2 3 4 5 6 7 8 9
| public PaymentResponse createPayOrder(PaymentRequest request) { PaymentOrder existed = paymentOrderMapper.selectByBizNo(request.getBizNo()); if (existed != null) { return new PaymentResponse(existed.getOrderNo(), existed.getStatus(), true); }
PaymentOrder order = createOrder(request); return new PaymentResponse(order.getOrderNo(), order.getStatus(), false); }
|
这类返回方式更适合:
1、调用方本来就会重试
2、接口超时后,调用方不确定服务端有没有处理成功
3、客户端更关心“最终结果”而不是“你是不是第一次提交”
MQ 消费幂等要单独做
MQ 消费场景下,重复消费几乎是默认前提,不是例外情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public void onMessage(OrderPaidMessage message) { String consumeKey = "order:paid:" + message.getBizNo(); if (!consumeLogService.tryMarkProcessing(consumeKey)) { log.info("duplicate message, bizNo={}", message.getBizNo()); return; }
try { benefitService.grant(message); consumeLogService.markSuccess(consumeKey); } catch (Exception e) { consumeLogService.markFail(consumeKey, e.getMessage()); throw e; } }
|
消费幂等的常见做法:
1、消费日志表
2、业务记录唯一键
3、状态机 + 重试次数
4、Redis 短期防重配合数据库底线
常见坑
1、把 Redis 锁当成完整幂等方案
Redis 只能挡一部分瞬时重复,持久化结果还是要靠数据库或状态机兜底。
2、只做唯一索引,不回查旧结果
调用方看到 DuplicateKeyException,还是不知道这笔请求最后有没有成功。
3、幂等键设计太粗
如果只按 userId 做幂等,一个用户连续两次不同业务动作都可能被误判成重复。
4、幂等键设计太细
如果每次重试都生成新的 requestId,那服务端根本识别不出是同一笔业务。
5、处理中状态没有超时恢复
一旦处理中状态卡死,后续真实重试也永远进不来。
收一下
接口幂等真正有用的部分,不是“挡住一次重复请求”,而是把重复调用收敛成同一笔业务结果。
真正值得保留的常用实践,核心就这几类:
1、先分清入口防重和业务幂等
2、入口防重可以用 Redis,但不能只靠 Redis
3、业务幂等键要落库,并配唯一约束兜底
4、同一笔业务要有状态机,不要只看有没有记录
5、重复请求最好能返回第一次处理结果
6、消息消费幂等必须单独设计,不能依赖中间件保证只投一次