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,而是原子性到底从哪来。
八、配套的完整性设计
库存不是只有"防超卖"一个点,真正上线要一起考虑这几件事:
- 两层防护:Redis 做前置预扣挡流量,数据库作为最终库存账本——落库以数据库为准,Redis 异常时直接走数据库扣减
- 幂等:
order:{orderNo}幂等键,防重复扣减 - 回补幂等:取消订单回补要按订单号去重,否则重复回补会超过总库存
- 对账:定时比对 Redis 剩余 +
stock_log+ DB 实际库存,差异告警而非自动改数 - 降级: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_* 四个变量后自动开启。