限流算法实战对比:计数器、滑动窗口、漏桶与令牌桶
1. 先搞清楚限流到底在解决什么问题我第一次被线上问题逼着认真研究限流策略是从一次活动接口雪崩开始的。那天我们一个秒杀接口的 QPS 直接冲上两千数据库连接池被打满紧接着整个服务像多米诺骨牌一样往下倒。连夜排查之后我开始把计数器、滑动窗口、漏桶、令牌桶这四种经典方案挨个实现了一遍又用模拟流量做了压测才慢慢摸清楚它们的脾气和适用边界。这篇文章不是纸上谈兵的教学稿而是把我踩过的坑、推导过的原理、能直接跑的代码都一起整理出来。1.1 从一次服务雪崩说起很多人一听到限流就觉得是把请求丢掉其实它的本质是在系统承受能力达到上限之前主动放弃一部分流量保护核心服务不被打垮。可以把它想象成一个水库的闸门上游是湍急的河水下游是脆弱的农田。如果没有闸门洪水一来下游直接被冲垮有了闸门不管上游多猛下游始终能按它能承受的速度缓慢放水。我当时遇到的场景更现实秒杀活动一开始大量用户同时点击请求像洪水一样涌进网关。网关本身还能扛但下游的商品服务和订单服务开始响应变慢数据库连接池被打满最后连健康检查也失败负载均衡把流量转到其他实例其他实例又被迅速压垮。这时候如果能在入口处提前限流哪怕丢掉一部分请求至少核心链路不会全挂。限流不是万能的但它是在雪崩之前最后一道能拉住的闸。1.2 限流、熔断、降级各管一段很多刚开始做稳定性治理的同学会把限流、熔断、降级混为一谈其实它们的职责完全不同限流控制进入系统的请求速率防止上游流量超过自身处理能力。管的是入口水量。熔断当下游依赖出现大量错误、超时主动短路不再调用下游让下游有时间恢复。管的是下游健康状况。降级把非核心功能临时关闭比如推荐算法砍掉、评论功能关闭把计算资源让给核心交易链路。管的是资源分配优先级。这三者经常配合使用。限流保护系统自身熔断保护下游依赖降级保护核心业务。我见过不少团队只做了限流下游一抖照样崩也有团队只做熔断入口流量太大时熔断根本来不及生效——因为还没到下游系统自己先被流量打满了。所以限流通常放在最外层是最先起作用的那道防线。1.3 先定指标再谈算法聊算法之前得先搞清楚一个更基础的问题你要限的是什么指标最常见的两种是QPS每秒请求数衡量的是速度。适合控制对外接口的调用频率比如限制某个客户端每秒最多调用 10 次。并发数同时处理的请求数量衡量的是水位。适合保护线程池、连接池、数据库连接等有限资源。这两种指标经常被混淆。举个例子一个接口平均耗时 2 秒QPS 上限是 100按稳定状态的换算同时处理的请求可能高达 200。如果线程池只有 50 个线程用 QPS 限流根本保护不了线程池。反过来如果接口耗时只有 5 毫秒QPS 限到 100并发值其实很低用并发数限流反而可能限制得太死。所以动手写代码之前先问自己三个问题我要保护的是什么我的瓶颈是 CPU、数据库连接、线程池还是第三方 API 的配额这个指标的合理上限是多少限流算法只是工具指标选错算法再精致也白搭。2. 计数器最直观却有一个临界突刺暗坑计数器是四种算法里最朴素的一种也是很多人学限流时写的第一版代码。它的思想简单到不行在一个固定时间窗口内累计请求数超过阈值就拒绝窗口结束就清零重来。2.1 固定窗口计数器实现用 Python 写一个最简单的固定窗口计数器import time class FixedWindowCounter: def __init__(self, limit, window_seconds1.0): self.limit limit self.window_seconds window_seconds self.start_ts time.monotonic() self.count 0 def allow(self): now time.monotonic() if now - self.start_ts self.window_seconds: self.start_ts now self.count 0 if self.count self.limit: return False self.count 1 return True为什么用time.monotonic()而不是time.time()因为系统时间可能被人工调整或者被 NTP 校时time.time()返回的是墙上时钟有可能往前跳或者往后跳导致窗口计算出现负数间隔。time.monotonic()保证单调递增只适合计算时间差是这类场景的标准选择。这段代码本身没什么巧妙之处最多记住一个窗口的开始时间和计数内存占用 O(1)性能非常好。但要注意上面的实现不是线程安全的生产环境要在计数器上加锁或使用原子类否则高并发下 count 会被并发更新限流效果直接失真。2.2 临界突刺问题固定窗口计数器最致命的缺陷是窗口边界处的突刺。假设限流规则是每秒最多 100 个请求时间窗口是[0, 1)和[1, 2)。如果在 0.9 秒时第一个窗口已经放行了 100 个请求到了 1.1 秒第二个窗口重新开始计数又可以放行 100 个请求。那么在这 0.2 秒内系统实际放行了 200 个请求平均 QPS 高达 1000接近目标值的 10 倍。你看这个漏洞并不需要多复杂的攻击手段只要流量在窗口重置前后扎堆出现就能轻松突破限流阈值。对于下游数据库不友好的业务这种突刺很可能直接把连接池打满限流形同虚设。2.3 为什么还要用它既然有这么大的问题为什么计数器仍然存在因为它简单、省资源、在某些低要求场景够用。比如限制某个后台管理接口每分钟最多调用 60 次这种低频接口的流量本来就小窗口边界突刺带来的风险很有限用固定窗口完全可以。另外一些客户端本地限速组件也喜欢用它因为客户端场景内存和性能都很敏感能省则省。但如果你的场景是公网 API、秒杀入口、高并发网关那就要往后看滑动窗口和令牌桶了。3. 滑动窗口削掉边界毛刺的进化版如果你刷过 LeetCode应该对滑动窗口这个词不陌生C 里的滑动窗口最大值、Python 里的滑动窗口中位数都是同一个思想。限流里的滑动窗口本质上就是让一个固定长度的窗口随时间慢慢平移窗口内的记录会不断滑出而不是像计数器那样到期全部清零。3.1 窗口怎么滑固定窗口的问题是重置瞬间把历史计数全部抹掉。滑动窗口的解决办法很直白不重置而是维护一个最近 N 秒内的窗口每当有新请求进来就把超出窗口时间范围的旧请求剔除然后看窗口内的计数是否达到上限。窗口滑动的粒度决定了限流的精确度。粒度越细越接近理想状态但需要记录的东西也越多。3.2 精确版滑动窗口实现最精确的滑动窗口实现是记录每个请求的时间戳通常叫滑动日志import time from collections import deque class SlidingWindowCounter: def __init__(self, limit, window_seconds1.0): self.limit limit self.window_seconds window_seconds self.timestamps deque() def allow(self): now time.monotonic() while self.timestamps and now - self.timestamps[0] self.window_seconds: self.timestamps.popleft() if len(self.timestamps) self.limit: return False self.timestamps.append(now) return True每次请求到达时先做一次垃圾清理把窗口外的旧时间戳从队列头部弹出然后看队列里剩下的数量。如果已经达到上限就拒绝否则把当前时间戳放入队尾。这个实现的好处是精确——窗口内每一个请求都被记录下来不存在边界突刺。坏处也明显每个请求都要记录一个时间戳内存占用是 O(limit)而且每次请求都要剔除过期记录最坏情况下要遍历窗口内的所有记录。在高并发场景下如果限流阈值很大比如每秒几万这个队列会非常长性能会比较难看。好在实际生产里限流阈值一般不会设得特别离谱这个实现在中等并发下依然能跑得不错。3.3 工程中更常用的分桶近似滑动窗口为了兼顾精确度和性能工程里更常用的是分桶方案把一个完整窗口切成若干小格子每个格子维护一个计数请求到达时只更新当前格子的计数统计时把窗口覆盖到的所有格子加一遍。比如把 1 秒切成 10 个格子每个格子 100 毫秒。请求到达时当前格子的计数加 1判断是否限流时从当前格子向前数 10 个格子把计数加起来。格子越细越接近精确滑动窗口但内存和 CPU 消耗也会上升。分桶方案的内存占用从 O(limit) 降到了 O(格子数)不管限流阈值多大格子数量固定性能稳定。边界处会有一些小误差误差范围就是一个格子的时间跨度。一般生产环境下100 毫秒的误差完全可以接受。3.4 Redis Lua 分布式滑动窗口单机部署时上面代码就够了。但一旦服务部署了多个实例每台机器各自维护一个滑动窗口总放行量就会变成单机阈值 × 实例数全局限流直接失效。这时候需要一个所有实例共享的中心化存储Redis 是最常见的选型。用 Redis 的 Sorted Set 可以很自然地实现滑动窗口Score 存毫秒时间戳Member 存请求的唯一标识。判断是否放行时先删掉窗口外的旧记录再看集合里的数量。为了保证删旧-计数-新增这三步操作原子执行必须用 Lua 脚本local key KEYS[1] local limit tonumber(ARGV[1]) local window_ms tonumber(ARGV[2]) local now tonumber(ARGV[3]) local member now .. - .. math.random(1, 1000000) redis.call(ZREMRANGEBYSCORE, key, 0, now - window_ms) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, member) redis.call(PEXPIRE, key, window_ms) return 1 end return 0Member 必须带随机后缀否则同一个毫秒内多个请求的 Score 相同Redis 不知道它们是不是同一个元素可能把重复请求当成同一条记录。PEXPIRE也很重要防止那些很久没有新请求的 key 一直占着 Redis 内存。调用时把当前毫秒时间戳作为参数传入import time import redis client redis.Redis.from_url(redis://127.0.0.1:6379) script client.register_script( -- 上面的 Lua 脚本 ) key sliding:api:v1 now_ms int(time.time() * 1000) allowed script(keys[key], args[100, 1000, now_ms])Lua 脚本让客户端不需要自己处理分布式锁Redis 会保证整个脚本在单线程模型下原子执行。不过要注意追加一套 Redis 调用会增加网络开销QPS 很高时 Redis 本身也可能成为瓶颈后面第 7 节会聊两级限流的思路。4. 漏桶算法把流量抹平成匀速漏桶算法是我个人觉得最听话的限流算法。它的模型特别形象想象一个底部有个小洞的水桶。不管上游怎么往桶里倒水水从洞里漏出去的速度始终是固定的。如果倒水的速度太快桶装不下了水就溢出来相当于请求被丢弃。4.1 漏桶模型的核心特征漏桶的本质是强制整形输入速率可以任意输出速率恒定。这一点和计数器、滑动窗口完全不同后两者只是限制窗口内有多少请求进入但不关心请求是不是忽快忽慢地打到下游漏桶则保证下游看到的流量始终是一条平滑的直线没有突刺没有峰值。用一个生活化的例子奶茶店做奶茶每次只能同时做两杯出杯速度是固定的。顾客点单就像倒进桶里的水不管顾客一次性涌进来多少后厨依然按自己的节奏做。做不过来了就排队排不下就只能请顾客改天再来。4.2 两种常用的漏桶实现最直观的实现是队列 消费线程请求进来先看队列是否已满没满就入队后台线程以固定速率从队列里取请求处理。import threading import time from collections import deque class LeakyBucketQueue: def __init__(self, capacity, rate): self.capacity capacity self.rate rate self.queue deque() self.lock threading.Lock() threading.Thread(targetself._worker, daemonTrue).start() def allow(self): with self.lock: if len(self.queue) self.capacity: return False self.queue.append(time.time()) return True def _worker(self): interval 1.0 / self.rate while True: with self.lock: if self.queue: self.queue.popleft() time.sleep(interval)这种实现很直白但缺点是time.sleep的精度有限高速率下误差会放大而且每个漏桶实例都要占一个线程实例一多线程开销就不划算了。所以在实际代码里我更推荐用水位方式来模拟漏桶不真的启动消费线程而是记录上次请求时间用时间差来计算这段时间漏掉了多少请求。import threading import time class LeakyBucket: def __init__(self, capacity, leak_rate): self.capacity capacity self.leak_rate leak_rate # 每秒漏出多少个请求 self.water 0.0 self.last_ts time.monotonic() self.lock threading.Lock() def allow(self): with self.lock: now time.monotonic() elapsed now - self.last_ts # 漏掉过去这段时间本应处理完的请求 self.water max(0.0, self.water - elapsed * self.leak_rate) self.last_ts now # 桶满了请求溢出 if self.water self.capacity: return False # 请求进入桶里 self.water 1 return Truewater代表当前桶里积压的请求数量它随着时间匀速减少。请求进来时先根据时间差把已经漏掉的部分扣掉再检查桶满没满。如果满直接丢如果没满water 加 1表示这个请求已经进入桶里后续会按匀速慢慢漏出去处理。这种实现的内存占用是 O(1)不需要后台线程天然适合在一个进程内做轻量级限流。需要注意它返回的是一个是否允许进入桶内的布尔值并不保证请求被立即处理——真正处理速度由下游消费能力决定。如果业务方需要接受请求后排队等固定时间再处理那还是得用队列版本。4.3 漏桶适合什么场景漏桶最典型的应用场景是保护那些受不了突发的下游资源数据库写入、文件系统刷盘、第三方 API 调用、消息队列的生产端。我之前做一个数据同步任务需要把一批数据源源不断写入数据库。如果用普通的限流算法流量有高峰有低谷数据库连接池忽高忽低偶尔还会出现锁等待。后来在写入层套了一层漏桶不管上游流量怎么抖数据库每秒收到的写入量都稳定在一个安全值以下整个批处理的稳定性明显提升。漏桶的缺点也很明确它不允许任何突发。即使系统此刻很空闲刚刚恢复过来的请求也必须按匀速处理。假设系统每秒能处理 100 个请求漏桶限速也是 100但瞬间来了 1000 个请求桶容量只有 50那 950 个请求直接没了——即便系统在下一秒就能空闲下来也不允许提前补进度。这种场景就得用令牌桶。5. 令牌桶算法均匀限速和突发流量两不误令牌桶是目前生产环境里最受欢迎的限流算法因为它在限速和允许突发之间找到了一个很好的平衡点。5.1 令牌桶模型和漏桶相反的思路令牌桶的模型也很生活化一个桶里放着令牌系统以固定速率向桶里放令牌桶满了令牌就溢出每个请求到达时必须先从桶里取走一个令牌拿到了才放行拿不到就拒绝或者等有令牌了再放行。和漏桶相比漏桶控制的是出去的水流速度令牌桶控制的是进来的请求能不能拿到许可证。漏桶不管系统此刻多空闲出去的速度都恒定令牌桶如果长时间没有请求桶里会攒满令牌后面突然来一波流量时可以把攒下的令牌一次性花掉所以允许短时突发。5.2 手写一个令牌桶令牌桶的实现依然可以用懒更新思路不需要真的起一个后台线程往桶里放令牌。只要在请求到来时先计算上次放令牌到现在这段时间应该补多少然后补上再判断桶里够不够扣import threading import time class TokenBucket: def __init__(self, capacity, refill_rate): self.capacity capacity self.tokens float(capacity) # 初始状态是满桶 self.refill_rate refill_rate # 每秒补充多少个令牌 self.last_ts time.monotonic() self.lock threading.Lock() def allow(self, cost1): with self.lock: now time.monotonic() # 补令牌但不超过桶容量 self.tokens min( self.capacity, self.tokens (now - self.last_ts) * self.refill_rate ) self.last_ts now if self.tokens cost: self.tokens - cost return True return False初始状态下tokens直接设为capacity也就是满桶。这意味着服务刚启动时允许瞬间通过capacity个请求。比如 capacity10refill_rate5那么刚启动时可以一口气放行 10 个请求之后如果不再来请求令牌会慢慢重新攒到 10如果请求持续不断进来后续每秒只能通过 5 个。cost参数让令牌桶支持按成本扣减。比如一个请求要查询三个下游服务权重大可以给它设 cost3这样同样一秒内它能消耗的配额会被放大对混合流量场景很实用。有一点要注意令牌数量是浮点数长期高频运行下可能有微小的浮点误差但对限流场景来说误差完全可以忽略。如果强迫症发作也可以用整数毫秒作为时间单位把速率换算成每毫秒多少令牌再用math.floor算可扣除数不过一般情况下真没必要。5.3 突发流量为什么能通过这是令牌桶和漏桶最大的区别值得单独立一个小节讲清楚。漏桶的桶里装的是请求请求多了就溢出所以下游看到的永远是匀速。令牌桶的桶里装的是令牌令牌可以攒。假设容量是 10令牌补充速率是每秒 5 个如果系统已经空闲了 10 秒桶里会有 10 个令牌。这时候突然涌入 20 个请求前 10 个都能拿到令牌直接放行第 11 个开始令牌不够了需要等新令牌补充进来。也就是说瞬时速率可以达到 10 个请求连发但长期平均速率依然被限制在每秒 5 个。这种特性非常适合外部 API 网关、秒杀入口、活动接口。平时流量平稳限速合理大促开始时允许把之前积累的额度瞬间用出去扛住首波高峰同时不会让总量失控。5.4 进阶Guava RateLimiter 与预热如果你用 Java应该听过 Guava 的RateLimiter它就是对令牌桶思想的一个工程级实现。不过 Guava 不是简单地每次请求补充令牌而是基于下一次可用时间做预留RateLimiter limiter RateLimiter.create(10); // 每秒允许 10 个请求 double waitMs limiter.acquire() * 1000; // 获取令牌可能需要等待RateLimiter.create(10)默认实现的是SmoothBursty允许突发。另一个重要变体是SmoothWarmingUp创建时可以指定预热时间RateLimiter limiter RateLimiter.create(10, 5, TimeUnit.SECONDS);带预热参数的令牌桶不是一开始就能满速放行而是从较低速率逐渐爬升到目标速率。这个设计解决了一个真实问题系统刚启动时缓存还没建立、连接池还没热直接按全速放流量很容易把系统打崩。预热机制可以理解为给令牌桶加了一个慢启动过程让系统有时间把底层资源逐渐跑起来。我在做活动系统时用过这个特性大促开始时依赖的推荐服务和缓存服务刚扩容完如果瞬间把流量放开冷启动 突发流量会同时压过来。加上预热之后前 30 秒流量平缓上涨等下游资源都热了再满速放行整体稳定很多。6. 四种算法同台对比选型建议前面把四种算法的原理和实现都过了一遍下面用一张表把它们放到一起看算法速率平滑性是否允许突发实现复杂度典型使用场景固定窗口计数器不平稳有边界突刺基本不允许但可能窗口边界闪断最低O(1)低频接口、防暴力刷请求滑动窗口较平稳精度取决于分桶粒度突发被限制在窗口容量内中API 网关、活动限流漏桶输出绝对均匀不允许突发严格整形中O(1)保护数据库、第三方 API令牌桶长期均匀短期可以突刺允许突发上限等于桶容量中O(1)对外接口、秒杀入口、网关选型不是选最好的算法而是选和你的保护目标最匹配的算法。如果目标是简单防刷比如限制登录接口每个 IP 每分钟只能调用 30 次固定窗口计数器就够因为这种流量的窗口边界突刺影响不大如果目标是全局精确限速滑动窗口最合适能保证任意一个短时间内窗口内总量不超如果目标是保护不支持突发的下游比如数据库写入、第三方 API 配额漏桶最安全如果目标是既要限制长期速率又要让业务能扛住首波高峰比如秒杀、营销活动令牌桶是默认答案。还有一点值得强调四种算法不是非此即彼的关系完全可以组合。我现在的习惯是入口网关用令牌桶控制总流速服务内部对数据库的调用再套一层漏桶做流量整形。这样既能支持上游业务突发又能让最底层的数据库始终只看到一条平滑的写入曲线。7. 落地限流踩过的坑与实用建议代码写完只是第一步把限流真正落地到生产环境还有一堆实践问题要处理。下面这几个坑我基本都踩过每一项几乎都是用线上故障换来的经验。7.1 阈值别拍脑袋先压测再定限流阈值只能来自压测不能靠估算。你得知道系统在什么 QPS 下开始变慢什么 QPS 下开始报错才能定出合理的阈值。操作上先用 wrk、JMeter 这类压测工具对服务逐步加压观察 QPS、P999 时延、错误率三个指标。当错误率开始上升或延迟明显抬头的那个 QPS就是系统能力的上限。实际用的时候建议按上限的 70% 到 80% 作为限流阈值留出足够的安全余量。另外系统不是一成不变的。上线新功能、调整数据库连接池、扩容机器之后之前的阈值可能已经过时所以压测最好是定期做而不是一劳永逸。7.2 被限流的流量去哪了别只丢一个错误码限流最容易犯的错是直接返回 500。客户端收到 500 会以为是服务出错了然后开始自动重试重试又打到限流器上最终形成重试风暴反而把系统打得更死。正确做法是给被限流的请求一个明确的语义HTTP 场景返回 429 Too Many Requests同时带上Retry-After头告诉客户端多久之后可以重试。客户端要做指数退避而不是立刻重发。如果这个请求有降级方案比如读取缓存、返回默认数据那更好尽量让用户在限流时也能拿到一个合理的响应。7.3 限流对象别搞错QPS 不等于并发前面提过一次这里再展开讲。限流指标选错了算法再精也白搭。如果一个接口本身耗时很长比如平均 2 秒QPS 限到 100 并不等于同时处理的请求只有 100稳定的并发可能接近 200。这时候如果线程池只有 50照样会打挂。对于这类慢接口应该用并发数限流也就是信号量模型同时最多允许 N 个请求在处理超过就拒绝排队。Python 里可以用threading.BoundedSemaphoreJava 里可以用Semaphore。更严格的场景还可以同时用两种限流QPS 限流速信号量限并发双管齐下。7.4 分布式环境的单机限流失效问题单机的限流算法在多实例部署下会直接失效。假设有 10 个实例每个实例限 100 QPS总量就可能到 1000 QPS。如果全局目标只有 500 QPS那些单机限流根本没达到保护全局目标的效果。这就要引入中心化限流也就是前面提到的 Redis Lua。但 Redis 调用本身有网络开销如果每个请求都打一次 Redis吞吐高时 Redis 会成为新瓶颈。我实际用的是一套两级策略每台机器先做一个宽松的本地限流比如全局目标 500 QPS10 台机器每台本地先限 100 QPS粗粒度挡住大部分明显超频流量本地放行之后再到 Redis 做一次精确的滑动窗口或令牌桶限流把最终总量收敛到 500。这样既避免了所有请求都打 Redis又能保证全局总量可控。有人可能会纠结本地阈值加和超过全局的问题其实没关系本地这层只是成本最低的第一道粗过滤最终由 Redis 那层兜底。7.5 限流日志与监控别裸奔限流效果要有数据印证不然线上出了问题你都不知道是限流太狠、还是流量真的涨了。至少要做两块监控一是被限流的请求量按时间维度、按 IP、按用户维度统计如果某个用户长期大量被限流可能是攻击行为也可能是策略配置太严二是限流前后对比看限流开启后系统的成功率、P99 时延有没有明显变化判断限流阈值是否合理。日志要注意采样。限流产生的拒绝日志在高 QPS 下非常密集全量打日志可能把磁盘打爆反而伤害系统。通常做法是按 1% 或 5% 的概率采样只记录必要的字段时间、来源 IP、限流策略、命中阈值、目标接口。线上问题的定位靠采样日志加监控指标基本就够用了。踩过几次坑之后我的体会是限流不是单一算法的选择题而是一套流量治理的组合拳。计数器、滑动窗口、漏桶、令牌桶各有各的脾气真正落地时要根据保护对象选算法根据部署架构选单机还是分布式再搭配合理的客户端响应契约和监控告警。如果你们也在做限流我建议先把这四种算法的代码各实现一遍然后写个脚本模拟突发流量打一下你会直观看到它们的区别——比我在这里讲一百句话都管用。