SpringBoot + MyBatis-Plus + Redis + RabbitMQ:电商秒杀场景下的库存预扣与订单异步创建

作为一名有八年 Java 开发经验的老程序员,我经历过从单体应用到分布式系统的各种架构演进。其中,电商秒杀场景堪称高并发处理的 "试金石",最能体现开发者对技术栈的综合运用能力。今天我想结合最新的技术实践,聊聊如何用 SpringBoot + MyBatis-Plus + Redis + RabbitMQ 这一套主流技术栈,优雅地解决秒杀场景下的库存预扣与订单异步创建问题。

秒杀场景的技术挑战

秒杀业务看似简单:用户抢购限量商品,系统扣减库存并创建订单。但在高并发场景下,这个过程会暴露出三大核心问题:

  1. 流量削峰:秒杀瞬间的 QPS 可能是平时的 10-100 倍,直接冲击数据库会导致系统崩溃
  1. 库存一致性:如何避免超卖和库存不足的情况下创建订单,这是业务正确性的底线
  1. 系统响应:用户在秒杀场景下对响应速度预期极高,超时会严重影响体验

记得三年前我们团队第一次做大型秒杀活动时,采用的是 "数据库直接扣减" 方案,结果活动开始后 10 秒数据库就扛不住了,大量连接超时,最终只能紧急下线活动。那次事故让我们深刻认识到:秒杀系统必须在架构层面做特殊设计。

整体架构设计

经过多次迭代优化,我们形成了一套成熟的秒杀架构方案,核心思路是 "流量拦截 - 库存预扣 - 异步确认 - 最终一致":

用户请求 → 前端限流 → SpringBoot接口 → Redis预扣库存 → 生成订单ID
    ↓                  ↓                    ↓
  按钮置灰            令牌桶限流           Lua原子操作
                                               ↓
                                          RabbitMQ消息队列
                                               ↓
                                          订单服务消费者
                                               ↓
                                      MyBatis-Plus数据库操作
                                               ↓
                                          库存最终扣减

这套架构的关键设计决策:

  • 用 Redis 做库存预扣和热点数据缓存,扛住大部分读请求
  • 用 RabbitMQ 实现订单创建的异步化,削峰填谷
  • 用 MyBatis-Plus 的乐观锁保证数据库层的库存一致性
  • 全程采用 "一锁二判三更新" 原则处理并发问题

核心技术实现

1. 库存预热与 Redis 预扣

秒杀开始前,我们需要将商品库存从数据库加载到 Redis,这个过程称为 "库存预热"。预热时要设置合理的过期时间,避免缓存雪崩:

@Service
public class StockWarmUpService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductMapper productMapper;
    
    // 预热秒杀商品库存到Redis
    public void warmUpSeckillStock(Long seckillId) {
        Product product = productMapper.selectById(seckillId);
        if (product == null) {
            throw new BusinessException("商品不存在");
        }
        // 库存key设计:seckill:stock:{商品ID}
        String stockKey = "seckill:stock:" + seckillId;
        // 售出计数key:seckill:sold:{商品ID}
        String soldKey = "seckill:sold:" + seckillId;
        
        // 设置库存,过期时间设置为活动结束后1小时
        redisTemplate.opsForValue().set(stockKey, product.getStock(), 
            1, TimeUnit.HOURS);
        redisTemplate.opsForValue().set(soldKey, 0, 1, TimeUnit.HOURS);
    }
}

秒杀接口中,使用 Redis 进行库存预扣。这里的关键是用 Lua 脚本保证扣减操作的原子性,避免并发问题:

// Lua脚本:检查并扣减库存
private static final String STOCK_DEDUCT_LUA = "local stockKey = KEYS[1]n" +
        "local soldKey = KEYS[2]n" +
        "local stock = tonumber(redis.call('get', stockKey)) or 0n" +
        "local quantity = tonumber(ARGV[1])n" +
        "if stock >= quantity thenn" +
        "    redis.call('decrby', stockKey, quantity)n" +
        "    redis.call('incrby', soldKey, quantity)n" +
        "    return 1n" +
        "endn" +
        "return 0";
