限时秒杀场景里,瞬时流量可能达到平时的几十倍甚至上百倍。如果每次抢购请求都直接去数据库里执行库存查询和扣减,数据库很容易因为连接数耗尽或者行锁竞争而雪崩。因此,把库存量提前加载到Redis中,让用户在抢购时先对Redis里的库存做预减,是业界非常成熟的削峰方案。预减的意思并不是最终成交,而是先在内存里占住名额,后续再异步同步到数据库完成真实下单。

为什么数据库直接扣减撑不住秒杀流量
在普通的电商下单链路中,库存一般存放在MySQL这样的关系型数据库里,通过一行记录表示某个商品的剩余数量。当用户点击购买,系统先select出库存,判断大于零后再update减一。这个逻辑在少量并发时没有问题,但秒杀开启的一秒内可能有十万级请求同时打到同一行数据上。
数据库为了保证事务一致,会对这行库存记录加行级锁。大量请求排队等锁,线程池和连接池很快被占满,导致整个数据库实例响应变慢,甚至影响其他正常业务。更严重的是,如果应用层没有做好并发控制,先读后写的逻辑还会产生超卖:两个请求都读到库存为1,都判断通过,然后都减成0,结果卖出了两件商品。所以必须把高并发的读写转移到内存层,用Redis承接第一波冲击。
Redis基于内存且本身是单线程处理命令,不存在多个命令交错执行的情况,非常适合做这种计数器的原子操作。把商品库存以字符串或者散列形式放在Redis里,抢购时优先操作它,只有预减成功的人才允许继续走数据库下单,这样落到数据库的压力就只剩下少量真实订单了。
用Lua脚本保证库存预减的原子性
很多人第一反应是用DECR命令直接对库存key减一,然后判断返回值是否小于零再回滚。但这种方式在分布式环境下有漏洞:DECR之后如果程序崩溃没来得及处理负数,或者多步判断之间存在网络往返,就可能被其他线程钻空子。正确做法是将判断和扣减打包成一个原子操作,而Redis执行Lua脚本时整个脚本会被当成一条命令,中间不会被其他命令插入。
下面这段Lua脚本演示了安全的库存预减逻辑。它先获取当前库存,如果不存在或者已经不足,就返回0表示失败;否则执行减一并返回剩余库存。因为脚本在Redis服务端一次性执行完,所以不可能出现超卖。
-- 秒杀库存预减Lua脚本
-- KEYS[1]为库存key,ARGV[1]为本次扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock then
return 0
end
local decr = tonumber(ARGV[1])
if stock < decr then
return 0
end
local remain = redis.call('DECRBY', KEYS[1], decr)
return remain
在Java代码里可以通过RedisTemplate调用这个脚本。注意要先把脚本注册成DefaultRedisScript,并且用Long接收返回结果。如果返回大于等于零,说明预减成功,此时可以把用户ID和商品ID丢进消息队列,由消费者异步创建订单并扣数据库库存;如果返回0,直接告诉前端活动已结束。
这种方案的优点是逻辑简单、性能极高,单节点Redis轻松扛住几万QPS。缺点在于脚本里只能操作同一个key,如果业务需要同时扣多个库存(比如套餐含多件商品),就要用Hash Tag把相关key映射到同一槽位,或者拆成多个脚本分步执行并自己处理部分失败补偿。
预减成功后如何与数据库保持最终一致
Redis预减只是挡在前面的一道闸门,真正的库存资产仍以数据库为准。常见做法是预减成功后将订单消息发送到RabbitMQ或Kafka,后台消费者拿到消息后开启数据库事务,再次确认库存并生成订单。由于走到这一步的请求已经被Redis过滤过,量不大,数据库完全可以承受。
但这里有个隐患:如果消息队列丢消息,或者消费者处理到一半宕机,就会出现Redis减了但数据库没减,导致两边数据不一致。为此需要引入对账机制,比如每隔五分钟跑一个任务,用数据库的已售数量加上Redis剩余数量反推初始库存,发现偏差就报警并修正。另一种更稳妥的思路是使用Redis的延时双删或者binlog订阅(如Canal)来同步,不过复杂度会明显上升。
此外还要考虑库存预热和过期问题。活动开始前要把数据库库存批量写入Redis,并设一个比活动稍长的过期时间,防止Redis宕机后数据丢失造成永久超卖。如果Redis真的挂了,可以降级为直接数据库扣减并配合限流,虽然体验下降但不至于资损。整体来看,Redis预减配合Lua原子脚本和异步落库,是限时秒杀里最实用且容易落地的库存控制方案。