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

234 lines
7.3 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 脚本调用完整指南
## 概述
在生产环境中用 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<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
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 基础概念