限流器(rate limiter)限制一个客户端在给定窗口内能发多少请求,保护你的服务免受滥用、意外的流量风暴,以及当某个租户耗尽共享资源后随之而来的级联故障。它听起来就是一个计数器加一条 if 语句,但设计很快变难:计数器必须在一队无状态服务器之间共享且一致,检查必须几乎不给热路径加延迟,算法要公平地处理突发和窗口边界,而且当它自己的后端存储出问题时整套东西还得继续工作——是 fail-open 还是 fail-closed,要经过深思熟虑地选择。本文走一遍各种算法(token bucket、leaky bucket、fixed 与 sliding window)、在 Redis 上的分布式实现、让并发计数正确的原子性保证,以及客户端依赖的 HTTP 语义(429、Retry-After、X-RateLimit-*)。
- token bucket 是默认选择——允许突发到桶容量上限,然后被限到稳定的补充速率;Stripe、AWS 和大多数公开 API 都在用。
- leaky bucket 平滑输出——一个以恒定速率排空的 FIFO 队列;当下游需要稳定、可预测的请求速率时选它。
- fixed window 最简单但在边界漏——客户端能在相邻两个窗口跨界发到 2 倍上限。
- sliding window counter 修掉边界——按重叠比例给上一个窗口的计数加权;O(1) 内存、近乎精确。
- 分布式计数器放 Redis——每个
(client, window)key 用INCR+EXPIRE;所有网关节点共享同一份真相。 - Lua 让检查原子化——读取、比较、自增作为一个不可分割的 Redis 操作运行,消除先查后增的竞态。
- 放在 API 网关——一个收口处保护所有服务;客户端限流只是建议性的。
- 用 429 + 头部拒绝——返回
Retry-After和X-RateLimit-Limit/Remaining/Reset,让规矩的客户端退避。
把限流器放在 API 网关,这样在一处就保护了每个服务。token bucket 是首选算法——对突发友好、补充平滑;leaky bucket 用在需要恒定输出时;sliding window counter 用在想要 fixed-window 的简单又不要边界突发时。把计数器放在 Redis,用 Lua 脚本让"检查 + 自增"原子化。用 429 Too Many Requests 加 Retry-After 和 X-RateLimit-* 头部拒绝超额流量。为 Redis 不可达时决定 fail-open 还是 fail-closed。
Step 1 — 厘清需求
限流器是一个看似简单实则很深的面试题,因为"限制请求"背后藏着十几个决策。画框图前先定范围。
功能需求
- 按某个 key 限流:按 API key、按 user ID、按 IP,或按
(user, endpoint)元组。 - 支持多条规则——例如对同一客户端既限 10 req/s 突发又限 1,000 req/小时 持续。
- 被限流时给客户端一个清晰信号:带
Retry-After提示的429。 - 规则可配置、可热加载,无需重新部署服务。
- 对不同档位(免费 vs 付费)和不同端点(便宜的读 vs 昂贵的写)用不同上限。
非功能需求
- 低额外延迟——检查在每个请求上;目标 <1–2 ms p99 的开销。
- 并发下的准确性——当同一 key 的成千上万请求同时打到不同节点时,计数必须正确。
- 高可用——限流器不能成为单点故障;为它的后端存储宕机时的行为做决定。
- 分布式正确性——上限是在 负载均衡 后水平扩展的无状态服务器队上全局生效,而非每节点单独生效。
- 可扩展性——以余量处理平台的全部请求量(每秒数十万次检查)。
值得问的第一个澄清问题:"上限是所有服务器全局共享,还是每服务器单独?"每服务器很简单(一个内存计数器)但对大多数真实系统是错的——在负载均衡后,一个客户端的请求会扇出到多个节点,所以每节点上限 N、M 个节点实际就放行了 N×M。早早点明这个区别,表明你理解真正的问题是分布式计数。
Step 2 — 容量估算
按它要守护的流量来给限流器定容。假设一个 API 平台峰值服务 50 万 请求/秒。
吞吐
- 每个请求触发一次限流检查 → 50 万 检查/秒。每次检查是一次 Redis 往返(或一次 Lua eval)。
- 单个 Redis 实例约能处理 10 万–20 万 简单 ops/秒。在 50 万/秒下你需要一个 分片 Redis 集群(计数器按 client key 分区),或用本地+全局的混合来削减 Redis 流量。
- 给网络留预算:同机房的 Redis 每次检查加约 0.2–0.5 ms。让 Redis 和网关在同一 AZ,避免热路径上跨 AZ 延迟。
内存
- 每个活跃计数 key 都很小:
rl:{client}:{window}→ 一个整数加 TTL,算上 Redis 开销 ≈ ~100 字节。 - 一个窗口内 1000 万 个不同活跃客户端 → 1000 万 × 100 B ≈ ~1 GB。轻松放进内存;TTL 自动驱逐过期窗口,内存不会无界增长。
- token-bucket 状态每客户端需两个字段(token 数 + 上次补充时间戳)——仍然很小,存为 Redis hash。
数据很小;难点在吞吐和正确性,不在存储。限流器是受延迟和一致性约束的系统,而非受存储约束的系统——这正是为什么设计讨论围绕算法、原子性和检查放在哪里,而不是分片 TB 级数据。
Step 3 — 限流器放在哪里
放置位置是一等的设计决策。同一个算法因位置不同表现差别很大。
| 位置 | 优点 | 缺点 |
|---|---|---|
| 客户端 | 零服务端成本;即时反馈 | 只是建议性——恶意客户端直接无视它。永远不是安全边界。 |
| API 网关 / 反向代理 | 一个收口处保护所有服务;规则集中;在请求到达应用服务器前就拒绝超额 | 网关需要访问共享计数状态;若不扩展会成瓶颈 |
| 每个服务里的中间件 | 细粒度的每服务规则;无额外跳数 | 逻辑在各服务间重复;更难保持一致 |
| 专用 sidecar(service mesh) | 与应用代码解耦;通过 mesh 控制面统一策略 | mesh 的运维复杂度;每 pod 状态协调 |
标准答案是 API 网关:它是天然的单一入口,能在超额流量消耗下游资源前就拒绝它,且把规则集中在一处。网关自身无状态,查询一个共享存储(Redis)拿计数,所以网关水平扩展不会割裂上限。客户端限流仍值得作为一层礼貌(它减少浪费的往返),但绝不用于执行(enforcement)。
Step 4 — 规则与配置
上限是数据,不是代码。规则引擎让产品按档位和端点定义上限,并能不部署就重载。一种紧凑表示:
# 限流规则,从配置服务热加载
rules:
- match: { tier: free, endpoint: "POST /api/*" }
limit: { requests: 10, window: 1s, algorithm: token_bucket, burst: 20 }
- match: { tier: free, endpoint: "*" }
limit: { requests: 1000, window: 1h, algorithm: sliding_window }
- match: { tier: paid, endpoint: "*" }
limit: { requests: 100, window: 1s, algorithm: token_bucket, burst: 200 }
key_by: api_key # 或 user_id、ip、"user_id:endpoint"
on_fail: allow # 计数存储不可达时 fail-open
一个请求可能命中多条规则——一个免费用户打 POST /api/x 同时受 10/s 突发规则和 1,000/小时 持续规则约束。限流器评估所有命中的规则,任一超额就拒绝(最严格者胜)。把规则从最具体到最不具体排序,在第一个拒绝处短路以省 Redis 调用。
Step 5 — 算法全景
五种算法主导讨论。每种在准确性、内存和突发行为上权衡不同。
| 算法 | 突发处理 | 内存 | 准确性 |
|---|---|---|---|
| token bucket | 允许突发到桶大小,然后稳定补充 | O(1)——每 key 2 字段 | 精确 |
| leaky bucket | 把突发平滑成恒定流出(队列) | O(队列大小) | 速率精确,但加延迟 |
| fixed window | 窗口边界处允许到 2 倍 | O(1)——每 key 1 计数 | 近似(边界泄漏) |
| sliding window log | 无边界泄漏;精确 | O(N)——每请求一个时间戳 | 精确 |
| sliding window counter | 近乎精确;加权近似 | O(1)——每 key 2 计数 | 近似(非常接近) |
面试里,把 token bucket 说成默认,把 sliding window counter 说成当 fixed-window 边界泄漏要紧时的改进。只有当下游确实需要平滑、恒定的输出速率时(例如喂一个固定吞吐的 worker 池),leaky bucket 才是对的选择。接下来几步逐个深入。
Step 6 — Token Bucket
一个桶最多装 capacity 个 token,以每秒 refill_rate 个 token 的速度补充。每个请求拿走一个 token;桶空了请求就被拒。因为一个满桶能在一次突发里被抽干,token bucket 容忍最大到容量的突发,同时把长期平均限到补充速率——这正是大多数公开 API 想要的行为。
def allow(bucket, capacity, refill_rate, now):
# 按流逝时间惰性补充——无需后台定时器
elapsed = now - bucket.last_refill
bucket.tokens = min(capacity, bucket.tokens + elapsed * refill_rate)
bucket.last_refill = now
if bucket.tokens >= 1:
bucket.tokens -= 1
return True # 放行
return False # 拒绝 → 429
关键技巧是惰性补充(lazy refill):不用后台定时器加 token,而是从流逝时间算出自上次请求以来本应累积多少 token。这让每次检查 O(1),且两次调用之间除两个存储字段(tokens、last_refill)外无状态。在分布式场景里,这两个字段存在每客户端 key 的一个 Redis hash 里,整个 read-modify-write 必须原子(Step 10)。
token bucket 干净地分离两个关注点:capacity 控制你容忍多大的突发,refill_rate 控制持续速率。你可以让一个空闲后的客户端瞬时突发到 200 个请求,却把它平均限到 100/s——对真实的突发客户端(页面加载、重试)友好,又不允许持续滥用。这种可调性正是 Stripe、GitHub 和 AWS 公布 token-bucket 式上限的原因。
Step 7 — Leaky Bucket
leaky bucket 把请求建模为倒进一个有洞的桶里的水:请求进入一个 FIFO 队列,以固定速率被处理(漏出)。队列满了,新请求溢出被拒。其定义性质是恒定的流出速率,无论输入多突发。
| 性质 | token bucket | leaky bucket |
|---|---|---|
| 输出速率 | 突发(可瞬时到容量) | 恒定(平滑) |
| 额外延迟 | 无——立刻放行或拒绝 | 排队的请求要等 |
| 最适合 | 能容忍突发的面向用户 API | 保护固定吞吐的下游 |
| 内存 | O(1) | O(队列长度) |
代价是延迟:leaky bucket 可以延迟一个请求(它待在队列里)而非拒绝它,这平滑了尖峰但增加尾延迟并需要队列内存。当你保护的东西——支付处理器、遗留系统、固定容量的 worker 池——需要稳定的滴流、且宁愿让调用方等也不愿看到尖峰时,用它。对大多数限流(拒绝而非延迟),token bucket 更合适。
Step 8 — Fixed Window 与 Sliding Window
窗口计数家族最容易在 Redis 之上实现,但在窗口边界藏着一个微妙的公平性 bug。
Fixed window
把时间切成固定区间(例如每个自然分钟)。每个 (client, minute) 保一个计数;每请求自增;超上限就拒;窗口结束让 key 过期。极简——一个 INCR——但它允许边界突发:客户端能在一个窗口的最后一刻发满上限,又在下一个窗口的第一刻再发满,在一个短于一个窗口的跨度里放行到 2 倍上限。
Sliding window log
在每客户端的一个有序集合里给每个请求存一个时间戳。每次请求时,丢掉早于 now - window 的时间戳,数剩下的;低于上限就放行。这是精确的且无边界泄漏——但它每请求存一条,所以一个做 10K req/s 的客户端每窗口要花 10K 个时间戳的内存。准确但昂贵。
Sliding window counter
务实的折中:为当前和上一个固定窗口各保一个计数,通过按上一个窗口与滑动窗口仍重叠的比例给它加权,来估计滚动计数。
# 对滚动窗口内请求数的加权估计
elapsed = now - current_window_start # 进入当前窗口的秒数
weight = (window_size - elapsed) / window_size # 与上一个窗口的重叠
estimated = current_count + previous_count * weight
if estimated < limit:
current_count += 1
return True # 放行
return False # 拒绝 → 429
如果你进入当前分钟 25%,上一分钟仍贡献其计数的 75% 到滚动估计。这杀掉了边界突发,只花每 key 两个计数(O(1) 内存),实践中准确到不到一个百分点。这正是 Cloudflare 因这个原因推广的算法——fixed-window 的成本配 sliding-window 的公平。
如果被问"fixed 还是 sliding window?",先抛边界突发问题并画出来:相邻两个窗口,一个的末尾和下一个的开头各发满上限。然后给出 sliding window counter 作为 O(1) 的修法。只有当面试官要求精确且愿意付每请求内存时,才上 sliding window log。
Step 9 — Redis 里的分布式计数器
在负载均衡后限流器是无状态的,计数必须共享,所以计数器放在 Redis——单线程(无内部数据竞争)、内存(亚毫秒)、且自带原子 INCR 和每 key TTL。一个 fixed-window 限流器就是两条命令:
# key = rl:{client}:{window_id},如 rl:api_key_42:1719600000
INCR rl:api_key_42:1719600000 # 返回新计数
EXPIRE rl:api_key_42:1719600000 60 # TTL = 窗口大小;自动清理
# 应用逻辑:
# count = INCR(key); if count == 1: EXPIRE(key, 60)
# if count > limit: 拒绝 (429)
window id 由时间戳推出(floor(now / window_size)),所以 key 自动轮转、旧窗口经 TTL 过期——无需清扫任务。在 50 万 检查/秒下单个 Redis 节点超预算,所以把计数器跨一个分片集群分区:把 client key 哈希到一个分片。因为每个计数器都限定到一个 client key,按 client 分片让每个客户端的计数器留在单个节点上——无跨分片协调、无分布式事务。
按 client key 分片意味着单个非常热的客户端仍集中在一个分片上(热 key 问题)。可以在 Redis 前为该客户端加一个本地进程内限流(Step 11)来缓解,或把一个热客户端的计数器拆成 N 个子 key 再求和——以精确性换分散。对大多数负载,client-key 分片均匀分摊负载,因为没有单个客户端占主导。
Step 10 — 用 Lua 脚本保证原子性
朴素流程——读计数、比上限、再自增——是经典的 read-modify-write 竞态。同一 key 的两个请求同时到两个网关节点;都 GET 到 count=99,都判定自己在上限 100 之内,都 INCR 到 101。上限被突破。INCR 本身原子,但 token-bucket 和 sliding-window 的运算涉及读状态、计算、写回——多步骤不能交错。
Redis 用原子运行 Lua 脚本解决:整个脚本在 Redis 的单线程上作为一个不可分割的单元执行——中间不跑别的命令。读、上限检查、写要么一起发生、要么都不发生。
-- token bucket,经 EVAL 原子执行
-- KEYS[1]=bucket key ARGV: capacity, refill_rate, now, requested
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or tonumber(ARGV[1]) -- 起始满桶
local ts = tonumber(b[2]) or tonumber(ARGV[3])
local cap, rate, now, need = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
tokens = math.min(cap, tokens + (now - ts) * rate) -- 惰性补充
local allowed = tokens >= need
if allowed then tokens = tokens - need end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return allowed and 1 or 0
脚本在一个原子步骤里做完补充、检查、扣减和持久化,返回单个放行/拒绝。用 SCRIPT LOAD 上传一次,经 EVALSHA 按 SHA 调用,避免每次重发脚本体。替代方案——WATCH/MULTI 乐观事务——也行但在争用时要重试;对重并发下的一个热 key,Lua 方案更简单更快,因为它从不中止。
Step 11 — 同步 vs 异步与本地+全局
每个请求都查 Redis 是同步模型:完全准确,但把一次网络往返放在热路径上,并让 Redis 成为吞吐瓶颈和故障依赖。两个改进放松这点。
本地+全局(两层)
每个网关节点保一个小的进程内 token bucket,大小为全局上限中它该分到的份额,只周期性地与 Redis 对账。大多数请求在纳秒级本地放行或拒绝;Redis 被用来重分配余量,而非每次调用。这大幅削减 Redis 流量并能扛住短暂的 Redis 抖动,代价是轻微超额放行——节点本地的桶在一个同步间隔内可能合起来超过全局上限。对 10 个节点上 1,000/s 的上限,你可能短暂放行 1,050。通常可接受;调同步间隔以在准确性和 Redis 负载间权衡。
异步回写(write-behind)
基于本地读放行,然后把自增异步推给 Redis。最低延迟,最弱准确性——计数滞后、超额放行增大。把它留给软上限(approximate 即可,如分析限流),而非硬安全上限。
同步 Redis = 精确但每请求一次往返加一个硬依赖。本地+全局 = 近乎精确,Redis 流量小得多且能优雅降级,代价是有界的超额放行。正确选择取决于这个上限是硬安全边界(同步、fail-closed)还是公平/成本控制(本地+全局、fail-open)。先说清上限的目的,再选。
Step 12 — 429 响应与头部
当请求被限流时,返回 429 Too Many Requests 并告诉客户端怎么做。好的头部把盲目重试风暴变成礼貌退避。
HTTP/1.1 429 Too Many Requests
Retry-After: 2 # 客户端可重试前等待的秒数
X-RateLimit-Limit: 100 # 本窗口的上限
X-RateLimit-Remaining: 0 # 此刻剩余 token
X-RateLimit-Reset: 1719600062 # 窗口补满的 epoch 时间
Content-Type: application/json
{ "error": "rate_limited",
"message": "Too many requests. Retry after 2s." }
429是正确状态码——与503(服务器过载)区分,让客户端能对限流单独处理。Retry-After是最有用的单个头部:它把客户端重试逻辑从猜测变成精确等待,防止把限流变成宕机的那种重试风暴。X-RateLimit-*让规矩的客户端在撞墙之前就自我调速——在成功响应上也返回它们,客户端就能实时看到剩余预算。- 服务端上限要配上客户端的指数退避 + 抖动(jitter),这样一群同时撞上限的客户端不会同步成惊群一起重试。
Step 13 — 边界情形与失败模式
面试深度都在这里——区分教科书答案和生产答案的那些情形。
- 计数存储宕机——决定 fail-open(放行;限流器宕机不该变成整体宕机——常见默认)还是 fail-closed(拒绝;用于保护脆弱或安全关键资源的上限)。健壮设计在 Redis 不可达时回退到一个保守的本地内存上限,既不放洪水也不全黑。
- 时钟偏移——token-bucket 惰性补充和 window id 都依赖时间。在 Lua 脚本里用 Redis 服务端时间(
TIME命令)作为单一时钟,而非各网关的本地时钟,以避免跨节点偏移。 - 窗口边界突发——fixed-window 的 2 倍泄漏(Step 8);用 sliding window counter 修。
- 竞态——并发的先查后增超额放行;用 Lua 原子脚本修(Step 10)。
- 分布式超额计数——本地+全局两层在同步期间超额放行有界;对软上限可接受,对硬上限不可。
- 热 key——一个客户端猛打单个分片;用本地第一层桶或子 key 拆分缓解。
- 按什么身份做 key——IP 可伪造且共享(NAT、企业代理会让同一 IP 下许多用户连坐);优先用已认证的 API key 或 user ID,仅对未认证流量回退到 IP。
- 重试风暴——没有
Retry-After和带抖动的退避,被限的客户端会齐步猛打;限流器必须塑造客户端行为,而非只拒绝。
Step 14 — 扩展与容错
在平台规模下,限流器自身必须高可用且可水平扩展。
扩展检查路径
网关无状态——在负载均衡后水平扩展它们。共享状态是 Redis;通过按 client 把计数器跨 Redis Cluster 分片来扩展它。加上本地+全局层(Step 11)把每请求的 Redis 往返削减一个数量级,让 Redis 远在其 ops 上限之下。
容错
- Redis 故障——让 Redis 带副本和自动故障转移(Sentinel 或 Cluster)。在故障转移窗口里,限流器遵循其
on_fail策略:大多数端点 fail-open 配本地回退桶,安全关键端点 fail-closed。 - 网关故障——无状态,所以负载均衡直接绕开宕掉的节点;计数状态不丢,因为它在 Redis 里。
- 区域故障——每区域跑一个 Redis 集群并按区域限流。真正跨区域的全局上限需要跨区域复制并接受最终一致;大多数设计按区域限流,因为跨区域同步计数会给热路径加不可接受的延迟。
跨区域的全局精确性与物理规律冲突:一个同步的全局计数器意味着每个请求都要等一次跨区域往返。标准折中是给每个区域分配全局预算的一片(例如 60% 给流量最大的区域、40% 给另一个)并本地限流——全局近似、本地精确、请求路径上零跨区域延迟。
Step 15 — 关键取舍
| 决策 | 选择 | 接受的取舍 |
|---|---|---|
| 算法 | token bucket | 允许突发到容量;调容量 vs 补充来塑形 |
| 窗口公平 | sliding window counter | 用微小近似误差换 O(1) 内存和无边界泄漏 |
| 状态存储 | 分片 Redis | 热路径上的依赖;用本地回退和 fail-open 缓解 |
| 原子性 | Lua 脚本 (EVALSHA) | 逻辑跑在 Redis 内;比应用代码难调试,但无竞态 |
| 一致性模型 | 本地+全局 | 同步窗口内有界超额放行,换 Redis 流量大降 |
| 失败行为 | fail-open(默认) | Redis 宕机时短暂超额放行,胜过拒绝所有流量 |
| 放置 | API 网关 | 一个收口处;网关须可扩展并能访问共享状态 |
限流器是一小撮状态外加一大堆细致推理的守护。token bucket 因其对突发友好的可调性成为默认;sliding window counter 以 O(1) 成本修掉 fixed-window 的边界泄漏;Redis 持有共享计数器,Lua 脚本让"检查 + 自增"原子;API 网关是它的栖身处;一个深思熟虑的 fail-open vs fail-closed 选择让限流器宕机不至于变成服务宕机。深度在边界情形里——时钟偏移、热 key、分布式超额计数、重试风暴——所以点名它们并说出你的取舍。
token bucket 还是 leaky bucket——什么时候用哪个?token bucket 允许短时突发到桶容量上限,然后被限到补充速率——适合能容忍突发流量的 API。leaky bucket 通过 FIFO 队列强制恒定的流出速率,把突发平滑成稳定的流——适合下游系统需要稳定、可预测请求速率的场景。
为什么 fixed window 在边界处允许两倍上限,滑动窗口怎么修?fixed window 在每个区间边界重置计数,所以客户端能在一个窗口的最后一秒发满上限,又在下一个窗口的第一秒再发满——短时间内最多到 2 倍上限。sliding window log 保存精确时间戳(准确但占内存);sliding window counter 按重叠比例给上一个窗口的计数加权,用 O(1) 内存和近乎精确的准确度修掉边界突发。
为什么 Redis 限流器需要 Lua 脚本?先检查再自增是一个 read-modify-write 竞态:两个并发请求都读到 count=99,都判断自己在上限 100 之内,都自增到 101——超额放行。Lua 脚本在 Redis 内部作为单个操作原子执行,所以读取、上限检查和自增之间没有交错。
Redis 挂了时限流器应该 fail-open 还是 fail-closed?大多数生产系统 fail-open——如果限流存储不可达,放行请求而不是拒绝所有流量,因为限流器故障不该变成整体故障。登录、支付这类关键端点可能 fail-closed,或回退到一个保守的本地内存限流。明确说出选择;这是可用性 vs 滥用的取舍。
限流器应该放在哪里?把它集中放在 API 网关或服务前的专用中间件,这样每个服务都继承保护、规则集中在一处。客户端限流只是建议性的,因为客户端能绕过它。对超低延迟热路径,把一个本地进程内 token bucket 作为第一道防线,和一个共享 Redis 计数器(全局真相)结合。