系统设计面试:如何设计多租户 API 限流器?
题干与适用场景
请为多租户 API 平台设计限流器:免费租户每分钟 100 次、付费租户每分钟 10,000 次,平台有多个应用实例和区域。请比较算法,说明共享状态、429 响应、故障降级与验证方法。
这道题适合后端、平台和系统设计岗位。公开面试记录给出了按用户维护 token bucket、按时间补充令牌的编码题;系统设计资料则把限流列为多租户 API 的典型讨论。分类依据是跨实例共享状态、租户策略和流量保护,不是某个云厂商的配置记忆。
面试官考察点
- 能否先区分平均速率、突发容量、租户公平和全局保护目标。
- 能否解释 token bucket、fixed window、sliding window 的边界与代价。
- 能否把限流状态放到原子共享存储,并处理热键、分区和时钟问题。
- 能否定义 429、重试提示、降级和观测,使限流器本身不会成为单点。
回答前需要澄清的问题
- 限制对象是 API key、租户、用户、IP,还是它们的组合?匿名请求如何归属?
- 限制是平均速率、严格滑动窗口,还是允许可控突发?是否还需要日配额?
- 多区域要求全局精确,还是允许短暂超额换取可用性?
- 被拒请求应立即返回 429,还是进入有界队列等待?下游是否能承受任何突发?
30 秒回答框架
我会先在网关做粗粒度的 IP 和未认证流量保护,再在应用层按租户策略做精确限流。若 API 允许短突发,默认选 token bucket;每个桶保存令牌数和上次更新时间,按租户维度放在可原子更新的共享存储。超限返回 429 和可计算的重试提示。多区域若不要求精确全局计数,就按区域配额和超额监控换取低延迟;存储不可用时采用明确的 fail-open 或 fail-closed 策略,并持续告警。
分步骤深入解答
先定义预算与公平性
免费租户的 100 次/分钟是长期预算,付费租户的 10,000 次/分钟也是长期预算;两者都需要独立 burst capacity,不能只用一个全局计数器。全局保护还应限制所有租户合计的 RPS,防止单个大租户把数据库连接池耗尽。策略键至少包含租户 ID、API 动作和策略版本,避免不同接口意外共享额度。
选择算法
| 算法 | 主要语义 | 成本与风险 | 适用条件 |
|---|---|---|---|
| Token bucket | 平均速率受限,允许容量以内突发 | 两个状态值,需原子补充与扣减 | 面向用户的 API,短突发可接受 |
| Fixed window | 固定时间段计数 | 窗口边界可能接近两倍流量 | 规则简单、允许近似 |
| Sliding window log | 精确统计窗口内每个请求 | 保存时间戳,内存和清理成本高 | 小规模且需要严格精度 |
| Sliding window counter | 用相邻窗口加权估算 | 近似但内存低,需说明误差 | 大规模公平限流 |
AWS API Gateway 文档明确说明其 throttling 使用 token bucket,令牌速率和 burst 容量分别表达稳态速率与突发上限;超限可能返回 429,但限制是 best effort,不应当当作数学上绝对的天花板。回答时要把“策略目标”和“平台保证”分开。
Token bucket 的不变量
令牌数 tokens 始终位于 [0, capacity]。请求到达时,先用经过时间乘 refill rate 补充令牌并截断到 capacity,再检查是否至少有一个令牌;允许请求就扣减一个。这个顺序保证空闲期间只积累到 burst 上限,不会因为停机时间制造无限额度。
~~~text allow(key, now): state = atomicRead(key) elapsed = max(0, now - state.lastRefill) refilled = min(capacity, state.tokens + elapsed * rate) if refilled < 1: atomicWrite(key, refilled, now) return reject(429) atomicWrite(key, refilled - 1, now) return allow ~~~
伪代码必须在真实实现中由一次 Lua 脚本、事务或等价 compare-and-swap 原子执行;先读后写的两个独立网络请求会在并发下超发令牌。时间戳应由可信的单调时间来源处理,不能让客户端提交时间。
部署层级与共享状态
网关层负责挡住明显的 IP 洪泛和未认证流量;应用层按租户、用户或接口做业务限额。所有实例若只在本地内存计数,负载均衡后一个租户可以把请求分散到多个实例而获得多份额度。共享 Redis、带原子脚本的键值存储或具备条件写的数据库都可以承载状态,选择取决于延迟、精度和故障模型。
多区域有三种取舍:单一全球存储提供更精确的额度但增加跨区延迟;每区独立桶延迟低但可能产生短暂超额;租户先分配区域配额并设全局保护则介于两者之间。必须先问清“精确”是否比“可用”重要,不能把区域拆分后仍声称全局严格不超限。
拒绝、降级与观测
超限返回 429,并提供 Retry-After 或响应头中的剩余额度;客户端应使用有上限的退避,不能立即重试形成反馈环。限流存储故障时,高风险写接口通常 fail-closed 或进入有界队列,低风险读接口可短时 fail-open 但必须有本地熔断、过期时间和总量保护。监控应区分允许率、拒绝率、每租户额度命中、存储延迟、热键、脚本错误和实际下游负载。
高质量示范回答
我会把限流分两层:网关先按 IP 和认证状态做粗粒度保护,应用层再按租户和接口执行业务策略。免费租户和付费租户分别拥有 rate 与 burst 两个参数;默认用 token bucket,因为 API 通常允许短突发而仍需限制长期平均速率。每个桶保存令牌数和上次补充时间,补充、检查和扣减由共享存储中的原子脚本完成。
多个区域如果不要求每次请求都得到全球精确结果,我会按区域分配配额,并用全局指标检测异常超额;若支付或配额结算必须严格,就把决策放到一致性更强的中心服务并接受延迟。超限返回 429 与重试提示,存储故障按接口风险选择 fail-closed、短时 fail-open 或有界队列。最后用并发压测验证突发容量、窗口边界、租户公平、故障恢复和下游负载,而不是只检查 429 数量。
常见错误
- 只说“用 Redis 计数” → 没有算法语义和原子更新 → 写出桶状态、脚本边界与故障策略。
- 所有租户共用一个限额 → 大租户可以挤占小租户 → 按租户和接口拆分策略,并补充全局保护。
- 把 fixed window 当成严格每分钟上限 → 窗口边界可形成近两倍短时流量 → 说明误差并换 sliding 或 token bucket。
- 限流存储故障就无限放行 → 保护组件失效后下游先崩溃 → 按业务风险选择有界降级并告警。
- 让客户端立即重试 429 → 拒绝流量变成更高负载 → 返回重试提示并要求退避、抖动和上限。
追问及应对
同一租户的请求被分到 20 个实例,如何避免额度放大?
把桶状态放到所有实例可见的共享存储,并以租户策略键做原子更新。若暂时只能本地计数,就必须把它表述为近似保护,并设置网关总量上限,不能声称全局精确。
跨区域必须严格执行每分钟 100 次,怎么办?
优先选择一致性更强的全球决策点或按租户固定主区域串行化扣减;代价是跨区延迟和区域故障时的可用性下降。若业务允许短暂超额,可改用区域配额加全局对账,并把误差写进 SLO。
限流器本身变慢,如何防止它拖垮 API?
给限流调用设置严格超时和熔断,避免无限等待;预留本地保护上限,在共享存储不可用时按接口风险降级。把限流延迟、超时和脚本错误单独监控,不能只看业务接口的最终成功率。
什么时候应该排队而不是返回 429?
如果请求可异步处理、等待不会超过用户预算且下游必须平滑输入,可以使用有界队列;队列满时仍需拒绝。交互式读请求或不可取消的长队列通常直接 429 更诚实。
如何验证 fixed window 的边界问题?
构造窗口结束前发送一批、窗口开始后立即发送一批的测试,统计任意连续窗口内的真实请求数;再对 token bucket 的空闲积累、满桶突发和并发扣减做同样的时间驱动测试。