5.6 KiB
5.6 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
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 次数(过高可能说明缓存丢失频繁)
关联笔记
- Go-Redis Lua 调用指南 — Go-Redis 中 Lua 调用的完整封装方式
- Lua脚本 — Redis Lua 基础概念
- 分布式限流 — EVALSHA 在限流架构中的实际位置