Redis单线程使Lua脚本具备原子性,因其被整体当作一条命令排队执行;必须用SCRIPT LOAD+EVALSHA预热脚本,且所有判断与操作须在脚本内完成,返回统一语义结果。

Redis单线程执行让Lua脚本变成“原子超级命令”
根本不是Lua多厉害,而是Redis把整个脚本当做一个不可分割的命令来排队处理。哪怕脚本里有GET、IF、DECRBY三步,在Redis眼里就是一条命令——中间绝不会切出去响应别的客户端请求。
所以两个并发请求A和B同时跑同一段扣库存脚本,A必须完整走完“读→判→减”,B才开始;不存在“A读到10 → 切走 → B也读到10 → 都扣成5”的竞态窗口。
常见错误现象:EVAL每次传源码、没预加载脚本,导致网络+解析开销飙升,QPS上不去还容易超时;生产环境必须用EVALSHA配合SCRIPT LOAD预热。
Lua脚本里必须把判断和扣减写在一起
如果把GET放在Java里做,再根据结果决定是否发DECRBY,就退回了经典竞态:两次网络往返之间,库存可能已被别人改掉。
正确做法是所有逻辑收进一个脚本,返回值语义统一:
-
return -1表示失败(库存不足或key不存在) - 非负数表示扣减后的剩余值,Java端只做结果判别,不参与分支逻辑
典型安全脚本结构:
-- 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])集群环境下KEY必须落在同一个slot
Redis集群不支持跨slot操作,脚本里所有KEYS必须哈希到同一个slot,否则直接报错:CROSSSLOT Keys in request don't hash to the same slot。
这意味着你的key设计不能随意:
- ✅ 推荐:
item:{id}:stock+item:{id}:sold(用商品ID做hash tag) - ❌ 危险:
item:1001:stock和order:1001:seq混用,脚本一跑就崩
验证方式:用CLUSTER KEYSLOT item:1001:stock查slot号,确保所有key返回值一致。
为什么不用redis.pcall而要用redis.call
redis.pcall会捕获错误并返回status, err结构体,掩盖真实失败原因;而redis.call遇到异常直接中断脚本并抛出错误,让调用方能明确感知失败。
防超卖场景下,你必须知道“到底是因为库存不足,还是key不存在,还是网络断了”。用redis.pcall会让-1返回值含义模糊,调试困难,线上问题定位变慢。
另外注意:TIME命令在Lua里只返回秒级时间,精度不够;令牌桶类逻辑建议由客户端传入毫秒时间戳(ARGV[3]),服务端只做比较和更新。
真正容易被忽略的是:脚本返回值的业务语义必须和Java/Go层严格对齐,且所有key的生命周期、过期策略、初始值设置都要提前约定好。一个没SETNX预热的库存key,第一次GET返回nil,tonumber(nil)是0,会导致脚本误判为“库存为0”而非“key不存在”,这种细节在线上压测时才暴露。


















