接口幂等性保障方案

接口幂等性保障方案

作者:技术团队
更新时间:2026-01-16
文档类型:技术知识库


目录


一、什么是幂等性

1.1 定义

幂等性(Idempotence):同一个请求执行一次和执行N次的效果完全相同,不会因为重复调用而产生副作用。

1.2 数学表达

f(f(x)) = f(x)

多次调用函数f的结果与调用一次的结果相同。

1.3 天然幂等的操作

HTTP方法 SQL操作 说明 幂等性
GET SELECT 查询操作 ✅ 天然幂等
PUT UPDATE SET status=1 更新为固定值 ✅ 天然幂等
DELETE DELETE WHERE id=1 删除指定数据 ✅ 天然幂等
POST INSERT 新增操作 ❌ 非幂等
POST UPDATE SET amount=amount+100 累加操作 ❌ 非幂等

二、为什么需要幂等性

2.1 重复请求的常见场景

graph TB A[重复请求来源] --> B[前端重复点击] A --> C[网络超时重试] A --> D[消息队列重复投递] A --> E[分布式系统重试] A --> F[接口调用方重试] B --> G[用户快速点击提交按钮] C --> H[请求超时后客户端自动重试] D --> I[MQ消费失败后重新投递] E --> J[微服务调用失败后重试] F --> K[第三方回调失败后重复通知]

2.2 不保证幂等的后果

场景 后果 影响
订单重复创建 同一商品生成多个订单 资金损失、库存混乱
支付重复扣款 用户被多次扣款 用户投诉、法律风险
库存重复扣减 库存超卖 无法发货、商业纠纷
消息重复消费 业务逻辑重复执行 数据不一致
积分重复发放 用户获得额外积分 运营成本增加

三、按场景分类的幂等性方案


场景1:防止前端重复提交

问题描述

  • 用户快速连续点击"提交订单"按钮
  • 网络延迟导致用户重复点击
  • 表单重复提交

解决方案:Token机制

原理

  1. 用户打开表单页面时,后端生成唯一Token
  2. 前端提交表单时携带Token
  3. 后端校验Token,验证通过后立即删除
  4. 后续重复请求因Token已失效而被拒绝

流程图

sequenceDiagram participant 前端 participant 后端 participant Redis 前端->>后端: 1. 请求获取Token 后端->>Redis: 2. 生成并存储Token Redis-->>后端: 3. 存储成功 后端-->>前端: 4. 返回Token 前端->>后端: 5. 提交表单(携带Token) 后端->>Redis: 6. 验证并删除Token (DEL) alt Token存在 Redis-->>后端: 删除成功(返回1) 后端->>后端: 7. 执行业务逻辑 后端-->>前端: 8. 返回成功 else Token不存在 Redis-->>后端: 删除失败(返回0) 后端-->>前端: 9. 返回"请勿重复提交" end 前端->>后端: 10. 用户再次点击(重复提交) 后端->>Redis: 11. 验证Token Redis-->>后端: Token已被删除 后端-->>前端: 12. 返回"请勿重复提交"

代码实现

@RestController
@RequestMapping("/order")
public class OrderController {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 获取防重复提交Token
     */
    @GetMapping("/getToken")
    public Result<String> getToken() {
        // 生成唯一Token
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = "order:token:" + token;
        
        // 存储到Redis,设置5分钟过期
        redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
        
        return Result.success(token);
    }
    
    /**
     * 提交订单(防重复提交)
     */
    @PostMapping("/submit")
    public Result<Long> submitOrder(@RequestBody OrderSubmitDTO dto) {
        String token = dto.getToken();
        if (StringUtils.isBlank(token)) {
            return Result.fail("Token不能为空");
        }
        
        // 验证Token并删除(原子操作)
        String key = "order:token:" + token;
        Boolean deleted = redisTemplate.delete(key);
        
        if (deleted == null || !deleted) {
            // Token不存在或已被删除,说明是重复提交
            return Result.fail("请勿重复提交订单");
        }
        
        try {
            // 执行业务逻辑
            Long orderId = orderService.createOrder(dto);
            return Result.success(orderId);
        } catch (Exception e) {
            // 业务异常时,可以选择恢复Token(允许重试)
            // redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
            throw e;
        }
    }
}

前端配合

// Vue示例
export default {
  data() {
    return {
      token: '',
      submitting: false
    }
  },
  async mounted() {
    // 页面加载时获取Token
    const res = await this.$axios.get('/order/getToken')
    this.token = res.data
  },
  methods: {
    async submitOrder() {
      if (this.submitting) {
        this.$message.warning('正在提交,请稍候')
        return
      }
      
      this.submitting = true
      try {
        const res = await this.$axios.post('/order/submit', {
          ...this.formData,
          token: this.token // 携带Token
        })
        this.$message.success('提交成功')
        
        // 提交成功后重新获取Token
        const tokenRes = await this.$axios.get('/order/getToken')
        this.token = tokenRes.data
      } catch (error) {
        this.$message.error(error.message)
      } finally {
        this.submitting = false
      }
    }
  }
}

优点

  • ✅ 用户体验好,前端可实时反馈
  • ✅ 实现简单,前后端配合容易
  • ✅ 支持Token过期自动失效

缺点

  • ❌ 需要前端配合传递Token
  • ❌ Redis宕机时失效(可降级为UUID去重)
  • ❌ 不适用于后端间调用

适用场景

  • 用户表单提交(订单、支付、注册等)
  • 按钮点击操作(关注、点赞等)
  • 需要前端实时反馈的场景

场景2:防止订单重复创建

问题描述

  • 用户下单时网络抖动,前端重复发起请求
  • 订单创建接口被多次调用
  • 生成多个相同内容的订单

解决方案:数据库唯一索引

原理

在数据库表中对订单号或其他业务唯一键设置唯一索引,利用数据库约束防止重复插入。

数据库设计

CREATE TABLE `t_order` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `product_id` BIGINT NOT NULL COMMENT '商品ID',
  `amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`) -- 唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

代码实现

