前一篇文章,《酒类商城,防止超卖的尝试系列(第一次》
介绍了超卖的产生原因和一些基础的解决方案,主要还是围绕将“查--扣” 两个动作进行原子化操作,分别在代码层面加锁和数据库层面用乐观锁的概念进行控制,其弊端也显而易见,大并发环境下,性能很容易见顶,数据库的压力极大,用户体验极差。
先说 JVM 锁方案:
synchronized 虽然简单,但在实际生产环境中很少被用于库存扣减——锁内操作一旦变慢,后续线程就会排队阻塞,请求积压最终导致线程池耗尽。即便代码本身没有问题,将业务逻辑和数据库操作锁在一起,也天生存在性能隐患。
再说乐观锁方案:
WHERE stock >= buyCount 看似高效,但高并发下大量线程同时更新同一行记录,数据库行锁竞争激烈,连接池被占满只是时间问题。
能不能将“查--扣” 动作前置,放在内存里执行呢?毕竟将大量请求直接给到数据库本身就显得不太专业。
Redis 基于内存,读写速度极快。更重要的是,Redis 采用单线程模型处理命令,单个命令天然具备原子性,无需额外加锁。
将整体下单扣减流程调整为:
用户A下单,调用后台接口,先去缓存里查询此商品的剩余库存,库存满足扣减条件,则在redis缓存里先扣减库存,修改剩余库存数量信息,同步落库,将结果返回,如果剩余库存不够,则直接返回错误结果。
有人说,这看起来好像 “查--扣” 两个动作中间还是有时间间隙,能保证原子性吗?能保证另一个线程B不会“见缝插针”?
答案是:确实有间隙,所以不能分开执行
可以通过Lua脚本或将“查--扣”两个合并为一个单元直接甩给redis执行来进行控制
local stock = redis.call('get', KEYS[1])
if stock and tonumber(stock) >= tonumber(ARGV[1]) then
redis.call('decrby', KEYS[1], ARGV[1])
return 1
else
return 0
endLong remain = redisTemplate.opsForValue().decrement(key, buyCount);
if (remain < 0) {
// 库存不足,需要回滚
redisTemplate.opsForValue().increment(key, buyCount);
return "库存不足";
}我查资料大概汇总了两种方式的优缺点,整理如下:
对比维度 | Lua脚本 | DECR + INCR 回滚 |
|---|---|---|
原子性保证 | 整个脚本在 Redis 服务端一次性执行,不会被任何其他命令打断 | DECR 和 INCR 是两个独立命令,中间存在时间间隙 |
网络开销 | 极小。只需一次网络 I/O(发送脚本 + 参数,Redis 内部处理) | 较大。需要至少 2 次网络 I/O(发 DECR,再发 INCR 回滚),冲突时可能更多 |
回滚可靠性 | 无需回滚。要么成功(库存减 1),要么失败(库存不变),不存在中间状态 | 回滚依赖客户端代码执行。若网络超时或服务重启,INCR 未执行,库存将永久为负,只能人工修复 |
并发性能 | 高。一次执行,无重试机制,Redis 单线程处理,排队执行 | 较低。高并发下库存为 0 时,大量请求读到负数,疯狂执行 INCR 回滚,造成 CPU 浪费和网络拥堵 |
业务扩展性 | 强。可以轻松在脚本中加入限购逻辑、防刷逻辑、热 key 检测等复杂判断 | 弱。只能做简单的"减 -> 判断 -> 加",无法处理复杂业务规则 |
DECR + 回滚方案存在三个致命伤:
因此,在生产环境中,Lua 脚本是 Redis 原子性操作的唯一正解,它并不复杂,却能带来质的提升。
假设采用了decr+incr 回滚方案,最终需要其他途径来将负库存进行回滚,比如可以用job或者在数据库层面做负库存拦截。
Redis 作为缓存,数据存在易失性(宕机丢数据)和不可靠性(主从切换可能丢写),不能作为最终的库存凭证。数据库才是真正的数据源(Source of Truth),Redis 中的库存本质上只是数据库库存的一份预热副本。
Redis 预扣减成功后,接下来只需要在数据库里直接扣减对应商品的库存即可——不需要再查询、判断,一次 UPDATE 解决问题:
UPDATE product SET stock = stock - #{buyCount}
WHERE id = #{productId} AND stock >= #{buyCount};这条 SQL 能同时应对三种异常情况:
这是在数据库层面做的最后一道防线,确保无论如何,DB 库存都不会为负。
这里有几条建议的兜底方案:
就不具体展开了
本文章到此,各位应该发现这么几个问题:
虽然我们在 DB 落库时加了乐观锁兜底,Redis 扣减失败时也有回滚机制,但极端情况下(网络超时、服务重启、Redis 主从切换)仍可能出现数据不一致:
这些问题目前的方案只能靠 Job 补偿,无法 100% 实时保证一致。
我们成功地把压力从数据库转移到了 Redis,数据库的压力确实大大减轻。但 Redis 本身也有极限:
虽然 DB 不再承担"查询+判断" 的压力,但同步落库意味着每一次下单都要等待 DB 写入完成才能返回结果。DB 的写入 TPS 天然有限(几千级别),在高并发下仍然是整个链路的短板。
下一篇,我们将继续演进:
引入 RocketMQ / RabbitMQ,将"落库"操作异步化。
用户下单的响应时间将只取决于 Redis 的操作速度(毫秒级),而 DB 的写入压力被 MQ 削峰填谷,系统整体的吞吐量将再上一个台阶。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。