ARTICLE DETAIL

资讯详情

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

高并发下库存扣减之殇:从同步行锁到异步最终一致性

高并发下库存扣减之殇:从同步行锁到异步最终一致性 1. 一台售货机为什么让我半夜爬起来改库存1.1 先说说这个项目在干什么我做无人零售这一行也有几年了手头这套无人售货机控制系统对接的不光是某品牌的自营设备还有一些第三方加盟的点位。简单说用户在手机上扫码、选货、支付机器出货后台同步扣减库存并实时展示剩余数量。之前这套系统跑得还算平稳直到去年秋天几家学校的点位集中上线问题一下子就暴露出来了。课间十五分钟是真正的流量高峰。一个学校几百台设备同时被扫平台把所有点位的请求汇总到同一套订单和库存服务上峰值下单量直接翻了近二十倍。用户反馈很直接手机上下单后一直转圈明明货道里还摆着商品系统却提示网络繁忙请稍后再试。设备端也出现了出货后库存没扣减或者扣了两次的情况。那段时间线上问题工单多到不得不加班处理核心矛盾就一个高并发场景下库存扣减这个动作把整条下单链路拖死了。后来我把问题归拢了一下拆成了三个层面。第一是数据库层面的行锁竞争所有订单都去更新同一批商品的库存记录第二是服务层面的分布式事务开销订单系统、库存系统、支付回调全串在一起互相等待第三是并发控制策略过于保守为了防超卖用了大量的同步加锁和乐观锁重试反而把正常请求也堵在了外面。定位到这些之后我开始设计一套异步更新库存的方案核心思路是下单链路不直接扣数据库而是先通过本地预占锁定库存再通过消息队列异步落库。方案上线后下单接口的TP99从1.8秒降到了300毫秒以内库存准确率也稳住了。1.2 卡顿到底是什么卡出来的先澄清一个误区很多同行以为无人售货机的单机并发很低不需要做高并发设计。单看一台设备确实不吓人但平台是聚合模式所有点位的流量都汇聚到同一套服务上。以我们线上的数据为例高峰期订单服务每秒要处理接近两千笔下单请求其中大概有三分之一都落在同一个热门商品上比如饮料机里的冰可乐、零食机里的薯片。库存服务是典型的读多写少。读是用户打开App就能看到剩余数量写是用户下单后扣减数量。问题出现在写入这一侧每笔订单下单时订单服务会调用库存服务执行一次同步扣减SQL长这样UPDATE sku_stock SET stock stock - 1 WHERE sku_id ? AND stock 0;这条SQL本身没问题但同一时刻有几百个请求并发更新同一个sku_id的同一行记录时InnoDB会给这一行加行锁。所有请求只能排队执行后面的请求越积越多连接池很快被占满。再加上库存服务为了确保不超卖通常还会先查一次库存再执行更新查一次、锁一次、改一次整个链路的RT就被拉起来了。我一开始还尝试过优化SQL、加索引、增大连接池但效果都很有限。后来才意识到问题的本质不是这条SQL写得不好而是同步扣减这个模式在高并发下天然受限——它把数据库的吞吐上限直接变成了整个系统的吞吐上限。要保持用户体验不卡顿就必须把扣库存这个操作从同步链路里挪走。2. 同步扣减为什么在高并发下必卡先拆清楚根因2.1 数据库行锁同一瓶水被十个人抢用生活里的场景类比一下一个小卖部只有一个售货员同时来了十个顾客都要买同一瓶水。售货员一次只能接待一个人其他九个人只能排队等着。这就是数据库行锁最直接的写照。在MySQL InnoDB引擎里执行UPDATE语句时会自动给涉及的数据行加上排他锁直到当前事务提交或回滚才释放。我们的下单扣库存SQL就是直接命中同一个热卖商品的同一行记录于是这行记录成了整个系统的单点瓶颈。高峰期实测下来正常情况下2毫秒就能完成的扣减SQL在行锁竞争激烈时能膨胀到200到500毫秒甚至更久。这里还要注意一个细节行锁等待是有超时时间的默认是50秒。但实际业务不会等到50秒因为Tomcat或线程池的连接等待时间会更早触发。线程拿不到数据库连接就抛异常用户端就表现为下单失败。更麻烦的是如果某个事务持有锁的时间长后面排队的事务还会连锁超时导致一批订单批量失败这就是卡顿现象的数据底子。2.2 分布式锁和事务叠加接口直接被拖垮很多团队在防超卖这个问题上会再加一层分布式锁比如用Redis的SETNX或者Redisson。我在前期方案里也这么干过。流程是加锁、查库存、判断是否足够、扣减、解锁。这套流程在低并发时很稳但高并发时问题也很明显——Redis锁的竞争本身也是一份开销而且锁粒度如果设计不好会把整个商品维度的请求全部串行化。更麻烦的是订单和库存往往不在同一个服务里。订单服务扣完库存还要去通知支付系统、更新订单状态甚至还要回调设备出货。如果其中任何一个环节出现问题就需要回滚库存。这个分布式事务的过程在同步模式下代价极高要么引入强一致性的分布式事务框架要么靠业务自己写反向补偿。在我们的场景里最开始就是典型的服务间同步调用链下单请求进来订单服务调库存服务扣减库存服务反馈结果后订单服务再更新状态。这条链路里任何一个服务抖动整个下单接口都会跟着遭殃。而用户感知的下单卡顿很多时候并不全是数据库慢而是链路中某个环节超时了。现在回头看同步模式的致命伤在于它把所有环节的执行时间相加作为用户最终的等待时间。要缩短等待时间只能把非核心环节从这条链路里拆出去。库存扣减这个动作恰恰是必须在数据库执行、但又不必让用户同步等待的典型场景这就是我后来做异步更新的切入点。3. 异步更新方案的整体设计把扣库存变成“先占后销”3.1 方案选型为什么不用纯Redis扣减也不用纯DB扣减在设计异步更新方案前我调研过两种主流做法纯Redis扣减和纯DB扣减。纯Redis扣减就是把库存数量放在Redis里用DECR命令扣速度极快能承受每秒几万的QPS。但它最大的问题是可靠性一旦Redis宕机或者主从切换丢数据库存数据就可能对不上无人售货机这种实物交易场景里超卖一头就会造成客诉和赔偿。纯DB扣减就是我们前面说的同步模式数据最可靠但吞吐受限于数据库性能扛不住瞬时冲量。最终采用的是折中方案Redis做前置的预占扣减承担高并发的流量数据库做最终的落地扣减保证数据的可靠与对账。一句话概括就是先占后销——用户下单时先在Redis里预占一件库存返回成功真正的数据库扣减动作丢给消息队列异步执行不在用户请求路径上等待。这个方案的关键在于预占和实际扣减之间的一致性保障。预占成功后如果用户支付超时或者取消订单需要把预占的库存回补到Redis。如果预占成功且支付成功就确保数据库一定能扣减成功。两边不能出现Redis扣了但数据库没扣或者数据库扣了但Redis没扣的情况否则库存数据就乱了。3.2 系统的四个核心角色我在方案里把整个过程拆成了四个角色下单服务、库存预占服务、消息队列、对账任务。下单服务的职责很简单接收用户下单请求生成订单调用库存预占服务预占成功则继续走支付流程预占失败则直接提示用户库存不足。它不再直接访问数据库所以压力小了很多。库存预占服务维护所有商品的可用库存缓存结构上直接用Redis的一个Hashkey是sku_idfield是availablevalue是剩余数量。用户下单时执行两件事一是原子扣减Redis中的可用库存二是写一条库存预占流水记录。这条流水记录就是后面异步落库的凭据。消息队列负责解耦。库存预占服务扣减成功后往MQ里发一条消息消息体包含订单号、商品编号、预占数量、流水号等信息。真正消费这条消息的是库存落库服务它负责把预占结果同步到数据库。对账任务是一个兜底机制。毕竟消息队列也不能保证100%不丢消息网络抖动、服务重启、磁盘满都可能出问题。所以每天定时扫描一次把Redis里的预占流水、数据库里的库存变更记录、订单支付状态三方核对一遍发现不一致就自动生成补偿任务。这套机制上线后帮我们抓了不少隐藏问题后面我会专门讲几个典型case。4. 核心细节解剖库存流水、幂等与最终一致性4.1 库存流水表库存系统的“账本”做异步扣减最重要的一件事不是把扣减动作挪到MQ里而是要有账本。所谓账本就是库存流水表。没做流水表之前库存服务一旦出错你很难追踪到底是哪笔订单扣了库存、哪笔订单回补了库存只能靠拍脑袋排查。有了流水表之后每一笔库存变动都有据可查。流水表的核心字段包括业务流水号、订单号、商品编号、变动类型占用预占、确认扣减、回补释放、变动数量、操作前库存快照、操作后库存快照、创建时间。有人会问为什么还要记录操作前的库存快照因为对账时我们需要知道这笔操作前后的库存是否连续。比如上一条流水显示某商品库存从100变成了99下一条流水如果显示99变成了98那这条链路就是连续的。如果中间缺了一条或者顺序不对对账程序就能立刻发现。另外流水表本身也会随着数据量增长变得很大。我们一般按月分表每月一张流水表保留最近半年的数据。因为流水表是追加写为主很少需要更新所以写入性能很好即使每天几百万条流水也没有压力。4.2 幂等消费订单号做唯一键防止重复扣MQ的消息投递模型是至少一次at-least-once也就是说在极端情况下消费者可能会收到同一条消息多次。如果不做处理一条订单消息被消费两次就会扣两次库存导致超卖。解决这个问题靠幂等。我在库存落库服务里做了一个唯一约束把订单号商品编号流水类型作为唯一键。每次消费消息时先往一个消息处理记录表里插入一条记录如果插入成功了说明这个消息第一次处理继续执行扣减如果插入时发现唯一键冲突说明消息已经处理过了直接丢弃。这个过程也可以用数据库的insert ignore或者on duplicate key update来实现核心是确保同一个订单同一件商品只被扣一次。之前有同事建议我用Redis的SETNX做幂等判断简单是简单但Redis本身也可能丢数据。数据库的唯一索引虽然性能比不上Redis但可靠性更高而且因为扣减操作本身就要写库加上一个唯一键判断的开销很小完全能够接受。4.3 分布式事务怎么落地本地消息表 定时扫描说到分布式事务很多人第一反应是SEATA、TCC这些框架。但在无人售货机这种业务里我的实践经验是最简单的方案往往是最可靠的。我采用的是本地消息表定时扫描模式本质上是一种消息最终一致性方案。流程是这样的库存预占服务在扣减Redis库存的同时在一个本地消息表里插入一条待发送的消息记录记录里包含订单号、商品编号、预占数量等信息。然后把这条消息发送到MQ发送成功就标记这条记录为已发送。如果在发送过程中MQ不可用消息就留在表中等定时任务扫描到未发送的记录重新发送。这个方案和直接发MQ的区别在于消息的生产和业务操作绑定在同一个本地事务里。要么业务操作成功且消息记录写入成功要么都失败。不会出现操作成功但消息没发出去的情况。消息到了消费者那边执行流程加对账任务以及定时扫描本地消息表构成了最终一致性体系的三个保障。这套方案线上跑下来数据不一致的情况极少发生。后来我还结合热搜里提到的SAP Q库存概念把库存状态做了更细的拆解可用库存对应可用数量预占库存对应SAP里的质检库存概念只是语义从待检验变成了待确认支付。这样订单状态、库存状态、设备货道状态三者的对应关系非常清晰排查问题时也容易对齐。5. 实操落地从表结构到核心代码一步步来5.1 库存流水表DDL和状态机这里给出一份可以直接用的库存流水表DDL大家根据业务情况调整字段长度和分表策略CREATE TABLE stock_flow ( id bigint(20) NOT NULL AUTO_INCREMENT, flow_no varchar(64) NOT NULL COMMENT 业务流水号, order_no varchar(64) NOT NULL COMMENT 订单号, sku_id bigint(20) NOT NULL COMMENT 商品编号, change_type tinyint(4) NOT NULL COMMENT 变动类型: 1-预占 2-确认扣减 3-回补释放, change_qty int(11) NOT NULL COMMENT 变动数量(正数增加,负数减少), before_qty int(11) NOT NULL COMMENT 操作前可用库存快照, after_qty int(11) NOT NULL COMMENT 操作后可用库存快照, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_flow_no (flow_no), KEY idx_order_no (order_no), KEY idx_sku_id (sku_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的核心是唯一的flow_no。每笔预占操作生成一个流水号后续的确认扣减或者回补释放都要通过flow_no来关联原预占流水。这样整个生命周期都可以追踪。我在设计状态机时参考了SAP库存管理的思路库存记录的状态主要分为两类预占中Pending和已完成Confirmed。订单支付成功预占状态就变成已完成数据库库存同步扣减订单取消或支付超时预占状态关闭Redis库存回补同时记录一条回补流水。5.2 异步扣减核心流程代码级展示光说理论不够直接看核心代码。库存预占服务的核心方法如下public boolean preoccupyStock(String orderNo, Long skuId, int qty) { // 1. Redis原子扣减可用库存 Long remain redisTemplate.opsForHash().increment(stock:sku: skuId, available, -qty); if (remain 0) { // 库存不足回补 redisTemplate.opsForHash().increment(stock:sku: skuId, available, qty); return false; } // 2. 记录流水 StockFlow flow StockFlow.buildPreoccupyFlow(orderNo, skuId, qty); stockFlowMapper.insert(flow); // 3. 写本地消息表 LocalMessage msg LocalMessage.build(flow.getFlowNo(), orderNo, skuId, qty); localMessageMapper.insert(msg); // 4. 发送MQ mqProducer.sendStockOccupyMsg(msg); localMessageMapper.markSent(msg.getId()); return true; }对应地库存落库服务的消费逻辑是这样的public void consumeOccupyMsg(StockOccupyMsg msg) { // 幂等判断消息处理记录表唯一键 if (messageProcessMapper.exist(msg.getFlowNo())) { return; } try { // 执行数据库扣减 int rows stockDbMapper.safeDecrease(msg.getSkuId(), msg.getQty()); if (rows 0) { // 数据库库存不足触发告警与人工介入 alertService.sendAlert(DB stock not enough, flowNo msg.getFlowNo()); return; } // 记录处理成功 messageProcessMapper.insert(msg.getFlowNo()); } catch (DuplicateKeyException e) { // 并发重复消费忽略即可 } }注意这里的数据库扣减用的是safeDecreaseSQL就是WHERE stock 0确保不会出现数据库层的负库存。5.3 对账任务每天半夜把两边算平异步方案的最后一个闭环就是对账。我正式上线后第三天凌晨对账任务就抓到了第一例不一致某商品Redis显示可用库存剩12件但数据库只扣了11件差了一件。定位下来发现是MQ消息发送失败本地消息表状态没更新定时扫描还没跑到这个点。对账任务的逻辑可以概括为三步按商品维度统计当天所有预占流水和Redis里的预占记录总数比对。按商品维度统计当天所有确认扣减流水和数据库里的库存变更记录比对。将订单支付状态、预占流水、数据库扣减记录三方拉齐处理不一致数据。SELECT sku_id, SUM(change_qty) AS total_qty FROM stock_flow WHERE change_type 2 AND create_time ? AND create_time ? GROUP BY sku_id;拿到这个汇总数再和数据库当前库存、Redis当前可用库存比对。误差超过阈值就告警误差为0就认为这一天的数据是平的。对账任务跑在低峰期比如凌晨两点到五点避免影响正常业务。任务本身要支持幂等因为当天对账失败后补跑时不能重复扣减或者重复回补。6. 高并发流量治理Sentinel和限流降级的组合拳6.1 先限流保命比抢生意重要异步更新解决了数据库瓶颈但并没有解决服务本身的承载上限。下单请求每秒冲到两千次如果全放进来下游Redis、MQ、支付网关都可能被打爆。所以我在接入层和服务层分别加了限流。这里用的是阿里开源的Sentinel。最开始我只是把它当限流组件用给下单接口配置了一条规则resources: - name: /order/create qps: 1000 type: QPS controlBehavior: 2控制行为选的是快速失败还是匀速排队需要结合场景来配。无人售货机下单这个场景用户对等待时间比较敏感所以我用了QPS阈值搭配快速失败超过阈值的请求直接返回系统繁忙丢给用户重试。注意不要用默认的Warm Up因为我们的流量是突发的不是逐渐爬升的。除了接口级限流我还给热点商品维度单独配置了限流。比如某款饮料在高峰期被抢购同一个sku的预占请求会集中打过来这时候即使整体QPS没超过阈值同一个key的竞争也可能非常激烈。所以在Sentinel里配置了热点参数限流指定第一个参数为skuId单商品每秒钟最多处理300笔预占请求。6.2 降级和熔断Redis挂了怎么办异步方案里Redis承担着预占库存的重任一旦Redis不可用整个下单链路就会瘫痪。所以必须做好降级预案。我在库存预占服务上加了Sentinel的熔断规则当Redis访问异常率达到20%时直接熔断下游调用返回一个特殊的降级标记。下单服务收到这个标记后会走一个降级接口——直接使用数据库同步扣减库存不做预占。这个降级接口的QPS阈值会调得很低比如每秒100次避免DB被打挂。有人可能担心数据库同步扣减在高并发下不是会卡吗没错所以这里只是一个保命方案。Redis故障期间宁可让下单成功率下降也不能让整条链路全部不可用。等到Redis恢复系统自动解除熔断流量切回正常的异步预占链路。这个降级方案我反复强调一个观点降级不是给用户添堵是给系统留后路。无人售货机这种线下场景用户看不到服务端的复杂架构他只知道点按钮没反应。我们要保证的底线是不下单也能正常浏览、正常看货道库存下单失败要有清晰快速的提示而不是一直转圈。7. 线上踩坑实录常见问题与排查思路7.1 消息积压导致库存显示不足异步方案上线后我们遇到过两次消息积压。一次是MQ消费端所在宿主机磁盘满了消费者线程全部阻塞另一次是消费端代码抛了未捕获异常导致消息一直重试积压了几十万条待消费消息。消息积压的连锁反应是Redis里的库存已经预占扣减了但数据库还没扣对账任务会拉出Redis和DB不一致的告警。更直接的用户感知是设备的可用库存显示还是满的但用户实际下单时Redis预占扣减失败提示库存不足。排查这类问题时第一步不是看代码而是看监控指标MQ中的消费堆积数是多少、消费者消费速率是多少、是否有消费异常日志。如果消费速率是0基本可以判断消费者线程卡死了。解决后要对积压消息做补偿处理确认每条消息都消费成功然后触发一次对账把Redis和DB的偏差拉齐。7.2 重复消费导致库存越扣越少这是异步系统最经典的坑。MQ的at-least-once投递模型决定了消息可能被重复消费我之前在设计时用了消息处理记录表的唯一键做幂等但有一次幂等表本身的数据清掉了导致同一笔订单的扣库存消息被消费者处理了两次。两次扣减的结果就是数据库库存凭空少了一件。更隐蔽的是如果订单状态正常、流水记录也完整从表面看很难发现问题只有对账时才会发现流水汇总和库存实际值对不上。解决思路有两个层面。代码层面幂等表绝对不能随便清理即使要清也要确保消息队列里没有尚未消费完的旧消息监控层面我加了一条规则同一订单号的扣减消息在1分钟内消费超过一次就告警。这样即使幂等失效也能第一时间发现。7.3 超时关单后库存回补丢失用户下单预占库存后如果一直不支付系统会自动关单释放预占的库存。这个回补动作在方案里是通过回补释放消息异步执行的。问题出在一次线上故障关单任务批量执行时不小心把MQ的topic名字写错了回补消息全发到了一个不存在的topic里导致大量超时订单的库存没有回补到Redis。这台设备在App上显示的库存一直比实际少用户投诉商品显示有货买的时候却说库存不足。我排查时先看Redis的可用库存数量再看数据库的库存变更记录最后看订单支付状态才发现回补消息根本没发出去。这个case让我意识到回补动作不能只靠MQ异步还要有一个兜底扫描任务。后续我增加了一个定时任务专门扫描预占超过一定时间且订单已关闭但库存未回补的记录发现异常直接调回补接口。可靠性比单靠MQ高了不止一个量级。7.4 排查三板斧流水、监控、对账脚本总结下来线上排查异步更新库存问题我基本遵循三板斧。第一板斧看流水。不管用户反馈什么问题先查库存流水表把订单号输入进去看一眼这笔订单的预占、扣减、回补记录是否完整状态是否正常。流水清晰了问题就定位了一半。第二板斧看监控。重点看Redis的可用库存曲线、数据库的库存变更曲线、MQ的消费堆积数三者放到同一张图里对比。如果Redis曲线下降但DB曲线没跟上说明消息链路有问题如果DB曲线下降但Redis没变化说明对账补偿可能没跑。第三板斧跑对账脚本。自动对账任务跑完会生成一份差异报表里面包含所有不一致的商品明细。逐个排查这些商品基本能把问题收敛到特定订单或特定时间窗口。这套三板斧下来大部分问题都能在十分钟内定位到根因少数的疑难杂症再结合日志和链路追踪慢慢查。8. 最后分享一点实战感受整个异步更新库存方案从设计到稳定运行大概花了两周时间。最初我对引入消息队列这件事是有顾虑的毕竟系统里多一个中间件就多一个故障点。但实际跑下来MQ的收益远大于成本库存扣减从用户路径上挪走之后下单接口的RT和成功率都有了质的提升。有个细节我印象很深上线后第一周我每天半夜都会起来看一眼对账报表生怕哪里出了偏差导致超卖。后来连续七天报表全绿我才算真正放心。这里也想提醒大家做异步最终一致性方案对账这条兜底一定不能省它是整个方案的保险丝。如果你也在做无人售货机、自动贩卖机或者任何涉及库存实时扣减的系统遇到高峰期卡顿问题我的建议是先别急着换数据库或者加机器。把库存扣减从同步链路里拆出来用预占加异步落库的方式先试试再配合限流降级和对账补偿大概率能解决大部分性能痛点。后续还可以在这个基础上做多级缓存、热key分片、甚至把库存服务独立拆分出来扩展空间很大。
返回列表