@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 创建订单(防重复)
     */
    @Transactional(rollbackFor = Exception.class)
    public Long createOrder(OrderCreateDTO dto) {
        // 生成订单号(客户端生成或后端生成)
        String orderNo = dto.getOrderNo();
        if (StringUtils.isBlank(orderNo)) {
            // 后端生成订单号:时间戳 + 用户ID + 随机数
            orderNo = generateOrderNo(dto.getUserId());
        }
        
        // 构建订单对象
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(dto.getUserId());
        order.setProductId(dto.getProductId());
        order.setAmount(dto.getAmount());
        order.setStatus(OrderStatus.WAIT_PAY);
        
        try {
            // 插入订单
            orderMapper.insert(order);
            log.info("创建订单成功: orderNo={}", orderNo);
            return order.getId();
            
        } catch (DuplicateKeyException e) {
            // 捕获唯一索引冲突异常
            log.warn("订单号已存在,重复创建: orderNo={}", orderNo);
            
            // 查询已存在的订单并返回
            Order existOrder = orderMapper.selectByOrderNo(orderNo);
            if (existOrder != null) {
                return existOrder.getId();
            }
            
            throw new BusinessException("订单创建失败,请稍后重试");
        }
    }
    
    /**
     * 生成订单号
     */
    private String generateOrderNo(Long userId) {
        // 格式:yyyyMMddHHmmss + 用户ID后4位 + 4位随机数
        String timestamp = LocalDateTime.now().format(
            DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
        String userSuffix = String.format("%04d", userId % 10000);
        String random = String.format("%04d", new Random().nextInt(10000));
        
        return timestamp + userSuffix + random;
    }
}

Mapper

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    
    /**
     * 根据订单号查询
     */
    @Select("SELECT * FROM t_order WHERE order_no = #{orderNo}")
    Order selectByOrderNo(@Param("orderNo") String orderNo);
}

优点

  • ✅ 数据库层面保证,最可靠
  • ✅ 无需额外中间件(Redis等)
  • ✅ 即使应用重启也不影响

缺点

  • ❌ 只能防止插入重复,不适用于更新操作
  • ❌ 唯一索引冲突会抛异常,需要捕获处理
  • ❌ 数据库性能开销(索引维护)

适用场景

  • 订单创建
  • 用户注册(手机号、邮箱唯一)
  • 流水号生成(交易流水、支付流水)
  • 任何需要保证数据唯一性的插入操作

场景3:防止支付重复扣款

问题描述

  • 用户支付时网络异常,触发重试
  • 支付网关回调接口被多次调用
  • 第三方支付平台可能重复发送通知
  • 导致用户被多次扣款

解决方案:业务单号(outTradeNo)幂等

原理

使用业务单号(商户订单号、支付流水号)作为唯一标识,结合Redis和数据库双重保证。

流程图

sequenceDiagram participant 支付网关 participant 应用服务器 participant Redis participant MySQL 支付网关->>应用服务器: 1. 支付回调(outTradeNo) 应用服务器->>Redis: 2. SETNX(key, 1) alt 首次请求 Redis-->>应用服务器: 设置成功 应用服务器->>MySQL: 3. 查询订单状态 alt 订单未支付 应用服务器->>MySQL: 4. 更新订单状态为已支付 应用服务器->>MySQL: 5. 插入支付流水记录 MySQL-->>应用服务器: 更新成功 应用服务器-->>支付网关: 6. 返回SUCCESS else 订单已支付 应用服务器-->>支付网关: 返回SUCCESS(幂等) end else 重复请求 Redis-->>应用服务器: 设置失败(Key已存在) 应用服务器->>MySQL: 查询支付结果 应用服务器-->>支付网关: 7. 返回SUCCESS(幂等) end 支付网关->>应用服务器: 8. 再次回调(重复) 应用服务器->>Redis: 9. SETNX(key, 1) Redis-->>应用服务器: 设置失败 应用服务器-->>支付网关: 10. 直接返回SUCCESS

代码实现

@Service
public class PaymentService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private PaymentRecordMapper paymentRecordMapper;
    
    /**
     * 处理支付回调(幂等)
     */
    @Transactional(rollbackFor = Exception.class)
    public void handlePayCallback(PayCallbackDTO dto) {
        String outTradeNo = dto.getOutTradeNo(); // 商户订单号
        String transactionId = dto.getTransactionId(); // 第三方交易号
        
        // 1. Redis幂等性检查(快速失败)
        String idempotentKey = "pay:callback:" + outTradeNo;
        Boolean lockSuccess = redisTemplate.opsForValue()
            .setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
        
        if (lockSuccess == null || !lockSuccess) {
            log.warn("重复的支付回调: outTradeNo={}", outTradeNo);
            // 查询支付结果并返回(幂等)
            PaymentRecord record = paymentRecordMapper.selectByOutTradeNo(outTradeNo);
            if (record != null && record.getStatus() == PayStatus.SUCCESS) {
                return; // 已支付成功,幂等返回
            }
        }
        
        try {
            // 2. 查询订单
            Order order = orderMapper.selectByOrderNo(outTradeNo);
            if (order == null) {
                throw new BusinessException("订单不存在: " + outTradeNo);
            }
            
            // 3. 检查订单状态(数据库层幂等)
            if (order.getStatus() == OrderStatus.PAID) {
                log.info("订单已支付,幂等返回: orderNo={}", outTradeNo);
                return;
            }
            
            if (order.getStatus() != OrderStatus.WAIT_PAY) {
                throw new BusinessException("订单状态异常: " + order.getStatus());
            }
            
            // 4. 更新订单状态(带状态校验)
            int updated = orderMapper.updateStatusWithCheck(
                order.getId(),
                OrderStatus.WAIT_PAY,  // 旧状态
                OrderStatus.PAID       // 新状态
            );
            
            if (updated == 0) {
                log.warn("订单状态已变更: orderNo={}", outTradeNo);
                return; // 幂等返回
            }
            
            // 5. 插入支付流水记录(唯一索引保证)
            PaymentRecord record = new PaymentRecord();
            record.setOutTradeNo(outTradeNo);
            record.setTransactionId(transactionId);
            record.setAmount(order.getAmount());
            record.setStatus(PayStatus.SUCCESS);
            record.setPayTime(LocalDateTime.now());
            
            try {
                paymentRecordMapper.insert(record);
            } catch (DuplicateKeyException e) {
                log.warn("支付流水已存在: outTradeNo={}", outTradeNo);
                return; // 幂等返回
            }
            
            // 6. 发送支付成功消息(异步)
            // mqProducer.send(new PaySuccessEvent(order.getId()));
            
            log.info("支付回调处理成功: orderNo={}, transactionId={}", 
                outTradeNo, transactionId);
            
        } catch (Exception e) {
            // 业务异常时删除Redis Key,允许重试
            redisTemplate.delete(idempotentKey);
            log.error("支付回调处理失败: outTradeNo={}", outTradeNo, e);
            throw e;
        }
    }
}

Mapper

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    
    /**
     * 带状态校验的更新(CAS)
     */
    @Update("UPDATE t_order SET status=#{newStatus}, update_time=NOW() " +
            "WHERE id=#{orderId} AND status=#{oldStatus}")
    int updateStatusWithCheck(@Param("orderId") Long orderId,
                              @Param("oldStatus") Integer oldStatus,
                              @Param("newStatus") Integer newStatus);
}

