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

0 阅读7分钟

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

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


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

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

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


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

// 传统写法:在订单创建的事务里同步扣库存
@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

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

3.1 Redis 预扣减: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 脚本

@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

// ========== 生产者:下单成功后发送库存扣减消息 ==========
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 削峰:

下单请求 → 扔进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 次都失败了进了死信队列。所以必须加一个定时对账任务兜底:

@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 异步方案
接口平均响应时间2800ms85ms
数据库 CPU峰值 75%稳定 20%
并发支撑200 QPS 开始超时2000 QPS 稳定
行锁等待大量 Lock wait timeout几乎为零

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


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