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有 total(总库存)、locked(已锁定/预扣数量)、sold(已售数量),示例结构如下:
redis复制HSET sku_stock_1001 total 500 locked 0 sold 0
用Hash的好处是:可以在一个Key里保存多个相关字段,Lua脚本里用 HGET、HSET、HINCRBY 一次性操作全部字段,不用维护多个Key的一致性。而且后续如果要扩展冻结库存、待发货库存等字段,只需要增加Field,不需要改变整体结构。
那为什么不用String?如果用String,你要么用多个Key(sku_stock_total_1001、sku_stock_locked_1001),多个Key之间就无法在单个命令里完成一致性扣减,还是得靠Lua脚本拼接;要么把JSON序列化进一个String里,这样每次扣减都要先GET反序列化、再SET序列化,多一次内存拷贝和序列化开销,性能损耗肉眼可见。
3.2 核心Lua脚本:库存扣减的原子逻辑
先上扣减脚本。这段脚本是整个方案的心脏,我用的是Redis自带的EVAL命令执行:
lua复制-- KEYS[1]: skuId对应的库存Hash Key,例如 sku_stock_1001
-- ARGV[1]: 扣减数量
-- ARGV[2]: 当前请求唯一幂等ID(用于日志追踪)
local stockKey = KEYS[1]
local quantity = tonumber(ARGV[1])
local total = tonumber(redis.call('HGET', stockKey, 'total') or '0')
local locked = tonumber(redis.call('HGET', stockKey, 'locked') or '0')
local available = total - locked
if available < quantity then
return -1
end
redis.call('HINCRBY', stockKey, 'locked', quantity)
redis.call('HINCRBY', stockKey, 'sold', quantity)
return 0
这段脚本的核心逻辑是:先计算可用库存(total - locked),如果不足直接返回-1,不执行任何写操作;如果充足,用HINCRBY原子地增加locked和sold字段。
为什么要维护sold字段?因为它是后续发送异步消息、对账MySQL的依据。你可能觉得只扣total就行了,但那样无法区分"预扣中"和"已售出",对账时会比较麻烦。locked表示"这个订单已锁定库存,但数据库还没落账",等异步对账完成后,可以通过另一个Lua脚本把locked回减,同时把total同步减掉,这个动作称为"确认扣减"。
确认扣减的脚本如下:
lua复制-- KEYS[1]: skuId对应的库存Hash Key
-- ARGV[1]: 确认扣减数量
local stockKey = KEYS[1]
local quantity = tonumber(ARGV[1])
local locked = tonumber(redis.call('HGET', stockKey, 'locked') or '0')
if locked < quantity then
return -1
end
redis.call('HINCRBY', stockKey, 'locked', -quantity)
redis.call('HINCRBY', stockKey, 'total', -quantity)
return 0
这里有个容易踩坑的细节:总量total一开始设定为500,扣减过程中如果一直只加sold、不动total,那么total字段始终是500,available始终是500 - locked。确认扣减时把locked回减、total同步减少,这样库存数量始终守恒,两边数据不会出现负值。
3.3 合并扣减怎么实现
前面说的都是单SKU扣减。合并扣减的场景是:一笔订单里有多个商品,希望在一次Redis操作中把多个SKU的库存统一扣减。Lua脚本天生支持多Key操作,只需要把多个SKU的Key作为KEYS数组传入,脚本里逐个检查、逐个扣减即可:
lua复制-- KEYS[1..n]: 多个SKU对应的库存Hash Key
-- ARGV[1..n]: 每个SKU对应的扣减数量
-- ARGV[n+1]: 幂等ID
for i = 1, #KEYS do
local stockKey = KEYS[i]
local quantity = tonumber(ARGV[i])
local total = tonumber(redis.call('HGET', stockKey, 'total') or '0')
local locked = tonumber(redis.call('HGET', stockKey, 'locked') or '0')
if (total - locked) < quantity then
return -1 * i -- 返回负的下标,业务层可以知道是第几个SKU失败
end
end
-- 所有SKU都检查通过,再统一执行扣减
for i = 1, #KEYS do
redis.call('HINCRBY', KEYS[i], 'locked', ARGV[i])
redis.call('HINCRBY', KEYS[i], 'sold', ARGV[i])
end
return 0
注意这里用了两轮循环:第一轮只检查不扣减,第二轮全部通过后才执行扣减。为什么不能边检查边扣减?因为如果第一个SKU扣减了,第二个SKU库存不足,你就需要回滚第一个SKU的扣减操作。回滚在Lua脚本里当然能做到,但多了一步操作,而且回滚逻辑容易出错。先把所有Key都验证一遍,再统一扣,这样要么全部成功,要么全部失败,不会出现中间状态。
还有一个合并扣减的隐藏收益:在同一订单多商品场景下,这个脚本天然保证了"全部扣减成功"或"全部失败",业务层不需要写补偿逻辑。如果失败,直接返回错误提示用户重新下单即可。
3.4 分布式缓存一致性细节
这套方案能工作,有一个前提条件:Redis中的数据必须可靠。如果Redis发生主从切换,在切换瞬间未持久化的写入可能会丢失,库存数据就会错乱。所以我会开启Redis的AOF持久化,并且设置 appendfsync everysec,这样最多可能丢失1秒的扣减记录。配合定时对账任务,可以把丢失的扣减记录从消息队列或MySQL流水里恢复出来。
另一个细节是缓存预热。系统启动时,需要把MySQL中的库存数据加载进Redis,这一步要防止用户请求先到而缓存还没准备好。我通常采用"启动时双读"策略:扣减前先判断Redis中是否存在该SKU的Key,不存在就查MySQL并回填,但这个过程需要用分布式锁防止并发回填覆盖。更稳妥的做法是在发布流程中,先预热Redis,再切流量。
3.5 缓存与数据库的最终一致性
异步消息对账是保证最终一致性的核心。每次扣减成功后,在业务代码里发送一条消息到MQ,消息内容格式可以简化为:
json复制{
"requestId": "uuid",
"skuId": "1001",
"quantity": 2,
"orderId": "O20250101120000001",
"timestamp": 1735704000000
}
消费端收到消息后,执行MySQL更新:
sql复制UPDATE sku_stock SET sold = sold + #{quantity}, version = version + 1 WHERE sku_id = #{skuId};
INSERT INTO stock_flow (request_id, sku_id, quantity, create_time) VALUES (#{requestId}, #{skuId}, #{quantity}, NOW());
如果更新失败,消息进入重试队列,最多重试3次。超过重试次数进入死信队列,由运维/开发介入人工核对。通过流水表stock_flow的幂等键request_id去重,可以保证消息重复投递时不重复扣减数据库。
4. 实操过程与核心环节实现
4.1 环境准备与依赖版本
实际操作起来,我先说基础环境。我用的Redis版本是6.2以上,因为6.x对Lua脚本的支持比较好(包括SCRIPT LOAD、EVALSHA),而且支持Redis Cluster模式下的多Key操作(需要所有Key在同一个Slot)。如果你用的是Redis Cluster,注意Lua脚本中涉及的多个Key必须带有相同的Hash Tag,比如把Key设计成 {stock}_1001 和 {stock}_1002,这样才会路由到同一个Slot执行,否则CLUSTER会直接报错。
在Java项目里我使用的是Spring Boot 2.7 + Lettuce连接池,底层用RedisTemplate执行Lua脚本。你也可以用Jedis,区别不大,核心是execute方法传入脚本和参数。下面是一个封装好的执行逻辑:
java复制private static final DefaultRedisScript<Long> DEDUCT_SCRIPT = new DefaultRedisScript<>();
static {
DEDUCT_SCRIPT.setLocation(new ClassPathResource("lua/deductStock.lua"));
DEDUCT_SCRIPT.setResultType(Long.class);
}
public boolean deductStock(String skuId, int quantity, String requestId) {
List<String> keys = Collections.singletonList("sku_stock_" + skuId);
Long result = redisTemplate.execute(DEDUCT_SCRIPT, keys, String.valueOf(quantity), requestId);
return result != null && result == 0L;
}
注意:脚本文件路径放在resources/lua/deductStock.lua下,通过ClassPathResource加载。每次执行会先EVALSHA检查缓存,如果没有编译过会自动EVAL,不必担心网络开销。
4.2 合并扣减的调用示例
如果是合并扣减多个SKU,代码逻辑类似,需要注意RedisTemplate的execute方法中keys和args的对应关系:
java复制public boolean deductStockBatch(List<StockDeductRequest> requests, String requestId) {
List<String> keys = requests.stream()
.map(req -> "sku_stock_" + req.getSkuId())
.collect(Collectors.toList());
List<String> args = requests.stream()
.map(req -> String.valueOf(req.getQuantity()))
.collect(Collectors.toList());
args.add(requestId);
Long result = redisTemplate.execute(BATCH_DEDUCT_SCRIPT, keys, args.toArray(new String[0]));
return result != null && result == 0L;
}
这个接口在订单提交链路里被调用,传入的是订单里的SKU明细列表。如果返回失败,我可以根据返回负数拿到是哪一个下标失败了,从而给用户一个具体的提示:"商品XXX库存不足"。
4.3 参数计算与库存校验的独立环节
在真实场景里,库存扣减不能只依赖Redis里的数字,还需要做前置校验。我这里说的不是"数量>0"这种基础校验,而是"购买数量不能超过限购数量"和"同一用户不能重复抢购"这两个限制。
限购逻辑我放在扣减之前:先从Redis里读取该用户已购买的SKU数量,然后判断本次购买数量+已购数量是否超过限购数。这个检查也必须原子化,所以我把限购校验也写进了Lua脚本:
lua复制local buyLimitKey = KEYS[2] -- 用户购买记录Key,例如 user_buy_1001_12345
local limit = tonumber(ARGV[2]) -- 限购数量
local bought = tonumber(redis.call('GET', buyLimitKey) or '0')
if bought + quantity > limit then
return -2 -- 超过限购
end
redis.call('INCRBY', buyLimitKey, quantity)
redis.call('EXPIRE', buyLimitKey, 86400) -- 1天有效
我把限购校验和库存扣减放在同一个Lua脚本中,避免两个操作分离导致的超卖。这里有一个经验:不要把校验逻辑和扣减逻辑拆分到不同脚本执行,否则并发场景下可能出现"校验通过但扣减失败后用户没有购买记录"这种不一致。
4.4 Redis故障降级方案
缓存不是银弹。Redis宕机时,如果直接拒绝请求,等于系统不可用;如果直接放行到数据库,大流量会把MySQL打爆。我采用的降级策略是:Redis不可用时,开启"限流降级"模式——只允许一部分请求(例如总流量的20%)通过,并且在代码中直接切换成MySQL行扣减作为兜底。
MySQL兜底扣减用一条带条件的UPDATE实现原子扣减:
sql复制UPDATE sku_stock
SET sold = sold + #{quantity}, version = version + 1
WHERE sku_id = #{skuId}
AND total - sold >= #{quantity};
如果受影响行数为1,说明扣减成功。这种方式撑不住大流量,但能在Redis故障期间保住核心交易链路的可用性,而不是全盘崩溃。恢复Redis后,通过定时任务把MySQL的sold数据重新同步回Redis,重新预热。
4.5 压测数据与调优
我这套方案在测试环境做了压测。配置是4核8G的云主机,单实例Redis,模拟2000个并发用户同时抢购200件库存。实测结果:
| 指标 | 数值 |
|---|---|
| 总请求数 | 200000 |
| 扣减成功数 | 200 |
| 扣减失败数 | 199800 |
| 平均RT | 0.8ms |
| P99 RT | 2.1ms |
| Redis CPU峰值 | 68% |
| 超卖数量 | 0 |
从压测结果看,吞吐量惊人,扣减成功的请求数严格等于库存200,说明Lua脚本的原子性完全理想。P99值稍高是因为部分请求在等待Redis线程执行其他脚本,总体在一个可接受的范围。
需要提示的是,这个压测结果是"纯扣减接口",不含业务逻辑和消息发送。如果把消息发送放在同步链路里,RT会高不少。所以我建议:消息发送必须在Lua脚本确认成功后再执行,但使用异步线程池发送,或者在事务提交后发布事件,不要让MQ阻塞主链路。
5. 常见问题与排查技巧实录
5.1 缓存穿透:Key不存在导致回源数据库
有段时间我发现某个冷门商品第一次扣减时经常超时几百毫秒。排查发现原因是:扣减前判断Redis中不存在该SKU的Key,于是回源MySQL加载,这一步是同步的。如果同一秒内大量请求同时回源,MySQL压力瞬间增大。
解决之道是引入缓存重建的互斥锁:发现Key不存在时,先去获取一个分布式锁(比如SETNX lock:stock:1001),获取成功的线程负责查库回填,获取失败的线程则短暂自旋重试读取Redis。这样回源只发生一次。
5.2 Redis内存被大Key打爆
有些SKU的Hash里field很多(比如一个SKU对应多个仓库),导致Key的value非常大,达到几十MB。这种大Key在Redis中会阻塞其他命令的执行,尤其在AOF重写或主从同步时。我的排查方法是使用 redis-cli --bigkeys 扫描大Key,然后在业务层把大Key拆分为多个子Key,每个子Key对应一个仓库维度。
库存合并扣减时,本来要操作多个field,现在改成操作多个Key。Lua脚本支持多Key,所以改造成本不高,但要注意,如果这些Key在Cluster模式下分散在不同Slot,合并操作就执行不了。所以我给子Key加上了Hash Tag,形如 {stock_1001}_warehouse_01,保证所有子Key落在同一Slot。
5.3 数据不一致:缓存和数据库的数量对不上
跑了一段时间后,我写了个定时对账任务,每天扫描MySQL的sold、Redis的sold,发现部分SKU两边不一致,原因主要有三类:
第一类是消息重复消费导致MySQL多扣,我的解决办法依靠stock_flow表的request_id唯一索引去重,重复消费时插入失败,忽略即可。第二类是Redis在扣减成功后、消息发送前宕机,导致MySQL没扣到,这类问题只能依靠定时任务反查:如果Redis的sold大于MySQL的sold,且Redis Key已经过期或初始化后无新流量,需要人工补扣或清除Redis中多余库存。第三类是退款/取消订单场景,Redis的locked已在确认扣减时回减,但MySQL没有对应的回滚记录,这类问题要在对账SQL中加入订单状态过滤,只对比有效订单的扣减明细。
这个对账任务不需要频繁执行,一天一次就够,因为大多数问题在消息消费端就已经被拦截了。关键是任务要生成报表,标记出有差异的SKU列表,方便运营人工介入。
5.4 热点Key导致的Redis实例倾斜
即使加了分片,如果某个商品实在太过热门,单个Redis节点还是会成为瓶颈。有些团队用Redis Cluster,让不同Key分散到不同节点,但热点商品的Key只有一个主节点,流量依然集中在那一个分片上。
我的解决思路是在Redis前面再加一层本地缓存,但注意:本地缓存只能缓存"非扣减"类型的读操作,比如查询商品详情页的库存展示,它展示的是近似值,可以接受短暂不一致。对于扣减操作,绝不能走本地缓存,因为本地缓存无法保证分布式环境下的强一致性。其实最终扣减逻辑本身就是Redis单实例单线程处理的,热点Key再热,单Redis也能扛住几万QPS的纯命令操作,真正的瓶颈往往在业务代码的序列化和网络开销上,所以优先优化业务链路,而不是急着拆分Redis。
6. 踩坑思考与可扩展方向
6.1 踩过的一些坑
第一个坑是关于Lua脚本中的类型转换。Redis的Lua脚本里,HGET返回的是字符串,如果你直接和数值比较,Lua会做隐式转换,但转换规则和Java不一样。我遇到过一个问题:库存字段为"0"时,tonumber返回0,但在比较时如果不显式转换,可能会因为字符串"0"和数值0比较时Lua的自动转换行为导致逻辑分支出错。解决方案很简单:所有从Redis取出的字段,都必须显式通过tonumber转换后再参与运算。
第二个坑是分布式锁的失效时间设置短了。在缓存回填逻辑里,我用SETNX做互斥锁时,设置过期时间5秒。但有一次MySQL慢查询导致回填耗时超过5秒,锁自动过期,又有线程进来重复回填。后来我把过期时间改为10秒,并加了一个"续期"机制。不过锁的续期会引入复杂度,更简化的做法是:回填操作本身要做幂等——如果Redis中已经有值,就直接返回,不覆盖。
第三个坑是Redis主从延迟带来的脏读。在读写分离架构下,如果你恰好读的是从库,而写命令还没同步过来,会读到旧值。我强制所有库存读写都走主库,绝不分离。库存操作的读写比例虽然读多写少,但一致性要求极高,不能因为想分担读压力而引入不一致。
6.2 扩展方向:引入本地扣减缓冲区
如果未来业务量继续增长,我还有两个扩展方向。
第一个是引入线程级本地扣减缓冲区。在Redis之前再加一层JVM内的ConcurrentHashMap,记录每个SKU的预扣减余量,请求先在本机内存里扣减,达到一定阈值后再批量同步到Redis。这样可以把Redis的写压力进一步降低,但实现复杂度会上升,还要考虑JVM宕机后本地缓冲库存怎么回收。
第二个是引入TCC事务方案。目前这套方案是"缓存预扣+异步确认"的最终一致性模型,对极端一致性场景(比如库存预占后需要实时联动资金账户)不够强。如果业务方要求"预占后必须实时保证数据库也扣减",可以改成TCC模式:Try阶段在Redis里扣减,Confirm阶段落数据库,Cancel阶段回滚Redis和数据库。虽然事务框架(比如Seata)会把简单链路变复杂,但对账和补偿逻辑会更清晰。
6.3 给新接手库存系统的人三个建议
最后说一下给新接手库存系统的人三个实战建议。
第一,不要一次性在Redis里放全量库存数据,上线时先灰度,只放参与大促的SKU。否则缓存预热时间太长,而且很多滞销商品的数据占内存。
第二,任何库存相关的Redis Key都要设置合理的内存淘汰策略,我通常使用allkeys-lru,防止冷数据堆积。注意,如果使用 volatile-lru,没设过期时间的库存Key可能永远不会被淘汰,不推荐。
第三,扣减脚本里一定要打印日志,至少记录请求ID、SKU ID、扣减数量、执行耗时。上线排查问题时,这些日志就是你的第一手线索,没有日志的库存系统等于蒙眼开车。我会在业务代码中通过MDC框架把requestId贯穿整条链路,日志级别使用INFO,正常情况下不影响性能。
这套方案从设计到上线,最核心的收获其实就一句话:把"高并发下的一致性问题"提前到缓存层用原子操作解决,数据库只做最终确认,两者各司其职,整体系统的吞吐上限自然就上来了。如果你们也在做类似的库存、优惠券、配额扣减系统,这套思路完全可以直接借鉴过去,重点是把Lua脚本和异步对账这两块做扎实,剩下的就是根据业务形态做适配了。
