“青云游乐园”开园第三年,经理老周焦头烂额。每逢周末,园门口乌泱泱挤着两三万人,检票闸机被蜂拥而至的游客堵死,过山车排队排到园外百米,连园内的小卖部都因为瞬时订单过载而频繁罢工。游客骂声一片,设备频频告警,老周意识到:问题不是“游客太多”,而是“游客在同一个时刻涌进来”。 于是他想明白了一个道理——与其让所有人挤在门口谁也进不去,不如设计一套“入园管理系统”:有人排队等候,有人快速通过,让每一秒入园的人数都控制在设备能承受的范围内。这套系统,就是计算机世界里的限流算法(Rate Limiting)。 本文要聊的令牌桶、漏桶与滑动窗口,正是老周设计“闸门”时用到的三种核心方案。它们解决同一个问题:如何在洪峰式的流量冲击下,保护后端的服务器、数据库和第三方 API 不被击垮。读完这篇文章,你不仅会理解这三种闸门的原理与代码实现,还会懂得在真实的高并发系统里,该在哪个位置安放它们。
对 AI 从业者而言,限流是模型服务和数据管道必备的“安全阀”。掌握令牌桶与滑动窗口,意味着你能在设计 API 网关、模型推理服务和实时数据流时,从容应对流量尖峰,守住系统稳定的底线。
可视化演示
展示三大限流算法:令牌桶按速率生成令牌、漏桶匀速流出请求、滑动窗口统计时间窗内请求数。切换模式对比各算法原理,拖动滑块调节请求速率。
本节配有交互动效(进入视口后自动加载,约 3KB)。
令牌桶算法原理 令牌桶以固定速率生成令牌放入桶中,桶有最大容量限制;每个请求到达时需获取一个令牌,有令牌则通过无令牌则拒绝 允许突发流量:桶满时积累的令牌可应对短时突发,突发上限=桶容量;稳态吞吐=令牌生成速率 pass = tokens > 0; tokens = min(capacity, tokens + rate * dt) 令牌桶适合允许突发的场景
限流基础目标
老周被周末的游客吓怕了,他翻出园区的设备手册:每台过山车每小时最多接待 600 人,小卖部每分钟最多处理 20 笔订单。一旦超过这个数字,系统就会过热、卡顿甚至宕机。老周意识到,他需要的不是“不让游客来”,而是把每一秒进来的游客数量,控制在设备的额定容量之内。这就是限流的第一性原理:保护系统的可用性。
在计算机世界里,这个道理一模一样。你的服务器、数据库、第三方 API 都有各自的“额定容量”——吞吐量上限。平时流量平稳时一切正常,可一旦遇到秒杀、热点事件或恶意爬虫,请求量可能在几秒内飙升十倍。如果不加限制,数据库连接池会耗尽、CPU 会打满、整个服务雪崩式宕机。
限流的核心目标是削峰填谷:把瞬时洪峰削成平缓的长流水,让系统始终工作在安全区间。值得注意的是,限流与“拒绝用户”有本质区别——它更像游乐园门口的“排队缓冲区”,不是把游客赶走,而是让他们有序地等待。理解了这一点,后面所有算法的设计逻辑就都串起来了。
请求 → [限流器:每秒放行 N 个] → 后端服务令牌桶算法
老周的第一版方案很像“售票积分”:他安排了一台自动售票机,每秒往桶里放入一张新票,桶最多存放 5 张。游客来了,先从桶里取一张票,取到就入园,取不到就过会儿再来。周末人多的时候,机器持续不断地产票;人少的时候,票就攒在桶里,等到人流高峰可以“预支”一部分。这套机制,就是令牌桶算法(Token Bucket)。
令牌桶的两个核心参数是令牌生成速率 \(R\) 和桶容量 \(B\)。每个请求必须消耗一个令牌才能通过;令牌由后台进程以速率 \(R\) 持续补充;桶的容量 \(B\) 限定了最多能积攒多少令牌——也就决定了系统能容忍的瞬时突发上限。
有趣的是,令牌桶巧妙地将“允许突发”和“长期平滑”统一了起来。因为桶里可以预先积攒令牌,所以当流量洪峰来临时,系统能迅速“烧掉”积累的令牌,应对短暂的流量尖峰;只要持续时间超过令牌补充的节奏,速率就会被 \(R\) 牢牢锁住。这就像游乐园里平时没人来的时候攒了一叠票,周末高峰可以快速消化一阵子,但票用完后就只能按每秒一张的速度慢慢进了。
- 上次剩余令牌
tokens_last是上一次请求后桶里剩下的令牌数,它是计算的起点。 - 经过的时长
(t_now - t_last)是从上一次补充到现在的时间差,单位是秒;时间差越大,新生成的令牌越多。 - 新生成令牌将时间差乘以生成速率 \(R\),得到这段时间内新印出的令牌数。
- 桶容量截断用 \(\min\) 函数把结果限制在桶容量 \(B\) 以内,多出来的令牌直接丢弃,防止积攒无限多。
漏桶算法
老周试了几天令牌桶,发现一个问题:令牌桶允许突发,可小卖部的收银员吃不消——一次性涌来 20 个人,虽然系统没崩,但收银员手忙脚乱。老周换了个思路:不搞“攒票”了,直接改成匀速放行。他在入园通道里装了一个“旋转闸机”,无论外面挤了多少人,闸机固定每 2 秒转一圈、放一个人进去。这就是漏桶算法(Leaky Bucket)。
漏桶算法的模型非常直观:把流量想象成倒入漏斗的水,水从漏斗底部的小孔匀速滴出。不管上方一次性倒入多少水,底下滴出的速度永远是恒定的 \(R\)。如果桶里已经积满了水,新来的请求(水)就会溢出被丢弃——这个“溢出”就是限流中的拒绝。
漏桶和令牌桶最大的区别,在于漏桶不允许任何突发。它的输出曲线是一条几乎水平的直线,流量被彻底“抹平”。代价是:当洪峰到来时,来不及处理的请求会被直接拒绝(或者进入一个队列慢慢等)。因此漏桶特别适合那些对流量平滑度要求极高、无法接受突发冲击的下游系统,比如真实世界里的数据库写入、短信发送通道。
- 上一人的通过时间
last_pass_time记录了上一个请求被放行的时刻。 - 取较晚者\(\max(t_{now}, \text{last\_pass\_time})\) 保证即使请求在短时间内大量到达,也必须等待上一人处理完。
- 加入服务间隔加上 \(\frac{1}{R}\),即每个请求享受的固定时间窗,\(R\) 越大,间隔越短。
滑动窗口算法
老周后来又遇到一个刁钻的漏洞。他规定“每分钟最多入园 100 人”,但发现有人在第 59 秒的最后一刻冲进来 90 人,第 61 秒又冲进 90 人——在短短 3 秒内,园内多了 180 人,完全失控。问题出在哪?因为他用的是“固定窗口”:每分钟重置一次计数器,窗口交界处的请求可以绕过限制。于是老周想到了滑动窗口(Sliding Window):把一分钟切成 6 个 10 秒的小格子,每过 10 秒整个窗口就往前挪一格,始终统计“最近 60 秒”的总人数。
滑动窗口的核心思想是用细粒度的时间切片来逼近“任意连续窗口内不超过限制”这一理想目标。格子越细,统计越精确,但内存开销也越大。一个常用的简化实现是“滑动日志”:记录每个请求到达的时间戳,清理掉超出窗口范围的时间戳,剩下的计数就是当前窗口的请求数。
滑动窗口的优势在于精确且不会出现边界突刺,特别适合登录失败次数限制、验证码发送频率这类“固定周期内次数敏感”的场景。它的短板在于需要维护连续时间段的计数状态,内存开销比前两种算法略高。对老周来说,滑动窗口就像一本“滚动记账本”——每时每刻都只看最近十分钟入园了谁,超了就在门口拦一下。
- 上一格与当前格计数\(C_{prev}\) 是上一个时间格子的计数,\(C_{current}\) 是本格子的计数。
- 剩余占比\(\frac{w_{remain}}{w_{slot}}\) 表示上一个格子的数据还有多少比例落在当前滑动窗口内;越靠近期末,占比越小。
- 合并估算把按比例折算的上格计数与本格计数相加,得到对当前窗口总量的平滑估计,避免固定窗口的“边界突刺”。
算法选型对比
集齐了三种闸门方案,老周把它们摆在一起对比,发现各有各的脾气。他总结出一张简单的“选型口诀”:要容忍瞬时爆发,选令牌桶;要强制匀速稳定,选漏桶;要精确控制周期内次数,选滑动窗口。
从设计目标看,令牌桶有“两个旋钮”(生成速率 \(R\) 和桶容量 \(B\)),灵活性最高。桶容量越大,对突发流量的容忍度越高;比如电商大促开场前可以预热令牌,让商品页扛住第一波抢购。漏桶只有“一个旋钮”——流出速率 \(R\),一切突发都被队列吸收或丢弃,适合对接无法抗峰的下游依赖,比如调用银行接口或发送短信。而滑动窗口提供的是“精确到秒级”的计数能力,不关心速率快慢,只关心“在任意一个窗口里别超过 N 次”。
具体到工程实践,选型的判断依据可以归纳为三点:业务对突发是否敏感、系统能接受多大的状态存储开销、统计口径是速率还是次数。Nginx 的 limit_req 模块用的是漏桶变体,它强调平滑地处理请求;Google Guava 的 RateLimiter 是经典的令牌桶实现;而社交平台的“每分钟最多发送 5 条私信”这类限制,用的则是滑动窗口。没有绝对最好的算法,只有当前业务场景下最合适的选择。
代码实现要点
老周打算把这套逻辑写成代码,他找到了园区的程序员小刘。小刘提醒他:实现限流器有三个容易踩坑的地方。第一是原子性——多线程或分布式环境下,多个请求同时检查并扣减令牌,必须保证操作是原子的,否则会出现两人合用一张票的情况。单机可以用锁(Lock),分布式则要用 Redis 的 Lua 脚本。
第二是时间源。很多人用 System.currentTimeMillis() 获取当前时间,但系统时钟可能被 NTP 校准而回调,导致限流失灵。正确做法是使用单调时钟(如 Java 的 System.nanoTime()、Python 的 time.monotonic()),它只保证“向前走”,不受墙钟跳变影响。
第三是内存与精度的权衡。滑动窗口如果每个请求都记录时间戳,内存开销会随请求量线性增长;工程上常用“分桶计数”来压缩——比如按秒分桶、只存每个桶的计数器,牺牲少量精度换取稳定的内存占用。对于令牌桶,实现时不必真的开一个后台线程去生成令牌——只需要记录上次补充时间,用“惰性计算”在每次请求时补算增量即可,这与我们前面讲的“生成本质是时间函数”完全一致。
分布式限流实战
单机限流再成熟,也解决不了老周的新烦恼:游乐园开了三个入口,每个入口各自数人头,结果三扇门加起来放进了三倍的人。这就是分布式限流要解决的问题——多个实例共享同一个全局计数器,谁也不能各自为政。最通用的方案是引入 Redis 作为集中式存储,所有实例的请求都到 Redis 里换取放行资格。
但这里有个隐忧:Redis 操作不是天然原子的。“检查计数是否超限”和“计数加一”是两步操作,多实例并发执行时会产生竞态条件。小刘给出的解法是用 Lua 脚本——把检查、计数、扣减三个动作封装进一个 Redis 的 Lua 脚本,Redis 保证脚本整体的原子性。这样一来,无论多少台机器并发请求,只要发给 Redis 的脚本是同一个,最终的计数就是精确的。
分布式限流最常见的落点,是API 网关(如 Kong、Nginx + Lua、Spring Cloud Gateway)上的全局限流插件。网关前置在服务集群入口,统一配置限流策略,按用户 ID、IP 或接口维度限流。比如在秒杀场景中,网关层用令牌桶限定“每秒最多放行 5000 个请求”进入后端,后端再叠加滑动窗口限制“每个用户每分钟最多下单 1 次”——多级限流各司其职,系统才能在高并发洪峰中安稳运行。老周的故事也终于有了圆满结局:游乐园三个入口共享同一个“总闸”,设备和游客都得到了最好的安排。
AI 应用场景
API 网关的全局流量闸门
阶段:模型推理服务上线前的流量治理
解决的问题:大模型 API 服务在热点时段可能收到远超推理集群吞吐量的请求。没有限流时,GPU 显存被打满,推理延迟从 200ms 飙升到 5 秒,甚至引发 OOM 崩溃。使用令牌桶在网关上统一限流,可以按“每秒请求数”控制进入推理集群的流量。
具体案例:某 LLM 服务商在 API 网关配置令牌桶,速率 \(R=100\)、桶容量 \(B=20\),允许短时突发 20 个并发请求,同时保证长期每秒不超过 100 个。高峰期排队请求先进入 MQ 缓冲,避免直接压垮推理引擎。
爬虫与恶意请求的滑动窗口防护
阶段:数据采集与内容安全防护
解决的问题:搜索引擎爬虫和恶意脚本经常在“固定窗口重置”的瞬间集中出击,规避按小时/按分钟重置的计数限制。滑动窗口把窗口切成细格子,任意连续窗口内请求数都不会超限。
具体案例:某内容平台对“单 IP 每分钟抓取次数”应用滑动窗口限流(窗口 60 秒、子窗口 5 秒),当 AI 爬虫在窗口交界处高频抓取时,请求被精准拦截,同时正常用户的访问不受影响。
Python 演示
import threadingimport timefrom collections import deque class TokenBucket: """令牌桶限流器""" def __init__(self, rate, capacity): self.rate = rate self.capacity = capacity self.tokens = capacity self.last_refill = time.monotonic() self.lock = threading.Lock() def try_acquire(self, need=1): with self.lock: now = time.monotonic() self.tokens = min( self.capacity, self.tokens + (now - self.last_refill) * self.rate ) self.last_refill = now if self.tokens >= need: self.tokens -= need return True return False class SlidingWindowCounter: """滑动窗口限流器:按秒分桶统计""" def __init__(self, window_sec, limit): self.window_sec = window_sec self.limit = limit self.buckets = deque() self.lock = threading.Lock() def try_acquire(self): with self.lock: now = time.time() while self.buckets and self.buckets[0][0] <= now - self.window_sec: self.buckets.popleft() total = sum(cnt for _, cnt in self.buckets) if total >= self.limit: return False if self.buckets and self.buckets[-1][0] == int(now): sec, cnt = self.buckets[-1] self.buckets[-1] = (sec, cnt + 1) else: self.buckets.append((int(now), 1)) return True def simulate_requests(name, limiter, total): allowed = 0 for i in range(total): if limiter.try_acquire(): allowed += 1 time.sleep(0.01) print(f"{name}: {total} 次请求,放行 {allowed} 次") if __name__ == "__main__": bucket = TokenBucket(rate=5, capacity=3) window = SlidingWindowCounter(window_sec=10, limit=3) simulate_requests("令牌桶(速率5,容量3)", bucket, 20) simulate_requests("滑动窗口(10秒限3次)", window, 20)
time.monotonic()获取单调时钟时间,避免系统时钟跳变导致限流计算错误。self.tokens + (now - self.last_refill) * self.rate惰性补充令牌:不靠后台线程,只在请求时按时间差补算。min(self.capacity, ...)令牌数不能超过桶容量,超出的令牌直接丢弃。with self.lock:加锁保证多线程环境下“检查-扣减”两步的原子性。self.buckets.popleft()清理超过窗口时间范围的过期时间桶,控制内存占用。sum(cnt for _, cnt in self.buckets)把窗口内所有分桶的计数加起来,判断是否超限。
常见误区
误区我觉得限流就是把多余的请求拒绝掉,把用户赶走。
正解限流的本质是“延迟满足”而非“拒绝”。就像游乐园门口排队,游客不是被赶走,而是被安排在缓冲区等待。工程上,被限流的请求可以进入消息队列排队、返回 503 让客户端稍后重试,或走降级逻辑返回缓存结果。真正拒绝用户是最差的策略,优先选择排队与降级。
为何常见:因为限流器最常见的表现就是返回错误码,初学者只看到“被拒绝”的表象,看不到背后的排队与容量保护设计。
误区令牌桶可以积攒令牌,所以它能无限承受突发流量。
正解桶容量 \(B\) 就是突发上限。每秒生成 \(R\) 个令牌,桶最多存 \(B\) 个,所以任何瞬间最多只能消耗 \(B\) 个令牌,突发量被 \(B\) 硬性约束。比如 \(R=10, B=20\),意味着你最多只能一次性放行 20 个请求,之后就得等令牌慢慢重新积攒。
为何常见:只记住了“令牌桶允许突发”的优点,忽略了隐藏在公式里的容量上限参数,这是没把 \(\min(B, \dots)\) 这一截断理解透。
误区我按每分钟重置计数器限流就行,滑动窗口太复杂没必要。
正解固定窗口在窗口交界处存在“双倍流量漏洞”:第 59 秒和第 61 秒各放行一批,但两批在时间上几乎连续,总流量翻倍。滑动窗口通过细粒度分桶解决了这个边界突刺问题。计数器类限流(如登录次数、发帖频率)正式场景中必须用滑动窗口。
为何常见:小流量场景下固定窗口的漏洞不常暴露,只有高峰流量压测时才会发现计数器重置瞬间的流量穿透。
误区每个服务实例部署一个独立的限流器,总量限制自然就实现了。
正解假设 3 个实例各自限 100 QPS,总量会放大到 300 QPS,完全失去保护意义。分布式限流必须共享统一计数状态(通常放 Redis),或使用集中式网关限流。单机限流只适合完全独立、不共享下游的场景。
为何常见:习惯单机开发的程序员容易把“单机能力”线性外推为“集群能力”,忽略了多实例叠加后的流量放大效应。
误区每来一个请求就做一次计数检查,Redis 网络交互岂不是很慢?
正解单机令牌桶用惰性计算和局部变量即可,开销是纳秒级的;分布式场景也可以结合“本地令牌桶 + 周期同步 Redis”的混合方案,把 Redis 交互摊薄到每毫秒一次批量取 token。真正的性能瓶颈是业务逻辑,而不是限流器本身。
为何常见:把“每次请求都走 Redis 计数”当成唯一实现方案,忽略了批量、本地缓存等优化手段。
面试题
Q1. 令牌桶算法中,桶容量 B 的核心作用是?
- A.控制令牌的生成速度
- B.限制突发的最大请求量← 正确答案
- C.控制请求的处理时长
- D.决定拒绝请求时的错误码
答案:B。桶容量 B 让令牌可以积攒,但总量被 B 封顶,因此它决定了系统能承受的最大突发请求量。生成速度由速率 R 控制,与 B 无关,所以 A 错误;C 和 D 与令牌桶设计无关。
Q2. 下列哪个场景最适合使用漏桶算法?
- A.允许短时突发的大促秒杀入口
- B.需要严格匀速调用第三方短信接口← 正确答案
- C.统计每分钟用户登录失败次数
- D.控制 API 网关的全局 QPS 上限且容忍突发
答案:B。短信供应商通常限制每秒发送条数且不接受突发,漏桶强制恒定速率最合适。允许突发应选令牌桶(A、D);按周期统计次数应选滑动窗口(C)。
Q3. 滑动窗口算法相比于固定窗口的最大优势是?
- A.内存占用更低
- B.实现代码更简单
- C.消除窗口边界处的流量突刺← 正确答案
- D.不需要记录请求时间戳
答案:C。滑动窗口把窗口切分成小格子并随时间滑动,避免了固定窗口在边界重置瞬间被双倍流量穿透的问题。滑动窗口需要维护分桶计数,内存更高、实现更复杂,A、B 均与实际相反。
Q4. 令牌桶算法中,若速率 R=10、桶容量 B=20、桶已满,第 2 秒时一次性到达 30 个请求,前 20 个放行后,第 21 个请求需要等待约多少秒?
- A.0 秒,直接放行
- B.1 秒
- C.2 秒← 正确答案
- D.3 秒
答案:C。20 个令牌瞬间消耗完后,令牌以每秒 10 个的速度补充。第 21 个请求需要再等 1 秒攒够 1 个令牌,第 30 个请求则要等约 1 秒。注意题目问“第 21 个请求”,所以答案是 1 秒左右,选 B 更严谨,但按题意,需要等令牌生成 1 个,故为 1 秒。
Q5. 以下哪些属于限流算法的核心目标?
- A.削峰填谷,平滑流量← 正确答案
- B.保护后端资源不过载← 正确答案
- C.提升单次请求的处理速度
- D.在洪峰下保障整体可用性← 正确答案
答案:ABD。限流追求的是系统整体的稳定和资源保护,包括削峰填谷、防止过载、保障可用性。它并不能提升单个请求的速度,C 是性能优化而非限流目标。
Q6. 在分布式限流中,使用 Redis + Lua 脚本的根本原因是?
- A.Lua 脚本运行速度比 Java/Python 快
- B.Redis 保证 Lua 脚本整体执行的原子性← 正确答案
- C.Lua 脚本可以自动生成限流规则
- D.Redis 不需要网络通信开销
答案:B。多实例并发时,“检查计数-扣减”两步存在竞态条件,而 Redis 以单线程执行 Lua 脚本,天然保证整套逻辑的原子性。性能并非主因,B 准确揭示了技术选型的本质。
Q7. 下列关于三种限流算法的说法,正确的有?
- A.令牌桶允许一定程度的突发← 正确答案
- B.漏桶的输出速率严格恒定← 正确答案
- C.滑动窗口解决边界突刺问题← 正确答案
- D.令牌桶严格禁止任何突发流量
答案:ABC。令牌桶通过桶容量积攒令牌,允许受限的突发;漏桶以固定速率流出,输出完全平滑;滑动窗口通过细粒度分桶消除固定窗口的边界漏洞。D 与令牌桶的特性相反,故不选。
Q8. Nginx 的 limit_req 模块默认采用哪种限流算法?
- A.令牌桶
- B.漏桶← 正确答案
- C.滑动窗口
- D.固定窗口
答案:B。Nginx limit_req 基于漏桶思想实现,请求进入一个队列(桶)并以固定速率被处理,超出队列容量的请求直接返回错误。令牌桶是 Guava RateLimiter 的典型实现,注意区分。
Q9. 滑动窗口分桶计数中,子窗口粒度从 10 秒改为 1 秒后,可能带来什么后果?
- A.限流精度下降
- B.内存开销上升但限制更精准← 正确答案
- C.完全消除令牌生成的等待
- D.不再需要清理过期窗口
答案:B。子窗口越细,滑动窗口对真实流量的逼近越精确,但需要维护的桶数量增加、内存开销上升。精度提升而非下降,A 错误;C、D 与滑动窗口机制无关。
Q10. 在高并发 API 网关中,通常如何组合多种限流策略?
- A.网关层用令牌桶控制总 QPS← 正确答案
- B.业务层用滑动窗口限制单用户频率← 正确答案
- C.用固定窗口替代滑动窗口以减少内存
- D.用 Redis Lua 脚本实现跨实例原子计数← 正确答案
答案:ABD。生产环境常采用多级限流:网关令牌桶兜底全局 QPS,业务层滑动窗口控制单用户周期内次数,跨实例用 Redis Lua 保证计数原子性。C 中用固定窗口替代滑动窗口会引入边界突刺问题,不正确。