diff --git a/hzh/REDIS/EVALSHA预加载.md b/hzh/REDIS/EVALSHA预加载.md new file mode 100644 index 0000000..eeef76b --- /dev/null +++ b/hzh/REDIS/EVALSHA预加载.md @@ -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 在限流架构中的实际位置 diff --git a/hzh/REDIS/Go-Redis Lua 调用指南.md b/hzh/REDIS/Go-Redis Lua 调用指南.md new file mode 100644 index 0000000..3e2eed1 --- /dev/null +++ b/hzh/REDIS/Go-Redis Lua 调用指南.md @@ -0,0 +1,233 @@ +--- +tags: [redis, lua, go-redis, evalsha, script-load] +create time: 2026-06-01 10:30 +--- + +# Go-Redis Lua 脚本调用完整指南 + +## 概述 + +在生产环境中用 Redis + Lua 做限流,最核心的技能不是写 Lua 脚本本身——而是**如何在 Go 代码中优雅地管理、加载和执行这些脚本**。本节讲解 Go-Redis 库的调用方式、预加载流程和常见陷阱。 + +对应你的薄弱点:Q12(Lua 在 Go-Redis 中的调用)、Q14(EVALSHA 预加载)。 + +## 一、三种调用方式对比 + +### 1.1 Eval:发送脚本正文 + +```go +const script = ` +local key = KEYS[1] +return redis.call('INCR', key) +` + +// 每次请求都传输完整的脚本字符串 +result, err := client.Eval(ctx, script, []string{"mykey"}, 100).Int() +``` + +**问题:** 每次都要把脚本文本通过网络传给 Redis,带宽浪费严重。 + +### 1.2 EvalSHA:只传 SHA1 指纹 + +```go +// 脚本 SHA1: b78d89f7f7654... +result, err := client.EvalSHA(ctx, "b78d89f7f7654...", []string{"mykey"}, 100).Int() +``` + +**优势:** 只传 40 字节指纹,大幅节省带宽和网络延迟。 + +### 1.3 EVAL vs EVALSHA 选择流程 + +```mermaid +flowchart LR + 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 + + style A fill:#e1f5fe + style C fill:#fff3e0 + style G fill:#c8e6c9 + style H fill:#ffebee +``` + +## 二、Go-Redis 最佳实践 + +### 2.1 预加载 + 容错回退 + +```go +package ratelimit + +import ( + "context" + "fmt" + "github.com/redis/go-redis/v9" +) + +var ( + // 生产环境推荐:程序启动时统一预加载 + tokenBucketSHA string + + tokenBucketLua = ` +local tokensKey = KEYS[1] +local capacity = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local requested = tonumber(ARGV[3]) +local now = tonumber(ARGV[4]) + +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 +tokens = math.min(capacity, tokens + elapsed * rate) + +local allowed = 0 +local remaining = tokens +if tokens >= requested then + tokens = tokens - requested + allowed = 1 + remaining = tokens +end + +redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now) +redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2) + +return {allowed, math.floor(remaining * 1000 / rate)} +` +) + +// InitSHA 在应用启动时调用,注册所有 Lua 脚本 +func InitSHA(ctx context.Context, rdb *redis.Client) error { + var err error + + // 预加载令牌桶脚本 + 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 调用 +func TokenBucketAllow(ctx context.Context, rdb *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) { + key := fmt.Sprintf("rate:tokenbucket:{%s}", id) + now := time.Now().UnixMilli() + + result, err := rdb.EvalSHA(ctx, tokenBucketSHA, []string{key}, capacity, rate, 1, now).IntSlice() + if err != nil { + // NOSCRIPT 错误:缓存丢失,尝试用 EVAL 回退 + if err == redis.Nil || isNoScriptError(err) { + // 重新加载 + newSHA, loadErr := rdb.ScriptLoad(ctx, tokenBucketLua).Result() + if loadErr != nil { + return false, 0, loadErr + } + tokenBucketSHA = newSHA + + result, err = rdb.EvalSHA(ctx, newSHA, []string{key}, capacity, rate, 1, now).IntSlice() + if err != nil { + return false, 0, err + } + } else { + return false, 0, err + } + } + + allowed := result[0] == 1 + waitMs := result[1] + return allowed, waitMs, nil +} + +func isNoScriptError(err error) bool { + return err != nil && strings.Contains(err.Error(), "NOSCRIPT") +} +``` + +### 2.2 更简单的写法:用 Eval 自动处理 NOSCRIPT + +如果你不想自己处理回退逻辑,可以直接用 `Eval()`——它会在收到 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() + + result, err := rdb.Eval(ctx, tokenBucketLua, []string{key}, capacity, rate, 1, now).IntSlice() + if err != nil { + return false, 0, err + } + return result[0] == 1, result[1], nil +} +``` + +> [!tip]- 哪种方式更好? +> +> | 方式 | 优点 | 缺点 | +> |------|------|------| +> | **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 +``` + +对于高频调用的限流场景,这个优化非常可观。 + +## 四、常见问题排查 + +| 症状 | 原因 | 解决 | +|------|------|------| +| `NOSCRIPT` 报错 | Redis 重启后缓存清空 | 添加回退逻辑或使用 Eval 自动重试 | +| `BUSYERR` 超时 | 脚本执行超过 5 秒 | 优化脚本逻辑,避免大 Key 操作 | +| `CROSSKEYS` 错误 | Lua 中多个 Key 不在同一 slot | 使用 Hash Tag `{}` 包裹路由键 | +| 返回值解析失败 | Lua 返回类型与 IntSlice()/StringSlice() 不匹配 | 检查 Redis 协议映射表 | + +## 五、Redis 协议类型映射速查 + +| Lua 返回值 | Redis 协议 | Go-Redis 接收方法 | +|-----------|-----------|------------------| +| `integer` | Bulk String `"42"` | `.Int()` | +| `"string"` | Bulk String | `.String()` | +| `nil` | Null Bulk String | `redis.Nil` | +| `table{1, 2}` | Array | `.IntSlice()` / `.StringsSlice()` | +| `true/false` | Integer `1/0` | `.Int()` | + +## 关联笔记 + +- [[分布式限流]] — Lua 脚本在各限流算法中的实际应用 +- [[EVALSHA预加载]] — 更深入的性能分析和监控指标 +- [[Lua脚本]] — Redis Lua 基础概念 diff --git a/hzh/REDIS/Jitter抖动与重试策略.md b/hzh/REDIS/Jitter抖动与重试策略.md new file mode 100644 index 0000000..628fe8d --- /dev/null +++ b/hzh/REDIS/Jitter抖动与重试策略.md @@ -0,0 +1,180 @@ +--- +tags: [http, retry, jitter, backoff, distributed-system] +create time: 2026-06-01 10:30 +--- + +# Jitter 抖动与重试策略 + +## 概述 + +当客户端收到 429 响应后,简单地重试是不够的——如果所有客户端在同一个时刻同时重试,会造成"惊群效应"(thundering herd),让刚恢复的服务再次被打垮。**Jitter(随机抖动)** 是让分布式系统优雅退避的关键技术。 + +对应薄弱点 F4:指数退避 + Jitter 的组合使用。 + +## 一、为什么需要 Jitter? + +### 问题场景 + +假设你的 API 限流阈值是 100 QPS,有 100 个客户端共享这个配额: + +``` +t = 0s: 所有客户端发现超限 → 等待 5 秒 (Retry-After: 5) +t = 5s: 100 个客户端同时重试 → 瞬间又达到 100+ QPS → 再次被限流 +t = 10s: 再次全部超时... +``` + +这就是**惊群效应**——大量客户端在同一时刻涌入,效果相当于人为制造了一次流量脉冲。 + +### 解决方案 + +```mermaid +flowchart TD + subgraph "无 Jitter(糟糕)" + C1["Client 1"] -->|wait 5s| S1["t=5s
⚡ 100 clients fire at once"] + C2["Client 2"] -->|wait 5s| S1 + C3["Client N"] -->|wait 5s| S1 + S1 --> FAIL["🔴 Server overwhelmed again"] + 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"] + D4 --> OK["🟢 Server handles gracefully"] + D5 --> OK + D6 --> OK + end + + style FAIL fill:#ffebee + style OK fill:#e8f5e9 +``` + +## 二、指数退避 + Jitter 公式 + +### 2.1 基础公式 + +```go +// 第 attempt 次重试的等待时间(简单加减 Jitter 实现) +// wait = base × 2^attempt ± random(0, base),上限不超过 Retry-After +wait := min(base * math.Pow(2, float64(attempt)) + (-jitter ~ +jitter), retryAfter) +``` + +分解说明: + +| 部分 | 作用 | 示例值 | +|------|------|--------| +| `base` | 基础等待时间 | 1 秒 | +| `2^attempt` | 指数增长因子 | attempt=0→1x, 1→2x, 2→4x, 3→8x | +| `randomJitter` | 随机抖动,防止同步 | ±[0, base] 秒 | +| `retryAfter` | 服务端上限,不违反约定 | Retry-After 头部的值 | + +### 2.2 具体数字示例 + +假设 `base = 1s`,`Retry-After = 10s`: + +| 重试次数 | 指数退避 | +Jitter(±1s) | 实际等待 | capped to Retry-After | +|---------|---------|-------------|---------|---------------------| +| #1 | 1 × 2⁰ = 1s | 1s ± 1s | [0, 2s] | min([0,2], 10) = [0, 2] | +| #2 | 1 × 2¹ = 2s | 2s ± 1s | [1, 3s] | min([1,3], 10) = [1, 3] | +| #3 | 1 × 2² = 4s | 4s ± 1s | [3, 5s] | min([3,5], 10) = [3, 5] | +| #4 | 1 × 2³ = 8s | 8s ± 1s | [7, 9s] | min([7,9], 10) = [7, 9] | +| #5 | 1 × 2⁴ = 16s | — | — | min(16, 10) = **10s** ← 不再增长 | + +> [!tip]- 为什么要取 min(..., Retry-After)? +> +> `Retry-After` 是服务器给出的硬性上限,客户端不应超过它。但如果指数退避还没到 Retry-After 的时间,也应该遵循服务器的建议等待。 + +## 三、Go 实现 + +```go +import ( + "math/rand" + "time" +) + +// calculateRetryDelay 计算带 Jitter 的指数退避等待时间 +func calculateRetryDelay(attempt int, retryAfterSec int64) time.Duration { + // 1. 指数退避 + base := time.Second + wait := base * time.Duration(math.Pow(2, float64(attempt))) + + // 2. 添加 Jitter:±[0, base] 的随机偏移 + jitter := time.Duration(rand.Int63n(int64(base))) + if rand.Intn(2) == 0 { + jitter = -jitter // 50% 概率减,50% 加 + } + wait = wait + jitter + + // 3. 不超过 Retry-After + if retryAfterSec > 0 { + maxWait := time.Duration(retryAfterSec) * time.Second + if wait > maxWait { + wait = maxWait + } + } + + return wait +} + +// 使用示例 +func callWithRetry(ctx context.Context, maxRetries int, callFunc func() error) error { + for attempt := 0; attempt <= maxRetries; attempt++ { + err := callFunc() + if err == nil { + return nil + } + + // 检查是否是 429 + if isRateLimited(err) { + retryAfter := getRetryAfterHeader() // 从响应头读取 + wait := calculateRetryDelay(attempt, retryAfter) + + select { + case <-time.After(wait): + continue // 继续重试 + case <-ctx.Done(): + return ctx.Err() + } + } + + // 非限流错误,直接返回 + return err + } + return fmt.Errorf("max retries (%d) exceeded", maxRetries) +} +``` + +## 四、常见 Jitter 算法对比 + +| 算法 | 公式 | 优点 | 缺点 | +|------|------|------|------| +| **Full Jitter**(AWS 推荐) | `random(0, min(cap, base × 2^attempt))` | 分布均匀,去抖效果好 | 可能等待很短导致过早重试 | +| **Equal Jitter** | `min(cap, base × 2^attempt / 2 + random(0, base × 2^attempt / 2))` | 介于两者之间 | 略复杂 | +| **Decorrelated Jitter**(AWS 默认) | `min(cap, random(base, prev_wait × 3))` | 快速分散,适应性强 | 参数调优门槛高 | +| **简单加减 Jitter** | `base × 2^attempt ± random(0, base)` | 实现最简单 | 抖动范围小 | + +> [!tip]- 推荐方案 +> +> 对于限流场景,**简单加减 Jitter**(上面 Go 代码的实现)已经足够。如果你的系统规模极大(数千客户端),建议用 Full Jitter(AWS 推荐)。 + +## 五、完整流程总结 + +```mermaid +flowchart TD + Req["请求发出"] --> Resp{"收到响应?"} + Resp -- "200" --> Success["✅ 成功"] + Resp -- "429" --> Read["读取 Retry-After 头部"] + Read --> Calc["计算 wait = 指数退避 + Jitter"] + Calc --> Wait["⏳ 等待"] + Wait --> Retry{"还有重试次数?"} + Retry -- "是" --> Req + Retry -- "否" --> Fail["❌ 最终失败,上报告警"] + + style Success fill:#c8e6c9 + style Fail fill:#ffebee +``` + +## 关联笔记 + +- [[分布式限流]] — 限流架构中的客户端重试策略 +- [[多级限流架构]] — 各层超时配置与降级策略设计 diff --git a/hzh/REDIS/Pipeline批量操作.md b/hzh/REDIS/Pipeline批量操作.md new file mode 100644 index 0000000..f5d9810 --- /dev/null +++ b/hzh/REDIS/Pipeline批量操作.md @@ -0,0 +1,219 @@ +--- +tags: [redis, pipeline, batch, performance, multi-exec] +create time: 2026-06-01 10:30 +--- + +# Pipeline 批量操作 + +## 概述 + +Redis 的每次命令执行都有网络往返(RTT)开销。对于需要执行多条命令的场景,**Pipeline** 可以把多个命令打包成一个批次发送,大幅降低 RTT 次数。这是限流性能优化的核心手段之一(F14 考点)。 + +## 一、为什么需要 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 +``` + +### 有 Pipeline:批量发送 + +``` +应用 Redis + │ ───INCR key1 │ + │ ───EXPIRE key1 60 │ (一次 TCP 发送) + │ ───INCR key2 │ + │ ───EXPIRE key2 60─────────▶ │ (RTT #1) + │ ◀──[1, 1, 1, 1]─────────── │ + │ │ +总 RTT: 1 次,延迟 = 1 × network_latency +``` + +**性能提升:** 如果网络 RTT 是 1ms,4 条命令从 4ms 降到 1ms——**节省了 75% 的网络延迟**。 + +## 二、Pipeline vs MULTI/EXEC + +这是最容易混淆的概念。填空题 F14 的答案是 MULTI/EXEC,但实际使用中有重要的区别。 + +### 对比表 + +| 维度 | Pipeline | MULTI/EXEC | +|------|----------|------------| +| **原子性** | ❌ 每条命令独立执行 | ✅ EXEC 时整体执行 | +| **取消支持** | ❌ 不能中途取消 | ✅ DISCARD 取消 | +| **事务回滚** | ❌ 单条失败不影响其他 | ⚠️ EXEC 失败则全部不执行 | +| **嵌套管道** | ❌ 不能在事务内用 Pipeline | ❌ 不支持嵌套 | +| **Watch 支持** | ❌ 无 | ✅ 配合 WATCH 实现乐观锁 | + +### 关键区别:原子性 + +```go +// Pipeline:每条命令独立执行,前一条成功后面失败也照常返回 +pipe := rdb.Pipeline() +pipe.Incr(ctx, "key1") // 成功 +pipe.Expire(ctx, "key1", 60) // 即使这步出错,Incr 结果仍会返回 +results, _ := pipe.Exec(ctx) + +// MULTI/EXEC:EXEC 时所有命令作为一个整体执行 +pipe2 := rdb.TxPipeline() // TxPipeline = MULTI/EXEC 包装 +pipe2.Incr(ctx, "key1") +pipe2.Expire(ctx, "key1", 60) +results2, _ := pipe2.Exec(ctx) +``` + +> [!tip]- Go-Redis 的 API 区分 +> +> | API | 对应行为 | +> |-----|---------| +> | `rdb.Pipeline()` | 普通 Pipeline(非原子批量) | +> | `rdb.TxPipeline()` | MULTI/EXEC 事务包装(原子批量) | +> +> **生产环境推荐用 `TxPipeline()`**,因为它保证了操作的原子性。 + +## 三、在限流中的应用 + +### 3.1 多级限流的 Pipeline 优化 + +```go +// 原始方式:三次独立的 Redis 调用 +func multiLevelLimitSlow(ctx context.Context, rdb *redis.Client, ip, userID, endpoint string) error { + if err := checkIPLimit(ctx, rdb, ip); err != nil { + return err // L1 拦截 + } + if err := checkUserLimit(ctx, rdb, userID); err != nil { + return err // L2 拦截 + } + if err := checkTokenBucket(ctx, rdb, endpoint); err != nil { + return err // L3 拦截 + } + return nil +} + +// Pipeline 优化:L2 + L3 合并为一个批次 +func multiLevelLimitFast(ctx context.Context, rdb *redis.Client, ip, userID, endpoint string) error { + // L1 单独调用(不可合并到同一个 Key space) + if err := checkIPLimit(ctx, rdb, ip); err != nil { + return err + } + + // L2 + L3 用 Pipeline 打包 + pipe := rdb.TxPipeline() + + userResult := pipe.Eval(ctx, slidingWindowLua, []string{fmt.Sprintf("rate:user:{%s}", userID)}, 86400, 1000) + 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 + } + + // 检查结果 + userCount, _ := userResult.Int() + if userCount >= 1000 { + return ErrRateLimited + } + + tokenResult, _ := tokenResult.IntSlice() + if tokenResult[0] == 0 { + return ErrRateLimited + } + + return nil +} +``` + +### 3.2 ZAdd + EXPIRE 的 Pipeline 优化 + +```go +// 不用 Lua 时的简化写法(牺牲部分原子性换取简单) +func SlidingWindowPipeline(ctx context.Context, rdb *redis.Client, id string, windowSec int, maxLen int64) error { + key := fmt.Sprintf("rate:sliding:%s", id) + now := time.Now().UnixMilli() + cutoff := now - int64(windowSec)*1000 + member := ulid.Now().String() + + pipe := rdb.TxPipeline() + 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() + pipe2.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: member}) + pipe2.Expire(ctx, key, time.Duration(windowSec)*time.Second) + _, err = pipe2.Exec(ctx) + return err +} +``` + +## 四、Pipeline 的注意事项 + +### 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 +``` + +### 4.2 最佳实践 + +| 场景 | 推荐方式 | 原因 | +|------|---------|------| +| 多条独立写入 | Pipeline (`TxPipeline`) | 减少 RTT,保持简单 | +| 读 → 判断 → 写 | Lua 脚本 | 需要原子性和业务逻辑 | +| 批量删除大 Key | UNLINK (逐个) | DEL 在大 Key 时会阻塞 | +| 统计类聚合操作 | Lua 中遍历 | 避免多次往返的数据不一致 | + +## 五、性能基准参考 + +假设网络 RTT = 1ms,本地部署(RTT ≈ 0.1ms): + +| 命令数 | 无 Pipeline (RTT×N) | Pipeline (1 RTT) | 加速比 | +|--------|-------------------|-----------------|-------| +| 10 条 | 10ms / 1ms | 1ms / 0.1ms | **10x** | +| 100 条 | 100ms / 10ms | 1ms / 0.1ms | **100x** | +| 1000 条 | 1000ms / 100ms | 1ms / 0.1ms | **1000x** | + +> [!note]- 实际应用 +> +> 对于限流这种高频场景(每秒数千到数万请求),即使是 1ms 的额外 RTT 也可能成为瓶颈。Pipeline 的价值在于将 N 次 RTT 压缩为 1 次。 + +## 关联笔记 + +- [[分布式限流]] — 性能优化清单中的 Pipeline 章节 +- [[Go-Redis Lua 调用指南]] — 与 EvalSHA 配合使用的组合策略 +- [[SCAN命令]] — SCAN 遍历时可以结合 Pipeline 提高批量处理效率 diff --git a/hzh/REDIS/SCAN命令.md b/hzh/REDIS/SCAN命令.md new file mode 100644 index 0000000..a1d94e1 --- /dev/null +++ b/hzh/REDIS/SCAN命令.md @@ -0,0 +1,240 @@ +--- +tags: [redis, scan, keys, traversal, performance] +create time: 2026-06-01 10:30 +--- + +# Redis SCAN 遍历命令 + +## 概述 + +在生产环境中,你经常需要找到所有符合某个模式的 Key——比如找出所有 `rate:limit:*` 做清理或统计。直觉上会用 `KEYS *pattern*`,但**大库中使用 KEYS 会导致服务雪崩**。`SCAN` 是唯一的正确选择。 + +这对应你的薄弱点 Q15:为什么用 SCAN 替代 KEYS。 + +## 一、KEYS vs SCAN:核心对比 + +### KEYS:简单但危险的阻塞操作 + +```bash +# 一行搞定,但会阻塞整个 Redis +KEYS rate:limit:* +→ user1 +→ user2 +→ user3 +... +``` + +**问题:** KEYS 是一次性全量扫描——它会在单个操作中遍历所有 Key,然后一次性全部返回。在这期间: + +```mermaid +flowchart LR + A["KEYS *pattern* 开始"] --> B["阻塞 Redis 主线程
遍历所有数据库"] + B --> C["如果库中有 1000 万个 Key?
可能耗时数秒到数十秒"] + C --> D["所有其他客户端被阻塞
超时/连接堆积"] + D --> E["生产事故:服务不可用"] + + style A fill:#fff3e0 + style D fill:#ffebee + style E fill:#b71c1c,color:#fff +``` + +> [!warning]- KEYS 的三个致命问题 +> +> 1. **阻塞主线程**:Redis 单线程,KEYS 执行期间所有其他请求都无法处理 +> 2. **内存峰值**:结果一次性返回,匹配大量 Key 时可能 OOM +> 3. **无法增量迭代**:一次返回所有结果,中途无法停止 + +### SCAN:分步游标,永不阻塞 + +```bash +# 第一次调用,cursor = 0 +SCAN 0 MATCH rate:limit:* COUNT 100 +→ 1) "12345" ← 下一个 cursor +→ 2) 1) "user1" + 2) "user2" + ... + +# 下次用返回的 cursor 继续 +SCAN 12345 MATCH rate:limit:* COUNT 100 +→ 1) "67890" +→ 2) 1) "user3" + ... + +# 当 cursor 返回 "0" 时,遍历完成 +``` + +**优势:** +- 每次只返回少量结果(由 COUNT 控制) +- 不会阻塞 Redis 主线程 +- 可以安全地在生产环境使用 + +## 二、SCAN 参数详解 + +```bash +SCAN cursor [MATCH pattern] [COUNT count] [TYPE type] +``` + +| 参数 | 含义 | 示例 | +|------|------|------| +| `cursor` | 游标,0 表示开始,返回 0 表示结束 | `0`, `12345` | +| `MATCH` | 模式过滤(支持 `* ? []` 通配符) | `rate:limit:*` | +| `COUNT` | 每次迭代的元素数量(默认 10) | `COUNT 100` | +| `TYPE` | 按数据类型过滤(Redis 4.0+) | `TYPE string` | + +### COUNT 参数的误区 + +`COUNT` **不是精确返回数量**,而是告诉 Redis "这一轮大概应该返回多少条"。实际返回数量可能多也可能少。 + +``` +COUNT 越大 → 每次返回越多,总迭代次数越少,但单次延迟略高 +COUNT 越小 → 每次返回越少,总迭代次数越多,但单次延迟极低 +``` + +**建议值:** 根据数据量和延迟要求调整,通常 100 ~ 500 是 sweet spot。 + +## 三、Go-Redis 中的使用 + +### 3.1 基础用法 + +```go +func scanAllKeys(ctx context.Context, rdb *redis.Client, pattern string) ([]string, error) { + var allKeys []string + + // Cursor 为 0 时会自动从第一轮开始 + cursor := uint64(0) + for { + // KeysByPattern 内部处理了游标迭代 + keys, nextCursor, err := rdb.Scan(ctx, cursor, pattern, 100).Result() + if err != nil { + return nil, err + } + + allKeys = append(allKeys, keys...) + + if nextCursor == 0 { + break // 遍历完成 + } + cursor = nextCursor + } + + return allKeys, nil +} +``` + +### 3.2 ⚠️ 快捷方法(不推荐用于大数据量) + +```go +// rdb.Keys() 底层调用的是 KEYS 命令——会阻塞 Redis! +keys, err := rdb.Keys(ctx, "rate:limit:*").Result() +``` + +> [!note]- Go-Redis 的坑 +> +> `rdb.Keys()` 虽然调用方便,但底层仍然是阻塞式的 `KEYS` 命令。**在大数据量场景下务必使用上面的手动 SCAN 循环**(见 3.1 节)。 + +### 3.3 带 TYPE 过滤的高效扫描 + +```go +// 只找 String 类型的限流 Key +cursor := uint64(0) +for { + iter := rdb.Scan(ctx, cursor, "rate:limit:*", 200) + keys, _ := iter.Result() + + for _, key := range keys { + t, _ := rdb.Type(ctx, key).Result() + if t.Err() != nil || t.Val() != "string" { + continue + } + // 处理这个限流 Key... + handleKey(key) + } + + if iter.Cursor() == 0 { + break + } +} +``` + +## 四、SCAN 的不一致性保证 + +这是 SCAN 最容易让人困惑的地方——**它不保证返回的结果是某一时刻的快照**。 + +```mermaid +flowchart TD + A["SCAN 开始遍历"] --> B["过程中有新 Key 插入"] + B --> C{"新 Key 会被返回吗?"} + C -- "不一定" --> D["可能被下一轮 SCAN 返回"] + C -- "也可能错过" --> E["本轮不会看到"] + + A --> F["过程中有 Key 被删除"] + F --> G{"已访问过的 Key 被删?"} + G -- "不会重复返回" --> H["正常,因为游标已越过"] + G -- "未访问到的 Key 被删?" --> I["自然跳过,不影响"] + + style C fill:#fff3e0 + style D fill:#e3f2fd + style E fill:#fff9c4 +``` + +**三个一致性保证:** + +| 保证 | 说明 | +|------|------| +| ✅ 不会遗漏 | 如果一个 Key 在整个 SCAN 过程中始终存在且没被删除,它至少被返回一次 | +| ⚠️ 可能重复 | 同一个 Key 可能在多次迭代中返回多次(去重即可) | +| ⚠️ 可能遗漏 | 如果在 SCAN 开始前就存在的 Key 中途被删除了,它可能被跳过 | + +> [!tip]- 实际影响 +> +> 对限流场景来说,SCAN 的一致性模型完全够用——你要做的通常是清理即将过期的 Key 或者统计当前有多少活跃 Key,不需要精确到某一毫秒的快照。只需要在代码中对返回的 Key 集合做 `set` 去重即可。 + +## 五、Lua 脚本中使用 SCAN + +Redis Lua 中也支持 `redis.call('SCAN', ...)`,但有重要限制: + +```lua +-- Lua 中可以使用 SCAN +local cursor = 0 +repeat + local result = redis.call('SCAN', cursor, 'MATCH', KEYS[1], 'COUNT', 100) + cursor = tonumber(result[1]) + local keys = result[2] + -- 处理 keys... +until cursor == 0 +``` + +> [!warning]- Lua + SCAN 的性能陷阱 +> +> Lua 脚本最长只能运行 5 秒。如果你在 Lua 里用 SCAN 遍历一个包含百万级 Key 的数据库,很可能超时。解决思路: +> - 减少 `COUNT` 参数 +> - 把 SCAN 放在 Lua 外部,用 Go 循环控制 +> - 使用 `KEYS` 替代仅在开发/小库环境中 + +## 六、排查场景应用 + +### 查找僵尸 Key + +```go +// 找出所有 rate:limit:* 开头的 Key 并检查 TTL +cursor := uint64(0) +for { + keys, next, _ := rdb.Scan(ctx, cursor, "rate:limit:*", 200).Result() + for _, key := range keys { + ttl, _ := rdb.TTL(ctx, key).Result() + if ttl < 0 { + log.Printf("zombie key found: %s (TTL=%d)", key, ttl) + } + } + if next == 0 { + break + } + cursor = next +} +``` + +## 关联笔记 + +- [[分布式限流]] — 限流架构中 SCAN 的实际应用场景 +- [[内存持续增长排查]] — SCAN 是排查内存问题的必备工具 +- [[僵尸Key防护]] — 利用 SCAN 发现和清理僵尸 Key diff --git a/hzh/REDIS/Sorted Set 滑动窗口.md b/hzh/REDIS/Sorted Set 滑动窗口.md new file mode 100644 index 0000000..510caff --- /dev/null +++ b/hzh/REDIS/Sorted Set 滑动窗口.md @@ -0,0 +1,195 @@ +--- +tags: [redis, sorted-set, sliding-window, lua-script] +create time: 2026-06-01 10:30 +--- + +# Sorted Set 实现滑动窗口详解 + +## 概述 + +**Sorted Set(有序集合)** 是 Redis 中最适合实现精确滑动窗口的数据结构。每条请求的时间戳作为 score,Redis 自动按分数排序,配合范围查询和清理就能实现精确的窗口统计。 + +这个方案你标记为 Q4 薄弱点——核心在于理解它的 **运作流程、时间计算方式和空间代价**。 + +## 一、基本思路 + +```mermaid +flowchart LR + subgraph "请求到达" + A["客户端构造请求"] --> B["生成唯一 member
uuid / requestId"] + end + + subgraph "Redis 操作(Lua 原子化)" + B --> C["ZREMRANGEBYSCORE 清理过期"] + C --> D["ZCARD 计数"] + D --> E{"count >= max?"} + E -- "否" --> F["ZADD 插入新记录"] + E -- "是" --> G["❌ 拒绝"] + F --> H["EXPIRE 设 TTL"] + G --> I["返回 429"] + end + + style C fill:#fff3e0 + style D fill:#fff3e0 + style F fill:#e8f5e9 + style G fill:#ffebee +``` + +## 二、关键参数与时间计算 🔑 + +这是你最薄弱的地方之一(对应填空题 F6)。 + +### 2.1 Lua 脚本结构 + +```go +const slidingWindowLua = ` +local key = KEYS[1] +local window = tonumber(ARGV[1]) -- 窗口大小,单位:秒 +local maxLen = tonumber(ARGV[2]) -- 最大允许的记录数 +local now = tonumber(ARGV[3]) -- 毫秒级 Unix 时间戳 +local member = ARGV[4] -- 唯一标识(UUID/RequestID) + +-- 步骤1:清理过期记录 +redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000) + +-- 步骤2:统计当前窗口内的数量 +local count = redis.call('ZCARD', key) + +-- 步骤3:判断并插入 +if count >= maxLen then + return count -- 超限,直接返回 +end + +-- 步骤4:放行,添加记录 +redis.call('ZADD', key, now, member) +redis.call('EXPIRE', key, window) + +return count + 1 +` +``` + +### 2.2 清理范围详解(F6 考点) + +```lua +redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000) + │ │ + │ └─ 窗口起始边界(毫秒) + └────────── 从负无穷到这个边界 +``` + +- **下界 `-inf`**:所有比它小的分数(更早的时间戳)都是过期的 +- **上界 `now - window * 1000`**:窗口开始的时间点 + - `now` 是毫秒级时间戳 + - `window` 的单位为秒 + - 乘以 1000 是为了统一单位到毫秒 + +**为什么用 `-inf` 而不是具体数值?** +因为 Sorted Set 中可能存着历史遗留数据,用 `-inf` 确保一次性清理干净。如果只从某个固定值开始,可能会漏掉更早的过期记录。 + +### 2.3 成员(member)的唯一性 + +每个请求必须生成一个全局唯一的 member,避免 score 相同时被覆盖: + +```go +import ( + "crypto/rand" + "fmt" +) + +func generateMember() string { + var b [8]byte + rand.Read(b[:]) + return fmt.Sprintf("%x", b[:]) +} +``` + +或者用更标准的 `ulid` / `uuid`: + +```go +import "github.com/oklog/ulid/v2" + +member := ulid.Now().String() // 单调递增,天然有序 +``` + +> [!tip]- 为什么不用序列号? +> +> 分布式环境下多实例并发,简单的自增序号可能在同一毫秒产生重复。UUIDv4 / ULID / RequestID 能保证全局唯一。 + +## 三、完整调用示例 + +```go +func SlidingWindowAllow(ctx context.Context, c *redis.Client, id string, windowSec int, maxRequests int) (int, error) { + key := fmt.Sprintf("rate:sliding:%s", id) + now := time.Now().UnixMilli() + member := generateMember() + + result, err := c.Eval(ctx, slidingWindowLua, []string{key}, windowSec, maxRequests, now, member).Int() + if err != nil { + return 0, err + } + return result, nil +} +``` + +## 四、空间复杂度分析(Q4 考点) + +这是 Q4 选择题考察的点:**在高并发场景下 Sorted Set 会成为瓶颈。** + +### 4.1 计算公式 + +``` +存储空间 ≈ maxQPS × window_seconds × 单条记录大小 +``` + +每条记录的内存开销: +- score(double):8 字节 +- member(字符串):约 26 字节(ULID) +- Sorted Set 节点开销:约 64 字节 +- **合计约 ~100 字节/条** + +### 4.2 实际数字对比 + +| 场景 | maxQPS | window(s) | 记录数 | 内存 | +|------|--------|-----------|--------|------| +| 日配额(低频) | 10 | 86400 | 864,000 | ~86 MB | +| API 接口(中频) | 100 | 60 | 6,000 | ~600 KB | +| 高频网关 | 1000 | 60 | **60,000** | ~6 MB | +| 超高并发 | 10000 | 60 | **600,000** | ~60 MB | + +> [!warning]- 什么时候不该用 Sorted Set? +> +> 如果你的 API 接口 QPS > 1000 且窗口 > 10s,Sorted Set 的方案就不太合适了。这时应该切换到: +> - **令牌桶/漏桶**:O(1) 空间,适合保护后端资源 +> - **滑动窗口计数器**:O(N) 空间,N 远小于记录数 + +### 4.3 时间复杂度 + +| 操作 | 复杂度 | 说明 | +|------|--------|------| +| ZADD | O(log N) | Sorted Set 基于跳表 | +| ZREMRANGEBYSCORE | O(log N + M) | N 是集合大小,M 是删除数 | +| ZCARD | O(1) | Redis 内部维护计数 | +| EXPIRE | O(1) | 设置过期时间 | + +高并发下每次请求都要做几次 O(log N) 操作,当 N 达到数万甚至数十万时,CPU 和延迟都会明显上升。 + +## 五、优化方向 + +```mermaid +flowchart TD + Problem["Sorted Set 太大导致性能问题"] --> Choice{"选择优化方向"} + + Choice -- "降低精度换取空间" --> SWC["切换为滑动窗口计数器
O(N) 子窗口,N << 记录数"] + Choice -- "保持精确但控制规模" --> REDUCE["减少窗口大小或 maxQPS
例如 60s → 10s"] + Choice -- "接受高频开销" --> KEEP["继续使用 Sorted Set
加监控告警"] + + style Problem fill:#ffebee + style SWC fill:#e3f2fd + style KEEP fill:#fff3e0 +``` + +## 关联笔记 + +- [[分布式限流]] — 完整的限流算法演进路线 +- [[Lua脚本]] — Lua 脚本中的 Sorted Set 操作 +- [[滑动窗口计数器]] — 空间效率更高的替代方案 diff --git a/hzh/REDIS/僵尸Key防护.md b/hzh/REDIS/僵尸Key防护.md new file mode 100644 index 0000000..1316928 --- /dev/null +++ b/hzh/REDIS/僵尸Key防护.md @@ -0,0 +1,177 @@ +--- +tags: [redis, memory, ttl, expiration, best-practices] +create time: 2026-06-01 10:30 +--- + +# 僵尸 Key 防护 + +## 概述 + +**僵尸 Key** 是指不再有新请求但仍长期占用 Redis 内存的 Key。它们通常是因为 `EXPIRE` 设置失败或遗漏导致的——虽然单个 Key 不大,但随着时间积累会消耗越来越多的内存,最终引发 OOM。 + +对应薄弱点 F13:令牌桶中 `EXPIRE` 的保护机制及其计算方法。 + +## 一、什么是僵尸 Key? + +```mermaid +flowchart TD + Day1["📅 第 1 天
用户 user123 访问了 API"] --> Create["创建 Key
rate:limit:user123:daily"] + Create --> EXPIRE["设置 EXPIRE
TTL = 86400s (1天)"] + + Day2["📅 第 2 天"] --> Active{"user123 还访问吗?"} + Active -- "是" --> Renew["正常续期 ✓"] + Active -- "否" --> Expire["✅ Key 自动过期删除 ✓"] + + Day3["📅 第 3 天"] --> NoExpire{"如果 EXPIRE 没生效?"} + NoExpire --> Zombie["❌ Key 仍然占用 ~500B
但已无实际用途"] + + zombieCount["Zombie Count 增长"] --> Accumulate["1000 users × 500B = 500KB
10000 users → 5MB
..."] + + style Expire fill:#c8e6c9 + style Zombie fill:#ffebee + style Accumulate fill:#ffcdd2 +``` + +### 典型场景 + +| 场景 | 原因 | 结果 | +|------|------|------| +| EXPIRE 在 Lua 脚本外面 | 原子性被破坏,EXPIRE 可能不执行 | Key 永久存在 | +| Lua 脚本中的 EXPIRE 报错 | 条件判断导致跳过 | Key 不过期 | +| 代码忘记写 EXPIRE | 开发疏忽 | Key 永不消失 | +| Redis 内存满导致无法写入 | OOM 后部分操作失败 | 新增 Key 没设置 TTL | + +## 二、F13 考点详解:令牌桶的 EXPIRE 保护机制 + +这是你最薄弱的地方之一。令牌桶的 Lua 脚本中有一行关键的 EXPIRE: + +```lua +-- 令牌桶 Lua 脚本(节选) +redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now) +redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2) +return {allowed, math.floor(remaining * 1000 / rate)} +``` + +### 2.1 为什么是 `math.ceil(capacity / rate) * 2`? + +这行公式的含义是:**填满整个桶所需的时间 × 2** + +| 参数 | 含义 | 示例值 | +|------|------|--------| +| `capacity` | 桶的最大容量(最大令牌数) | 100 | +| `rate` | 每秒补充的令牌数 | 10/s | +| `capacity / rate` | **从空到满需要的时间** | 100 / 10 = 10 秒 | +| `* 2` | **留一倍余量** | 20 秒 | +| `math.ceil(...)` | **向上取整**(Redis EXPIRE 需要整数秒) | 20 | + +**逻辑推导:** + +``` +如果一个用户在 t=0 时创建了限流 Key,之后再也不来了: + - 最晚的可能活动时间:t = capacity/rate(桶从空逐渐加满的时间) + - 但如果用户在 t=0 就用完了所有令牌,然后不再来... + - Key 实际上从最后一次活动就开始倒计时了 + +所以 EXPIRE 应该从"最后一次有活动的时刻"开始算。 +我们已经通过 HMSET 更新时间戳保证了这一点。 +但为什么要乘 2?因为: + 1. 用户可能在某个时间点刚好用完令牌 + 2. 然后又过了一段时间才再来 + 3. 这段时间内,桶已经在慢慢补充令牌了 + 4. ×2 给了足够的缓冲,确保即使是最长的空闲间隔也能覆盖 +``` + +### 2.2 数值演示 + +| capacity | rate | capacity/rate | ×2 后的 TTL | 说明 | +|----------|------|-------------|-----------|------| +| 100 | 10/s | 10s | 20s | 一般 API 限流 | +| 1000 | 100/s | 10s | 20s | 高频接口 | +| 10000 | 100/s | 100s | 200s | 高配额用户 | +| 100 | 1/s | 100s | 200s | 低频长周期 | + +> [!tip]- 这个公式的直觉理解 +> +> "一个桶要多久才能从空变成满?"— 答:capacity/rate。 +> +> 在这个时间内肯定能看到用户的活动迹象。如果没有活动,2 倍时间就足以让它安全过期。既不会太短(刚设完就过期),也不会太长(僵尸太久)。 + +## 三、排查与清理 + +### 3.1 查找僵尸 Key + +```bash +# 方法1: 检查特定前缀的 Key 是否没有 TTL +redis-cli --scan --pattern "rate:*" | while read key; do + ttl=$(redis-cli TTL "$key") + if [ "$ttl" = "-1" ]; then + echo "ZOMBIE: $key" + fi +done + +# 方法2: Go 程序批量扫描(见 SCAN 文档) +``` + +### 3.2 手动清理 + +```bash +# DEL 同步删除(小 Key) +DEL rate:limit:zombie_user_001 + +# UNLINK 异步删除(大 Key,不阻塞) +UNLINK rate:sliding:large_zombie_key +``` + +### 3.3 预防 Checklist + +```yaml +prevention_checklist: + lua_scripts: + - "所有涉及数据写入的 Lua 脚本,必须包含 EXPIRE" + - "EXPIRE 应该在 Lua 内部(保证原子性),不能放在外面" + - "使用合理的 TTL 计算公式: ceil(max_lifetime / rate) × safety_factor" + + code_review: + - "新加的 Key 必须有对应的 EXPIRE" + - "INCR + EXPIRE 组合必须是原子的(Lua 或管道内的 MULTI/EXEC)" + - "禁止用 INCR 后立即 EXPIRE 的两个独立命令(非原子,中间可能被其他操作打断)" + + monitoring: + - "监控 DBSIZE 趋势(keys 总数)" + - "定期检查未设置 TTL 的 Key 数量" + - "告警阈值: 未过期 Key 占比 > 5% 触发告警" +``` + +## 四、INCR + EXPIRE 的原子性问题 + +```go +// ❌ 错误:非原子操作 +count := rdb.Incr(ctx, key) // Step 1: 自增 +rdb.Expire(ctx, key, windowSeconds) // Step 2: 设置过期 +// 如果 Step 2 失败(网络抖动、超时),Key 就永远不会过期! + +// ✅ 正确:Lua 脚本内原子化 +const luaScript = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return count +` +result := rdb.Eval(ctx, luaScript, []string{key}, windowSeconds).Int() +``` + +## 五、常见误解 + +| 误解 | 真相 | +|------|------| +| "Redis 会自动删除过期的 Key" | 是的,但前提是设置了 TTL。没设置 TTL 的 Key 永远不会自动删除 | +| "LRU 淘汰会帮我清理僵尸 Key" | LRU 基于访问频率,僵尸 Key 可能因为曾经活跃过而暂时"免死" | +| "重启 Redis 就能清空" | 生产环境不能随便重启,且 AOF 持久化会在重启后恢复这些 Key | +| "TTL 很短没关系,反正很快会过期" | 10000 个 Key × 1 秒 TTL = 瞬时内存峰值,仍可能造成压力 | + +## 关联笔记 + +- [[分布式限流]] — 排错指南中的内存持续增长章节 +- [[内存持续增长排查]] — 系统性的内存问题排查流程 +- [[SCAN命令]] — 用 SCAN 批量扫描僵尸 Key diff --git a/hzh/REDIS/内存持续增长排查.md b/hzh/REDIS/内存持续增长排查.md new file mode 100644 index 0000000..45cd10f --- /dev/null +++ b/hzh/REDIS/内存持续增长排查.md @@ -0,0 +1,206 @@ +--- +tags: [redis, memory, troubleshooting, monitoring] +create time: 2026-06-01 10:30 +--- + +# Redis 内存持续增长排查 + +## 概述 + +生产环境中 Redis 内存持续增长是一个常见的告警场景。它不一定意味着 Bug——但如果不及时处理,最终会导致 OOM 或频繁 Swap。本节提供一套**系统性的排查思路**,对应你的薄弱点 F10(TTL 命令检查 Key 过期状态)。 + +## 一、排查流程图 + +```mermaid +flowchart TD + Start["⚠️ 内存持续增长告警"] --> Step1["Step 1: MEMORY USED 确认增长幅度"] + + Step1 --> Q1{"增长是持续的还是突发的?"} + Q1 -- "持续增长" --> SLOW["缓慢增长路径"] + Q1 -- "突然飙升" --> SPIKE["突发峰值路径"] + + subgraph SLOW["慢增长排查"] + S1["检查 Key 总数变化
DBSIZE / SCAN"] --> S2["找出增长最快的 Key 前缀
SCAN + TTL 检查"] --> S3["确认 EXPIRE 是否生效
TTL key → -1 表示未过期"] --> S4["僵尸 Key 清理"] + end + + subgraph SPIKE["爆发增长排查"] + P1["监控 BigKey 列表
MEMORY USAGE + SORTED BY size"] --> P2["检查是否有大量写入并发
Slowlog / Monitoring"] --> P3["排查是否为临时批处理任务
定时任务/数据同步"] + end + + S4 --> Action{"找到原因了?"} + P3 --> Action + + Action -- "是" --> Fix["修复"] + Action -- "否" --> Dump["MEMORY ANALYZER
深入分析内存分布"] + + style Start fill:#fff3e0 + style Fix fill:#c8e6c9 + style Dump fill:#e3f2fd +``` + +## 二、逐步排查方法 + +### Step 1:定位增长来源 + +```bash +# 查看所有数据库的大小 +INFO keyspace +→ db0:keys=12345,expires=12000,avg_ttl=3600000 +``` + +输出解析: +| 字段 | 含义 | +|------|------| +| `keys` | 该数据库中 Key 的总数 | +| `expires` | 设置了过期时间的 Key 数量 | +| `avg_ttl` | 这些 Key 的平均剩余生存时间(毫秒) | + +如果 `keys` 在持续增长而 `expires` 不变 → 有大量未设置过期的 Key + +### Step 2:查找大 Key + +```bash +# 方式1: redis-cli --bigkeys(推荐快速使用) +redis-cli --bigkeys + +# 扫描中... +# --- STRINGS --- +# biggest rate:sliding:user123 (6 MB) +# count 15 (total 15 keys) +# ... + +# 方式2: 手动用 SCAN + MEMORY USAGE +SCAN 0 MATCH rate:* TYPE string | xargs -I{} redis-cli MEMORY USAGE {} +``` + +> [!tip]- bigkeys 工具的坑 +> +> `redis-cli --bigkeys` 会遍历所有 Key,对大库来说仍然可能阻塞 Redis。**生产环境慎用**,建议在从库或低峰期运行。 + +### Step 3:检查过期状态(F10 考点 🔑) + +这是排查内存问题的核心技能——**判断 Key 是否已经过期**。 + +```bash +# 检查单个 Key 的 TTL +TTL rate:limit:user123 +→ 86400 ← 剩余 86400 秒,正常 + +TTL rate:limit:user456 +→ -1 ← ❌ 没有过期时间!这就是僵尸 Key + +TTL rate:limit:user789 +→ -2 ← ❌ Key 已不存在,已被删除 +``` + +| TTL 返回值 | 含义 | 处理方式 | +|-----------|------|---------| +| `> 0` | Key 存在,还有 N 秒过期 | 正常,等待自动清理 | +| `-1` | **Key 存在但没有设置过期时间** | ❌ 检查代码中是否漏了 EXPIRE,手动 DEL | +| `-2` | Key 已不存在 | 正常,无需操作 | + +### Step 4:批量检查僵尸 Key + +```go +func findZombieKeys(ctx context.Context, rdb *redis.Client, pattern string) []string { + var zombies []string + cursor := uint64(0) + + for { + keys, nextCursor, _ := rdb.Scan(ctx, cursor, pattern, 200).Result() + for _, key := range keys { + ttl, err := rdb.TTL(ctx, key).Result() + if err == nil && ttl == -1 { + zombies = append(zombies, key) + } + } + if nextCursor == 0 { + break + } + cursor = nextCursor + } + return zombies +} +``` + +## 三、常见内存增长原因及对策 + +| 原因 | 表现 | 解决方式 | +|------|------|---------| +| **EXPIRE 未生效** | TTL 返回 -1 | 检查 Lua 脚本或 Go 代码中的 EXPIRE 调用 | +| **Sorted Set 规模过大** | 个别 Key 占几 MB~几十 MB | 切换到令牌桶方案(O(1) 空间) | +| **批量写入无上限** | DBSIZE 快速增长 | 添加写入限流和 Key 数量上限 | +| **大 Hash/List 结构** | 单 Key 占用巨大 | 拆分或使用更合适的数据结构 | +| **副本同步延迟** | Master 已过期,Slave 还没删 | 配置 `replica-ignore-maxmemory` | +| **Redis 缓存淘汰策略不当** | maxmemory-policy 设为 noeviction | 改为 `allkeys-lru` 或 `volatile-ttl` | + +## 四、预防性措施 + +```mermaid +flowchart TD + Monitor["📊 持续监控"] --> A["MEMORY USAGE per Key 告警"] + Monitor --> B["DBSIZE 增长速率告警"] + Monitor --> C["Evicted Keys 计数告警"] + + Policy["⚙️ 合理的淘汰策略"] --> D["maxmemory-policy: allkeys-lru"] + Policy --> E["maxmemory: 设为可用内存的 70%"] + + Design["🔧 设计时考虑"] --> F["所有 Key 必须有 EXPIRE"] + Design --> G["高频场景用 O(1) 算法(令牌桶)"] + Design --> H["定期 SCAN 清理残留数据"] + + style Monitor fill:#e3f2fd + style Policy fill:#fff3e0 + style Design fill:#e8f5e9 +``` + +### 推荐配置 + +```conf +# redis.conf + +# 最大内存限制(建议设置为服务器总内存的 70%,留余量给 Swap) +maxmemory 2gb + +# 淘汰策略:LRU(最近最少使用) +maxmemory-policy allkeys-lru + +# 或者:优先删除有过期时间的 Key 中最早过期的 +# maxmemory-policy volatile-ttl + +# RDB/AOF 混合持久化 +rdb-save-incremental-mem YES +aof-use-rdb-preamble YES +``` + +## 五、紧急止血步骤 + +``` +当内存已经接近上限时的应急操作: + +1. 停止所有非必要的写入(暂停定时任务、数据同步) +2. 找到并删除最大的几个 Key: + redis-cli --bigkeys + DEL ... +3. 如果无法确定哪些 Key 该删,先批量减少 Key 总量: + SCAN 0 MATCH rate:* COUNT 1000 → DEL 随机抽取的部分 Key(谨慎评估影响) +4. 临时提升 maxmemory(如果服务器还有物理内存可用) +5. 事后复盘:为什么没及时发现?监控阈值要不要调低? +``` + +> [!warning]- DEL vs UNLINK +> +> 删除大 Key 时用 `UNLINK`(异步删除)替代 `DEL`(同步删除),避免阻塞主线程: +> ```bash +> # 同步删除(阻塞) +> DEL rate:sliding:large_key +> +> # 异步删除(不阻塞) +> UNLINK rate:sliding:large_key +> ``` + +## 关联笔记 + +- [[分布式限流]] — 排错指南中的内存问题章节 +- [[僵尸Key防护]] — 针对僵尸 Key 的具体防护措施 +- [[SCAN命令]] — SCAN 是排查内存问题的必备工具 diff --git a/hzh/REDIS/分布式限流.md b/hzh/REDIS/分布式限流.md index 29ba670..f985052 100644 --- a/hzh/REDIS/分布式限流.md +++ b/hzh/REDIS/分布式限流.md @@ -450,6 +450,24 @@ flowchart LR ## 关联笔记 +### 进阶阅读(按知识点拆分) + +- [[路由键与Hash Tag]] — Redis Cluster 中 Hash Tag 的完整用法 +- [[滑动窗口计数器]] — 贡献度计算详解与加权公式 +- [[Sorted Set 滑动窗口]] — Sorted Set 方案的时空复杂度分析 +- [[Go-Redis Lua 调用指南]] — Go-Redis 封装 + EVALSHA 预加载流程 +- [[限流HTTP响应头]] — 标准限流响应头规范 +- [[EVALSHA预加载]] — EVALSHA 性能优化与监控 +- [[SCAN命令]] — SCAN vs KEYS 对比与使用示例 +- [[Jitter抖动与重试策略]] — 指数退避 + Jitter 的 Go 实现 +- [[多级限流架构]] — L1/L2/L3 三层架构实战 +- [[滑动窗口日志方案]] — Lua 脚本中的清理逻辑与时序 +- [[内存持续增长排查]] — 系统性的内存问题排查思路 +- [[僵尸Key防护]] — EXPIRE 保护机制与 TTL 公式推导 +- [[Pipeline批量操作]] — Pipeline vs MULTI/EXEC 区别与应用 + +### 基础参考 + - [[Lua脚本]] — Redis Lua 脚本基础概念与使用模式 - [[02-服务治理/06-容错模式]] — 限流是容错体系的第三层防线 - [[02-服务治理/01-API网关]] — API 网关层的限流插件集成 diff --git a/hzh/REDIS/多级限流架构.md b/hzh/REDIS/多级限流架构.md new file mode 100644 index 0000000..bacc0a6 --- /dev/null +++ b/hzh/REDIS/多级限流架构.md @@ -0,0 +1,246 @@ +--- +tags: [redis, rate-limiting, multi-layer, architecture] +create time: 2026-06-01 10:30 +--- + +# 多级限流架构实战 + +## 概述 + +单个限流算法通常不够用。生产环境中的限流像洋葱一样**层层叠加**——每层保护不同的目标,使用不同的算法和维度。这种设计既防御了大规模攻击,又为正常用户提供了良好的配额管理。 + +对应薄弱点 F5:多级限流架构各层的设计选择。 + +## 一、为什么需要多级? + +```mermaid +flowchart LR + subgraph "单一维度的局限" + L1["仅 IP 限流"] --> PROBLEM1["问题:无法区分同一 IP 下的不同用户"] + L2["仅用户限流"] --> PROBLEM2["问题:恶意攻击者可注册大量账号绕过"] + L3["仅接口限流"] --> PROBLEM3["问题:不防刷单/暴力扫描"] + end + + PROBLEM1 --> MULTI["→ 需要多级组合"] + PROBLEM2 --> MULTI + PROBLEM3 --> MULTI + + style PROBLEM1 fill:#ffebee + style PROBLEM2 fill:#ffebee + style PROBLEM3 fill:#ffebee + style MULTI fill:#e8f5e9 +``` + +单层限流的盲区: +- 只看 IP → 一个用户的多个账号共享配额(或反之,一个 IP 下多个用户被误伤) +- 只看用户 → 恶意用户可以创建无数个账号突破限制 +- 只看接口 → 对暴力破解、爬虫扫描无能为力 + +## 二、经典三级限流架构 + +```mermaid +flowchart TD + Client["👤 客户端请求"] --> GW["🌐 API Gateway"] + GW --> L1 + + subgraph L1Layer["🔴 L1: IP 维度 - 第一道防线"] + direction LR + L1Action["防暴力扫描
防恶意攻击"] + L1Algorithm["短窗口固定计数
例如: 100 req/s/IP"] + end + + L1 -- pass --> L2 + + subgraph L2Layer["🟡 L2: 用户维度 - 配额管理"] + direction LR + L2Action["控制每日/每月配额
区分免费/付费等级"] + L2Algorithm["天级滑动窗口日志
例如: 1000 req/day"] + end + + L2 -- pass --> L3 + + subgraph L3Layer["🟢 L3: 接口维度 - 保护后端"] + direction LR + L3Action["保护实例不被打满
QPS/内存保护"] + L3Algorithm["令牌桶/漏桶
例如: 5000 req/s/endpoint"] + end + + L3 -- pass --> Biz["💼 执行业务逻辑"] + + L1 -- fail --> R429["HTTP 429 Too Many Requests"] + L2 -- fail --> R429 + L3 -- fail --> R429 + + style L1Layer fill:#ffebee,color:#000 + style L2Layer fill:#fff3e0,color:#000 + style L3Layer fill:#e8f5e9,color:#000 + style R429 fill:#b71c1c,color:#fff +``` + +### 层级详解 + +| 层级 | 限流维度 | 核心目标 | 推荐算法 | 典型参数 | Key 示例 | +|------|---------|---------|---------|---------|---------| +| **L1** | IP / CIDR | 防刷、防攻击、防爬虫 | 短窗口固定计数 | 100 req/s/IP | `rate:ip:{IP}:short` | +| **L2** | 用户 ID / Account | 配额管理(免费/付费) | 天级滑动窗口日志 | 1000 req/day | `rate:user:{UID}:daily` | +| **L3** | Endpoint / Interface | 保护下游资源 | 令牌桶 / 漏桶 | 5000 req/s/path | `rate:api:{path}` | + +### L1:IP 层 - 最短视的窗口 + +``` +时间窗口:1 ~ 5 秒 +算法:固定窗口计数器 +目的:快速拦截自动化攻击 + +为什么用短窗口? +- 恶意流量的特点是短时间内密集发起 +- 短窗口可以及时响应,不留攻击窗口 +- 不会因为窗口太长导致误伤正常用户的突发流量 +``` + +```go +// L1: IP 限流 - 短窗口固定计数 +const shortWindowLua = ` +local key = KEYS[1] +local maxCount = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) + +local count = redis.call('INCR', key) +if count == 1 then + redis.call('EXPIRE', key, window) +end +return count +` + +func IPLimitAllow(ctx context.Context, rdb *redis.Client, ip string) (bool, error) { + // 5 秒窗口,最多 100 次请求 + const windowSec = 5 + const maxReq = 100 + + key := fmt.Sprintf("rate:ip:{%s}", ip) + result, err := rdb.Eval(ctx, shortWindowLua, []string{key}, maxReq, windowSec).Int() + if err != nil { + return false, err + } + return result <= maxReq, nil +} +``` + +### L2:用户层 - 精确的日配额 + +``` +时间窗口:24 小时(按天重置) +算法:Sorted Set 滑动窗口(逐条精确统计) +目的:区分免费用户和付费用户的配额 + +为什么用 Sorted Set? +- 日配额 QPS 不高(1000/day ≈ 0.01 QPS) +- 需要精确统计,不能有近似误差 +- 免费/付费分级,每个用户独立配额 +``` + +### L3:接口层 - 保护后端资源 + +``` +时间窗口:持续监控(令牌桶模型) +算法:令牌桶 +目的:防止某个接口被打爆,保护数据库/下游服务 + +为什么用令牌桶? +- O(1) 空间,不随时间增长 +- 允许合理突发(用户批量操作) +- 输出速率可控,保护后端稳定 +``` + +## 三、多级限流的调用链路 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant G as Go Service + participant R as Redis + + C->>G: HTTP Request + G->>R: L1: IP Check (100/s/5s) + alt Blocked by L1 + R-->>G: count > 100 + G-->>C: 429 (L1 blocked) + else + G->>R: L2: User Check (1000/day) + alt Blocked by L2 + R-->>G: count >= 1000 + G-->>C: 429 (L2 blocked) + else + G->>R: L3: Token Bucket (5000/s) + alt Blocked by L3 + R-->>G: no tokens + G-->>C: 429 (L3 blocked) + else + R-->>G: allowed + G->>G: Execute Business Logic + G-->>C: 200 OK + end + end + end +``` + +> [!note]- 性能考量 +> +> 多级限流意味着每次请求要发 **3 次 Redis 调用**。优化方向: +> - **本地缓存 + 远程校验**:L1 可以在本地做短期计数(如 sync.Map),定期同步到 Redis +> - **Pipeline 合并**:L1/L2/L3 的三个 Lua 调用可以用 Pipeline 打包 +> - **短路策略**:如果 L1 就拒绝了,不需要继续查 L2/L3 + +## 四、降级与熔断联动 + +```mermaid +flowchart TD + Normal["✅ 正常限流状态"] --> Check{"Redis 可用?"} + + Check -- "是" --> RL["正常走多级限流"] + Check -- "否" --> Degradation{"降级策略"} + + Degradation -- "短暂断开" --> Local["本地内存限流
降级模式"] + Degradation -- "长时间不可用" --> PermitAll["放行所有请求
宁可超限不可停机"] + + Local --> Recovery{"Redis 恢复?"} + Recovery -- "是" --> Normal + Recovery -- "否" --> PermitAll + + style Normal fill:#c8e6c9 + style PermitAll fill:#fff3e0 + style Local fill:#e3f2fd +``` + +| Redis 状态 | L1 IP 层 | L2 用户层 | L3 接口层 | +|-----------|---------|----------|----------| +| **正常** | Redis Lua | Redis Lua | Redis Lua | +| **短暂故障** | 本地 sync.Map 缓存计数 | 跳过(默认放行) | 本地令牌桶 | +| **长时间故障** | 全部放行 | 全部放行 | 全部放行 | + +> [!warning]- 降级不是无限制的 +> +> 如果 L1(IP 层)也降级放行了,意味着任何 IP 都没有限制——这时应该配合 CDN/WAF 等外部防御层做兜底。 + +## 五、各层的超时与重试配置 + +``` +多层限流系统的超时设置: + +┌─────────┬──────────────┬──────────────────────────┐ +│ 层级 │ Redis 超时 │ 客户端超时 │ +├─────────┼──────────────┼──────────────────────────┤ +│ L1 │ 1ms │ 50ms(快速判断) │ +│ L2 │ 5ms │ 200ms(配额查询) │ +│ L3 │ 3ms │ 100ms(令牌检查) │ +│ 合计 │ 9ms │ — │ +└─────────┴──────────────┴──────────────────────────┘ +``` + +各层超时应该远小于整体业务超时,否则限流模块本身会成为瓶颈。 + +## 关联笔记 + +- [[分布式限流]] — 限流的基础算法介绍 +- [[Jitter抖动与重试策略]] — 各层的客户端重试策略设计 +- [[02-服务治理/06-容错模式]] — 限流在容错体系中的位置 diff --git a/hzh/REDIS/滑动窗口日志方案.md b/hzh/REDIS/滑动窗口日志方案.md new file mode 100644 index 0000000..d2aa9b4 --- /dev/null +++ b/hzh/REDIS/滑动窗口日志方案.md @@ -0,0 +1,198 @@ +--- +tags: [redis, sliding-window, sorted-set, performance, monitoring] +create time: 2026-06-01 10:30 +--- + +# 滑动窗口日志方案细节 + +## 概述 + +滑动窗口日志方案是最精确的限流方式:每条请求作为 Sorted Set 的一条记录,通过范围查询实现精确统计。这一节深入你标记为薄弱的 F6——Lua 脚本中的清理逻辑和各操作的详细时序。 + +## 一、核心流程(F6 考点深挖) + +### 完整时序图 + +```mermaid +sequenceDiagram + participant App as Go 应用 + participant R as Redis + + Note over App,R: ── Lua 脚本执行(原子化)─ + + App->>R: EVALSHA sha1 ...