无人售货机库存异步更新方案:解决高并发下单库存卡顿问题

17-库存异步更新方案:解决高并发下单库存卡顿问题

作者:黒漂技术佬 系列:RocketMQ 核心原理与无人售货柜项目实战


一、问题场景:早高峰的"库存卡顿"

早上 8 点,办公楼大厅。5 台无人售货柜同时迎来购买高峰------咖啡、面包、三明治被疯狂扫码下单。每笔订单都要扣库存,而库存都在同一台 MySQL 里。200 个并发请求同时打过来,数据库的 库存表 行锁排起了长队,接口响应从 50ms 飙升到 3 秒。用户等得不耐烦,干脆不买了。

这就是经典的同步扣减库存问题。先看看传统写法为什么不行。


二、传统同步扣减库存的三大痛点

java 复制代码
// 传统写法:在订单创建的事务里同步扣库存
@Transactional
public void createOrder(CreateOrderRequest req) {
    // 1. 插入订单
    orderMapper.insert(order);
    // 2. 逐行扣库存------这里加行锁!
    for (OrderItem item : req.getItems()) {
        int rows = stockMapper.deduct(item.getSkuId(), item.getQty());
        if (rows == 0) {
            throw new BizException("库存不足");
        }
    }
}

这段代码有三个致命问题:

痛点一:行锁竞争。

UPDATE stock SET count = count - ? WHERE sku_id = ? AND count >= ? 这条 SQL 对同一 SKU 加了行锁。高并发下,所有买同一款咖啡的人都得排队等这把锁释放。数据库 CPU 也许不高,但锁等待时间把整个系统拖垮了。

痛点二:事务过长。

订单插入 + 库存扣减在同一个事务里,事务持续时间 = 网络延迟 + SQL 执行 + 锁等待,毫秒级变秒级。而数据库连接池是有限的,长事务占着连接不释放,其他请求只能等待。

痛点三:耦合太紧。

库存扣成不成功直接影响订单创建结果。但实际上从用户视角,他关心的是"我下单成功了没",至于库存是同步扣还是异步扣,没人在意。把这两个操作强行绑定,牺牲了响应速度。


三、方案一:Redis 预扣减 + MQ 异步同步到 DB

核心思路:下单时先扣 Redis(内存操作,微秒级),后台再通过 MQ 慢慢同步到 MySQL

sql 复制代码
用户下单
  ↓
Redis DECR stock:sku:1001(原子操作,极快)
  ↓
库存够? → 保存订单(MySQL)→ 发MQ消息 → 返回"下单成功"
库存不够? → 返回"库存不足"
  ↓(异步)
MQ消费者收到消息 → UPDATE MySQL库存

3.1 Redis 预扣减:Lua 脚本保证原子性

lua 复制代码
-- Lua脚本:batch_deduct_stock.lua
-- KEYS[1..N]: 商品SKU的Redis key,格式 stock:sku:{skuId}
-- ARGV[1..N]: 对应扣减数量
-- 返回:1表示成功,0表示库存不足

for i = 1, #KEYS do
    local current = redis.call('GET', KEYS[i])
    if not current or tonumber(current) < tonumber(ARGV[i]) then
        -- 库存不足,回滚前面已扣的(虽然GET不扣库存,这里只是检查)
        return 0
    end
end

-- 全部检查通过,统一扣减
for i = 1, #KEYS do
    redis.call('DECRBY', KEYS[i], tonumber(ARGV[i]))
end
return 1

为什么要用 Lua 脚本?因为"检查库存 → 扣减库存"是两个 Redis 命令,如果不用 Lua,两个命令之间可能被其他请求插队,导致超卖。Lua 脚本在 Redis 中是原子执行的,脚本运行期间其他命令排队等待,彻底杜绝并发问题。

3.2 Java 调用 Lua 脚本

