首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >酒类商城,防止超卖的尝试系列(第三次)

酒类商城,防止超卖的尝试系列(第三次)

原创
作者头像
持码待晴
发布2026-07-15 13:42:59
发布2026-07-15 13:42:59
730
举报

前置条件:

本篇前置还是 单机 情况下(逻辑单机、部署单机)

其实通过前面两篇文章的介绍,大概应该心里有一个直观感受,防止超卖的最核心关键就是将所有请求进行串行化。

代码语言:txt
复制
操作流程:

begin:
//查询库存
xxxx
//判断库存
xxx
//扣减库存
end

A用户在请求接口,进行上述操作流程过程中,后续所有用户请求过来的时候,都进行排队,等A请求结束以后,按照顺序,进行后续请求的执行,这是 在数据正确的前提下,能百分百保证不超卖 的方案。

但其实这种方案是不现实的,不说让用户超长待机,体验极差以外,对整个服务资源来说也是纯纯的属于浪费。

换句话说,防止超卖必须同时兼顾两个条件:

  1. 用户体验:不管成功还是失败,尽快返回结果
  2. 系统正确性:绝对不能出现卖超的情况

通过第二篇文章 Redis预扣减库存+同步落库 方案,其实基本上就可以解决上面两个问题了,为什么这么说呢?

回顾上面的 “操作流程”,“查--判断--扣减”这些动作,前置到缓存,处理速度比在数据库里执行要快几个数量级。抛开“同步落库”这个步骤,基本上用户立马就可以得到反馈,用户体验基本不被影响。

即使是大量瞬时流量,Redis 单线程每秒可处理数万到十万级请求,远高于绝大多数业务场景的峰值 QPS,足以支撑。

另外,能不能真的防止出现超卖的情况,想想和什么因素有关:

1:缓存的产品库存数据,是否和数据库保持一致 2:缓存里的一系列操作,是不是都正确的执行了

如果默认一切正常,这两个因素可以忽略。但在实际生产环境中,网络问题、数据一致性问题等都可能导致这两个因素出现异常。

但关键是:Redis 本身是单线程执行命令的,所有请求在 Redis 层面天然串行化。所以,在缓存层面,绝对不会出现超卖

这样一来,第二篇方案中的性能瓶颈,就只能出现在 "同步落库" 这个步骤里。因此,本篇的核心思路就是:将"同步落库"改为"异步落库",彻底消除这个瓶颈。

一:第三次调整后的流程

核心变化:用户不再等待 DB 写入完成,响应时间 = Redis 操作时间 + 网络往返(毫秒级)。

二:MQ选型

这个选型,要综合考虑,比如项目开发语言,团队规模,有没有熟悉的中间件,加上一些比如业务需求,一致性要求,可靠性要求等。

我这里选择的是 RocketMQ

理由如下:

1:我这个《八公酒业商城》微信小程序,后台开发语言是Java,可能和RocketMQ更贴合一点 2:在接下来的方案里,其实会出现很多数据一致性的问题,当然篇幅有限,不具体展开,而RocketMQ原生支持事务消息,所以对于后续 探讨 “数据最终一致性” 有帮助。

3:另外RocketMQ还自带一些对订单系统很实用的功能:比如Tag过滤,死信队列与重试等

4:其他的一些优势,比如出自阿里之手,吞吐量极高,对电商场景更友好等

三:简略的一些伪代码

代码语言:txt
复制
//生产者:发送 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));
    }
}

PS:秒杀场景补充

在我的项目中,有秒杀的场景 ——延迟消息解决支付超时

异步落库解决了性能瓶颈,但秒杀还有一个典型问题:用户抢到库存后不支付,导致库存被锁死

传统做法是用定时任务扫描订单表,但高并发下会拖垮数据库。RocketMQ 的延迟消息可以优雅地解决这个问题:下单成功后发送一条xxx 分钟后触发的延迟消息,到期后消费者检查订单状态,若未支付则自动取消并回滚库存。

这种方式将"定时扫描"变成了"事件驱动",既减轻了数据库压力,又实现了更实时的超时处理。

四:异步落库可能出现的问题

1:生产者发送消息失败怎么处理

如果 Redis 扣减成功了,但 MQ 消息发送失败,Redis 库存已经扣了,DB 却不会落库——数据直接不一致

代码语言:txt
复制
 //回滚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 回滚),不允许中间状态

2:消费者消费失败怎么处理

DB 落库可能因为网络抖动、DB 连接池满、乐观锁冲突等原因失败。

代码语言:txt
复制
@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 重试
        }
    }
}

3:数据一致性如何保证

这是异步落库最核心的问题。引入 MQ 后,我们无法像同步方案那样"立刻知道"结果,只能通过最终一致性来保证。

代码语言:txt
复制
┌─────────────────────────────────────────────────────────────┐
│  第 1 层:实时补偿(发送 MQ 失败时立即回滚 Redis)          │
│  第 2 层:自动重试(RocketMQ 内置重试机制)                │
│  第 3 层:死信队列 + 人工介入(重试失败后)                │
│  第 4 层:定时对账 Job(凌晨跑批,核对 Redis 和 DB 库存)  │
└─────────────────────────────────────────────────────────────┘

五:总结与预告

异步落库方案,大幅提升了系统吞吐量,但系统复杂度提高了,却也带来了新的问题:

  1. 一致性降级:从"强一致"变为"最终一致",需要配套补偿机制
  2. 排查难度增加:问题从"同步报错"变成了"消息丢失、重复消费、积压",排查链路更长
  3. 运维成本上升:需要监控 MQ 的积压量、消费延迟、死信队列等

下一篇,介绍 本地消息表 + 补偿重试 方案。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前置条件:
  • 一:第三次调整后的流程
  • 二:MQ选型
  • 三:简略的一些伪代码
    • PS:秒杀场景补充
  • 四:异步落库可能出现的问题
    • 1:生产者发送消息失败怎么处理
    • 2:消费者消费失败怎么处理
    • 3:数据一致性如何保证
  • 五:总结与预告
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档