@Service
public class SeckillService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    public Result<String> doSeckill(Long seckillId, Long userId, int quantity) {
        // 1. 检查用户是否已秒杀过(防重复下单)
        String userSeckillKey = "seckill:user:" + userId + ":" + seckillId;
        Boolean hasSeckilled = redisTemplate.hasKey(userSeckillKey);
        if (Boolean.TRUE.equals(hasSeckilled)) {
            return Result.fail("您已参与过秒杀,请勿重复提交");
        }
        
        // 2. Redis预扣库存
        String stockKey = "seckill:stock:" + seckillId;
        String soldKey = "seckill:sold:" + seckillId;
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(STOCK_DEDUCT_LUA, Long.class),
            Arrays.asList(stockKey, soldKey),
            String.valueOf(quantity)
        );
        
        if (result == null || result == 0) {
            return Result.fail("手慢了,商品已抢完");
        }
        
        // 3. 记录用户秒杀记录,设置过期时间
        redisTemplate.opsForValue().set(userSeckillKey, "1", 24, TimeUnit.HOURS);
        
        // 4. 发送消息到RabbitMQ,异步创建订单
        OrderMessage message = new OrderMessage();
        message.setOrderId(generateOrderId());
        message.setSeckillId(seckillId);
        message.setUserId(userId);
        message.setQuantity(quantity);
        message.setCreateTime(new Date());
        
        rabbitTemplate.convertAndSend("seckill.order.exchange", 
            "seckill.order.key", message);
        
        return Result.success(message.getOrderId());
    }
}

八年经验总结:在 Redis 扣减库存时,一定要用原子操作(Lua 脚本或 Redis 命令),避免先查后改的分布式问题。早期我们吃过这个亏,导致少量超卖情况。

2. RabbitMQ 异步创建订单

为了应对秒杀高峰期的流量冲击,订单创建必须异步化。我们使用 RabbitMQ 实现这一功能,并利用死信队列处理超时未支付的订单。

首先配置 RabbitMQ:

@Configuration
public class RabbitMQConfig {
    // 普通交换机
    public static final String SECKILL_ORDER_EXCHANGE = "seckill.order.exchange";
    // 普通队列
    public static final String SECKILL_ORDER_QUEUE = "seckill.order.queue";
    // 死信交换机
    public static final String SECKILL_DLX_EXCHANGE = "seckill.dlx.exchange";
    // 死信队列
    public static final String SECKILL_DLX_QUEUE = "seckill.dlx.queue";
    
    // 声明普通交换机
    @Bean
    public DirectExchange seckillOrderExchange() {
        return new DirectExchange(SECKILL_ORDER_EXCHANGE, true, false);
    }
    
    // 声明普通队列,指定死信交换机和过期时间
    @Bean
    public Queue seckillOrderQueue() {
        Map<String, Object> arguments = new HashMap<>();
        // 设置死信交换机
        arguments.put("x-dead-letter-exchange", SECKILL_DLX_EXCHANGE);
        // 设置死信路由键
        arguments.put("x-dead-letter-routing-key", "seckill.dlx.key");
        // 设置消息过期时间(15分钟未支付自动取消)
        arguments.put("x-message-ttl", 15 * 60 * 1000);
        return QueueBuilder.durable(SECKILL_ORDER_QUEUE)
                .withArguments(arguments)
                .build();
    }
    
    // 绑定普通队列和交换机
    @Bean
    public Binding seckillOrderBinding() {
        return BindingBuilder.bind(seckillOrderQueue())
                .to(seckillOrderExchange())
                .with("seckill.order.key");
    }
    
    // 声明死信交换机和队列(代码略)
}

订单消息消费者:

@Component
public class OrderConsumer {
    @Autowired
    private OrderService orderService;
    
