强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Redis 传输协议精讲 / 08 - Lua 脚本协议

Lua 脚本协议

8.1 为什么需要 Lua 脚本

Redis 的 Lua 脚本引擎允许在服务端执行自定义逻辑,解决了几个关键问题:

问题纯命令方案Lua 脚本方案
原子性MULTI/EXEC 不支持条件逻辑✅ 脚本原子执行
减少 RTTPipeline 减少但不消除✅ 一次发送,服务端执行全部逻辑
条件逻辑需要多轮往返✅ 脚本内实现 if/else
复杂计算需要客户端参与✅ 服务端完成

典型应用场景

  • 分布式锁(SET NX + 过期时间 + 校验删除)
  • 限流器(令牌桶算法)
  • 原子性的读-改-写操作
  • 批量数据处理
  • 自定义命令

8.2 EVAL 命令

命令格式

EVAL script numkeys key [key ...] arg [arg ...]
参数说明
scriptLua 脚本内容
numkeyskey 参数的数量
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 类型示例
numberIntegerreturn 42:42
stringBulk Stringreturn "hello"$5\r\nhello
boolean trueInteger 1return true:1
boolean falseNULLreturn false$-1
nilNULLreturn nil$-1
tableArrayreturn {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 分布式锁生产级分布式锁实现

上一章:事务协议 | 下一章:哨兵协议