本篇前置还是 单机 情况下(逻辑单机、部署单机)
其实通过前面两篇文章的介绍,大概应该心里有一个直观感受,防止超卖的最核心关键就是将所有请求进行串行化。
操作流程:
begin:
//查询库存
xxxx
//判断库存
xxx
//扣减库存
endA用户在请求接口,进行上述操作流程过程中,后续所有用户请求过来的时候,都进行排队,等A请求结束以后,按照顺序,进行后续请求的执行,这是 在数据正确的前提下,能百分百保证不超卖 的方案。
但其实这种方案是不现实的,不说让用户超长待机,体验极差以外,对整个服务资源来说也是纯纯的属于浪费。
换句话说,防止超卖必须同时兼顾两个条件:
通过第二篇文章 Redis预扣减库存+同步落库 方案,其实基本上就可以解决上面两个问题了,为什么这么说呢?
回顾上面的 “操作流程”,“查--判断--扣减”这些动作,前置到缓存,处理速度比在数据库里执行要快几个数量级。抛开“同步落库”这个步骤,基本上用户立马就可以得到反馈,用户体验基本不被影响。
即使是大量瞬时流量,Redis 单线程每秒可处理数万到十万级请求,远高于绝大多数业务场景的峰值 QPS,足以支撑。
另外,能不能真的防止出现超卖的情况,想想和什么因素有关:
1:缓存的产品库存数据,是否和数据库保持一致 2:缓存里的一系列操作,是不是都正确的执行了
如果默认一切正常,这两个因素可以忽略。但在实际生产环境中,网络问题、数据一致性问题等都可能导致这两个因素出现异常。
但关键是:Redis 本身是单线程执行命令的,所有请求在 Redis 层面天然串行化。所以,在缓存层面,绝对不会出现超卖。
这样一来,第二篇方案中的性能瓶颈,就只能出现在 "同步落库" 这个步骤里。因此,本篇的核心思路就是:将"同步落库"改为"异步落库",彻底消除这个瓶颈。

