1. 项目概述与核心问题拆解
1.1 这个项目要解决的问题
先说场景。你有一个商品SKU,库存只有500件,今天大促开售,第一秒就涌进来几万个扣减请求。如果扣减逻辑直接打在MySQL行锁上,结果就是数据库连接池被打满、慢查询堆积、超时率陡增,甚至直接把主库拖垮。
库存扣减本质上是一个"读-改-写"操作:读取当前库存,判断是否充足,扣减后写回。在高并发场景下,这个操作必须做到原子性,否则就会出现超卖。分布式缓存方案的核心思路,是把库存的"预扣"动作从数据库前移到Redis这类内存组件中,让热点请求在缓存层被快速消化掉,数据库只承担最终的落账和异步对账。
我做的这个项目,业务场景比单纯扣一个SKU的库存更复杂一点:它还要处理"合并扣减"。什么叫合并扣减?一个订单包含多个SKU,或者一个用户在一次操作里同时提交了多个规格的商品,这些数量在业务层可以被合并成一个批量请求,在Redis层通过一个Lua脚本一次性完成多个Key的检查与扣减,而不是逐个发请求。这样既减少网络往返次数,又能保证多个SKU的扣减在同一原子操作内完成,不会出现"A扣成功了、B失败了"这种脏状态。
1.2 直接打数据库为什么会出问题
有人可能会问,MySQL本身有事务,行锁也能保证扣减正确性,为什么还要绕一圈缓存?问题不在正确性,在吞吐量。一个常见电商商品页的峰值QPS可能是几千到几万,但MySQL单库单表的行锁扣减,稳定支撑的QPS大约只有几百到一两千,还要算上连接池、网络、日志等开销。你用数据库扛交易峰值,数据库就要按峰值配资源,成本是线性上升的。
更麻烦的是"热点"问题。比如限量发售的球鞋、秒杀单品,几乎所有流量都打在同一行数据上。MySQL的InnoDB行锁在这种场景下会产生严重的锁竞争,大量事务阻塞在锁等待上,导致RT(响应时间)飙升。用Redis做前置缓存,本质上是把"热点行"从磁盘/ Buffer Pool 挪到内存里,用单线程命令执行天然串行化的特性,把扣减变成一次O(1)操作,几万QPS的扣减也能轻松顶住。
1.3 这个方案适合谁参考
这个方案适合以下几类读者:准备做秒杀、限量抢购、高并发交易系统的后端开发;库存系统、订单系统、供应链系统相关从业者;以及正在准备大厂面试、需要把"库存扣减"讲清楚的候选人。无论你面对的是单机Redis、主从架构还是Cluster集群,这篇文章给出的核心思路和代码框架都可以直接适配。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案设计与技术选型考量
2.1 分层架构:缓存拦截+异步对账
我最终落地的架构分为三层:
第一层是接入层,也就是流量入口。所有扣减请求先到这里,做参数校验、幂等校验、用户维度限流。这个层不碰任何库存数据,只负责把请求格式化和路由。
第二层是缓存扣减层,核心组件是Redis。这一层负责处理所有实时扣减逻辑,通过Lua脚本保证多条扣减指令的原子性。库存预扣在这里完成,返回结果是成功或者失败。
第三层是异步对账层。扣减成功的请求写入一条消息(比如RocketMQ/Kafka的Topic),由消费端从消息里取出扣减明细,异步更新MySQL里的库存字段,并生成流水记录。这一步保证缓存和数据库的最终一致性。
为什么缓存和数据库之间要用异步消息而不是同步双写?因为同步双写等于把数据库的延迟又串回请求链路里,缓存就没意义了。异步对账的代价是:在极端情况下(比如Redis数据丢失、消息丢失),缓存和数据库可能出现短暂不一致。所以需要配合定时对账任务兜底,具体我会在第五部分详细讲。
2.2 为什么选Redis+Lua而不是分布式锁
方案设计阶段我做过对比。用分布式锁(比如Redisson的RLock)把"读库存-判断-扣减"这段逻辑锁起来,也能保证线程安全。但它的问题是:锁的粒度、持有时间、释放时机都需要精心设计,锁冲突时线程要自旋等待,高并发下反而把压力转移到锁组件上,吞吐量天花板明显低于纯Redis命令执行。
Redis的Lua脚本在执行期间是原子的,不需要加锁就能保证同一时间只有一个线程在执行这段扣减逻辑。真正的优势在于:它是无锁设计,所有请求串联执行,天然避免了竞争条件;而且一次RPC就能完成多个Key的读和写,网络开销极低,性能上限非常高。
我在实际压测中对比过:用分布式锁方案,单机Redis能支撑约8000 QPS的扣减请求;改用Lua脚本方案后,同样条件下可以支撑到3万+ QPS,而且RT稳定在1ms左右。这个差距就是方案选型带来的。
2.3 热点库存的进一步拆分
很多文章会提到"库存分片"或"库存分段",把200件库存分散到10个Key里,每个Key保存20件,请求随机路由到某一个Key,消掉单Key热点。我一开始也这么做了,但后来发现一个问题:分片导致部分库存段剩余、部分已扣完时,会出现"明明总库存还剩,但随机路由的Key已经扣光了"假失败现象。
我的最终方案是:默认不分片,先用Lua脚本单Key扣减;当单Key的QPS接近单实例上限时,再在代码配置里开启分片模式。分片模式下,每个Key维护一段独立库存,扣减时优先尝试一个Key,如果该Key库存不足,Lua脚本里会自动尝试下一个Key,直到找到可扣减的Key或者全部Key都失败。这个过程在同一个Lua脚本内完成,依然是原子的,业务层感知不到分片逻辑。
分片Key的名字规范是 sku_stock_{skuId}segment,通过一个配置中心管理分片数量。我不建议把分片数量设置得过大,一般4~8个就够打掉热点了,分片过多会导致尾号库存碎片问题更严重。
3. 核心实现细节与实操要点
3.1 Redis中库存的数据结构选型
库存数据在Redis里我用的是Hash,而不是String。这一点比较关键。每个SKU对应一个Hash,field有 tota
