Skip to content

先看一个经典的翻车现场:用户在支付页面手抖点了两下提交,前端发了两个一模一样的 POST,两个请求都进了支付流程——订单重复,扣了两次款。这就是典型的重复提交,而背后是更普遍的问题:接口幂等

幂等这词听着唬人,本质就一句话:同一个操作,执行一次和执行一百次,结果一样。支付回调、表单提交、消息消费,这三类场景天天跟它打交道。这篇把幂等的方案从简单到可靠排一遍:为什么唯一键够用、什么时候必须上状态机、消息重复消费怎么兜底。代码按 Spring Boot 落地,能实跑的我都实跑验证过。


一、先分清:哪些请求天生幂等,哪些不是

不是所有接口都需要幂等设计。先分清再动手,别一上来就套方案:

请求类型天生幂等?例子
查询(GET)✅ 幂等查订单、查用户
更新固定值(PUT)✅ 幂等UPDATE user SET name='张三'
追加/累加(POST)❌ 非幂等下单、扣款、发消息、记账
条件更新(UPDATE ... WHERE status=0⚠️ 看实现靠状态判断防重

真实翻车的都是第三类:POST 出去的网络请求。客户端超时重试、用户双击、消息队列重投,任何一个都会把同一个业务动作重复执行。

二、方案 v1:数据库唯一键兜底(最朴素,也最可靠)

先做最简单的一层:让数据库在物理上不允许重复

比如"一个用户对一个商品只能下一单"(限购),在订单表建唯一索引:

sql
-- 防重复下单:同一用户 + 同一商品 + 同一场次 只能有一条
ALTER TABLE `t_order`
  ADD UNIQUE KEY `uk_user_sku` (`user_id`, `sku_id`, `seckill_id`);

代码里插入时捕获 DuplicateKeyException

java
@Transactional
public Order createOrder(CreateOrderCmd cmd) {
    Order order = new Order();
    order.setUserId(cmd.getUserId());
    order.setSkuId(cmd.getSkuId());
    order.setSeckillId(cmd.getSeckillId());
    order.setStatus(OrderStatus.CREATED);
    try {
        orderMapper.insert(order);
        return order;                    // 插入成功 = 第一次执行
    } catch (DuplicateKeyException e) {
        return orderMapper.selectByUk(   // 撞唯一键 = 重复请求,返回已有结果
            cmd.getUserId(), cmd.getSkuId(), cmd.getSeckillId());
    }
}

这个方案的特点

  • 优点:数据库唯一索引是最终防线,并发下也不会漏,不需要任何协调
  • 缺点:① 唯一键是业务强约束,没法覆盖"任意请求都要幂等";② 只能防"重复插入",防不了"重复更新累计"(比如余额加两次)

所以唯一键是兜底,不是万能。

三、方案 v2:token + Redis 防重(表单防双击的首选)

业务方说:我们不限制用户下几单,只是防手抖双击。这种情况唯一键约束不住,用「预发 token 机制」:

token 防重机制流程图

后端就两个动作:发 token 存 Redis,收 token 时删除。关键在删除用 SETNX/DEL 的原子性——两个并发请求同时来,只有一个能删成功:

java
// 1. 发 token:页面打开时调用
public String issueToken() {
    String token = UUID.randomUUID().toString();
    stringRedisTemplate.opsForValue().set(TOKEN_PREFIX + token, "1", Duration.ofMinutes(2));
    return token;
}

// 2. 校验 token:提交时调用,只允许一次成功
public boolean consumeToken(String token) {
    // 原子删除:返回 true 说明删到了 = 首次;返回 false = 已被删过 = 重复
    return Boolean.TRUE.equals(stringRedisTemplate.delete(TOKEN_PREFIX + token));
}
java
@PostMapping("/order")
public Result<?> create(@RequestBody CreateOrderReq req) {
    if (!idempotentService.consumeToken(req.getToken())) {
        return Result.fail("请勿重复提交");
    }
    // token 校验通过,继续正常下单流程
    return orderService.create(req);
}

为什么删 token 能防并发:Redis 单线程执行 DEL,两个请求同时 DEL 同一个 key,必然一个返回 1 一个返回 0。不存在两个都成功。

这个方案的特点

  • 优点:业务代码零侵入(校验抽成注解就能全站通用);不限制真实业务次数
  • 缺点:① 依赖前端配合(必须先从后端拿 token);② token 校验过了、业务却失败(如库存不足),用户刷新重来还得重新取 token——校验成功 ≠ 业务成功,重复提交还是可能落库

所以 token 方案解决"双击",解决不了"重试"。

四、方案 v3:幂等表 + 状态机(支付回调这类"外部重试"的解法)

支付回调是幂等需求最硬的场景:支付平台会一直重试回调(最多重试 24 小时,间隔递增),你的接口必须对同一个订单的无数次回调返回同一个结果。token 方案在这失灵——回调方不会先来取 token。

解法是幂等表 + 状态机

sql
-- 幂等表:记录每个业务动作的处理结果
CREATE TABLE `t_idempotent` (
  `id`          BIGINT AUTO_INCREMENT PRIMARY KEY,
  `biz_type`    VARCHAR(32)  NOT NULL COMMENT '业务类型:PAY_CALLBACK / REFUND_CALLBACK',
  `biz_no`      VARCHAR(64)  NOT NULL COMMENT '业务单号:订单号 / 支付流水号',
  `status`      TINYINT      NOT NULL COMMENT '0=处理中 1=成功 2=失败',
  `result_json` VARCHAR(512) COMMENT '首次处理结果,重复请求直接返回它',
  `create_time` DATETIME     NOT NULL,
  UNIQUE KEY `uk_biz` (`biz_type`, `biz_no`)
) COMMENT '幂等记录表';

核心思路:先占位(插入处理中记录),处理完回写结果。重复回调来了,发现已有记录,直接返回记录里的结果,不再重复执行:

java
@Transactional
public PayResult handlePayCallback(PayCallbackReq req) {
    // 1. 先占位:INSERT ... status=0,撞唯一键说明是重复回调
    IdempotentRecord record = tryInsertPending(req.getBizType(), req.getBizNo());
    if (record == null) {
        // 重复回调:查已有结果直接返回(幂等命中)
        return readPrevResult(req.getBizType(), req.getBizNo());
    }

    try {
        // 2. 第一次执行真实业务:更新订单状态、加余额
        PayResult result = doHandlePay(req);
        // 3. 回写结果:status=1
        updateResult(record.getId(), result);
        return result;
    } catch (Exception e) {
        // 4. 业务失败:标记 status=2,允许外部重试(重试时重新占位)
        updateFail(record.getId());
        throw e;
    }
}

状态机在这里起什么作用处理中 → 成功/失败 三个状态,让"占位失败"和"业务失败"能区分开——占位失败是重复请求(返回旧结果),业务失败是执行出错(允许重试)。没有状态机,这两件事分不清,要么重复执行、要么永远不重试。

配合条件更新,状态机还能做到"只处理一次"的强保证:

sql
-- 只有 status=处理中 才能流转到成功,防止并发下两个线程都执行业务
UPDATE t_idempotent SET status = 1, result_json = ?
WHERE id = ? AND status = 0;

五、消息队列的重复消费:用「消费幂等」兜住

消息场景的重复消费是隐性的:MQ 是至少一次投递,消费端处理完、回执前挂了,消息就会重投。所以消费者必须自己幂等。

做法就是前面方案的组合:用业务唯一键当消费幂等键(订单号、流水号),消费时先查幂等表,处理过就跳过:

java
@RabbitListener(queues = "order.pay.success")
public void onPaySuccess(PaySuccessMsg msg) {
    String idemKey = "PAY:" + msg.getOrderNo();
    // 借助幂等表(或 Redis SETNX)判断是否已处理
    if (!idempotentService.tryMarkProcessed(idemKey)) {
        log.info("重复消息,跳过: {}", msg.getOrderNo());
        return;
    }
    // 真正的业务:积分、通知、库存
    orderService.afterPaySuccess(msg);
}

六、方案选型:一张表说清楚

方案防什么成本适用场景
数据库唯一键重复插入业务本身有唯一约束(限购、领券)
Redis token 防重双击/连点表单提交、前端交互类
幂等表 + 状态机外部重试/回调支付回调、Webhook、MQ 消费
状态机条件更新并发下只执行一次对"只处理一次"有强要求

我的组合拳(生产常用):

  1. 前端交互类 → token 防重(体验好)
  2. 支付回调/Webhook → 幂等表 + 状态机(防重试)
  3. 数据库层 → 唯一索引兜底(最终防线)
  4. MQ 消费 → 消费幂等键(防重投)

小结

幂等设计的核心就两句话:用"唯一的业务标识"把重复请求识别出来用"先占位后回写"让重复请求只返回结果不重复执行。方案从唯一键到状态机,一层比一层通用,也一层比一层重——按场景选,别一上来就上分布式锁。

和分布式锁的关系:有人用 Redis 锁做幂等(先加锁再处理),但锁只保证"并发互斥",不保证"重复请求返回同一结果"——防并发和防重复是两码事。想彻底分清,看《锁的分类与实现原理》和《Redis 分布式锁详解》。


验证记录:文中的 Redis token 防重逻辑(SET + DEL 原子性)、幂等表占位回写逻辑(唯一键 + 状态流转),已用真实 Redis + MySQL 语义验证。Redis 部分:SET token 1 EX 120DEL 两次,第一次返回 1、第二次返回 0,实测符合"只允许一次成功"的预期。