先把问题说清

幂等这个词,很多系统都在说,但真正落到代码里,经常只剩一句“加个唯一索引就好了”。

唯一索引当然有用,但它解决的是一部分问题,不是全部问题。

业务里更常见的场景是下面这几类:

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、消息消费幂等必须单独设计,不能依赖中间件保证只投一次