vault backup: 2026-06-01 10:06:16
This commit is contained in:
+133
-75
@@ -7,47 +7,82 @@ create time: 2026-06-01 10:30
|
||||
|
||||
## 概述
|
||||
|
||||
在生产环境中用 Redis + Lua 做限流,最核心的技能不是写 Lua 脚本本身——而是**如何在 Go 代码中优雅地管理、加载和执行这些脚本**。本节讲解 Go-Redis 库的调用方式、预加载流程和常见陷阱。
|
||||
本文讲解如何在 Go 中使用 go-redis 库安全地管理、加载和执行 Redis Lua 脚本。掌握后你将能够:
|
||||
|
||||
对应你的薄弱点:Q12(Lua 在 Go-Redis 中的调用)、Q14(EVALSHA 预加载)。
|
||||
- 理解为什么需要 Lua 而不是 Pipeline / MULTI/EXEC
|
||||
- 选择最适合场景的调用方式(Eval vs EvalSHA)
|
||||
- 写出带容错回退的生产级脚本加载逻辑
|
||||
|
||||
## 一、三种调用方式对比
|
||||
阅读建议:如果你还不清楚 Lua 脚本在 Redis 中能做什么,先看 [[Lua脚本]] 了解基础概念;如果你的目标是限流,搭配 [[分布式限流]] 一起阅读效果更好。
|
||||
|
||||
### 1.1 Eval:发送脚本正文
|
||||
## 一、为什么用 Lua?
|
||||
|
||||
Redis 是单线程模型,Lua 脚本的核心价值在于**原子性执行**——脚本内的读、算、写不会被并发请求打断。这是 Pipeline 和事务做不到的:
|
||||
|
||||
| 特性 | Lua | Pipeline | MULTI/EXEC |
|
||||
|------|-----|----------|------------|
|
||||
| 原子性 | ✅ 完全原子 | ❌ 仅批量发送 | ⚠️ EXEC 时才检测冲突 |
|
||||
| 条件判断 | ✅ 可写业务逻辑 | ❌ 只能发命令 | ❌ 同上 |
|
||||
| 返回值控制 | ✅ 自定义 | ❌ 固定格式 | ❌ 同上 |
|
||||
|
||||
一个典型例子:令牌桶限流需要 "读取当前余额 → 计算是否充足 → 扣减" 三步操作。如果用 Pipeline,这三步之间其他请求可能插进来;而 Lua 保证了整个过程不可分割。
|
||||
|
||||
```lua
|
||||
-- KEYS[1]: Redis Key,如 "rate:key"
|
||||
local tokens = redis.call('GET', 'tokens')
|
||||
-- tonumber() 将字符串转为数字,nil 时返回 0
|
||||
if tonumber(tokens) >= 1 then
|
||||
redis.call('DECR', 'tokens') -- 原子减 1
|
||||
return 1 -- 1 = 允许通过
|
||||
end
|
||||
return 0 -- 0 = 拒绝
|
||||
```
|
||||
|
||||
**思考题**:既然 Pipeline 也能做到一次发多条命令,为什么不直接用 Pipeline + WATCH/MULTI/EXEC 实现同样的效果?
|
||||
|
||||
> **答案**:WATCH 只能检测 key 是否被修改,无法在条件判断(`if tokens < 1`)时中断流程。Lua 脚本可以内嵌完整的决策树。
|
||||
|
||||
## 二、三种调用方式对比
|
||||
|
||||
### 2.1 EVAL — 每次传脚本正文
|
||||
|
||||
```go
|
||||
const script = `
|
||||
local key = KEYS[1]
|
||||
return redis.call('INCR', key)
|
||||
local key = KEYS[1] -- KEYS[1]: Redis Key,从调用方传入
|
||||
return redis.call('INCR', key) -- 原子递增并返回新值
|
||||
`
|
||||
|
||||
// 每次请求都传输完整的脚本字符串
|
||||
// Eval: 直接传脚本正文,go-redis 每次都会计算 SHA1
|
||||
result, err := client.Eval(ctx, script, []string{"mykey"}, 100).Int()
|
||||
// └─ script └─ KEYS列表 └─ ARGV参数
|
||||
```
|
||||
|
||||
**问题:** 每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。
|
||||
**缺点**:每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。不适合高频调用。
|
||||
|
||||
### 1.2 EvalSHA:只传 SHA1 指纹
|
||||
### 2.2 EVALSHA — 只传 SHA1 指纹
|
||||
|
||||
```go
|
||||
// 脚本 SHA1: b78d89f7f7654...
|
||||
result, err := client.EvalSHA(ctx, "b78d89f7f7654...", []string{"mykey"}, 100).Int()
|
||||
// EvalSHA: 只传 SHA1 指纹,脚本需提前 SCRIPT LOAD 到 Redis 缓存中
|
||||
sha := "b78d89f7f7654..." // 之前 ScriptLoad 返回的指纹
|
||||
result, err := client.EvalSHA(ctx, sha, []string{"mykey"}, 100).Int()
|
||||
// └─ sha └─ KEYS列表 └─ ARGV参数
|
||||
```
|
||||
|
||||
**优势:** 只传 40 字节指纹,大幅节省带宽和网络延迟。
|
||||
**优势**:只传 40 字节指纹,大幅节省带宽和网络延迟。
|
||||
|
||||
### 1.3 EVAL vs EVALSHA 选择流程
|
||||
### 2.3 预热与调用流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
flowchart TD
|
||||
A[客户端启动] --> B["SCRIPT LOAD<br/>发送脚本正文"]
|
||||
B --> C["Redis 计算 SHA1<br/>缓存到脚本库"]
|
||||
C --> D["返回 SHA1 指纹"]
|
||||
D --> E["运行时调用 EvalSHA<br/>传 SHA1 即可"]
|
||||
E --> F{NOSCRIPT?}
|
||||
F -- "否" --> G["执行成功"]
|
||||
F -- "是" --> H["Redis 缓存已丢失<br/>重新 SCRIPT LOAD"]
|
||||
H --> E
|
||||
F -- "是" --> H["Redis 重启/内存不足<br/>缓存已丢失"]
|
||||
H --> I["重新 SCRIPT LOAD"]
|
||||
I --> E
|
||||
|
||||
style A fill:#e1f5fe
|
||||
style C fill:#fff3e0
|
||||
@@ -55,9 +90,25 @@ flowchart LR
|
||||
style H fill:#ffebee
|
||||
```
|
||||
|
||||
## 二、Go-Redis 最佳实践
|
||||
**关键点**:即使你全程用 EvalSHA,也必须做好 NOSCRIPT 错误的回退处理——Redis 重启或内存淘汰后,脚本库会被清空。
|
||||
|
||||
### 2.1 预加载 + 容错回退
|
||||
### 2.4 EVALSHA 能省多少?
|
||||
|
||||
```
|
||||
脚本大小 ≈ 1KB(典型限流脚本)
|
||||
SHA1 指纹 = 40 字节
|
||||
|
||||
每次调用节省 ≈ 960 字节
|
||||
每秒 10000 次调用 → 节省 ≈ 9.6 MB/s
|
||||
```
|
||||
|
||||
对于高频调用的限流场景,这个优化非常可观。
|
||||
|
||||
## 三、生产级最佳实践
|
||||
|
||||
### 3.1 方案 A:显式预加载 + 手动回退(推荐生产环境)
|
||||
|
||||
程序启动时统一预加载所有脚本,失败则直接退出——比线上踩坑更可靠。
|
||||
|
||||
```go
|
||||
package ratelimit
|
||||
@@ -65,106 +116,130 @@ package ratelimit
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
// 生产环境推荐:程序启动时统一预加载
|
||||
tokenBucketSHA string
|
||||
|
||||
|
||||
tokenBucketLua = `
|
||||
-- KEYS[1]: 存储令牌桶的 Redis Key
|
||||
-- ARGV[1]: 桶容量(最大令牌数)
|
||||
-- ARGV[2]: 补充速率(每秒补充的令牌数)
|
||||
-- ARGV[3]: 本次请求消耗的令牌数
|
||||
-- ARGV[4]: 当前时间戳(毫秒)
|
||||
local tokensKey = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local requested = tonumber(ARGV[3])
|
||||
local now = tonumber(ARGV[4])
|
||||
|
||||
-- 从 Redis 读取上次剩余的令牌数和最后补充时间
|
||||
local bucket = redis.call('HMGET', tokensKey, 'tokens', 'last_refill')
|
||||
local tokens = tonumber(bucket[1])
|
||||
local lastFill = tonumber(bucket[2])
|
||||
|
||||
-- 首次请求,桶为空,初始化为满桶
|
||||
if tokens == nil then
|
||||
tokens = capacity
|
||||
lastFill = now
|
||||
end
|
||||
|
||||
local elapsed = (now - lastFill) / 1000.0
|
||||
-- 根据经过的时间补充令牌
|
||||
local elapsed = (now - lastFill) / 1000.0 -- 转换为秒
|
||||
tokens = math.min(capacity, tokens + elapsed * rate)
|
||||
|
||||
-- 判断是否允许通过:剩余令牌 >= 请求消耗?
|
||||
local allowed = 0
|
||||
local remaining = tokens
|
||||
if tokens >= requested then
|
||||
tokens = tokens - requested
|
||||
allowed = 1
|
||||
tokens = tokens - requested -- 扣减令牌
|
||||
allowed = 1 -- 1 = 允许,0 = 拒绝
|
||||
remaining = tokens
|
||||
end
|
||||
|
||||
-- 写回状态并设置过期时间(防止残留 Key)
|
||||
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
|
||||
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)
|
||||
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2) -- TTL 为填满所需时间的 2 倍
|
||||
|
||||
-- 返回 {是否允许通过, 等待毫秒数}
|
||||
return {allowed, math.floor(remaining * 1000 / rate)}
|
||||
`
|
||||
)
|
||||
|
||||
// InitSHA 在应用启动时调用,注册所有 Lua 脚本
|
||||
// InitSHA 在应用启动时调用,注册所有 Lua 脚本到 Redis 缓存
|
||||
func InitSHA(ctx context.Context, rdb *redis.Client) error {
|
||||
var err error
|
||||
|
||||
// 预加载令牌桶脚本
|
||||
|
||||
// ScriptLoad: 将脚本发送 Redis,Redis 计算 SHA1 并缓存
|
||||
tokenBucketSHA, err = rdb.ScriptLoad(ctx, tokenBucketLua).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("script load failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
log.Printf("token bucket script loaded, SHA: %s", tokenBucketSHA)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenBucketAllow 使用 EvalSHA 调用
|
||||
// TokenBucketAllow 使用 EvalSHA 调用令牌桶限流,含 NOSCRIPT 回退
|
||||
func TokenBucketAllow(ctx context.Context, rdb *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) {
|
||||
// Hash Tag {}: 确保不同用户的 Key 路由到同一 slot(Cluster 模式需要)
|
||||
key := fmt.Sprintf("rate:tokenbucket:{%s}", id)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// 用预加载的 SHA1 调用,避免每次传输脚本正文
|
||||
result, err := rdb.EvalSHA(ctx, tokenBucketSHA, []string{key}, capacity, rate, 1, now).IntSlice()
|
||||
if err != nil {
|
||||
// NOSCRIPT 错误:缓存丢失,尝试用 EVAL 回退
|
||||
if err == redis.Nil || isNoScriptError(err) {
|
||||
// 重新加载
|
||||
// NOSCRIPT 说明 Redis 重启或内存淘汰导致缓存丢失
|
||||
if isNoScriptError(err) {
|
||||
// 重新 ScriptLoad 获取新的 SHA1
|
||||
newSHA, loadErr := rdb.ScriptLoad(ctx, tokenBucketLua).Result()
|
||||
if loadErr != nil {
|
||||
return false, 0, loadErr
|
||||
}
|
||||
// 更新全局 SHA,后续请求直接使用新指纹
|
||||
tokenBucketSHA = newSHA
|
||||
|
||||
|
||||
// 用新 SHA 重试
|
||||
result, err = rdb.EvalSHA(ctx, newSHA, []string{key}, capacity, rate, 1, now).IntSlice()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
} else {
|
||||
// 非 NOSCRIPT 的错误(如参数类型错误),直接返回
|
||||
return false, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
allowed := result[0] == 1
|
||||
waitMs := result[1]
|
||||
return allowed, waitMs, nil
|
||||
|
||||
// IntSlice 返回 [allowed(0/1), waitMs]
|
||||
return result[0] == 1, result[1], nil
|
||||
}
|
||||
|
||||
// isNoScriptError 判断是否为 NOSCRIPT 错误
|
||||
func isNoScriptError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "NOSCRIPT")
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 更简单的写法:用 Eval 自动处理 NOSCRIPT
|
||||
**设计要点**:
|
||||
- `InitSHA` 在 `main()` 中调用,启动失败即阻断部署
|
||||
- 全局变量存 SHA,避免重复 `ScriptLoad`
|
||||
- NOSCRIPT 时先 `ScriptLoad` 再 `EvalSHA` 重试
|
||||
|
||||
如果你不想自己处理回退逻辑,可以直接用 `Eval()`——它会在收到 NOSCRIPT 时自动重试:
|
||||
### 3.2 方案 B:EVAL 自动重试(推荐开发/简单场景)
|
||||
|
||||
如果不想手写回退逻辑,可以直接用 `Eval()`——go-redis 内部会在收到 NOSCRIPT 时自动重新加载:
|
||||
|
||||
```go
|
||||
// 这样写最简单,Go-Redis 内部已经处理了 NOSCRIPT 回退
|
||||
func TokenBucketAllowSimple(ctx context.Context, rdb *redis.Client, id string, capacity, rate float64) (bool, int64, error) {
|
||||
key := fmt.Sprintf("rate:tokenbucket:{%s}", id)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Eval 会自动处理 NOSCRIPT:先尝试 SHA,失败则重新 LOAD + RETRY
|
||||
result, err := rdb.Eval(ctx, tokenBucketLua, []string{key}, capacity, rate, 1, now).IntSlice()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
@@ -173,39 +248,21 @@ func TokenBucketAllowSimple(ctx context.Context, rdb *redis.Client, id string, c
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- 哪种方式更好?
|
||||
### 3.3 两种方案怎么选?
|
||||
|
||||
> [!tip]- 方案对比
|
||||
|
||||
| 维度 | 方案 A(ScriptLoad + EvalSHA) | 方案 B(Eval 自动重试) |
|
||||
|------|------|------|
|
||||
| 代码复杂度 | 中等(需处理回退) | 极简 |
|
||||
| 网络开销 | 最低(始终走指纹) | 首次多一次往返 |
|
||||
| 故障发现 | 启动时暴露问题 | 运行时才感知 |
|
||||
| 适用场景 | 生产环境、高频调用 | 开发测试、低频调用 |
|
||||
|
||||
> [!info]- 总结
|
||||
>
|
||||
> | 方式 | 优点 | 缺点 |
|
||||
> |------|------|------|
|
||||
> | **ScriptLoad + EvalSHA** | 明确管理脚本生命周期,启动时报错可快速定位 | 代码较繁琐,需要手写回退逻辑 |
|
||||
> | **Eval(自动重试)** | 简洁,库内部处理 NOSCRIPT | 首次或缓存丢失时多一次网络往返 |
|
||||
>
|
||||
> **推荐:** 生产环境用方案 A(显式 ScriptLoad),因为你需要确保启动时就注册成功;开发/测试环境用方案 B。
|
||||
|
||||
## 三、关键要点总结
|
||||
|
||||
### Q12:为什么用 Lua?
|
||||
|
||||
Redis 的单线程模型保证 Lua 脚本**原子化执行**——读 → 算 → 写不会被并发打断。这是 Pipeline + MULTI/EXEC 做不到的:
|
||||
|
||||
| 特性 | Lua | Pipeline | MULTI/EXEC |
|
||||
|------|-----|----------|------------|
|
||||
| 原子性 | ✅ 完全原子 | ❌ 仅批量发送 | ⚠️ EXEC 时才检测冲突 |
|
||||
| 条件判断 | ✅ 可写业务逻辑 | ❌ 只能发命令 | ❌ 同上 |
|
||||
| 返回值控制 | ✅ 自定义 | ❌ 固定格式 | ❌ 同上 |
|
||||
| 网络 RTT | 1 次 | 1 次(但无逻辑) | 1 次 |
|
||||
|
||||
### Q14:EVALSHA 的核心收益
|
||||
|
||||
```
|
||||
脚本大小 ≈ 1KB(典型限流脚本)
|
||||
SHA1 指纹 = 40 字节
|
||||
|
||||
每次调用节省 ≈ 1KB - 40B ≈ 960 字节
|
||||
每秒 10000 次调用 → 节省 ≈ 9.6 MB/s
|
||||
```
|
||||
|
||||
对于高频调用的限流场景,这个优化非常可观。
|
||||
> - **生产环境**:方案 A。你需要确保启动时就注册成功,上线前就知道问题。
|
||||
> - **开发/快速原型**:方案 B。代码少、维护成本低。
|
||||
|
||||
## 四、常见问题排查
|
||||
|
||||
@@ -213,8 +270,8 @@ SHA1 指纹 = 40 字节
|
||||
|------|------|------|
|
||||
| `NOSCRIPT` 报错 | Redis 重启后缓存清空 | 添加回退逻辑或使用 Eval 自动重试 |
|
||||
| `BUSYERR` 超时 | 脚本执行超过 5 秒 | 优化脚本逻辑,避免大 Key 操作 |
|
||||
| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键 |
|
||||
| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 检查 Redis 协议映射表 |
|
||||
| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键,见 [[路由键与Hash Tag]] |
|
||||
| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 见下方协议映射表 |
|
||||
|
||||
## 五、Redis 协议类型映射速查
|
||||
|
||||
@@ -223,7 +280,7 @@ SHA1 指纹 = 40 字节
|
||||
| `integer` | Bulk String `"42"` | `.Int()` |
|
||||
| `"string"` | Bulk String | `.String()` |
|
||||
| `nil` | Null Bulk String | `redis.Nil` |
|
||||
| `table{1, 2}` | Array | `.IntSlice()` / `.StringsSlice()` |
|
||||
| `table{1, 2}` | Array | `.IntSlice()` |
|
||||
| `true/false` | Integer `1/0` | `.Int()` |
|
||||
|
||||
## 关联笔记
|
||||
@@ -231,3 +288,4 @@ SHA1 指纹 = 40 字节
|
||||
- [[分布式限流]] — Lua 脚本在各限流算法中的实际应用
|
||||
- [[EVALSHA预加载]] — 更深入的性能分析和监控指标
|
||||
- [[Lua脚本]] — Redis Lua 基础概念
|
||||
- [[路由键与Hash Tag]] — Cluster 模式下 Key 路由注意事项
|
||||
|
||||
Reference in New Issue
Block a user