@Mapper
public interface PaymentRecordMapper extends BaseMapper<PaymentRecord> {
    
    /**
     * 根据商户订单号查询
     */
    @Select("SELECT * FROM t_payment_record WHERE out_trade_no=#{outTradeNo}")
    PaymentRecord selectByOutTradeNo(@Param("outTradeNo") String outTradeNo);
}

数据库设计

-- 支付流水表
CREATE TABLE `t_payment_record` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `out_trade_no` VARCHAR(64) NOT NULL COMMENT '商户订单号',
  `transaction_id` VARCHAR(64) NOT NULL COMMENT '第三方交易号',
  `amount` DECIMAL(10,2) NOT NULL COMMENT '支付金额',
  `status` TINYINT NOT NULL COMMENT '支付状态',
  `pay_time` DATETIME COMMENT '支付时间',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_out_trade_no` (`out_trade_no`), -- 商户订单号唯一
  UNIQUE KEY `uk_transaction_id` (`transaction_id`) -- 第三方交易号唯一
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';

优点

  • ✅ Redis + 数据库双重保证,安全可靠
  • ✅ 快速失败(Redis层),性能好
  • ✅ 数据库兜底(唯一索引),数据一致
  • ✅ 支持幂等查询结果

缺点

  • ❌ 实现相对复杂
  • ❌ 依赖Redis(可降级为纯数据库方案)

适用场景

  • 支付回调处理
  • 第三方接口回调
  • 退款处理
  • 任何需要保证资金安全的场景

场景4:防止库存超卖

问题描述

  • 高并发下多个用户同时购买同一商品
  • 库存数据被并发修改
  • 实际扣减次数超过库存数量
  • 导致库存变为负数,超卖

解决方案对比

方案 原理 性能 复杂度 推荐场景
分布式锁 Redisson锁 中等并发
数据库悲观锁 SELECT FOR UPDATE 低并发
数据库乐观锁 版本号 高并发
Redis原子操作 DECR 很高 超高并发

方案1:分布式锁(Redisson)

代码实现

@Service
public class StockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StockMapper stockMapper;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 扣减库存(分布式锁)
     */
    public void deductStock(Long productId, Integer count, Long userId) {
        String lockKey = "stock:lock:" + productId;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试加锁:最多等待10秒,锁30秒后自动释放
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new BusinessException("系统繁忙,请稍后重试");
            }
            
            // 1. 查询库存
            Stock stock = stockMapper.selectById(productId);
            if (stock == null) {
                throw new BusinessException("商品不存在");
            }
            
            // 2. 检查库存
            if (stock.getAmount() < count) {
                throw new BusinessException("库存不足");
            }
            
            // 3. 扣减库存
            stock.setAmount(stock.getAmount() - count);
            stockMapper.updateById(stock);
            
            // 4. 创建订单
            orderService.createOrder(userId, productId, count);
            
            log.info("扣减库存成功: productId={}, count={}, remaining={}", 
                productId, count, stock.getAmount());
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("获取锁失败");
        } finally {
            // 释放锁(仅当前线程持有时才释放)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

优点

  • ✅ 实现简单,逻辑清晰
  • ✅ 适用于复杂业务逻辑
  • ✅ 支持锁自动续期(Redisson Watchdog)

缺点

  • ❌ 性能相对较低(锁等待)
  • ❌ 依赖Redis,Redis宕机影响业务
  • ❌ 需要处理锁超时、死锁等问题

方案2:数据库乐观锁(推荐)

数据库设计

CREATE TABLE `t_stock` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `product_id` BIGINT NOT NULL COMMENT '商品ID',
  `amount` INT NOT NULL COMMENT '库存数量',
  `version` INT NOT NULL DEFAULT 0 COMMENT '版本号',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';

代码实现

@Service
public class StockService {
    
    @Autowired
    private StockMapper stockMapper;
    
    /**
     * 扣减库存(乐观锁,带重试)
     */
    public void deductStock(Long productId, Integer count) {
        int maxRetry = 3; // 最多重试3次
        int retryCount = 0;
        
        while (retryCount < maxRetry) {
            try {
                // 1. 查询库存(不加锁)
                Stock stock = stockMapper.selectById(productId);
                if (stock == null) {
                    throw new BusinessException("商品不存在");
                }
                
                // 2. 检查库存
                if (stock.getAmount() < count) {
                    throw new BusinessException("库存不足");
                }
                
                // 3. 扣减库存(乐观锁更新)
                int updated = stockMapper.deductWithVersion(
                    productId, 
                    count, 
                    stock.getVersion()
                );
                
                if (updated > 0) {
                    // 更新成功,跳出重试循环
                    log.info("扣减库存成功: productId={}, count={}, version={}", 
                        productId, count, stock.getVersion());
                    return;
                } else {
                    // 更新失败,版本号冲突,重试
                    retryCount++;
                    log.warn("库存扣减冲突,重试第{}次: productId={}", retryCount, productId);
                    
                    if (retryCount >= maxRetry) {
                        throw new BusinessException("系统繁忙,请稍后重试");
                    }
                    
                    // 短暂等待后重试(避免CPU空转)
                    Thread.sleep(50);
                }
                
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new BusinessException("系统异常");
            }
        }
    }
}

Mapper

@Mapper
public interface StockMapper extends BaseMapper<Stock> {
    
    /**
     * 扣减库存(乐观锁)
     * 返回影响行数:0表示版本号冲突,1表示成功
     */
    @Update("UPDATE t_stock " +
            "SET amount = amount - #{count}, " +
            "    version = version + 1 " +
            "WHERE product_id = #{productId} " +
            "  AND amount >= #{count} " +  // 防止库存变负数
            "  AND version = #{version}")   // 版本号校验
    int deductWithVersion(@Param("productId") Long productId,
                          @Param("count") Integer count,
                          @Param("version") Integer version);
}

优点

  • ✅ 性能好,无锁等待
  • ✅ 不依赖Redis等中间件
  • ✅ 数据库原生支持

缺点

  • ❌ 冲突时需要重试,用户体验较差
  • ❌ 高并发下重试次数多

方案3:Redis原子操作(超高并发)

代码实现

@Service
public class StockService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private StockMapper stockMapper;
    
    /**
     * 预热库存到Redis
     */
    public void preloadStock(Long productId) {
        Stock stock = stockMapper.selectById(productId);
        String key = "stock:" + productId;
        redisTemplate.opsForValue().set(key, String.valueOf(stock.getAmount()));
    }
    
    /**
     * 扣减库存(Redis原子操作)
     */
    public void deductStock(Long productId, Integer count) {
        String key = "stock:" + productId;
        
        // 使用Lua脚本保证原子性
        String luaScript = 
            "local stock = tonumber(redis.call('get', KEYS[1])) " +
            "if stock >= tonumber(ARGV[1]) then " +
            "  redis.call('decrby', KEYS[1], ARGV[1]) " +
            "  return 1 " +
            "else " +
            "  return 0 " +
            "end";
        
        RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
        Long result = redisTemplate.execute(
            script, 
            Collections.singletonList(key), 
            String.valueOf(count)
        );
        
        if (result == null || result == 0) {
            throw new BusinessException("库存不足");
        }
        
        // 异步更新数据库(最终一致性)
        asyncUpdateDatabase(productId, count);
        
        log.info("扣减Redis库存成功: productId={}, count={}", productId, count);
    }
    
    /**
     * 异步更新数据库
     */
    @Async
    public void asyncUpdateDatabase(Long productId, Integer count) {
        try {
            stockMapper.deduct(productId, count);
        } catch (Exception e) {
            log.error("异步更新数据库失败: productId={}", productId, e);
            // 补偿机制:恢复Redis库存
            String key = "stock:" + productId;
            redisTemplate.opsForValue().increment(key, count);
        }
    }
}

优点

  • ✅ 性能极高,支持超高并发
  • ✅ 原子操作,无竞态条件
  • ✅ 无锁,无等待

缺点

  • ❌ 实现复杂,需要数据同步
  • ❌ 数据一致性要求高(需要补偿机制)
  • ❌ Redis宕机影响业务

场景5:防止MQ消息重复消费

问题描述

  • MQ消息可能重复投递(网络波动、消费者重启)
  • 消费者业务逻辑被重复执行
  • 导致数据重复或状态异常

解决方案:消息ID + 数据库唯一索引

流程图

sequenceDiagram participant MQ participant 消费者 participant Redis participant MySQL MQ->>消费者: 1. 投递消息(messageId) 消费者->>Redis: 2. SETNX(消息ID) alt 首次消费 Redis-->>消费者: 设置成功 消费者->>MySQL: 3. 插入消费记录(唯一索引) alt 插入成功 MySQL-->>消费者: 插入成功 消费者->>消费者: 4. 执行业务逻辑 消费者->>MQ: 5. ACK确认 else 记录已存在 MySQL-->>消费者: 唯一索引冲突 消费者->>MQ: ACK确认(幂等) end else 重复消费 Redis-->>消费者: 设置失败 消费者->>MQ: 6. ACK确认(幂等) end MQ->>消费者: 7. 再次投递(重复) 消费者->>Redis: 8. SETNX(消息ID) Redis-->>消费者: 设置失败 消费者->>MQ: 9. 直接ACK

数据库设计

-- 消息消费记录表
CREATE TABLE `t_message_consume_log` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `message_id` VARCHAR(64) NOT NULL COMMENT '消息ID',
  `topic` VARCHAR(64) NOT NULL COMMENT '消息主题',
  `content` TEXT COMMENT '消息内容',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '消费状态:0-消费中,1-成功,2-失败',
  `consume_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_id` (`message_id`) -- 消息ID唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息消费日志';

代码实现

/**
 * RocketMQ消费者(幂等处理)
 */
@Component
@RocketMQMessageListener(
    topic = "ORDER_TOPIC",
    consumerGroup = "order-consumer-group"
)
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private MessageConsumeLogMapper consumeLogMapper;
    
    @Autowired
    private OrderService orderService;
    
    @Override
    public void onMessage(MessageExt message) {
        String messageId = message.getMsgId();
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
        
        log.info("收到消息: messageId={}, body={}", messageId, body);
        
        try {
            // 1. Redis快速幂等检查
            String idempotentKey = "mq:consumed:" + messageId;
            Boolean lockSuccess = redisTemplate.opsForValue()
                .setIfAbsent(idempotentKey, "1", 24, TimeUnit.HOURS);
            
            if (lockSuccess == null || !lockSuccess) {
                log.warn("重复消费的消息: messageId={}", messageId);
                return; // 幂等返回,ACK确认
            }
            
            // 2. 数据库幂等记录(防止Redis数据丢失)
            MessageConsumeLog consumeLog = new MessageConsumeLog();
            consumeLog.setMessageId(messageId);
            consumeLog.setTopic(message.getTopic());
            consumeLog.setContent(body);
            consumeLog.setStatus(ConsumeStatus.CONSUMING);
            
            try {
                consumeLogMapper.insert(consumeLog);
            } catch (DuplicateKeyException e) {
                log.warn("消息已消费过: messageId={}", messageId);
                return; // 幂等返回
            }
            
            // 3. 执行业务逻辑
            OrderMessage orderMsg = JSON.parseObject(body, OrderMessage.class);
            orderService.processOrder(orderMsg);
            
            // 4. 更新消费状态为成功
            consumeLog.setStatus(ConsumeStatus.SUCCESS);
            consumeLogMapper.updateById(consumeLog);
            
            log.info("消息消费成功: messageId={}", messageId);
            
        } catch (Exception e) {
            log.error("消息消费失败: messageId={}", messageId, e);
            
            // 更新消费状态为失败
            consumeLogMapper.updateStatusByMessageId(
                messageId, ConsumeStatus.FAILED);
            
            // 删除Redis标识,允许重新消费
            String idempotentKey = "mq:consumed:" + messageId;
            redisTemplate.delete(idempotentKey);
            
            // 抛出异常,触发消息重试
            throw new RuntimeException("消息处理失败", e);
        }
    }
}

Kafka消费者示例

@Component
public class KafkaOrderConsumer {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private MessageConsumeLogMapper consumeLogMapper;
    
    @KafkaListener(topics = "order-topic", groupId = "order-group")
    public void consume(ConsumerRecord<String, String> record) {
        // 使用 offset + partition + topic 作为唯一ID
        String messageId = String.format("%s-%d-%d", 
            record.topic(), record.partition(), record.offset());
        
        String body = record.value();
        
        log.info("收到Kafka消息: messageId={}, body={}", messageId, body);
        
        // 幂等处理逻辑同上...
    }
}

优点

  • ✅ Redis + 数据库双重保证
  • ✅ 适用于各种MQ(RocketMQ、Kafka、RabbitMQ)
  • ✅ 可追溯消费记录

缺点

  • ❌ 需要额外的消费日志表
  • ❌ 依赖Redis(可降级为纯数据库方案)

适用场景

  • RocketMQ消息消费
  • Kafka消息消费
  • RabbitMQ消息消费
  • 任何需要保证消息幂等的场景

场景6:防止状态重复变更

问题描述

  • 订单状态从"待支付"变更为"已支付"
  • 并发情况下可能被多次变更
  • 状态流转不符合业务规则

解决方案:状态机 + CAS更新

状态机设计

stateDiagram-v2 [*] --> 待支付: 创建订单 待支付 --> 已支付: 支付成功 待支付 --> 已取消: 超时/用户取消 已支付 --> 待发货: 商家确认 待发货 --> 已发货: 物流发货 已发货 --> 已完成: 用户确认收货 已完成 --> 已评价: 用户评价 已支付 --> 退款中: 用户申请退款 退款中 --> 已退款: 退款成功 退款中 --> 已支付: 拒绝退款 已取消 --> [*] 已评价 --> [*] 已退款 --> [*]

代码实现

/**
 * 订单状态枚举
 */
public enum OrderStatus {
    WAIT_PAY(0, "待支付"),
    PAID(1, "已支付"),
    WAIT_DELIVER(2, "待发货"),
    DELIVERED(3, "已发货"),
    COMPLETED(4, "已完成"),
    CANCELLED(5, "已取消"),
    REFUNDING(6, "退款中"),
    REFUNDED(7, "已退款");
    
    private final Integer code;
    private final String desc;
    
    // 定义允许的状态流转规则
    private static final Map<OrderStatus, Set<OrderStatus>> STATE_MACHINE = 
        new HashMap<>();
    
    static {
        STATE_MACHINE.put(WAIT_PAY, Set.of(PAID, CANCELLED));
        STATE_MACHINE.put(PAID, Set.of(WAIT_DELIVER, REFUNDING));
        STATE_MACHINE.put(WAIT_DELIVER, Set.of(DELIVERED, REFUNDING));
        STATE_MACHINE.put(DELIVERED, Set.of(COMPLETED, REFUNDING));
        STATE_MACHINE.put(COMPLETED, Set.of(REFUNDING));
        STATE_MACHINE.put(REFUNDING, Set.of(REFUNDED, PAID));
    }
    
    /**
     * 检查状态流转是否合法
     */
    public boolean canTransitionTo(OrderStatus target) {
        Set<OrderStatus> allowedStates = STATE_MACHINE.get(this);
        return allowedStates != null && allowedStates.contains(target);
    }
}

Service实现

@Service
public class OrderStatusService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 更新订单状态(幂等)
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateOrderStatus(Long orderId, OrderStatus targetStatus) {
        // 1. 查询当前订单状态
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        
        OrderStatus currentStatus = OrderStatus.fromCode(order.getStatus());
        
        // 2. 幂等性检查:如果已经是目标状态,直接返回
        if (currentStatus == targetStatus) {
            log.info("订单状态已是目标状态,幂等返回: orderId={}, status={}", 
                orderId, targetStatus);
            return;
        }
        
        // 3. 状态机校验:检查是否允许流转
        if (!currentStatus.canTransitionTo(targetStatus)) {
            throw new BusinessException(
                String.format("订单状态不允许从[%s]变更为[%s]", 
                    currentStatus.getDesc(), targetStatus.getDesc())
            );
        }
        
        // 4. CAS更新:带状态校验的更新(乐观锁)
        int updated = orderMapper.updateStatusWithCheck(
            orderId,
            currentStatus.getCode(),  // 期望的旧状态
            targetStatus.getCode()    // 目标新状态
        );
        
        if (updated == 0) {
            log.warn("订单状态更新失败,可能已被其他请求修改: orderId={}", orderId);
            throw new BusinessException("订单状态已变更,请刷新后重试");
        }
        
        // 5. 记录状态变更日志
        OrderStatusLog statusLog = new OrderStatusLog();
        statusLog.setOrderId(orderId);
        statusLog.setOldStatus(currentStatus.getCode());
        statusLog.setNewStatus(targetStatus.getCode());
        statusLog.setChangeTime(LocalDateTime.now());
        orderStatusLogMapper.insert(statusLog);
        
        log.info("订单状态更新成功: orderId={}, {} -> {}", 
            orderId, currentStatus.getDesc(), targetStatus.getDesc());
    }
}

Mapper

@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    
    /**
     * CAS更新状态(带状态校验)
     */
    @Update("UPDATE t_order " +
            "SET status = #{newStatus}, update_time = NOW() " +
            "WHERE id = #{orderId} AND status = #{oldStatus}")
    int updateStatusWithCheck(@Param("orderId") Long orderId,
                              @Param("oldStatus") Integer oldStatus,
                              @Param("newStatus") Integer newStatus);
}

优点

  • ✅ 业务语义清晰,符合领域模型
  • ✅ 状态流转规则集中管理
  • ✅ CAS更新保证并发安全
  • ✅ 天然支持幂等(相同状态直接返回)

缺点

  • ❌ 状态机设计需要前期规划
  • ❌ 状态流转规则复杂时维护成本高

适用场景

  • 订单状态流转
  • 支付状态流转
  • 审批流程
  • 任何有明确状态流转规则的业务

场景7:防止余额重复扣减

问题描述

  • 用户余额扣减操作被重复执行
  • 并发扣减导致余额错误
  • 余额不足时仍被扣减(负数)

解决方案:数据库行锁 + 余额校验

代码实现

@Service
public class AccountService {
    
    @Autowired
    private AccountMapper accountMapper;
    
    @Autowired
    private AccountLogMapper accountLogMapper;
    
    /**
     * 扣减余额(幂等)
     */
    @Transactional(rollbackFor = Exception.class)
    public void deductBalance(Long userId, BigDecimal amount, String bizNo) {
        // 1. 幂等性检查:根据业务单号查询扣款记录
        AccountLog existLog = accountLogMapper.selectByBizNo(bizNo);
        if (existLog != null) {
            log.warn("重复的扣款请求: userId={}, bizNo={}", userId, bizNo);
            return; // 幂等返回
        }
        
        // 2. 查询账户并加行锁(SELECT FOR UPDATE)
        Account account = accountMapper.selectByIdForUpdate(userId);
        if (account == null) {
            throw new BusinessException("账户不存在");
        }
        
        // 3. 检查余额是否足够
        if (account.getBalance().compareTo(amount) < 0) {
            throw new BusinessException("余额不足");
        }
        
        // 4. 扣减余额
        BigDecimal newBalance = account.getBalance().subtract(amount);
        account.setBalance(newBalance);
        accountMapper.updateById(account);
        
        // 5. 记录扣款流水(唯一索引保证幂等)
        AccountLog accountLog = new AccountLog();
        accountLog.setUserId(userId);
        accountLog.setBizNo(bizNo); // 业务单号(唯一)
        accountLog.setType(AccountLogType.DEDUCT);
        accountLog.setAmount(amount);
        accountLog.setBeforeBalance(account.getBalance().add(amount));
        accountLog.setAfterBalance(newBalance);
        accountLog.setCreateTime(LocalDateTime.now());
        
        try {
            accountLogMapper.insert(accountLog);
        } catch (DuplicateKeyException e) {
            // 唯一索引冲突,说明已扣款
            log.warn("扣款记录已存在: bizNo={}", bizNo);
            throw new BusinessException("请勿重复扣款");
        }
        
        log.info("扣减余额成功: userId={}, amount={}, bizNo={}, newBalance={}", 
            userId, amount, bizNo, newBalance);
    }
    
    /**
     * 充值余额(幂等)
     */
    @Transactional(rollbackFor = Exception.class)
    public void rechargeBalance(Long userId, BigDecimal amount, String bizNo) {
        // 幂等性检查
        AccountLog existLog = accountLogMapper.selectByBizNo(bizNo);
        if (existLog != null) {
            log.warn("重复的充值请求: userId={}, bizNo={}", userId, bizNo);
            return;
        }
        
        // 查询账户并加锁
        Account account = accountMapper.selectByIdForUpdate(userId);
        if (account == null) {
            throw new BusinessException("账户不存在");
        }
        
        // 增加余额
        BigDecimal newBalance = account.getBalance().add(amount);
        account.setBalance(newBalance);
        accountMapper.updateById(account);
        
        // 记录充值流水
        AccountLog accountLog = new AccountLog();
        accountLog.setUserId(userId);
        accountLog.setBizNo(bizNo);
        accountLog.setType(AccountLogType.RECHARGE);
        accountLog.setAmount(amount);
        accountLog.setBeforeBalance(account.getBalance().subtract(amount));
        accountLog.setAfterBalance(newBalance);
        accountLogMapper.insert(accountLog);
        
        log.info("充值余额成功: userId={}, amount={}, bizNo={}, newBalance={}", 
            userId, amount, bizNo, newBalance);
    }
}

Mapper

@Mapper
public interface AccountMapper extends BaseMapper<Account> {
    
    /**
     * 查询账户并加行锁
     */
    @Select("SELECT * FROM t_account WHERE user_id = #{userId} FOR UPDATE")
    Account selectByIdForUpdate(@Param("userId") Long userId);
}

@Mapper
public interface AccountLogMapper extends BaseMapper<AccountLog> {
    
    /**
     * 根据业务单号查询流水
     */
    @Select("SELECT * FROM t_account_log WHERE biz_no = #{bizNo}")
    AccountLog selectByBizNo(@Param("bizNo") String bizNo);
}

数据库设计

-- 账户表
CREATE TABLE `t_account` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `balance` DECIMAL(20,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
  `frozen_balance` DECIMAL(20,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户表';

-- 账户流水表
CREATE TABLE `t_account_log` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号',
  `type` TINYINT NOT NULL COMMENT '类型:1-充值,2-扣减',
  `amount` DECIMAL(20,2) NOT NULL COMMENT '金额',
  `before_balance` DECIMAL(20,2) NOT NULL COMMENT '变更前余额',
  `after_balance` DECIMAL(20,2) NOT NULL COMMENT '变更后余额',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz_no` (`biz_no`), -- 业务单号唯一
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户流水表';

