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

10 KiB
Raw Blame History

tags, create time
tags create time
redis
lua
go-redis
evalsha
script-load
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 保证了整个过程不可分割。

-- 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 — 每次传脚本正文

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 指纹

// EvalSHA: 只传 SHA1 指纹,脚本需提前 SCRIPT LOAD 到 Redis 缓存中
sha := "b78d89f7f7654..." // 之前 ScriptLoad 返回的指纹
result, err := client.EvalSHA(ctx, sha, []string{"mykey"}, 100).Int()
//             └─ sha     └─ KEYS列表 └─ ARGV参数

优势:只传 40 字节指纹,大幅节省带宽和网络延迟。

2.3 预热与调用流程

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:显式预加载 + 手动回退(推荐生产环境)

程序启动时统一预加载所有脚本,失败则直接退出——比线上踩坑更可靠。

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 时自动重新加载:

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()

关联笔记