高并发

Redis 为什么能挡住超卖:根因不是并发写

超卖的根因不是「两个人同时写」,而是「有人拿着过期的读数做判断」。理解这一点,才能明白为什么单条命令原子解决不了问题,以及原子操作为什么比分布式锁更适合库存场景。

2026-02-16 约 12 分钟 4883 字 Redis 并发编程 库存 高并发

一、先复现一次超卖

库存只剩 1 件,两个请求同时进来:

请求A: 读库存 → 1
请求B: 读库存 → 1        ← 缝隙:A 还没扣,B 也读到了 1
请求A: 判断 1≥1 → 够 → 扣 → 0
请求B: 判断 1≥1 → 够 → 扣 → -1     ← 超卖

最终库存变成 -1,两个请求都下单成功。

这段代码写出来大概是这样,看不出任何毛病:

// ❌ 看起来没问题,实际上会超卖
long stock = redis.get("stock:" + skuId);
if (stock >= 1) {
    redis.decr("stock:" + skuId);
    // 下单成功
}

二、根因不是「同时写」

大多数人对超卖的第一反应是:「两个线程同时写,所以冲突了」。

这个理解是错的。

写操作本来就是串行、原子的——Redis 单线程模型下,DECRBY 一条条排队执行,不可能"同时写"。真正的问题出在判断依据上:

超卖的根因不是「两个人同时写」,而是「有人拿着过期的读数做判断」。

回到上面的流程:B 先扣、库存变 0;A 拿着它之前读到、现在已经过期的那个 1,判断结论依然是「够」,于是从 0 又扣了一次,变成 -1。

所以修的方向不是「让写变串行」,而是:

让「读 + 判断 + 扣」不可分割,这样判断依据不可能过期。

三、单条命令原子 ≠ 多步操作原子

这里有一个特别容易混淆的点,值得单独说清。

很多人会说:「Redis 是单线程的,所以不会超卖。」

这个说法是错的。

原子的范围 结果
单条命令(GET / DECRBY) 只有这一条命令本身 ❌ 两条命令之间仍有缝 → 超卖
一段脚本(读 + 判断 + 扣) 整段逻辑 ✅ 判断依据不可能过期 → 不超卖

单线程只保证「执行这条命令时没人插进来」,它管不了你在两条命令之间干了什么。

缝不在 Redis 里,缝在你的两次调用之间。

用售票窗口理解

100 张票,只有一个售票窗口(= Redis 单线程)。

差的流程:让顾客自己看还剩几张票、自己判断,再让售票员撕一张。

→ 两个顾客都看到「还剩 1 张」、都判断够、都让撕票 → 撕出 2 张,票数变 -1

好的流程:顾客直接说「我要 1 张」,售票员低头看票数 + 判断 + 撕票一口气做完。

→ 第 101 个人听到的就是「没票了」

后者就是原子操作:把「看、判断、扣」打包成一次不可打断的动作。

四、谁才是真正解决超卖的那一层

把四层职责拆开看,会清晰很多:

层 角色 负责什么
Redis 服务端 提供能力 「执行命令 / 脚本时不可被打断」——原子性的来源
一段脚本 解决方案的形状 把「读 + 判断 + 扣」打包成一个单元
Redisson 客户端封装 把这段脚本包成 tryAcquire() 一个方法
业务代码 使用方 调用它,并决定失败怎么处理

结论很反直觉但很重要:

Redisson 是搬运工,不是发明家。解决超卖的是「原子操作」,不是 Redisson。

证据:用了 Redisson 照样会超卖

// ❌ 虽然用了 Redisson,但仍然是「先读再写」
long cur = stock.get();
if (cur >= 1) {
    stock.set(cur - 1);      // 中间有缝 → 照样超卖
}
// ✅ CAS:读和写绑在一次原子判断里
long cur;
do {
    cur = stock.get();
    if (cur < qty) throw new BizException("库存不足");
} while (!stock.compareAndSet(cur, cur - qty));

同一个类,两种用法,一个超卖一个不超卖。

反过来说:不用 Redis 也能解决

方案 靠什么原子 要不要 Redis
Redisson 原子操作 Redis 执行脚本不可打断 要
自己写 Lua 脚本 同上,但脚本自己维护 要
数据库条件更新 数据库 WHERE + 行锁 不用
分布式锁 + 读/写 锁保证串行 要
MQ 串行化扣减 单消费者天然无并发 不用

原子性可以来自很多地方,Redis 只是其中一种。

五、一个可运行实验

用 Python 模拟 Redis 的单线程执行模型,把「两条命令」和「一段脚本」放在同样的并发下对比。

实验 1:两条命令(GET 然后 DECRBY)—— 库存 1 件
   请求A: GET  -> 1
   请求B: GET  -> 1          ← 两个人都读到了 1
   请求B: 判断 1>=1 够 -> DECRBY -> 0
   请求A: 判断 1>=1 够 -> DECRBY -> -1   ← A 的判断依据已过期
   最终库存 = -1,两个请求都下单成功 —— 超卖

实验 2:一段脚本(读+判断+扣 一次做完)—— 库存 1 件
   请求A: 脚本执行 -> 成功,剩余 0
   请求B: 脚本执行 -> 库存不足,售罄
   最终库存 = 0 —— 没有超卖

实验 3:100 个请求抢 10 件库存
   两条命令版:下单成功 19 个,剩余 -9   → 超卖 9 件
   脚本版    :下单成功 10 个,剩余  0   → 正好卖完

把实验 1 里的间隔调大,超卖更容易复现;调成 0 就几乎不出现。

缝越大,超卖越容易发生。

这条规律也解释了为什么超卖问题在测试环境很难发现、一到大促就爆发——平时并发低,那个"缝"根本撞不上。

六、为什么不用分布式锁