优点

  • ✅ SELECT FOR UPDATE 保证并发安全
  • ✅ 业务单号保证幂等性
  • ✅ 流水记录可追溯

缺点

  • ❌ 行锁性能相对较低
  • ❌ 可能发生死锁(需要注意锁顺序)

适用场景

  • 用户余额扣减/充值
  • 积分变更
  • 虚拟货币操作
  • 任何涉及金额计算的场景

场景8:防止第三方接口重复调用

问题描述

  • 调用第三方接口(短信、邮件、推送)
  • 网络异常导致重试
  • 第三方接口被重复调用
  • 造成资源浪费或费用增加

解决方案:请求指纹 + Redis缓存

代码实现

@Service
public class SmsService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private ThirdPartySmsClient smsClient;
    
    /**
     * 发送短信(幂等)
     */
    public void sendSms(String mobile, String content, String bizType) {
        // 1. 生成请求指纹(MD5)
        String fingerprint = generateFingerprint(mobile, content, bizType);
        String cacheKey = "sms:fingerprint:" + fingerprint;
        
        // 2. 检查是否已发送(1小时内)
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.isNotBlank(cached)) {
            log.warn("重复的短信发送请求: mobile={}, bizType={}", 
                mobile, bizType);
            return; // 幂等返回
        }
        
        try {
            // 3. 调用第三方接口发送短信
            SmsResult result = smsClient.send(mobile, content);
            
            if (!result.isSuccess()) {
                throw new BusinessException("短信发送失败: " + result.getMessage());
            }
            
            // 4. 记录发送成功标识(1小时过期)
            redisTemplate.opsForValue().set(
                cacheKey, 
                result.getMessageId(), 
                1, 
                TimeUnit.HOURS
            );
            
            log.info("短信发送成功: mobile={}, messageId={}", 
                mobile, result.getMessageId());
            
        } catch (Exception e) {
            log.error("短信发送异常: mobile={}", mobile, e);
            throw new BusinessException("短信发送失败,请稍后重试");
        }
    }
    
    /**
     * 生成请求指纹
     */
    private String generateFingerprint(String mobile, String content, String bizType) {
        String raw = mobile + "|" + content + "|" + bizType;
        return DigestUtils.md5DigestAsHex(raw.getBytes(StandardCharsets.UTF_8));
    }
}

