Files
cs-note/hzh/REDIS/Go-Redis Lua 调用指南.md
T

292 lines
10 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, go-redis, evalsha, script-load]
create time: 2026-06-01 10:30
---
# Go-Redis Lua 脚本调用完整指南
## 概述
本文讲解如何在 Go 中使用 go-redis 库安全地管理、加载和执行 Redis Lua 脚本。掌握后你将能够:
- 理解为什么需要 Lua 而不是 Pipeline / MULTI/EXEC
- 选择最适合场景的调用方式(Eval vs EvalSHA)
- 写出带容错回退的生产级脚本加载逻辑
阅读建议:如果你还不清楚 Lua 脚本在 Redis 中能做什么,先看 [[Lua脚本]] 了解基础概念;如果你的目标是限流,搭配 [[分布式限流]] 一起阅读效果更好。
## 一、为什么用 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] -- 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,带宽浪费严重。不适合高频调用。
### 2.2 EVALSHA — 只传 SHA1 指纹
```go
// EvalSHA: 只传 SHA1 指纹,脚本需提前 SCRIPT LOAD 到 Redis 缓存中
sha := "b78d89f7f7654..." // 之前 ScriptLoad 返回的指纹
result, err := client.EvalSHA(ctx, sha, []string{"mykey"}, 100).Int()
// └─ sha └─ KEYS列表 └─ ARGV参数
```
**优势**:只传 40 字节指纹,大幅节省带宽和网络延迟。
### 2.3 预热与调用流程
```mermaid
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/>缓存已丢失"]
H --> I["重新 SCRIPT LOAD"]
I --> E
style A fill:#e1f5fe
style C fill:#fff3e0
style G fill:#c8e6c9
style H fill:#ffebee
```
**关键点**:即使你全程用 EvalSHA,也必须做好 NOSCRIPT 错误的回退处理——Redis 重启或内存淘汰后,脚本库会被清空。
### 2.4 EVALSHA 能省多少?
```
脚本大小 ≈ 1KB(典型限流脚本)
SHA1 指纹 = 40 字节
每次调用节省 ≈ 960 字节
每秒 10000 次调用 → 节省 ≈ 9.6 MB/s
```
对于高频调用的限流场景,这个优化非常可观。
## 三、生产级最佳实践
### 3.1 方案 A:显式预加载 + 手动回退(推荐生产环境)
程序启动时统一预加载所有脚本,失败则直接退出——比线上踩坑更可靠。
```go
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 -- 转换为秒
tokens = math.min(capacity, tokens + elapsed * rate)
-- 判断是否允许通过:剩余令牌 >= 请求消耗?
local allowed = 0
local remaining = tokens
if tokens >= requested then
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) -- TTL 为填满所需时间的 2 倍
-- 返回 {是否允许通过, 等待毫秒数}
return {allowed, math.floor(remaining * 1000 / rate)}
`
)
// 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 调用令牌桶限流,含 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 说明 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
}
}
// 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")
}
```
**设计要点**:
- `InitSHA` 在 `main()` 中调用,启动失败即阻断部署
- 全局变量存 SHA,避免重复 `ScriptLoad`
- NOSCRIPT 时先 `ScriptLoad` 再 `EvalSHA` 重试
### 3.2 方案 B:EVAL 自动重试(推荐开发/简单场景)
如果不想手写回退逻辑,可以直接用 `Eval()`——go-redis 内部会在收到 NOSCRIPT 时自动重新加载:
```go
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
}
return result[0] == 1, result[1], nil
}
```
### 3.3 两种方案怎么选?
> [!tip]- 方案对比
| 维度 | 方案 A(ScriptLoad + EvalSHA) | 方案 B(Eval 自动重试) |
|------|------|------|
| 代码复杂度 | 中等(需处理回退) | 极简 |
| 网络开销 | 最低(始终走指纹) | 首次多一次往返 |
| 故障发现 | 启动时暴露问题 | 运行时才感知 |
| 适用场景 | 生产环境、高频调用 | 开发测试、低频调用 |
> [!info]- 总结
>
> - **生产环境**:方案 A。你需要确保启动时就注册成功,上线前就知道问题。
> - **开发/快速原型**:方案 B。代码少、维护成本低。
## 四、常见问题排查
| 症状 | 原因 | 解决 |
|------|------|------|
| `NOSCRIPT` 报错 | Redis 重启后缓存清空 | 添加回退逻辑或使用 Eval 自动重试 |
| `BUSYERR` 超时 | 脚本执行超过 5 秒 | 优化脚本逻辑,避免大 Key 操作 |
| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键,见 [[路由键与Hash Tag]] |
| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 见下方协议映射表 |
## 五、Redis 协议类型映射速查
| Lua 返回值 | Redis 协议 | Go-Redis 接收方法 |
|-----------|-----------|------------------|
| `integer` | Bulk String `"42"` | `.Int()` |
| `"string"` | Bulk String | `.String()` |
| `nil` | Null Bulk String | `redis.Nil` |
| `table{1, 2}` | Array | `.IntSlice()` |
| `true/false` | Integer `1/0` | `.Int()` |
## 关联笔记
- [[分布式限流]] — Lua 脚本在各限流算法中的实际应用
- [[EVALSHA预加载]] — 更深入的性能分析和监控指标
- [[Lua脚本]] — Redis Lua 基础概念
- [[路由键与Hash Tag]] — Cluster 模式下 Key 路由注意事项