Skip to content
第 58 章 后端 ⏱ 6 分钟阅读

第 58 章:幂等性与防重复提交 ​

学习目标 ​

  • 理解幂等性的概念
  • 学会用 token 防止重复提交
  • 学会用 Redis 唯一键防重

一、什么是幂等 ​

同一个请求,执行多次和执行一次效果相同。

转账 100 元:第一次成功,第二次不能再扣
创建订单:第一次成功,第二次不能重复

二、token 方案(防止前端重复提交) ​

⚠️ 这个 token 和 55 章的 JWT 不是一回事。碰巧都叫 token,作用完全相反:

幂等 token(本节)JWT(55 章)
干嘛防重复提交证明身份(已登录)
生命周期一次一领,用完即焚一次签发,反复使用直到过期
类比银行叫号票身份证
丢了咋办重新领一张重新登录

怎么区分:55 章的是验证"你是谁",请求头是 Authorization: Bearer xxxx;58 章的是验证"这次请求是不是重复",请求头是 Idempotent-Token: xxxx。

1. 前端进入页面 → 后端生成 token,放 Redis(setnx)
2. 提交时带 token → 后端校验 + 删 token
3. 第二次提交带同一个 token → 失败(已被删)
java
@GetMapping("/token")
public Result<String> getToken() {
    String token = UUID.randomUUID().toString();
    redis.opsForValue().set("idempotent:" + token, "1", 5, TimeUnit.MINUTES);
    return Result.ok(token);
}

@PostMapping("/submit")
public Result<Void> submit(@RequestHeader("Idempotent-Token") String token,
                           @RequestBody OrderDTO dto) {
    Boolean deleted = redis.delete("idempotent:" + token);
    if (Boolean.FALSE.equals(deleted)) {
        throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
    }
    // 业务逻辑
    return Result.ok();
}

⚠️ 坑 1:用 setnx + 校验,不是查值再删。并发情况下两个请求都查到 token 存在,然后都删,都通过。

💡 为什么要"删"而不是"查":redis.delete 是原子操作——Redis 一次完成"删并告诉你有没有删到",并发两个请求只有一个人能删成功;如果先查后删,两个请求都查到"在",逻辑就乱了。下面流程图里 Boolean deleted = redis.delete(...):返回 true = 第一次提交(通过);返回 false = token 已被别人删过(重复,拒掉)。

完整流程(图):

前端进入下单页
   ↓
GET /idempotent/token
   ↓
后端:UUID 存 Redis(key="idempotent:abc123", val="1", 5min)
   ↓
后端:把 token "abc123" 返回给前端
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
前端:用户点 "提交订单",把 token 放 header 里发请求
   ↓
POST /orders, Header: Idempotent-Token=abc123
   ↓
后端:redis.delete("idempotent:abc123")
   ├─ 删得到(token 还在) → 第一次,处理订单,✓ 返回成功
   └─ 删不到(token 已删) → 重复,抛 "DUPLICATE_SUBMIT" 拒掉

一句话总结 token 的"一次性":像银行叫号票——领一张办一次业务,办完作废,下次重新领。

2.1 多业务隔离(同一个 token 不能跨业务复用) ​

⚠️ 坑 3:多个业务场景(下单/支付/评论)不能共用同一个 token,否则会误判"重复提交"。

反例:三个场景都用同一把钥匙 idempotent:token,用户先点"支付"用掉 token,再点"发表评论"——后端一看 token 已删,提示"重复提交",但实际是评论的第一次。

java
// ❌ 错:三个场景一个钥匙
下单 → idempotent:token:abc
支付 → idempotent:token:abc    // 同一把,先到先得,后面的全报错
评论 → idempotent:token:abc

// ✅ 对:每个业务一把钥匙,用前缀区分
下单 → idempotent:order:abc
支付 → idempotent:pay:abc
评论 → idempotent:comment:abc

实现:

