Files
cs-note/hzh/REDIS/Lua脚本.md
T

12 KiB
Raw Blame History

tags, create time
tags create time
redis
lua
scripting
atomicity
2026-05-31 14:30

Redis Lua 脚本

概述

Redis 从 2.6 版本开始内嵌了 Lua 解释器,允许开发者将一段 Lua 代码直接发送到 Redis 服务器执行。它不是让你在 Redis 里写业务逻辑,而是用来把多个命令打包成一个不可分割的原子操作。

[!question]- 💡 思考:为什么不用 Pipeline?

Pipeline 确实能把多个命令一起发出去——但它只是"批量发送",中间的任何一步都可能被其他客户端插入。想象你在银行取钱:先查余额,再扣款。如果这两步之间被别人插入了转账,你就可能取超。Lua 脚本相当于"查余额并扣款"一个柜台窗口一条龙办完,中间没人能插队。

核心能力只有三件事:

能力 说明 对应传统方案
原子性 Redis 单线程串行执行,脚本运行期间无并发 CAS / MULTI-EXEC + 业务判断
一次 RTT 整段逻辑只往返一次网络 Pipeline 多次拼接
可复用 脚本按 SHA1 缓存,后续调用仅传指纹 每次发送完整脚本的开销

一、基本用法

1.1 EVAL:发送并执行

EVAL script numkeys key [key ...] arg [arg ...]

三个部分:

参数 作用 示例
script Lua 代码字符串 "return redis.call('INCR', KEYS[1])"
numkeys 告诉 Redis 有几个 Key(便于分隔 Key 和 Argv) 1
KEYS[] / ARGV[] 在 Lua 中通过全局表访问 KEYS[1], ARGV[1]

实际调用:

EVAL "return redis.call('INCR', KEYS[1])" 1 rate:limit:user_10086

返回 integer: 1(第一次执行)、2(第二次)……

1.2 EVALSHA:按 SHA1 指纹调用

每次发送完整脚本有带宽和解析成本。Redis 会对脚本做 SHA1 哈希,之后只需传指纹:

# 第一步:加载脚本,得到 SHA1 指纹
SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
→ "b78d89f7f7654..."

# 第二步:用指纹调用(注意是 EVALSHA,不带脚本正文)
EVALSHA b78d89f7f7654... 1 rate:limit:user_10086
→ integer: 2

生产环境的标准流程:启动时预加载 → 运行时只传 SHA1。Go 的 go-redis 封装成了 client.EvalSHA()。

[!warning]- NOSCRIPT 错误

如果指纹对应的脚本不在缓存中(例如 Redis 重启后),EVALSHA 会返回 NOSCRIPT。健壮的客户端应当捕获这个错误,回退到 EVAL 重新加载。

1.3 EVAL vs EVALSHA:何时用哪个?

flowchart LR
    A[客户端] -->|首次使用| B{SHA1 命中缓存?}
    B -->|否| C["SCRIPT LOAD\n发送完整脚本"]
    C --> D["Redis 计算 SHA1,\n存入脚本缓存"]
    D --> E["返回 SHA1 指纹"]
    E --> F["客户端调用 EVALSHA\n传指纹即可"]

    B -->|是| F
    F --> G["Lua 执行完毕,\n返回结果"]

    H[Redis 重启 / FLUSHALL] --> I["脚本缓存清空"]
    I --> J["EVALSHA → NOSCRIPT"]
    J --> K["客户端回退到 EVAL\n重新加载"]

    style A fill:#e1f5fe
    style G fill:#c8e6c9
    style K fill:#fff9c4

选择原则:

方案 优点 缺点 适用场景
EVAL 简单粗暴,无需管理状态 每次发完整脚本,带宽+解析开销大 脚本只跑几次、调试阶段
EVALSHA 指纹传输(约 40 字节),高性能 需处理 NOSCRIPT 回退 生产环境高频调用

[!tip]- 现代客户端已自动处理

Go go-redis 的 Eval() 在收到 NOSCRIPT 时会自动重试;Java Jedis / Lettuce 同理。除非手动拼命令,否则无需自己写回退逻辑。


二、Lua 脚本中的两个世界

Lua 脚本运行在 Redis 服务端,它与客户端通过两套固定接口通信:

