心跳大冒险:泰遇官方中文版
· 2025-10-04
作为一名有八年 Java 开发经验的老程序员,我经历过从单体应用到分布式系统的各种架构演进。其中,电商秒杀场景堪称高并发处理的 "试金石",最能体现开发者对技术栈的综合运用能力。今天我想结合最新的技术实践,聊聊如何用 SpringBoot + MyBatis-Plus + Redis + RabbitMQ 这一套主流技术栈,优雅地解决秒杀场景下的库存预扣与订单异步创建问题。
秒杀业务看似简单:用户抢购限量商品,系统扣减库存并创建订单。但在高并发场景下,这个过程会暴露出三大核心问题:
记得三年前我们团队第一次做大型秒杀活动时,采用的是 "数据库直接扣减" 方案,结果活动开始后 10 秒数据库就扛不住了,大量连接超时,最终只能紧急下线活动。那次事故让我们深刻认识到:秒杀系统必须在架构层面做特殊设计。
经过多次迭代优化,我们形成了一套成熟的秒杀架构方案,核心思路是 "流量拦截 - 库存预扣 - 异步确认 - 最终一致":
用户请求 → 前端限流 → SpringBoot接口 → Redis预扣库存 → 生成订单ID
↓ ↓ ↓
按钮置灰 令牌桶限流 Lua原子操作
↓
RabbitMQ消息队列
↓
订单服务消费者
↓
MyBatis-Plus数据库操作
↓
库存最终扣减
这套架构的关键设计决策:
秒杀开始前,我们需要将商品库存从数据库加载到 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 命令),避免先查后改的分布式问题。早期我们吃过这个亏,导致少量超卖情况。
为了应对秒杀高峰期的流量冲击,订单创建必须异步化。我们使用 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 重启后丢失了一批订单消息。
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 次,有效减少了失败率。
秒杀场景中,库存一致性是核心问题。我们采用多层次保障机制:
@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 进行压测。测试环境:
JMeter 配置:
优化前的压测结果并不理想,主要瓶颈在:
针对这些问题,我们做了以下优化:
优化后的压测结果:
回顾这些年处理秒杀系统的经验,我总结出以下几点心得:
不要盲目追求新技术,适合业务场景的才是最好的。我们曾尝试用分布式事务 Seata,但发现对于秒杀场景,最终一致性方案已经足够,过度设计反而影响性能。
从最初的单机架构到现在的分布式系统,我们经历了多次重构和优化。性能优化没有终点,需要根据业务增长持续迭代。
秒杀系统的设计与实现是一个综合性的工程,涉及高并发、分布式、缓存、消息队列等多个技术领域。本文介绍的 SpringBoot + MyBatis-Plus + Redis + RabbitMQ 方案,通过库存预扣和异步订单创建,有效解决了秒杀场景的核心痛点。
随着业务的发展,我们还将引入更多技术来优化系统,比如:
希望这篇文章能给正在开发秒杀系统的同行们一些参考,也欢迎大家在评论区交流更多实战经验。架构之路无止境,让我们一起在技术的道路上不断前行。