Files
cs-note/hzh/REDIS/EVALSHA预加载.md
T

5.6 KiB
Raw Blame History

tags, create time
tags create time
redis
evalsha
script-load
performance
optimization
2026-06-01 10:30

EVALSHA 预加载与性能优化

概述

在 Redis 生产环境中,频繁使用 EVAL 传输完整脚本会带来可量化的性能损耗。SCRIPT LOAD + EVALSHA 的组合是解决这个问题的标准方案。本节深入讲解它的工作原理、性能收益和最佳实践。

对应薄弱点 Q14(EVALSHA 预加载的核心好处)。

一、工作原理

1.1 流程分解

sequenceDiagram
    participant App as 应用实例
    participant Redis as Redis Server

    Note over App,Redis: ── 阶段1:启动时一次性预加载 ──
    App->>Redis: SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
    Redis->>Redis: SHA1(脚本内容) = b78d89f7...
    Redis->>App: "b78d89f7..." (SHA1 指纹)

    Note over App,Redis: ── 阶段2:运行时高频调用 ──
    loop 每次请求
        App->>Redis: EVALSHA b78d89f7... 1 mykey
        Redis->>Redis: 查找缓存中的脚本 → 命中
        Redis->>Redis: 执行脚本
        Redis-->>App: 返回值
    end

    Note over App,Redis: ── 异常路径:NOSCRIPT ──
    alt Redis 重启 / FLUSHALL
        App->>Redis: EVALSHA b78d89f7... 1 mykey
        Redis-->>App: NOSCRIPT 错误
        App->>Redis: SCRIPT LOAD "..." (重新加载)
        App->>Redis: EVALSHA <新SHA> ...
    end

1.2 两种方式的对比

维度 EVAL EVALSHA
传输内容 完整脚本字符串(通常 500B ~ 5KB) SHA1 指纹(固定 40 字节)
Redis 处理 计算 SHA1 + 存入缓存 + 执行 直接查缓存 + 执行
网络开销 大(每次都发脚本) 小(只发 40 字节)
适用场景 调试、低频脚本 生产环境、高频调用

二、性能收益量化

以典型的令牌桶 Lua 脚本为例(约 1.2KB):

场景:每秒 10,000 次限流请求

EVAL 模式:
  - 每次传输:~1.2 KB
  - 每秒总带宽:10,000 × 1.2 KB = 12 MB/s

EVALSHA 模式:
  - 每次传输:40 字节(SHA1)
  - 每秒总带宽:10,000 × 40 B = 400 KB/s
  
节省:12 MB/s - 0.4 MB/s ≈ 11.6 MB/s(节省 96.7%)

对于高频限流场景,这个差异非常可观。即使脚本只有几百字节,乘以百万级 QPS 后也是显著的网络 IO。

三、Go-Redis 中的实现

3.1 显式预加载(推荐生产使用)

// 程序启动时统一加载
func initScripts(ctx context.Context, rdb *redis.Client) error {
    scripts := map[string]string{
        "token_bucket":   tokenBucketLua,
        "sliding_window": slidingWindowLua,
        "fixed_window":   fixedWindowLua,
    }
    
    shas := make(map[string]string)
    for name, lua := range scripts {
        sha, err := rdb.ScriptLoad(ctx, lua).Result()
        if err != nil {
            return fmt.Errorf("load %s: %w", name, err)
        }
        shas[name] = sha
        log.Printf("loaded [%s]: SHA=%s", name, sha)
    }
    
    // 保存到全局或依赖注入容器
    return nil
}

3.2 NOSCRIPT 容错处理

// 捕获 NOSCRIPT 错误,自动回退到 EVAL
func safeEval(ctx context.Context, rdb *redis.Client, sha, lua string, keys []string, args ...interface{}) (interface{}, error) {
    result, err := rdb.EvalSHA(ctx, sha, keys, args...).Result()
    if err == nil {
        return result, nil
    }
    
    // 不是 NOSCRIPT 错误,直接返回
    if !isNoScript(err) {
        return result, err
    }
    
    // NOSCRIPT:重新加载并再次尝试
    newSHA, err := rdb.ScriptLoad(ctx, lua).Result()
    if err != nil {
        return nil, err
    }
    
    result, err = rdb.EvalSHA(ctx, newSHA, keys, args...).Result()
    return result, err
}

func isNoScript(err error) bool {
    if err == nil {
        return false
    }
    errStr := err.Error()
    return strings.Contains(errStr, "NOSCRIPT") || 
           strings.Contains(errStr, "no such script")
}

四、什么时候不需要预加载?

场景 建议
每个请求都调用的限流脚本 ✅ 必须预加载
每天只跑几次的管理脚本 ⭕ 用 EVAL 即可,省得维护状态
开发/测试环境 ⭕ Eval 的自动重试已够用
Redis 集群多节点 ✅ 必须在每个节点上都加载(通过 Sentinel 或 Cluster 客户端自动处理)

[!warning]- 分布式部署注意

如果你的 Go 服务连接到 Redis Cluster,ScriptLoad 会自动在所有主节点上加载脚本。但如果你用了连接池连接多个独立 Redis 实例(非 Cluster 模式),每个实例都需要单独 ScriptLoad——或者使用统一的代理层(如 Twemproxy、Codis)。

五、常见误区

误区 真相
EVALSHA 执行更快 ❌ EVALSHA 和 EVAL 的执行速度几乎一样,区别只在传输带宽
只需要加载一次永远有效 ❌ Redis 重启、FLUSHALL、内存淘汰都会清空脚本缓存
EVALSHA 可以绕过沙箱限制 ❌ EVALSHA 的沙箱规则和 EVAL 完全相同
语法更简洁 ❌ 需要额外传 SHA1,反而多了步骤

六、监控指标

在生产环境中,建议监控以下 EVALSHA 相关指标:

# Redis 端:脚本缓存统计
SCRIPT STATS    # Redis 7+,查看各脚本调用次数和缓存命中率

# Go 端:
- EvalSHA 失败率(NOSCRIPT 占比)
- EvalSHA 平均延迟 vs Eval 平均延迟
- Script Load 次数(过高可能说明缓存丢失频繁)

关联笔记