限流设计
我决定不再裸奔
上次流量洪峰后,我躺在椅子上盯着天花板发呆。虽然最后扛过去了,但那种系统即将崩溃的无力感让我后怕。
更糟糕的是,第二天我又发现了新问题:
用户 A:用脚本每秒发 1000 次请求创建短链接
用户 B:疯狂刷某个短链接,每秒点击 500 次
用户 C:写了个爬虫,在 1 分钟内遍历了 10 万个短链接虽然总流量不算大,但这些人像洪水一样涌入,正常用户的请求反而被挤掉了。我的系统像个没有红绿灯的十字路口,谁抢得凶谁先过。
我意识到:限流是保护系统的第一道防线。
我需要一个”红绿灯”🚦——让请求有序通过,超出的部分礼貌地拒绝。
我研究了一周的限流算法
限流的核心很简单:限制单位时间内的请求数量。
正常流量: ──────○──────○──────○──────○────── ✅ 放行
↓ ↓ ↓ ↓ ↓
限流后: ──────○──────×──────○──────×────── ✅ 部分拒绝
↓ ↓ ↓
系统负载: ▓▓▓▓▓░░░░░▓▓▓▓▓░░░░░▓▓▓▓▓ 稳定可控但怎么实现这个”限制”,我花了一周时间研究了三种经典算法。
固定窗口计数器:简单但有坑 🪟
最简单的方式——把时间切成固定大小的窗口,每个窗口内统计请求数。
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
我一开始觉得这个方案挺好的,直到我在测试时发现了一个致命问题:边界突刺。
时间轴: |← 窗口1 →|← 窗口2 →|
请求量: 100 100
↑ ↑
实际流量: ...99|100 1|99|100...
↑
这里瞬间通过了 200 个请求!
(窗口1末尾 100 + 窗口2开头 100)在窗口边界处,实际流量可能达到限流值的 2 倍!这个缺陷让我无法接受。
滑动窗口:精确但消耗内存 📊
为了解决固定窗口的边界问题,我研究出了滑动窗口——让窗口”滑动”起来。
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
滑动窗口解决了边界突刺问题,精确度很高:
固定窗口: |████████|████████|████████|
↑ 边界突刺
滑动窗口: ...████████▓
╱ 窗口随时间平滑滑动| 特性 | 固定窗口 | 滑动窗口 |
|---|---|---|
| 实现难度 | 简单 | 中等 |
| 内存消耗 | 低(1 个计数器) | 中(存时间戳) |
| 精确度 | 低(有边界突刺) | 高 |
| 适用场景 | 要求不高的场景 | 精确限流 |
但我发现一个问题:滑动窗口需要存储每个请求的时间戳,内存消耗会随着流量增长。对于高并发系统,这可能成为瓶颈。
令牌桶:我选择了它 🪣⭐
研究到最后,我发现了业界最常用的限流算法——令牌桶。
核心思想很简单:以固定速率往桶里放令牌,每个请求拿走一个令牌。
令牌以固定速率放入
↓ ↓ ↓
┌─────────────┐
│ 🪣 令牌桶 │ ← 桶满了就不再放
│ 🪙🪙🪙🪙🪙 │
│ 🪙🪙🪙🪙 │ ← 最多装 max_tokens 个
└──────┬──────┘
│
请求来了拿一个令牌
↓
有令牌?✅ 放行
没令牌?❌ 拒绝落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
令牌桶最大的优点是允许突发流量 🔥:
正常情况:桶里有 200 个令牌
突发 180 个请求:全部通过 ✅(桶里还有 20 个)
随后恢复:以每秒 100 个的速率补充令牌这对我的短链接服务特别友好——平时积累令牌,突发时可以一次性消耗。
漏桶:强制匀速 🚰
我也研究了漏桶算法——请求像水一样倒入桶中,以固定速率从底部漏出。
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
但对于短链接跳转场景,漏桶的”强制匀速”特性太严格了。用户点击短链接时,我不希望他们感受到延迟。
四种算法的最终对比
| 算法 | 突发流量 | 实现难度 | 内存消耗 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | ❌ 不支持 | ⭐ 简单 | ⭐ 低 | 粗粒度限流 |
| 滑动窗口 | ❌ 不支持 | ⭐⭐ 中 | ⭐⭐ 中 | 精确限流 |
| 令牌桶 | ✅ 允许突发 | ⭐⭐ 中 | ⭐ 低 | API 限流(推荐) |
| 漏桶 | ❌ 匀速处理 | ⭐⭐ 中 | ⭐⭐ 中 | 流量整形 |
最终,我选择了令牌桶——既能限流,又能容忍合理的突发流量。
我落地了分布式限流
选定算法后,我开始落地。单机限流只能保护单个实例,在分布式系统中,我需要全局限流。
我把令牌桶限流器拆成几个关键动作:
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
为什么用 Lua 脚本?因为在 Redis 中执行 Lua 脚本是原子性的,可以避免并发竞争问题。
我设计了分层限流策略
不同的场景需要不同的限流策略。我设计了四级限流:
┌──────────────────────────────────────────┐
│ 全局限流:整个系统 100,000 QPS │ ← 保护整体
├──────────────────────────────────────────┤
│ 用户限流:每用户 100 QPS │ ← 防止单用户滥用
├──────────────────────────────────────────┤
│ 接口限流:创建接口 50 QPS/用户 │ ← 保护写操作
├──────────────────────────────────────────┤
│ IP 限流:每 IP 200 QPS │ ← 防止爬虫
└──────────────────────────────────────────┘落地思路:
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
HTTP 429 响应的最佳实践
当请求被限流后,我返回标准的 HTTP 429 响应:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1672531200
{
"error": "Too Many Requests",
"message": "API rate limit exceeded",
"retry_after": 60
}这样客户端就知道什么时候可以重试,而不是盲目地不断请求。
限流保住了系统,但用户体验不好
部署了限流后,系统终于不再被恶意用户打垮了。但我发现了一个新问题:
正常用户在高峰期也被限流了。
短链接服务的流量特点是:突发性强、地域集中。比如某个营销活动在上午 10 点推送,大量用户同时点击,限流虽然保护了系统,但用户体验很差——很多人看到”请求过于频繁,请稍后再试”。
我在想,能不能让用户离得更近一些?把压力分散到边缘节点?
答案可能是 CDN。
想一想
- 令牌桶和漏桶的核心区别是什么?为什么我选择了令牌桶?
- 如果 Redis 挂了,我的分布式限流会怎样?应该放行还是拒绝?
- 除了返回 429,还有没有更好的处理方式来提升用户体验?