flowchart TB
    subgraph Client["客户端进程"]
        direction LR
        C1[业务代码<br/>Go / Java / Python] --> L1["序列化参数"]
        L1 --> C2["构造 KEYS[] & ARGV[]"]
        C2 --> L2["EVAL / EVALSHA"]
        L2 --> C3[Lua 字节码]
    end

    subgraph Redis["Redis Server(单线程)"]
        direction TB
        R1[解析命令<br/>提取 KEYS / ARGV] --> R2[加载 Lua 解释器]
        R2 --> R3[执行脚本]
        R3 --> R4{redis.call?<br/>命中命令?}
        R4 -->|是| R5["操作数据<br/>读写内存"]
        R4 -->|否| R6[返回错误]
        R5 --> R7["序列化返回值"]
        R6 --> R7
        R7 --> R8[写入回复缓冲区]
    end

    C3 --> T1["TCP 发送"]
    T1 --> R1
    R8 --> T2["TCP 响应"]
    T2 --> C1

    style Client fill:#e3f2fd
    style Redis fill:#fce4ec

与客户端通过两套固定接口通信:

-- KEYS[]:所有「真正的 Key 名」放在这里
-- ARGV[]:所有「业务参数」放在这里
local myKey    = KEYS[1]        -- 第一个 Key
local maxValue = tonumber(ARGV[1]) -- 第一个参数,需转数字

-- 通过 redis.call() 调用 Redis 命令
local count = redis.call('GET', myKey)
if tonumber(count) < maxValue then
    redis.call('INCR', myKey)
    return 1  -- 放行
end
return 0  -- 拒绝
维度 KEYS[] ARGV[]
用途 Redis Key 名称 数值、字符串等业务参数
类型 string string(数值需 tonumber)
Slot 计算 参与 Hash Tag 计算 不参与

[!tip]- 为什么一定要分 KEYS 和 ARGV?

Redis Cluster 根据 Key 的 hash slot 路由请求。Lua 脚本中涉及多个 Key 时,Redis 需要确认它们都在同一个 slot 上。只有 KEYS[] 中的成员会被检查,所以必须把所有 Key 放进 KEYS[],纯参数放 ARGV[]。


三、返回值映射

Lua 表达式的返回值会被映射为 Redis 协议类型,客户端拿到的是对应结构:

Lua 返回值 Redis 协议 Go go-redis 接收方式
integer Bulk String(如 "42") Int()
"string" Bulk String String()
nil Null Bulk String Nil error
table{1, 2, 3} Array IntSlice() / StringsSlice()
true / false Integer (1 / 0) Int()
error(...) Error reply error

典型的多值返回(令牌桶场景):

return {allowed, math.floor(remaining * 1000 / rate)}
-- Go: result, _ := c.Eval(ctx, script, []string{key}, ...).IntSlice()

四、安全边界与限制

Redis 的 Lua 解释器不是通用的编程环境,它有意限制了危险操作:

限制 原因 替代方案
不允许 I/O 或系统调用 防止阻塞或副作用 只能用 redis.call() 操作数据
没有 math.random / os.time 保证确定性回放 从 ARGV 传入时间戳和随机种子
没有标准库大部分函数 缩小攻击面 string.*, table.*, math.*(部分可用)
脚本最长 5 秒 防止阻塞主线程 超时返回 BUSY,Key 不会被修改
不能有协程/异步 Redis 单线程模型 同步编写即可

[!info]- 可用的 Lua 标准库

Redis 内嵌的 Lua 保留了以下模块:string, table, math, os(仅 time/date/difftime), tcp/udp(禁用), _G。实际上,99% 的场景只需要 redis.call + 基础 table/string/math 就足够了。


五、实战模式速查

下面是生产中最常见的几种 Lua 脚本模式,每个都配了一段最小可运行的例子。

5.1 计数 + 条件判断

场景:限流、配额检查等需要"读-判-写"原子的场景。

local key      = KEYS[1]
local maxCount = tonumber(ARGV[1])
local window   = tonumber(ARGV[2])

local count = redis.call('INCR', key)
if count == 1 then
    redis.call('EXPIRE', key, window)
end
if count > maxCount then
    return 0  -- 超限
end
return 1

5.2 分布式锁(SETNX + TTL / 获取 + 释放)

场景:防止多实例同时修改同一条数据。