验证码发送(限流)

@Service
public class CaptchaService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private SmsService smsService;
    
    /**
     * 发送验证码(幂等 + 限流)
     */
    public void sendCaptcha(String mobile, CaptchaType type) {
        String rateLimitKey = "captcha:limit:" + mobile + ":" + type;
        String cacheKey = "captcha:code:" + mobile + ":" + type;
        
        // 1. 限流检查:60秒内只能发送一次
        Long ttl = redisTemplate.getExpire(rateLimitKey, TimeUnit.SECONDS);
        if (ttl != null && ttl > 0) {
            throw new BusinessException(
                String.format("操作过于频繁,请%d秒后再试", ttl));
        }
        
        // 2. 生成6位验证码
        String code = String.format("%06d", new Random().nextInt(1000000));
        
        // 3. 发送短信
        String content = String.format("您的验证码是:%s,5分钟内有效", code);
        smsService.sendSms(mobile, content, type.name());
        
        // 4. 缓存验证码(5分钟有效)
        redisTemplate.opsForValue().set(cacheKey, code, 5, TimeUnit.MINUTES);
        
        // 5. 设置限流标识(60秒)
        redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
        
        log.info("验证码发送成功: mobile={}, type={}", mobile, type);
    }
    
    /**
     * 校验验证码
     */
    public boolean verifyCaptcha(String mobile, CaptchaType type, String code) {
        String cacheKey = "captcha:code:" + mobile + ":" + type;
        String cached = redisTemplate.opsForValue().get(cacheKey);
        
        if (StringUtils.isBlank(cached)) {
            return false; // 验证码不存在或已过期
        }
        
        boolean valid = cached.equals(code);
        
        if (valid) {
            // 验证成功后删除验证码(一次性使用)
            redisTemplate.delete(cacheKey);
        }
        
        return valid;
    }
}

