首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >酒类商城,防止超卖的尝试系列(第二次)

酒类商城,防止超卖的尝试系列(第二次)

原创
作者头像
持码待晴
发布2026-07-13 14:03:00
发布2026-07-13 14:03:00
720
举报

前一篇文章,《酒类商城,防止超卖的尝试系列(第一次》

介绍了超卖的产生原因和一些基础的解决方案,主要还是围绕将“查--扣” 两个动作进行原子化操作,分别在代码层面加锁和数据库层面用乐观锁的概念进行控制,其弊端也显而易见,大并发环境下,性能很容易见顶,数据库的压力极大,用户体验极差。

先说 JVM 锁方案:

synchronized 虽然简单,但在实际生产环境中很少被用于库存扣减——锁内操作一旦变慢,后续线程就会排队阻塞,请求积压最终导致线程池耗尽。即便代码本身没有问题,将业务逻辑和数据库操作锁在一起,也天生存在性能隐患。

再说乐观锁方案:

WHERE stock >= buyCount 看似高效,但高并发下大量线程同时更新同一行记录,数据库行锁竞争激烈,连接池被占满只是时间问题。

能不能将“查--扣” 动作前置,放在内存里执行呢?毕竟将大量请求直接给到数据库本身就显得不太专业。

一:Redis预扣减(前提是已经在缓存里预热了产品库存信息)

Redis 基于内存,读写速度极快。更重要的是,Redis 采用单线程模型处理命令,单个命令天然具备原子性,无需额外加锁。

将整体下单扣减流程调整为:

用户A下单,调用后台接口,先去缓存里查询此商品的剩余库存,库存满足扣减条件,则在redis缓存里先扣减库存,修改剩余库存数量信息,同步落库,将结果返回,如果剩余库存不够,则直接返回错误结果。

有人说,这看起来好像 “查--扣” 两个动作中间还是有时间间隙,能保证原子性吗?能保证另一个线程B不会“见缝插针”?

答案是:确实有间隙,所以不能分开执行

可以通过Lua脚本或将“查--扣”两个合并为一个单元直接甩给redis执行来进行控制

1:通过Lua脚本(一次请求完成判断+扣减)

代码语言:txt
复制
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
end

2:无需查询,操作redis直接扣减,通过返回值判断是否扣减成功

代码语言:txt
复制
Long 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 + 回滚方案存在三个致命伤:

  • 非原子性:回滚操作有失败风险,可能导致库存永久为负。
  • 惊群效应:库存为 0 时,大量请求会疯狂执行"扣减--回滚",造成资源浪费。
  • 业务局限:无法支持限购、风控等复杂逻辑。

因此,在生产环境中,Lua 脚本是 Redis 原子性操作的唯一正解,它并不复杂,却能带来质的提升。

假设采用了decr+incr 回滚方案,最终需要其他途径来将负库存进行回滚,比如可以用job或者在数据库层面做负库存拦截。

三:同步落库

Redis 作为缓存,数据存在易失性(宕机丢数据)和不可靠性(主从切换可能丢写),不能作为最终的库存凭证。数据库才是真正的数据源(Source of Truth),Redis 中的库存本质上只是数据库库存的一份预热副本。

Redis 预扣减成功后,接下来只需要在数据库里直接扣减对应商品的库存即可——不需要再查询、判断,一次 UPDATE 解决问题:

代码语言:txt
复制
UPDATE product SET stock = stock - #{buyCount} 
WHERE id = #{productId} AND stock >= #{buyCount};

这条 SQL 能同时应对三种异常情况:

  • 初始化不一致:Redis 加载的库存和 DB 实际库存有出入(比如 Redis 重启后从 DB 加载时,DB 库存已被外部系统修改)。
  • 运行时数据漂移:Redis 扣减成功了,但 DB 落库失败了(网络超时/连接池耗尽),导致两者数据不一致。
  • 并发落库覆盖:多个线程同时执行这条 SQL,stock >= buyCount 条件能保证只有真正有库存的请求才能扣减成功。

这是在数据库层面做的最后一道防线,确保无论如何,DB 库存都不会为负。

四:落库失败怎么办

这里有几条建议的兜底方案:

  • 立即回滚 Redis:调用 INCRBY 把刚才扣掉的库存加回来
  • 记录失败日志:如果回滚也失败了(极端情况),记录一条"待补偿日志",由后台定时任务Job扫描重试
  • 返回用户"下单失败":告知用户本次操作未成功,请重试

就不具体展开了

五:总结与预告

本文章到此,各位应该发现这么几个问题:

1:数据一致性依然脆弱

虽然我们在 DB 落库时加了乐观锁兜底,Redis 扣减失败时也有回滚机制,但极端情况下(网络超时、服务重启、Redis 主从切换)仍可能出现数据不一致:

  • Redis 扣了,DB 没扣(回滚失败了)
  • DB 扣了,Redis 没扣(回滚成功了但记录丢失)

这些问题目前的方案只能靠 Job 补偿,无法 100% 实时保证一致。

2:性能瓶颈依然存在,只是转移了

我们成功地把压力从数据库转移到了 Redis,数据库的压力确实大大减轻。但 Redis 本身也有极限:

  • 单点 Redis 能抗 10 万 QPS,但秒杀峰值可能到百万级
  • 热 key 问题:一个爆款商品的库存 key 被疯狂访问
  • 网络带宽:大量请求涌入 Redis,网卡可能先被打满

3:同步落库依然是性能短板

虽然 DB 不再承担"查询+判断" 的压力,但同步落库意味着每一次下单都要等待 DB 写入完成才能返回结果。DB 的写入 TPS 天然有限(几千级别),在高并发下仍然是整个链路的短板。

下一篇,我们将继续演进:

引入 RocketMQ / RabbitMQ,将"落库"操作异步化。

用户下单的响应时间将只取决于 Redis 的操作速度(毫秒级),而 DB 的写入压力被 MQ 削峰填谷,系统整体的吞吐量将再上一个台阶。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一:Redis预扣减(前提是已经在缓存里预热了产品库存信息)
    • 1:通过Lua脚本(一次请求完成判断+扣减)
    • 2:无需查询,操作redis直接扣减,通过返回值判断是否扣减成功
  • 二:两种方案的优缺点比较
  • 三:同步落库
  • 四:落库失败怎么办
  • 五:总结与预告
    • 1:数据一致性依然脆弱
    • 2:性能瓶颈依然存在,只是转移了
    • 3:同步落库依然是性能短板
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档