Redis 传输协议精讲 / 08 - Lua 脚本协议
Lua 脚本协议
8.1 为什么需要 Lua 脚本
Redis 的 Lua 脚本引擎允许在服务端执行自定义逻辑,解决了几个关键问题:
| 问题 | 纯命令方案 | Lua 脚本方案 |
|---|---|---|
| 原子性 | MULTI/EXEC 不支持条件逻辑 | ✅ 脚本原子执行 |
| 减少 RTT | Pipeline 减少但不消除 | ✅ 一次发送,服务端执行全部逻辑 |
| 条件逻辑 | 需要多轮往返 | ✅ 脚本内实现 if/else |
| 复杂计算 | 需要客户端参与 | ✅ 服务端完成 |
典型应用场景
- 分布式锁(SET NX + 过期时间 + 校验删除)
- 限流器(令牌桶算法)
- 原子性的读-改-写操作
- 批量数据处理
- 自定义命令
8.2 EVAL 命令
命令格式
EVAL script numkeys key [key ...] arg [arg ...]
| 参数 | 说明 |
|---|---|
script | Lua 脚本内容 |
numkeys | key 参数的数量 |
key [key ...] | KEYS 数组(从 1 开始) |
arg [arg ...] | ARGV 数组 |
RESP 编码
# EVAL "return 1" 0
*3\r\n
$4\r\n
EVAL\r\n
$9\r\n
return 1\r\n
$1\r\n
0\r\n
# EVAL "return redis.call('GET', KEYS[1])" 1 mykey
*4\r\n
$4\r\n
EVAL\r\n
$38\r\n
return redis.call('GET', KEYS[1])\r\n
$1\r\n
1\r\n
$5\r\n
mykey\r\n
脚本内访问 KEYS 和 ARGV
-- KEYS 和 ARGV 是全局数组
-- KEYS[1], KEYS[2], ... 对应命令中的 key 参数
-- ARGV[1], ARGV[2], ... 对应命令中的 arg 参数
local key = KEYS[1]
local value = ARGV[1]
redis.call('SET', key, value)
return redis.call('GET', key)
→ EVAL "local k=KEYS[1] local v=ARGV[1] redis.call('SET',k,v) return redis.call('GET',k)" 1 mykey hello
← $5
← hello
8.3 脚本返回值
Lua 脚本的返回值会被转换为 RESP 类型:
| Lua 类型 | RESP 类型 | 示例 |
|---|---|---|
number | Integer | return 42 → :42 |
string | Bulk String | return "hello" → $5\r\nhello |
boolean true | Integer 1 | return true → :1 |
boolean false | NULL | return false → $-1 |
nil | NULL | return nil → $-1 |
table | Array | return {1,2,3} → *3\r\n:1\r\n:2\r\n:3 |
表(Table)的特殊处理
-- 数组式 table(连续整数键)
return {1, "hello", true, nil, 3}
-- → *3\r\n:1\r\n$5\r\nhello\r\n:1\r\n
-- 注意:nil 和其后的元素被忽略
嵌套表
return {{1, 2}, {3, 4}}
-- → *2\r\n*2\r\n:1\r\n:2\r\n*2\r\n:3\r\n:4\r\n
8.4 EVALSHA 命令
动机
EVAL 每次都要传输完整的脚本内容,对于大脚本来说浪费带宽。EVALSHA 使用脚本的 SHA1 摘要来引用已缓存的脚本。
SHA1 计算
import hashlib
script = "return 1"
sha1 = hashlib.sha1(script.encode()).hexdigest()
print(sha1) # "a5260dd1a28a0680e6dbb9a441b25a4944419b21"
命令格式
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
响应
如果脚本已缓存,EVALSHA 与 EVAL 行为完全相同。如果脚本未缓存:
→ EVALSHA a5260dd1a28a0680e6dbb9a441b25a4944419b21 0
← -NOSCRIPT No matching script. Use EVAL.
8.5 脚本缓存管理
SCRIPT LOAD
将脚本加载到缓存,返回 SHA1:
→ SCRIPT LOAD "return 1"
← $40
← a5260dd1a28a0680e6dbb9a441b25a4944419b21
SCRIPT EXISTS
检查脚本是否在缓存中:
→ SCRIPT EXISTS a5260dd1a28a0680e6dbb9a441b25a4944419b21
← *1
← :1 ← 存在
SCRIPT FLUSH
清空脚本缓存:
→ SCRIPT FLUSH
← +OK
SCRIPT KILL
终止正在运行的脚本(仅限只读脚本):
→ SCRIPT KILL
← +OK
8.6 标准模式:EVAL + EVALSHA
生产环境推荐的标准模式:
import redis
import hashlib
class ScriptManager:
"""脚本缓存管理器"""
def __init__(self, client: redis.Redis):
self.client = client
self._cache = {} # script → sha1
def register(self, script: str) -> str:
"""注册脚本并返回 SHA1"""
sha1 = hashlib.sha1(script.encode()).hexdigest()
self._cache[sha1] = script
# 预加载到服务器
self.client.script_load(script)
return sha1
def evalsha(self, sha1: str, keys=[], args=[]):
"""使用 EVALSHA 执行脚本,失败时自动回退到 EVAL"""
try:
return self.client.evalsha(sha1, len(keys), *keys, *args)
except redis.exceptions.NoScriptError:
# 脚本不在缓存中,使用 EVAL
script = self._cache.get(sha1)
if script is None:
raise ValueError(f"Unknown script: {sha1}")
return self.client.eval(script, len(keys), *keys, *args)
# 使用
r = redis.Redis()
sm = ScriptManager(r)
# 注册分布式锁脚本
lock_script = """
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then
return 1
else
return 0
end
"""
lock_sha = sm.register(lock_script)
# 执行
result = sm.evalsha(lock_sha, keys=["mylock"], args=["owner1", 30])
8.7 脚本中的 Redis 命令调用
redis.call() vs redis.pcall()
| 函数 | 错误处理 | 适用场景 |
|---|---|---|
redis.call() | 错误中断脚本,返回错误给客户端 | 大多数场景 |
redis.pcall() | 错误被捕获,返回错误对象 | 需要在脚本内处理错误 |
-- 使用 redis.call():错误会传播
local result = redis.call('GET', 'nonexistent')
-- result 是 false(nil),不会报错
-- 类型错误时:
local result = redis.call('LPUSH', 'string-key', 'value')
-- 报错:WRONGTYPE Operation against a key...
-- 脚本中断,错误返回给客户端
-- 使用 redis.pcall():错误被捕获
local ok, result = pcall(redis.call, 'LPUSH', 'string-key', 'value')
-- ok = false, result = "WRONGTYPE ..."
-- 脚本继续执行
可用的 Redis 命令
脚本中可以调用几乎所有 Redis 命令,但有例外:
| 禁止的命令 | 原因 |
|---|---|
SUBSCRIBE / PSUBSCRIBE | 会改变连接状态 |
WATCH / MULTI / EXEC | 脚本本身是原子的 |
BLPOP 等阻塞命令 | 脚本执行期间不能阻塞 |
SCRIPT | 避免递归 |
8.8 脚本调试
SCRIPT DEBUG 命令
Redis 6.0+ 支持脚本调试:
# 启用同步调试模式
→ SCRIPT DEBUG YES
← +OK
# 执行脚本时会进入调试模式
→ EVAL "return 1" 0
# 启用异步调试模式(日志输出到 Redis 日志)
→ SCRIPT DEBUG SYNC
← +OK
# 关闭调试
→ SCRIPT DEBUG NO
← +OK
Lua 日志
-- 在脚本中输出日志(写入 Redis 日志文件)
redis.log(redis.LOG_DEBUG, "Debug message")
redis.log(redis.LOG_VERBOSE, "Verbose message")
redis.log(redis.LOG_NOTICE, "Notice message")
redis.log(redis.LOG_WARNING, "Warning message")
-- 示例
local value = redis.call('GET', KEYS[1])
redis.log(redis.LOG_NOTICE, "Current value: " .. tostring(value))
return value
redis-cli 调试
# 使用 redis-cli 的 --ldb 选项调试脚本
redis-cli --ldb --eval /tmp/script.lua mykey , myarg
# 调试命令:
# s - step(单步执行)
# n - next
# c - continue(继续执行)
# p <var> - print variable
# b <line> - break at line
# r - run until return
8.9 实战脚本示例
示例一:分布式锁
-- 加锁脚本
-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的持有者标识
-- ARGV[2]: 过期时间(秒)
-- 返回: 1(成功)/ 0(失败)
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then
return 1
else
return 0
end
-- 解锁脚本
-- KEYS[1]: 锁的 key
-- ARGV[1]: 锁的持有者标识
-- 返回: 1(成功)/ 0(失败或锁不属于当前持有者)
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
示例二:限流器(令牌桶)
-- KEYS[1]: 限流 key
-- ARGV[1]: 桶容量
-- ARGV[2]: 每秒填充速率
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求的令牌数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 获取当前桶状态
local data = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now
-- 计算新增令牌
local elapsed = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + elapsed * rate)
-- 判断是否允许
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 更新桶状态
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return allowed
示例三:滑动窗口限流
-- KEYS[1]: 限流 key
-- ARGV[1]: 窗口大小(秒)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前时间戳(微秒)
-- ARGV[4]: 唯一标识
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local uuid = ARGV[4]
-- 清除窗口外的成员
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000000)
-- 计算当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 允许请求,添加到有序集合
redis.call('ZADD', key, now, uuid)
redis.call('PEXPIRE', key, window * 1000)
return 1 -- 允许
else
return 0 -- 拒绝
end
8.10 脚本的性能考量
脚本执行是阻塞的
Lua 脚本在 Redis 主线程中执行,执行期间会阻塞所有其他客户端:
Client A: EVAL long_script ...
Client B: GET key ... ← 等待脚本执行完成
Client C: SET key value ... ← 等待脚本执行完成
时间限制
# 配置脚本最大执行时间(默认 5 秒)
lua-time-limit 5000
# 超过限制后,其他客户端的命令会返回 BUSY 错误
# 但脚本不会自动停止,需要手动 SCRIPT KILL
性能建议
| 建议 | 说明 |
|---|---|
| 脚本尽量短小 | 避免长时间阻塞 |
| 避免大循环 | for i=1,1000000 do ... end 会阻塞服务器 |
| 使用 EVALSHA | 减少网络传输 |
| 批量操作使用 MGET/MSET | 比脚本循环更高效 |
| 监控慢脚本 | SLOWLOG GET |
8.11 集群中的脚本
重要限制
在 Redis 集群中,脚本访问的所有 key 必须在同一个哈希槽:
-- ✅ 正确:所有 key 在同一个槽
redis.call('SET', '{user:1}:name', 'Alice')
redis.call('SET', '{user:1}:age', '30')
-- ❌ 错误:key 在不同槽
redis.call('SET', 'user:name', 'Alice')
redis.call('SET', 'user:age', '30') -- 可能在不同槽
使用 hash tag {} 确保相关 key 在同一个槽:
{user:1}:name → 槽 X
{user:1}:age → 槽 X(相同)
{user:2}:name → 槽 Y(不同)
8.12 注意事项
⚠️ 脚本中不要使用全局变量 全局变量会在脚本间共享,导致不可预测的行为。始终使用
local。
-- ❌ 错误
count = count + 1
-- ✅ 正确
local count = 0
⚠️ 注意脚本的确定性 避免在脚本中使用
math.random()、os.time()等不确定函数。在主从复制中,相同的脚本必须在所有节点上产生相同的结果。
-- ❌ 错误:随机结果
return math.random(1, 100)
-- ✅ 正确:使用 Redis 提供的随机数
return redis.call('RANDOMKEY')
⚠️ Lua 数字精度 Lua 5.1 使用双精度浮点数,大整数(超过 2^53)会丢失精度。
8.13 扩展阅读
| 资源 | 说明 |
|---|---|
| Redis Lua 脚本文档 | 官方文档 |
| Redis Lua API | 可用的 Redis 命令 |
| Lua 5.1 参考手册 | Lua 语言规范 |
| Redisson 分布式锁 | 生产级分布式锁实现 |