java 复制代码
@Service
public class InventoryService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private DefaultRedisScript<Long> deductScript;

    @PostConstruct
    public void init() {
        deductScript = new DefaultRedisScript<>();
        deductScript.setLocation(
            new ClassPathResource("lua/batch_deduct_stock.lua"));
        deductScript.setResultType(Long.class);
    }

    /**
     * Redis预扣减库存
     * @return true-成功,false-库存不足
     */
    public boolean preDeduct(List<OrderItemDTO> items) {
        List<String> keys = items.stream()
            .map(i -> "stock:sku:" + i.getSkuId())
            .collect(Collectors.toList());
        // 注意:ARGV需要转为String数组
        Object[] args = items.stream()
            .map(i -> String.valueOf(i.getQuantity()))
            .toArray();

        Long result = redisTemplate.execute(deductScript, keys, args);
        return result != null && result == 1;
    }

    /**
     * 回滚Redis库存(订单取消/超时时调用)
     */
    public void rollbackStock(List<OrderItemDTO> items) {
        for (OrderItemDTO item : items) {
            redisTemplate.opsForValue()
                .increment("stock:sku:" + item.getSkuId(), item.getQuantity());
        }
    }
}

3.3 MQ 异步同步到 MySQL

java 复制代码
// ========== 生产者:下单成功后发送库存扣减消息 ==========
public CreateOrderResult createOrder(CreateOrderRequest req) {
    // 1. Redis预扣减
    if (!inventoryService.preDeduct(req.getItems())) {
        throw new BizException("库存不足");
    }
    // 2. 保存订单到MySQL
    Order order = orderMapper.save(req.toOrder());
    // 3. 发送MQ消息(异步同步库存到DB)
    StockSyncMessage msg = StockSyncMessage.builder()
        .orderId(order.getId())
        .items(req.getItems())
        .operation("DEDUCT")
        .timestamp(System.currentTimeMillis())
        .build();
    rocketMQTemplate.asyncSend("StockTopic:DEDUCT", msg,
        new SendCallback() {
            @Override
            public void onSuccess(SendResult result) {
                log.info("库存同步消息发送成功,订单:{}", order.getId());
            }
            @Override
            public void onException(Throwable e) {
                // 发送失败,记录到本地消息表,定时补偿
                localMsgService.save(msg);
            }
        });
    // 4. 立即返回(不等DB同步完成!)
    return new CreateOrderResult(order.getId());
}

// ========== 消费者:批量消费,合并UPDATE ==========
@Service
@RocketMQMessageListener(
    topic = "StockTopic",
    selectorExpression = "DEDUCT",
    consumerGroup = "stock-sync-group",
    consumeMessageBatchMaxSize = 50  // 批量消费,一次拉50条
)
public class StockSyncConsumer implements RocketMQListener<List<StockSyncMessage>> {

    @Override
    public void onMessage(List<StockSyncMessage> messages) {
        if (messages.isEmpty()) return;

        // 按SKU聚合:相同SKU的扣减数量相加
        Map<Long, Integer> deductMap = messages.stream()
            .flatMap(m -> m.getItems().stream())
            .collect(Collectors.groupingBy(
                OrderItemDTO::getSkuId,
                Collectors.summingInt(OrderItemDTO::getQuantity)
            ));

        // 批量UPDATE:一条SQL更新所有SKU的库存
        // UPDATE stock SET count = count - CASE sku_id
        //   WHEN 1001 THEN 3
        //   WHEN 1002 THEN 5
        // END
        // WHERE sku_id IN (1001, 1002)
        stockMapper.batchDeduct(deductMap);
    }
}

批量消费和合并 UPDATE 是关键优化点。如果不合并,50 条消息就要执行 50 次 UPDATE,合并后只需要 1 次 CASE WHEN 语句,数据库压力直降两个数量级。


四、方案二:MQ 削峰 + 批量扣减

如果 Redis 成本太高(或者担心 Redis 和 DB 不一致),可以退而求其次用 MQ 削峰:

sql 复制代码
下单请求 → 扔进MQ队列 → 消费者批量消费 → 批量UPDATE库存

削峰的原理很简单:MQ 像个蓄水池,瞬时的大流量先存起来,消费者按自己能承受的速度慢慢处理。原本 1000 QPS 的瞬时流量,经过 MQ 缓冲后消费者以 200 QPS 的匀速处理,数据库就轻松了。

