Files
cs-note/hzh/REDIS/分布式限流.md
T

474 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [redis, rate-limiting, distributed-system, lua-script, sliding-window, token-bucket]
create time: 2026-05-30 14:30
---
# 分布式限流(基于 Redis)
## 概述
假设你写了一个用户注册接口,设置了每秒最多处理 100 个请求。在单机环境下这没问题——一个计数器就够了。
但当你把服务扩展到 10 台机器后,问题来了:**每台机器各自计数,实际吞吐量变成了 1000 QPS**,远超数据库的承载能力。
> [!question]- 💡 思考:你怎么解决?
>
> 现在有 10 个服务实例,每个都在自己的内存里维护计数器。用户的一个请求打到任意一台机器上。
>
> 想一想——如果让你设计一个方案,让所有机器看到同一个数字,你会怎么做?
答案是:**把所有计数器的状态集中到一个共享存储**。Redis 天然适合这个场景——原子操作保证一致性、低延迟支撑高频判断、集群模式可以横向扩展。
```mermaid
flowchart LR
subgraph "多实例应用"
I1["📦 实例 A"]
I2["📦 实例 B"]
I3["📦 实例 C"]
end
GW["🌐 API Gateway"] --> I1
GW --> I2
GW --> I3
I1 -->|共享 Key| R(("🔴 Redis<br/>统一限流状态"))
I2 -->|共享 Key| R
I3 -->|共享 Key| R
style R fill:#ffebee,stroke:#ef5350,stroke-width:3px,color:#000
```
接下来我们从最简单的方案开始,一步步看到边界和问题,再引入更复杂的算法来解决它们。**每一节的"缺陷"都是下一节存在的理由。**
---
## 一、从固定窗口开始
### 1.1 最简单的方法:固定窗口计数器
核心思想就两句话:
1. 把时间切成一个个**等长的窗口**(比如每 1 秒一个窗口)
2. 每个窗口内用一个**计数器**记录请求数,到达阈值就拒绝
```mermaid
flowchart TD
Req["请求到达"] --> TS["取当前时间戳<br/>映射到当前窗口起始点"]
TS --> Key["构造 Key:<br/>rate:limit:{id}:{windowStart}"]
Key --> INCR["INCR 自增"]
INCR --> First{"首次?"}
First -- 是 --> Expire["EXPIRE 设过期时间"]
First -- 否 --> SkipExpire["跳过 EXPIRE"]
Expire --> Check{"count > max?"}
SkipExpire --> Check
Check -- 是 --> Reject["❌ 拒绝 (429)"]
Check -- 否 --> Allow["✅ 放行"]
```
对应的 Go + Lua 实现:
```go
const fixedWindowLua = `
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
`
```
调用时这样构造 Key:
```go
// 将时间戳对齐到窗口起点,确保同一窗口共用一个 Key
windowStart := time.Now().Unix() / int64(windowSeconds) * int64(windowSeconds)
key := fmt.Sprintf("rate:limit:%s:%d", userId, windowStart)
```
这段代码短小精悍,看起来已经够用了。但它有一个**隐蔽的边界问题**。
> [!info]- 💡 Redis Key 中的 `id` 是什么?
>
> 文中的 `id` 不是固定指某个业务字段,而是**限流维度的路由标识(identifier)**——你想限制谁,它就等于谁的唯一标识。常见取:
>
> | 限流维度 | `id` 实际取值 | Key 示例 |
> |---------|-------------|----------|
> | **用户级**(L2) | 用户 ID / 账号 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,做到细粒度控制。
### 1.2 边界突发:窗口的"双杀"效应
> [!warning]- ⚠️ 先看一个具体例子
>
> 限制 100 请求/秒,窗口大小 1 秒。考虑两个相邻窗口的交界处:
>
> | 时刻 | 所在窗口 | 行为 |
> |------|---------|------|
> | t = 0.9s | 第 0 秒窗口 | 计数 +1,累计 100 |
> | t = 1.1s | 第 1 秒窗口 | **计数归零** +1,累计 100 |
>
> **在 0.2 秒内通过了 200 个请求**,突增量达到了限制的两倍。
为什么会这样?因为**前后两个窗口各自独立计数,互不关心**。窗口切换的那一刻,新旧两个窗口可以同时满负荷运行。
这个问题的根源在于「窗口」太粗了。我们换一个思路:**不要只盯着当前窗口,而是看最近一段时间内的全部请求。**
这就是下一节要讲的滑动窗口。
---
## 二、更精确:滑动窗口
### 2.1 滑动窗口计数器
先别急着看代码,想想你直觉上会怎么改进固定窗口:
> 与其让两个相邻窗口各算各的,不如用**多个小窗口加权平均**来估算当前窗口的总量?
比如把 1 秒分成 N=10 个子窗口,每个子窗口 100ms。当请求在第 8 个窗口时,前 7 个已经过期的窗口按剩余时间比例折算贡献度:
```mermaid
flowchart LR
subgraph "N=3 个子窗口示意图"
W1["⬛ 旧窗口<br/>已过期的部分 × 占比"]
W2["🟨 当前完整窗口<br/>100% 计入"]
W3["🟩 新窗口<br/>刚开始,从 0 累积"]
end
Current["当前请求时刻"] --> Calc["weightedCount = W1.weight × W1.count + W2.count"]
Calc --> Compare{"total > max?"}
Compare -- 是 --> Reject["拒绝"]
Compare -- 否 --> Allow["放行"]
```
**效果对比:**
| 维度 | 固定窗口 | 滑动窗口 (N=10) |
|------|---------|----------------|
| **精度** | 低(可突破 2 倍) | 中(突限量缩小到 ~1/N) |
| **空间** | O(1) per window | O(N) 子窗口 |
| **复杂度** | 极低 | 中等 |
> [!tip] 折中选择
> 如果你的业务对限流精度要求不高(比如日配额限流),N=10 的滑动窗口计数器已经足够,且内存开销很小。
>
> 如果需要**逐条精确统计**,可以用 Sorted Set 实现"日志级"滑动窗口(见 2.2)。
### 2.2 日志级滑动窗口(Sorted Set)
这是最精确的方案:每条请求的时间戳作为 Sorted Set 的成员(score),通过范围查询和清理来实现窗口统计。
**流程:**
```mermaid
sequenceDiagram
participant App as 应用实例
participant R as Redis
App->>R: ZREMRANGEBYSCORE key -(now-T] now<br/>清理过期记录
R-->>App: 返回清理数量
App->>R: ZCARD key<br/>统计当前窗口内请求数
R-->>App: currentCount
alt currentCount >= max
App->>App: ❌ 拒绝 (429)
else currentCount < max
App->>R: ZADD key now member<br/>EXPIRE key TTL
App->>App: ✅ 放行
end
```
关键是把三个步骤(清理 → 计数 → 插入)放在**一个 Lua 脚本**里,确保原子性:
```go
const slidingWindowLua = `
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小 (秒)
local maxLen = tonumber(ARGV[2]) -- 最大请求数
local now = tonumber(ARGV[3]) -- 毫秒级时间戳
local member = ARGV[4] -- 唯一 ID (uuid / requestId)
-- 清理窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window * 1000)
-- 统计当前窗口内的数量
local count = redis.call('ZCARD', key)
if count >= maxLen then
return count -- 超限
end
-- 添加新记录
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)
return count + 1
`
```
> [!warning]- 代价与取舍
>
> | 维度 | 滑动窗口计数器 | 滑动窗口日志 |
> |------|-------------|------------|
> | **精度** | 近似 | 精确到每一条 |
> | **空间** | O(N) 子窗口 | O(maxQPS × window) |
> | **何时不用** | — | 高并发下 sorted set 太大 |
>
> 以 maxQPS=1000、窗口 60s 为例,需要存储 **6 万条** Sorted Set 记录。对于高频 API 网关,这可能成为瓶颈——这时应该切换到令牌桶或漏桶方案。
---
## 三、换个角度:令牌桶 & 漏桶
前面的算法都在回答一个问题:"过去一段时间内有多少请求?"但还有一种不同的思考方式——**不管输入节奏,规定系统能处理的速率。**
### 3.1 为什么需要"桶"模型?
回想一下固定窗口的问题:它在窗口边界处会出现突发。令牌桶的思路完全不一样:
> 想象一个桶,你以**恒定速度**往里灌水(补充令牌),下面的排水孔以**恒定速度**漏水(处理请求)。桶有上限,水满了就溢掉;桶空了,新来的水流不进。
关键在于:**桶在空闲时可以攒下多余的令牌**。这意味着短时间内的突发流量也能被容忍——只要之前积累够了就行。这对用户体验友好得多。
```mermaid
flowchart TD
subgraph "🪣 令牌桶内部"
TANK["令牌桶<br/>容量 = maxBurst"]
FILL["⚡ 固定速率 r/s 持续添加令牌<br/>最多填满到 maxBurst"]
end
REQ["请求到来"] --> CHECK{桶中有令牌?}
CHECK -- 是 --> CONSUME["消耗 N 个令牌<br/>✅ 放行"]
CHECK -- 否 --> REJECT["❌ 拒绝 / 等待"]
FILL -.-> TANK
CONSUME -.->|"桶 - N"| TANK
```
### 3.2 令牌桶 vs 漏桶:到底有什么区别?
这两个概念经常混淆,核心区别只有四个字:**允许/不允许突发**。
| 维度 | 令牌桶 🪣 | 漏桶 🕳 |
|------|---------|-------|
| **突发** | ✅ 允许(空闲时令牌会累积) | ❌ 不允许(多余直接拒绝) |
| **输出形态** | 跟随输入节奏 | 始终匀速 |
| **隐含队列** | 无(拒绝即拒) | 有(先入桶排队再处理) |
| **保护对象** | 对客户端更友好 | 对下游更安全 |
| **典型场景** | API 网关、第三方配额 | 数据库连接池、消息队列消费者 |
```mermaid
flowchart TD
Choice["突发流量来了怎么办?"] --> Q{"是否需要允许合理突发?"}
Q -- "是,给客户端好体验" --> TB["🪣 选令牌桶<br/>• REST API 限流<br/>• 用户多次点击不都失败"]
Q -- "否,稳定压住下游" --> LB["🕳 选漏桶<br/>• DB 连接池限速<br/>• 防止后端被打满"]
```
### 3.3 Redis + Lua 实现令牌桶
**为什么要用 Lua?** 因为令牌桶的操作涉及三步:读当前令牌数 → 计算补充量 → 写回新值。这三个步骤如果不原子化,多实例并发时就会超发令牌。Redis 的单线程模型 + Lua 脚本天然解决这个问题。
```mermaid
flowchart TD
Req["请求到达"] --> Read["读取 tokens + last_refill"]
Read --> Calc["计算补充:<br/>newTokens = min(capacity,<br/>tokens + elapsed × rate)"]
Calc --> Check{tokens ≥ needed?}
Check -- 是 --> Consume["tokens -= needed<br/>✅ 放行"]
Check -- 否 --> Decline["❌ 拒绝<br/>返还需等待时长"]
Consume --> Writeback["写回 tokens + 更新时间<br/>设置 TTL 防僵尸 Key"]
Decline --> Writeback
```
完整的 Lua 脚本:
```go
const 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)}
`
```
对应 Go 封装:
```go
func TokenBucketAllow(ctx context.Context, c *redis.Client, id string, capacity float64, rate float64) (bool, int64, error) {
key := fmt.Sprintf("rate:tokenbucket:%s", id)
now := time.Now().UnixMilli()
result, err := c.Eval(ctx, tokenBucketLua, []string{key}, capacity, rate, 1, now).IntSlice()
if err != nil {
return false, 0, err
}
// result[0]: 1=放行 0=拒绝, result[1]: 下次可用毫秒数
return result[0] == 1, result[1], nil
}
```
---
## 四、生产实践:组合与优化
单一种算法通常不够用。现实中的限流体系像洋葱一样层层叠加——**每层保护不同的目标,用不同的算法。**
### 4.1 多级限流架构
```mermaid
flowchart TD
Req["请求进来"] --> L1
L1["🔴 L1: IP 维度<br/>防刷 / 防攻击"] -- pass --> L2
L2["🟡 L2: 用户维度<br/>控制每日/每月配额"] -- pass --> L3
L3["🟢 L3: 接口维度<br/>保护服务峰值"] -- pass --> Biz["执行业务逻辑"]
L1 -- fail --> R["返回 429"]
L2 -- fail --> R
L3 -- fail --> R
style R fill:#ffebee,color:#000
```
每层的典型配置:
| 层级 | 限流目标 | 推荐算法 | 典型参数 |
|------|---------|---------|---------|
| **L1 IP 层** | 防暴力扫描、恶意攻击 | 短窗口固定计数 | 100 req/s/IP |
| **L2 用户层** | 控制付费/免费配额 | 天级滑动窗口日志 | 1000 req/day |
| **L3 接口层** | 保护实例不被打满 | 令牌桶 | 5000 req/s/endpoint |
### 4.2 优雅降级:被限流时该说什么
被限流的请求不应静默丢弃,而应给出明确信号:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 2 # 建议客户端等待的秒数
X-RateLimit-Limit: 100 # 限流阈值
X-RateLimit-Remaining: 0 # 当前窗口剩余配额
X-RateLimit-Reset: 1690000060 # 窗口重置时间戳
{
"error": {
"code": "RATE_LIMITED",
"message": "请求过于频繁,请稍后再试",
"retry_after_seconds": 2
}
}
```
> [!tip]- 客户端收到 429 后该怎么做?
>
> 1. **遵守 `Retry-After` 头部**进行等待重试
> 2. 使用**指数退避 + jitter**:`wait = min(base × 2^attempt + random_jitter, Retry-After)`
> 3. **不要**立即无脑重发——这会造成 "惊群效应"(thundering herd),再次打垮恢复中的服务
### 4.3 性能优化清单
```mermaid
flowchart LR
Pipelines["Pipeline 减少 RTT<br/>MULTI/EXEC 合并命令"]
EvalSHA["EVALSHA 预加载 Lua<br/>避免重复传输脚本"]
HashKey["Hash 压缩 Key 数<br/>HSET 替代多个独立 Key"]
SCAN["SCAN 替代 KEYS<br/>避免阻塞其他客户端"]
Pipelines --> Perf["⚡ 整体性能"]
EvalSHA --> Perf
HashKey --> Perf
SCAN --> Perf
```
| 技巧 | 收益 | 注意 |
|------|------|------|
| **Pipeline** | 降低网络往返,QPS 翻倍 | 注意批量命令不能拆成单独事务 |
| **EVALSHA** | 节省带宽,Lua 调用提速 | 需先用 SCRIPT LOAD 预加载脚本 |
| **Hash 聚合 Key** | 减少内存碎片和过期管理开销 | 设计时就要规划好聚合粒度 |
| **SCAN 替代 KEYS** | 避免大库扫按时阻塞服务端 | KEYS 在数据量大时影响严重 |
---
## 五、排错指南
> [!example]- 出了问题先从这些方向排查
| 症状 | 可能原因 | 排查方法 |
|------|---------|---------|
| 超出限制大量通过 | 时钟不同步导致 Lua 中 `now` 异常 | `date` 比对 Redis 和应用服务器 |
| Redis CPU 飙高 | 滑动窗口日志 Sorted Set 规模过大 | `MEMORY USAGE key` 查大 Key;换令牌桶 |
| 偶发性限流不一致 | Pipeline 非原子性拆分了判断逻辑 | 确认核心判断走单个 Lua 脚本 |
| 内存持续增长 | Key 的 EXPIRE 未生效 | `TTL key` 检查是否已过期 |
| Cluster 下限流失效 | Key 跨 slot,Lua 无法多 Key 操作 | 使用 Hash Tag `{user123}.daily` 锁定 slot |
> [!warning]- Redis Cluster 的关键陷阱
> Lua 脚本中涉及多个 Key 时,它们必须在**同一个 hash slot**。默认的 Key 格式可能导致不同实例落在不同 slot 上。
>
> **解决方法:** 用 `{}` 包裹路由键,强制 Hash Tag 一致:
> ```
> rate:limit:{user123}:daily ← {} 保证路由到同一 slot
> rate:limit:{user123}:hourly
> ```
---
## 关联笔记
### 进阶阅读(按知识点拆分)
- [[路由键与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 网关层的限流插件集成