核心变化:用户不再等待 DB 写入完成,响应时间 = Redis 操作时间 + 网络往返(毫秒级)。
这个选型,要综合考虑,比如项目开发语言,团队规模,有没有熟悉的中间件,加上一些比如业务需求,一致性要求,可靠性要求等。
我这里选择的是 RocketMQ
理由如下:
1:我这个《八公酒业商城》微信小程序,后台开发语言是Java,可能和RocketMQ更贴合一点 2:在接下来的方案里,其实会出现很多数据一致性的问题,当然篇幅有限,不具体展开,而RocketMQ原生支持事务消息,所以对于后续 探讨 “数据最终一致性” 有帮助。
3:另外RocketMQ还自带一些对订单系统很实用的功能:比如Tag过滤,死信队列与重试等
4:其他的一些优势,比如出自阿里之手,吞吐量极高,对电商场景更友好等
//生产者:发送 MQ 消息
@Service
public class OrderService {
@Autowired
private RedisService redisService;
@Autowired
private MqProducer mqProducer;
public void createOrder(Long productId, Integer buyCount) {
// 1. Redis 预扣减(Lua 脚本,原子操作)
boolean deducted = redisService.deductStock(productId, buyCount);
if (!deducted) {
throw new InsufficientStockException("库存不足");
}
// 2. 发送 MQ 消息(异步落库)
OrderMessage msg = new OrderMessage();
msg.setProductId(productId);
msg.setBuyCount(buyCount);
msg.setUserId(getCurrentUserId());
msg.setCreateTime(System.currentTimeMillis());
mqProducer.send("order-topic", msg);
// 3. 立即返回成功
return "下单成功";
}
}
//发送延迟消息
/**
* 发送订单超时检查消息(延迟20分钟)
*/
private void sendOrderTimeoutMessage(SeckillOrderModel order) {
try {
SeckillOrderMessage messageData = SeckillOrderMessage.builder()
.orderId(order.getId())
.userId(order.getUserId())
.seckillProductId(order.getSeckillProductId())
.activityId(order.getActivityId())
.quantity(order.getQuantity())
.createTime(System.currentTimeMillis())
.messageType("CHECK")
.build();
Message<SeckillOrderMessage> message = MessageBuilder
.withPayload(messageData)
.build();
// 发送延迟消息,延迟20分钟
rocketMQTemplate.syncSend(
RocketMQConstants.SECKILL_ORDER_TOPIC,
message,
3000,
RocketMQConstants.DELAY_LEVEL_20_MIN
);
log.info("发送订单超时检查消息成功: orderId={}", order.getId());
} catch (Exception e) {
log.error("发送订单超时检查消息失败: orderId={}", order.getId(), e);
// 不抛异常,避免影响主流程
}
}
//消费者:异步落库
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer-group")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
@Autowired
private ProductMapper productMapper;
@Override
public void onMessage(OrderMessage msg) {
// 1. DB 落库(乐观锁兜底)
int rows = productMapper.decreaseStock(msg.getProductId(), msg.getBuyCount());
if (rows == 0) {
// 极端情况:DB 库存不足或数据不一致
// 发送告警 + 记录失败日志 + 人工介入
log.error("DB 落库失败,productId={}, buyCount={}", msg.getProductId(), msg.getBuyCount());
return;
}
// 2. 创建订单
orderMapper.insert(buildOrder(msg));
}
}在我的项目中,有秒杀的场景 ——延迟消息解决支付超时
异步落库解决了性能瓶颈,但秒杀还有一个典型问题:用户抢到库存后不支付,导致库存被锁死。
传统做法是用定时任务扫描订单表,但高并发下会拖垮数据库。RocketMQ 的延迟消息可以优雅地解决这个问题:下单成功后发送一条xxx 分钟后触发的延迟消息,到期后消费者检查订单状态,若未支付则自动取消并回滚库存。
这种方式将"定时扫描"变成了"事件驱动",既减轻了数据库压力,又实现了更实时的超时处理。
如果 Redis 扣减成功了,但 MQ 消息发送失败,Redis 库存已经扣了,DB 却不会落库——数据直接不一致
//回滚Redis
try {
// 2. 发送 MQ 消息
mqProducer.send("order-topic", buildMessage(productId, buyCount));
} catch (Exception e) {
// 3. 发送失败 → 立即回滚 Redis
redisService.rollbackStock(productId, buyCount);
log.error("MQ 发送失败,已回滚 Redis,productId={}", productId, e);
throw new RuntimeException("系统繁忙,请重试");
}关键原则:要么 Redis 和 MQ 都成功,要么都失败(Redis 回滚),不允许中间状态。
DB 落库可能因为网络抖动、DB 连接池满、乐观锁冲突等原因失败。
@Component
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer-group")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
@Override
public void onMessage(OrderMessage msg) {
try {
// 1. DB 落库(乐观锁兜底)
int rows = productMapper.decreaseStock(msg.getProductId(), msg.getBuyCount());
if (rows == 0) {
// DB 库存不足——说明 Redis 和 DB 数据不一致
// 这种情况重试也无法解决,直接告警 + 人工介入
log.error("DB 库存不足,Redis 与 DB 数据不一致!productId={}", msg.getProductId());
return; // 不重试,直接让消息进入死信队列
}
// 2. 创建订单
orderMapper.insert(buildOrder(msg));
} catch (Exception e) {
// 网络超时、DB 连接池满等可恢复异常,RocketMQ 会自动重试
log.warn("消费者落库失败,将自动重试,productId={}", msg.getProductId(), e);
throw e; // 抛出异常,触发 RocketMQ 重试
}
}
}这是异步落库最核心的问题。引入 MQ 后,我们无法像同步方案那样"立刻知道"结果,只能通过最终一致性来保证。
┌─────────────────────────────────────────────────────────────┐
│ 第 1 层:实时补偿(发送 MQ 失败时立即回滚 Redis) │
│ 第 2 层:自动重试(RocketMQ 内置重试机制) │
│ 第 3 层:死信队列 + 人工介入(重试失败后) │
│ 第 4 层:定时对账 Job(凌晨跑批,核对 Redis 和 DB 库存) │
└─────────────────────────────────────────────────────────────┘异步落库方案,大幅提升了系统吞吐量,但系统复杂度提高了,却也带来了新的问题:
下一篇,介绍 本地消息表 + 补偿重试 方案。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。