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

178 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 在限流架构中的实际位置