Lua 脚本需要同时处理 获取锁 和 释放锁 两种情况,通过 ARGV 传入操作类型来区分:

-- KEYS[1]: 锁 key, ARGV[1]: "acquire" | "release", ARGV[2]: token (随机值), ARGV[3]: ttl (秒)
local lockKey = KEYS[1]
local action  = ARGV[1]
local token   = ARGV[2]
local ttl     = tonumber(ARGV[3])

if action == "acquire" then
    -- SET IF NOT EXISTS: 只在没有锁时创建,设置过期时间防死锁
    if redis.call('SET', lockKey, token, 'NX', 'EX', ttl) then
        return 1  -- 获取成功
    end
    return 0  -- 锁已被持有

elseif action == "release" then
    -- 只允许自己释放(校验 token),避免误删别人的锁
    if redis.call('GET', lockKey) == token then
        redis.call('DEL', lockKey)
        return 1  -- 释放成功
    end
    return 0  -- token 不匹配,不是自己的锁
end

[!note]- 为什么不能用 GET + DEL?

GET 然后 DEL 不是原子的——两个命令之间其他客户端可能已经获取了这把锁。必须在 Lua 里把检查和删除打包。

[!tip]- Redlock 与简化版

生产环境推荐使用成熟的实现(如 go-redsync),它们处理了时钟漂移、节点宕机等边缘情况。上面的脚本是理解原理的最小示例。

5.3 Sorted Set 滑动窗口

场景:精确统计最近 N 秒内的请求数(见 [[分布式限流]] 2.2 节)。

local key      = KEYS[1]
local windowMs = tonumber(ARGV[1]) * 1000
local maxLen   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local member   = ARGV[4]

redis.call('ZREMRANGEBYSCORE', key, '-inf', now - windowMs)
local count = redis.call('ZCARD', key)
if count >= maxLen then
    return count
end
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, math.ceil(windowMs / 1000))
return count + 1

5.4 ⚠️ 常见陷阱

陷阱 表现 对策
KEYS 命令通配 KEYS *pattern* 会遍历全库,阻塞主线程 用 SCAN 替代(Lua 中通过 redis.call('SCAN', ...))
大 Key 操作 HGETALL / SMEMBERS 拉出百万级元素 改分批接口或换数据结构
误以为 MULTI-EXEC 是原子包 WATCH/MULTI-EXEC 只在 EXEC 时检测冲突,中间仍可被读 Lua 才是真·原子执行体
过期时间不一致 EXPIRE 和写操作不在一个脚本里,过期后数据丢失 用 Lua 打包读写 + EXPIRE

六、实战注意事项

除了上面的模式,还有几个容易踩坑的细节:

[!danger]- 原子性 ≠ 正确性

Lua 保证了脚本内的 Redis 命令不被穿插,但不会帮你处理业务逻辑错误。例如限流脚本允许了请求 A,后续你的业务代码依然可能因为网络重试造成重复执行。原子性只是防并发的一层保障。

[!tip]- 脚本体积优化

  • 生产环境中脚本建议控制在 10KB 以内。太大不仅浪费带宽,还会影响缓存命中率。
  • 善用局部变量减少全局查找开销。Lua 表索引访问比全局变量快很多。
  • 避免在脚本内部构造动态命令——每次 redis.call 都有哈希成本,固定结构更好。

[!info]- Redis 6+ 的 Cluster 兼容性增强

Redis 6.2 起引入了 @ro(只读)、@rw(读写)等命令标志。EVAL 默认标记为 @admin,需要 ACL 的 scripting 权限。如果你的 Redis 开了 ACL,记得给应用账号授予对应的脚本权限。


七、调试技巧

Lua 脚本在服务端运行,不能 print 断点。常用的排错方式:

方法 用法 适用场景
redis-cli --eval 本地直接传参测试脚本 快速验证逻辑
SCRIPT DEBUG (Redis 7+) 开启逐行调试追踪 定位诡异行为
返回中间值 return {step1, step2, count} 分段观察数据流
SCRIPT KILL 强制终止正在运行的脚本 脚本卡住超时的时候

本地测试示例:

redis-cli --eval test.lua , rate:key 100 60

注意 , 前后的参数顺序——, 右边先列 Keys,再跟 Args(如示例中的 rate:key 是 Key,100 60 是两个 Argv)。


关联笔记