这个方案的缺点是用户下单后不能立刻知道库存是否足够(需要异步回调或轮询),体验不如 Redis 预扣方案。


五、数据一致性保障

Redis + DB 双写的经典难题:怎么保证 Redis 里的库存和 MySQL 里的库存最终一致?

5.1 MQ 消费失败重试

RocketMQ 默认重试 16 次,间隔递增(10s → 30s → 1min → 2min → ... → 2h)。只要重试最终成功,DB 库存就能追上 Redis。

5.2 定时对账

再强的重试也有死角。比如 MQ 消息丢了(虽然概率极低),或者消费了 16 次都失败了进了死信队列。所以必须加一个定时对账任务兜底:

java 复制代码
@Component
public class StockReconciliationJob {

    @Scheduled(cron = "0 */10 * * * ?")  // 每10分钟对账一次
    public void reconcile() {
        // 1. 查出Redis中所有库存
        Set<String> keys = redisTemplate.keys("stock:sku:*");
        // 2. 查出MySQL中所有库存
        List<StockVO> dbStocks = stockMapper.selectAll();
        // 3. 逐一比对
        for (StockVO db : dbStocks) {
            String redisKey = "stock:sku:" + db.getSkuId();
            String redisVal = redisTemplate.opsForValue().get(redisKey);
            int redisStock = redisVal == null ? 0 : Integer.parseInt(redisVal);

            if (redisStock != db.getCount()) {
                // 差异记录告警
                log.warn("库存不一致!SKU={}, Redis={}, DB={}",
                    db.getSkuId(), redisStock, db.getCount());
                // 根据业务规则决定以谁为准(一般以DB为准修复Redis)
                redisTemplate.opsForValue()
                    .set(redisKey, String.valueOf(db.getCount()));
            }
        }
    }
}

5.3 本地消息表补偿

MQ 发送失败时,消息写入本地数据库的 local_msg 表,另一个定时任务扫描未发送成功的消息重试。这个设计也叫事务消息的简化版------不使用 RocketMQ 的事务消息特性,而是自己实现。


六、无人售货柜早高峰性能效果

以我们实际部署的数据为例:

指标 同步扣减方案 Redis + MQ 异步方案
接口平均响应时间 2800ms 85ms
数据库 CPU 峰值 75% 稳定 20%
并发支撑 200 QPS 开始超时 2000 QPS 稳定
行锁等待 大量 Lock wait timeout 几乎为零

85ms 的响应时间里,大部分是网络开销和订单插入 MySQL 的时间。库存扣减因为走 Redis,实际耗时不到 1ms。


库存异步更新的本质就是用空间换时间、用最终一致换强一致。Redis 承担热数据的读写压力,MySQL 退居二线做持久化存储,MQ 在中间做异步衔接。这个模式不局限于无人售货柜,电商、秒杀、抢票等场景同理。

相关推荐
阿拉斯攀登1 小时前
无人售货机MQ消息丢失、重复消费、超时异常全套兜底方案
后端
做系统的大强1 小时前
我用中文从零写了一个操作系统(下篇):从45个BUG到165个——假持久化、USB地狱与OS自举
后端
程序员老赵1 小时前
Docker 部署 Rocky Linux:轻松搭建 RHEL 兼容企业级基础镜像平台
linux·后端·docker
用户6152612132101 小时前
Java主流框架与源码:Spring Framework
后端
yume_sibai2 小时前
03-Rust 函数式编程特性(闭包 + Iterator + Option/Result + 链式调用)
开发语言·后端·rust
吃饱了得干活2 小时前
Redis 不是死脑筋,它是一套“会进化”的存储系统
redis·后端
伩仁2 小时前
别再 HTTP 200 一把梭了:用 RFC 9457 Problem Details 给 FastAPI 错误响应"立规矩"
后端
MeetTanG2 小时前
Go 实战锦囊|errgroup:优雅地管理并发任务组
后端·go
PragmaticWorks3 小时前
DDD 学了很多却用不上?因为你把“责任”和“时机”揉在了一起
后端·领域驱动设计