适合谁看:正在处理高并发库存扣减的后端开发者,如果你只关心业务逻辑可以跳过代码部分直接看思路。
前置知识:熟悉 Redis 分布式锁、消息队列基本概念,能看懂 PHP 伪代码。
incident:大促当天,系统开始“卡死”
2024 年黑五期间,一个日淘代购站点在 1000 并发左右时,订单处理开始出现明显延迟。监控显示:
- 下单接口平均响应时间从 120ms 飙升到 3.2s
- 库存扣减接口超时率高达 15%
- 用户端看到”库存不足”但实际还有货,超卖率约 3‰
当时团队以为是数据库扛不住,紧急扩容了从库,但问题只缓解了10分钟。真正的瓶颈藏得很深。
debug:逐层排查,发现两个“隐形杀手”
1. N+1 查询:100 个商品产生 301 次查询
先看数据库。慢查询日志里大量重复的 SQL:
SELECT*FROM`products`WHERE`id`=?;-- 执行了100次SELECT*FROM`inventory`WHERE`product_id`=?;-- 又执行了100次SELECT*FROM`prices`WHERE`product_id`=?;-- 再100次原来采购模块在生成订单时,对每个商品都单独查询了库存和价格表。100 个商品就是 301 次查询。这是典型的 N+1 问题,但之前因为数据量小没暴露。
2. 自研 Redis 锁:性能抖动 + 锁失效
再看库存扣减逻辑。自研了一套 Redis 分布式锁,基于SETNX+ 过期时间:
$lockKey='stock_lock_'.$productId;$locked=Redis::setnx($lockKey,1);if($locked){Redis::expire($lockKey,3);// 3秒自动释放// 扣减库存$stock=Redis::decr('stock_'.$productId);if($stock<0){// 回滚Redis::incr('stock_'.$productId);}Redis::del($lockKey);}这个方案有两个致命问题:
- 锁过期导致数据不一致:当扣减操作超过 3 秒(比如网络抖动或 GC 停顿),锁自动释放,其他请求进入后读到旧库存,导致超卖。
- 性能抖动:高并发下
SETNX争抢锁本身就有开销,而且del操作在锁被其他线程持有时会误删,引发连锁反应。
压测显示:1000 并发下,锁平均等待时间从 1ms 飙升到 50ms,且约 0.5% 的请求会因锁误删而出现库存负数。
root_cause:选型失衡,性能和一致性双双失守
两个问题叠加,本质是性能与一致性之间的平衡被打破。N+1 查询是设计阶段的偷懒,而自研锁则是过度相信”简单方案能扛住高并发”。技术选型需要在性能和可维护性之间找到平衡点,而不是极端追求简单或极端追求复杂。这套系统上线前从未做过性能基准测试,导致隐患一直潜伏到黑五流量高峰才暴露。
当时面临的选择:
| 方案 | 一致性 | 性能 | 复杂度 |
|||||
| 自研 Redis 锁 | 弱 | 中 | 低 |
| Redlock | 强 | 低 | 高 |
| Lua 脚本 | 强 | 高 | 中 |
| 消息队列异步化 | 最终一致 | 极高 | 中 |
自研锁在低并发下表现尚可,但一旦突破阈值,性能抖动和锁失效风险同时爆发。一个方案只能适应特定场景,超出就崩溃。
fix:Lua 脚本 + 消息队列,把库存扣减变成异步事件
1. 用 Lua 脚本实现原子库存扣减
Redis 2.6+ 支持 Lua 脚本,可以保证多条命令的原子性,且不依赖锁:
-- stock_decr.lualocalkey=KEYS[1]localdecrBy=tonumber(ARGV[1])localstock=redis.call('GET',key)ifnotstockthenreturn-1-- key不存在endstock=tonumber(stock)ifstock<decrBythenreturn-2-- 库存不足endredis.call('DECRBY',key,decrBy)returnstock-decrByPHP调用:
$script=file_get_contents('stock_decr.lua');$result=Redis::eval($script,1,'stock_'.$productId,$quantity);if($result==-2){// 库存不足,进入等待队列或提示用户}这个方案消除了锁的争抢和过期问题,单次操作耗时从 50ms 降到 1ms 以内。
2. 引入消息队列,异步处理订单
库存扣减成功后,不立即生成订单,而是将订单数据推送到 RocketMQ,由消费者异步处理。这样:
- 下单接口只做库存校验 + 消息推送,响应时间降到 20ms
- 消费者批量处理订单,顺便解决 N+1 查询(用
WHERE id IN (...)一次查完) - 如果库存扣减成功但后续处理失败,通过消息重试保证最终一致
改造后性能基准测试数据(1000 并发):
| 指标 | 改造前 | 改造后 |
||||
| 平均响应时间 | 3.2s | 45ms |
| 超时率 | 15% | 0% |
| 超卖率 | 3‰ | 0‰ |
| 数据库 QPS | 1200 | 180 |
这个方案后来被固化到 Taocarts 的采购模块中——这是 Taocarts 中采购模块的简化实现,实际生产环境还要加上失败重试和消息队列缓冲。Taocarts 的库存扣减组件内置了 Lua 脚本,并提供 RocketMQ 的默认配置模板,方便开发者快速接入。
效果:一次性能复盘推动的性能基准测试体系
这次事故后,建立了一套性能基准测试流程:每次大促前,用 JMeter 模拟 1.5 倍预期并发,持续压测 30 分钟,观察响应时间、错误率、CPU/内存/网络 IO。如果某个指标超过阈值,自动触发告警并回滚。这套性能基准测试体系后来被固化到发布流程中,所有核心接口在上线前必须通过基准测试。
后续两次大促的线上事故从平均 3 次降为 0 次。更重要的是,团队学会了”先做性能基准测试,再上线”的工程纪律。
记忆点:自研 Redis 锁在高并发下不是”简单可靠”,而是”简单脆弱”——性能抖动和锁失效风险是隐蔽的,只有压测才能暴露。而 Lua 脚本 + 消息队列的异步化方案,用可预期的性能换来了稳定性。