题干与适用场景
为一个多租户 API 设计分布式限流器。规则按 (tenantId, routeId) 生效,每条规则包含持续速率、突发容量和单次请求成本。系统部署 100 个无状态 API 网关,峰值为每秒 100 万次限流检查;面试中暂定限流链路新增延迟目标为 p99 不超过 5 毫秒,可用性目标为 99.99%。这些数字是本题的设计输入,不是某个供应商的性能承诺。
超限请求返回 HTTP 429,并给出可执行的重试时间。普通业务接口在限流存储短暂故障时优先保持可用,登录、昂贵写入等高风险接口可采用更严格的故障策略。系统要支持规则动态发布、灰度观察、热点租户、多地域部署和水平扩容;计费、DDoS 清洗、排队调度和后端自适应负载丢弃不在本题范围内。
这道题适合中高级后端、平台和基础设施岗位。面试官要看候选人能否把“一个请求是否消耗配额”落实成可扩展、可观测且有明确失败语义的同步决策链路。
面试官考察点
强回答会先锁定限流维度、准确性和故障语义。若 100 个网关各自保存完整令牌桶,同一租户可在每个网关分别消费配额,实际放行量会随实例数和负载分布变化。共享状态或经过租约约束的本地状态才有全局含义。
第二个信号是把原子边界画对。令牌桶的一次判断包含读取旧余额、按时间补充令牌、判断余额、扣减和写回。把这些步骤拆成多次网络请求,即使单条 Redis 命令原子,也会让并发请求读到同一余额并重复消费。读—算—写必须在同一个短 Lua 脚本或等价的服务端原子操作中完成。
第三个信号是有数量级。峰值 100 万次检查/秒意味着数据面也要承受 100 万次同步决策,不能在请求路径上查配置数据库。假设有 1000 万个活跃桶,键平均 64 字节,两个 8 字节状态字段,则逻辑数据约为 10,000,000 × 80 B = 800 MB;Redis 对象、哈希表、过期索引和复制会显著增加实际占用,容量规划必须以真实键形态压测。若每个检查请求约 100 字节、响应约 32 字节,单向入站约 100 MB/s、出站约 32 MB/s,也应计入网卡和代理预算。
最后看取舍是否闭环:令牌桶为什么匹配突发需求、热键怎样拆、存储失联是放行还是拒绝、异步复制可能造成什么偏差、多地域要精确上限还是分区可用性,以及上线前如何用影子模式观察误杀。
回答前需要澄清的问题
- 限流键是什么? 本题按租户与规范化路由组合限流,不直接把原始 URL、查询参数或未经校验的转发 IP 放进键,避免无界基数和身份伪造。
- 规则是固定窗口还是允许突发? 本题要求持续速率加突发容量,选择令牌桶。若要求任意滑动窗口内绝不超过
N次,算法和存储成本会改变。 - 一次请求是否只消耗一个令牌? 默认成本为 1,也允许按接口传入正整数成本;余额与补充速率使用固定精度整数,避免浮点累计误差。
- 限流要多精确? 单地域正常路径要求同一桶的判断原子;故障切换与多地域下允许的超放或少放必须单独约定,不能只说“最终一致”。
- 存储故障时优先可用还是优先保护? 普通读取接口采用超时后放行并叠加网关本地应急上限;登录、计费相关或高成本接口可拒绝或使用预分配租约。
- 规则多久生效? 控制面给规则带单调递增版本,推送并缓存到限流服务;数据面不在每个请求上查询数据库,版本落后的实例需要监控。
- 429 响应需要什么? 返回机器可读原因和
Retry-After。IETF 对 429 定义了“给定时间内请求过多”,并允许用Retry-After告知等待时间,但没有规定服务端必须采用哪种计数算法。
30 秒回答框架
“我会把限流检查放在 API 网关,网关把租户、规范化路由、成本和规则版本发给无状态限流服务。服务按 (tenantId, routeId) 计算一个稳定键,在 Redis Cluster 的单个分片上用短 Lua 脚本原子完成令牌补充、判断、扣减、TTL 和重试时间计算。规则由独立控制面版本化并推送,数据面只读本地缓存。正常路径水平扩展限流服务和 Redis 分片;热键改用配额租约。普通接口故障时放行并启用本地保护,高风险接口拒绝。上线先影子运行,观察延迟、超限率、错误率、热键和配置版本,再逐步执行拦截。”
分步骤深入解答
先做容量拆分。100 个网关平均每个承担每秒 1 万次检查,但不能按平均值配置连接池和实例数,应按故障转移后的峰值与安全余量压测。若单个 Redis 分片在真实 Lua 脚本、键大小、持久化和复制配置下测得安全吞吐为 Q,主分片数至少是 ceil(1,000,000 / Q),再为故障和扩容预留余量。这里故意不套用网上的“单机每秒多少次”,因为脚本长度、硬件、网络和复制模式都会改变结果。
数据面由五部分组成:网关限流过滤器、无状态限流服务、分片状态存储、规则控制面和可观测管道。网关负责可信身份解析与路由规范化;限流服务负责规则匹配和决策;Redis Cluster 保存活跃桶;控制面从数据库发布带版本的规则;指标管道记录允许、拒绝、错误、故障放行和延迟。成熟代理已经采用“过滤器生成 descriptor、调用外部限流服务、超限返回 429”的边界,说明这个接口可以独立于业务服务演进。
内部 API 可以这样定义:
CheckRateLimitRequest {
tenant_id: string
route_id: string
cost: uint32
rule_version: uint64
}
CheckRateLimitResponse {
allowed: bool
remaining: uint64
retry_after_ms: uint64
rule_id: string
applied_rule_version: uint64
}网关只接受认证链路产生的 tenantid,routeid 来自路由模板,例如 /orders/:id,不能使用会随订单号增长的原始路径。Redis 键可写成 rl:{tenantId:routeId};花括号内的稳定标签使一个桶的状态落在同一 Cluster 槽位。状态只需 tokens 和 lastrefillms,缺失键表示一个已补满的新桶。限流键应使用容量独立、禁止静默淘汰的存储空间;若内存策略淘汰了仍活跃的桶,下一次访问会错误地恢复满额。
令牌桶每毫秒补充 refillperms 个固定精度令牌,容量为 capacity。一次决策的核心是:
elapsed = max(0, now_ms - last_refill_ms)
available = min(capacity, tokens + elapsed * refill_per_ms)
if available >= cost:
allowed = true
available = available - cost
retry_after_ms = 0
else:
allowed = false
retry_after_ms = ceil((cost - available) / refill_per_ms)脚本随后写回余额和时间,并设置“桶从空到补满所需时间加安全余量”的 TTL。空闲时间超过补满时间后删除键是安全的,因为下次访问本来就应看到满桶。时间应来自同一桶稳定使用的可信时钟来源,并对负数 elapsed 归零;令牌计算使用整数缩放。Redis 文档明确保证脚本原子执行,但脚本会阻塞同一实例的其他活动,所以逻辑必须短小,不能在里面扫描键或访问多个不相关桶。
算法选择与需求绑定。固定窗口每个键只需计数和过期时间,便宜但窗口边界两侧可能连续放行两批。滑动日志保存窗口内每次请求时间,精确但每键空间随请求数增长。滑动窗口计数器空间固定,却是近似值。令牌桶用两个状态量表达持续速率和可控突发;AWS API Gateway 的公开文档也将 rate 与 burst 分开,并说明其限流是 best-effort 目标。因此本文的自建方案不能借供应商行为宣称“任何故障下都绝不超额”。
正常请求流程如下:网关解析身份和规范路由,由本地路由配置生成 descriptor 与期望规则版本;调用同地域限流服务;服务从本地规则缓存取参数,定位 Redis 分片并执行原子脚本;允许时把请求转给后端,拒绝时返回 429、原因和按余额算出的 Retry-After。内部的毫秒等待值向上取整为 HTTP 头使用的整秒数,JSON 响应仍可保留毫秒精度。控制面发布新版本时先校验规则,再推送到限流服务;实例回报已应用版本。紧急规则可推送,普通规则允许短暂传播延迟,但同一响应必须带实际应用版本,便于定位版本分叉。
为了守住 5 毫秒 p99 目标,网关到限流服务以及限流服务到 Redis 都使用长连接和连接池,每次判断只产生一次 Redis 往返,并给内部调用设置约 3 毫秒的截止时间,为网关处理预留预算。限流服务优先选择近端实例;压测若仍无法达标,就要减少网络跳数或对高流量租户使用本地租约。目标只能由峰值负载下的端到端分位数证明,不能用 Redis 单次命令的平均延迟代替。
瓶颈通常出现在热键、共享存储和同步网络跳数。某个大租户若占到每秒 10 万次检查,即使集群总吞吐足够,也可能压满一个槽位。解决方案是由全局桶向多个限流节点发放小额、短期令牌租约,本地消费后再续租。租约降低共享存储访问,却引入未用令牌浪费、节点失联后的回收延迟和公平性问题;所有未到期租约之和必须受总预算约束。普通租户继续走共享桶,避免为少数热点增加全局复杂度。
故障策略按业务风险分层。限流服务超时或 Redis 短暂不可用时,普通读取接口可 fail-open,同时由每个网关的粗粒度应急限流器保护后端,并记录 failuremodeallowed;这个本地上限只保护容量,不承诺租户全局配额。登录、昂贵写入或防滥用接口可 fail-closed,或只消费此前取得的租约。Redis 主从切换若使用异步复制,最近扣减可能丢失并造成短暂超放;答案应把它写进误差预算,而非把“有副本”等同于零数据丢失。
多地域严格共用一个桶会把跨地域网络延迟和分区放进每个请求。更实用的方案是中央配额分配器按短周期向各地域租出令牌,地域内再原子消费。总租约不超过全局预算时不会凭空增加配额,但网络分区中的地域只能使用剩余租约,牺牲部分可用性;若允许各地域先放行再异步合并,则可用性更高,但超放上界要按地域数和本地额度计算。面试中必须让业务选择这条精确性—可用性边界。
上线从影子模式开始:计算决策但不拦截,核对哪些租户和路由会被拒绝;再按规则和租户小流量启用。监控至少包括决策 p50/p95/p99、允许/拒绝/错误/故障放行计数、Redis 脚本耗时、分片负载与热键、规则版本落后量,以及后端在限流前后的饱和度。Stripe 的公开工程实践也强调暗发布、开关、清晰错误和故障安全,这些操作面信息比只画一张组件图更接近生产设计。
高质量示范回答
“我先把目标定为单地域正常路径下,对 (tenantId, routeId) 的令牌消费做原子判断。输入是 100 个网关、峰值每秒 100 万次检查、p99 新增延迟 5 毫秒。每条规则有持续速率、突发容量和请求成本,超限返回 429 与 Retry-After。
网关解析可信租户身份并把原始 URL 归一成路由模板,然后调用同地域的无状态限流服务。服务从本地缓存取带版本规则,用 rl:{tenantId:routeId} 定位 Redis Cluster 槽位。一个短 Lua 脚本读取令牌和上次补充时间,按经过时间补充到容量上限,判断并扣减,再写回 TTL;读、算、写在同一个原子边界内,避免两个网关重复消费最后一个令牌。
容量上,我会用真实脚本压测得到单分片安全吞吐 Q,至少配置 ceil(1,000,000 / Q) 个主分片并留故障余量。1000 万活跃桶按 64 字节键加两个 8 字节字段计算,逻辑数据约 800 MB,实际内存还要加入 Redis 对象、过期索引和副本开销。热租户不能只靠增加普通分片,我会给限流节点发放小额短租约,让本地消费并把未到期租约总量纳入全局预算。
故障语义按路由分层:普通读接口超时后放行,但启用网关本地保护并记录故障放行;登录和高成本写入拒绝,或仅使用已有租约。多地域也采用配额租约,明确分区时在精确上限和可用性之间选择。发布先跑影子模式,再按租户灰度,盯住决策延迟、超限、错误、故障放行、热键和规则版本。这样系统的正常精度、故障偏差和运营回滚都有可验证边界。”
常见错误
- 每个网关维护完整本地桶 → 同一配额被实例数放大,负载均衡变化还会改变结果 → 正常流量使用共享原子状态,热点优化使用总额受约束的租约。
- 先
GET,在应用里计算,再SET→ 并发请求可读到相同余额并重复扣减 → 把读取、补充、判断、扣减和写回放进一个短服务端脚本。 - 只说“用 Redis 就能扛住” → 没有吞吐、内存、复制和热键预算,无法判断设计是否可行 → 用真实脚本压测
Q,据此计算分片数并报告热点分布。 - 允许限流键被内存策略静默淘汰 → 活跃桶消失后会以满桶重建,造成无监控的超放 → 使用独立容量和非淘汰策略,并对内存水位与写入失败报警。
- 用原始 URL 或客户端自报身份作为键 → 键基数失控或配额可被绕过 → 使用认证上下文中的租户和路由模板生成 descriptor。
- 把 429 当成固定窗口专属响应 → 协议状态码没有规定内部算法 → 返回 429、原因和按当前算法计算的
Retry-After。 - 所有故障都 fail-open → 登录、昂贵写入和防滥用链路失去保护 → 按路由风险配置 fail-open、fail-closed 或预分配租约。
- 宣称 Redis 副本保证绝不超放 → 异步复制和故障切换可能丢失最近扣减 → 把复制丢失写入误差预算,并为严格场景选择更强一致性或少量可用性。
- 多地域各自配置完整额度 → 全局上限最多按地域数被放大 → 中央分配地域租约,或明确计算并接受异步方案的超放上界。
- 直接全量开启拦截 → 错误规则会立刻误伤真实流量 → 先影子计算,再按规则、租户和比例灰度,并保留关闭开关。
追问及应对
追问一:怎样实现严格的全球上限?
最直接的方法是让所有地域同步访问一个强一致配额服务,但每个请求都会承受跨地域延迟,分区时也必须拒绝部分请求。更常用的是中央分配器按短周期发放地域租约,保证所有有效租约之和不超过全局额度。地域可以低延迟本地消费,代价是闲置租约造成利用率下降,分区时只能消耗剩余额度。若业务要求分区期间继续全量服务,就必须接受可计算的超放,三者不能同时免费获得。
追问二:一个超级租户形成热键怎么办?
单纯增加 Redis 分片不会拆开同一个键。让全局桶向多个限流节点发放例如几百个令牌的短租约,节点在内存中原子消费,低水位时续租。租约批次越大,共享存储压力越低,但节点宕机时浪费和短时不公平越大;批次越小,精度更高但续租更频繁。按热点程度动态调整批次,并监控未使用租约和续租延迟。
追问三:Redis 完全不可用时如何避免后端被打垮?
普通接口的 fail-open 旁边必须有独立的本地保护:每个网关设置粗粒度总入口上限、连接池上限和熔断,不尝试维持精确租户配额。高风险接口 fail-closed 或只消费预取租约。超时要远小于业务请求预算,故障放行单独计数并报警。存储恢复后不回补已经放行的请求,避免恢复瞬间产生第二次冲击。
追问四:限流服务超时后重试,会不会重复扣令牌?
会。服务端可能已经扣减,只是响应丢失。低成本请求可以约定网关不在同步路径重试,直接执行该路由的故障策略。若扣减必须幂等,请求带唯一 requestId,脚本在与桶相同的 Cluster 槽位保存短期去重记录并返回第一次结果;这会增加内存和写放大,TTL 至少覆盖调用方重试窗口。不能只在 RPC 层打开自动重试。
追问五:同时有租户总额度和接口额度,如何保证一次判断一致?
把键改为 rl:{tenantId}:global 和 rl:{tenantId}:route:<routeId>,让同一租户的相关键进入同一 Cluster 槽位。脚本先计算所有桶,只有全部允许时才统一扣减。规则很多时,单次脚本成本和热键会增长;可把最关键的两层放在原子路径,其余作为独立保护,并明确可能出现的部分计数。跨槽位事务会显著增加复杂度,不应在没有严格业务要求时默认引入。