既然要保证「读 + 判断 + 扣」的原子性,那加把分布式锁不就完了?

先澄清一个前提:Redisson 是一个工具箱,不是一把锁。它是 Redis 的 Java 客户端库,提供一整套分布式组件:

分类 组件
原子数值 RAtomicLong、RAtomicDouble
并发工具 RSemaphore(信号量)、RCountDownLatch、RRateLimiter
锁 RLock(可重入锁)、RReadWriteLock、RMultiLock
数据结构 RMap、RSet、RList、RBucket
其他 RBloomFilter、RDelayedQueue

所以「为什么不用 Redisson 的分布式锁」这个问题的准确说法是:既然用了 Redisson,为什么用它的信号量而不是它的 RLock?

RLock(分布式锁) RSemaphore(信号量)
语义 「同一时刻只允许一个进入临界区」 「一次裁决,够就给、不够就拒」
并发行为 抢不到的排队等 抢不到的立即拿到失败
1000 并发抢 100 件 900 个阻塞等待,而且要等前面走完才知道没货 瞬间全部出结果,900 个立刻收到售罄
额外要处理 锁超时、看门狗续期、释放失败、死锁 无(不存在「持有」状态)

锁是把并发变成串行(排队等结果);原子操作是并发出结果(立即裁决)。

库存场景要的是后者——高吞吐,加上售罄时的即时反馈。

七、Redisson 的两种写法

方式一:RSemaphore 信号量(语义最贴合)

库存的本质就是「有限数量的许可」。

RSemaphore sem = redisson.getSemaphore("stock:" + skuId);

sem.trySetPermits(1000);                 // 初始化总库存(已初始化则返回 false)

if (!sem.tryAcquire(qty)) {              // 原子抢许可,抢不到即售罄
    throw new BizException("库存不足");
}

sem.release(qty);                        // 取消订单 / 超时释放时归还
sem.addPermits(500);                     // 补货
int remain = sem.availablePermits();     // 查剩余

方式二:RAtomicLong + CAS

RAtomicLong stock = redisson.getAtomicLong("stock:" + skuId);
long current;
do {
    current = stock.get();
    if (current < qty) {
        throw new BizException("库存不足");
    }
} while (!stock.compareAndSet(current, current - qty));
RSemaphore RAtomicLong CAS
语义 库存就是有限许可,最贴切 通用计数器
并发失败时 直接返回 false,不重试 自旋重试,竞争激烈时可能反复失败
剩余量 availablePermits() 需另查一次 返回值就是余量,方便展示「仅剩 N 件」

关键问题:Redisson 底层怎么保证原子?

Redisson 是把「读取-判断-写入」这段逻辑用 Lua 脚本发到 Redis 服务端执行的。Redis 单线程执行脚本期间不会处理其他命令,所以这组操作整体是原子的。我用的是它的封装,没有自己写脚本。

这句话一定要能说出来。 选 Redisson 不等于绕开原子性问题,只是把「写脚本」换成了「调封装」。真正要理解的不是会不会写 Lua,而是原子性到底从哪来。

八、配套的完整性设计

库存不是只有"防超卖"一个点,真正上线要一起考虑这几件事:

  1. 两层防护:Redis 做前置预扣挡流量,数据库作为最终库存账本——落库以数据库为准,Redis 异常时直接走数据库扣减
  2. 幂等:order:{orderNo} 幂等键,防重复扣减
  3. 回补幂等:取消订单回补要按订单号去重,否则重复回补会超过总库存
  4. 对账:定时比对 Redis 剩余 + stock_log + DB 实际库存,差异告警而非自动改数
  5. 降级:Redis 不可用时直接走数据库库存扣减,性能下降但不超卖

第 1 条里的数据库兜底,写法通常是条件更新:

UPDATE stock SET available = available - 1
WHERE sku_id = 1001 AND available >= 1;
-- 影响 0 行 → 库存不足 → 直接返回售罄

这里有个细节值得注意:条件更新的失败语义是「资源不足」,不是「冲突」,所以不该重试。这和 version 字段版的乐观锁有本质区别(后者失败才需要重读重试),库存场景几乎一律用条件更新版——热点 SKU 上 version 冲突率极高,重试风暴反而会把数据库压垮。

九、把这条链路讲清楚

如果要用几句话概括整套思路:

「超卖的根因不是并发写冲突,而是有人拿着过期的读数做判断——两个请求都读到库存 1、都判断够,结果扣成 -1。而单条命令原子并不能解决这个问题,因为问题出在两条命令之间的缝隙里。

所以做法是把『读库存、判断够不够、扣减』打包成一个不可打断的执行单元交给 Redis 执行。Redis 单线程执行脚本期间不会处理其他命令,这三步之间就没有窗口能被插进来。用 Redisson 的话,tryAcquire 背后就是这么一段脚本,不需要自己写。

至于为什么用原子操作而不是分布式锁:锁的语义是同一时刻只允许一个进入临界区,用它做库存所有请求会排队;原子操作的语义是一次裁决,抢不到的立即拿到失败结果。库存场景要的是高吞吐,加上售罄时的即时反馈。」

十、小结

  • 超卖根因:拿着过期的读数做判断,不是并发写冲突
  • 单条命令原子 ≠ 多步操作原子,缝在两次调用之间
  • 解法是把读、判断、扣打包成原子单元,Redis 执行脚本不可打断
  • Redisson 是搬运工,原子性来自 Redis 本身
  • 库存场景选原子操作而非分布式锁:要的是裁决,不是排队
  • 完整方案 = 原子预扣 + 数据库最终账本 + 幂等 + 回补去重 + 对账 + 降级

把「根因是过期读数」这句话讲出来,这题就已经赢了一半——大多数人还在解释"两个线程同时写会冲突"。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。