必须用Lua脚本而非客户端分步判断,因GET+DECR在并发下必然超卖;Lua在Redis端原子执行“读-判-改”,避免中间插队导致库存为负或资格校验失效。

为什么必须用 Lua 而不是客户端分步判断
因为秒杀场景下,GET 库存再 DECR 的两步操作在并发时必然出现超卖——中间可能有其他请求插队。Lua 脚本在 Redis 服务端原子执行,整个“读-判-改”过程不会被中断。
常见错误现象:(integer) -1 出现在库存字段里,或日志里反复看到“资格已用完”但用户实际没抢到——本质是业务层校验和 Redis 操作没对齐。
- 不要在客户端做
if inventory > 0 then DECR:网络延迟 + 多实例部署会让这个判断彻底失效 - 脚本里别用
redis.call("GET", ...)再手动转数字:直接用tonumber(ARGV[1])更安全,避免字符串比较陷阱 - 如果用
EVALSHA预加载脚本,记得先SCRIPT LOAD,否则返回NULL导致逻辑跳过
一个能同时校验用户资格和库存的 Lua 脚本怎么写
核心思路:把用户资格(比如是否在白名单、是否已抢过)和库存扣减放在同一个脚本里,用 redis.call 统一查、统一改,返回值明确区分成功/失败原因。
示例脚本(精简版):
if redis.call("SISMEMBER", "whitelist", KEYS[1]) == 0 then
return {0, "not_in_whitelist"}
end
if redis.call("SISMEMBER", "seckilled", KEYS[1]) == 1 then
return {0, "already_seckilled"}
end
local stock = tonumber(redis.call("GET", KEYS[2]))
if stock <= 0 then
return {0, "out_of_stock"}
end
redis.call("DECR", KEYS[2])
redis.call("SADD", "seckilled", KEYS[1])
return {1, stock - 1}说明:KEYS[1] 是用户 ID,KEYS[2] 是商品库存 key;返回数组第一个元素是结果码,第二个是附带信息。
- 用
SISMEMBER查白名单比EXISTS+ 字符串匹配更高效,也避免误匹配 - 资格和库存检查顺序不能颠倒:先确认人有资格,再动库存,否则可能卡住有效用户
- 别在脚本里用
redis.log:生产环境默认关闭日志,且影响性能
Java 客户端调用时容易漏掉的关键点
Spring Data Redis 的 execute() 方法传参稍不注意就会错位,导致脚本收到空 KEYS 或乱序 ARGV。
典型错误写法:redisTemplate.execute(script, Collections.emptyList(), userId, stockKey) —— 这里 userId 和 stockKey 被当成了 ARGV,而脚本里用的是 KEYS[1],结果取不到值。
- 正确传参:第一个参数是
script,第二个是keys(List<String>),第三个及以后才是args(可变参数) - 所以应写成:
redisTemplate.execute(script, Arrays.asList(userId, stockKey)) - 如果脚本需要额外参数(比如限购数量),才用
redisTemplate.execute(script, keys, arg1, arg2) - 别忽略返回值类型:脚本返回数组,Java 接收要用
List<Object>,强转Long或String前先instanceof判断
库存回滚和幂等性怎么补
Lua 脚本本身不支持事务回滚,一旦 DECR 成功就不可逆。所以“资格校验失败但库存已扣”这种事,只能靠外围机制兜底。
真实场景中,下单失败(比如支付超时)必须触发补偿:用另一个 Lua 脚本把库存加回去,并确保只加一次。
- 补偿脚本要检查该用户是否真的没生成订单:
EXISTS order:uid:sku123,而不是只看“是否调用过秒杀接口” - 加库存前用
INCRBY并捕获返回值,如果返回值 ≤ 0,说明可能已被其他补偿覆盖,跳过 - 所有涉及库存变更的操作,必须记录操作 trace ID 到 Redis Hash(如
HSET seckill_log:sku123 trace_id status),方便排查重复执行
最麻烦的其实是缓存与 DB 库存不一致——Lua 只管 Redis,DB 更新失败时,下次秒杀开始前必须用定时任务对账,而不是指望脚本解决一切。

















