这个标题看着不算新,但真正在无人售货机项目里把它落地过的团队,应该都能感受到这句话背后的分量。我去年接手一套区域无人售货机云平台的时候,高峰期一台热门机柜只要一搞促销,下单接口的P99响应时间能从200毫秒直接飙到4秒以上,数据库CPU长时间跑在90%以上,运维大半夜被报警电话叫醒是常态。表面看是数据库扛不住,往深了说,是"库存扣减"这个动作的同步事务边界设计从一开始就埋了雷。
先说结论:无人售货机的库存模型,本质上和传统电商SKU海量、库存分散的模型不一样,它是单点资源竞争极其激烈的模型——一台机柜就那几个货道,可乐就30瓶,几百个人同时冲着一瓶可乐下单,同步扣库存的天花板非常低。要解决这个场景下的卡顿问题,不能靠单纯加机器,必须把"扣库存"这个动作从下单链路里彻底异步化,同时用限流、对账、补偿机制把异步带来的新问题兜住。
下面我把这套方案的完整设计思路、落地代码、压测数据和踩坑记录都摊开讲,给同样在做自动售货机、自助货柜甚至其他物联网设备交易系统的团队一个可以直接套用的参考。
1. 库存卡顿的根因:同步扣减在微服务架构下的瓶颈
1.1 大家习以为常的"下单即扣库存"错在哪里
绝大多数团队的第一版都是这么干的:用户点击购买 -> 后端创建订单 -> 在同一事务里先查库存够不够然后再做扣减 -> 库存表行锁 -> 提交事务返回下单成功。
这套逻辑在日单量几千、峰值QPS个位数的系统里完全没问题。但无人售货机的场景有一个特性:流量具有极强的时间聚集性和空间聚集性。比如办公楼里一台机器,中午12点到12点10分这十分钟内可能涌入全楼70%的下单量;再赶上运营放一批优惠券,一秒钟可能就是几十个请求同时打在一台设备上。
这时你会看到两个核心问题:
- 数据库行锁竞争:扣减库存的SQL本质是对同一行记录做更新,InnoDB的行锁机制让所有对同一瓶可乐库存的修改请求排队执行。并发越高,锁等待时间越长,事务堆积越多,最终连接池被占满,整个系统雪崩。
- 事务边界过长:一条完整下单链路往往包含创建订单、查询设备状态、校验价格、扣减库存、更新商品快照等多个步骤。如果全部放在一个本地事务里,一个步骤变慢,整个事务持有的锁和数据库连接就迟迟不释放,拖垮的不是一个接口,而是所有依赖这个库的业务。
我统计过上线前的数据:单台设备在促销高峰期的下单QPS才40左右,但库存表所在数据库的活跃连接数能冲到350以上。问题根本不在流量大,而在每个请求都在死磕同一行库存记录。
1.2 Redis预扣之后,真正的死穴在哪
后来我们团队也做过一层优化:引入Redis,把库存预热到缓存里,先原子扣减Redis库存,扣成功再走数据库落账。这一步确实把下单接口的RT从秒级降到了几十毫秒。
但凡是这么干过并且真正经历过高峰期的同学,一定遇到过下面这几个怪现象:
- Redis扣成功了,但数据库落账失败(比如数据库连接抖动、死锁回滚),导致Redis扣了库存、订单却没生成,用户莫名其妙买不到东西;
- 扣减Redis成功后返回用户"支付成功",但后台库存记录对不上,需要定时任务反复校准;
- 数据库的库存扣减SQL在高峰时依然出现大量锁等待——因为Redis只是挡在前面,数据库落账依然是同步执行的。
核心问题在于:数据库扣减这个慢操作并没有被移出用户请求链路,只是被往后挪了一步而已。连接池被占满、锁等待、CPU飙升这些问题,一个都没解决。
1.3 为什么"加了缓存还是会卡"
我见过不少团队在这个阶段反复调优效果都不好,然后开始怀疑是不是数据库配置不行、是不是连接池参数不对。最后发现,问题是架构层面的,不是性能调优能解决的。
看一下同步链路下的资源占用公式:
关键点是"用户等待"这四个字。用户的浏览器/小程序端迟迟收不到响应,前端就会一直挂着请求,反向代理的超时、网关的线程池、应用的Servlet线程全都被这个长事务占着。一个高频事务卡住,整个系统像被按了暂停键。
而且无人售货机还有一个特殊性:机柜本身的MCU(主控板)同步状态也是周期性的,设备端自己会把出货结果上报,所以用户侧对"下单响应时间"的容忍度其实是比较高的。换句话说,用户真正关心的是"我付了钱,可乐能不能掉下来",而不是"服务器在50毫秒还是500毫秒内返回订单号"。
这就为异步化改造提供了天然的可行性基础。
2. 全链路异步化改造:从下单到履约有四步
2.1 整条链路重设计:闸门、异步引擎、救济队
我们在第二版彻底重构了下单流程,核心思路是把"响应"和"动作"分开:用户下单后,接口立刻返回"订单受理成功",真正扣库存的动作交给异步引擎慢慢做。
整套系统拆成四个角色:
- 入口闸门(Gateway):只做参数校验、幂等判断、流量控制,然后把下单消息扔进消息队列,立刻返回受理结果;
- 异步扣减引擎(Stock Consumer):从消息队列消费下单事件,真正执行Redis预扣和数据库落账,这是整个改造最核心的模块;
- 执行状态表(Order Stock Record):记录每一次扣减请求的处理状态,作为幂等和对账的依据;
- 对账救济队(Reconciler):周期性扫描执行状态表,找出"已受理但未扣减"或"已扣减但未支付"的订单,分别做补扣和回补库存。
用户从点击"立即购买"到看到"下单成功"的响应,中间不再等待任何数据库写操作。我做过一个比喻:以前是"客人点完菜,要等厨房炒完才给号",现在是"先给号让客人坐着等,后厨自己排单炒菜"。
2.2 为什么把扣减改成"预占+确认/冲正"
纯异步的扣减方案,最怕出现的问题是"超卖"。用户下单时看到库存还有,结果异步扣减时发现库存已经没了,这时候订单处于什么状态?
所以异步扣减不能是"扭过头再去减一次库存"这么简单,而要设计成三个阶段的预占确认模式:
- 下单受理时:Redis中先做一次预占,比如库存是10,预占数量是2,那么可用库存变为8,预占库存变为2;
- 支付成功时:异步引擎把预占中的库存正式扣掉,同时落数据库流水,这叫"确认扣减";
- 下单超时或用户取消时:把预占的库存释放回可用库存,这叫"冲正"。
这个设计的核心价值在于:预占动作只操作Redis,毫秒级完成,在受理阶段就已经挡住了超卖的可能;数据库扣减则延后到用户支付成功之后。用户没支付,永远都不会动数据库的库存行。
2.3 各模块职责拆解
落实到具体模块上,我是这样划分的:
| 模块 | 职责边界 | 关键技术点 |
|---|---|---|
| 下单接口 | 校验参数、生成订单号、推送MQ消息、返回受理成功 | 接口幂等、快速失败 |
| Redis库存服务 | 预占/释放/确认扣减,维护实时库存 | Lua脚本保证原子性 |
| MQ消息 | 承载"下单事件""支付成功事件""超时关闭事件" | 顺序消息、重试机制、死信队列 |
| 异步扣减Worker | 接收支付成功事件,执行数据库扣减流水落账 | 幂等控制、失败重试、告警 |
| 对账任务 | 定期扫描状态表,处理卡单、补单、库存回补 | 分布式锁、分片扫描 |
| 设备下发服务 | 把扣减成功的订单下发至机柜MCU执行出货 | 设备在线状态、失败重推 |
这套链路里,用户侧能感知到的变化是:下单接口变快了非常多,而且高峰期哪怕瞬时流量翻倍,接口RT也稳如泰山。
2.4 改造前后的效果对比
我放一组我们上线前后同一促销场景的实测数据,机器配置不变、数据库不变、网络环境不变:
| 指标 | 同步扣减方案 | 异步更新方案 |
|---|---|---|
| 下单接口P99响应时间 | 3400ms | 95ms |
| 数据库活跃连接数峰值 | 380 | 75 |
| 库存表锁等待超时次数/小时 | 27 | 0 |
| 单机柜并发下单支持能力 | 约20 QPS | 100 QPS以上(受限于MQ处理) |
| 高峰期下单失败率 | 6.4% | 0.12% |
这里要特别说明:异步化之后,接口变快不是核心收益,真正的收益是整个系统的稳定性上限被大幅拉高了。数据库连接池不再被长时间事务占着,其他业务模块(订单查询、会员、退款)跟着一起变稳了。
3. 核心代码与数据结构的落地写法
3.1 关键表结构设计
异步扣减方案需要两张核心表:库存表和扣减流水表。第一版的库存表就一个sku_id、一个库存数量字段,改造后我用的是"可用库存 + 预占库存 + 版本号"的三字段模型:
CREATE TABLE `t_sku_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `sku_id` varchar(64) NOT NULL COMMENT '商品SKU编码,机柜+货道维度', `available_stock` int(11) NOT NULL DEFAULT '0' COMMENT '可用库存', `occupied_stock` int(11) NOT NULL DEFAULT '0' COMMENT '预占库存', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku` (`sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';扣减流水表记录每一次扣减请求的执行状态,这个表是整个方案能够落地的基石:
CREATE TABLE `t_stock_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '业务订单号', `sku_id` varchar(64) NOT NULL COMMENT 'SKU编码', `quantity` int(11) NOT NULL COMMENT '扣减数量', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待处理 1已预占 2已确认扣减 3已冲正 4扣减失败', `retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '重试次数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_sku` (`order_no`,`sku_id`), KEY `idx_status` (`status`,`update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存扣减流水表';这里的唯一键uk_order_sku是整个幂等设计的地基——同一个订单对同一个SKU只允许有一条扣减流水,不管MQ消息重复投递多少遍,数据库层面天然拦截。
3.2 Redis预占扣减的核心逻辑
真正的下单接口不再直接操作数据库,而是走一段Lua脚本把预占动作做成原子操作:
private static final String PREOCCUPY_LUA = "local available = redis.call('hget', KEYS[1], 'available') " + "local occupied = redis.call('hget', KEYS[1], 'occupied') " + "if not available or tonumber(available) < tonumber(ARGV[1]) then " + " return -1 " + "end " + "redis.call('hset', KEYS[1], 'available', tonumber(available) - tonumber(ARGV[1])) " + "redis.call('hset', KEYS[1], 'occupied', tonumber(occupied) + tonumber(ARGV[1])) " + "return 1 ";为什么用Hash结构而不是简单的String加减?因为一个SKU同时需要维护可用和预占两个数值,用Hash可以在同一个key下原子操作多个字段。Lua脚本保证这两个字段的修改不可分割,比"先GET再SET"的方式安全得多。
Redis库存的预热逻辑很简单:每天早上从数据库加载所有机柜的实时库存到Redis;期间数据库库存变更后,通过异步消息同步更新Redis。这里有一个注意点:Redis只作为预占和展示库存的加速层,真正的最终数据仍然以数据库为准。
预占成功后,订单状态转入"待支付",同时往MQ发送一条延迟消息用于超时未支付的自动冲正。
3.3 异步扣减Worker的幂等实现
用户支付成功后,支付回调服务往MQ发送一条"支付成功"消息。扣减Worker收到后,执行以下逻辑:
@Transactional(rollbackFor = Exception.class) public void confirmDeduct(String orderNo, String skuId, int quantity) { // 1. 幂等判断:尝试插入流水记录,如果主键冲突说明已经处理过 StockRecord record = stockRecordMapper.selectByOrderSku(orderNo, skuId); if (record != null && (record.getStatus() == StockRecord.STATUS_CONFIRMED)) { log.info("重复扣减消息,跳过: {}", orderNo); return; } // 2. 乐观锁版本号校验 + 条件更新,防止并发把库存扣成负数 int rows = stockMapper.conditionDeduct(skuId, quantity, System.currentTimeMillis()); if (rows == 0) { // 3. 扣减失败,更新流水状态为失败,触发告警 stockRecordMapper.updateStatus(orderNo, skuId, StockRecord.STATUS_FAILED); throw new BizException("库存扣减失败"); } // 4. 更新流水状态为已确认扣减 stockRecordMapper.updateStatus(orderNo, skuId, StockRecord.STATUS_CONFIRMED); }对应的SQL是这样:
-- 乐观锁条件扣减,确认当前可用库存依然充足 UPDATE t_sku_stock SET available_stock = available_stock - #{quantity}, version = version + 1 WHERE sku_id = #{skuId} AND available_stock >= #{quantity}这条SQL的精髓在available_stock >= #{quantity}这个条件——数据库层面的"余额不足"判断,天然防止超卖,同时UPDATE会锁行,但是此时处于MQL消费者异步执行阶段,用户根本感知不到这一点耗时的存在。
这里有一个极其重要的细节:confirmDeduct不要加@Transactional之后再调用远程服务或者MQ操作,事务里只做本地DB操作,否则会把事务时间拉长,重新陷入连接池被占满的老坑。调远程服务、发通知类消息务必放在事务提交之后,用TransactionSynchronizationManager.registerSynchronization或事务事件监听器处理。
3.4 订单超时冲正逻辑
用户下单后没支付,系统需要把预占的库存释放回去。冲正逻辑和预占是对称的:
public void releaseOccupied(String orderNo, String skuId, int quantity) { // 更新流水状态为冲正 stockRecordMapper.updateStatus(orderNo, skuId, StockRecord.STATUS_CANCELED); // Redis中对占用库存做冲正 stockService.releaseOccupied(skuId, quantity); // 注意:这里不需要立刻更新数据库库存, // 因为数据库的可用库存从未被扣减过,预占只发生在Redis层 }这个设计的好处在于:用户未支付的订单,全程不碰数据库库存行,不会产生任何锁等待。数据库只在"支付成功"这个确定性事件发生后才扣减,那一眼就能想明白数据库的压力来源被压缩到了一个很小的业务路径上。
4. 异步不等于放开闸门:流量治理在入口的兜底
4.1 异步化解决的是"处理慢",不解决"来得猛"
很多团队做完异步化改造后,觉得万事大吉了——反正数据库负载降下来了,再多并发也不怕了。这是一个非常危险的错觉。
异步化解决的是"每个请求处理时间过长导致的资源占用",它没有解决"海量请求同时打过来时,无限制的请求堆积会拖垮所有下游组件"的问题。即使每个请求只花1毫秒,如果1秒涌进来10万个请求,网关线程池、MQ写入、下游设备指令通道都会被打爆。
无人售货机最常见的场景是运营在早高峰或促销开始时统一推送消息、发优惠券,几十个微信群同时发链接,用户点击时间高度集中,流量模型是瞬时流量削峰填谷的最好案例。
4.2 Sentinel怎么用量化配
我们用的是阿里的Sentinel做流量治理。在异步化链路中,主要在三个位置埋限制:
- 入口闸门:限制下单接口的总QPS和并发线程数;
- 异步Worker消费线程池:限制它的处理速率,避免一下拉取太多消息把数据库连接池打爆;
- Redis操作:限制对库存服务访问的频率。
下单接口的Sentinel规则可以这样配置:
FlowRule rule = new FlowRule(); rule.setResource("createOrder"); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setCount(200); // 单机阈值 rule.setLimitApp("default"); rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER); rule.setMaxQueueingTimeMs(500); // 匀速排队,超时快速失败 FlowRuleManager.loadRules(Collections.singletonList(rule));这里的CONTROL_BEHAVIOR_RATE_LIMITER匀速排队模式特别适合我们这种场景:它把突发的脉冲流量整形为匀速的小流量,多余请求在队列里等待,而不是直接拒绝。用户最多多等几百毫秒,体验上不会有明显感知,但系统压力曲线会变得非常平滑。
4.3 热点参数限流针对单机柜
普通QPS限流是全局的,但无人售货机场景还有更细粒度的问题:几十台机柜里,某一台因为位置好、折扣大,流量特别集中。全局限流保护了系统,但不够精细。
Sentinel的热点参数限流可以精确到sku_id维度——针对单个热销SKU设置更严的阈值,其他冷门SKU不受影响:
ParamFlowRule hotRule = new ParamFlowRule("createOrder") .setParamIdx(1) // 第2个参数,即 skuId .setCount(50); // 单个SKU每秒钟最多处理50个预占请求 // 还可以给特定热点SKU设置更大的阈值 ParamFlowItem hotItem = new ParamFlowItem().setObject("SKU-A001").setClassType(String.class.getName()).setCount(100); hotRule.setParamFlowItemList(Collections.singletonList(hotItem));并发这一层用线程数限流做兜底,仓库里有一个通用写法:
FlowRule threadRule = new FlowRule(); threadRule.setResource("createOrder"); threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); threadRule.setCount(80); // 同时最多80个线程处理下单请求这个80的数字不是随便拍的,是根据下单接口平均耗时和Tomcat最大线程数算出来的:Tomcat核心线程数200 * 目标利用率0.4 = 80,留出了足够Buffer避免线程池队列堆积。
4.4 限流的最佳实践反思
一个让我印象很深的坑:纯异步化上线后,团队里有同学觉得系统"无敌了",把Sentinel规则关了,结果某天晚上运营做了个"限量秒杀",300个用户同时抢50瓶饮料,整个MQ消费者线程池被打挂了——不是数据库挂了,而是消费者从MQ拉取消息后,同一时间发起大量HTTP调用到设备指令服务,把设备网关的HTTP接口打崩了。
所以要理解:异步链路不是把流量消灭了,而是把流量从一个地方平移到了另一个地方。限流需要配合异步链路的每个环节重新设计,尤其是消费端的速率限制,否则只是把瓶颈从"数据库"搬到了"下游服务"。
5. 异步后的老问题:订单和库存怎么对上账
5.1 异步引入的新风险清单
异步化把高峰期的性能问题解决了,但它天然引入了几个新问题,缺一不可地考验着系统的鲁棒性:
- 消息丢失:MQ宕机或者消费时应用重启,支付成功消息没消费到,订单支付了但库存不扣、设备不出货;
- 重复消费:MQ的at-least-once投递语义下,同一个支付成功消息可能被投递多次;
- 乱序:同一个订单的"预占"和"冲正"消息如果乱序,会出现先冲正后确认的诡异状态;
- 系统间不一致:订单系统认为已支付,库存系统认为未扣减,设备一直不出货,用户投诉。
异步化项目上线后,运维的核心工作从"盯着数据库负载"变成了"盯着执行状态表的status分布"。哪一个状态的数据异常堆积,就说明哪一个环节出了问题。
5.2 不用分布式事务,用本地消息表+状态机
对于订单和库存的一致性问题,很多人的第一反应是上Seata或者TCC分布式事务。但我的实践经验是:无人售货机这个场景根本不需要分布式事务,需要的是最终一致性。
用户支付成功后,订单服务在本地事务中同时完成"更新订单状态为已支付"和"插入一条库存扣减消息"这两个操作,然后由MQL消费者异步执行扣减。这就是经典的本地消息表模式:
@Transactional(rollbackFor = Exception.class) public void handlePayCallback(PayCallbackDTO dto) { // 1. 订单状态改为已支付 orderMapper.updateStatus(dto.getOrderNo(), OrderStatus.PAID); // 2. 本地消息表插入一条待发送消息 messageMapper.insert(MessageBuilder.create() .topic("STOCK_DEDUCT") .bizKey(dto.getOrderNo()) .payload(dto.getOrderNo() + ":" + dto.getSkuId() + ":" + dto.getQuantity()) .build()); }有了本地消息表,就不存在"订单状态变了但消息丢了"的问题。定时任务扫描message_status = 0的记录重新投递,保证了消息不丢。
5.3 对账救济队的三个扫表任务
光有消息表还不够,我设计了三个定时对账任务,每5分钟跑一轮:
- 补扣任务:扫描状态为"已支付但库存未扣"的订单,重新推送扣减消息。应用场景:MQ消费者临时宕机导致消息堆积,或扣减代码抛了异常但没走到失败分支;
- 冲正任务:扫描状态为"已预占但超时未支付"的订单,释放预占库存。应用场景:用户下单后一直不支付,也不主动取消;
- 库存校准任务:将Redis中的SKU库存与数据库的库存做全量比对,发现差异以数据库为准回写Redis。应用场景:Redis重启、Lua脚本Bug、人为改数据库等导致缓存不一致。
这三个任务是系统的"安全网",它们不需要多高级的技术,一个简单的Spring@Scheduled+ 分布式锁就能实现。但它们的意义非常巨大——正是有了这个兜底机制,前面的异步方案才敢放心使用。
5.4 设备出货失败后的库存补偿
无人售货机还有一个电商没有的场景:库存已经扣了,但设备出货失败(货道卡货、电机故障)。
这种情况下,用户支付了钱但没拿到货,系统不能就这样算了。我们的做法:
- 设备上报出货失败后,消息触发退款流程,用户收到原路退款;
- 同时把成功扣减的库存回补——注意回补的是"可售库存",并且追加一条"设备故障"类型的流水记录;
- 运维人员在管理后台看到该货道故障次数达到阈值后,自动将该货道置为不可售状态。
这些逻辑同样是异步的,但一定要保证通过消息队列串起来,不能直接同步调用退款服务,否则又会把长事务带到MQL消费链路里。
6. 压测数据与避坑清单(基于真实生产观察)
6.1 压测怎么设计才有参考价值
我们上线前做了两轮压测:第一轮是自己写的脚本模拟;第二轮直接联动运营做了"线上真实促销灰度"。
第一轮压测方案供参考:
- 压测工具:JMeter分布式压测,8台施压机;
- 场景模型:300个虚拟用户同时点击下单,随机分布在50台机柜的200个SKU上,其中设置3个SKU的概率权重为70%(模拟冰可乐、热咖啡这类爆品);
- 持续时长:15分钟,观察系统在较长持续压力下的表现;
- 核心指标:P99响应时间、数据库活跃连接数、MQ消费积压数量、内存GC频率。
压测结果:
| 指标 | 压测前同步方案 | 压测后异步方案 |
|---|---|---|
| 并发下单用户数 | 300 | 300 |
| 下单接口P95 | 2350ms | 78ms |
| 库存表锁等待次数 | 421 | 3 |
| MQ积压最大数量 | 0 | 1205 |
| 消息处理延迟(最大) | 0ms | 450ms |
| 库存扣减最终一致时间 | 实时 | 平均2.8秒 |
看这份数据,MQ有积压不代表系统有问题,只要在用户可接受时间窗口内(比如30秒)消费完,体感上完全无感。真正需要监控的是"积压是否持续增长、积压是否超过用户容忍阈值"。
6.2 我踩过的几个真实的坑
坑一:多实例消费导致重复扣减
我们的扣减Worker早期部署了3个实例,用的是RocketMQ集群消费模式。正常情况下同一消息只会被一个实例消费,但消费者在业务代码执行中宕机,比如执行了扣减SQL但没有来得及更新流水状态为"已确认",消息被重新投递给另一个实例,就会发生重复扣减。
靠数据库唯一索引兜底没有救回来,因为我们的流水表唯一键是(order_no, sku_id),第二次插入失败后,事务回滚会把第一次已经提交的扣减也连带回滚吗?不会!因为第一次扣减已经提交了。这是一个隐蔽的生产事故,排查了大半天。
解决方案:在扣减前先查流水状态,只有在"非已确认"状态下才执行扣减SQL,并且把"查询并扣减"包在分布式锁里。
坑二:消费者批量拉取模式下数据库连接池被打满
RocketMQ的批量拉取默认一次可能拉取32条消息,如果32条都是数据库扣减操作,瞬间会创建32个数据库连接,连接池如果设置的是20,就开始排队等待,消费者Block住,消息大量积压,形成恶性循环。
方案:把消费线程池的最大并发数控制到连接池可用连接数的70%左右,并且开启consumeThreadMin和consumeThreadMax的合理配置。宁可处理慢一点,不能让数据库连接池打满。
坑三:Redis库存和数据库库存的"中间态"故障
我们遇到过RedisCluster主从切换导致Lua脚本执行失败,预占没有成功但订单创建成功了。此时用户看到的界面是"待支付",但Redis里可用库存没有减少。如果这个类型的订单大量存在,等交易结束后对账发现数据库库存没有少,但订单确实产生了。
后来专门加了一步:在订单创建时设置Redis预占失败视为"下单失败"直接返回库存繁忙,绝不允许创建无预占记录的订单进入支付环节。
坑四:系统时钟漂移影响冲正判断
订单超时冲正用的是下单时间 + 超时时长与当前时间比较,多台应用服务器如果NTP同步有问题,时间差超过几分钟,会导致提前冲正或延迟冲正。后来我们把时间判断全部改到数据库用NOW()执行,不在应用层用本地时间比较。
6.3 上线步骤和回滚预案
异步化改造涉及核心交易链路,不要想着一次性全量替换,建议按这个顺序灰度:
- 第一阶段:只把"库存预占"逻辑上线,数据库操作保留同步,Redis和数据库双写。跑一周,观察Redis预占和数据库扣减的数据是否一致;
- 第二阶段:开"支付成功异步扣减"开关,但保留同步扣减代码,通过配置中心动态切换,遇到问题可以秒级回到同步模式;
- 第三阶段:等两个大促周期稳定后,删除同步扣减的代码分支,保留异步为唯一路径。
回滚的难度在于:库存扣减不像普通的接口可以随意开关。同步和异步两套扣减逻辑不能同时在线跑,否则会出现双扣。所以我们用了一个全局的"交易库存模式"开关:SYNC或ASYNC,切换时刻要有10分钟的过渡期,等MQ中堆积的扣减消息消费完再做切换。
6.4 监控指标的落地建议
异步化系统的监控指标与同步系统完全不同,建议重点盯这几个:
| 监控指标 | 告警阈值 | 说明 |
|---|---|---|
| 预占-确认扣减差值 | > 50笔持续5分钟 | 有订单支付成功但扣减没跟上 |
| 预占-冲正差值 | > 100笔持续10分钟 | 大量用户下单未支付,库存可能不足 |
| MQ积压数量 | > 5000持续3分钟 | 消费能力跟不上或下游故障 |
| 消费者线程池活跃度 | > 80%持续5分钟 | 消费链路出现瓶颈 |
| Redis库存与DB库存差异 | > 10持续30分钟 | 缓存与数据库同步异常 |
这些指标的告警要用短信 + 电话双重通知,因为交易链路出问题的黄金止损时间非常短,晚发现十分钟可能就是几百个客诉。
补充一个操作小技巧:建议给对账任务的日志单独建一个文件输出,不要把对账日志混在业务主日志里,否则排查问题时要从海量日志里捞对账记录,非常痛苦。输出格式可以改成结构化的JSON,方便日志平台检索。
7. 写在最后的几点实战心得
方案上线半年多,经历过两次大促、一次设备批量故障,整体运行稳定。最后分享几条个人觉得很有价值的心得:
异步化解决的是"处理慢导致的阻塞",不是"系统不用扛并发了"。限流、降级、熔断的流量治理机制一个都不能少,它们和异步化是互补关系,不是替代关系。
所有异步操作都要有对应的幂等方案,这是异步架构的命门。日志里见过太多"同一笔订单扣了两次库存"的事故,根因都是没有在数据库层面做好唯一约束。宁可多做一条查询来保证幂等,也不要图省事省略。
给异步链路加一张执行状态表,是这个方案能落地的关键。不要迷信分布式事务,最终一致性 + 对账补救在这个场景下是最省心、最可控的方案。那张表就是你这套系统的心脏监护仪,任何时候出了问题,先查它。
对于无人售货机这种IoT+交易结合的场景,一定要理解设备侧的异步特性。机柜MCU的联网状态不稳定,设备响应消息可能延迟几秒甚至几分钟。交易系统必须做到"先确认订单,再等待设备确认出货",而出货结果则通过设备回调异步通知,这样即便设备离线,也不会拖垮交易接口的稳定性。
如果你正在为无人售货机或类似的IoT交易系统发愁,希望这篇实战拆解能帮你少走一些弯路。异步化的本质不是把问题藏起来,而是把问题挪到一个可控的时间和空间里去解决。