邮件发送(异步 + 幂等)

@Service
public class EmailService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private JavaMailSender mailSender;
    
    /**
     * 异步发送邮件(幂等)
     */
    @Async
    public void sendEmail(String to, String subject, String content, String bizNo) {
        String cacheKey = "email:sent:" + bizNo;
        
        // 幂等性检查
        Boolean sent = redisTemplate.hasKey(cacheKey);
        if (Boolean.TRUE.equals(sent)) {
            log.warn("重复的邮件发送请求: to={}, bizNo={}", to, bizNo);
            return;
        }
        
        try {
            // 构建邮件
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true);
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, true); // 支持HTML
            
            // 发送邮件
            mailSender.send(message);
            
            // 标记已发送(24小时)
            redisTemplate.opsForValue().set(cacheKey, "1", 24, TimeUnit.HOURS);
            
            log.info("邮件发送成功: to={}, subject={}, bizNo={}", 
                to, subject, bizNo);
            
        } catch (Exception e) {
            log.error("邮件发送失败: to={}, bizNo={}", to, bizNo, e);
            // 不抛出异常,避免异步任务失败
        }
    }
}

优点

  • ✅ 防止重复调用第三方接口
  • ✅ 节省成本(短信、推送等按次收费)
  • ✅ 支持限流控制

