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

345 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [redis, lua, scripting, atomicity]
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:何时用哪个?
```mermaid
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 服务端,它与客户端通过两套固定接口通信:
```mermaid
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
```
与客户端通过两套固定接口通信:
```lua
-- 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 |
典型的多值返回(令牌桶场景):
```lua
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 计数 + 条件判断
场景:限流、配额检查等需要"读-判-写"原子的场景。
```lua
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 传入操作类型来区分:
```lua
-- 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 节)。
```lua
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`** | 强制终止正在运行的脚本 | 脚本卡住超时的时候 |
本地测试示例:
```bash
redis-cli --eval test.lua , rate:key 100 60
```
注意 `,` 前后的参数顺序——`,` 右边先列 **Keys**,再跟 **Args**(如示例中的 `rate:key` 是 Key,`100 60` 是两个 Argv)。
---
## 关联笔记
- [[分布式限流]] — Redis Lua 脚本最核心的应用场景
- [[02-服务治理/06-容错模式]] — 限流在容错体系中的位置