cswork / 系统设计

第 4 章 / 共 57 章 · 约 7 分钟

案例 2:分布式限流器

本页目录
  1. 1. 需求澄清
  2. 2. 容量估算
  3. 3. 接口
  4. 4. 算法对比
  5. 5. 高层架构
  6. 6. 深入讨论
  7. 7. 常见追问
  8. 8. 回答分级
  9. 9. 不同级别的期望
  10. 10. 面试节奏建议
  11. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

限流器看起来是一道“组件题”,考察面却很全:算法选择、并发原子性、分布式一致性,以及故障时到底该放行还是拒绝。

1. 需求澄清

先问清楚三个问题,答案会直接改变设计:

  1. 限谁:按用户 ID、API Key、IP,还是组合维度?
  2. 放在哪:客户端(不可信,只能辅助)、API 网关,还是每个服务内部?
  3. 多精确:能否接受偶尔超出几个百分点?

功能需求

  • 按规则限流,例如“免费用户每分钟 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 分钟左右讲完需求和算法选型,然后把重点放在分布式部分:原子性、延迟预算、故障策略。面试官往往更关心你如何保证限流器“不会变成新的故障点”,而不是你能背出几种算法。

章节测验

6 道选择题
  1. 固定窗口限额 100 次/分钟。用户在 0:59 发 100 次、1:00 再发 100 次,结果是?
  2. 滑动窗口计数:上一窗口 60 次,当前窗口已过去 40%、已有 50 次,限额 100。估计值和结果是?
  3. 令牌桶的容量 B 和补充速率 r 分别控制什么?
  4. 两个网关节点同时 GET 到计数 99,各自 SET 为 100 并放行。正确的修复是?
  5. Redis 集群故障时,“发送短信验证码”接口更合理的策略是?
  6. 为什么一般不推荐“每个网关节点各自限 limit/N”?

章节讨论

正在加载讨论…