缺点

  • ❌ 依赖Redis,Redis宕机影响功能
  • ❌ 指纹算法需要合理设计

适用场景

  • 短信发送
  • 邮件发送
  • Push推送
  • 第三方API调用(支付、物流查询等)

四、方案对比与选择指南

4.1 方案对比表

方案 实现复杂度 性能 可靠性 依赖组件 适用场景
Token机制 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ Redis 前端表单提交
唯一索引 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 数据库 订单创建、用户注册
业务单号 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Redis+数据库 支付、退款
分布式锁 ⭐⭐⭐ ⭐⭐ ⭐⭐⭐ Redis 库存扣减、复杂业务
悲观锁 ⭐⭐⭐⭐ 数据库 低并发场景
乐观锁 ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 数据库 高并发场景
状态机 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 数据库 状态流转业务
Redis原子操作 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ Redis 超高并发

4.2 决策树

graph TD A[需要保证幂等性] --> B{是否前端操作?} B -->|是| C[Token机制] B -->|否| D{是否涉及金额?} D -->|是| E{支付还是余额?} E -->|支付| F[业务单号 + 唯一索引] E -->|余额| G[行锁 + 流水记录] D -->|否| H{是否高并发?} H -->|是| I{读多还是写多?} I -->|写多| J[Redis原子操作] I -->|读多| K[乐观锁] H -->|否| L{是否状态流转?} L -->|是| M[状态机 + CAS] L -->|否| N[唯一索引或分布式锁]

4.3 场景选择速查表

业务场景 推荐方案 备选方案
用户下单 Token + 唯一索引 分布式锁
支付回调 业务单号 + 唯一索引 状态机
库存扣减 乐观锁 分布式锁、Redis原子
余额变更 行锁 + 流水记录 分布式锁
订单状态变更 状态机 + CAS 乐观锁
MQ消息消费 消息ID + 唯一索引 分布式锁
短信发送 请求指纹 + Redis Token
用户注册 唯一索引 Token
积分发放 业务单号 + 唯一索引 行锁
第三方回调 业务单号 + Redis 唯一索引

五、实战案例

案例1:电商秒杀系统

需求: 10000件商品,10万用户同时抢购,保证不超卖

方案组合:

  1. Redis预加载库存(Lua脚本扣减)
  2. 数据库乐观锁兜底
  3. 唯一索引防止重复下单
@Service
public class SecKillService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private StockMapper stockMapper;
    
    /**
     * 秒杀抢购
     */
    public OrderVO secKill(Long userId, Long productId) {
        // 1. 检查用户是否已抢购(幂等)
        String userKey = "seckill:user:" + productId + ":" + userId;
        Boolean purchased = redisTemplate.hasKey(userKey);
        if (Boolean.TRUE.equals(purchased)) {
            throw new BusinessException("您已参与过该商品的秒杀");
        }
        
        // 2. Redis扣减库存(Lua脚本保证原子性)
        String stockKey = "seckill:stock:" + productId;
        String luaScript = 
            "local stock = tonumber(redis.call('get', KEYS[1])) " +
            "if stock and stock > 0 then " +
            "  redis.call('decr', KEYS[1]) " +
            "  return 1 " +
            "else " +
            "  return 0 " +
            "end";
        
        RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
        Long result = redisTemplate.execute(
            script, Collections.singletonList(stockKey));
        
        if (result == null || result == 0) {
            throw new BusinessException("商品已售罄");
        }
        
        // 3. 标记用户已抢购(1小时)
        redisTemplate.opsForValue().set(userKey, "1", 1, TimeUnit.HOURS);
        
        // 4. 异步创建订单(MQ)
        SecKillMessage message = new SecKillMessage();
        message.setUserId(userId);
        message.setProductId(productId);
        mqProducer.send("SECKILL_ORDER_TOPIC", message);
        
        // 5. 返回结果(订单异步生成)
        OrderVO vo = new OrderVO();
        vo.setStatus("PROCESSING");
        vo.setMessage("抢购成功,订单生成中...");
        return vo;
    }
}

案例2:订单支付全流程

需求: 用户支付订单,防止重复支付、重复扣款

方案组合:

  1. 前端Token防重复提交
  2. 支付单号保证幂等
  3. 订单状态机控制流转
