第 4 章 / 共 57 章 · 约 7 分钟
案例 2:分布式限流器
先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。
先写你的思路
只存在这台设备上;登录后会存进你的账号。
限流器看起来是一道“组件题”,考察面却很全:算法选择、并发原子性、分布式一致性,以及故障时到底该放行还是拒绝。
1. 需求澄清
先问清楚三个问题,答案会直接改变设计:
- 限谁:按用户 ID、API Key、IP,还是组合维度?
- 放在哪:客户端(不可信,只能辅助)、API 网关,还是每个服务内部?
- 多精确:能否接受偶尔超出几个百分点?
功能需求
- 按规则限流,例如“免费用户每分钟 100 次,付费用户每分钟 1000 次”
- 超限返回
429 Too Many Requests,并带上Retry-After和剩余额度等响应头 - 规则可动态修改,无需重新部署
非功能需求
- 低延迟:每个请求的额外开销控制在 1~2 ms 以内
- 高可用:限流器自身出故障,不能让整个 API 不可用
- 分布式:几百个网关节点共享同一份额度
- 可以容忍轻微不精确
2. 容量估算
- 全站峰值 100 万 QPS,每个请求一次限流检查 → 每秒 100 万次计数器操作
- 单个 Redis 分片每秒可处理十万级简单操作 → 至少 10~20 个分片,并预留余量
- 活跃 key 1000 万个,每个状态约 100 B → 约 1 GB 内存,完全放得下
结论:存储量不是问题,吞吐和延迟才是。
3. 接口
// 内部接口(网关调用)
allow(key, rule_id, cost=1) → { allowed, remaining, reset_at }
// 规则配置
- match: { route: /v1/search, tier: free }
limit: 100
window: 60s
algorithm: token_bucket
超限时的 HTTP 响应:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
4. 算法对比
| 算法 | 保存的状态 | 精度 | 突发 | 备注 |
|---|---|---|---|---|
| 固定窗口计数 | 1 个计数器 | 低 | 窗口边界可达 2 倍 | 最简单 |
| 滑动窗口日志 | 每个请求的时间戳 | 精确 | 无 | 内存随每个 key 的限额(窗口内允许的请求数)线性增长 |
| 滑动窗口计数 | 2 个计数器 | 近似 | 较平滑 | 性价比高 |
| 令牌桶 | 令牌数 + 上次时间 | 好 | 允许,最多到桶容量 | 业界最常用 |
| 漏桶 | 一个队列 | 好 | 被整形为恒定速率 | 适合保护脆弱的下游 |
固定窗口的边界问题:限额 100 次/分钟,用户在 0:59 发 100 次、1:00 再发 100 次,两秒内通过了 200 次。
滑动窗口计数用上一窗口按比例折算:
估计值 = 当前窗口计数 + 上一窗口计数 × (1 - 当前窗口已过去的比例)
例如上一分钟 80 次,当前分钟过去了 25%、已有 30 次:30 + 80 × 0.75 = 90,未超过 100,放行。
令牌桶:容量 B,每秒补充 r 个。不需要定时器,请求到达时按时间差惰性补充:
tokens = min(B, tokens + (now - last) * r)
if tokens >= cost: tokens -= cost → 放行
else → 拒绝
B 决定允许多大的突发,r 决定长期平均速率,两个参数语义清晰、容易向产品解释,这是它最受欢迎的原因。
5. 高层架构
查看图表源码
flowchart TD
accTitle: 分布式限流器的高层架构
client["Client"] --> waf["CDN / WAF(IP 级粗粒度防护)"]
waf --> gw["API Gateway"]
gw --> lim["限流客户端"] --> redis[("Redis Cluster(计数状态)")]
rules["规则服务"] -->|"推送到本地缓存"| lim
gw --> backend["后端服务(资源级限流、过载保护)"]
规则由配置中心推送到每个网关节点的本地缓存,热路径上只访问一次 Redis。
6. 深入讨论
6.1 原子性:先 GET 再 SET 有竞态,两个节点同时读到 99 都会放行。解决办法是把“读取-判断-写入”放进一个 Redis Lua 脚本原子执行。固定窗口可以用 INCR,但首次创建时设置过期也要放进同一脚本,否则进程在两步之间崩溃,key 会永不过期。
在 Redis Cluster 上还有一个限制:一个 Lua 脚本访问的所有 key 必须落在同一个 slot。滑动窗口要同时读当前和上一窗口两个计数器,就要用 hash tag 把它们放进同一个 slot,比如 rl:{user42}:1700000040 和 rl:{user42}:1699999980,花括号里的部分决定 slot。
6.2 时间来源:各网关节点的时钟有偏差,令牌桶里的 now 最好在 Lua 脚本里用 Redis 的 TIME 命令取服务器时间,让所有网关共用一个时钟;或者明确容忍小偏差。
6.3 降低延迟和 Redis 压力
- 网关与 Redis 同机房部署,使用连接池和 pipeline
- 本地预取额度:节点先从全局领一小批额度在本地消费,用完再领,用精度换访问次数
- 按节点平均分配(每个节点 limit/N)最简单,但流量不均时会误杀或超发,一般不推荐
6.4 热点 key:某个大客户的所有请求都落到同一个 Redis 分片。可以把它拆成多个子 key 分散到不同分片;或者单独对它启用本地预取,每个网关一次领一批配额在本地扣减;也可以在本地先累计计数,每隔几十毫秒批量同步到 Redis,用短时间的轻微超发换掉每个请求一次远程调用。
6.5 故障策略:fail-open 还是 fail-closed?
- 普通读接口选 fail-open:Redis 不可用就放行,再用进程内的本地限流器兜底,保证业务可用
- 登录、发短信、调用按次计费的第三方选 fail-closed:宁可拒绝,也不能被刷爆成本或被撞库
- 访问 Redis 必须设置很短的超时并配合熔断,限流器绝不能把请求卡住
6.6 多区域:全局精确限流需要跨区域同步,延迟无法接受。常见做法是按区域切分额度,或各区域独立计数、异步汇总,接受短时间超发。
6.7 分层防护:边缘层按 IP 防 DDoS,网关层按 API Key 管配额,服务层做并发控制和过载丢弃(load shedding)。限流不是一个点,而是一套纵深防御。
6.8 客户端退避:被限流的客户端如果立刻重试,会形成重试风暴,让情况更糟。服务端通过 Retry-After 告诉客户端何时再来,SDK 采用指数退避加随机抖动(jitter),把重试在时间上打散。
7. 常见追问
- 请求成本不一样怎么办? 一次批量导出比一次查询贵得多,或者按大模型 token 数计费。令牌桶天然支持
cost参数,每次扣除对应数量的令牌即可。 - 要限并发而不是限速率? 对长连接或慢请求,真正要限的是“同时进行中的数量”。用计数信号量:开始时加一、结束时减一,并给每次占用设置超时,防止进程崩溃后额度泄漏。
- 规则改错了怎么办? 规则变更要像代码发布一样灰度:先以“只记录、不拦截”的影子模式(shadow mode)运行,观察会误伤多少请求,再逐步生效。
8. 回答分级
Bad:只会背一个算法;用关系数据库存计数;没意识到并发竞态;默认“Redis 不会挂”。
Good:先澄清限流维度和位置;对比至少两种算法并给出选型理由;用 Redis + Lua 保证原子性;返回正确的 429 和响应头。
Great:量化 Redis 分片数和延迟预算;讨论本地预取与精度的取舍;按接口区分 fail-open/fail-closed;处理热点 key、规则热更新、多区域和分层防护;能讲清“限流”(保护公平与配额)和“过载保护”(保护自己)的区别。
9. 不同级别的期望
| 级别 | 期望 |
|---|---|
| Mid | 正确实现一种算法,知道需要集中式存储和原子操作 |
| Senior | 主动比较算法,处理竞态与故障策略,给出合理的延迟预算 |
| Staff | 把限流放进整体流量治理:分层、多区域、成本敏感接口、可观测性(被限流比例、误杀告警),以及规则变更的灰度发布流程 |
10. 面试节奏建议
这道题很容易陷进算法细节。建议用 10 分钟左右讲完需求和算法选型,然后把重点放在分布式部分:原子性、延迟预算、故障策略。面试官往往更关心你如何保证限流器“不会变成新的故障点”,而不是你能背出几种算法。