---
tags: [redis, lua, go-redis, evalsha, script-load]
create time: 2026-06-01 10:30
---
# Go-Redis Lua 脚本调用完整指南
## 概述
在生产环境中用 Redis + Lua 做限流,最核心的技能不是写 Lua 脚本本身——而是**如何在 Go 代码中优雅地管理、加载和执行这些脚本**。本节讲解 Go-Redis 库的调用方式、预加载流程和常见陷阱。
对应你的薄弱点:Q12(Lua 在 Go-Redis 中的调用)、Q14(EVALSHA 预加载)。
## 一、三种调用方式对比
### 1.1 Eval:发送脚本正文
```go
const script = `
local key = KEYS[1]
return redis.call('INCR', key)
`
// 每次请求都传输完整的脚本字符串
result, err := client.Eval(ctx, script, []string{"mykey"}, 100).Int()
```
**问题:** 每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。
### 1.2 EvalSHA:只传 SHA1 指纹
```go
// 脚本 SHA1: b78d89f7f7654...
result, err := client.EvalSHA(ctx, "b78d89f7f7654...", []string{"mykey"}, 100).Int()
```
**优势:** 只传 40 字节指纹,大幅节省带宽和网络延迟。
### 1.3 EVAL vs EVALSHA 选择流程
```mermaid
flowchart LR
A[客户端启动] --> B["SCRIPT LOAD
发送脚本正文"]
B --> C["Redis 计算 SHA1
缓存到脚本库"]
C --> D["返回 SHA1 指纹"]
D --> E["运行时调用 EvalSHA
传 SHA1 即可"]
E --> F{NOSCRIPT?}
F -- "否" --> G["执行成功"]
F -- "是" --> H["Redis 缓存已丢失
重新 SCRIPT LOAD"]
H --> E
style A fill:#e1f5fe
style C fill:#fff3e0
style G fill:#c8e6c9
style H fill:#ffebee
```
## 二、Go-Redis 最佳实践
### 2.1 预加载 + 容错回退
```go
package ratelimit
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
var (
// 生产环境推荐:程序启动时统一预加载
tokenBucketSHA string
tokenBucketLua = `
local tokensKey = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
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
remaining = tokens
end
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)
return {allowed, math.floor(remaining * 1000 / rate)}
`
)
// InitSHA 在应用启动时调用,注册所有 Lua 脚本
func InitSHA(ctx context.Context, rdb *redis.Client) error {
var err error
// 预加载令牌桶脚本
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 调用
func TokenBucketAllow(ctx context.Context, rdb *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) {
key := fmt.Sprintf("rate:tokenbucket:{%s}", id)
now := time.Now().UnixMilli()
result, err := rdb.EvalSHA(ctx, tokenBucketSHA, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
// NOSCRIPT 错误:缓存丢失,尝试用 EVAL 回退
if err == redis.Nil || isNoScriptError(err) {
// 重新加载
newSHA, loadErr := rdb.ScriptLoad(ctx, tokenBucketLua).Result()
if loadErr != nil {
return false, 0, loadErr
}
tokenBucketSHA = newSHA
result, err = rdb.EvalSHA(ctx, newSHA, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
return false, 0, err
}
} else {
return false, 0, err
}
}
allowed := result[0] == 1
waitMs := result[1]
return allowed, waitMs, nil
}
func isNoScriptError(err error) bool {
return err != nil && strings.Contains(err.Error(), "NOSCRIPT")
}
```
### 2.2 更简单的写法:用 Eval 自动处理 NOSCRIPT
如果你不想自己处理回退逻辑,可以直接用 `Eval()`——它会在收到 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()
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
}
```
> [!tip]- 哪种方式更好?
>
> | 方式 | 优点 | 缺点 |
> |------|------|------|
> | **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
```
对于高频调用的限流场景,这个优化非常可观。
## 四、常见问题排查
| 症状 | 原因 | 解决 |
|------|------|------|
| `NOSCRIPT` 报错 | Redis 重启后缓存清空 | 添加回退逻辑或使用 Eval 自动重试 |
| `BUSYERR` 超时 | 脚本执行超过 5 秒 | 优化脚本逻辑,避免大 Key 操作 |
| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键 |
| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 检查 Redis 协议映射表 |
## 五、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()` / `.StringsSlice()` |
| `true/false` | Integer `1/0` | `.Int()` |
## 关联笔记
- [[分布式限流]] — Lua 脚本在各限流算法中的实际应用
- [[EVALSHA预加载]] — 更深入的性能分析和监控指标
- [[Lua脚本]] — Redis Lua 基础概念