大家好,我是极特,大厂技术团队负责人
之前公司电商平台计划上线秒杀功能。周五下午五点,产品经理找到核心开发强哥,"强哥,老板说了,下周要上线秒杀活动,预计峰值会有百万QPS,靠你了。"
"百万QPS?下周上线?" 强哥揉了揉太阳穴,"这不是开玩笑吗..."
01.临时方案:能跑就行
秒杀系统的核心特征是高并发、瞬时高峰、库存有限,主要挑战在于如何保证系统性能和数据一致性,常见问题包括超卖、少卖、响应延迟和库存回滚不及时。
什么是超卖?------ 库a存明明只有100台,却卖出了120台 什么是少卖?------ 系统显示已售罄,实际却还有20台库存
超卖产生流程:
少卖产生流程:
梳理好思路,整个周末,小强都泡在公司,作为团队的核心开发他设计了一个看似合理的方案:
- Redisson分布式锁:控制并发,防止超卖
- RabbitMQ消息队列:异步处理订单,提高吞吐量
- 定时任务:处理未支付订单,归还库存
代码很快写好了:
csharp
public OrderResult createOrder(Long userId, Long productId, Integer quantity) {
// 构建分布式锁
RLock lock = redissonClient.getLock("product_" + productId);
try {
// 尝试获取锁
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
return new OrderResult(false, "系统繁忙,请稍后再试");
}
// 查询数据库库存
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
return new OrderResult(false, "库存不足");
}
// 扣减库存
productMapper.decreaseStock(productId, quantity);
// 创建订单(异步)
OrderMessage message = new OrderMessage(userId, productId, quantity);
rabbitTemplate.convertAndSend("order.exchange", "order.create", message);
return new OrderResult(true, "下单成功");
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
用分布式锁代替数据库,消息队列异步处理,解决超卖跟性能,定时任务释放超时订单,库存归还,解决少卖,强哥感觉稳了!
02.现实的残酷
周一上午,在做方案评审的时候,强哥满怀信心地向我展示了他的方案。
"思路不错,"我点点头,"你压测过吗?而且你的方案还有几个严重问题"
- 锁粒度太大:Redisson锁虽然能保证一致性,但会严重影响并发性能
- 读写分离不彻底:每次都查询数据库库存,成为性能瓶颈
- 异步处理不可靠:缺乏事务保证,导致数据不一致"
- 库存少卖:定时任务在大数据量场景下,性能下降,库存归还延迟大
强哥陷入了深深的焦虑。距离上线只剩三天了!
03.优化:全流程解决方案
"老大,我能和你聊聊吗,马上就要上线了,我应该怎么改进呢?" 会后强哥找到了我。
"三个关键词:Redis Lua脚本、RocketMQ事务消息、延时消息。"
通过Redis Lua脚本实现原子性库存预扣,借助RocketMQ事务消息完成库存扣减,支付回调完善订单状态,并利用RocketMQ 5.0时间轮算法的延时消息实现库存补偿,整体流程如下:
1. 库存预扣
采用Lua脚本在Redis服务器端原子执行,保障性能,同时,需要保证用户幂等性。通过Redis Lua脚本在一个操作中完成用户校验、库存检查和库存扣减,有效防止超卖和重复购买问题。示例代码如下:
swift
/**
* 使用Lua脚本进行库存预扣
* @param userId 用户ID
* @param productId 商品ID
* @param quantity 购买数量
* @return 预扣结果
*/
public boolean tryPreDeductStockWithLua(Long userId, Long productId, int quantity) {
String userRecordKey = USER_SECKILL_PREFIX + userId + ":" + productId;
String stockCountKey = PRODUCT_STOCK_COUNT_PREFIX + productId;
// Lua脚本:检查用户是否已购买 -> 检查库存 -> 扣减库存 -> 记录用户购买
String luaScript =
//检查用户是否已购买
"if redis.call('exists', KEYS[1]) == 1 then\n" +
" return 0\n" +
"end\n" +
//检查库存
"local stock = redis.call('get', KEYS[2])\n" +
"if not stock then\n" +
" return -1\n" +
"end\n" +
"local stockNumber = tonumber(stock)\n" +
"local quantity = tonumber(ARGV[1])\n" +
"if stockNumber < quantity then\n" +
" return -2\n" +
"end\n" +
//扣减库存
"redis.call('decrby', KEYS[2], quantity)\n" +
//记录用户购买
"redis.call('setex', KEYS[1], tonumber(ARGV[2]), ARGV[1])\n" +
"return 1";
try {
List<String> keys = Arrays.asList(userRecordKey, stockCountKey);
List<String> args = Arrays.asList(String.valueOf(quantity), String.valueOf(USER_RECORD_EXPIRE));
Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), keys, args.toArray());
if (result == null) {
logger.error("执行Lua脚本返回空结果");
return false;
}
LuaResult luaResult = LuaResult.fromCode(result.intValue());
luaResult.log(logger, productId, userId);
return luaResult.success;
} catch (Exception e) {
logger.error("执行库存预扣Lua脚本异常", e);
return false;
}
}
2. 库存扣减
库存预扣成功后,采用RocketMq的分布式事务实现库存扣减:
第一阶段:发送半消息
- 生产者发送半事务消息到RocketMQ
- RocketMQ存储半事务消息,此时消息不可被消费者消费
第二阶段:执行本地事务
- 生产者执行Redis Lua脚本进行预扣库存操作
- 如果Lua脚本执行成功,向RocketMQ返回COMMIT
- 如果Lua脚本执行失败,向RocketMQ返回ROLLBACK
第三阶段:消息回查机制
- 如果RocketMQ长时间未收到事务执行结果,会触发消息回查
- 生产者检查是否存在对应的流水记录
- 如果存在流水记录,说明本地事务执行成功,返回COMMIT
- 如果不存在流水记录,说明本地事务执行失败,返回ROLLBACK
第四阶段:消费消息
- RocketMQ将确认提交的消息投递给消费者
- 消费者执行数据库库存扣减操作
- 消费者记录操作流水,确保幂等性
- 只有在确保数据成功落库后,才向RocketMQ返回消费ACK
- 如果消费失败,RocketMQ会通过重试机制保证消息最终被消费
3. 支付回调
用户下单后,可能支付成功,也可能支付失败或超时。因此,需要通过支付回调正确处理库存状态。
支付状态处理逻辑
- 支付成功:更新订单状态为已支付,交易完成。
- 支付失败:更新订单状态为支付失败,并触发库存补偿流程。
- 支付超时:设置订单支付超时自动取消机制,超时后触发库存补偿。
示例代码:
scss
@Service
public class PaymentCallbackService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void handlePaymentCallback(PaymentCallbackRequest callback) {
Order order = orderRepository.findByOrderId(callback.getOrderId());
if (order == null) {
log.error("Order not found: {}", callback.getOrderId());
return;
}
if (callback.isSuccess()) {
// 支付成功,更新订单状态
updateOrderStatus(order.getId(), OrderStatus.PAID);
} else {
// 支付失败,更新订单状态并触发库存补偿
updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED);
// 发送库存补偿消息
StockCompensationMessage message = new StockCompensationMessage();
message.setOrderId(order.getOrderId());
message.setProductId(order.getProductId());
message.setQuantity(order.getQuantity());
rocketMQTemplate.syncSend(
"TOPIC_STOCK_COMPENSATION",
MessageBuilder.withPayload(message).build()
);
}
}
@Transactional
public void updateOrderStatus(Long id, OrderStatus status) {
orderRepository.updateStatus(id, status, LocalDateTime.now());
}
}
4. 库存补偿
当用户下单后未在规定时间内完成支付,我们需要及时将预扣的库存释放回去,避免库存少卖。 在面对高并发大量订单的场景时,订单超时关闭机制的设计涉及几个问题:
- 订单关闭时效性 :超时订单需及时关闭,关系用户体验和库存释放;
- 性能平衡 :避免直接查询千万级订单表,降低数据库压力;
- 分布式一致性 :确保订单状态一致,防止重复或遗漏处理;
- 资源消耗控制 :优化定时扫描策略,合理分配系统资源;
- 异常处理机制 :在系统宕机等情况下防止订单"卡单";
针对这些挑战,我们可以采用RocketMQ 5.0提供的时间轮算法,它能高效处理大规模定时任务,保证库存及时释放,同时避免系统资源过度消耗。 关于时间轮相关的,可以参考 :
以下为库存补偿的流程:
RocketMQ 5.0引入的时间轮算法相比传统的延时消息实现有以下优势:
- 高效处理大量定时任务 :时间轮算法可以高效地管理大量的定时任务,适合电商场景下的大规模订单超时处理;
- 精度更高 :支持更精细的时间粒度,可以更准确地控制库存补偿的触发时间;
- 资源占用更少 :相比传统的延时队列实现,时间轮算法占用的系统资源更少;
- 扩展性更好 :可以轻松应对流量峰值,如秒杀、大促等场景下的订单量激增;
04.写在最后
经过这一系列分析与优化,我们的秒杀系统成功支撑了百万级并发,没有出现一例超卖问题。正如《失控》中所言:"复杂系统往往诞生于简单规则的迭代",我们通过Redis Lua脚本、RocketMQ事务消息、延时消息三招解决了秒杀超卖少卖问题。
每当新人问起秒杀系统超卖少卖如何设计时,我总会笑着说:"让强哥给你讲讲他的'百万QPS'故事吧..."
如果你正在经历类似的技术长征,希望我们的经验能给你带来一些启发。当然,技术无止境,方案肯定存在不足之处。如果你有更好的实践经验或改进建议,非常欢迎在留言区交流分享------毕竟,正如爱因斯坦所说:"提出新问题比解决问题更需要创造性"。
-- END --