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 在限流架构中的实际位置
+233
View File
@@ -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<br/>发送脚本正文"]
B --> C["Redis 计算 SHA1<br/>缓存到脚本库"]
C --> D["返回 SHA1 指纹"]
D --> E["运行时调用 EvalSHA<br/>传 SHA1 即可"]
E --> F{NOSCRIPT?}
F -- "否" --> G["执行成功"]
F -- "是" --> H["Redis 缓存已丢失<br/>重新 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 基础概念
+180
View File
@@ -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<br/>⚡ 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<br/>😊 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
```
## 关联笔记
- [[分布式限流]] — 限流架构中的客户端重试策略
- [[多级限流架构]] — 各层超时配置与降级策略设计
+219
View File
@@ -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 做条件逻辑<br/>无法根据 A 的结果决定 B"] --> T2["需要判断分支时用 Lua 脚本"]
T3["大批量发送可能超过 Redis<br/>maxpacket 限制"] --> T4["控制单次 Pipeline 的命令数<br/>建议 50~200 条"]
T5["流水线中的错误不会中断后续命令"] --> T6["需要在客户端检查每个命令的返回"]
T7["Cluster 模式下多 Key<br/>必须在同一 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 提高批量处理效率
+240
View File
@@ -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 主线程<br/>遍历所有数据库"]
B --> C["如果库中有 1000 万个 Key?<br/>可能耗时数秒到数十秒"]
C --> D["所有其他客户端被阻塞<br/>超时/连接堆积"]
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
+195
View File
@@ -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<br/>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["切换为滑动窗口计数器<br/>O(N) 子窗口,N << 记录数"]
Choice -- "保持精确但控制规模" --> REDUCE["减少窗口大小或 maxQPS<br/>例如 60s → 10s"]
Choice -- "接受高频开销" --> KEEP["继续使用 Sorted Set<br/>加监控告警"]
style Problem fill:#ffebee
style SWC fill:#e3f2fd
style KEEP fill:#fff3e0
```
## 关联笔记
- [[分布式限流]] — 完整的限流算法演进路线
- [[Lua脚本]] — Lua 脚本中的 Sorted Set 操作
- [[滑动窗口计数器]] — 空间效率更高的替代方案
+177
View File
@@ -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 天<br/>用户 user123 访问了 API"] --> Create["创建 Key<br/>rate:limit:user123:daily"]
Create --> EXPIRE["设置 EXPIRE<br/>TTL = 86400s (1天)"]
Day2["📅 第 2 天"] --> Active{"user123 还访问吗?"}
Active -- "是" --> Renew["正常续期 ✓"]
Active -- "否" --> Expire["✅ Key 自动过期删除 ✓"]
Day3["📅 第 3 天"] --> NoExpire{"如果 EXPIRE 没生效?"}
NoExpire --> Zombie["❌ Key 仍然占用 ~500B<br/>但已无实际用途"]
zombieCount["Zombie Count 增长"] --> Accumulate["1000 users × 500B = 500KB<br/>10000 users → 5MB<br/>..."]
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
+206
View File
@@ -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 总数变化<br/>DBSIZE / SCAN"] --> S2["找出增长最快的 Key 前缀<br/>SCAN + TTL 检查"] --> S3["确认 EXPIRE 是否生效<br/>TTL key → -1 表示未过期"] --> S4["僵尸 Key 清理"]
end
subgraph SPIKE["爆发增长排查"]
P1["监控 BigKey 列表<br/>MEMORY USAGE + SORTED BY size"] --> P2["检查是否有大量写入并发<br/>Slowlog / Monitoring"] --> P3["排查是否为临时批处理任务<br/>定时任务/数据同步"]
end
S4 --> Action{"找到原因了?"}
P3 --> Action
Action -- "是" --> Fix["修复"]
Action -- "否" --> Dump["MEMORY ANALYZER<br/>深入分析内存分布"]
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 <bigkey1> <bigkey2> ...
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 是排查内存问题的必备工具
+18
View File
@@ -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 网关层的限流插件集成
+246
View File
@@ -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["防暴力扫描<br/>防恶意攻击"]
L1Algorithm["短窗口固定计数<br/>例如: 100 req/s/IP"]
end
L1 -- pass --> L2
subgraph L2Layer["🟡 L2: 用户维度 - 配额管理"]
direction LR
L2Action["控制每日/每月配额<br/>区分免费/付费等级"]
L2Algorithm["天级滑动窗口日志<br/>例如: 1000 req/day"]
end
L2 -- pass --> L3
subgraph L3Layer["🟢 L3: 接口维度 - 保护后端"]
direction LR
L3Action["保护实例不被打满<br/>QPS/内存保护"]
L3Algorithm["令牌桶/漏桶<br/>例如: 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["本地内存限流<br/>降级模式"]
Degradation -- "长时间不可用" --> PermitAll["放行所有请求<br/>宁可超限不可停机"]
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-容错模式]] — 限流在容错体系中的位置
+198
View File
@@ -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 ... <script>
rect rgb(240, 248, 255)
Note over R: Step 1: 清理过期记录
R->>R: ZREMRANGEBYSCORE key -inf now-window*1000
Note right of R: 删除所有 score < now-window<br/>的记录
end
rect rgb(255, 248, 240)
Note over R: Step 2: 统计当前数量
R->>R: ZCARD key
Note right of R: 返回当前窗口内<br/>剩余的有效记录数
end
alt count >= maxLen
Note over R: Step 3a: 超限
R-->>App: return count (拒绝)
else count < maxLen
Note over R: Step 3b: 放行
R->>R: ZADD key now member
Note right of R: 插入新记录<br/>score = now (毫秒时间戳)
R->>R: EXPIRE key window
Note right of R: TTL = window 秒<br/>防止 Key 永远存在
R-->>App: return count + 1 (放行)
end
```
### F6 考点详解:清理范围
```lua
-- 关键行(对应填空题 F6)
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
│ │ │
│ │ └── 窗口大小 × 1000(秒 → 毫秒)
│ └────────────── 窗口起始边界
└────────────────────── 从负无穷开始
```
| 参数 | 含义 | 为什么这样设? |
|------|------|--------------|
| `-inf` | 负无穷 | 确保所有更早的历史记录都被清理,不留死角 |
| `now - window * 1000` | 窗口起始点 | 这是"当前时刻往前推一个窗口长度"的时间点 |
**单位统一很重要:**
- `now` 是毫秒级时间戳(`time.Now().UnixMilli()`)
- `window` 传入的是秒
- 所以 `window * 1000` 转换为毫秒
- 最终比较的上下界都是毫秒级数值
## 二、member 唯一性保障
Sorted Set 中,如果两条记录的 score 完全相同且 member 也相同,第二条会**覆盖第一条**。因此必须保证 member 全局唯一:
```go
// 方案1: ULID(推荐,单调递增 + 分布式安全)
import "github.com/oklog/ulid/v2"
func makeMember() string {
return ulid.Now().String()
}
// 方案2: UUID v4
import "github.com/google/uuid"
func makeMember() string {
return uuid.New().String()
}
// 方案3: 毫秒时间戳 + 随机后缀
func makeMemberV3() string {
ts := time.Now().UnixMilli()
randSuffix := rand.Intn(10000)
return fmt.Sprintf("%d_%04d", ts, randSuffix)
}
```
> [!tip]- 为什么不用简单计数器?
>
> 分布式环境下不同实例并发时,序号可能冲突。ULID/UUID 天然去重且有序(ULID 按时间排序),是 Sorted Set 的最佳选择。
## 三、Redis 原生 Sorted Set 方案(非 Lua)
如果你的场景不需要 Lua 的原子性保证,可以直接用两个 Redis 命令组合:
```go
// 不需要 Lua 的轻量版实现
func SlidingWindowSimple(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()
// 步骤1: 原子性地做两件事
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
}
// 步骤2: 添加并设置过期
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)
return err
}
```
> [!warning]- 这个版本的隐患
>
> Pipeline 虽然减少了 RTT,但 ZRemRangeByScore → ZCard → ZAdd **不是原子操作**。在高并发下可能出现:
> - 线程 A 查了 ZCard(count=99)
> - 线程 B 插入了 ZADD(count=100)
> - 线程 A 又插入 ZADD(count=101)→ **超限**
>
> 所以对严格限流的场景,**必须用 Lua**。
## 四、空间增长与监控
### 4.1 各层级的内存占用
```
每条记录约 ~100 字节(含 Sorted Set 节点开销)
日配额场景 (QPS ≈ 0.01):
活跃记录 ≈ 1000/day × 100B = 100 KB ✓
接口限流场景 (QPS = 100):
活跃记录 ≈ 100/s × 60s × 100B = 600 KB ✓
高频网关 (QPS = 1000):
活跃记录 ≈ 1000/s × 60s × 100B = 6 MB ⚠️
超高并发 (QPS = 10000):
活跃记录 ≈ 10000/s × 60s × 100B = 60 MB ❌ 不建议用此方案
```
### 4.2 监控指标
```bash
# 查看某个 Sorted Set 的大小
ZCARD rate:sliding:user123
# 检查内存使用
MEMORY USAGE rate:sliding:user123
# 查看所有限流相关 Key 的大小(SCAN + MEMORY USAGE)
redis-cli --scan --pattern "rate:sliding:*" | xargs -I{} redis-cli MEMORY USAGE {}
```
在 Prometheus 或 Grafana 中建议监控:
- `rate:sliding:*` 集合的 ZCARD 总量分布
- 单个 Key 的最大 ZCARD 值(告警阈值设为 maxLen 的 2 倍)
- 每个 Key 的内存占用 `MEMORY USAGE`
## 五、常见问题排查
| 症状 | 原因 | 排查方法 |
|------|------|---------|
| 超限后仍大量通过 | ZADD member 重复导致覆盖 | 检查 member 是否全局唯一 |
| 内存持续增长 | EXPIRE 未生效 | 用 `TTL key` 检查是否为 -1 |
| 计数偏大 | 时间戳单位不匹配 | 确认 now 是毫秒,window 转毫秒 |
| CPU 飙高 | Sorted Set 规模过大 | `ZCARD` 看数量,考虑切换到令牌桶 |
## 关联笔记
- [[分布式限流]] — 滑动窗口日志的整体定位和对比
- [[Sorted Set 滑动窗口]] — Sorted Set 滑动窗口的全面讲解
- [[滑动窗口计数器]] — O(N) 空间的替代方案
+139
View File
@@ -0,0 +1,139 @@
---
tags: [redis, rate-limiting, sliding-window-counter, contribution]
create time: 2026-06-01 10:30
---
# 滑动窗口计数器:加权贡献度详解
## 概述
固定窗口计数器在边界处会出现**突放量达到限制两倍**的问题。滑动窗口计数器的改进思路很直观:**把总窗口切分成 N 个子窗口,用一个加权公式估算当前窗口的总量**。
本节深入讲解它的核心——**贡献度计算**(对应选择题 Q11),以及它在生产中的实际实现方式。
## 一、基本模型
假设总窗口是 1 秒,切成 N=10 个子窗口(每个 100ms):
```mermaid
flowchart TD["滑动窗口计数器 - N=10 子窗口"]
subgraph Window["1 秒总窗口"]
direction LR
W1["W1<br/>已过期的部分 × 占比"]
W2["W2<br/>已过期的部分 × 占比"]
W3["W3<br/>已过期的部分 × 占比"]
W4["W4<br/>当前完整窗口 → 100%"]
end
Current["当前时刻"] --> Pos["位于第 8 个子窗口"]
Pos --> Calc["total = Σ(各子窗口贡献)"]
```
| 子窗口位置 | 计入比例 | 说明 |
|-----------|---------|------|
| **已过期的前 M-1 个窗口** | 剩余时间比例折算 | `count × (剩余未过期时间 / 总窗口大小)` |
| **当前正在进行的窗口 M** | 100% 全计 | `count × 1.0` |
| **未来的窗口** | 0% | 还未发生,不计入 |
> [!question]- 💡 思考:为什么要加权?
>
> 想象一个场景:某个用户在第 1 秒内发了 100 个请求,但在第 2 秒开始时这些请求"还剩多少"仍然算在当前滑动窗口内?显然是零——因为完全过期了。但如果某个用户在上一个子窗口的末尾发的请求,它只有一小部分时间已经超出当前窗口。这种"活了多少"的差别,就是加权要解决的问题。
## 二、贡献度计算(核心知识点 🔑)
这是你最薄弱的一环,我们用具体数字来演示。
### 2.1 Q11 考点:一句话答案
> **已过期的窗口按其剩余未过期时间占总窗口大小的比例折算贡献度。**
>
> 例如:总窗口 10s,某个子窗口已过 8s 只剩 2s 仍在窗口内 → 贡献度 = count × 2/10
这在 Q11 选择题中对应答案 **C. 按剩余时间比例折算**。
### 2.2 数值演示
让我们看一个完整的例子来加深理解:
```
总窗口 W = 10s,N = 10 个子窗口,每窗口 1s
当前时刻:t = 7.5s(第 8 个子窗口内,进度 50%)
滑动窗口范围: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
...(中间同理)...
W-1(-2~-1s): 存活 0.5s → 占比 0.05
```
在这个场景中,`total = W8.count × 0.05 + W7.count × 0.10 + ... + W-1.count × 0.05`
### 2.3 实际生产实现
在实际代码中,大多数实现维护 N 个槽位(数组),循环覆盖。这样不需要记录复杂的时间线:
```go
// 假设 N=10,每 100ms 一个子窗口
type SlidingWindowCounter struct {
slots [10]int64 // 10 个子窗口
slotIdx int // 当前子窗口索引
windowMs int64 // 总窗口 1000ms
}
func (sw *SlidingWindowCounter) TryAcquire() bool {
now := time.Now().UnixMilli()
// 计算当前属于哪个子窗口
currentSlot := int(now % sw.windowMs / (sw.windowMs / int64(len(sw.slots))))
// 计算滑动窗口内的总请求数
var total float64
for i := 0; i < len(sw.slots); i++ {
if i == currentSlot {
// 当前窗口:100% 计入
total += float64(sw.slots[i])
} else if i < currentSlot {
// 之前的窗口:按相对位置递减权重
diff := currentSlot - i
weight := float64(len(sw.slots)-diff) / float64(len(sw.slots))
total += float64(sw.slots[i]) * weight
}
// i > currentSlot:未来窗口,weight = 0
}
return total < maxCount
}
```
**关键公式:**
```
slot[i] 的贡献度 = count[i] × weight[i]
其中 weight[i] = (len(slots) - (currentSlot - i)) / len(slots),对 i < currentSlot
```
举例(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
> [!note]- 两种实现的等价性
>
> 上面的"槽位差值权重"实现和前面"基于时间的剩余比例折算"在数学上是等价的。槽位差值实现是工程上的简化版本,省去了精确时间线的维护,但效果一致——离当前越近的窗口权重越高。
## 三、三种窗口算法对比
| 维度 | 固定窗口 | 滑动窗口计数器 | 滑动窗口日志 |
|------|---------|-------------|------------|
| **精度** | 低(可突破 2×) | 中(≈1/N 误差) | 高(精确到每条) |
| **空间** | O(1) | O(N) 子窗口 | O(maxQPS × window) |
| **突放量** | 2× 阈值 | ≤ (1+1/N)× 阈值 | 精确控制 |
| **适用场景** | 日配额、粗略统计 | 毫秒级精准限流 | 低频次高精场景 |
## 关联笔记
- [[分布式限流]] — 完整的限流算法演进路线
- [[路由键与Hash Tag]] — Cluster 环境下 Key 的路由设计
+135
View File
@@ -0,0 +1,135 @@
---
tags: [redis, cluster, hash-slot, routing-key, hash-tag]
create time: 2026-06-01 10:30
---
# Redis Cluster 路由键与 Hash Tag
## 概述
当你把 Redis 部署成集群模式后,数据不再存在于一台机器上——而是被拆分到多个节点。这个拆分的单位叫做 **hash slot(哈希槽)**。理解路由机制是正确使用 Redis Cluster 的前提,否则你可能会遇到 "Key 跨 slot、Lua 脚本执行失败" 这种让人头疼的问题。
## 一、Hash Slot 基础
Redis Cluster 固定分配 **16384 个 slot**(编号 0 ~ 16383)。每个 Key 在存入前都会被计算一个 slot 编号,决定它落在哪个节点上:
```go
slot := crc16(key) % 16384
```
**这意味着:**
- 同一个 Key 永远落在同一个 slot
- 不同 Key 可能碰撞到同一个 slot(取决于 CRC16 结果)
- 16384 个 slot 均匀分布在所有主节点上
> [!question]- 💡 思考:为什么是 16384?
>
> 这个数字不是随意选的。早期设计者考虑过:如果只有 1024 个 slot,节点多了单个节点的负载会不均衡;如果 100 万+,slot 迁移和拓扑管理的开销又太大。16384 是一个经验值——足够细粒度地分布,又不会让管理成本爆炸。
## 二、Hash Tag:强制锁定同一 Slot
这是本节最重要的实战概念。**`{}` 包裹的部分叫 Hash Tag**,它会告诉 Redis:"不管后面的字符串差异多大,整个 Key 都走 `{}` 内的内容来计算 slot"。
### 2.1 规则
```mermaid
flowchart LR["Hash Tag 计算规则"]
A["rate:limit:{user123}:daily"] --> B["提取 {} 内内容: user123"]
B --> C["CRC16(user123) % 16384"]
C --> D["落点 = slot X"]
E["rate:limit:{user123}:hourly"] --> B
F["rate:limit:user456:daily"] --> G["无 {} → 取完整 Key"]
G --> H["CRC16(rate:limit:user456:daily) % 16384"]
H --> I["落点 = slot Y (通常 ≠ X)"]
style D fill:#c8e6c9
style I fill:#fff3e0
```
| Key 示例 | 实际参与计算的字符串 | 落点 |
|----------|-------------------|------|
| `rate:limit:user123:daily` | `rate:limit:user123:daily`(完整 Key) | slot Y |
| `rate:limit:{user123}:daily` | `user123`({} 内部分) | slot X |
| `rate:limit:{user123}:hourly` | `user123`(相同!落到同一 slot) | slot X ✓ |
**核心结论:** 使用相同的 Hash Tag 的所有 Key,一定路由到同一个 slot。
### 2.2 为什么需要它?
Lua 脚本中涉及多个 Key 时,这些 Key **必须在同一个 slot**。例如令牌桶脚本同时操作 `tokensKey` 和写入 `EXPIRE`:
```lua
local tokensKey = KEYS[1]
-- HMSET + EXPIRE 都需要操作同一 slot
redis.call('HMSET', tokensKey, 'tokens', remaining, 'last_refill', now)
redis.call('EXPIRE', tokensKey, math.ceil(capacity / rate) * 2)
```
如果你构造的 Key 没有用 `{}` 包裹,在高并发下不同用户的数据可能散落在不同 slot,当 Lua 脚本需要跨 slot 多 Key 操作时就会报错:
```
CROSSKEYS Lua script tried to access keys in different slots
```
### 2.3 正确实践
```go
// ❌ 错误:不同维度可能分到不同 slot
key := fmt.Sprintf("rate:limit:%s:daily", userId) // user123:daily 算 slot
key := fmt.Sprintf("rate:limit:%s:hourly", userId) // user123:hourly 算 slot → 不同 slot!
// ✅ 正确:用 {} 包裹业务标识,保证同用户所有 Key 同 slot
key := fmt.Sprintf("rate:limit:{%s}:daily", userId) // {user123} 算 slot
key := fmt.Sprintf("rate:limit:{%s}:hourly", userId) // {user123} 算 slot → 同一 slot ✓
```
> [!tip]- Hash Tag 的最佳选择
>
> `{}` 内的字符串应该尽量简短且区分度高。常见做法:
> - 用户 ID:`{user123}`
> - IP 地址段:`{192.168.1}`
> - API Key:`{app_key_abc}`
> - **避免用复杂字段**(如 UUID 全量),这会增加 CRC16 计算的无意义熵但不提升安全性。
## 三、常见问题排查
### 问题:Cluster 下限流失效/报错
```
MISDIRCTED or CROSSKEYS error
```
**原因:** Lua 脚本中的多个 Key 分属不同 slot。
**排查步骤:**
1. 打印 Key 格式,确认是否包含 Hash Tag
2. 检查是否在同一脚本中混用了不同前缀的 Key
3. 确认 `{}` 括号内的标识符是一致的
**解决:** 统一使用 `{业务ID}` 格式构造 Key。
## 四、测试 Hash Tag 效果
本地可以直接验证两个 Key 是否落到了同一 slot:
```bash
# CRC16 计算工具(需安装 crc16 或自己写脚本)
# 或者通过 Redis Cluster 节点直接查询
CLUSTER GETKEYSINSLOT <slot> <count>
# 快速验证思路:
# 在开发环境启动一个 3 节点的 Cluster,观察 key 分布
redis-cli -p 7000 CLUSTER NODES
```
> [!warning]- Hash Tag 不是银弹
>
> 所有用了相同 Hash Tag 的 Key 都会落在同一个 slot,意味着 **该 slot 所在节点的负载会偏高**。不要把所有 Key 都用 `{all}` 或 `{1}` ——那样等于没做分布式。Hash Tag 只应该包裹真正需要关联操作的标识符(如用户 ID)。
## 关联笔记
- [[分布式限流]] — Redis Cluster 限流的 Key 设计与 Hash Tag 应用
- [[Lua脚本]] — Lua 脚本多 Key 操作的 slot 约束
- [[02-服务治理/02-数据库中间件]] — Redis Cluster 架构概览
+148
View File
@@ -0,0 +1,148 @@
---
tags: [http, rate-limiting, headers, standard]
create time: 2026-06-01 10:30
---
# 限流 HTTP 响应头标准
## 概述
当请求被限流时,服务端不仅要返回 **429 Too Many Requests** 状态码,还需要通过 HTTP 响应头告知客户端**为什么被限流、什么时候可以再试**。这些标准化的头部字段是前后端协作的重要契约。
这对应你的薄弱点 Q13:区分哪些是**标准的限流响应头**,哪些不是。
## 一、三大标准限流头(RFC 7231 / Twitter API 惯例)
这三个头组成了限流响应的核心信息:
### 1. X-RateLimit-Limit
```
X-RateLimit-Limit: 100
```
**含义:** 当前时间窗口内的最大允许请求数(限流阈值)。
**用途:** 让客户端了解它的配额上限,用于前端 UI 展示进度条或提示文案。
### 2. X-RateLimit-Remaining
```
X-RateLimit-Remaining: 42
```
**含义:** 当前时间窗口内剩余的可用请求次数。
**用途:** 客户端可以据此做本地决策——如果剩余为 0,就不需要再发请求了,直接等。
### 3. X-RateLimit-Reset
```
X-RateLimit-Reset: 1690000060
```
**含义:** 当前窗口重置的 Unix 时间戳(秒级),此时 Remaining 会恢复为 Limit。
**用途:** 客户端计算等待时间:`waitSeconds = Reset - now`。
## 二、Retry-After 头部
除了上面三个 "信息型" 头部,还有一个 **动作型** 头部:
```
Retry-After: 5
```
**含义:** 建议客户端等待指定秒数后再重试。
这个头部在两种场景下使用:
- **被限流时**(429 响应):告诉客户端等多久再试
- **服务繁忙时**(503 响应):服务器正在维护或过载,请稍后重试
> [!tip]- Retry-After vs X-RateLimit-Reset
>
> | 头部 | 类型 | 单位 | 语义 |
> |------|------|------|------|
> | `Retry-After` | 动作指令 | 秒(整数) | "请等 N 秒后再试" |
> | `X-RateLimit-Reset` | 信息参考 | 绝对时间戳 | "窗口将在该时间点重置" |
>
> **最佳实践:** 同时返回两者。`Retry-After` 给简单逻辑用,`Reset` 给精细调度的客户端用。
## 三、完整响应示例
### 被限流时的响应
```http
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1690000060
Retry-After: 2
{
"error": {
"code": "RATE_LIMITED",
"message": "请求过于频繁,请稍后再试",
"retry_after_seconds": 2
}
}
```
### 正常通过的响应(也带上限流信息)
```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1690000060
{
"data": { ... }
}
```
> [!tip]- 为什么正常响应也要带限流头?
>
> 客户端可以在不进入错误分支的情况下实时监控自己的配额消耗情况,提前做策略调整(比如把突发请求均匀分布)。
## 四、Q13 考点总结:哪些是标准头?
| 头部 | 是否标准 | 说明 |
|------|---------|------|
| ✅ `X-RateLimit-Limit` | **是** | 限流阈值 |
| ✅ `X-RateLimit-Remaining` | **是** | 剩余配额 |
| ✅ `X-RateLimit-Reset` | **是** | 重置时间戳 |
| ❌ `X-RateLimit-Burst` | **否** | 非标准头,虽然有些系统会用,但不是通用规范 |
| ✅ `Retry-After` | **是** | RFC 7231 定义的 retry 头部 |
> [!note]- 记忆技巧
>
> 记住三个关键词:**Limit(总量)、Remaining(剩余)、Reset(重置)** —— 这就是完整的限流信息三角。任何超出这个范围的自定义头部(如 Burst、Window、Quota 等)都是各公司自行扩展的,不属于标准规范。
## 五、Go 实现示例
```go
func setRateLimitHeaders(w http.ResponseWriter, limit, remaining int64, resetTime time.Time) {
w.Header().Set("X-RateLimit-Limit", strconv.FormatInt(limit, 10))
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(remaining, 10))
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(unixMilliToSecond(resetTime.UnixMilli()), 10))
}
func setRateLimitedResponse(w http.ResponseWriter, retryAfterSec int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSec))
w.WriteHeader(http.StatusTooManyRequests) // 429
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"code": "RATE_LIMITED",
"message": "请求过于频繁,请稍后再试",
"retry_after_seconds": retryAfterSec,
},
})
}
```
## 关联笔记
- [[分布式限流]] — 限流架构中的响应处理部分
- [[Jitter抖动与重试策略]] — 客户端收到 429 后的重试策略
+379
View File
@@ -0,0 +1,379 @@
---
create time: 2026-06-01 10:00
tags: [redis, rate-limiting, test]
type: quiz
---
# 分布式限流(基于 Redis)— 练习题
## 一、选择题(15 道)
**Q1.【场景题】** 你的服务部署在 5 台机器上,每台机器各自用内存计数器做限流(每秒 100 次)。用户反馈接口偶尔能跑到 600 QPS。根本原因是?
A. Redis 连接池配置过小
B. 多台机器各自独立计数,实际吞吐量 = 单机阈值 × 机器数
C. 数据库锁竞争导致请求堆积后爆发
D. API Gateway 没有启用限流插件
> [!info]- 参考答案
> B — 每台机器独立计数,100 × 5 = 500,加上边界突发可达 600
---
**Q2.** 固定窗口计数器在窗口边界处可能出现的突放量最多是限制阈值的几倍?
> [!tip]- 💡 术语:什么是突放量?
> 本应均匀通过的请求,因窗口切换缺陷在极短时间内集中挤过来,超过设定限制总量的多余流量。例:限制 100/s,交界 0.2s 内通过 200 个,突放量 ≈ 2×。
A. 1.5 倍
B. 2 倍
C. 3 倍
D. 与窗口大小 N 成正比
> [!info]- 参考答案
> B — 前后两个相邻窗口各满负荷运行,可在极短时间内通过双倍请求
---
**Q3.** 以下哪个 Key 的设计能确保在同一 Redis Cluster[^1] 下路由到同一个 hash slot[^2]?
A. `rate:limit:user123:daily`
B. `rate:limit:{user123}:daily`
C. `rate:limit/user123/daily`
D. `rate:limit-daily-user123`
> [!info]- 参考答案
> B — `{}` 包裹的路由键(Hash Tag)强制锁定到同一 slot
---
**Q4.** 日志级滑动窗口(Sorted Set[^3] 实现)在高并发场景下最大的缺点是什么?
A. 无法保证原子性
B. 空间复杂度 O(maxQPS × window),高频下 Sorted Set 过大
C. 不支持多实例部署
D. Lua 脚本执行时间过长
> [!info]- 参考答案
> B — maxQPS=1000、窗口 60s 需存储 6 万条记录,成为瓶颈
---
**Q5.** 令牌桶和漏桶最核心的区别在于:
> [!tip]- 💡 术语对比:令牌桶 vs 漏桶
> | 维度 | 🪣 令牌桶 | 🕳 漏桶 |
> |------|---------|-------|
> | **突发** | 允许(空闲时令牌累积) | 不允许(多余直接拒绝) |
> | **输出形态** | 跟随输入节奏 | 始终匀速 |
> | **隐含队列** | 无(拒绝即拒) | 有(先入桶排队再处理) |
> | **保护对象** | 对客户端更友好 | 对下游更安全 |
> | **典型场景** | REST API 限流、第三方配额 | DB 连接池限速、防止后端被打满 |
A. 令牌桶使用 Lua 脚本,漏桶不需要
B. 令牌桶允许合理突发,漏桶不允许
C. 令牌桶只能用于 API 网关,漏桶只能用于数据库
D. 令牌桶的速率不可调,漏桶的速率可调
> [!info]- 参考答案
> B — 令牌桶空闲时累积令牌允许突发;漏桶输出始终匀速
---
**Q6.【场景题】** 你负责一个付费 API 服务,需要限制每个用户每天最多调用 1000 次,希望精确统计且对精度要求高。最适合的算法是?
A. 固定窗口计数器
B. 滑动窗口计数器 (N=10)
C. 日志级滑动窗口(Sorted Set)
D. 令牌桶
> [!tip]- 💡 术语:滑动窗口计数器 vs 日志级滑动窗口
> - **滑动窗口计数器**:将总窗口划分为 N 个子窗口加权估算,近似统计总量。优点空间 O(N)、效率高;缺点精度受子窗口数量限制。适合日配额等对精度要求不高的场景。
> - **日志级滑动窗口**:每条请求的时间戳作为 Sorted Set 的成员逐条精确统计。优点是精确到每一条;缺点是空间 O(maxQPS × window),高频下 Sorted Set 规模过大。适合低频次高精度的场景。
> [!info]- 参考答案
> C — 日志级滑动窗口逐条精确统计,适合日配额等低频次高精度的场景
---
**Q7.【场景题】** 你正在为一个数据库连接池设计限速模块,目标是保护下游数据库不被瞬间大量请求打满。以下哪种方案最合适?
> [!tip]- 💡 术语:隐含队列
> 漏桶模型中包含一个"排队缓冲区"的概念——请求先被接收放入桶中,再由排水孔以恒定速率逐步处理。这相当于内置了一个 FIFO 队列,起到削峰填谷的作用。而令牌桶没有隐含队列,令牌不足时请求直接被拒绝。
A. 令牌桶——给前端好体验
B. 漏桶——稳定匀速输出,保护下游安全
C. 固定窗口计数器——实现最简单
D. 日志级滑动窗口——精度最高
> [!info]- 参考答案
> B — 漏桶输出始终匀速且有隐含队列,适合保护数据库连接池等下游资源
---
**Q8.** Redis Token Bucket Lua 脚本中,`math.min(capacity, tokens + elapsed * rate)` 这行代码的作用是什么?
A. 计算当前应该消费的令牌数
B. 根据经过的时间补充令牌,但不超过桶容量
C. 判断是否应该拒绝请求
D. 计算下次可用的等待时长
> [!info]- 参考答案
> B — 补充公式,elapsed 秒内应补充 elapsed×rate 个令牌,但上限为 capacity
---
**Q9.** 客户端收到 429 响应后,以下哪种做法是**错误的**?
A. 遵守 Retry-After 头部进行等待
B. 使用指数退避 + \_\_\_ 策略重试[^4]
C. 立即无脑重发所有失败的请求
D. 将 retry_after_seconds 写入本地计时器
> [!info]- 参考答案
> C — 立即无脑重发会造成 "惊群效应"(thundering herd),再次打垮刚恢复的服务
---
**Q10.【场景题】** 某监控告警显示 Redis CPU 飙升至 90%+,同时发现某个限流 Key 的 Sorted Set[^3] 有 50 万条成员。以下哪个是最优的解决方向?
A. 增大 Redis 服务器内存
B. 将滑动窗口日志(Sorted Set)切换为令牌桶方案
C. 增加滑动窗口的窗口大小
D. 在应用层用本地内存缓存计数结果
> [!info]- 参考答案
> B — Sorted Set 规模过大导致 CPU 飙升,切换到 O(1) 空间的令牌桶/漏桶是最佳解
---
**Q11.** 滑动窗口计数器(N 个子窗口加权估算[^5])中,如果当前时刻在第 8 个子窗口,前 7 个子窗口已过期的部分按什么方式折算贡献度?
A. 全部计入 100%
B. 全部忽略不计
C. 按剩余时间比例折算
D. 按已过期时间的线性函数衰减
> [!info]- 参考答案
> C — 已过期的窗口按其剩余未过期时间占总窗口大小的比例折算贡献度
---
**Q12.** Redis 使用 Lua 脚本来实现限流的核心原因是什么?
A. Lua 语言本身性能优于其他语言
B. Redis 只支持 Lua 作为扩展脚本
C. Lua 脚本原子化[^6]执行,避免多步骤操作产生竞态条件[^7]
D. Lua 脚本更容易被 Go/Java 等语言调用
> [!info]- 参考答案
> C — Redis 单线程模型 + Lua 原子执行,确保读 → 算 → 写不会被并发打断
---
**Q13.** 以下哪个 HTTP 头**不是**标准的限流响应头?
A. `X-RateLimit-Limit: 100`
B. `X-RateLimit-Remaining: 0`
C. `X-RateLimit-Reset: 1690000060`
D. `X-RateLimit-Burst: 50`
> [!info]- 参考答案
> D — 标准限流头包括 Limit(阈值)、Remaining(剩余)、Reset(重置时间戳),Burst 不是标准头
---
**Q14.** 生产环境性能优化中,使用 `EVALSHA`[^8] 替代 `EVAL` 的主要好处是什么?
A. EVALSHA 语法更简洁
B. EVALSHA 可以绕过 Lua 的沙箱限制
C. EVALSHA 无需重复传输脚本内容,节省带宽
D. EVALSHA 执行速度比 EVAL 快 10 倍以上
> [!info]- 参考答案
> C — 先用 SCRIPT LOAD 预加载脚本,后续 EVALSHA 仅传 sha1 哈希值,避免每次传输整个脚本字符串
---
**Q15.** 在多级限流架构的性能优化清单中,为什么推荐用 `SCAN`[^9] 替代 `KEYS` 命令?
A. SCAN 返回的数据更准确
B. KEYS 在数据量大时会阻塞 Redis 服务端,影响其他客户端
C. SCAN 支持更多的通配符匹配模式
D. KEYS 命令已被 Redis 官方废弃
> [!info]- 参考答案
> B — KEYS 是全库扫描的阻塞操作,大库时会严重影响线上;SCAN 分步游标迭代不阻塞
---
## 二、填空题(15 道)
**F1.** Redis 实现令牌桶 Lua 脚本时,使用 `HMSET` 命令写入两个字段:`tokens` 和 \_\_\_\_\_\_。
> [!tip]- 💡 术语:tokens 与 last_refill
> - `tokens`:当前桶中剩余的令牌数量,每次请求到来时检查并扣减。
> - `last_refill`:上一次补充令牌的时间戳,用于计算从上次到现在经历了多少秒,从而推算出这段时间新产生了多少个令牌。
> [!info]- 参考答案
> last_refill
---
**F2.** 被限流的 HTTP 响应应返回状态码 \_\_\_\_\_\_,并在 `Retry-After` 头部中给出建议等待秒数。
> [!info]- 参考答案
> 429
---
**F3.** 生产环境中推荐先通过 \_\_\_\_\_\_[^8] 预加载 Lua 脚本,再通过 EVALSHA 调用以节省带宽并提升性能。
> [!info]- 参考答案
> SCRIPT LOAD
---
**F4.** 当客户端连续多次收到 429 响应时,建议使用 **指数退避 + jitter**[^10] 策略进行重试,避免 "惊群效应"。
> [!tip]- 💡 术语:指数退避 + Jitter
> - **指数退避**:每次重试的等待时间按指数增长,如 `base × 2^attempt`(1s → 2s → 4s → 8s...),避免频繁重试加重服务器负担。
> - **Jitter(随机抖动)**:在指数退避的基础上加入随机偏移,如 `wait = min(base × 2^attempt + random_jitter, Retry-After)`,让不同客户端的重试时间错开,防止所有客户端在同一时刻同时涌入造成"惊群效应"。
> [!info]- 参考答案
> jitter(随机抖动)
---
**F5.** 多级限流架构中,L1 层针对 IP 维度通常采用 \_\_\_\_\_\_ 算法来防刷 / 防攻击。
> [!info]- 参考答案
> 短窗口固定计数(或:固定窗口计数器)
---
**F6.** 滑动窗口日志方案中,Lua 脚本使用 ZREMRANGEBYSCORE 清理过期记录,该操作的时间范围是从 `-inf` 到 \_\_\_\_\_\_。
> [!info]- 参考答案
> now - window * 1000
---
**F7.** 令牌桶初始有 100 个令牌。3 个请求依次到达,各消耗 1 个令牌,请求间隔可忽略不计(elapsed ≈ 0 无补充)。第 4 个请求检查时桶中还剩余 \_\_\_\_\_\_ 个令牌。
> [!info]- 参考答案
> 97
---
**F8.** Redis Key 中的 `id` 是限流维度的路由标识——若要对某个 IP 做限流,`id` 的实际取值应为 \_\_\_\_\_\_。
> [!tip]- 💡 术语:限流维度的路由标识(id)
> Key 中的 `id` 不是固定指某个业务字段,而是**限流维度的路由标识**——你想限制谁,它就等于谁的唯一标识。常见取:
> - **用户级**(L2)→ 用户 ID,如 `rate:limit:user_10086:1717132800`
> - **IP 级**(L1)→ 客户端 IP,如 `rate:limit:192.168.1.42:1717132800`
> - **接口级**(L3)→ 接口路径,如 `rate:limit:/api/register:1717132800`
> - **API Key 级**→ 客户端凭证,如 `rate:limit:app_key_xxx:1717132800`
> 生产环境中通常是**多级组合**:例如同时用 `userId + endpoint` 作为复合 Key。
> [!info]- 参考答案
> 客户端 IP
---
**F9.** 生产环境排查 "偶发性限流不一致" 问题时,应确认核心判断逻辑是否走单个 \_\_\_\_\_\_ 脚本。
> [!info]- 参考答案
> Lua
---
**F10.** 生产环境排查 "内存持续增长" 问题时,可通过 \_\_\_\_\_\_ 命令检查特定 Key 是否已过期(TTL)。
> [!info]- 参考答案
> TTL
---
**F11.** 分布式限流的核心思路是将多个服务实例各自的内存计数器集中到一个共享存储中,这个共享存储需要满足三个条件:原子操作一致性、\_\_\_\_\_\_、以及集群模式可横向扩展。
> [!tip]- 💡 术语:共享存储三要素
> 分布式限流的本质是让所有服务实例看到同一个计数值,因此共享存储必须满足:
> 1. **原子操作一致性**:自增、比较、写入必须是原子性的,避免多实例并发导致超发。
> 2. **低延迟**:每次请求都要查限流状态,额外的延迟不能超过毫秒级,否则限流模块会成为瓶颈。
> 3. **集群模式可横向扩展**:当限流 Key 数量增长时,存储系统需要能够水平扩容,不能出现单点瓶颈。
> [!info]- 参考答案
> 低延迟
---
**F12.** 滑动窗口计数器将总窗口划分为 N 个子窗口,当请求到达第 M 个子窗口时,之前的子窗口按 \_\_\_\_\_\_ 估算当前总量的加权贡献度。
> [!info]- 参考答案
> 剩余时间比例(或:时间占比)
---
**F13.** 令牌桶 Lua 脚本中,EXPIRE 设置 Key 的过期时间为 `ceil(capacity / rate) * 2`,这是一个防止 \_\_\_\_\_\_ 的保护机制。
> [!tip]- 💡 术语:僵尸 Key
> 不再有新请求但仍占用 Redis 内存的 Key。如果没有 EXPIRE,这些 Key 会永远存在,随着时间积累消耗越来越多的内存。`ceil(capacity / rate) * 2` 的含义是:填满整个桶所需的时间(capacity / rate)乘以 2,确保最后一次有活动的 Key 在足够长的时间后自动过期。
> [!info]- 参考答案
> 僵尸 Key(即不再有新请求但仍占用内存的 Key)
---
**F14.** 使用 Pipeline[^11] 减少网络 RTT[^12] 的方式是在限流操作中用 \_\_\_\_\_\_ 命令合并多个 Redis 操作为一个批量事务。
> [!info]- 参考答案
> MULTI/EXEC
---
**F15.** 生产排错中"超出限制大量通过"的症状,往往是因为不同服务器的时钟不同步导致 Lua 脚本中获取的 \_\_\_\_\_\_ 出现异常。
> [!tip]- 💡 术语:now(当前时间戳)
> Lua 脚本中的 `now` 是由应用服务器传入的毫秒级 Unix 时间戳(`time.Now().UnixMilli()`)。如果多台服务器的系统时间不同步,有的服务器 `now` 偏早、有的偏晚,会导致 Lua 脚本计算的 elapsed(elapsed = now - lastRefill)出现负值或异常大值,进而引发令牌补充错误、限流判断失效。排查方法:`date` 命令比对 Redis 和应用服务器的时间。
> [!info]- 参考答案
> now(当前时间戳)
---
## 关联笔记
### 核心文档
- [[REDIS/分布式限流]] — 本题库所属基础笔记,完整的算法演进路线
- [[REDIS/Lua脚本]] — Redis Lua 脚本基础概念与使用模式
### 按知识点索引
| 编号 | 知识点 | 对应文档 |
|------|--------|---------|
| Q3 / Cluster 陷阱 | Hash Tag 路由键 | [[REDIS/路由键与Hash Tag]] |
| Q4 / F6 | Sorted Set 滑动窗口 | [[REDIS/Sorted Set 滑动窗口]] |
| Q11 | 贡献度计算 | [[REDIS/滑动窗口计数器]] |
| Q12 / EVALSHA | Go-Redis Lua 调用 | [[REDIS/Go-Redis Lua 调用指南]] |
| Q13 | 限流 HTTP 响应头 | [[REDIS/限流HTTP响应头]] |
| Q14 | EVALSHA 预加载 | [[REDIS/EVALSHA预加载]] |
| Q15 | SCAN 遍历命令 | [[REDIS/SCAN命令]] |
| F4 | Jitter 抖动 | [[REDIS/Jitter抖动与重试策略]] |
| F5 | 多级限流 | [[REDIS/多级限流架构]] |
| F10 | 内存持续增长 | [[REDIS/内存持续增长排查]] |
| F13 | 僵尸 Key | [[REDIS/僵尸Key防护]] |
| F14 | Pipeline | [[REDIS/Pipeline批量操作]] |
| — | 滑动窗口日志细节 | [[REDIS/滑动窗口日志方案]] |
[^1]: **Redis Cluster**:Redis 的分布式集群模式,数据按照 hash slot(共 16384 个 slot)分布在不同节点上。
[^2]: **Hash Slot**:Redis Cluster 中的数据分片单位。多个 Key 如果要一起操作(如 Lua 脚本中的多 Key),它们必须落在同一个 hash slot。使用 `{}` 包裹的路由键(Hash Tag)可以强制锁定到同一 slot。
[^3]: **Sorted Set**:Redis 的一种数据结构,每个成员关联一个分数(score),集合自动按分数排序。常用于实现精确滑动窗口、排行榜等场景。
[^4]: **惊群效应(Thundering Herd)**:大量客户端在同一时刻同时发起请求,导致刚刚恢复的服务再次被打垮。通过 jitter 随机偏移可以让客户端的重试时间错开。
[^5]: **加权估算**:用多个子窗口的计数按比例估算当前总量,是一种近似而非精确的统计方式。N 越大越精确,但内存开销也越大。
[^6]: **原子性(Atomicity)**:一组操作要么全部执行、要么全部不执行,中间状态不会被其他客户端观察到。Lua 脚本在 Redis 中天然具有原子性。
[^7]: **竞态条件(Race Condition)**:多个并发操作交叉执行导致的结果不确定性问题。例如:读取计数 → 判断超限 → 写入新计数,如果这三步被并发打断,就会出错。
[^8]: **EVALSHA / SCRIPT LOAD**:`SCRIPT LOAD` 先将 Lua 脚本文本发送给 Redis,Redis 计算其 sha1 哈希值并缓存;`EVALSHA` 后续只需传递这个哈希值即可执行对应脚本,避免每次传输冗长的脚本文本。
[^9]: **SCAN**:Redis 提供的分步游标遍历命令,每次返回少量结果并返回新游标继续遍历。不会阻塞服务端,适合大数据量场景。与之对比的 `KEYS` 是一次性全量返回所有匹配 Key,大库时会严重阻塞。
[^10]: **指数退避 + Jitter**:重试策略的经典组合。指数退避防止过早重试浪费资源,jitter 防止大量客户端同时重试引发惊群效应。