Java 开发第一次接触幂等,都是从一句定义开始:“多次执行和一次执行,对系统结果的影响相同。”

这句话当然没错,但在项目里它其实没什么指导意义。
因为真正让人头疼的不是定义,而是这些具体问题:

  • 用户连续点了两次“提交订单”,为什么生成了两笔订单?
  • 支付平台回调通知了两次,为什么系统扣了两次库存?
  • MQ 消息重复投递,为什么短信发了两遍?
  • 定时任务重跑了一次,为什么数据又结算了一遍?

你会发现,很多线上事故并不是“请求失败了”,而是:

请求成功了两次。

这篇文章就不讲空泛理论了,我们直接讲最常见的 4 类幂等场景,以及对应的落地方案。

一、先理解什么是幂等

你可以把幂等理解成一句人话:

同一件事,不管请求来几次,系统都只能认一次。

比如:

  • 同一个订单,只能创建一次
  • 同一笔支付,只能处理一次
  • 同一条消息,只能消费一次
  • 同一个任务,只能结算一次

幂等的目标:防止系统在网络重试、消息重复、回调重复、人工重放时,把同一件业务做了两遍。


二、最容易出事的 4 个幂等场景

真实项目里,幂等问题通常集中在这几类:

  1. 前端重复提交
  2. 第三方回调重复通知
  3. MQ 消息重复消费
  4. 定时任务或批处理重复执行

这四种场景虽然都叫幂等,但做法并不完全一样。
如果你试图拿一种方案打天下,最后大概率会踩坑。


三、场景 1:前端重复提交,怎么防止重复下单?

这是最常见的场景。

用户网络卡了一下,没看到结果,就又点了一次“提交订单”。
或者用户手速快,连续点了两下。
如果后端没有保护,很容易生成两笔订单。

错误写法

@PostMapping("/order/create")
public Long createOrder(@RequestBody CreateOrderRequest request) {
    Order order = new Order();
    order.setUserId(request.getUserId());
    order.setProductId(request.getProductId());
    order.setAmount(request.getAmount());
    orderService.save(order);
    return order.getId();
}

这段代码的问题不是业务逻辑错了,而是:

它默认每次请求都是一笔新业务。

但现实里,这两次请求可能其实是同一笔业务。


解决方案一:前端传唯一请求号,后端做幂等控制

比如前端提交订单前,先生成一个 requestId:

{
  "requestId": "f4f1df0f-8b98-4d12-9d64-123456789abc",
  "userId": 1001,
  "productId": 2001,
  "amount": 99.00
}

后端先查这个 requestId 是否已经处理过。

@PostMapping("/order/create")
public Long createOrder(@RequestBody CreateOrderRequest request) {
    Order existing = orderService.getByRequestId(request.getRequestId());
    if (existing != null) {
        return existing.getId();
    }

    Order order = new Order();
    order.setRequestId(request.getRequestId());
    order.setUserId(request.getUserId());
    order.setProductId(request.getProductId());
    order.setAmount(request.getAmount());
    orderService.save(order);
    return order.getId();
}

数据库给 request_id 加唯一索引:

ALTER TABLE orders ADD UNIQUE INDEX uk_request_id (request_id);

为什么要加唯一索引?

因为只靠“先查再插”是不够的。
两个请求几乎同时进来时,都可能查不到,然后都去插入。

所以真正的做法是:

  • 业务代码里先判断
  • 数据库层再用唯一索引兜底

这样即使并发下两个请求同时进来,也只有一个能插成功。


四、场景 2:支付回调重复通知,怎么防止重复扣款?

支付场景是幂等最经典的案例。

因为第三方支付平台的回调机制通常都不是“只通知一次”,而是:

只要你没正确响应,它就会不断重试。

所以你一定要默认:

支付回调可能来 2 次、3 次、5 次,甚至更多。

错误写法

