vault backup: 2026-06-01 00:35:50

This commit is contained in:
2026-06-01 00:35:50 +08:00
parent 420744ce9d
commit 0eccd338da
15 changed files with 2890 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
---
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 在限流架构中的实际位置