这是一个非常经典的**"看似可行,实则巨坑"**的方案。
因为为了防止库存回补,所以你需要保证库存不能为负数, 所以,你在代码层面的逻辑是:
redis.decr(key)if (返回值 < 0) { redis.incr(key); return "失败"; }
结论是:千万不要这样写。
虽然逻辑上看起来"借了再还"最后是平的,但在生产环境(特别是高并发或不稳定网络环境)下,这会引发数据永久不一致 和性能雪崩。
主要有以下 3 个致命缺陷:
1. 致命缺陷:网络中断或服务崩溃导致"只减不加"
这是最大的风险点。因为 DECR 和 INCR 是两次独立的网络请求,它们之间没有原子性保护。
场景推演:
- 当前库存为 0。
- 线程 A 发起
DECR,Redis 执行成功,库存变为 -1。 - 线程 A 收到返回值 -1,准备发起
INCR进行补偿回滚。 - 就在这毫秒之间,发生了意外:
- 情况一: 线程 A 所在的服务器宕机/重启了(比如被 Kubernetes OOM Kill 了)。
- 情况二: 网络抖动,线程 A 发出的
INCR请求包丢了,或者 Redis 的响应包丢了导致超时异常。
- 结果:
INCR永远没有被执行。 - 后果: Redis 里的库存永久停留在 -1 。
- 接着有人退款(真正的回滚),库存变成了 0。
- 本来应该恢复成 1 个库存给别人抢,现在因为那个永久的 -1,这 1 个库存被"吞"了。
- 这就是典型的"库存泄露"。
2. 性能缺陷:Redis 写压力翻倍(写放大)
Redis 的读性能极高,但写性能相对昂贵(涉及到 AOF 刷盘、主从同步)。
场景推演: 假设库存 0,这时候来了 10,000 个并发请求。
-
使用 Lua 脚本:
- 10,000 个请求 -> Redis 执行 Lua -> 判断
stock <= 0-> 直接返回 0。 - Redis 实际发生的写操作:0 次(因为没有实际扣减)。
- Redis 只是做了一次内存读取和逻辑判断,速度飞快,不产生 AOF 日志。
- 10,000 个请求 -> Redis 执行 Lua -> 判断
-
使用 DECR + INCR 补偿方案:
- 10,000 个请求 -> 10,000 次
DECR(库存狂掉到 -10000)。 - 10,000 个请求发现小于 0 -> 10,000 次
INCR(库存慢慢加回 0)。 - Redis 实际发生的写操作:20,000 次!
- 这 2 万次操作全部要写 AOF 日志,全部要同步给从节点(Slave)。
- 后果: 在没货的时候,反而把 Redis 的磁盘 IO 和网络带宽打满了,可能直接把 Redis 搞挂。
- 10,000 个请求 -> 10,000 次
3. 竞态与监控干扰
- 监控报警失效: 正常运维中,我们通常会设置"库存 < 0 报警"。如果你用这种方案,库存会频繁在负数跳动,报警系统会一直响,导致你无法区分是"真的出 Bug 了"还是"正常的补偿逻辑"。
- 并发视觉干扰: 在高并发瞬间,你通过 Redis 客户端查看库存,可能会看到
-500这种数字,很难排查问题。
总结
Lua 脚本的本质不仅仅是逻辑判断,更是为了"原子性"和"减少无效写操作"。
| 特性 | Lua 脚本方案 | DECR + INCR 补偿方案 |
|---|---|---|
| 原子性 | 完全原子(要么全做,要么不做) | 无原子性(两个独立步骤,中间可能断开) |
| 故障后果 | 即使服务崩了,库存依然准确 | 服务崩了 = 库存永久变负 |
| Redis 写压力 | 库存不足时,0 写操作 | 库存不足时,2倍 写操作(最致命) |
| 推荐指数 | ⭐⭐⭐⭐⭐ | ☠️ (绝对禁止) |
所以,宁愿多写几行 Lua 脚本代码,也不要为了省事用 DECR 后再 INCR,风险成本太高了。