事务之后,我们来看 Redis 如何用 Lua 脚本 把「读-判断-写」等多步逻辑变成一段在服务端原子执行的过程。 MULTI/EXEC 只能打包已有命令,无法在服务端做条件分支或循环;Lua 则可以在 Redis 内部执行任意逻辑,期间其他命令不会插入,从而避免竞态。本讲从「为什么需要 Lua」说起,再谈原子性保证,最后以限流与库存扣减为例说明如何用 Lua 实现复杂逻辑。
很多业务逻辑需要「先读、再判断、再写」:限流要先看当前计数是否超限再决定是否 INCR,库存扣减要先看剩余量是否足够再 DECR。若这些步骤在应用层用多条 Redis 命令完成,就会存在竞态:两个客户端同时读到「未超限」或「库存充足」,然后都执行 INCR 或 DECR,导致限流失效或超卖。把这段逻辑放进 Lua 脚本,在 Redis 服务端执行,脚本在执行期间会独占单线程,其他客户端的命令必须等脚本执行完才能执行,因此「读-判断-写」在脚本内是原子的,不会被打断。
Lua 脚本还减少了网络往返:原本「GET → 判断 → INCR → EXPIRE」需要多次请求,改成一段脚本后只需一次 EVAL 或 EVALSHA,延迟与连接占用都更低。对于限流、库存、分布式锁等「强一致」场景,Lua 是首选;对于简单「打包执行」且无条件判断的场景,MULTI/EXEC 也可用,但若涉及条件分支,Lua 更清晰、更安全。
|-- 限流:INCR 后若超限则返回 0 并回退,否则返回 1 local current = redis.call('INCR', KEYS[1]) if redis.call('TTL', KEYS[1]) == -1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if current > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]) return 0 end return 1

Redis 执行 Lua 脚本时,脚本会以「单线程、串行」方式运行:脚本开始后,其他客户端的命令必须等待脚本执行完毕才能执行,因此脚本内部的多条 redis.call() 是连续的、不会被其他请求插入的。从语义上,整段脚本相当于「一条原子命令」:要么全部执行完,要么脚本报错中断(此时已执行的 redis.call 不会自动回滚,因此脚本里应尽量避免逻辑错误)。脚本不应执行长时间阻塞操作(如 sleep、大循环),否则会阻塞整个 Redis,影响其他请求。
传参通过 KEYS 与 ARGV 进行:KEYS 用于传递 Key 名(便于集群模式下做路由),ARGV 用于传递其他参数(如限流阈值、过期时间)。生产环境建议用 EVALSHA 代替 EVAL:先将脚本加载到 Redis(SCRIPT LOAD),得到 SHA1 摘要,后续用 EVALSHA 传摘要即可,避免每次传输完整脚本。脚本应保持简短、无副作用、可重复执行(幂等),便于调试与运维。
|-- 库存扣减:仅当剩余 >= 扣减量 时才 DECR,返回剩余量;否则返回 -1 local current = tonumber(redis.call('GET', KEYS[1]) or 0) local deduct = tonumber(ARGV[1]) if current < deduct then return -1 end redis.call('DECRBY', KEYS[1], deduct) return current -

限流(固定窗口)的 Lua 实现思路是:对 Key 做 INCR,若为 1 则顺带设置 EXPIRE;然后根据 INCR 的返回值与阈值比较,若超限则 DECR 回退并返回 0,否则返回 1。这样「INCR + EXPIRE + 判断 + 可能 DECR」在同一脚本内完成,多客户端并发时不会出现「都读到未超限然后都 INCR」的情况。滑动窗口限流则用 ZSET:ZREMRANGEBYSCORE 删窗口外记录,ZCARD 统计窗口内数量,若未超限则 ZADD 当前时间戳,最后 EXPIRE Key;整段逻辑放在 Lua 里保证原子性(第八讲已给出示例)。
库存扣减的 Lua 实现思路是:GET 当前库存,判断是否 >= 扣减量;若不足则直接返回失败;若足够则 DECRBY 扣减量并返回剩余库存。这样「读-判断-扣减」在脚本内原子完成,不会超卖。注意 GET 与 DECRBY 之间若有其他逻辑,应放在同一脚本内;若库存存在多个 Key(如分仓),需要根据业务决定是多个 Key 放同一脚本(用 KEYS[1]、KEYS[2])还是分多次调用,避免跨 Key 逻辑导致的不一致。在下一讲中,我们将看 Redis 的持久化机制:RDB、AOF 以及数据安全与性能的取舍。
