diff --git a/hzh/REDIS/Go-Redis Lua 调用指南.md b/hzh/REDIS/Go-Redis Lua 调用指南.md
index 3e2eed1..2a4c273 100644
--- a/hzh/REDIS/Go-Redis Lua 调用指南.md
+++ b/hzh/REDIS/Go-Redis Lua 调用指南.md
@@ -7,47 +7,82 @@ create time: 2026-06-01 10:30
## 概述
-在生产环境中用 Redis + Lua 做限流,最核心的技能不是写 Lua 脚本本身——而是**如何在 Go 代码中优雅地管理、加载和执行这些脚本**。本节讲解 Go-Redis 库的调用方式、预加载流程和常见陷阱。
+本文讲解如何在 Go 中使用 go-redis 库安全地管理、加载和执行 Redis Lua 脚本。掌握后你将能够:
-对应你的薄弱点:Q12(Lua 在 Go-Redis 中的调用)、Q14(EVALSHA 预加载)。
+- 理解为什么需要 Lua 而不是 Pipeline / MULTI/EXEC
+- 选择最适合场景的调用方式(Eval vs EvalSHA)
+- 写出带容错回退的生产级脚本加载逻辑
-## 一、三种调用方式对比
+阅读建议:如果你还不清楚 Lua 脚本在 Redis 中能做什么,先看 [[Lua脚本]] 了解基础概念;如果你的目标是限流,搭配 [[分布式限流]] 一起阅读效果更好。
-### 1.1 Eval:发送脚本正文
+## 一、为什么用 Lua?
+
+Redis 是单线程模型,Lua 脚本的核心价值在于**原子性执行**——脚本内的读、算、写不会被并发请求打断。这是 Pipeline 和事务做不到的:
+
+| 特性 | Lua | Pipeline | MULTI/EXEC |
+|------|-----|----------|------------|
+| 原子性 | ✅ 完全原子 | ❌ 仅批量发送 | ⚠️ EXEC 时才检测冲突 |
+| 条件判断 | ✅ 可写业务逻辑 | ❌ 只能发命令 | ❌ 同上 |
+| 返回值控制 | ✅ 自定义 | ❌ 固定格式 | ❌ 同上 |
+
+一个典型例子:令牌桶限流需要 "读取当前余额 → 计算是否充足 → 扣减" 三步操作。如果用 Pipeline,这三步之间其他请求可能插进来;而 Lua 保证了整个过程不可分割。
+
+```lua
+-- KEYS[1]: Redis Key,如 "rate:key"
+local tokens = redis.call('GET', 'tokens')
+-- tonumber() 将字符串转为数字,nil 时返回 0
+if tonumber(tokens) >= 1 then
+ redis.call('DECR', 'tokens') -- 原子减 1
+ return 1 -- 1 = 允许通过
+end
+return 0 -- 0 = 拒绝
+```
+
+**思考题**:既然 Pipeline 也能做到一次发多条命令,为什么不直接用 Pipeline + WATCH/MULTI/EXEC 实现同样的效果?
+
+> **答案**:WATCH 只能检测 key 是否被修改,无法在条件判断(`if tokens < 1`)时中断流程。Lua 脚本可以内嵌完整的决策树。
+
+## 二、三种调用方式对比
+
+### 2.1 EVAL — 每次传脚本正文
```go
const script = `
-local key = KEYS[1]
-return redis.call('INCR', key)
+local key = KEYS[1] -- KEYS[1]: Redis Key,从调用方传入
+return redis.call('INCR', key) -- 原子递增并返回新值
`
-// 每次请求都传输完整的脚本字符串
+// Eval: 直接传脚本正文,go-redis 每次都会计算 SHA1
result, err := client.Eval(ctx, script, []string{"mykey"}, 100).Int()
+// └─ script └─ KEYS列表 └─ ARGV参数
```
-**问题:** 每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。
+**缺点**:每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。不适合高频调用。
-### 1.2 EvalSHA:只传 SHA1 指纹
+### 2.2 EVALSHA — 只传 SHA1 指纹
```go
-// 脚本 SHA1: b78d89f7f7654...
-result, err := client.EvalSHA(ctx, "b78d89f7f7654...", []string{"mykey"}, 100).Int()
+// EvalSHA: 只传 SHA1 指纹,脚本需提前 SCRIPT LOAD 到 Redis 缓存中
+sha := "b78d89f7f7654..." // 之前 ScriptLoad 返回的指纹
+result, err := client.EvalSHA(ctx, sha, []string{"mykey"}, 100).Int()
+// └─ sha └─ KEYS列表 └─ ARGV参数
```
-**优势:** 只传 40 字节指纹,大幅节省带宽和网络延迟。
+**优势**:只传 40 字节指纹,大幅节省带宽和网络延迟。
-### 1.3 EVAL vs EVALSHA 选择流程
+### 2.3 预热与调用流程
```mermaid
-flowchart LR
+flowchart TD
A[客户端启动] --> B["SCRIPT LOAD
发送脚本正文"]
B --> C["Redis 计算 SHA1
缓存到脚本库"]
C --> D["返回 SHA1 指纹"]
D --> E["运行时调用 EvalSHA
传 SHA1 即可"]
E --> F{NOSCRIPT?}
F -- "否" --> G["执行成功"]
- F -- "是" --> H["Redis 缓存已丢失
重新 SCRIPT LOAD"]
- H --> E
+ F -- "是" --> H["Redis 重启/内存不足
缓存已丢失"]
+ H --> I["重新 SCRIPT LOAD"]
+ I --> E
style A fill:#e1f5fe
style C fill:#fff3e0
@@ -55,9 +90,25 @@ flowchart LR
style H fill:#ffebee
```
-## 二、Go-Redis 最佳实践
+**关键点**:即使你全程用 EvalSHA,也必须做好 NOSCRIPT 错误的回退处理——Redis 重启或内存淘汰后,脚本库会被清空。
-### 2.1 预加载 + 容错回退
+### 2.4 EVALSHA 能省多少?
+
+```
+脚本大小 ≈ 1KB(典型限流脚本)
+SHA1 指纹 = 40 字节
+
+每次调用节省 ≈ 960 字节
+每秒 10000 次调用 → 节省 ≈ 9.6 MB/s
+```
+
+对于高频调用的限流场景,这个优化非常可观。
+
+## 三、生产级最佳实践
+
+### 3.1 方案 A:显式预加载 + 手动回退(推荐生产环境)
+
+程序启动时统一预加载所有脚本,失败则直接退出——比线上踩坑更可靠。
```go
package ratelimit
@@ -65,106 +116,130 @@ package ratelimit
import (
"context"
"fmt"
+ "log"
+ "strings"
+ "time"
+
"github.com/redis/go-redis/v9"
)
var (
- // 生产环境推荐:程序启动时统一预加载
tokenBucketSHA string
-
+
tokenBucketLua = `
+-- KEYS[1]: 存储令牌桶的 Redis Key
+-- ARGV[1]: 桶容量(最大令牌数)
+-- ARGV[2]: 补充速率(每秒补充的令牌数)
+-- ARGV[3]: 本次请求消耗的令牌数
+-- ARGV[4]: 当前时间戳(毫秒)
local tokensKey = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
+-- 从 Redis 读取上次剩余的令牌数和最后补充时间
local bucket = redis.call('HMGET', tokensKey, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local lastFill = tonumber(bucket[2])
+-- 首次请求,桶为空,初始化为满桶
if tokens == nil then
tokens = capacity
lastFill = now
end
-local elapsed = (now - lastFill) / 1000.0
+-- 根据经过的时间补充令牌
+local elapsed = (now - lastFill) / 1000.0 -- 转换为秒
tokens = math.min(capacity, tokens + elapsed * rate)
+-- 判断是否允许通过:剩余令牌 >= 请求消耗?
local allowed = 0
local remaining = tokens
if tokens >= requested then
- tokens = tokens - requested
- allowed = 1
+ tokens = tokens - requested -- 扣减令牌
+ allowed = 1 -- 1 = 允许,0 = 拒绝
remaining = tokens
end
+-- 写回状态并设置过期时间(防止残留 Key)
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
-redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)
+redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2) -- TTL 为填满所需时间的 2 倍
+-- 返回 {是否允许通过, 等待毫秒数}
return {allowed, math.floor(remaining * 1000 / rate)}
`
)
-// InitSHA 在应用启动时调用,注册所有 Lua 脚本
+// InitSHA 在应用启动时调用,注册所有 Lua 脚本到 Redis 缓存
func InitSHA(ctx context.Context, rdb *redis.Client) error {
var err error
-
- // 预加载令牌桶脚本
+
+ // ScriptLoad: 将脚本发送 Redis,Redis 计算 SHA1 并缓存
tokenBucketSHA, err = rdb.ScriptLoad(ctx, tokenBucketLua).Result()
if err != nil {
return fmt.Errorf("script load failed: %w", err)
}
-
+
log.Printf("token bucket script loaded, SHA: %s", tokenBucketSHA)
return nil
}
-// TokenBucketAllow 使用 EvalSHA 调用
+// TokenBucketAllow 使用 EvalSHA 调用令牌桶限流,含 NOSCRIPT 回退
func TokenBucketAllow(ctx context.Context, rdb *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) {
+ // Hash Tag {}: 确保不同用户的 Key 路由到同一 slot(Cluster 模式需要)
key := fmt.Sprintf("rate:tokenbucket:{%s}", id)
now := time.Now().UnixMilli()
+ // 用预加载的 SHA1 调用,避免每次传输脚本正文
result, err := rdb.EvalSHA(ctx, tokenBucketSHA, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
- // NOSCRIPT 错误:缓存丢失,尝试用 EVAL 回退
- if err == redis.Nil || isNoScriptError(err) {
- // 重新加载
+ // NOSCRIPT 说明 Redis 重启或内存淘汰导致缓存丢失
+ if isNoScriptError(err) {
+ // 重新 ScriptLoad 获取新的 SHA1
newSHA, loadErr := rdb.ScriptLoad(ctx, tokenBucketLua).Result()
if loadErr != nil {
return false, 0, loadErr
}
+ // 更新全局 SHA,后续请求直接使用新指纹
tokenBucketSHA = newSHA
-
+
+ // 用新 SHA 重试
result, err = rdb.EvalSHA(ctx, newSHA, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
return false, 0, err
}
} else {
+ // 非 NOSCRIPT 的错误(如参数类型错误),直接返回
return false, 0, err
}
}
-
- allowed := result[0] == 1
- waitMs := result[1]
- return allowed, waitMs, nil
+
+ // IntSlice 返回 [allowed(0/1), waitMs]
+ return result[0] == 1, result[1], nil
}
+// isNoScriptError 判断是否为 NOSCRIPT 错误
func isNoScriptError(err error) bool {
return err != nil && strings.Contains(err.Error(), "NOSCRIPT")
}
```
-### 2.2 更简单的写法:用 Eval 自动处理 NOSCRIPT
+**设计要点**:
+- `InitSHA` 在 `main()` 中调用,启动失败即阻断部署
+- 全局变量存 SHA,避免重复 `ScriptLoad`
+- NOSCRIPT 时先 `ScriptLoad` 再 `EvalSHA` 重试
-如果你不想自己处理回退逻辑,可以直接用 `Eval()`——它会在收到 NOSCRIPT 时自动重试:
+### 3.2 方案 B:EVAL 自动重试(推荐开发/简单场景)
+
+如果不想手写回退逻辑,可以直接用 `Eval()`——go-redis 内部会在收到 NOSCRIPT 时自动重新加载:
```go
-// 这样写最简单,Go-Redis 内部已经处理了 NOSCRIPT 回退
func TokenBucketAllowSimple(ctx context.Context, rdb *redis.Client, id string, capacity, rate float64) (bool, int64, error) {
key := fmt.Sprintf("rate:tokenbucket:{%s}", id)
now := time.Now().UnixMilli()
+ // Eval 会自动处理 NOSCRIPT:先尝试 SHA,失败则重新 LOAD + RETRY
result, err := rdb.Eval(ctx, tokenBucketLua, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
return false, 0, err
@@ -173,39 +248,21 @@ func TokenBucketAllowSimple(ctx context.Context, rdb *redis.Client, id string, c
}
```
-> [!tip]- 哪种方式更好?
+### 3.3 两种方案怎么选?
+
+> [!tip]- 方案对比
+
+| 维度 | 方案 A(ScriptLoad + EvalSHA) | 方案 B(Eval 自动重试) |
+|------|------|------|
+| 代码复杂度 | 中等(需处理回退) | 极简 |
+| 网络开销 | 最低(始终走指纹) | 首次多一次往返 |
+| 故障发现 | 启动时暴露问题 | 运行时才感知 |
+| 适用场景 | 生产环境、高频调用 | 开发测试、低频调用 |
+
+> [!info]- 总结
>
-> | 方式 | 优点 | 缺点 |
-> |------|------|------|
-> | **ScriptLoad + EvalSHA** | 明确管理脚本生命周期,启动时报错可快速定位 | 代码较繁琐,需要手写回退逻辑 |
-> | **Eval(自动重试)** | 简洁,库内部处理 NOSCRIPT | 首次或缓存丢失时多一次网络往返 |
->
-> **推荐:** 生产环境用方案 A(显式 ScriptLoad),因为你需要确保启动时就注册成功;开发/测试环境用方案 B。
-
-## 三、关键要点总结
-
-### Q12:为什么用 Lua?
-
-Redis 的单线程模型保证 Lua 脚本**原子化执行**——读 → 算 → 写不会被并发打断。这是 Pipeline + MULTI/EXEC 做不到的:
-
-| 特性 | Lua | Pipeline | MULTI/EXEC |
-|------|-----|----------|------------|
-| 原子性 | ✅ 完全原子 | ❌ 仅批量发送 | ⚠️ EXEC 时才检测冲突 |
-| 条件判断 | ✅ 可写业务逻辑 | ❌ 只能发命令 | ❌ 同上 |
-| 返回值控制 | ✅ 自定义 | ❌ 固定格式 | ❌ 同上 |
-| 网络 RTT | 1 次 | 1 次(但无逻辑) | 1 次 |
-
-### Q14:EVALSHA 的核心收益
-
-```
-脚本大小 ≈ 1KB(典型限流脚本)
-SHA1 指纹 = 40 字节
-
-每次调用节省 ≈ 1KB - 40B ≈ 960 字节
-每秒 10000 次调用 → 节省 ≈ 9.6 MB/s
-```
-
-对于高频调用的限流场景,这个优化非常可观。
+> - **生产环境**:方案 A。你需要确保启动时就注册成功,上线前就知道问题。
+> - **开发/快速原型**:方案 B。代码少、维护成本低。
## 四、常见问题排查
@@ -213,8 +270,8 @@ SHA1 指纹 = 40 字节
|------|------|------|
| `NOSCRIPT` 报错 | Redis 重启后缓存清空 | 添加回退逻辑或使用 Eval 自动重试 |
| `BUSYERR` 超时 | 脚本执行超过 5 秒 | 优化脚本逻辑,避免大 Key 操作 |
-| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键 |
-| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 检查 Redis 协议映射表 |
+| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键,见 [[路由键与Hash Tag]] |
+| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 见下方协议映射表 |
## 五、Redis 协议类型映射速查
@@ -223,7 +280,7 @@ SHA1 指纹 = 40 字节
| `integer` | Bulk String `"42"` | `.Int()` |
| `"string"` | Bulk String | `.String()` |
| `nil` | Null Bulk String | `redis.Nil` |
-| `table{1, 2}` | Array | `.IntSlice()` / `.StringsSlice()` |
+| `table{1, 2}` | Array | `.IntSlice()` |
| `true/false` | Integer `1/0` | `.Int()` |
## 关联笔记
@@ -231,3 +288,4 @@ SHA1 指纹 = 40 字节
- [[分布式限流]] — Lua 脚本在各限流算法中的实际应用
- [[EVALSHA预加载]] — 更深入的性能分析和监控指标
- [[Lua脚本]] — Redis Lua 基础概念
+- [[路由键与Hash Tag]] — Cluster 模式下 Key 路由注意事项
diff --git a/hzh/REDIS/Jitter抖动与重试策略.md b/hzh/REDIS/Jitter抖动与重试策略.md
index 628fe8d..b92766b 100644
--- a/hzh/REDIS/Jitter抖动与重试策略.md
+++ b/hzh/REDIS/Jitter抖动与重试策略.md
@@ -37,9 +37,9 @@ flowchart TD
end
subgraph "有 Jitter(正常)"
- D1["Client 1"] -->|wait 5s + jitter(+0.3s)| D4["t=5.3s
😊 gradual arrival"]
- D2["Client 2"] -->|wait 5s + jitter(-0.7s)| D5["t=4.3s"]
- D3["Client N"] -->|wait 5s + jitter(+1.2s)| D6["t=6.2s"]
+ D1["Client 1"] -->|"wait 5s, jitter +0.3s"| D4["t=5.3s
😊 gradual arrival"]
+ D2["Client 2"] -->|"wait 5s, jitter -0.7s"| D5["t=4.3s"]
+ D3["Client N"] -->|"wait 5s, jitter +1.2s"| D6["t=6.2s"]
D4 --> OK["🟢 Server handles gracefully"]
D5 --> OK
D6 --> OK
diff --git a/hzh/REDIS/Pipeline批量操作.md b/hzh/REDIS/Pipeline批量操作.md
index f5d9810..0058ced 100644
--- a/hzh/REDIS/Pipeline批量操作.md
+++ b/hzh/REDIS/Pipeline批量操作.md
@@ -9,38 +9,47 @@ create time: 2026-06-01 10:30
Redis 的每次命令执行都有网络往返(RTT)开销。对于需要执行多条命令的场景,**Pipeline** 可以把多个命令打包成一个批次发送,大幅降低 RTT 次数。这是限流性能优化的核心手段之一(F14 考点)。
+> [!question]- 思考一下
+>
+> 假设你的应用和 Redis 在同一机房(RTT ≈ 0.5ms),每条命令平均处理时间 0.1ms。如果一次请求需要 10 条 Redis 命令,总延迟是多少?用 Pipeline 打包后呢?
+
## 一、为什么需要 Pipeline?
### 无 Pipeline:逐条发送
-```
-应用 Redis
- │ ───INCR key1─────────────▶ │ (RTT #1)
- │ ◀──1────────────────────── │
- │ │
- │ ───EXPIRE key1 60─────────▶ │ (RTT #2)
- │ ◀──1────────────────────── │
- │ │
- │ ───INCR key2─────────────▶ │ (RTT #3)
- │ ◀──1────────────────────── │
- │ │
- │ ───EXPIRE key2 60─────────▶ │ (RTT #4)
- │ ◀──1────────────────────── │
- │ │
-总 RTT: 4 次,延迟 = 4 × network_latency
+```mermaid
+sequenceDiagram
+ participant App as 应用
+ participant R as Redis
+
+ App->>R: INCR key1
+ Note over App,R: RTT #1
+ R-->>App: 1
+ App->>R: EXPIRE key1 60
+ Note over App,R: RTT #2
+ R-->>App: 1
+ App->>R: INCR key2
+ Note over App,R: RTT #3
+ R-->>App: 1
+ App->>R: EXPIRE key2 60
+ Note over App,R: RTT #4
+ R-->>App: 1
+
+ Note over App: 总 RTT: 4 次
延迟 = 4 × network_latency
```
### 有 Pipeline:批量发送
-```
-应用 Redis
- │ ───INCR key1 │
- │ ───EXPIRE key1 60 │ (一次 TCP 发送)
- │ ───INCR key2 │
- │ ───EXPIRE key2 60─────────▶ │ (RTT #1)
- │ ◀──[1, 1, 1, 1]─────────── │
- │ │
-总 RTT: 1 次,延迟 = 1 × network_latency
+```mermaid
+sequenceDiagram
+ participant App as 应用
+ participant R as Redis
+
+ App->>+R: INCR key1
EXPIRE key1 60
INCR key2
EXPIRE key2 60
+ Note over App,R: 一次 TCP 发送 (Pipeline 打包)
+ R-->>-App: [1, 1, 1, 1]
+
+ Note over App: 总 RTT: 1 次
延迟 = 1 × network_latency
```
**性能提升:** 如果网络 RTT 是 1ms,4 条命令从 4ms 降到 1ms——**节省了 75% 的网络延迟**。
@@ -55,89 +64,110 @@ Redis 的每次命令执行都有网络往返(RTT)开销。对于需要执
|------|----------|------------|
| **原子性** | ❌ 每条命令独立执行 | ✅ EXEC 时整体执行 |
| **取消支持** | ❌ 不能中途取消 | ✅ DISCARD 取消 |
-| **事务回滚** | ❌ 单条失败不影响其他 | ⚠️ EXEC 失败则全部不执行 |
+| **事务回滚** | ❌ 单条失败不影响其他 | ⚠️ 编译错误时不执行,运行时错误照常返回 |
| **嵌套管道** | ❌ 不能在事务内用 Pipeline | ❌ 不支持嵌套 |
| **Watch 支持** | ❌ 无 | ✅ 配合 WATCH 实现乐观锁 |
### 关键区别:原子性
+> [!warning]- Redis 事务的"坑"
+>
+> Redis 的 MULTI/EXEC **不提供回滚语义**!如果某条命令在 EXEC 时因为类型错误等运行时异常失败,其他命令仍会执行。它只保证 EXEC 后的命令不被其他客户端打断——也就是**串行化**,而非传统数据库的 ACID 事务。
+
```go
// Pipeline:每条命令独立执行,前一条成功后面失败也照常返回
pipe := rdb.Pipeline()
-pipe.Incr(ctx, "key1") // 成功
-pipe.Expire(ctx, "key1", 60) // 即使这步出错,Incr 结果仍会返回
+pipe.Incr(ctx, "key1") // 成功写入
+pipe.Expire(ctx, "key1", 60) // 即使这步出错,Incr 结果仍会返回
results, _ := pipe.Exec(ctx)
-// MULTI/EXEC:EXEC 时所有命令作为一个整体执行
-pipe2 := rdb.TxPipeline() // TxPipeline = MULTI/EXEC 包装
+// TxPipeline:MULTI/EXEC 包装,命令串行执行(不被其他客户端插入)
+// 注意:不是真正的原子回滚!
+pipe2 := rdb.TxPipeline() // = MULTI ... EXEC 包装
pipe2.Incr(ctx, "key1")
pipe2.Expire(ctx, "key1", 60)
results2, _ := pipe2.Exec(ctx)
```
-> [!tip]- Go-Redis 的 API 区分
+### 如何选择:Pipeline 还是 TxPipeline?
+
+> [!tip]- Go-Redis 的 API 选择指南
>
> | API | 对应行为 |
> |-----|---------|
-> | `rdb.Pipeline()` | 普通 Pipeline(非原子批量) |
-> | `rdb.TxPipeline()` | MULTI/EXEC 事务包装(原子批量) |
+> | `rdb.Pipeline()` | 普通 Pipeline(非原子批量,仅合并发送) |
+> | `rdb.TxPipeline()` | MULTI/EXEC 事务包装(命令串行执行) |
>
-> **生产环境推荐用 `TxPipeline()`**,因为它保证了操作的原子性。
+> | 场景 | 选择 |
+> |------|------|
+> | 只需要减少 RTT,每条命令独立执行 | `rdb.Pipeline()` |
+> | 需要多条命令作为一个整体串行执行 | `rdb.TxPipeline()` |
+> | 需要乐观锁(WATCH + CHECK) | `rdb.TxPipeline()` |
+>
+> **限流场景**中,每个命令通常操作不同的 Key、各自独立判断——用 `Pipeline()` 即可。
+> **数据一致性要求高**(如统计计数 + 过期设置绑定)——用 `TxPipeline()`。
## 三、在限流中的应用
### 3.1 多级限流的 Pipeline 优化
+原始方式三次独立 Redis 调用,每次都要经历完整的网络往返。Pipeline 可以将其压缩为两次往返(L1 单独 + L2/L3 合并)。
+
```go
-// 原始方式:三次独立的 Redis 调用
+// 原始方式:三次独立的 Redis 调用 → 3× RTT
func multiLevelLimitSlow(ctx context.Context, rdb *redis.Client, ip, userID, endpoint string) error {
if err := checkIPLimit(ctx, rdb, ip); err != nil {
- return err // L1 拦截
+ return err // L1 IP 拦截
}
if err := checkUserLimit(ctx, rdb, userID); err != nil {
- return err // L2 拦截
+ return err // L2 用户级拦截
}
if err := checkTokenBucket(ctx, rdb, endpoint); err != nil {
- return err // L3 拦截
+ return err // L3 TokenBucket 拦截
}
return nil
}
-// Pipeline 优化:L2 + L3 合并为一个批次
+// Pipeline 优化:L2 + L3 合并为一个批次 → 2× RTT
func multiLevelLimitFast(ctx context.Context, rdb *redis.Client, ip, userID, endpoint string) error {
- // L1 单独调用(不可合并到同一个 Key space)
+ // L1 单独调用(不可合并到同一个 Key space,需要串行拦截优先级)
if err := checkIPLimit(ctx, rdb, ip); err != nil {
return err
}
-
+
// L2 + L3 用 Pipeline 打包
- pipe := rdb.TxPipeline()
-
+ pipe := rdb.Pipeline() // 独立判断,不需要事务语义
+
+ // slidingWindowLua 参数说明:KEYS[1]=rate:user:{userID}, ARGV[1]=maxCount(86400), ARGV[2]=limit(1000)
userResult := pipe.Eval(ctx, slidingWindowLua, []string{fmt.Sprintf("rate:user:{%s}", userID)}, 86400, 1000)
+
+ // tokenBucketLua 参数说明:KEYS[1]=rate:tokenbucket:{endpoint}, ARGV[1]=capacity(100), ARGV[2]=refillRate(10), ARGV[3]=tokens(1), ARGV[4]=timestamp(ms)
tokenResult := pipe.Eval(ctx, tokenBucketLua, []string{fmt.Sprintf("rate:tokenbucket:{%s}", endpoint)}, 100, 10, 1, time.Now().UnixMilli())
-
+
_, err := pipe.Exec(ctx)
if err != nil {
return err
}
-
- // 检查结果
+
+ // 依次检查 L2 / L3 结果
userCount, _ := userResult.Int()
if userCount >= 1000 {
return ErrRateLimited
}
-
- tokenResult, _ := tokenResult.IntSlice()
- if tokenResult[0] == 0 {
+
+ tkArr, _ := tokenResult.IntSlice()
+ if tkArr[0] == 0 {
return ErrRateLimited
}
-
+
return nil
}
```
### 3.2 ZAdd + EXPIRE 的 Pipeline 优化
+当滑动窗口算法不使用 Lua 脚本时(牺牲部分原子性换取代码简单),可以用 Pipeline 把"清理旧数据 → 判断数量 → 写入新数据 → 设置过期"分批执行。
+
```go
// 不用 Lua 时的简化写法(牺牲部分原子性换取简单)
func SlidingWindowPipeline(ctx context.Context, rdb *redis.Client, id string, windowSec int, maxLen int64) error {
@@ -146,20 +176,22 @@ func SlidingWindowPipeline(ctx context.Context, rdb *redis.Client, id string, wi
cutoff := now - int64(windowSec)*1000
member := ulid.Now().String()
- pipe := rdb.TxPipeline()
+ // 第一批次:清理过期记录 + 读取当前数量
+ pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(cutoff, 10))
pipe.ZCard(ctx, key)
results, err := pipe.Exec(ctx)
if err != nil {
return err
}
-
+
currentCount := int(results[1].(*redis.IntCmd).Val())
if currentCount >= int(maxLen) {
return ErrRateLimited
}
-
- pipe2 := rdb.TxPipeline()
+
+ // 第二批次:写入新记录 + 设置 Key 过期时间
+ pipe2 := rdb.Pipeline()
pipe2.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: member})
pipe2.Expire(ctx, key, time.Duration(windowSec)*time.Second)
_, err = pipe2.Exec(ctx)
@@ -167,35 +199,35 @@ func SlidingWindowPipeline(ctx context.Context, rdb *redis.Client, id string, wi
}
```
+> [!question]- 这里有两批 Pipeline,能合并成一批吗?
+>
+> 想一想:`ZCard` 返回的数量决定了是否允许 `ZAdd`——这是一个**读 → 判断 → 写**的模式。Pipeline 无法根据第一条命令的结果决定是否执行第二条,所以必须分成两批。这种情况应该考虑什么方案?
+
## 四、Pipeline 的注意事项
-### 4.1 注意事项汇总
+### 4.1 常见陷阱
```mermaid
-flowchart TD
- subgraph "⚠️ Pipeline 陷阱"
- T1["不能用 Pipeline 做条件逻辑
无法根据 A 的结果决定 B"] --> T2["需要判断分支时用 Lua 脚本"]
-
- T3["大批量发送可能超过 Redis
maxpacket 限制"] --> T4["控制单次 Pipeline 的命令数
建议 50~200 条"]
-
- T5["流水线中的错误不会中断后续命令"] --> T6["需要在客户端检查每个命令的返回"]
-
- T7["Cluster 模式下多 Key
必须在同一 slot"] --> T8["用 Hash Tag {} 保证同 slot"]
- end
-
- style T2 fill:#e3f2fd
- style T4 fill:#fff3e0
- style T6 fill:#fff3e0
- style T8 fill:#e3f2fd
+flowchart LR
+ A["不能用 Pipeline
做条件逻辑"] --> B["读 → 判断 → 写
需要 Lua 脚本"]
+ C["大批量可能超过
Redis 内部限制"] --> D["控制单次 50~200 条"]
+ E["错误不会中断
后续命令"] --> F["客户端逐个检查结果"]
+ G["Cluster 多 Key
必须在同一 slot"] --> H["用 Hash Tag {} 同槽"]
+
+ classDef warn fill:#fff3e0
+ classDef info fill:#e3f2fd
+ class B,H info
+ class D,F warn
```
### 4.2 最佳实践
| 场景 | 推荐方式 | 原因 |
|------|---------|------|
-| 多条独立写入 | Pipeline (`TxPipeline`) | 减少 RTT,保持简单 |
-| 读 → 判断 → 写 | Lua 脚本 | 需要原子性和业务逻辑 |
-| 批量删除大 Key | UNLINK (逐个) | DEL 在大 Key 时会阻塞 |
+| 多条独立写入(如批量 SET) | `Pipeline()` | 只需减少 RTT,无需事务 |
+| 需要原子顺序执行(如 INCR + EXPIRE 绑定) | `TxPipeline()` | 避免被其他客户端插入 |
+| 读 → 判断 → 写 | Lua 脚本 | Pipeline 无法做条件分支 |
+| 批量删除大 Key | UNLINK(逐个) | DEL 在大 Key 时会阻塞主线程 |
| 统计类聚合操作 | Lua 中遍历 | 避免多次往返的数据不一致 |
## 五、性能基准参考
@@ -212,8 +244,13 @@ flowchart TD
>
> 对于限流这种高频场景(每秒数千到数万请求),即使是 1ms 的额外 RTT 也可能成为瓶颈。Pipeline 的价值在于将 N 次 RTT 压缩为 1 次。
+> [!question]- Pipeline 是银弹吗?
+>
+> Pipeline 虽然减少了网络延迟,但它不能解决所有问题——无法在流水线中做条件分支、大批量可能触达 Redis 内部限制。什么时候该用 Pipeline、什么时候该换 Lua 脚本?答案就在上一节和下一节的对比中。
+
## 关联笔记
- [[分布式限流]] — 性能优化清单中的 Pipeline 章节
- [[Go-Redis Lua 调用指南]] — 与 EvalSHA 配合使用的组合策略
- [[SCAN命令]] — SCAN 遍历时可以结合 Pipeline 提高批量处理效率
+- [[MULTI/EXEC]] — Redis 事务详解
diff --git a/hzh/REDIS/Sorted Set 滑动窗口.md b/hzh/REDIS/Sorted Set 滑动窗口.md
index 2f97623..be9dcff 100644
--- a/hzh/REDIS/Sorted Set 滑动窗口.md
+++ b/hzh/REDIS/Sorted Set 滑动窗口.md
@@ -134,7 +134,7 @@ count < maxLen → 放行,插入新记录
flowchart LR
A[请求到达] --> B[ZREMRANGEBYSCORE
删过期]
B --> C[ZCARD
数一数]
- C --> D{>= max?}
+ C --> D{超过max?}
D -- 否 --> E[ZADD 插入
EXPIRE 设过期]
D -- 是 --> F[返回 429 拒绝]
diff --git a/hzh/REDIS/内存持续增长排查.md b/hzh/REDIS/内存持续增长排查.md
index 45cd10f..9affe9c 100644
--- a/hzh/REDIS/内存持续增长排查.md
+++ b/hzh/REDIS/内存持续增长排查.md
@@ -49,6 +49,7 @@ INFO keyspace
```
输出解析:
+
| 字段 | 含义 |
|------|------|
| `keys` | 该数据库中 Key 的总数 |
diff --git a/hzh/REDIS/滑动窗口计数器.md b/hzh/REDIS/滑动窗口计数器.md
index af6906a..9be6538 100644
--- a/hzh/REDIS/滑动窗口计数器.md
+++ b/hzh/REDIS/滑动窗口计数器.md
@@ -16,8 +16,8 @@ create time: 2026-06-01 10:30
假设总窗口是 1 秒,切成 N=10 个子窗口(每个 100ms):
```mermaid
-flowchart TD["滑动窗口计数器 - N=10 子窗口"]
- subgraph Window["1 秒总窗口"]
+flowchart TD
+ subgraph Window["1 秒总窗口(N=10 子窗口)"]
direction LR
W1["W1
已过期的部分 × 占比"]
W2["W2
已过期的部分 × 占比"]
@@ -62,11 +62,11 @@ flowchart TD["滑动窗口计数器 - N=10 子窗口"]
滑动窗口范围:t ∈ [7.5 - 10, 7.5] = [-2.5, 7.5]
各个窗口在当前窗口内的存活时间:
- W8 (7~8s) : 存活 0.5s → 占比 5%/10s = 0.05
- W7 (6~7s) : 存活 1.0s → 占比 1.0s/10s = 0.10
- W6 (5~6s) : 存活 1.0s → 占比 0.10
+ W8 (7~8s) : 存活 0.5s → 占比 0.5/10 = 0.05
+ W7 (6~7s) : 存活 1.0s → 占比 1.0/10 = 0.10
+ W6 (5~6s) : 存活 1.0s → 占比 1.0/10 = 0.10
...(中间同理)...
- W-1(-2~-1s): 存活 0.5s → 占比 0.05
+ W₋₁(-2~-1s): 存活 0.5s → 占比 0.5/10 = 0.05
```
在这个场景中,`total = W8.count × 0.05 + W7.count × 0.10 + ... + W-1.count × 0.05`
@@ -118,7 +118,7 @@ slot[i] 的贡献度 = count[i] × weight[i]
举例(N=10,当前在第 8 个槽):
- slot[7](上一个完整窗口): weight = (10-1)/10 = 0.9
- slot[6]: weight = (10-2)/10 = 0.8
-- slot[0](最早的那个): weight = (10-8)/10 = 0.2
+- slot[0](距当前最远的那个): weight = (10-8)/10 = 0.2
> [!note]- 两种实现的等价性
>