java
@GetMapping("/token")
public Result<String> getToken(@RequestParam String biz) {  // ① 前端告诉后端:哪个业务(order/pay/comment)
    String token = UUID.randomUUID().toString();
    redis.opsForValue().set(
        "idempotent:" + biz + ":" + token,                  // ② 拼上 biz 前缀,业务之间不打架
        "1", 5, TimeUnit.MINUTES);
    return Result.ok(token);
}

@PostMapping("/submit")
public Result<Void> submit(@RequestHeader("Idempotent-Token") String token,
                           @RequestHeader("X-Biz") String biz,    // ③ 提交时也带业务名
                           @RequestBody OrderDTO dto) {
    Boolean deleted = redis.delete("idempotent:" + biz + ":" + token);  // ④ 用 biz 前缀拼回去删
    if (Boolean.FALSE.equals(deleted)) {
        throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
    }
    return Result.ok();
}

业务量级选择:

业务量做法
1 个业务要防重写死一把钥匙,简单粗暴
2~3 个业务每个业务一个 Redis key 前缀,手动写死就行
10+ 个业务抽成注解 @Idempotent(biz="order"),自动拼前缀(参考 48 章 @ValidStatus)

三、Redis 唯一键方案(订单等) ​

java
@PostMapping("/orders")
public Result<Order> create(@RequestBody OrderDTO dto) {

    // ① 拼 Redis key:哪个用户买哪个商品 = 唯一的一单
    //    userId=123 + skuId=999 → "order:dedup:123:999"
    //    同一个用户对同一个商品,key 一样(去重粒度)
    String key = "order:dedup:" + dto.getUserId() + ":" + dto.getSkuId();

    // ② 抢锁/占位:setIfAbsent = "key 不存在才设"
    //    - 第一次请求(key 没人占过)→ 返回 true,抢成功
    //    - 并发第二次请求(key 已被自己占)→ 返回 false,拒掉
    //    60 秒后自动过期,防止 key 永远占着(兜底)
    Boolean success = redis.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS);

    // ③ 没抢到 → 重复请求,直接抛错
    if (Boolean.FALSE.equals(success)) {
        //   Boolean.FALSE.equals 写法比 !success 安全,避免空指针
        throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
    }

    // ④ 抢到了 → 真下单
    try {
        return Result.ok(orderService.create(dto));

    // ⑤ 下单失败(key 占着但事没办成)→ 删 key,允许前端重试
    //    比如库存不足抛异常,客户调整数量后再点,得能重新下单
    } catch (Exception e) {
        redis.delete(key);    // 失败释放,允许重试
        throw e;              // 异常继续往上抛,让全局 handler 处理
    }
}

💡 三个关键动作缺一不可:

行干嘛不写会怎样
① 拼 key定义"啥叫重复"——同一用户同一商品算重复去重粒度乱
② setIfAbsent原子抢锁(不是先查再设)并发会两个都通过(类似坑 1)
⑤ catch 里 delete失败释放失败后 60 秒内同请求全拒,客户炸毛

一句话总结:setIfAbsent = 抢位子——同一个人对同一个商品,redis 里只有一个位子,先到先得;失败记得把位子让出来,别人能重试。

四、数据库唯一索引(最稳妥) ​

sql
CREATE TABLE order (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(50) UNIQUE,    -- 唯一索引
    user_id BIGINT,
    amount DECIMAL(10,2)
);
java
@PostMapping("/orders")
public Result<Order> create(@RequestBody OrderDTO dto) {
    dto.setOrderNo(generateOrderNo());
    try {
        orderMapper.insert(dto);
    } catch (DuplicateKeyException e) {
        throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
    }
    return Result.ok(dto);
}

优点:

  • 数据库兜底,绝对可靠
  • 即使 Redis 挂了也不会出问题

⚠️ 坑 4:单 order_no UNIQUE 不足以防"用户重复下单"——因为 generateOrderNo() 每次都是新的随机号,用户两次提交会生成两个不同的 order_no,DB 都让插入,结果还是两单。