    @RabbitListener(queues = RabbitMQConfig.SECKILL_ORDER_QUEUE)
    public void handleOrderMessage(OrderMessage message, Channel channel, 
                                  @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
        try {
            // 创建订单
            orderService.createOrder(message);
            // 手动确认消息
            channel.basicAck(tag, false);
        } catch (Exception e) {
            // 处理异常,根据情况决定重试或拒绝
            if (e instanceof BusinessException) {
                // 业务异常,直接拒绝,进入死信队列
                channel.basicReject(tag, false);
            } else {
                // 非业务异常,重试几次后进入死信队列
                channel.basicNack(tag, false, false);
            }
        }
    }
    
    // 死信队列消费者,处理超时未支付订单(代码略)
}

八年经验总结:消息队列一定要开启手动确认模式,并合理设置重试策略。对于订单这类关键业务,建议使用消息持久化和生产者确认机制,确保消息不丢失。我们曾因未开启持久化,在 MQ 重启后丢失了一批订单消息。

3. MyBatis-Plus 实现数据库层库存扣减

Redis 预扣只是第一步,最终库存扣减需要在数据库层完成。这里我们使用 MyBatis-Plus 的乐观锁来处理并发问题。

首先在实体类中添加版本号字段:

@Data
public class Product {
    private Long id;
    private String name;
    private Integer stock;
    // 乐观锁版本号
    @Version
    private Integer version;
}

配置乐观锁插件:

@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加乐观锁插件
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
    }
}

订单服务中扣减库存:

@Service
@Transactional
public class OrderService {
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 创建订单并扣减库存
    public void createOrder(OrderMessage message) {
        // 1. 查询商品信息(带乐观锁)
        Product product = productMapper.selectById(message.getSeckillId());
        if (product == null) {
            throw new BusinessException("商品不存在");
        }
        
        // 2. 检查库存是否充足
        if (product.getStock() < message.getQuantity()) {
            // 库存不足,需要回滚Redis预扣的库存
            rollbackRedisStock(message.getSeckillId(), message.getQuantity());
            throw new BusinessException("库存不足");
        }
        
        // 3. 扣减数据库库存(乐观锁生效)
        int newStock = product.getStock() - message.getQuantity();
        product.setStock(newStock);
        int rows = productMapper.updateById(product);
        
        // 4. 处理乐观锁更新失败的情况
        if (rows == 0) {
            // 回滚Redis库存
            rollbackRedisStock(message.getSeckillId(), message.getQuantity());
            throw new BusinessException("创建订单失败,请重试");
        }
        
        // 5. 创建订单记录
        Order order = new Order();
        order.setId(message.getOrderId());
        order.setUserId(message.getUserId());
        order.setProductId(message.getSeckillId());
        order.setQuantity(message.getQuantity());
        order.setStatus(OrderStatus.PENDING_PAYMENT);
        order.setCreateTime(message.getCreateTime());
        orderMapper.insert(order);
    }
    
    // 回滚Redis库存
    private void rollbackRedisStock(Long seckillId, int quantity) {
        String stockKey = "seckill:stock:" + seckillId;
        String soldKey = "seckill:sold:" + seckillId;
        redisTemplate.opsForValue().increment(stockKey, quantity);
        redisTemplate.opsForValue().decrement(soldKey, quantity);
    }
}

八年经验总结:乐观锁在高并发场景下会出现更新失败的情况,这时候一定要回滚 Redis 中预扣的库存,否则会导致库存不一致。我们采用了 "阶梯式重试" 策略:首次失败后间隔 100ms 重试,第二次 200ms,最多重试 3 次,有效减少了失败率。

库存一致性保障

