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

7.3 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 脚本调用完整指南

概述

在生产环境中用 Redis + Lua 做限流,最核心的技能不是写 Lua 脚本本身——而是如何在 Go 代码中优雅地管理、加载和执行这些脚本。本节讲解 Go-Redis 库的调用方式、预加载流程和常见陷阱。

对应你的薄弱点:Q12(Lua 在 Go-Redis 中的调用)、Q14(EVALSHA 预加载)。

一、三种调用方式对比

1.1 Eval:发送脚本正文

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

// 脚本 SHA1: b78d89f7f7654...
result, err := client.EvalSHA(ctx, "b78d89f7f7654...", []string{"mykey"}, 100).Int()

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

1.3 EVAL vs EVALSHA 选择流程

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 预加载 + 容错回退

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

关联笔记