反例:

用户点 "立即购买" 两次(网慢重发):
  第 1 次:order_no = "ORD20260824ABC123" → 插入成功 ✓
  第 2 次:order_no = "ORD20260824XYZ789" ← 新号,跟第一次不同 → 插入成功 ✓
  结果:同一个用户买了 2 单 ❌

order_no UNIQUE 兜底的真实场景:

兜的场景做法
程序生成重复单号(雪花算法时钟回拨等极小概率)order_no UNIQUE(默认,基本撞不上)
同一业务对象不能重复(同用户同商品同天)UNIQUE(user_id, sku_id, date) 联合唯一 ← 真防重

真防重的写法:

sql
CREATE TABLE order (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(50) UNIQUE,        -- 防程序 bug
    user_id BIGINT,
    sku_id BIGINT,
    create_date DATE,
    UNIQUE KEY uk_user_sku_day (user_id, sku_id, create_date)  -- ① 关键:联合唯一,业务级防重
);

💡 UNIQUE KEY uk_user_sku_day (user_id, sku_id, create_date) 翻译:把"用户+商品+日期"这三列的组合当成一个整体判重,组合全表只能出现 1 次。

user_id | sku_id | create_date   | 能插入?
  123   |  999  | 2026-08-24    | ✓ 第一次
  123   |  999  | 2026-08-24    | ❌ 三列完全一样,撞了
  123   |  999  | 2026-08-25    | ✓(日期不同,不算同一单)
  456   |  999  | 2026-08-24    | ✓(用户不同)

单列允许重复,组合不允许——这就是"业务级防重"(防你用户重复下单),区别于 order_no UNIQUE 防的"程序 bug 级撞号"。

java
try {
    orderMapper.insert(dto);
} catch (DuplicateKeyException e) {
    // ② (user_id=123, sku_id=999, date=今天) 撞了 → 真拦住"重复下单"
    throw new BusinessException("不能重复下单");
}

💡 选型建议:

想防的场景用啥
程序意外生成重复单号order_no UNIQUE(兜底)
用户不能同一天买同一个商品UNIQUE(user_id, sku_id, date) 联合唯一
综合防重(防抖 + 防并发 + 业务规则)联合唯一 + Redis 占位(本节 + 上节组合)

五、状态机(订单状态流转) ​

5.1 状态机本身 ​

java
// 支付订单
@PostMapping("/orders/{id}/pay")
public Result<Void> pay(@PathVariable Long id) {
    int rows = orderMapper.updateStatus(id, OrderStatus.PAID, OrderStatus.UNPAID);
    if (rows == 0) {
        throw new BusinessException("订单状态已变,不能重复支付");
    }
    return Result.ok();
}
sql
UPDATE order SET status = 'PAID'
WHERE id = #{id} AND status = 'UNPAID'  -- 乐观锁 / 状态机

5.2 状态机能防啥、不能防啥 ​

⚠️ 坑 5:状态机只防"DB 状态重复置",不防"支付 SDK 被重复调用"——并发两个请求同时进,状态判断都会被绕过,可能扣两次钱。

反例:

请求 A:查订单 → UNPAID → 调支付 SDK → 扣钱 ✓
请求 B:查订单 → UNPAID → 调支付 SDK → 扣钱 ✓   ← 重复扣!
两个都 UPDATE → 一个 rows=1,一个 rows=0
结果:钱扣了 2 次,但订单只置 1 次 PAID

解决:在状态机外面再加一层防重,三种方案任选:

方案 1:前置判断(简单,有 race) ​

java
public Result<Void> pay(@PathVariable Long id) {
    Order order = orderMapper.selectById(id);
    if (order.getStatus() != OrderStatus.UNPAID) {
        return Result.ok();   // 已经支付过,直接返回成功
    }
    paymentService.callPay(order.getOrderNo(), order.getAmount());
    orderMapper.updateStatus(id, PAID, UNPAID);
    return Result.ok();
}