秒杀场景中,库存一致性是核心问题。我们采用多层次保障机制:

  1. Redis 预扣校验:每次扣减前检查库存是否充足
  1. 数据库乐观锁:确保最终扣减的原子性
  1. 定时对账任务:每天凌晨比对 Redis 和数据库库存,修复不一致
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行
public void checkStockConsistency() {
    log.info("开始执行库存一致性检查");
    
    // 查询所有秒杀商品
    List<Product> seckillProducts = productMapper.selectSeckillProducts();
    for (Product product : seckillProducts) {
        String stockKey = "seckill:stock:" + product.getId();
        String soldKey = "seckill:sold:" + product.getId();
        
        // 获取Redis中的库存和售出数量
        Integer redisStock = (Integer) redisTemplate.opsForValue().get(stockKey);
        Integer redisSold = (Integer) redisTemplate.opsForValue().get(soldKey);
        
        // 计算Redis中的实际库存 = 初始库存 - 售出数量
        Integer initialStock = product.getInitialStock();
        Integer actualRedisStock = initialStock - redisSold;
        
        // 如果Redis库存与实际计算不符,进行修正
        if (!Objects.equals(redisStock, actualRedisStock)) {
            log.warn("库存不一致,商品ID:{},Redis库存:{},实际应有的库存:{}",
                    product.getId(), redisStock, actualRedisStock);
            redisTemplate.opsForValue().set(stockKey, actualRedisStock);
        }
    }
    log.info("库存一致性检查完成");
}

性能测试与优化

为了验证系统在高并发下的表现,我们使用 JMeter 进行压测。测试环境:

  • 应用服务器:4 核 8G,2 台
  • Redis:8 核 16G,主从架构
  • MySQL:8 核 16G,读写分离
  • RabbitMQ:4 核 8G,集群部署

JMeter 配置:

  • 线程数:2000
  • Ramp-Up 时间:1 秒
  • 循环次数:10
  • 使用 Throughput Shaping Timer 控制 QPS 在 2000 左右

优化前的压测结果并不理想,主要瓶颈在:

  1. Redis 连接池耗尽
  1. 数据库连接竞争激烈
  1. 消息队列出现堆积

针对这些问题,我们做了以下优化:

  1. Redis 优化
    • 调整连接池大小(max-active=200)
    • 启用 Redis 集群分担压力
    • 热点数据本地缓存(Caffeine)
  1. 数据库优化
    • 增加数据库连接池大小
    • 秒杀商品表单独分表
    • 索引优化(商品 ID、订单状态等)
  1. 消息队列优化
    • 增加消费者实例
    • 调整 prefetchCount 参数
    • 启用消息压缩

优化后的压测结果:

  • 平均响应时间:< 200ms
  • 成功率:99.9%
  • 最大 QPS:3000+
  • 无超卖和库存不一致情况

八年开发经验总结

回顾这些年处理秒杀系统的经验,我总结出以下几点心得:

  1. 架构设计三原则
    • 能在前端拦截的绝不放到后端
    • 能在缓存处理的绝不访问数据库
    • 能异步处理的绝不同步执行
  1. 技术选型要务实

不要盲目追求新技术,适合业务场景的才是最好的。我们曾尝试用分布式事务 Seata,但发现对于秒杀场景,最终一致性方案已经足够,过度设计反而影响性能。

  1. 容错设计很重要
    • 限流降级必须有,这是系统的最后一道防线
    • 关键操作一定要有日志,方便问题排查
    • 重要业务要考虑降级方案,比如库存不足时返回友好提示而非系统错误
  1. 性能优化是持续过程

从最初的单机架构到现在的分布式系统,我们经历了多次重构和优化。性能优化没有终点,需要根据业务增长持续迭代。

结语

秒杀系统的设计与实现是一个综合性的工程,涉及高并发、分布式、缓存、消息队列等多个技术领域。本文介绍的 SpringBoot + MyBatis-Plus + Redis + RabbitMQ 方案,通过库存预扣和异步订单创建,有效解决了秒杀场景的核心痛点。

随着业务的发展,我们还将引入更多技术来优化系统,比如:

  • 接入 Sentinel 实现更精细的流量控制
  • 使用 Elasticsearch 存储订单日志,方便分析
  • 尝试 Serverless 架构处理流量波动

希望这篇文章能给正在开发秒杀系统的同行们一些参考,也欢迎大家在评论区交流更多实战经验。架构之路无止境,让我们一起在技术的道路上不断前行。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]