Yujun's Blog
Redis+MySQL下的伪超卖问题
伪超卖是什么?
伪超卖不是指系统BUG导致卖出的商品数超过了实际库存,这就是真超卖了。
而是指因业务流程(主要是支付等待)导致库存被临时占用,使得真实想购买的用户无法下单,从而造成商机流失和用户体验下降的现象。
简单来说,就是商品库存被锁定了,但是最终并未完成交易。
Redis+MySQL下的“伪超卖”:库存扣到手软,用户却骂你“骗子”?
兄弟们,晚上好。
今天想聊一个话题,保证每个做过秒杀、抢购、电商系统的后端都遇到过,而且十有八九都踩过坑——库存超卖。
“这有啥难的?数据库加个事务,UPDATE product SET stock = stock - 1 WHERE stock > 0,不就搞定了?”
如果你这么想,那说明你还没被真正的“高并发”毒打过。在秒杀这种场景下,你敢让所有请求都直接打到MySQL身上,那你的DBA第二天就得提刀来见你。
所以,我们现在的标准架构都是:用Redis抗住绝大部分读请求,最后再异步落到MySQL。
这个架构性能是上去了,但一个新的、更隐蔽的“魔鬼”出现了。我管它叫——“伪超卖”。
“伪超卖”现场:一场对用户体验的“诈骗”
我们先来还原一下案发现场:
- 秒杀开始:iPhone 18 Pro Max,库存只有1件。
 - 流量洪峰:一瞬间,几千个请求涌进来,查询库存。它们都打到了Redis上。
 - Redis说“有货”:Redis非常快,它告诉前100个幸运儿:“嘿,兄弟,还有1件,快去下单吧!”
 - 用户狂喜:这100个用户都看到了“抢购成功,正在为您创建订单”的页面。
 - MySQL的“审判”:这100个创建订单的请求,开始往MySQL里写数据,准备扣减那最后一件库存。
 - 惨烈结局:只有第一个请求成功了。后面99个用户,在付钱的那一刻,或者刷新订单页面时,突然看到了一个刺眼的提示:“抱歉,库存不足,下单失败”。
 