@PostMapping("/pay/callback")
public String callback(@RequestBody PayCallbackRequest request) {
    Order order = orderService.getByOrderNo(request.getOrderNo());
    order.setStatus("PAID");
    orderService.update(order);

    stockService.reduce(order.getProductId(), order.getQuantity());
    messageService.sendPaySuccessMessage(order);

    return "success";
}

这段代码的问题非常严重。
如果回调重复两次,就可能出现:

  • 状态重复更新
  • 库存重复扣减
  • 消息重复发送

正确做法:基于业务状态做幂等

支付回调最稳妥的做法,是先判断订单状态。

@PostMapping("/pay/callback")
@Transactional
public String callback(@RequestBody PayCallbackRequest request) {
    Order order = orderService.getByOrderNo(request.getOrderNo());
    if (order == null) {
        return "fail";
    }

    if ("PAID".equals(order.getStatus())) {
        return "success";
    }

    order.setStatus("PAID");
    order.setPayTime(request.getPayTime());
    orderService.update(order);

    stockService.reduce(order.getProductId(), order.getQuantity());
    messageService.sendPaySuccessMessage(order);

    return "success";
}

这样就够了吗?

还不完全够。

如果两个支付回调同时进来,都读到订单状态还是 UNPAID,那仍然可能并发更新成功两次。

所以支付回调这类高风险场景,推荐再加一层:

解决方案:状态更新加条件

UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_no = ? AND status = 'UNPAID';

Java 里判断更新行数:

int updated = orderMapper.updatePaid(orderNo);
if (updated == 0) {
    return "success";
}

后面的扣库存、发消息只在 updated == 1 时执行。

为什么这样更稳?

因为这不是“先查再改”,而是:

数据库层直接保证只有未支付状态才能改成已支付。

谁先改成功,谁才有资格继续执行业务。


五、场景 3:MQ 重复消费,怎么防止重复发消息?

只要你用了 MQ,就要默认一件事:

消息可能重复。

很多消息中间件都只能尽力保证“至少投递一次”,而不是“绝对只投递一次”。

比如发短信:

错误写法

public void onMessage(OrderMessage message) {
    smsService.send(message.getPhone(), "您的订单已支付成功");
}

如果这条消息被重复投递两次,短信就会发两遍。


解决方案:消费前先检查消息唯一标识

每条消息必须带一个唯一 messageId 或业务唯一号,比如 orderNo。

public void onMessage(OrderMessage message) {
    if (consumeRecordService.isConsumed(message.getMessageId())) {
        return;
    }

    smsService.send(message.getPhone(), "您的订单已支付成功");
    consumeRecordService.markConsumed(message.getMessageId());
}

数据库建表:

CREATE TABLE mq_consume_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    message_id VARCHAR(64) NOT NULL,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_message_id (message_id)
);

更稳一点的写法

不要先查再插,直接插入唯一记录,利用唯一索引判断是否重复:

public void onMessage(OrderMessage message) {
    boolean locked = consumeRecordService.tryMarkConsumed(message.getMessageId());
    if (!locked) {
        return;
    }

    smsService.send(message.getPhone(), "您的订单已支付成功");
}

tryMarkConsumed 底层逻辑就是:

  • 插入成功:说明第一次消费
  • 唯一索引冲突:说明已经消费过

为什么推荐这样做?

因为它天然抗并发。
相比“先查后插”,这种方案在重复消息同时到达时更安全。


六、场景 4:定时任务重复执行,怎么防止重复结算?

很多系统里都有结算任务、统计任务、补偿任务。

问题在于:

  • 任务可能重跑
  • 多实例部署可能同时执行
  • 人工补跑时也可能重复执行

如果没有幂等控制,就可能造成重复结算。

错误写法

@Scheduled(cron = "0 0 1 * * ?")
public void settle() {
    List<Account> accounts = accountService.listNeedSettle();
    for (Account account : accounts) {
        settlementService.settle(account);
    }
}

如果服务部署了 2 台,两台机器同时跑这个定时任务,就可能一批数据被结算两遍。


