vault backup: 2026-05-31 21:15:22
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
---
|
||||
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-容错模式]] — 限流在容错体系中的位置
|
||||
@@ -91,6 +91,19 @@ key := fmt.Sprintf("rate:limit:%s:%d", userId, windowStart)
|
||||
|
||||
这段代码短小精悍,看起来已经够用了。但它有一个**隐蔽的边界问题**。
|
||||
|
||||
> [!info]- 💡 Redis Key 中的 `id` 是什么?
|
||||
>
|
||||
> 文中的 `id` 不是固定指某个业务字段,而是**限流维度的路由标识(identifier)**——你想限制谁,它就等于谁的唯一标识。常见取:
|
||||
>
|
||||
> | 限流维度 | `id` 实际取值 | Key 示例 |
|
||||
> |---------|-------------|----------|
|
||||
> | **用户级**(L2) | 用户 ID / 账号 ID | `rate:limit:user_10086:1717132800` |
|
||||
> | **IP 级**(L1) | 客户端 IP | `rate:limit:192.168.1.42:1717132800` |
|
||||
> | **接口级**(L3) | 接口路径或名称 | `rate:limit:/api/register:1717132800` |
|
||||
> | **API Key 级** | 客户端凭证 | `rate:limit:app_key_xxx:1717132800` |
|
||||
>
|
||||
> 生产环境中通常是**多级组合**:例如同时用 `userId + endpoint` 作为复合 Key,做到细粒度控制。
|
||||
|
||||
### 1.2 边界突发:窗口的"双杀"效应
|
||||
|
||||
> [!warning]- ⚠️ 先看一个具体例子
|
||||
@@ -437,5 +450,6 @@ flowchart LR
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[Lua脚本]] — Redis Lua 脚本基础概念与使用模式
|
||||
- [[02-服务治理/06-容错模式]] — 限流是容错体系的第三层防线
|
||||
- [[02-服务治理/01-API网关]] — API 网关层的限流插件集成
|
||||
|
||||
Reference in New Issue
Block a user