@Service
public class OrderPayService {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 支付订单(完整流程)
     */
    @Transactional(rollbackFor = Exception.class)
    public PayResult payOrder(OrderPayDTO dto) {
        Long orderId = dto.getOrderId();
        String token = dto.getToken();
        
        // 1. 验证Token(防前端重复提交)
        String tokenKey = "order:pay:token:" + token;
        Boolean deleted = redisTemplate.delete(tokenKey);
        if (deleted == null || !deleted) {
            throw new BusinessException("请勿重复支付");
        }
        
        try {
            // 2. 查询订单
            Order order = orderService.getById(orderId);
            if (order == null) {
                throw new BusinessException("订单不存在");
            }
            
            // 3. 检查订单状态(幂等)
            if (order.getStatus() == OrderStatus.PAID.getCode()) {
                log.info("订单已支付,幂等返回: orderId={}", orderId);
                return PayResult.success(order.getPayNo());
            }
            
            if (order.getStatus() != OrderStatus.WAIT_PAY.getCode()) {
                throw new BusinessException("订单状态异常,无法支付");
            }
            
            // 4. 生成支付单号(幂等标识)
            String payNo = generatePayNo(orderId);
            
            // 5. 调用支付网关
            PayGatewayResult gatewayResult = paymentService.pay(
                payNo, 
                order.getAmount(), 
                dto.getPayType()
            );
            
            if (!gatewayResult.isSuccess()) {
                throw new BusinessException("支付失败: " + gatewayResult.getMessage());
            }
            
            // 6. 更新订单状态
            orderService.updatePayInfo(
                orderId, 
                payNo, 
                gatewayResult.getTransactionId()
            );
            
            return PayResult.success(payNo);
            
        } catch (Exception e) {
            // 失败时恢复Token,允许重新支付
            redisTemplate.opsForValue().set(tokenKey, "1", 5, TimeUnit.MINUTES);
            throw e;
        }
    }
}

六、常见问题与陷阱

6.1 Redis宕机怎么办?

问题: 基于Redis的幂等方案,Redis宕机后失效

解决方案:

  1. Redis主从 + 哨兵

    • 部署Redis高可用集群
    • 自动故障转移
  2. 降级方案

    public void deductStock(Long productId, Integer count) {
        try {
            // 尝试Redis方案
            redisStockService.deduct(productId, count);
        } catch (Exception e) {
            log.error("Redis异常,降级为数据库乐观锁", e);
            // 降级为数据库乐观锁
            dbStockService.deductWithOptimisticLock(productId, count);
        }
    }
    
  3. 数据库兜底

    • Redis快速失败
    • 数据库唯一索引保证最终一致性

6.2 Token被前端缓存怎么办?

问题: 前端缓存Token,刷新页面后Token失效

解决方案:

// 不缓存Token,每次打开页面重新获取
async mounted() {
  const res = await this.$axios.get('/order/getToken')
  this.token = res.data
}

// 或者:提交成功后立即获取新Token
async submitOrder() {
  await this.$axios.post('/order/submit', { token: this.token })
  // 重新获取Token
  const res = await this.$axios.get('/order/getToken')
  this.token = res.data
}

6.3 分布式锁死锁怎么办?

问题: 服务宕机,锁未释放,导致死锁

解决方案:

  1. 设置锁过期时间

    // 锁30秒后自动释放
    lock.tryLock(10, 30, TimeUnit.SECONDS);
    
  2. Redisson Watchdog

    • Redisson自动续期机制
    • 线程存活时自动延长锁时间
  3. 手动续期

    // 长时间任务,手动续期
    RFuture<Boolean> future = lock.renewExpirationAsync();
    

6.4 乐观锁冲突率太高怎么办?

问题: 高并发下乐观锁冲突频繁,用户体验差

解决方案:

  1. 增加重试次数

    int maxRetry = 5; // 从3次增加到5次
    
  2. 随机退避

    // 重试间隔随机化,避免冲突
    Thread.sleep(50 + new Random().nextInt(50));
    
  3. 分段锁

    // 库存拆分为多个段,减少冲突
    // 商品1000件 -> 拆分为10段,每段100件
    
  4. 切换为分布式锁或Redis原子操作


6.5 幂等Key的过期时间怎么设置?

原则:

  • 短期操作(表单提交):5分钟
  • 支付回调:24小时
  • MQ消费:24-48小时
  • 第三方接口调用:根据业务(短信1小时,邮件24小时)

示例:

// 表单提交:5分钟
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);

// 支付回调:24小时
redisTemplate.opsForValue().set(key, "1", 24, TimeUnit.HOURS);

// 短信发送:1小时
redisTemplate.opsForValue().set(key, "1", 1, TimeUnit.HOURS);

6.6 业务异常时是否删除幂等标识?

原则:

  • 数据校验失败 → 不删除(如"库存不足"、"余额不足")
  • 系统异常 → 删除,允许重试(如网络异常、数据库异常)
  • 业务状态异常 → 不删除(如"订单已支付"、"订单已取消")

示例:

try {
    // 业务逻辑
} catch (BusinessException e) {
    // 业务异常:不删除幂等标识
    log.warn("业务异常: {}", e.getMessage());
    throw e;
} catch (Exception e) {
    // 系统异常:删除幂等标识,允许重试
    log.error("系统异常", e);
    redisTemplate.delete(idempotentKey);
    throw e;
}

6.7 如何测试幂等性?

测试方法:

  1. 单元测试(模拟重复请求)

    @Test
    public void testIdempotent() {
        OrderDTO dto = new OrderDTO();
        dto.setUserId(1L);
        dto.setProductId(100L);
        
        // 第一次调用
        Long orderId1 = orderService.createOrder(dto);
        
        // 第二次调用(重复)
        Long orderId2 = orderService.createOrder(dto);
        
        // 验证:两次调用返回相同订单ID
        assertEquals(orderId1, orderId2);
    }
    
  2. 压力测试(JMeter)

    • 模拟1000个并发请求
    • 验证数据库只插入1条记录
  3. 手动测试

    • 快速点击按钮多次
    • 检查是否生成重复数据

七、总结

核心原则

  1. 业务语义清晰 - 幂等方案要符合业务逻辑
  2. 多层防护 - Redis + 数据库双重保证
  3. 性能优先 - 快速失败,减少数据库压力
  4. 可降级 - Redis宕机时有备用方案
  5. 可追溯 - 记录幂等日志,便于排查问题

选择建议

优先级 考虑因素 建议
1 是否涉及金额 优先考虑可靠性,采用多重保障
2 并发量 高并发用Redis,低并发用数据库
3 业务复杂度 简单业务用唯一索引,复杂业务用状态机
4 前后端分离 前端操作用Token,后端操作用业务单号
5 第三方依赖 第三方接口调用必须做幂等

最佳实践

  1. 设计阶段

    • 识别哪些接口需要幂等
    • 选择合适的幂等方案
    • 设计唯一标识(Token、业务单号)
  2. 开发阶段

    • 统一幂等工具类/注解
    • 规范异常处理
    • 完善日志记录
  3. 测试阶段

    • 单元测试覆盖幂等场景
    • 压力测试验证并发安全
    • 模拟各种异常情况
  4. 上线阶段

    • 监控幂等Key的命中率
    • 告警Redis/数据库异常
    • 定期清理过期数据

文档结束

如有问题或建议,欢迎反馈。

posted @ 2026-01-16 11:59  菜鸟~风  阅读(2)  评论(0)    收藏  举报