解决方案一:分布式锁防止任务并发执行

比如用 Redis 锁:

public void settle() {
    boolean locked = redisLock.tryLock("settle_task", 60);
    if (!locked) {
        return;
    }

    try {
        List<Account> accounts = accountService.listNeedSettle();
        for (Account account : accounts) {
            settlementService.settle(account);
        }
    } finally {
        redisLock.unlock("settle_task");
    }
}

但要注意:

分布式锁解决的是“多个实例同时执行”,不是“单条业务重复处理”。

所以真正稳的做法还要加第二层。


解决方案二:业务层状态幂等

例如结算表增加状态:

  • INIT
  • SETTLING
  • DONE

处理前先做条件更新:

UPDATE settlement
SET status = 'SETTLING'
WHERE id = ? AND status = 'INIT';

只有抢到处理权的线程,才能继续执行结算逻辑。

结算完成后再改成:

UPDATE settlement
SET status = 'DONE'
WHERE id = ? AND status = 'SETTLING';

为什么要两层?

因为:

  • 分布式锁控制“任务级”并发
  • 状态流转控制“数据级”幂等

两层一起用,才更稳。


七、幂等常见方案,到底该怎么选?

很多人学完幂等,会记住很多关键词:

  • token
  • 唯一索引
  • 状态机
  • 分布式锁
  • 去重表

但真正项目里最重要的问题是:

不同场景到底选哪个?

我给你一个最实用的对应关系。

1. 防止前端重复提交

优先选:

  • 请求唯一号
  • 唯一索引
  • Redis 短期去重

适合:

  • 下单
  • 提交申请
  • 创建记录

2. 支付回调、状态变更类接口

优先选:

  • 业务状态判断
  • 条件更新
  • 乐观锁 / 状态流转控制

适合:

  • 支付成功
  • 发货成功
  • 审批完成
  • 状态推进类业务

3. MQ 消费幂等

优先选:

  • 消息唯一 ID
  • 消费记录表
  • 唯一索引去重

适合:

  • 发短信
  • 发通知
  • 发优惠券
  • 异步业务事件处理

4. 定时任务、批处理任务

优先选:

  • 分布式锁
  • 数据状态控制
  • 任务执行记录

适合:

  • 每日结算
  • 对账
  • 补偿任务
  • 批量修复任务

八、幂等最容易踩的 4 个坑

1. 只在代码里 if 判断,不做数据库兜底

if (!exists) { insert(); }

这个在并发下非常脆弱。


2. 误以为加了分布式锁就万事大吉

锁只能解决“同时执行”,不一定能解决“重复执行”。


3. 把“防重复提交”和“业务幂等”混为一谈

前端按钮置灰可以减少重复请求,
但它从来不能替代后端幂等。


4. 幂等校验做了,但副作用操作没控制

比如订单状态只更新了一次,但短信发了两次、库存扣了两次。
这说明你只对主流程做了幂等,没有对副作用链路做幂等。


九、我最推荐的一种幂等思路:业务主键 + 状态流转 + 数据库兜底

如果你问我项目里最稳的思路是什么,我会给你这套:

  1. 给业务找到“唯一身份”

    • requestId
    • orderNo
    • payNo
    • messageId
  2. 用状态流转保证“只处理一次”

    • INIT -> PROCESSING -> SUCCESS
    • UNPAID -> PAID
  3. 用数据库唯一索引或条件更新兜底

    • 防并发
    • 防重复
    • 防代码层判断失效

这套方案不是最花哨,但通常最稳。


十、最后给一份幂等设计清单

每次设计接口前,先问自己这 6 个问题:

  1. 这类请求有没有可能重复到达?
  2. 同一件业务的唯一标识是什么?
  3. 如果请求来两次,系统哪一步最危险?
  4. 我用的是“先查后改”,还是“数据库原子控制”?
  5. 副作用操作有没有重复执行风险?
  6. 失败重试时,这个接口还能保证结果一致吗?

更多推荐