--- tags: [redis, evalsha, script-load, performance, optimization] create time: 2026-06-01 10:30 --- # EVALSHA 预加载与性能优化 ## 概述 在 Redis 生产环境中,频繁使用 `EVAL` 传输完整脚本会带来**可量化的性能损耗**。`SCRIPT LOAD` + `EVALSHA` 的组合是解决这个问题的标准方案。本节深入讲解它的工作原理、性能收益和最佳实践。 对应薄弱点 Q14(EVALSHA 预加载的核心好处)。 ## 一、工作原理 ### 1.1 流程分解 ```mermaid 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 显式预加载(推荐生产使用) ```go // 程序启动时统一加载 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 容错处理 ```go // 捕获 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 相关指标: ```bash # Redis 端:脚本缓存统计 SCRIPT STATS # Redis 7+,查看各脚本调用次数和缓存命中率 # Go 端: - EvalSHA 失败率(NOSCRIPT 占比) - EvalSHA 平均延迟 vs Eval 平均延迟 - Script Load 次数(过高可能说明缓存丢失频繁) ``` ## 关联笔记 - [[Go-Redis Lua 调用指南]] — Go-Redis 中 Lua 调用的完整封装方式 - [[Lua脚本]] — Redis Lua 基础概念 - [[分布式限流]] — EVALSHA 在限流架构中的实际位置