12 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
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)。
关联笔记
- 分布式限流 — Redis Lua 脚本最核心的应用场景
- 02-服务治理/06-容错模式 — 限流在容错体系中的位置