使用lua脚本将库存校验、一人一单、扣减、下单标记四步合并为一次原子操作,避免应用层分步调用导致的超卖问题;因redis单命令线程安全但多命令不保证原子性,“读-判-写”链路必须封装进lua,且get返回字符串需用tonumber()转数值。

直接用 redis.call() 把库存校验、一人一单、扣减、下单标记这四步打包进一个 Lua 脚本里执行,就能把原本需要 4 次网络往返 + 数据库事务的流程,压成 1 次原子操作,接口响应从几百毫秒降到几毫秒。
为什么不能在应用层分步调用 Redis
常见错误是先 GET 库存,再 INCRBY 扣减——中间存在时间窗口。两个并发请求同时读到库存为 1,都判断“够”,然后都执行扣减,结果库存变成 -1。这不是 Redis 不线程安全,而是应用层逻辑拆开了原子性边界。
使用场景:优惠券秒杀、限量商品抢购、防重复提交表单
- Redis 单次命令天然线程安全,但多个命令之间不保证原子性
- 只要涉及“读-判-写”链路(比如查库存 → 判是否 >0 → 扣减),就必须进 Lua
- Spring Boot 中若用
StringRedisTemplate.opsForValue().get()+.decrement()组合,等于主动放弃原子性
脚本里必须用 tonumber() 转换字符串值
Redis 所有 GET 返回的都是字符串,Lua 里直接拿它做数值比较会出错。比如 redis.call('get', 'seckill:stock:1001') 返回 "5",而 "5" 在 Lua 中是 <code>false(字符串比较),但 tonumber("5") 才是预期的 <code>false。
容易踩的坑:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 忘记对
ARGV参数调用tonumber(),比如用户 ID 当作数字参与sismember没问题,但若后续要算差值就会失败 - 对空值没做防御:
tonumber(nil)返回nil,再参与运算会报错,应先用if not stock then return -1 end - 哈希结构取值后,
goodsInfo[1]是字符串,必须tonumber(goodsInfo[1])才能和数字比较
KEYS 和 ARGV 的传参方式决定脚本复用性
脚本里所有动态部分必须通过 KEYS 或 ARGV 注入,硬编码 key 名会让脚本无法复用。比如库存 key 是 seckill:stock:{voucherId},就得让调用方传 voucherId 进 ARGV[1],再在脚本里拼接:local stockKey = 'seckill:stock:' .. ARGV[1]。
参数差异:
-
KEYS用于 Redis 键名,必须是非空字符串,且会被 Redis 集群路由机制校验(集群模式下所有KEYS必须落在同一 slot) -
ARGV用于任意值,包括数字、用户 ID、布尔标识,不参与路由,更灵活 - Spring Boot 中调用时,
redisTemplate.eval(script, keys, args...)的keys是List<string></string>,args是可变参数列表,顺序必须和脚本里KEYS[1]、ARGV[1]严格对应
返回值设计要覆盖所有业务分支
不要只返回成功/失败布尔值。Lua 脚本的 return 值会原样透传给应用层,建议用整数编码状态,方便前端或网关快速分流:
-
return 0:抢购成功 -
return 1:库存不足(tonumber(redis.call('get', stockKey)) ) -
return 2:用户已下单(redis.call('sismember', orderKey, userId) == 1) -
return -1:key 不存在或类型错误(比如期望是 string 却是 hash)
性能影响:返回简单整数比返回 JSON 字符串快一个数量级,且避免了序列化开销;兼容性上,所有 Redis 客户端都支持接收 Long 类型返回值。
复杂点在于,一旦脚本里出现未捕获的运行时错误(比如除零、nil 参与运算),Redis 会直接抛 LUA script failed 异常,而不是返回你定义的错误码。所以每个数值操作前都要加 if not xxx then return -1 end 防御。










