根本原因是Redis将Lua脚本作为不可分割的“超级命令”原子执行,确保读-判-减全程无并发干扰。集群下需保证所有key落在同一slot,且必须用redis.call而非redis.pcall以暴露错误。

Redis 单线程模型如何堵死超卖漏洞
根本原因不是 Lua 多厉害,而是 Redis 把整个脚本当做一个不可分割的“超级命令”来排队执行。哪怕脚本里调了 5 次 redis.call("GET")、3 次 redis.call("DECRBY"),在 Redis 看来,这整段逻辑就是一条命令,中间绝不会切出去处理其他客户端的请求。
这意味着:两个并发请求 A 和 B 同时想扣减库存,它们的 Lua 脚本不会“交错执行”。A 的脚本必须从头到尾跑完(读→判→减),B 才能开始;反之亦然。没有“读到 1 → 切走 → 别人也读到 1 → 都扣减 → 变 -1”的窗口期。
注意:EVAL 和 EVALSHA 都遵循这个规则,但若脚本未预加载而用 EVAL 每次传源码,会额外增加网络和解析开销,不推荐生产环境高频使用。
一个典型超卖脚本为什么必须检查 + 扣减合并在 Lua 里
下面这段脚本是防超卖的最小安全单元:
-- KEYS[1] 是库存 key,如 "item:1001:stock"
-- ARGV[1] 是要扣减的数量,如 "1"
local stock = tonumber(redis.call("GET", KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then
return -1
end
return redis.call("DECRBY", KEYS[1], ARGV[1])关键点在于:
-
redis.call("GET", KEYS[1])和redis.call("DECRBY", ...)必须在同一个脚本内完成,不能拆成 Java 里的两次网络调用 - 返回值
-1表示失败(库存不足或 key 不存在),非负数才是扣减后的剩余值 —— 这个语义必须由脚本统一定义,Java 端只做结果判别,不参与逻辑分支 - 如果把
GET放在 Java 里,再根据结果决定是否发DECRBY,就回到了竞态起点:两次网络往返之间,库存可能已被别人改掉
集群环境下 Lua 脚本的 key 分片限制
Redis 集群模式下,Lua 脚本只能操作落在同一个 slot 上的 key。如果你的库存 key 设计没对齐分片规则,比如用了 "item:1001:stock" 和 "order:1001:seq" 两个 key 并试图在脚本里一起操作,EVAL 会直接报错 CROSSSLOT Keys in request don't hash to the same slot。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
解决办法只有两个:
- 所有相关 key 共享同一哈希标签,例如都写成
"{item:1001}:stock"和"{item:1001}:order",让 Redis 强制路由到同一节点 - 放弃多 key 原子操作,把订单生成等后续动作挪到 Java 层,用幂等 + 补单机制兜底 —— 这更现实,也避免把复杂性全压给 Redis
别指望集群自动帮你合并 slot,它不会。
脚本里用 redis.call 而不是 redis.pcall 的真实影响
几乎所有防超卖脚本都用 redis.call,因为它的行为更可控:一旦某条命令出错(比如 GET 读到非数字字符串),整个脚本立刻中断并抛异常,返回值是错误对象,Java 端可捕获 org.springframework.dao.DataAccessException 类型异常。
而 redis.pcall 会吞掉错误、返回状态表,容易掩盖逻辑缺陷。例如:
- 库存 key 被误设为字符串
"sold_out",tonumber("sold_out")得nil,后续if not stock or stock < ...依然能走通,但实际已失效 - 脚本里漏写
tonumber(),直接拿字符串做数值比较,Redis 不报错但结果不可靠
所以宁可让脚本因异常失败,也不要让它静默走错分支 —— 超卖问题从来不是“慢一点”,而是“错一点”。

















