ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无人售货机高并发库存扣减:从行锁竞争到异步最终一致性

无人售货机高并发库存扣减:从行锁竞争到异步最终一致性 1. 项目背景无人售货机库存卡顿的真实场景我做了几年无人零售相关的后端系统第一次遇到库存卡顿问题是在一个社区生鲜柜项目上线后的第二周。当时系统逻辑并不复杂用户扫码开柜、拿商品、关门扣款库存由云端统一维护。但问题来了——早晚高峰时段十几个柜子同时被用户操作订单请求集中在几秒内涌入数据库的库存行锁竞争非常激烈下单接口耗时从正常的80毫秒飙升到2秒以上用户端表现为“支付成功但出货失败”“柜门开了但扣款转圈”运营后台则看到库存数据和柜端实际货道余量经常对不上。这个标题涉及的解决方案核心目标就是回答一个问题在高并发下单的场景下如何减少库存扣减对数据库的同步压力同时保证库存数据的最终一致性和货道出货的准确性。这套方案不只适用于无人售货机凡是涉及“设备端云端库存”的IoT零售场景比如共享货柜、自助咖啡机、冰淇淋机都可以复用同样的设计思路。在展开技术细节前需要先搞清楚业务链条里的两个核心角色云端库存和设备端实货。云端库存是用户下单时用来校验和扣减的“账本”而设备端实货是货道里真实存在的商品数量。两者天然存在时间差——用户下单的瞬间扣的是云端库存机器货道里的实物只有在出货成功或失败后才能真正确定。任何库存方案本质上都是在处理这两个数字之间的同步与纠偏问题。2. 高并发场景下的库存卡顿根因分析2.1 数据库行锁竞争库存扣减为什么慢先看一个最传统的库存扣减SQLUPDATE product_sku_stock SET stock stock - 1 WHERE sku_id P12345 AND stock 0;这条语句在低并发下没有任何问题原子性由数据库行锁保证。但在无人售货机的高峰场景里同一个热门sku比如可乐在同一时刻可能有几十个用户同时下单这些请求全部落在这一行记录上。数据库为了保证不超卖必须串行化处理这些UPDATE每个请求都要等待前一个请求释放行锁。锁等待时间一长接口RT就上去了。我实测过一个压测数据单行库存记录在MySQL默认配置下若每秒有200个并发扣减请求平均等待时间在300ms左右而表里有其他慢查询时这个数字直接翻倍。无人售货机业务有明显的潮汐特征早高峰和午高峰的请求量差距可以达到20倍如果按峰值来设计同步扣减数据库压力极大。这里还藏着一个更隐蔽的问题库存表和其他业务表之间的关联更新。下单时要同时写订单表、扣减库存表、记录流水表如果这些操作在一个大事务里同步执行任何一个环节慢都会拖累整条链路。2.2 订单系统与库存扣减的耦合问题初版的库存扣减逻辑往往长这样Transactional public void createOrder(CreateOrderRequest request) { // 1. 锁定设备 // 2. 校验商品 // 3. 扣减库存UPDATE // 4. 写订单表 // 5. 下发指令到设备 }步骤3、4、5在一个事务里事务时间越长持有的数据库连接越久连接池耗尽的风险越高。一旦设备指令下发超时网络抖动、设备离线事务回滚库存又加回去但用户侧可能已经收到了扣款成功通知业务上非常被动。2.3 超卖与少卖两个方向的库存风险高并发下最怕两类问题超卖并发扣减时多个请求都读到库存大于0导致实际扣减数量超过真实库存。数据库行锁可以勉强防住这一点但水平分库分表后分布式环境下的库存校验会变得更复杂。少卖设备出货失败但云端库存已经扣减导致账实不符。这种情况在同步方案里同样存在因为同步方案保证的是“扣减动作的原子性”而不是“扣减与实际出货的一致性”。理解这两个方向的风险后就能理解为什么异步更新方案是无人售货机场景下的合理选择——它需要把“扣减库存”和“确认实际出货结果”解耦让每个步骤可以独立应对高并发和失败补偿。3. 异步更新方案的整体设计与架构拆解3.1 异步更新的核心思路削峰填谷与最终一致异步更新方案的核心是把“用户下单时同步扣减数据库库存”改为“用户下单时扣减本地缓存/预占额度异步任务再同步到数据库并完成最终扣减”。换成人话就是先用最快的方式把订单接下来然后慢慢把账算清楚。整个链路分为三个环节流量入口前置校验请求进来时先从本地缓存Caffeine或分布式缓存Redis里读取库存余量做一次快速预校验。如果缓存余量不足直接返回售罄根本不进数据库。预占扣减快速响应通过Lua脚本或Redis原子操作对缓存中的库存做预占扣减保证不超卖。此时用户看到的是“下单成功”这个过程通常在10ms以内。异步落库最终一致下单成功后把扣减事件写入消息队列RocketMQ或RabbitMQ由消费端异步更新MySQL中的真实库存同时记录库存流水为后续对账留痕。画成时序就是用户请求 - 网关/接口 - Redis预占扣减 - 返回成功 | v 消息队列异步 | v MySQL库存表最终扣减这个设计把原来同步链路里“数据库写操作订单写入设备指令下发”的串行等待变成了“Redis预占快 消息队列削峰缓冲 数据库异步落库稳”三个独立环节。数据库的压力从峰值200 QPS直接摊平到稳定30 QPSRT自然就降下来了。3.2 为什么选择Redis预占扣减而不是直接操作数据库选择Redis做预占扣减理由很直接Redis是单线程模型所有的操作天然串行化不会出现并发竞争问题。而且Lua脚本可以保证“检查库存扣减库存”是一个原子操作从机制上规避了超卖。实际项目的扣减脚本-- 扣减库存脚本 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 库存不存在 end if tonumber(stock) tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 扣减成功这个脚本的精髓在于判断和扣减在同一个原子操作里完成不会有两个请求同时读到“库存还够”的情况。如果直接用“GET再DECR”两步操作并发下超卖必然发生。3.3 消息队列在库存异步落库中的作用消息队列是整个异步方案里承上启下的部分。上游Redis预占成功后向MQ发送一条“库存扣减事件”内容至少包含skuId、数量、订单号、货道号、时间戳。消费者拿到这条消息后再对MySQL执行真正的扣减。这里要重点处理两个问题消息不丢和消息不重复。消息不丢靠MQ的ACK机制和生产者端的同步发送消息不重复则靠消费端的幂等设计。我比较推荐在库存流水表上建唯一索引用“订单号skuId”作为唯一键重复消费时插入失败直接忽略这样比单纯用分布式锁更简单可靠。4. 实操过程分布式锁与Sentinel流控的完整落地4.1 自研Redis分布式锁的要点Redis预占扣减虽然解决了一行库存的并发问题但无人售货机的库存操作往往不是单商品操作。比如用户一次购买“1瓶可乐1包薯片”Redis要扣减两个key这两个操作必须是原子的。最简单的方案就是用Lua脚本同时扣减多个key但如果跨多台Redis实例集群模式就需要分布式锁来保证操作顺序。我用的分布式锁基于Redisson实现RLock lock redissonClient.getLock(stock:lock: orderId); boolean locked false; try { locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (locked) { // 执行Redis预占扣减 stockService.deductStock(skuList); // 发送MQ消息 mqProducer.sendStockDeductMessage(orderId, skuList); } else { throw new BizException(系统繁忙请稍后重试); } } finally { if (locked) { lock.unlock(); } }锁的粒度要控制在“订单维度”不要用全局锁否则又变成串行化操作了。Redisson的锁基于看门狗机制默认30秒过期可以避免业务卡死导致锁无法释放的坑。4.2 Sentinel流量治理防止尖峰压垮下游仅仅做了异步化还不够因为Redis预占扣减虽然快但消费端最终还是要落库如果消息瞬间堆积MySQL同样扛不住。这里就需要引入流控组件我选择的是阿里的Sentinel。我在消费端和订单入口各设置了一条流控规则订单入口按接口QPS限流单机阈值设为200超出的请求直接返回“繁忙请稍后再试”避免雪崩。库存消费端按消息消费速率限流即每秒钟最多处理100条库存扣减消息多余的消息在MQ里排队让数据库有充足的时间完成写入。Sentinel配置规则# application.yml spring: cloud: sentinel: transport: dashboard: localhost:8080 datasource: flow: nacos: server-addr: localhost:8848 >CREATE TABLE stock_flow_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(64) NOT NULL, sku_id VARCHAR(32) NOT NULL, quantity INT NOT NULL, action_type TINYINT NOT NULL COMMENT 1预占扣减, 2出货确认, 3回滚释放, status TINYINT NOT NULL COMMENT 0处理中, 1成功, 2失败, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_order_sku (order_id, sku_id) );在消费逻辑里先insert流水表如果insert成功说明这条消息是第一次处理继续执行库存变更如果insert报唯一键冲突说明是重复消息直接返回消费成功。这个方案比分布式锁简单得多而且天然支持水平扩展。5. 分布式事务与最终一致性保障机制5.1 订单与库存的分布式事务处理在做无人售货机系统时订单服务和库存服务往往已经被拆成了独立的微服务此时“下单写订单 扣减库存”就变成了跨服务的分布式事务。常见的方案有本地消息表、MQ事务消息、TCC。考虑到无人售货机的业务特点库存扣减的最终一致性要求不是秒级而是秒到分钟级可接受因为用户从下单到实际出货有物理时间间隔在这里不需要强一致的分布式事务。RocketMQ的事务消息方案最合适。它的工作方式比较巧妙先发送一条“半消息”也就是消费者暂时不可见的消息同时去执行本地事务写订单表并修改业务状态。如果本地事务执行成功向MQ发送commit指令半消息变为可见消息消费者开始处理如果本地事务回滚发送rollback指令消息被销毁。步骤1: 发送半消息订单创建中 步骤2: 执行本地事务创建订单、扣减Redis预占 步骤3a: 本地事务成功 - commit - 消费者可见 - 异步落库 步骤3b: 本地事务失败 - rollback - 消息会被丢弃 步骤4: MQ回调检查本地事务状态防止第2步超时造成状态丢失这个方案最大的优点是生产者和消费者两个服务之间的数据最终一致不用额外维护本地消息表代码侵入小。缺点是MQ本身必须支持事务消息RocketMQ原生支持RabbitMQ需要额外插件才能实现类似能力。5.2 定时对账异步方案的兜底保障异步方案再完备也架不住消息丢失、消费者宕机等极端情况。所以定时对账是异步更新方案里绝对不能省的一环。我的做法是每5分钟跑一次对账任务逻辑如下查询所有设备在最近5分钟内的出货记录设备端上报。查询这些设备对应的云端订单状态和库存流水。对比三者的关系如果设备出货成功但云端库存流水没有扣减记录说明异步链路丢了消息触发补偿如果设备出货失败但云端已经扣减触发回滚。对账任务用xxl-job调度跑在独立的服务上不占业务线程。对账时发现了差异写一条补偿记录到专门的MQ主题里由消费者执行修正动作。注意如果没有特殊要求个人项目建议直接用日志人工定期核对不必一开始就上自动化对账成本会高很多。5.3 架构演进从单机锁到RedLock集群场景如果公司规模小只有一台Redis上面提到的分布式锁够用了。但如果系统扩展到了多个机房、多套Redis那就要考虑RedLock算法保证分布式锁在Redis集群下的安全。RedLock的机制通俗讲就是锁状态由多个独立的Redis节点共同确认在大多数节点上都加锁成功才算加锁成功。这样即使个别节点宕机其他节点依然能保证锁的正确性。需要注意RedLock在业界有一些争议G哥Martin Kleppmann写过一篇文章讨论它是否安全更多细节这里不展开结论是如果单机Redis能满足需求建议不要为了技术炫耀过度设计。6. 常见问题与排查技巧实录6.1 Redis缓存与数据库库存不一致这是我在项目里遇到频率最高的问题。Redis预占扣减成功后异步消费端可能因为网络问题、消费异常等没能及时同步到MySQL导致Redis显示库存少但数据库库存没变。排查步骤看消费端日志里是否有异常或重试记录。查MQ控制台的积压量如果积压量大说明消费端吞吐不足。看Redis和MySQL的库存差值如果Redis比MySQL小说明预占的消息未完全落库。触发一次手动补偿任务把Redis库存同步成数据库的真实值。如果补完很快又不一致说明消费端有持续性问题要重点看消费逻辑里的异常分支是否写了return而没有重试。6.2 消息重复消费导致库存扣少排查思路是查stock_flow_log表里同一order_id是否存在多条记录。如果唯一键没生效就去查表结构检查是不是字段设置了NULL导致唯一约束失效这种情况很坑因为MySQL的唯一索引在有NULL值时并不会约束。解决方案是确保order_id和sku_id都不允许为NULL或者用另一个专用的“消息去重表”来处理。6.3 Sentinel降级导致正常请求被拒绝Sentinel的流控规则如果设置不当会把正常请求也拦截掉。我遇到过把QPS阈值设为50结果早高峰流量正常到了80直接让40个用户的请求失败了。排查方式看Sentinel控制台的实时监控曲线是否触发了拦截。看降级日志中是否有Blocked by Sentinel的异常信息。调整阈值和排队等待时间不要只调高QPS还要注意超时时间的设置。6.4 定时任务与异步任务抢锁冲突对账任务和消费者同时在处理同一笔订单时可能造成数据错乱。解决方式是对order_id加一把独立锁对账修正和消息消费先抢锁抢到的人才允许操作订单状态。我踩过这个坑某天对账任务把一批失败的库存回滚了同时消费端又收到补偿消息再次扣减直接导致库存变成负数。后来所有库存变更操作都必须先经过stock:order:{orderId}这把分布式锁问题才彻底解决。7. 方案效果与适用边界上线这套异步更新方案后我们系统的关键指标变化如下指标优化前优化后下单接口平均RT850ms68ms下单接口最大RT2200ms180ms数据库CPU使用率峰值92%30%库存超卖次数月均5次0次订单/库存不一致恢复时长小时级分钟级对账触发在这个表里RT的大幅下降主要来自Redis预占数据库CPU下降则来自消息队列削峰。但这个方案也不是银弹。适合它的场景是库存操作频繁、单次库存变更要求不苛刻、允许最终一致。如果业务要求用户下单后严格看到实时剩余库存比如在线商城首页展示剩余件数异步更新可能带来页面展示与真实库存的短暂不一致。无人售货机恰好没有这个问题——用户面对的是物理货道货道里有货没货一眼可见云端库存只需要在设备出货前保持相对准确即可。另外如果客单价极高、用户对支付结果极度敏感异步方案的失败补偿链路需要更谨慎的设计一定要保证“扣款成功但出货失败”时能快速自动退款而不是让用户等待人工处理。8. 方案扩展与经验沉淀8.1 从无人售货机到共享设备场景的复用这套“缓存预占 消息异步 最终兜底”的架构可以平移到任何共享设备业务。我在后续做共享洗衣机和共享按摩椅时几乎直接搬了这套库存模型只是把“sku”换成了“设备编号服务时长”。凡是涉及“设备状态 订单 服务确认”三个要素的行业都能用同一套思路来解决高并发下的状态一致性问题。8.2 稳定性意识比炫技重要这套方案里每引入一个组件Redis、MQ、Sentinel、分布式锁就多一个故障点。项目初期如果并发量还没起来建议先从“同步扣减 定时补偿”开始等业务确实出现瓶颈再上异步。技术上“够用”比“强大”更重要这和我见过很多过度设计的惨痛教训一致。写到最后分享一个经验做无人售货机库存这类系统比技术方案更难的其实是“模拟真实设备各种异常”的能力。异步更新方案跑得好不好不能只盯着正常路径优化一定要把设备断电、网络延迟、出货卡货、重复出货这些边界情况全部走一遍。我后来搭了一个故障注入环境专门模拟这些异常才敢上线这段经历让我对“高并发下的一致性”有了更实在的理解。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进