ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

无人售货机高并发库存扣减:Redis预扣+异步对账的最终一致性实践

无人售货机高并发库存扣减:Redis预扣+异步对账的最终一致性实践 1. 售货机库存卡顿到底卡在哪问题现场与根因定位前阵子处理了一个无人售货机项目的库存问题现象非常典型白天高峰期用户扫码下单后设备半天没反应转圈转个五六秒才出货运气不好直接超时失败。后台日志一看数据库里库存表的行锁等待时间飙升下单接口的P99延迟从平时的200ms直接拉到3秒以上。先把这个场景拆开说清楚。无人售货机和普通电商有个很大的区别电商的库存是中心化的所有流量打到一个库存服务上而售货机的库存是分散的每个货道对应一种商品一台设备有几十个货道一个城市可能铺了几千台设备。表面上看每台设备的并发量不高但问题出在下单链路的耦合方式上。我接手时项目里原来的下单逻辑是这样的用户扫码请求打到订单服务。订单服务先查一次设备状态确认在线。再查一次商品信息确认货道里有货。执行库存扣减UPDATE inventory SET stock stock - 1 WHERE device_id ? AND slot_id ? AND stock 0。库存扣减成功生成订单推送消息给设备出货。设备上报出货结果再异步回写库存流水。最后一个异步回写库存流水看着是异步但真正的库存扣减还是同步的而且扣减之后还要插入一条库存流水表记录。更麻烦的是同一台设备的同一个货道在高并发下会同时收到多笔订单MySQL的行锁会让这些请求串行排队。锁等待一长数据库连接池被打满其他设备的请求也跟着遭殃——这就是卡顿的传播链。另一个隐藏问题在库存流水表。原本设计是每次扣减、回滚都插一条流水便于对账。但高峰期这个表的写入量特别大而且流水表还建了一个唯一索引约束device_id slot_id order_id type重复请求触发唯一键冲突时InnoDB会先拿锁再检查冲突进一步加剧锁竞争。我把问题归结为三点库存扣减所有逻辑都压在数据库层没有分层缓冲。扣减和流水写入是强一致事务高峰期每个货道都成了串行瓶颈。查询操作、状态检查、库存操作混在一个事务里锁持有时间被拉长。这是典型的单库单表扛并发的架构问题。硬件再好数据库的锁机制决定了它在高并发写场景下就是瓶颈这不是加机器能解决的得从架构上做拆分。2. 异步更新方案的整体设计思路为什么不能直接改SQL确定了问题方向之后第一版想法是优化SQL比如改成UPDATE inventory SET stock stock - 1 WHERE stock 0加个乐观锁版本号或者加个货道级别的分布式锁。这些方案能解决一部分问题但解决不了根本矛盾。核心矛盾在于高并发下单场景下库存扣减的准确性和响应速度之间的冲突。如果追求强一致那每一笔订单都必须实时校验库存、实时扣减、实时写流水这意味着一台设备的同一个货道同一时间只能有一个请求在操作库存其他请求必须排队。排队时间一长用户体验就会下降。如果追求响应速度那就不能实时扣减需要把扣减这个动作改成预占或异步扣这就引入了库存超卖的风险。权衡之后我选了折中方案同步预占 异步确认 对账兜底。也就是用Redis做前置库存校验和预扣真正落库改为异步批量处理。这个思路在电商大促场景很常见但用在售货机上需要做一些适配因为售货机涉及实体出货库存确认的时机比纯线上电商更难把握。整体流程分为四层接入层Nginx 网关做基础限流单台设备维度做令牌桶限流防止某台设备被恶意刷单拖垮整个服务。缓存层Redis保存每个货道的实时库存余量所有下单请求先打到Redis做预扣用Lua脚本保证原子性。异步队列层预扣成功后把扣减消息丢进MQ由库存消费者批量更新数据库。对账层定时任务比对Redis和数据库库存发现不一致就告警并自动修正。这套方案里最关键的一个设计决策是数据库不再是实时扣减而是异步记账。用户下单的响应速度不取决于数据库的写入速度而取决于Redis的操作速度。Redis单机QPS能到10万级别处理售货机这点流量绰绰有余。那有人会问Redis挂了怎么办Redis数据丢了怎么办异步更新数据库失败了怎么办这就要靠下面的链路设计来解决。3. 库存预扣的原子性保障Redis Lua脚本方案先讲最核心的预扣环节。之所以必须用Lua脚本是因为Redis的多个操作组合在一起时如果用普通的get、decr两条命令分开执行中间会插入其他请求的操作导致超卖。举个例子货道还剩1件库存两个用户同时下单。请求A读到库存1请求B也读到库存1。A执行扣减变成0B再执行扣减就变成-1了——这就超卖了。加分布式锁可以解决但锁的获取和释放有额外开销高并发下锁竞争本身又会变成瓶颈。Lua脚本的原子性来自Redis的单线程模型一个脚本执行期间其他命令必须等待。所以脚本里做的先检查再扣减是天然原子不需要额外加锁。我用的Lua脚本大概长这样local key KEYS[1] local current_stock tonumber(redis.call(GET, key)) if not current_stock then return -1 -- key不存在说明库存未加载走降级逻辑 end if current_stock 1 then return 0 -- 库存不足 end redis.call(DECR, key) return 1 -- 预扣成功这个脚本看起来简单但有几处细节要注意。第一GET返回的是字符串必须用tonumber转换否则和数字比较时Lua会做类型转换等于1的字符串和数字1在Lua里比较是会出问题的。第二key的粒度要设计好。我这里是inv:{device_id}:{slot_id}一个货道一个key。这样设计的好处是一个货道的预扣失败不影响其他货道而且Redis的热点压力被分散到了不同key上。第三预扣成功后要设置一个合理的过期时间。我给的是30分钟因为从用户下单到实际取货正常不会超过这个时间。如果用户一直不取货过期后Redis自动释放库存回到可售状态。这里有个细节Redis过期机制对每个key是惰性删除的过期后第一次访问才会真正删除所以过期时间不要太短否则容易出现明明有货但显示没货的尴尬。预扣成功后订单服务就可以直接返回下单成功了。数据库的扣减动作放到了MQ消费者的异步任务里。顺手贴一下Java里调用这段脚本的方式private static final String STOCK_PREDEDUCT_SCRIPT local key KEYS[1] local current_stock tonumber(redis.call(GET, key)) if not current_stock then return -1 end if current_stock 1 then return 0 end redis.call(DECR, key) return 1; public StockPreDeductResult preDeduct(String deviceId, String slotId, int quantity) { Long result redisTemplate.execute( new DefaultRedisScript(STOCK_PREDEDUCT_SCRIPT, Long.class), Collections.singletonList(inv: deviceId : slotId) ); // 根据 result 的值分别处理1预扣成功0库存不足-1缓存未加载 }要注意DefaultRedisScript只接受ListString类型的keys参数如果你习惯传可变参数数组进去会把第一个元素误当成keys之外的参数导致脚本执行报错。这个坑我踩过一次排查了半天。4. 数据库异步扣减与消息可靠性从MQ到最终一致预扣成功只是第一步真正的数据落库靠的是异步消费者。这里面的链路是订单服务把一条库存扣减消息发到MQ消费者拿到消息后执行数据库扣减。这个环节的可靠性直接决定了库存账目准不准。4.1 消息体设计消息体不要只传一个订单ID然后让消费者再去查库。查库有延迟而且如果订单服务本身挂了消费者可能查不到订单信息。我直接把扣减所需的全部字段放进消息体{ messageId: uuid, orderId: 20250115001, deviceId: DV10086, slotId: SLOT_A01, productId: SKU001, quantity: 1, operatorId: op_admin, timestamp: 1705305600000 }这样消费者拿到消息就能直接执行UPDATE不需要回查订单服务减少一次远程调用也避免了回查失败导致的消息处理失败。4.2 消费端幂等设计异步消息的幂等是最容易踩坑的地方。MQ的投递语义是at-least-once也就是说一条消息可能被投递多次消费者必须保证同一消息执行多次和执行一次效果一样。我的幂等方案是数据库里建了一张inventory_operation_log表主键是message_id插入操作执行的是INSERT IGNORE。消费者收到消息后先尝试插入这张表插入成功说明是第一次处理继续执行库存扣减。插入失败主键冲突说明之前已经处理过直接ACK跳过。有同学会问那扣减和插入操作日志不是两个操作吗怎么保证原子性答案是放在同一个本地事务里。INSERT IGNORE和UPDATE在同一个事务内执行要么都成功要么都失败。消息确认是在事务提交之后才做的所以不会出现扣减成功但消息没确认的情况。这里有一个细节要注意如果使用MySQL的INSERT IGNORE事务提交后主键冲突的行会占用一个自增ID可能导致主键自增跳跃。对业务没有影响但如果你们公司的DBA对自增ID连续性有强迫症建议换成INSERT ... ON DUPLICATE KEY UPDATE配合affected rows判断。4.3 消息重复与Redis预扣的联动还有一层容易漏掉的防护MQ可能重复投递但Redis只预扣了一次。如果消费者把同一条消息处理两次虽然数据库有幂等兜底不会重复扣但Redis的预扣已经减过了第二次处理时不能再去减Redis。我的做法是**Redis的预扣只在请求入口执行一次消费端只做数据库扣减不碰Redis。**Redis的库存恢复靠的是过期自动回补不靠消费端回写。这个设计把Redis和数据库的交互边界切得很干净不会出现两边互相覆盖数字的问题。但这样也引入了一个新的问题预设了Redis的库存一定准。如果数据库异步扣减失败比如设备已删除、数据库异常Redis的库存已经被预扣了怎么办这时靠第5节的对账机制来修复。4.4 MQ选型售货机项目的消息量不大单台设备每天撑死几百单几千台设备也就百万级一天所以对MQ的吞吐要求不高但对稳定性要求高。我使用的是RocketMQ选它的原因有几点事务消息支持好但这里我们用不到事务消息用的是普通消息。延迟消息内建支持用于超时未支付订单的自动取消。消费端有重试队列失败自动重试16次重试次数可配置。支持按Tag做消息过滤方便后续扩展消息类型。如果项目里没有RocketMQ用RabbitMQ也可以但要注意RabbitMQ的消息确认机制默认是手动ACK还是自动ACK配置错了会丢消息。4.5 消费者线程数与批量消费者端不建议一条一条消费建议开启批量消费。RocketMQ的consumeMessageBatchMaxSize可以设置一次拉取多少条但要注意批量消费的消息如果有一条处理失败整批都会重试所以批量大小不要太大我设置为20条。另外一个容易踩的坑是消费者的并发度。RocketMQ的消费者并发数默认为CPU核数如果消费者实例数量很多并发度太高数据库压力也会变大。我根据数据库性能把消费端的并发线程数限制到10个预留足够的数据库连接给其他业务。5. 库存还原与防超卖策略用户不取货怎么办异步方案最怕的就是库存空了但用户没付款或者付了款但没取货导致库存虚占。售货机场景里这个问题尤其明显用户下单后不取货货道里的货其实还在但Redis已经把库存扣了后续用户看到的就是无货。我的处理分了三个维度超时未支付、支付成功未取货、出货失败。5.1 超时未支付定时任务回补售货机的下单流程是用户选货 - 创建订单 - 跳转支付 - 支付成功 - 设备出货。如果用户下单后一直不支付订单会一直占着库存。我设了5分钟超时时间订单状态为待支付超过5分钟自动取消并释放库存。实现方式是最简单的定时任务扫描每30秒扫一次待支付超时的订单把Redis库存加回去再把订单状态改成已取消。扫描频率不要太高不然全表扫描会拖累数据库性能。释放库存的Lua脚本local key KEYS[1] local current tonumber(redis.call(GET, key)) if not current then redis.call(SET, key, ARGV[1]) return 1 end local newStock current tonumber(ARGV[1]) redis.call(SET, key, newStock) return 1注意这里不能直接用INCR因为key可能不存在直接INCR在key不存在时会默认从0开始加如果货道初始库存是10Redis里没有key时INCR会变成1而不是11所以要先判断key是否存在。5.2 支付成功未取货设备出货确认回写库存状态支付成功之后设备收到出货指令执行出货然后上报结果。这个过程中有两种失败可能一是设备出货失败卡货、缺货二是用户没去取货。这两种情况在设备端看来最终都会上报一个出货未完成的状态。我设计了出货结果回调接口设备在出货完成后调用出货成功订单状态更新为已完成同时通知库存消费者把该货道的数据库已售数量1。出货失败订单状态更新为出货失败自动退款同时释放Redis库存。这里要注意释放Redis库存的前提是确认设备确实没出货。如果设备出货了但回调失败你把库存加了回去就会导致超卖。所以我在设计时增加了一个状态机约束只有订单状态处于支付成功时才能执行释放库存的操作一旦订单状态流转到出货完成就不再允许库存回补。这个约束是在数据库层面实现的用乐观锁UPDATE orders SET status CANCELLED, stock_released 1 WHERE order_id ? AND status PAID AND stock_released 0如果影响行数为0说明订单状态不是PAID或者已经释放过库存直接忽略回调。5.3 防止并发回补导致超卖还有一个并发场景同一货道一个用户支付成功但设备出货失败系统自动释放库存与此同时另一个用户刚好看到库存恢复了下单成功。结果设备里其实没有货第二个用户也会出货失败。这是售货机特有的问题电商不会遇到因为电商的库存是统一的。要彻底解决这个问题必须在设备端也做校验。我在设备的下发指令里加了一个sale_token字段只有携带有效token的订单才能触发出货。设备出货前校验token每个货道的token用一个本地队列维护每次出货前从队列头部取出一个token。也就是即使后台库存显示有货如果设备本地队列没有token出货指令也会被拒绝。这个方案确保了后台库存 设备实际可出货量从源头上杜绝了超卖的可能。6. 缓存与DB的一致性保障三种偏差场景的对账修复异步方案跑了一段时间后我统计了一下Redis和数据库的一致性情况发现主要有三种偏差偏差类型产生原因频率严重程度Redis大于DB超时订单回补了Redis但DB的扣减还没完成中等低Redis小于DB业务流程正常但DB的异步扣减滞后高低Redis与DB明显不一致消息丢失、代码bug、数据修复操作低高前两种偏差是正常业务时序导致的过一段时间会自动对齐。第三种才是真正要关注的必须有机制及时发现。我写了一个对账定时任务每小时跑一次扫描当天有订单的货道从DB查出真实库存。从Redis查出预扣后的实时库存。计算差值如果差值大于阈值比如5件触发告警。对账任务的SQL很简单SELECT i.device_id, i.slot_id, i.quantity AS db_stock, i.created_at FROM inventory i WHERE i.updated_at NOW() - INTERVAL 1 HOUR ORDER BY i.updated_at DESC查出DB库存后再用一个MGET批量查Redis库存ListString invKeys new ArrayList(); for (InventoryRecord record : dbRecords) { invKeys.add(inv: record.getDeviceId() : record.getSlotId()); } ListString redisStocks redisTemplate.opsForValue().multiGet(invKeys);然后逐条对比。如果Redis库存和DB库存的差值超过阈值就发告警给值班人员。注意这里不是直接自动修复因为自动修复可能掩盖潜在bug。我一般先告警人工确认原因后再手动修复。但如果对账任务发现Redis和DB偏差是正常的业务时序比如预扣了但DB还没扣完不要告警避免打扰。判断逻辑是偏差小于等于当天该货道未完成异步扣减的订单数属于正常。这里要额外提醒一句对账任务千万不要用全表扫描。售货机项目虽然设备多但库存数据量也不小全表扫一次要好几分钟。我在inventory表上建了device_id slot_id联合索引并且对账只扫当天有变更的记录用updated_at字段过滤把每次对账的耗时控制在了10秒以内。7. 压测与调优一次不理想的压测结果带出的三个配置问题方案落地之后我做了一轮压测。压测目标很简单一台设备一个货道库存100件模拟100个用户同时下单。压测结果出来后问题不少这里记录一下最典型的三个。第一个问题Redis预扣串行化导致吞吐上不去。压测刚开始P99延迟就不太正常一直在500ms上下徘徊。查了Redis和应用的连接池发现应用连接Redis的方式有问题每个请求都新建连接没有用连接池。预扣操作的网络往返都消耗在连接建立和断开了。修复方案使用连接池。Jedis和Lettuce都支持连接池配置我用的Spring Data Redis默认走Lettuce配置一下spring.redis.lettuce.pool.max-active为50。第二个问题数据库扣减和Redis预扣走了两个事务。这个是我设计时的失误。原始的扣减代码里Redis预扣后紧接着做了一次数据库同步扣减当时想做个双写保险结果每次下单请求同时操作Redis和MySQLMySQL的锁等待又把延迟拉高了。去掉数据库同步扣减后P99延迟从500ms降到了150ms。第三个问题库存流水在同一张表。库存流水表和订单数量在同一个MySQL实例上每次异步扣减都要插入流水表。压测时流水表写入操作触发了磁盘IO瓶颈。把流水表拆分到单独的库或者用ES存储流水查询需求问题就解了。对于售货机这种体量完全可以用MongoDB存流水查询也灵活。这一轮压测调优之后数据比较理想了指标优化前优化后P99下单延迟3s以上180ms左右数据库行锁等待时长频繁基本消失库存准确率—99.97%对账结果单货道吞吐5 QPS50 QPS以上8. 高并发下的降级方案Redis挂了库存怎么不崩写异步方案最怕的就是Redis挂掉。虽然Redis本身可靠性很高但各种极端情况总得考虑。我的降级方案分了三档8.1 第一档Redis超时降级Redis访问超时设置了redisTemplate的connectTimeout为500ms先重试一次还超时就降级到数据库直接扣减。这时候数据库压力会突然变大所以降级开关要做成动态的线上通过配置中心控制不要写死。代码逻辑大致是public boolean deductStock(String deviceId, String slotId, int quantity) { try { Long result preDeductFromRedis(deviceId, slotId, quantity); if (result 1) { return true; } else if (result 0) { throw new InsufficientStockException(库存不足); } else { // key不存在走降级 return deductStockFromDb(deviceId, slotId, quantity); } } catch (RedisConnectionFailureException e) { log.warn(Redis连接异常降级到数据库扣减); return deductStockFromDb(deviceId, slotId, quantity); } }注意这里降级到数据库扣减时不能和Redis预扣同时跑否则会出现双扣。我通过一个开关变量控制一旦走降级本次请求不再触碰Redis。8.2 第二档Redis数据丢失降级Redis宕机重启后数据为空所有货道的库存key都失效。这时候如果还走Redis预扣所有请求都会返回-1直接把服务打没。我加了一个启动事件监听Redis启动时从数据库把当天有售卖的货道库存加载到Redis。Component public class RedisInventoryInitializer implements ApplicationListenerApplicationReadyEvent { Override public void onApplicationEvent(ApplicationReadyEvent event) { // 查出所有库存大于0的货道批量写入Redis ListInventory list inventoryMapper.selectAllAvailable(); for (Inventory inv : list) { String key inv: inv.getDeviceId() : inv.getSlotId(); redisTemplate.opsForValue().set(key, inv.getQuantity(), Duration.ofHours(12)); } } }这波预热的key过期时间设为12小时刚好覆盖一个营业日。第二天Redis还没挂的话key过期后重新加载挂了的话重启后又会预热一次。8.3 第三档全链路熔断极端情况Redis和数据库同时不稳定这时继续处理下单请求没有意义直接开启熔断在新的一段时间内比如3分钟所有下单请求直接返回系统繁忙请稍后再试同时告警给值班人员。熔断的实现用到了Sentinel是阿里开源的一个流量治理组件。我们资产里正好有Sentinel的依赖我就在下单接口上加了SentinelResource注解设置了异常比例阈值超过50%就熔断熔断时长3分钟。这块后续可以单独写一篇深度解析Sentinel在高并发治理中的实战细节这里先不展开。降级方案的优先级一定要设计好先本地降级再远程降级最后熔断。顺序反了会导致雪崩。9. 实测数据与效果对比压测数据背后的思考方案上线跑了三周我盯了一轮完整的数据这里分享几个关键指标。下单接口的P99延迟基本稳定在200ms以内比优化前的3秒降低了一个数量级。数据库的活跃连接数从之前的持续打满降到平时只有个位数高峰期也就三四十数据库CPU使用率平均下来降了一半多。库存准确率方面三周内对账跑下来Redis和DB的偏差全部在正常范围内没有出现超卖和负库存。触发过两次告警都是因为某个货道在设备出货失败后人工补货时没走系统流程导致DB库存和实际库存对不上。这种非技术原因导致的偏差系统只能发现不能规避。还有一点值得记录的是异步消费的平均延迟从订单创建到数据库库存扣减完成平均耗时约800ms。这个延迟对用户无感因为用户看到的是下单成功而实际取货还要十来秒足够异步任务完成了。跑了一段时间后我自己的体会是异步库存方案的重点不在于把Redis用得多溜而在于状态机设计得够不够严谨。库存从预扣到确认扣减到回补每一步的触发条件和幂等保障都要想清楚否则表面上看库存没问题遇到边缘场景就会出大事。另外售货机因为涉及实体设备和离线场景和纯电商还有两个额外的坑要提醒设备离线时也要支持下单。如果一台设备离线了用户还能扫码下单后台Redis有库存但设备收不到指令出不了货。我的处理是设备离线状态下商品标记为补货中前端禁售。这个是业务决策技术方案上需要实时感知设备在线状态并同步到商品查询接口。人工补货后的库存校准。设备补货员每次补完货系统如果只是简单地把库存替换成补货数高峰期可能会出现补货瞬间的偏差。我建议补货操作走一个补货单流程补货完成后生成一条库存变更流水异步更新Redis和DB而不是直接改库存表。10. 后续优化方向与个人踩坑小结方案跑通了但距离完美还很远。我在梳理代码时列了几个可以继续优化的地方供参考。第一个方向是缓存粒度细化。目前库存缓存是inv:{device_id}:{slot_id}粒度比较粗。如果一台设备有多个货道卖同一个商品按货道扣减会浪费库存比如货道A卖完了但货道B还有系统却显示无货。可以改成inv:{product_id}的商品级库存然后由调度服务决定从哪个货道出货。但这个改动涉及设备出货逻辑复杂度会上去小项目可以先不做。第二个方向是引入本地内存缓存。单机Redis的QPS完全够用但网络往返毕竟有开销。如果对延迟要求更高可以在一台设备维度加一层本地缓存Caffeine预扣时先扣本地再异步同步到Redis。不过这会引入新的不一致问题工程复杂度较高对售货机项目来说收益不大。第三个方向是库存预测。根据历史的销售数据预测每个货道未来几个小时的销量提前在本地设备端预留一部分库存避免高峰期所有请求都打到后台。这个听起来很美好但售货机的销量受地域和人流影响很大预测模型的效果需要长期数据验证短时间做不出来属于中长期的优化方向。最后聊几个我在这个项目里踩过的坑算是个人的经验总结涉猎的同行应该能引发共鸣用Redis预扣时一定不要忘了一个细节预扣成功但不支付Redis不会自动回补必须有超时回补任务兜底。这个任务漏了安全库存就堵死了。数据库异步扣减的消费逻辑里UPDATE inventory SET stock stock - 1必须带stock 0条件。即使Redis已经预扣过了也要防止因为数据回补时序问题导致数据库库存变成负数。所有涉及库存的接口都必须做幂等。我用的是message_id唯一约束简单有效比各种分布式ID方案都省事。从一开始就做好监控告警。库存偏差告警、消息积压告警、Redis降级告警这三个必须设置好。售货机是无人值守的业务出了问题没人及时发现损失是倍增的。这轮改造做完项目最大的变化就是用户下单卡顿的问题解决了数据库的压力也降下来了而且出了问题不用半夜爬起来手动改库存。对像我一样在传统行业里做IoT相关系统的朋友这套方案大概率是够用的。
返回列表