Redis Lua 脚本是 Redis 服务器内嵌 Lua 5.1 解释器执行的脚本,其核心特性是原子执行:整个脚本在运行期间不会被任何其他客户端命令插入,等效于一条原子命令。借助它,多条 Redis 操作可以在服务端一次性完成,省去网络往返,并天然获得原子性,因此成为实现限流、分布式锁、秒杀扣减等并发场景的标配方案。本文从用法讲起,逐一给出可直接上线的脚本。
为什么 Redis 内置 Lua
Redis 引入 Lua 主要解决三个痛点:
- 原子性语义:脚本执行期间其他命令被阻塞排队,脚本整体要么全部执行、要么完全不执行(出错时已执行的命令不回滚,这点与数据库事务不同)。
MULTI/EXEC事务只保证命令按序不被打断,但事务内后续命令无法基于前面命令的结果做条件判断——“先 GET 判断、再 SET"这类读-改-写模式用事务无法保证原子,必须借助 Lua。 - 减少网络往返:把多次
redis.call收敛为一次EVAL,省掉 RTT,高并发下吞吐提升明显。 - 替代事务的局限场景:条件更新、CAS、多键一致性操作、复合计数等逻辑在客户端实现会引入竞态,在脚本内实现则天然安全。
代价是脚本会阻塞主线程,因此官方建议脚本尽量短小,详见后文性能部分。
版本与沙箱限制
Redis 内嵌的是 Lua 5.1(LuaJIT 兼容语义),即使 Lua 已发展到 5.4+,Redis 出于稳定性考虑长期未升级,不能使用整除 // 等新语法。版本差异可参考 Lua 版本对比。
同时,脚本运行在受限制的沙箱中:io、os 的文件操作被移除(os.time 等少量函数保留但禁止使用,因为脚本要求确定性——随机数、时间等非确定性调用在从节点重放时会产生分叉),dofile、loadfile 不可用,也不能访问外部网络。这些限制的设计思路与 Lua 环境与沙箱机制 一脉相承。需要当前时间时,应通过 redis.call('TIME') 获取,或由客户端经 ARGV 传入。
基础用法:EVAL、EVALSHA 与参数约定
EVAL 与 EVALSHA
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey hello
语法为 EVAL script numkeys key... arg...。每次 EVAL 都会把脚本全文传给服务器重新编译,生产环境更常用 EVALSHA:先 SCRIPT LOAD 拿到 40 位 SHA1,之后只传摘要执行。
客户端的标准模式是:优先 EVALSHA,收到 NOSCRIPT 错误后回退 EVAL(脚本缓存因重启或 SCRIPT FLUSH 丢失时自动重载)。主流客户端库(Jedis、redis-py、go-redis)均已内置该回退逻辑。
KEYS 与 ARGV 的约定
所有键名必须通过 KEYS 传入,普通参数走 ARGV。这不仅是约定,更是硬性要求:Redis Cluster 会根据 KEYS 路由脚本并校验所有键落在同一 slot,在脚本内部拼接键名会导致路由错误或违反集群约束。
redis.call 与 redis.pcall
两者都用于在脚本内执行 Redis 命令,区别在于错误处理:
redis.call:命令出错时立即抛出 Lua 错误,终止脚本并把错误返回给客户端。redis.pcall:捕获错误并返回一个错误表({err = "..."}),脚本继续执行。
这与 Lua 原生 pcall 的语义一脉相承(详见 Lua 错误处理:pcall、xpcall 与 error 最佳实践)。一般约定:对类型可能不匹配的命令用 redis.pcall 做防御,其余用 redis.call 让错误尽快暴露。
Lua 与 Redis 的类型转换规则
Lua 类型与 Redis 协议(RESP)类型之间有固定映射,理解它是排查返回值诡异问题的前提:
| Lua 值 | Redis 回复 | 说明 |
|---|---|---|
| number(数字) | integer(截断为整数) | Lua 5.1 数字是双精度浮点,返回时丢弃小数部分 |
| string | bulk string | 原样返回 |
true | integer 1 | 注意不是字符串 “true” |
false / nil | nil | 多行批量回复中表现为空 |
table(带 ok 字段) | status reply | 如 return {ok = 'done'} |
table(带 err 字段) | error reply | redis.pcall 的错误返回形式 |
| 数组 table | multi-bulk | 遇 nil 即截断,后续元素丢失 |
反向转换(Redis 回复 → Lua):integer → number、bulk string → string、nil → false、multi-bulk → 数组 table、error → 带 err 字段的表。常见坑有两个:返回浮点数被截断(改返回字符串由客户端解析);数组中间出现 nil 导致截断(应填占位值)。
实战一:计数限流
固定窗口
最简单的限流:每个时间窗口允许 N 次请求。用 Lua 把 INCR 与 EXPIRE 合并为原子操作,避免"计数已加但过期没设上"的竞态:
-- 固定窗口限流:KEYS[1]=计数键, ARGV[1]=窗口秒数, ARGV[2]=阈值
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0 -- 拒绝
end
return 1 -- 放行
缺点众所周知:窗口边界处可能瞬间通过 2N 个请求。
滑动窗口(ZSET 实现)
用有序集合记录每次请求的时间戳,统计窗口内真实请求数,更平滑精确:
-- KEYS[1]=限流键, ARGV[1]=窗口毫秒, ARGV[2]=阈值, ARGV[3]=当前毫秒, ARGV[4]=唯一成员标识
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
return 0
end
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return 1
ARGV[4] 建议用 毫秒时间戳-随机后缀 保证成员唯一,避免同毫秒并发请求互相覆盖。
实战二:令牌桶限流
令牌桶支持突发流量,是网关层最常用的算法。核心状态只有"剩余令牌数"和"上次填充时间”,适合脚本化:
-- KEYS[1]=令牌数键 KEYS[2]=最后刷新时间键
-- ARGV[1]=容量 ARGV[2]=填充速率(个/秒) ARGV[3]=当前秒 ARGV[4]=本次消耗数
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local tokens = tonumber(redis.call('GET', KEYS[1])) or capacity
local last_time = tonumber(redis.call('GET', KEYS[2])) or now
-- 惰性填充:按时间差补发令牌
tokens = math.min(capacity, tokens + (now - last_time) * rate)
if tokens < requested then
return 0 -- 令牌不足
end
redis.call('SET', KEYS[1], tokens - requested)
redis.call('SET', KEYS[2], now)
return 1
“惰性填充"避免了后台定时任务,判断与扣减在一个原子步骤内完成。更完善的实现可用 Hash 存两个字段减少键数量,逻辑相同。
实战三:分布式锁
加锁与解锁
加锁用 SET key value NX PX ttl,其中 value 必须是持有者唯一标识(如 UUID:线程ID),ttl 防止持有者崩溃后死锁。解锁必须校验 value 再删除,否则会出现"锁已过期被 B 拿到,A 醒来后误删 B 的锁"的事故。校验与删除分两条命令会有竞态,所以解锁必须写成 Lua 脚本:
-- KEYS[1]=锁键, ARGV[1]=持有者标识;返回 1 删除成功,0 锁不属于自己
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
续期与可重入
业务执行时间不可预测时,需要"看门狗"续期:后台线程每隔 ttl/3 执行一次续期脚本,同样要校验持有者身份:
-- KEYS[1]=锁键, ARGV[1]=持有者标识, ARGV[2]=新过期毫秒
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0
可重入锁则改用 Hash:field 为持有者标识,value 为重入计数,加锁 HINCRBY,解锁时计数减到 0 才 DEL,Redisson 即采用此模型。
Redlock 简述与争议
单节点锁在主从切换时可能失效(锁写入主节点尚未同步到从节点,主宕机后从升主,另一客户端再次拿到同一把锁)。Redlock 的解法是向多数(N/2+1)个独立节点申请锁。该方案自提出起就争议不断(Martin Kleppmann 批评其依赖时钟假设),实践共识是:追求效率用单节点锁并容忍极小概率误删,追求正确性用基于共识的系统(ZooKeeper、etcd),Redlock 处于尴尬中间地带。
实战四:库存扣减防超卖
秒杀场景下"查库存、判断、扣减"必须原子化,否则高并发下必然超卖:
-- KEYS[1]=库存键, ARGV[1]=扣减数量
-- 返回: 剩余库存(成功); -1 库存不足; -2 键不存在
local stock = redis.call('GET', KEYS[1])
if not stock then
return -2
end
stock = tonumber(stock)
local n = tonumber(ARGV[1])
if stock < n then
return -1
end
return redis.call('DECRBY', KEYS[1], n)
客户端根据返回码区分"已售罄"与"未开始”,避免打满缓存或数据库。注意用 DECRBY 而非 SET stock-n:语义清晰,且返回新值省去二次查询。
实战五:排行榜操作封装
游戏和社区的排行榜常需"加分并返回新排名"的组合操作,客户端做要三次往返,脚本一次搞定:
-- KEYS[1]=排行榜键, ARGV[1]=分数增量, ARGV[2]=成员
local score = redis.call('ZINCRBY', KEYS[1], ARGV[1], ARGV[2])
local rank = redis.call('ZREVRANK', KEYS[1], ARGV[2])
return {score, rank + 1} -- rank 从 0 起,业务层习惯 1 起
返回的是多行批量回复,客户端按数组解析即可。
调试与排错
redis-cli –ldb
Redis 3.2+ 内置 Lua 调试器,支持断点、单步、打印变量:
redis-cli --ldb --eval rate_limit.lua rate:limit , 60 100
调试器使用 fork 出的副本会话执行脚本,写操作在调试结束后被丢弃,可安全地在测试库上反复试错。常用命令:step 逐行、break 5 设断点、print KEYS[1] 查看变量。
脚本超时与 SCRIPT KILL
脚本默认最长执行 5 秒(lua-time-limit,单位毫秒)。超时后 Redis 不会中断脚本(中断会破坏原子性),只是开始接受其他命令——但只有读命令能执行,写命令全部返回 BUSY。此时有两个选择:
SCRIPT KILL:仅当脚本尚未执行任何写操作时才能成功终止;SHUTDOWN NOSAVE:脚本已写入时的最后手段,代价是重启服务。
性能注意事项
- 脚本即阻塞:Redis 单线程执行命令,脚本运行期间所有请求排队,一个遍历 10 万元素的循环就能让 P99 飙到秒级。大数据量操作务必分批(如
SCAN游标分页,每批一个脚本)。 - 避免在脚本内做大表遍历与字符串拼接,Lua 5.1 的 GC 压力会直接体现在延迟上(思路与 Lua 性能优化实战 相通)。
- 脚本缓存:脚本编译后按 SHA1 缓存在服务端,
EVALSHA只传摘要。但SCRIPT FLUSH会清空全部缓存,之后所有客户端都会经历一轮NOSCRIPT回退;另外不要把动态数据拼进脚本字符串(如"return "..id),每次拼接都产生新 SHA1,缓存无限膨胀最终拖垮内存——动态值一律走ARGV。
常见问题(FAQ)
Redis 为什么选 Lua 5.1,不升级到新版本?
Redis 选型时 LuaJIT(兼容 5.1 语法)是当时最快的嵌入方案,且 5.1 体积小、API 稳定。升级意味着重写胶水层并可能破坏现存海量脚本的行为兼容性,收益小而风险大,社区长期维持冻结。写脚本时只需记住不能使用 5.2+ 的新语法即可。
Lua 脚本和 Redis 事务(MULTI/EXEC)有什么区别?
两者都保证命令不被其他客户端插入,但事务内的命令在 EXEC 时才按序执行,后续命令拿不到前面命令的结果,无法实现"根据查询结果决定下一步"的逻辑,且遇错不回滚。Lua 脚本可以拿到每条 redis.call 的返回值做条件分支,本质是服务端编程,能力上是事务的超集。
分布式锁为什么必须用 Lua 脚本解锁?
解锁需要"比较 value 是否为自己"和"删除键"两步。若拆成两条命令,GET 之后锁可能恰好过期并被其他客户端获取,此时 DEL 会误删别人的锁。Lua 脚本把比较与删除合成一个原子操作,从机制上消除该竞态。
脚本报错了,已经执行的命令会回滚吗?
不会。Lua 脚本不保证事务意义上的回滚,redis.call 出错时已执行的写操作会生效。需要强一致回滚的场景,应在脚本开头做参数预检,或自行在脚本里实现补偿逻辑。
为什么禁止在脚本里用 math.random 和 os.time?
脚本需要确定性:主节点执行完的脚本会以命令传播或脚本重放的形式同步到从节点,若结果依赖随机数或本地时间,主从数据将分叉。正确做法是用 redis.call('TIME') 取服务器时间,随机值由客户端生成后经 ARGV 传入。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。