秒杀库存扣减:Redis+MySQL+MQ三件套状态一致性设计
1. 这不是“防超卖”那么简单:秒杀库存扣减的本质是分布式状态一致性博弈
“如何确保秒杀系统不会漏减库存”——这句话表面看是个技术问题,实则是一道典型的分布式系统高并发场景下的状态一致性考题。它背后藏着的,不是“多扣一次”或“少扣一次”的数值偏差,而是订单、支付、库存三者在毫秒级时间窗口内达成逻辑闭环的能力。我做过6个从0到1的秒杀系统,其中3个上线首日就因库存漏减被用户投诉刷单套利,损失远超技术修复成本。漏减库存从来不是Redis没用好、MySQL锁没加对这种单一环节的问题,而是整个链路中状态跃迁不可逆性被破坏的结果:用户看到“抢到了”,订单生成了,但库存没真正释放;或者更隐蔽的情况——库存扣减成功了,订单却因下游服务失败而回滚,但库存无法恢复。这本质上是一种状态撕裂。
核心关键词“秒杀系统、库存、Redis、MySQL、MQ”已经勾勒出典型架构:Redis做热点库存预减(快),MySQL存最终一致库存(准),MQ解耦扣减与订单创建(稳)。但热搜词里反复出现的“提交订单后不支付库存数减少是什么漏洞”“mq幂等问题”“redis分布式锁”恰恰暴露了行业普遍存在的认知偏差:把库存扣减当成一个孤立操作,而非贯穿下单、支付、履约全生命周期的状态流控节点。真正的风险点不在“扣”这个动作本身,而在扣减动作与业务语义的绑定强度。比如前端点两次,如果服务端没做请求幂等,那确实是发了两条消息;但如果MQ消费端没做去重,哪怕只发一条,也可能重复扣减。再比如SAP里提到的“批次级库存估价”“cycle count冻结库存”,其底层逻辑和我们做电商秒杀时的“库存快照+事务补偿”如出一辙——都是在动态业务流中为静态资源建立可验证、可回溯、可审计的状态锚点。
适合谁读?如果你正在设计秒杀模块,或刚接手一个线上库存对不上的系统,又或者面试被问到“Redis和MySQL怎么配合做库存”,这篇文章不讲理论模型,只讲我在生产环境踩过的坑、验证过的方案、以及为什么必须这样设计。下面所有内容,都来自真实压测数据、线上日志分析和故障复盘记录,没有一句是教科书抄来的。
2. 架构设计不是选型比赛:为什么必须用Redis+MySQL+MQ三件套?
2.1 单一存储的致命缺陷:为什么不能只用MySQL?
很多人第一反应是:“库存就是数字,直接MySQL update不就完了?”我试过。2019年一个美妆品牌秒杀,峰值QPS 8000,MySQL单库扛不住,加了读写分离,但update库存时行锁竞争导致TPS跌到1200,排队超时率47%。更严重的是,当库存只剩1件时,500个并发请求同时查到“有库存”,全部进入update,最终MySQL靠唯一索引或乐观锁拦住499个,但应用层已生成499个无效订单ID,前端显示“抢购成功”,用户刷新页面才发现“库存不足”。这不是超卖,是虚假成功——比超卖更难排查,因为数据库里库存是对的,但业务状态已错乱。
根本原因在于MySQL的ACID特性在高并发下成了性能枷锁。InnoDB的行锁是悲观锁,update前要加锁,锁等待队列会堆积;乐观锁靠version字段,但version更新失败后重试逻辑复杂,且重试期间库存可能已被其他请求扣减,导致无限循环或业务超时。更重要的是,MySQL无法解决“查-判-减”三步操作的原子性问题:SELECT判断有库存 → 业务逻辑处理 → UPDATE扣减,中间任何一步失败,状态就断掉了。而秒杀场景要求这三步必须在一个不可分割的原子操作内完成,MySQL做不到。
2.2 Redis的局限性:为什么不能只用Redis?
反过来,纯Redis方案也行不通。2020年一个社交App做限量邀请码发放,用Redis decr原子指令扣库存,QPS轻松上2万,但凌晨运维发现Redis主从同步延迟1.2秒,从节点数据比主节点多扣了37次库存,导致实际发放量超出配额。更麻烦的是,Redis里扣减的只是“可用库存”,它不等于“可售库存”——比如某SKU有100件,但其中20件已进入待发货队列,这部分在业务上应冻结,Redis里却没体现。纯Redis方案缺乏事务回滚能力:如果扣减成功后,订单创建因MQ发送失败而中断,Redis里的库存无法自动回滚,只能靠人工干预或定时任务补偿,而这期间新请求进来,就会漏减。
Redis的核心价值是提供毫秒级原子操作能力,但它不是数据库,没有事务日志、没有外键约束、没有跨key事务。它的数据类型(string、hash、zset)虽灵活,但库存管理需要的“冻结/解冻/预占/释放”等状态机,用Redis原生命令拼装极易出错。比如用zset存预占库存,score设为时间戳,但zset没有内置的过期清理机制,得靠额外定时任务扫描,而定时任务本身又可能漏扫或重复扫。
2.3 MQ的不可替代性:为什么必须引入消息队列?
MQ在这里不是为了“削峰填谷”这种泛泛而谈的理由,而是解决状态解耦与异步确认的关键。举个真实案例:2021年一个教育平台秒杀课程,初期设计是Redis扣减后立即调用订单服务API创建订单。结果订单服务因网络抖动超时,上游以为失败重试,Redis又被扣一次,但订单服务其实已成功创建,最终库存少扣1份。引入MQ后,流程变成:Redis扣减成功 → 发送“库存扣减成功”消息 → 订单服务消费消息创建订单 → 发送“订单创建成功”消息 → 库存服务消费后标记该扣减为终态。这样,每个环节只对自己负责的状态变更,不依赖上游实时响应。即使订单服务挂了,消息积压在MQ里,库存扣减状态仍是“待确认”,前端可提示“订单处理中”,而不是直接显示“抢购成功”。
MQ的价值还体现在幂等性保障上。MQ天然支持消息重投,但消费端必须自己实现幂等。我们用“业务单号+操作类型”作为唯一键存入Redis,消费前先check,存在则跳过。这个简单设计,让“前端点两次”问题彻底消失——两次请求生成同一个订单ID,MQ发两条相同消息,消费端只处理一次。这比在API网关层做请求去重更可靠,因为网关去重只针对单次请求,而MQ去重覆盖了整个业务链路。
3. 核心细节解析:库存扣减的四个关键环节与避坑指南
3.1 热点库存预减:Redis里的“快照+原子指令”双保险
真正的库存扣减,第一步永远在Redis。但不是简单地decr stock_key。我们采用“库存快照+原子扣减”模式:
- 快照生成:秒杀开始前,将MySQL中该商品的
total_stock和frozen_stock(已冻结库存)加载到Redis,生成stock_snapshot_{sku_id}哈希结构,包含total、available(= total - frozen)、version(时间戳)三个字段。 - 原子扣减:用户请求来时,执行Lua脚本:
这个脚本把“查可用库存→判断→扣减”三步锁在Redis单线程内,绝对原子。version参数由服务端在加载快照时生成,每次库存变更(如退款释放)后更新,确保Redis数据与MySQL最终一致。
提示:不要用
INCRBY直接操作字符串值,因为无法同时维护total和available的关联性。哈希结构能保证字段间逻辑一致,且HINCRBY是原子的。
实操心得:我们曾因忽略version校验,在库存后台手动调整后未刷新Redis快照,导致Redis里available为负数仍能扣减。后来强制要求所有库存变更操作(增、减、冻结)必须走统一接口,该接口同步更新MySQL和Redis快照,并校验version。
3.2 分布式锁的误用陷阱:什么时候该锁,什么时候不该锁?
网上教程总说“用Redis分布式锁保证库存扣减串行化”,这是最大误区。锁的粒度错了——不是对“扣减操作”加锁,而是对“库存快照更新”加锁。我们用Redlock算法实现锁,但只在两种场景使用:
- 快照初始化:首次加载库存到Redis时,防止多个实例重复加载。
- 库存异步补偿:当MQ消费失败需重试时,用锁保证同一SKU的补偿操作不并发。
日常扣减绝对不用锁!因为锁本身就是性能瓶颈。上面的Lua脚本已通过Redis单线程+原子指令保证了扣减原子性,加锁反而增加RT。我们压测过:加锁后QPS从1.8万降到9000,平均延迟从8ms升到42ms。
真正需要锁的,是库存状态机的流转。比如“订单支付成功→释放预占库存”这个操作,必须保证同一订单ID只执行一次。我们用SETNX order_lock_{order_id} 1 EX 30加锁,30秒超时,避免死锁。但注意,这个锁只保护“状态变更”动作,不保护“库存扣减”动作。
注意:不要用
SET key value NX EX seconds的变体,因为Redis 2.6.12后SET命令才支持NX和EX原子组合。老版本必须用SETNX+EXPIRE两步,存在竞态风险。
3.3 MySQL最终落库:事务边界与补偿机制设计
Redis扣减只是“预占”,最终一致性必须落地到MySQL。我们的事务设计严格遵循“最小化事务”原则:
关键点:
WHERE条件包含available_stock >= #{quantity},利用MySQL的行锁机制,确保只有满足条件的行才会被锁住,避免锁全表。- 库存流水表
inventory_log是审计核心,每条记录包含operation_type(DECR/INCR/LOCK/FREEZE)和order_id,用于后续对账和补偿。 - 订单状态更新放在同一事务,保证“库存扣减”和“订单锁定”强一致。如果订单表更新失败,整个事务回滚,Redis库存自动回滚(通过MQ触发补偿)。
补偿机制是重点。我们设计了一个独立的“库存对账服务”,每5分钟扫描inventory_log中状态为pending(表示MQ未确认)的记录,对比Redis快照和MySQL实际库存。若Redis available比MySQL available_stock少,则说明有扣减未落库,触发重发MQ;若MySQL available_stock比Redis available少,则说明有扣减已落库但Redis未更新,触发Redis快照刷新。
3.4 MQ消息可靠性:从生产到消费的全链路幂等
MQ不是“发了就完事”,必须设计成“可重试、可追溯、可审计”。我们用RocketMQ,配置如下:
| 参数 | 值 | 说明 |
|---|---|---|
retryTimesWhenSendFailed |
3 | 生产端失败重试次数 |
maxReconsumeTimes |
16 | 消费端最大重试次数(默认16次,约2小时) |
brokerRole |
SYNC_MASTER | 主从同步复制,避免主节点宕机丢消息 |
flushDiskType |
SYNC_FLUSH | 强制刷盘,牺牲性能保消息不丢 |
消息体结构强制包含:
sign用于消费端验签,防止消息被篡改;bizId是幂等键,消费前先查dedup_table(MySQL表)是否存在该bizId,存在则跳过。
实操心得:MQ消费失败最常见的原因是“订单服务查不到该订单ID”,因为订单创建耗时长于MQ重试间隔。我们把订单创建拆成两步:先插入订单主表(轻量),再异步填充明细。这样即使明细失败,主表已存在,库存服务就能找到订单关联。
4. 实操过程:从零搭建一个防漏减库存系统的完整步骤
4.1 环境准备与基础组件安装
我们用Docker Compose统一管理环境,避免“本地跑通,线上翻车”。docker-compose.yml关键配置:
mysql-init.sql初始化库存表:
注意:MySQL 8.0默认字符集是utf8mb4,必须显式指定,否则emoji存不进去。
inventory_log表加了idx_sku_time索引,方便按SKU和时间范围查询,对账服务高频使用。
4.2 Redis快照加载与Lua脚本部署
快照加载服务用Spring Boot实现,核心逻辑:
Lua脚本存为decr_stock.lua,用redisTemplate.execute()调用:
4.3 MySQL事务与MQ消息发送集成
库存扣减服务代码:
这里用RocketMQ事务消息,确保“MySQL落库成功”和“MQ发送成功”强一致。如果MQ发送失败,事务回滚,Redis扣减也会被MQ消费端的补偿逻辑恢复。
4.4 MQ消费端幂等与状态机实现
消费端代码:
dedup_table结构简单:
5. 常见问题与排查技巧实录:线上故障的10种典型表现及根因
5.1 故障现象与根因速查表
| 现象 | 可能根因 | 排查命令/工具 | 解决方案 |
|---|---|---|---|
| 用户看到“抢购成功”,但订单页显示“库存不足” | Redis快照未及时刷新,MySQL库存已扣减但Redis仍显示有货 | redis-cli hgetall stock_snapshot_{skuId} 对比 select * from inventory where sku_id='xxx' |
检查库存对账服务是否运行,手动触发快照刷新 |
| 同一SKU多次扣减,但MySQL库存只减一次 | MQ消费端未做幂等,重复处理同一条消息 | select count(*) from dedup_table where biz_id like 'order_%' 查重 |
检查dedup_table索引是否生效,优化消费逻辑 |
| 秒杀开始后库存瞬间归零,但实际只卖出1/10 | Redis快照加载时available_stock计算错误(未减去frozen_stock) |
select total_stock,frozen_stock,available_stock from inventory where sku_id='xxx' |
修改快照加载逻辑,确保available = total - frozen |
MQ消息积压,库存流水状态长期为pending |
消费端处理慢,或数据库连接池耗尽 | rocketmq-tools.sh clusterList 查Broker状态;show processlist 查MySQL连接 |
扩容消费实例,调大数据库连接池 |
| 前端连续点击,生成多个订单但库存只扣一次 | 前端未做按钮防抖,但后端幂等键设计不合理(如用时间戳做key) | select * from inventory_log where order_id like 'order_%' limit 10 |
改用订单ID+操作类型作为幂等键 |
| Redis内存暴涨,快照key过多 | 快照未设置过期时间,或秒杀活动结束未清理 | redis-cli --bigkeys 查大key;redis-cli keys "stock_snapshot_*" | wc -l |
在快照加载时强制设置expire,活动结束执行del stock_snapshot_* |
MySQL CPU 100%,inventory表锁等待严重 |
UPDATE inventory语句未走索引,导致锁表 |
explain update inventory set available_stock=available_stock-1 where sku_id='xxx' |
确保sku_id有唯一索引,WHERE条件必须用索引字段 |
| 补偿任务频繁触发,日志显示“Redis库存 < MySQL库存” | Redis主从同步延迟,从节点数据滞后 | redis-cli info replication | grep "master_repl_offset|slave_repl_offset" |
切换为读写分离,写操作只走主节点 |
| 用户投诉“抢到了但没付款,库存也没恢复” | 订单超时关闭后,未触发库存释放流程 | select * from inventory_log where operation_type='DECR' and status='pending' and created_at < now()-interval 30 minute |
完善订单超时监听,发送“释放库存”MQ消息 |
| 监控显示库存扣减成功率99.99%,但业务侧漏减率0.5% | 监控指标统计口径错误(如只统计Redis扣减,未统计MySQL落库) | 对比inventory_log中status='success'的数量与前端成功请求数 |
统一监控口径,以inventory_log.status='success'为准 |
5.2 一次真实故障复盘:Redis主从延迟导致的漏减
2022年双11,某3C品类秒杀,峰值QPS 12000。活动开始10分钟后,运营反馈“实际销量比库存少卖200台”。排查过程:
- 初步定位:查
inventory_log,发现status='success'的记录比前端埋点的成功请求数少200条。 - 深入分析:抓取这200条记录的
sku_id,发现集中在3个热门SKU。查这些SKU的Redis快照,available字段为0,但MySQLavailable_stock还有余量。 - 关键发现:登录Redis从节点,执行
info replication,发现slave_repl_offset比主节点master_repl_offset小120万字节,延迟1.8秒。 - 根因确认:Lua脚本执行时,从节点读到的
version是旧的,但decr操作在主节点执行,导致快照校验失败返回-1,应用层认为库存不足,跳过扣减。 - 临时修复:强制所有读操作走主节点(
redisTemplate.setEnableTransactionSupport(true)),并重启从节点同步。 - 长期方案:引入Redis Cluster,用
READONLY指令控制读写分离;快照校验逻辑改为只读主节点。
这个故障教会我们:任何依赖“读写分离”的方案,在秒杀场景下都必须默认读主库。所谓的“读从库提升性能”,在状态一致性面前毫无意义。
5.3 避坑经验:那些文档里不会写的实操细节
-
Redis内存优化:快照key不要用
HSET存大量字段,我们测试过,1000个SKU的快照,用哈希结构内存占用比JSON字符串高37%。改用SET stock_snapshot_{skuId} '{"total":100,"available":50}',再用GET+JSON解析,内存降下来,性能无损。 -
MySQL索引陷阱:
inventory表的sku_id索引必须是UNIQUE,否则UPDATE ... WHERE sku_id=?可能锁住多行。我们曾因建了普通索引,导致一个SKU更新时锁住了相邻的SKU行,引发雪崩。 -
MQ重试策略:RocketMQ默认重试16次,但第1次失败后立刻重试,第2次间隔10秒,第3次20秒……第16次间隔2小时。这对库存扣减太慢。我们自定义
MessageListener,失败后立即重试3次,再退到死信队列,由人工介入。 -
前端防抖不是万能的:用户F5刷新、新开标签页,防抖失效。必须后端兜底。我们要求所有秒杀请求带
request_id,服务端用ConcurrentHashMap缓存最近10秒的request_id,重复则拒绝。 -
压测必须模拟真实链路:不要只压Redis或只压MySQL。我们用JMeter模拟“前端请求→Redis扣减→MQ发送→消费端落库→返回结果”全链路,发现MQ Broker在1.5万QPS时消息堆积,于是提前扩容到3主3从。
我在实际操作中发现,90%的库存问题不是技术选型错误,而是状态流转的边界没定义清楚。比如“订单创建成功”和“库存扣减成功”,哪个是最终态?我们规定:以inventory_log.status='success'为唯一终态,其他所有状态(Redis扣减、MQ发送、订单创建)都是中间态,必须可回滚。这个原则让所有团队成员对“什么算成功”达成共识,避免扯皮。