看到了吗?数据库里的库存,从始至终都是正确的,没有变成负数。但是,对那99个用户来说,这是一场彻头彻尾的欺骗。这就是伪超卖,一个严重伤害用户体验和平台信誉的“恶性BUG”。
病根在哪?Redis和MySQL的信任危机
这个问题的根源,在于我们把一个原子操作,硬生生给拆成了两步,而且这两步之间还存在时间差和信任鸿沟。
我们天真的代码是这样的:
// 1. 先查Redis int stock = redis.get("product:123:stock"); if (stock > 0) { // 2. Redis里有库存,看起来很美好,开始执行业务逻辑... // 创建订单、调用风控、积分计算... (这里可能耗时几十毫秒) // 3. 最后,去扣减MySQL库存 mysql.update("UPDATE product SET stock = stock - 1 WHERE id = 123 AND stock > 0"); }
问题就出在第1步和第3步之间。在高并发下,当第一个线程执行完第1步,正在慢悠悠地执行第2步时,成百上千个线程也已经执行完了它们的第1步。大家都从Redis那里拿到了“有货”的令牌,然后一窝蜂地去冲击MySQL那道最后的窄门。
如何治本?让Redis的“令牌”变得独一无二
既然问题出在Redis的“令牌”被滥发了,那我们就得让Redis的**“检查库存”和“扣减库存”这两个动作,合并成一个不可分割的原子操作**。
怎么做?大杀器来了——Lua脚本。
Redis执行Lua脚本是原子性的,在脚本执行期间,不会有其他任何命令可以插队。这就给了我们解决问题的钥匙。
我们可以写这样一个Lua脚本:
-- check_and_decr.lua -- KEYS[1] 是库存的key,比如 "product:123:stock" local stock = tonumber(redis.call('get', KEYS[1])) -- 检查库存是否大于0 if stock > 0 then -- 如果大于0,就执行扣减操作,并返回1(代表成功) redis.call('decr', KEYS[1]) return 1 else -- 如果库存不足,直接返回0(代表失败) return 0 end
然后,在我们的Java代码里,这样去调用它:
import org.springframework.data.redis.core.script.DefaultRedisScript; // ... // 加载Lua脚本 DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("...上面那段Lua脚本的文本..."); // 最好是从文件加载 script.setResultType(Long.class); // 执行脚本 Long result = redisTemplate.execute(script, Collections.singletonList("product:123:stock")); if (result == 1) { // 抢到了!只有抢到令牌的线程,才能进来执行后续的数据库操作 // 异步创建订单,更新MySQL... System.out.println("抢购成功,正在处理订单..."); // send message to MQ to update MySQL } else { // 没抢到,直接给用户一个快速失败的提示 System.out.println("抱歉,已售罄!"); }
效果如何?
现在,当1000个请求同时到达时,它们会在Redis层面排队执行这个Lua脚本。只有第一个线程能成功地将库存从1减为0并返回1。剩下的999个线程在执行脚本时,会发现库存已经是0了,直接返回0,被当场拒绝。
这样一来,我们就把“战场”从昂贵的MySQL,前置到了廉价且高效的Redis。只有唯一的胜利者,才有资格去访问数据库。那些失败的用户,也会在第一时间得到“已售罄”的反馈,而不是被“欺骗”感情。
最后的保险丝:数据最终一致性
用了Lua脚本,是不是就万事大吉了?
还没完。我们还得考虑一个极端情况:那个唯一的“胜利者”,在成功扣减Redis库存后,准备去更新MySQL时,服务突然宕机了!
这时候,Redis里的库存是0,但MySQL里的库存还是1。数据不一致了!
所以,一套完整的工业级方案,还需要一个“保险丝”——数据补偿与对账机制。
- 可靠的消息队列:抢到Lua令牌的线程,不应该同步去写MySQL,而是应该发一条可靠的消息(比如用RocketMQ)到消息队列里。由一个专门的消费者服务来负责扣减MySQL库存。如果扣减失败,MQ的重试机制可以保证它最终被成功执行。
 - 补偿任务:如果一个订单在创建后,长时间(比如15分钟)没有完成支付,系统应该有一个定时任务,去取消这个订单,并把之前在Redis里扣掉的库存加回去。
 - 定期对账:每天凌晨业务低峰期,跑一个定时任务,把Redis里的库存和MySQL里的库存做一次全量同步,保证数据的最终一致性。
 
健壮的库存扣减系统:
- 预扣库存(原子操作):用户点击抢购按钮。请求打到后端,执行我们的Lua脚本,对Redis里的real_stock(真实库存)进行原子性预扣减。只有成功返回的请求,才能继续。
 - 创建订单 & 发送延迟消息(异步解耦)
- 预扣成功后,系统并不直接操作MySQL。
 - 而是立刻创建一个订单,状态为 PENDING_PAYMENT,并记录订单创建时间。
 - 然后,立即向消息队列(如RocketMQ)发送一条延迟消息。延迟时间,就是订单的支付有效时间,比如15分钟。这条消息的内容,就是这个订单的ID。
 
 - 处理支付成功
- 如果用户在15分钟内支付成功,系统会收到支付回调。
 - 此时,我们才去真正地扣减MySQL里的库存,并将订单状态更新为 PAID。
 - 同时,需要有一种机制去取消之前发送的那条延迟消息(如果MQ支持的话),或者在消费时进行判断。
 
 - 处理支付超时(库存回补)
- 如果15分钟过去了,用户仍未支付。我们之前发送的延迟消息,现在可以被消费者消费了。
 - 消费者拿到订单ID,去数据库里一查,发现订单状态还是 PENDING_PAYMENT。
 - 消费者执行库存回补操作:原子性地给Redis里的real_stock加1(INCR),并将订单状态更新为 CANCELLED。
 
 
写在最后
你看,一个看似简单的“库存扣减”,背后牵扯出的是从原子性、并发控制到分布式数据一致性的一系列经典问题。
最终的完美方案,往往不是单一的技术,而是一套组合拳:
Lua脚本(保证Redis操作的原子性) + 可靠消息队列(实现DB的异步解耦和可靠执行) + 定时任务(处理异常和数据兜底)
这套架构,既利用了Redis的高性能,又保证了MySQL的数据权威性,同时还通过异步和补偿机制,优雅地处理了分布式环境下的各种“不确定性”。这,才是一个高级后端工程师,在面对高并发场景时,应该有的架构思考。