想象你开了一家网红奶茶店。后厨只有三个店员,一分钟最多能做十二杯。有一天品牌突然爆火,一秒钟涌进来两百条订单——后厨炸了,机器烧了,顾客等了四十分钟,差评淹没评论区。痛定思痛,你决定在门口装一道“流量阀门”:不管多少人想喝,进了店门就得守规矩。你先后试过几种制度:整点限号、滚动统计、发接单券、订单漏斗……这些制度,就是 API 世界里的“速率限制算法”。这篇文章,咱们就从这家奶茶店出发,把令牌桶(Token Bucket)、滑动窗口(Sliding Window)这些听起来高深的名字,讲得明明白白。
理解限流算法是设计可靠 API 服务的必修课——掌握令牌桶与滑动窗口,你就能为 AI 服务筑起第一道抗流量冲击的防线,这也是后端与 AI 工程化岗位的高频考点。
可视化演示
展示Token Bucket令牌桶与Sliding Window滑动窗口两种限流算法的工作原理,通过令牌生成消耗与窗口滑动的动态过程对比突发流量处理能力。
本节配有交互动效(进入视口后自动加载,约 3KB)。
令牌桶算法:以固定速率生成令牌 令牌:以固定速率填充桶 请求:消耗令牌通过 rate = tokens/sec, burst = bucket_size 支持突发流量,桶满时丢弃溢出令牌,空时拒绝请求。
一、限流基础认知
给奶茶店装流量阀门之前,你先要回答三个问题:限多少、在哪限、拿什么来算。限多少,是估算后厨产能;在哪限,是定在点单台入口;拿什么算,是数订单条数。API 限流世界也是一样:先把这三个基础想清楚,后面的算法才有意义。
API 限流的本质是“准入控制”。服务容量是有限的,在请求真正消耗计算资源之前,先按预定规则决定放行还是拒绝。它是 API 治理与安全的第一道防线,和认证、鉴权并列,共同保护后端不被流量冲垮。
限流最常用的度量指标是 QPS(Queries Per Second,每秒查询数)。它衡量请求到达的速率,所有限流配额都以它为单位。限流位置一般选在 API 网关或服务入口中间件,这样可以在请求进入业务逻辑之前就完成拦截。
就像奶茶店在点单台就把超载订单拦下,而不是等后厨炸了再补救,限流把灾难挡在系统入口之外。一个合理的 QPS 阈值,就是你对自己后厨产能的诚实估算。
- N_req 表示请求总数在统计窗口内进入 API 的请求条数,分子越大,速率越高。
- T 表示统计时长以秒为单位的时间长度,分母越大,平均速率越低。
- 除法得到每秒速率请求数除以秒数,就是每秒查询量,用来对齐配额上限。
二、固定窗口算法
你最先想到的规则是“整点放号”:每个小时最多发 100 个号,9 点、10 点、11 点……每个整点计数器清零重来。这就是固定窗口算法的直觉。
固定窗口把时间轴切成等长的片段,每个片段配一个独立的计数器。请求到达时,先判断自己落在哪个窗口,再给对应计数器加一;当计数器达到配额上限 L,后续请求被拒绝,直到下一个窗口开始。
它最大的优点是简单:进程内一张哈希表,或者 Redis 里一个带过期时间的 key 就能实现,几乎零额外开销。但它有一个著名的缺陷——窗口边界突发。
想象 8:59 你接了 100 单,9:00 刚清零又接了 100 单。两分钟内 200 个订单涌进后厨,固定窗口的防线在边界处形同虚设。于是你意识到:必须改成“滚动”的统计方式。
- Δ 窗口长度时间格子的跨度,比如 60 秒,它决定了计数清零的频率。
- kΔ 窗口起点第 k 个窗口的起始时刻,窗口编号从 0 开始递增。
- t_i 请求到达时间每个请求的时间戳,它决定请求被算进哪个窗口。
- L 配额上限单窗口最多放行的请求数,超过即拒绝。
三、滑动窗口算法
你改进了放号规则:不再看整点,而是挂一块“滚动看板”,永远显示“从现在往前推 1 小时”有多少单。10:00:30 看的是 9:00:30 到 10:00:30 的订单数。8:59 的 100 单和 9:00 的 100 单,终于不会被当成两个独立窗口了。
滑动窗口(Sliding Window)统计的是一个持续向前滚动的区间 $[t-\Delta, t)$。每来一个请求,窗口右端就推进到当前时刻,同时丢弃窗口左端滑出去的旧请求。计数永远反映“最近 Δ 秒”的真实负载。
最精确的实现是“滑动日志”:记录每个请求的时间戳,新请求到达时先删除窗口外的旧时间戳再计数,缺点是按请求量消耗内存。工程上常用分桶近似优化:把 Δ 切成 N 个小格,每个小格只保存计数,计算时按时间重叠比例加权求和,用少量精度换固定内存,这就是“滑动对数”的优化思路。
滑动窗口就是那块永远跟着现在走的看板,边界漏洞被彻底堵住。代价是:要么多花内存记日志,要么接受分桶近似的误差。精度和成本,永远是一对需要权衡的兄弟。
- t 当前时刻滑动窗口的右端点,随每个请求实时前进,窗口因此“滑”动。
- Δ 窗口长度向前回看的时长,决定统计范围的大小。
- 滚动区间左闭右开区间内的所有请求时间戳都会被计入,滑出区间的自动失效。
- 配额判断计数达到 L 即拒绝,与固定窗口相同,但区间随时在移动。
四、令牌桶算法
后来你换了更聪明的制度——“接单券”。收银台放一个券盒,每分钟自动掉进 5 张券,盒子最多装 10 张。顾客下单必须先交 1 张券。生意冷清时券攒满盒子,突然来一拨团购也能连做 10 杯;券用完了,就得等下一批券生成。
这就是令牌桶算法(Token Bucket)。它有两个核心参数:令牌生成速率 r 决定长期平均速率,桶容量 C 决定最大突发能力。请求到达时,桶里有令牌就消耗一个并放行,没有就拒绝或等待。
令牌的补充不是靠定时任务,而是“懒更新”:等请求来了,才用当前时间与上次更新时间的时间差,一次性补齐该补的令牌。这既精确又高效。桶满时令牌不再累积,保证空闲再久,突发也不会超过 C。
冷清时攒券、高峰时连做十杯,这正是令牌桶在真实世界中最受欢迎的原因:它不但限速,还允许合理突发,把“平均保护”和“用户体验”都照顾到了。市面上绝大多数 API 网关的默认限流器,就是它。
- N(t) 当前令牌数t 时刻桶里剩余的令牌数量,请求消耗它,时间补充它。
- r 令牌生成速率每秒向桶中补充的令牌数,决定长期平均每秒放行多少请求。
- 时间差补令牌$t - t_0$ 是距离上次更新的真实流逝时间,乘上 r 就是这段时间应补的令牌。
- min 容量约束补完也不能超过 C,令牌最多攒满一桶,避免无限累积。
五、漏桶算法
如果你追求的是“绝对平稳”,接单券还不够。你可能会想:所有订单先进一个等待队列,后厨不管外面多火爆,都按固定速度取单,比如每十秒做一杯;队列满了就拒收新单。这就是漏桶(Leaky Bucket)的直觉——一个订单漏斗。
漏桶有两个参数:队列容量 B 和恒定输出速率 r。请求先入队,后端按 r 的固定节奏消费队列。与令牌桶“有令牌就放、没令牌再看”的概率式放行不同,漏桶是确定性的整形器:输入再乱,输出都像涓涓细流。
工程上,漏桶适合对下游稳定性要求极高的场景,例如批量写数据库、消息推送、对接第三方受限接口。它的代价是引入了排队延迟:突发请求可能要在队列里等很久,甚至被直接丢弃。
令牌桶和漏桶,一个像“发券限流”,一个像“漏斗整形”。前者关心能不能放行,后者关心怎么平稳输出。挑哪个,取决于你是怕“平均超速”,还是怕“瞬间洪水”。
- q 队列积压量等待区排队的订单数,它是漏桶的状态核心。
- B 队列上限漏斗能容纳的最大积压量,达到上限后新请求被丢弃。
- r 恒定输出速率后端每单位时间处理的请求数,不随输入波动。
- max 保证非负队列处理完不会变成负数,用 max(0, ·) 截断。
六、分布式限流
生意好到开了 5 家分店,共用一个小程序点单。问题来了:如果每家店各挂一块自己的滚动看板或券盒,5 家店一分钟最多接的单子就会变成单店的 5 倍,中央厨房照样被淹没。你需要一本所有分店共享的“中央账本”。
这就是分布式限流的本质:当服务以多个实例部署时,单机限流在每台机器上各自计数,总配额会按实例数放大。正确做法是让所有实例共用一个集中式计数器。
最经典的实现基于 Redis:所有实例对同一个 key 执行 INCR 自增,第一次计数时设置 EXPIRE 过期时间,实现窗口自动翻转。为了在并发下保证“读计数-判断-自增”不被插队,通常把这段逻辑写成 Lua 脚本,由 Redis 原子执行。
中央账本的代价是多一次网络往返,以及 Redis 故障时的可用性风险——所以很多团队会再加一层本地限流兜底。分布式限流不是银弹,但它让“5 家分店共守一套配额”成为可能。
- INCR 原子自增Redis 的 INCR 命令把 limitKey 的值加 1 并返回新值,所有实例操作同一个键。
- 首次设置过期当计数等于 1 时,说明这是新窗口的第一个请求,用 EXPIRE 给键设置 Δ 秒过期时间,窗口自动翻转。
- 配额判断自增后的计数 N 超过配额 L,说明窗口已满,拒绝请求。
- Lua 原子执行把“读计数、判断、自增”写进一个 Lua 脚本整体执行,避免并发请求同时读到相同计数导致超额放行。
七、选型与响应设计
阀门装好了,还得教顾客怎么配合。门口贴三句告示:今天已约满(对应 HTTP 429)、下午三点放号(对应 Retry-After 头)、别一直刷手机(对应退避重试)。这三件事,就是限流响应设计的全部。
先说选型。需要精确统计最近时间窗内请求量,选滑动窗口;容忍短期突发、想要体验平滑,选令牌桶;要求输出绝对恒定,选漏桶;服务多实例部署,就叠加 Redis 分布式限流。没有最好的算法,只有最合适的场景。
再看响应。被限流的请求,API 应统一返回 429 Too Many Requests,并在响应头中给出 Retry-After 指示重试时间。客户端收到 429 后,应采用指数退避策略重试,否则大量客户端同时重试会引发“重试风暴”,把刚缓过来的服务再次打垮。
好的流量阀门,不仅要会“关”,还要告诉别人“什么时候能再来”。限流不是把用户挡在门外,而是让服务在承受范围内持续可用——这正是 API 治理与安全的核心素养。
- k 重试次数客户端连续收到 429 的次数,从 0 开始计数。
- t_base 基础等待时间第一次重试前的基础等待时长,例如 500 毫秒。
- 2^k 指数放大每次失败等待时间翻倍,第 1 次等 2×,第 2 次等 4×……
- t_max 上限保护防止等待时间无限增长,超过上限后保持固定等待。
AI 应用场景
场景一:AI 大模型 API 网关的令牌桶限流
阶段:模型服务接入与流量治理
解决的问题:大模型推理成本高,单个失控脚本能在几秒内打满 GPU 配额。传统按 IP 封禁误杀率高,且无法应对“平时不用、高峰猛刷”的流量。用令牌桶按 API Key 限流:一个 Key 每分钟生成 60 个令牌、桶容量 120,就像给每个顾客发“接单券”,既限制平均调用速率,又允许合理的短时脉冲。
具体案例:某 AI 公司开放 chat/completions 接口,为免费用户设 60 req/min、付费用户 600 req/min。一个免费用户跑了个死循环脚本,令牌耗尽后被接连拒绝并返回 429,GPU 集群稳定运行,其他用户的请求完全不受影响。
场景二:电商大促的滑动窗口限流
阶段:业务后端流量保护
解决的问题:秒杀场景下,固定窗口在整点边界会出现双倍突发。10:00:59 放行 100 单、10:01:00 又放行 100 单,订单服务照样被打垮。滑动窗口统计“最近 60 秒”的真实订单量,边界漏洞被消除,像滚动看板一样时刻反映真实负载。
具体案例:某电商平台大促期间,用滑动窗口分桶近似实现网关限流,把下单接口稳定控制在 5000 QPS 以内。即使瞬间涌入 10 万用户,后端订单服务也始终保持健康,限流期间用户看到友好的“排队中”提示。
场景三:开放平台的分布式限流
阶段:多实例服务的统一配额管理
解决的问题:服务部署 10 个实例,每台各自限流会导致总配额放大 10 倍。用 Redis 中央账本 + Lua 原子脚本,让所有实例共享同一份配额,响应统一返回 429 并携带 Retry-After 时间。
具体案例:某开放平台为第三方应用分配每天 100 万次调用额度,基于 Redis 的分布式限流保证跨实例总量一致;超限时返回 429 + Retry-After,SDK 自动按指数退避重试,既保护平台,也让开发者获得清晰的重试指引。
Python 演示
import timeimport threading class TokenBucket: """令牌桶限流器: 按速率补充令牌, 桶容量决定最大突发.""" def __init__(self, rate, capacity): self.rate = rate # 令牌生成速率(个/秒) self.capacity = capacity # 桶容量(最大令牌数) self.tokens = capacity # 初始满桶 self.last = time.monotonic() self.lock = threading.Lock() def acquire(self): """尝试获取一个令牌, 成功返回True, 失败返回False.""" with self.lock: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens >= 1: self.tokens -= 1 return True return False class SlidingWindow: """滑动窗口限流器: 统计最近window秒内的请求数.""" def __init__(self, limit, window): self.limit = limit # 窗口内最大请求数 self.window = window # 窗口长度(秒) self.requests = [] # 请求时间戳列表 def acquire(self): now = time.monotonic() self.requests = [t for t in self.requests if now - t < self.window] if len(self.requests) < self.limit: self.requests.append(now) return True return False def simulate(name, limiter, total=12, interval=0.05): print("===", name, "===", end="") ok_cnt = 0 for _ in range(total): time.sleep(interval) if limiter.acquire(): ok_cnt += 1 print(" A", end="") else: print(" R", end="") print(" -> 放行 %d/%d" % (ok_cnt, total)) if __name__ == "__main__": simulate("TokenBucket rate=10 cap=5", TokenBucket(rate=10, capacity=5)) simulate("SlidingWindow limit=3 window=0.3", SlidingWindow(limit=3, window=0.3))
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)按时间差补齐令牌且不超过桶容量,这是令牌桶“懒更新”的核心。if self.tokens >= 1:判断桶里还有没有令牌,决定这次请求放行还是拒绝。self.tokens -= 1每放行一个请求,就从桶里拿走一个令牌。now = time.monotonic()使用单调时钟计时,不受系统改时间影响。self.requests = [t for t in self.requests if now - t < self.window]滑动窗口的核心:把窗口外的旧时间戳全部剔除。if len(self.requests) < self.limit:窗口内计数小于配额才允许新请求进入窗口。with self.lock:用互斥锁保护令牌读写,保证多线程环境下计数准确。
常见误区
误区初学者以为限流就是对超配额请求一律返回错误,收到 429 就像被判了死刑,用户体验一定很差。
正解限流是“准入控制”,拒绝只是策略之一。令牌桶允许积累突发,漏桶用队列缓冲,很多系统对超限请求先排队等待,只有队列满才拒绝。就像奶茶店客流大时可以请顾客排队,而不是直接赶人。
为何常见:限流名字里带“限”,容易让人联想到“禁止”,忽略了“排队、等待、降级”也都是限流的兄弟策略。
误区都是数一分钟内的请求,选实现更简单的固定窗口不就行了?反正长期平均速率差不多。
正解固定窗口在窗口边界存在双倍突发漏洞:8:59 的 100 单加 9:00 的 100 单,两分钟内放行 200 单。滑动窗口统计的是 [t−Δ, t) 滚动区间,边界两侧的请求被同一个窗口统计,漏洞被消除。对时延敏感、配额严格的服务,两者差异巨大。
为何常见:只看长期平均值两者确实接近,但压垮系统的往往是“瞬间峰值”,只看平均便看不到边界风险。
误区服务有 3 个实例,每个实例限 100 QPS,加起来就是 300 QPS,完全没问题。
正解每个实例独立计数时,集群总量会放大到单机配额乘实例数,而且负载不均时某个实例可能先被打爆。分布式限流用 Redis 等中央存储共享同一个计数键,才能保证集群总配额一致,就像多家分店共用一本账本。
为何常见:单机限流实现简单、直觉清晰,把“单机正确”误当成“集群正确”,是很自然但危险的思维惯性。
误区容量大意味着能存更多令牌、更不容易触发限流,正常用户不会被误伤,何乐而不为?
正解桶容量决定一次性突发量。容量设为 100,等于允许一瞬间连放 100 个请求进入后端。如果下游数据库或模型推理扛不住 100 并发,照样被打垮。容量必须结合下游容量评估来确定,而不是越大越好。
为何常见:只盯着“别误伤正常用户”,却忘了限流的第一目标是“保护下游”。
误区都是控制速率,名字里都带桶,反正都能限速,没必要区分。
正解令牌桶看的是“有没有令牌”:有就放、没有就拒或等,输出是突发加平滑的混合;漏桶看的是“队列满没满”:请求先进队列,后端按恒定速率处理,输出完全平滑。选令牌桶通常意味着接受突发,选漏桶则要求绝对恒定输出。
为何常见:两个算法都叫“桶”,图示都是容器装水,视觉上太像,容易让人忽略一个“发券”、一个“漏斗”的本质差异。
面试题
Q1. 令牌桶中“令牌生成速率”这一参数的主要作用是?
- A.决定长期平均放行速率← 正确答案
- B.控制桶最大容量大小
- C.决定每次请求超时时间
- D.管理服务器并发连接数
答案:A。令牌生成速率 r 决定长期平均放行速率:每秒补 r 个令牌,意味着长期每秒最多放行 r 个请求。桶容量 C 管的才是突发上限;超时和并发连接数与令牌桶参数无关。
Q2. 固定窗口算法最大的缺陷是什么?
- A.窗口边界双倍流量突发← 正确答案
- B.实现复杂且维护困难
- C.需要大量额外存储开销
- D.无法配置每秒请求配额
答案:A。固定窗口把时间切成 [kΔ, (k+1)Δ),窗口边界两侧的请求在时间上相邻,却分属两个窗口、各自能满配额放行,造成两倍突发。固定窗口实现其实很简单,存储开销也小,配额也可配置。
Q3. 滑动窗口相比固定窗口的核心改进是什么?
- A.采用更长的时间窗口
- B.滚动统计替代固定边界← 正确答案
- C.增加请求排队缓冲机制
- D.改用令牌桶算法控制
答案:B。滑动窗口把计数区间改为 [t−Δ, t),窗口随当前时刻滚动,边界两侧的请求会被同一个窗口统计,从而消除双倍突发。A 是调整窗口长度而非改进;C 是漏桶特征;D 是换算法而非改进。
Q4. 令牌桶允许突发流量的根本原因是什么?
- A.令牌可以在桶中累积← 正确答案
- B.请求可以一直排队等待
- C.令牌生成速率非常高
- D.桶的容量设置为零
答案:A。令牌桶允许突发是因为令牌可累积:空闲时令牌持续补充、桶满停止,高峰期可一次性消耗积累的令牌。排队是漏桶的特征;生成速率高不等于允许突发;容量为零反而完全不允许突发。
Q5. 漏桶算法输出速率的特点是什么?
- A.随输入流量大小波动
- B.输出速率恒定不变← 正确答案
- C.与桶容量大小成正比
- D.完全由突发流量决定
答案:B。漏桶的核心特性就是恒定输出:后端按固定速率处理队列,与输入流量无关。A 是普通队列的特征;输出速率与桶容量无关;突发被队列吸收,不会直接体现在输出上。
Q6. 分布式限流使用 Redis 时,为什么用 Lua 脚本实现计数?
- A.提升网络数据传输速度
- B.保证判断与递增原子执行← 正确答案
- C.减少 Redis 的内存占用
- D.便于跨机房的数据同步
答案:B。分布式限流的关键是“读计数、判断、自增”必须原子完成,否则并发请求会同时读到相同计数、一起放行导致超额。Lua 脚本在 Redis 中原子执行,避免竞态。A、C、D 都不是使用 Lua 的核心原因。
Q7. 收到 HTTP 429 响应后,客户端最合理的做法是?
- A.立即无限重试直至成功
- B.按响应重试时间等待重试← 正确答案
- C.直接放弃所有后续请求
- D.立刻提高请求的并发数
答案:B。429 响应通常携带 Retry-After 头或 JSON 字段,告知客户端等待多久再重试。客户端应遵守该时间重试,避免重试风暴。无限重试会加剧服务压力;直接放弃不合理;提高并发恰恰是限流要防御的行为。
Q8. 以下哪些属于常见的限流算法?
- A.固定窗口算法← 正确答案
- B.滑动窗口算法← 正确答案
- C.令牌桶算法← 正确答案
- D.快速排序算法
答案:ABC。固定窗口、滑动窗口、令牌桶都是经典限流算法;快速排序是排序算法,与限流无关。本题考察对限流算法家族的整体认知。
Q9. 关于令牌桶与漏桶的区别,以下说法正确的有哪些?
- A.令牌桶允许短期突发← 正确答案
- B.漏桶输出速率恒定← 正确答案
- C.两者原理完全一致
- D.漏桶需要队列缓冲← 正确答案
答案:ABD。令牌桶允许短期突发(桶内令牌累积),漏桶以恒定速率输出(需要队列缓冲),两者原理完全不同。C 说“完全一致”是错的——一个发券、一个漏斗,本质一放一整形。
Q10. 设计限流响应时,需要考虑哪些要素?
- A.统一的 429 状态码← 正确答案
- B.Retry-After 重试时间← 正确答案
- C.清晰的错误提示信息← 正确答案
- D.完整的服务器运行日志
答案:ABC。好的限流响应应统一返回 429 状态码,携带 Retry-After 指示重试时间,并给出清晰的错误信息让调用方理解原因。把服务器运行日志直接暴露给用户既不安全也不合理。