仍有 race——A、B 都查到 UNPAID 都通过。单线程/低并发勉强能用。

方案 2:事务 + SELECT FOR UPDATE 行锁(90% 支付场景的标准答案) ​

java
@Transactional                                                   // ① 整个流程包成事务
public Result<Void> pay(@PathVariable Long id) {
    // ② FOR UPDATE:给这行加锁,并发请求会"排队等",不会两个都进来
    Order order = orderMapper.selectByIdForUpdate(id);
    if (order.getStatus() != OrderStatus.UNPAID) {
        return Result.ok();   // ③ 排队等到的请求查到的是 PAID,直接返回成功
    }
    paymentService.callPay(order.getOrderNo(), order.getAmount());  // ④ 一个一个来,只调一次
    orderMapper.updateStatus(id, PAID, UNPAID);
    return Result.ok();
}

这就是银弹——SELECT FOR UPDATE 把"查 → 调支付 → 改状态"压在一个事务里串行化,并发谁先到谁扣钱,后排队的查到 PAID 直接返回。

💡 selectByIdForUpdate 到底是什么? = "按 ID 查并加排他锁"——SQL 末尾拼 FOR UPDATE,事务里查,别的线程会阻塞等待直到你提交;**经典用于"扣库存、抢单、抢座位"**等防超卖场景,必须在 @Transactional 里用,索引还要建对(否则行锁升级成表锁)。

方案 3:Redis 幂等键防最外层(跨服务 / 外部 API / 长流程) ​

java
public Result<Void> pay(@PathVariable Long id,
                        @RequestHeader("Pay-Idempotent-Key") String requestId) {
    String key = "pay:idem:" + requestId;

    // ① 请求进来第一件事:抢 redis 占位
    if (!redis.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.MINUTES)) {
        return Result.ok();   // ② 同一个 requestId 已经处理过,直接返回成功(不重扣)
    }
    try {
        paymentService.callPay(...);                            // ③ 调支付
        orderMapper.updateStatus(id, PAID, UNPAID);             // ④ 改状态
    } catch (Exception e) {
        redis.delete(key);                                      // ⑤ 失败释放,允许重试
        throw e;
    }
    return Result.ok();
}

适用:跨多个微服务、调用外部 API、需要前端配合生成 requestId 的场景。

5.3 三种方案怎么选 ​

方案适用优缺点
1. 前置判断单体项目、低并发简单但有 race
2. 事务 + FOR UPDATE90% 支付场景的标准答案稳,但需要 DB 事务
3. Redis 幂等键跨服务、外部 API、长流程灵活,前端配合

💡 一句话总结:状态机只管 DB 状态,业务执行靠外面的锁(事务 / Redis 幂等键)——58 章五的状态机 + 58 章三的 Redis 占位 = 完整的支付幂等。

六、MQ 消息幂等 ​

java
@RabbitListener(queues = "order.queue")
public void handle(OrderMessage msg) {
    String dedupKey = "msg:" + msg.getMessageId();
    if (redis.opsForValue().setIfAbsent(dedupKey, "1", 7, TimeUnit.DAYS)) {
        // 第一次消费,执行
        process(msg);
    } else {
        log.info("消息已消费,忽略: {}", msg.getMessageId());
    }
}

七、方案对比 ​

方案适用可靠度
Token前端按钮中
Redis 唯一键后端业务高
数据库唯一索引强一致场景最高
状态机状态流转高

⚠️ 坑 2:金融场景必须用数据库唯一索引,Redis 不可靠。

八、本章小结 ​

要点关键
Token防止前端重复提交
唯一键Redis setnx
唯一索引数据库兜底
状态机状态流转控制

动手练习 ​

  1. 实现一个 token 方案,防止按钮重复提交
  2. 给订单表加唯一索引,写一个并发测试用例

下一章:第 59 章:Sentinel 限流与熔断 →

本站基于 VitePress 构建 · 由 StackHub 团队维护