聊限流算法,很多人第一反应是去抄一段计数器代码,但我这几年排查线上接口被打穿的问题,把常用限流算法翻来覆去核对了一遍又一遍,最大的感受是:算法本身都不复杂,复杂的是你知道每种算法会在什么场景下坑你。固定窗口、滑动窗口、漏桶、令牌桶,这些名字听着像考试重点,真正决定成败的反而是“为什么选它”“阈值怎么定”“集群里怎么保持一致”这三件事。
这篇文章不是教科书式的名词解释,我会按自己做限流模块时的实际梳理路径来写:先讲清限流到底要解决什么问题,然后把四个经典算法的原理、代码和边界都过一遍,再说分布式环境会跳出来的坑,最后用一张选型对照表收尾,并分享一些从生产事故里攒下来的参数配置经验。适合正在做接口防刷、网关限流、或者准备给服务加保护层的同学,不管你是刚入门还是已经写过一点限流逻辑,都应该能找到能直接拿去用的东西。
1. 先从“限流到底在解决什么问题”说起
1.1 限流不是拒绝用户,是给系统留一条活路
很多第一次接触限流的人,会把它理解成“不让用户访问”,这个方向其实偏了。限流保护的是系统自身的处理能力,不是业务规则。设想一个场景:某个查询接口平时有几百的QPS,突然上游一个数据同步任务跑起来,把QPS推到几千,数据库连接池先被打满,接着接口超时,最后线程池里的任务堆积,连健康检查都过不了。这时候如果有一个限流层在入口挡一下,把超过阈值的那部分请求直接返回“系统繁忙”或进入重试逻辑,后面那批正常用户反而能继续用系统,整体可用性比“全部一起卡死”要好得多。
所以限流本质上是在做“容量预算”:下游能扛多少,上游就放多少。我倾向于把限流看作一种保护性的“量入为出”,不是简单的来访计数,而是在资源有限的情况下决定谁先谁后、谁可以放弃。限流的动作通常有三种:直接拒绝、排队等待、快速失败并降级。直接拒绝适合对实时性要求高的场景;排队等待适合削峰填谷,比如秒杀、导出报表;快速失败适合那些请求可以重试的场景,返回一个错误码让调用方稍后再来。
1.2 两个容易搞混的维度:入口总量与服务分片
设计限流方案前,一定要先分清限流对象。
第一个是“入口总量”:所有请求聚合在一起计算,保护的是整体系统,比如网关层的总QPS限制。这种思路的问题在于,如果某一个恶意或者异常的调用方把入口流量全部吃掉,正常用户也会被误伤。所以很多时候不能只做一个全局限流,还要配合“按调用方限流”,比如按用户ID、按IP、按AppKey分配配额。
第二个是“服务分片”:一个服务有多个实例,每个实例都有自己本地的线程池和连接数。假设整个集群能扛8000 QPS,但你有8个实例,每个实例单机只能扛1000 QPS;如果只在网关做一个8000的全局限流,流量分配不均时某个实例照样会被打挂。这时候必须考虑单机维度的限流,不能只看集群总量。
我见过不少团队跳过这个分析,直接把限流算法套上去,结果要么阈值设太大没起到保护作用,要么设太小误杀了正常流量。我的建议是:动手写代码之前,先用一张表列出“系统入口在哪、下游最长链路是什么、单机瓶颈是什么、调用方有多少种”,这几个问题搞清楚,选哪个算法已经很清楚了。
2. 四类经典算法一次讲透:原理、代码与边界
2.1 固定窗口计数:实现最简单,坑也最明显
固定窗口算法是入门首选,逻辑相当直白:把时间切成固定大小的小段,比如每秒、每分钟,每一段维护一个计数器,请求进来就加一,超过阈值就拒绝,窗口切换时计数器清零后再重新计数。用代码写大概是这个样子:
import time class FixedWindowLimiter: def __init__(self, max_requests, window_seconds): self.max_requests = max_requests self.window_seconds = window_seconds self.current_window = 0 self.count = 0 def allow(self): now = int(time.time()) window = now // self.window_seconds if window != self.current_window: self.current_window = window self.count = 0 if self.count >= self.max_requests: return False self.count += 1 return True单看这段代码,每分钟限100次,确实能限制总量。但它的缺陷同样明显:窗口边界处会出现“双倍突发”。比如每分钟限制100次,第59秒来了100个请求,第60秒窗口刚切换又放进来100个请求,实际上在两秒内放行了200个请求,这完全可能把下游冲到崩溃。
分布式场景下,固定窗口还要多考虑一步:多个节点同时修改同一个计数器存在竞态问题,不能简单地先读再写,而要用具备原子自增和过期能力的中心化KV存储,并且保证“首次写入时设置过期时间”这个操作也是原子的。如果你用的是支持Lua脚本或原子命令的缓存组件,可以在服务端用一条脚本把自增和过期打包完成,否则并发很高时会有很多请求同时读到同一个旧值,限流失效。
它的适用场景其实有限:适合对精度要求不高的全局限额,比如“每分钟最多允许某个操作执行300次”,不太适合作为核心接口的精确保护手段。优点是内存占用极小、实现简单,可以快速挡住明显的流量洪峰。
2.2 滑动窗口:修掉边界毛刺,代价是存储结构
滑动窗口算法是把固定窗口的“整块切换”改成“逐步滑动”。最常见的实现是基于时间戳集合:请求到达时,把当前时间戳写入一个有序集合,然后清理掉窗口范围之外的历史时间戳,再统计集合长度,超过阈值就拒绝。思路相当于用一个随时移动的时间区间去统计请求数,而非跳到下一个固定整点。
import time class SlidingWindowLimiter: def __init__(self, max_requests, window_seconds): self.max_requests = max_requests self.window_seconds = window_seconds self.timestamps = [] def allow(self): now = time.time() while self.timestamps and self.timestamps[0] <= now - self.window_seconds: self.timestamps.pop(0) if len(self.timestamps) >= self.max_requests: return False self.timestamps.append(now) return True这个版本的实现有两处需要优化。一是不要用列表头部一直pop,性能不好,实际项目里往往用双端队列或者环形数组;二是每次请求都清理所有过期时间戳,在超高QPS下也是开销。很多生产实现会改成“按更细的格子切分”,比如把一分钟切成60个一秒的小格子,每个格子维护计数,窗口向前滚动时累加最近60个格子的值,精度比固定窗口高很多,内存又比存所有时间戳小得多。
滑动窗口解决了“边界双倍突发”问题,但要注意:它依然属于“计数类”限流,不具备控制请求消费节奏的能力。它只回答“这段时间内进来了多少个”,至于这堆请求进入后是立刻打到下游,还是排队慢慢消费,滑动窗口管不了。如果你既要防止突发,又要保证下游处理速度,它就不够了。
2.3 漏桶算法:让流量“排队”而不是“突刺”
漏桶的原理可以想象成一个底部有洞的水桶:水以任意速率倒进桶里,但桶底的小孔只能按固定速率漏水,桶如果满了,新倒进来的水就直接溢出。对应到系统里,桶是一个队列,请求先进入队列,由另一个消费者线程以恒定速率从队列里取出并转发给下游,队列满了就直接拒绝。
漏桶最大的优点是把“入口速率的不可控”转化成“出口速率的恒定”,这个特性在保护下游数据库、第三方接口时非常有价值,因为大多数下游系统对“最大承载速率”敏感,却对“请求到达的节奏”不敏感。例如调用某个外部服务,对方明确要求每秒最多100次,直接用漏桶设置恒定的出队速率,上游再乱也都是每秒100个请求被转发出去。
它的问题也很明显:一是队列会带来延迟,如果桶容量设置过大,一个请求可能要等待几秒甚至更久才被处理,客户端容易超时;二是无法应对“突发友好型”业务,比如运营活动开始时瞬间涌入一批合法流量,漏桶会统一把突刺削平,但有些业务本身就需要短期内多处理一点请求。实现漏桶时可以借用消息队列的思路,也可以直接用线程池配合有界队列:
ExecutorService executor = new ThreadPoolExecutor( 1, 1, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(capacity), new ThreadPoolExecutor.AbortPolicy() );用单线程线程池加有界队列模拟漏桶时,capacity就是桶容量,AbortPolicy对应“桶满溢出即拒绝”。注意,桶容量不能简单等于业务QPS,而是要结合客户端超时时间计算:如果下游处理一个请求平均耗时200ms,你能容忍请求排队最长2秒,那么桶里最多积累10个待处理请求,容量可以设为10上下。
2.4 令牌桶算法:允许突发,才是多数业务要的
令牌桶和漏桶的方向正好相反。系统以恒定的速率向桶里放令牌,比如每秒放100个,桶容量上限是突发量,比如最多存200个令牌。每个请求进来时先尝试从桶里取走一个令牌,拿到了就放行,拿不到就拒绝或等待。只要桶里还有之前攒下来的令牌,就可以在短时间内处理超过正常速率的请求,这就实现了“平时匀速,瞬时可突发”。
令牌桶的精妙在于它允许“带宽借用”。最典型的是某个接口正常每秒需要1000 QPS,但偶尔会有一两秒飙到2000,如果下一跳的资源能扛住这种短时峰值,令牌桶比固定窗口和漏桶都合适。它把“限流”和“突发容忍”统一到了一个模型里,也是生产环境里用得最普遍的一类算法。
令牌桶的关键参数有三个:填充速率rate、桶容量capacity、当前令牌数tokens。每次请求时按时间差补充令牌,然后判断是否足够:
import time class TokenBucketLimiter: def __init__(self, rate, capacity): self.rate = rate self.capacity = capacity self.tokens = capacity self.last_refill = time.time() def allow(self): now = time.time() self.tokens = min( self.capacity, self.tokens + (now - self.last_refill) * self.rate ) self.last_refill = now if self.tokens >= 1: self.tokens -= 1 return True return False这里有个细节:last_refill必须在每次请求时更新,否则重新计算补充令牌时会重复计入时间差,相当于放大了速率。更稳妥的做法是记录下一次补充到1个令牌所需的时间,而不是每次都学习全部时间差。另外,真正的高并发场景下不要用这种锁内计算的本地实现,可以改成“预生成令牌+原子扣减”的方式,或者把令牌数和时间戳存到中心化KV里,由一段原子脚本完成“补充-判断-扣减”三步。
不过令牌桶也不是万能的。如果下游其实承受不了突发,那它的突发能力反而是风险。容量参数一旦设置过大,等于允许流量瞬间打到下游保护上限之外,需要谨慎评估“这一秒的2000 QPS到底会不会让数据库连接池爆掉”。
3. 从单机到集群:分布式限流时跳不过去的三道坎
3.1 中心化计数解决不了的性能和一致性冲突
单机限流再优秀,部署多实例后都要面对同一个问题:每个实例看到的流量只是全局的一部分,如果各自独立限流,肯定限不住。比如集群有4个实例,每个实例本地限制1000 QPS,整体上限4000 QPS,结果由于负载不均,某个实例收到2500 QPS,它只放行了1000,剩下1500被拒绝,而其他实例可能只收到500 QPS,全局容量白白浪费。
所以分布式限流的第一反应是把计数器放在一个公共的中心化位置,所有实例共享同一个计数。但这样一来,每次请求都要多一次网络往返,延迟和缓存组件的压力都会上升。之前我在一个高QPS服务上试过,把所有限流判断都改成中心化计数之后,单请求限流判断本身就要耗费几百微秒到一毫秒,加上网络抖动,整体P99延迟明显变差。
这类问题的核心不是“要不要用中心化方案”,而是“能不能接受中心化带来的损耗”。我实践下来比较实用的组合是:本地令牌桶做主判断,拦截绝大多数突发流量;中心化计数做兜底,只处理那些本地判断放行的请求中大约百分之几的抽样校验,两个维度配合既保证了低延迟,又不会完全失去全局控制。
3.2 原子操作:不能先读再写
分布式限流比单机更容易出现“超卖”问题,原因就是多个实例并发读写同一个计数。比如固定窗口里,两个实例同时读到当前计数是98,阈值是100,都认为自己可以放行,各自把计数改成99,结果一个窗口内实际放行了101个请求。要避免这种情况,必须保证“读计数、判断阈值、写回计数”这三步是原子的。
业界成熟的做法是用指定缓存组件支持原子自增能力,或者把整个限流逻辑写成一段服务端脚本,在缓存内完成检查和扣减。下面是一段用伪代码描述令牌桶原子判断逻辑的示例,很多团队的实现本质都是这个模样:
local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local last_refill = tonumber(cache.call('GET', key .. ':time') or now) local tokens = tonumber(cache.call('GET', key .. ':tokens') or capacity) tokens = math.min(capacity, tokens + (now - last_refill) * rate) cache.call('SET', key .. ':time', now) if tokens >= requested then tokens = tokens - requested cache.call('SET', key .. ':tokens', tokens) return 1 end cache.call('SET', key .. ':tokens', tokens) return 0这一段的关键是:时间戳和令牌数必须用同一把锁或同一个原子文本来更新,否则两个实例并发执行时,一个补充了令牌,另一个基于旧时间戳又重新补充了一遍,令牌数会翻倍。如果缓存组件不支持这种脚本,那就退而求其次,把所有限流逻辑放进一个分布式锁里,但这样一来锁本身的竞争会成为瓶颈,只适合低QPS场景。
3.3 本地限流加全局近似:工程上更常见的“够用就好”
多数业务其实不要求限流做到“绝对精确”。我后来在几个中大型项目里采用的方案是“分桶配额”:把全局阈值按实例数量或权重平分到每个节点,各节点只做本地限流,不再频繁访问中心化存储。比如全局4000 QPS,4个实例,每个实例直接本地限1000。
这个方案最大的风险是负载不均。如果某个实例被流量热点打偏,它可能挡住大量正常请求,而其他实例的资源又没被用完。一种缓解思路是给每个实例预留一部分“共享配额”作为缓冲:全局4000,可以把3500按实例固定分配,剩下500作为共享池,本地超过固定额度的请求再动态申请共享池配额。这个实现复杂一些,但比完全平分要稳得多。
还有一条路线是用一致性哈希把调用方分散到固定实例,比如按用户ID取模,同一个用户永远落在同一个实例上,然后在这个实例上做精确限流。这种方法避免了不同节点之间的竞争,限流准确性和单机完全一致,代价是流量必须严格按哈希路由,节点扩缩容时会有一批请求被重新分配到新实例,限流状态也会被打散,需要接受一小段时间的不精确。
3.4 别忽视时钟一致性和网络分区
分布式限流没有想象中那么多高端问题,但越基础的问题越容易被忽略。时间戳在很多限流算法里都参与计算,比如令牌桶的补充速率、滑动窗口的窗口边界。如果各实例的系统时钟偏差过大,同一个请求在不同实例看来可能落在不同窗口内,全局限流的结果就乱了。生产环境一定要配置时钟同步,并且限流模块最好统一使用缓存组件侧的时间,而不是每台机器各自取本地时间。
网络分区也要考虑:中心化限流依赖外部缓存,一旦缓存不可用,到底放行还是拒绝?这个问题没有标准答案,我通常建议“快速失败优先”。因为此时系统本身可能已经处在不健康状态,继续放行只会让下游更惨。当然,如果缓存只是在高峰期抖动一下,直接全部拒绝又会误伤大量请求,所以更稳妥的做法是缓存不可用时退化为本地限流,至少不会完全失控。
4. 选型不是选算法,而是选业务容忍度
4.1 一张表对齐算法能力与业务特征
我把四个经典算法的特性整理成了一张对照表,做方案评审时可以直接对着看:
| 算法 | 是否能控制突发 | 是否能平滑速率 | 实现成本 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 否,边界有突发 | 否 | 极低 | 全局限量、粗粒度防刷 |
| 滑动窗口 | 相对于固定窗口更均衡 | 否 | 中 | 接口精确QPS控制 |
| 漏桶 | 不允许突发 | 是,恒定输出 | 中 | 保护第三方下游、削峰 |
| 令牌桶 | 允许一定突发 | 是,按速率补充 | 中高 | Web接口、网关、高量大促 |
选型的判断顺序我建议是这样的:先问下游能不能接受“瞬时高负载”,能接受就上令牌桶;完全不能接受,一切都要匀速,就选漏桶;只想知道大概请求量,不需要精确,固定窗口就够了;介乎之间又怕边界毛刺,选滑动窗口。
如果你还是纠结,我再给一个实际判断:限流层到底是为了“拦住坏流量”还是“保护下游组件”。为了拦坏流量,固定窗口加按调用方配额就够了,因为坏流量本身就充满突发,边界双倍突发根本不重要;为了保护一个只能每小时处理1000条消息的批量任务,固定窗口和滑动窗口都拦不住连续打过来的流量,必须用漏桶或令牌桶。
4.2 参数估算:从下游容量倒推限流阈值
多数人把阈值拍脑袋定成1000,上线后才发现DB连接数不够用。更好的办法是先从下游容量反推。
假设一个接口的链路是:网关 -> 应用 -> 数据库。数据库最大支持2000 QPS,平均每个请求消耗一个数据库连接,并发的请求数量取决于平均响应时间。可以先用这个公式估算单机容量:单机并发上限 = 1000 / 平均响应时间(毫秒)。比如接口平均RT是50ms,单机能承载约20并发请求,对应QPS上限大概是20 * 1000 / 50 = 400。如果有6个实例,集群理论容量约2400 QPS。
考虑到数据库连接池一般不会开满,CPU也不可能一直打满,我会把限流阈值设为理论容量的60%到70%,也就是1400到1700 QPS左右。这个“安全水位”的意义在于给系统留出处理日常波动和GC暂停的空间,也防止某个下游抖动时顺着链路把整个系统拖垮。阈值的60%不是拍脑袋,而是从“高峰期资源占用率”中观察出来的:如果线上高峰期数据库CPU已经跑到85%,那安全水位就是它,能用掉的余量只有15%。
令牌桶场景还多一个参数:突发容量。它可以参考业务允许的缓冲时间计算:如果客户端能容忍2秒的等待,正常速率是1000 QPS,那桶容量可以设置为2000,允许短期1倍突发。超过这个倍数基本等于允许打穿下游,需要谨慎。
4.3 从线上事故里总结的几条限流配置经验
参数配置只是开始,真正让限流不变成“下一个故障源”的反而是运维细节。第一,限流拒绝响应要带明确的错误码和重试建议,不要直接返回一个500让调用方误以为系统崩溃了。可以返回429并在响应头里带上Retry-After,让客户端能按提示退避重试,而不是大家一起疯了一样重打。
第二,限流阈值一定要支持动态调整。我经历过一次大促,提前压测得出的阈值到真实流量一来完全不适配,线上流量特征和压测模型差别很大,只能临时改参数。如果配置写死还要发布重启,等改完服务早就被打挂了。最好把阈值放到配置中心,限流算法只负责读值执行,调整阈值时能秒级生效。
第三,限流日志和监控比算法本身更重要。限流模块应该记录“放行数、拒绝数、当前令牌数/窗口计数、触发限流的调用方来源”等信息,这样一旦有用户反馈被误杀,你能快速定位到到底是阈值太低,还是某个调用方异常吃掉了配额。我之前排查一个偶发超时问题,最后就是靠限流日志发现有一个内部任务在整点批量拉数,把其他业务用户的配额全占了。
最后说一个我自己的习惯:不管用哪种算法,都会在限流触发时打印一条带唯一追踪ID的日志。这个ID会跟着请求继续往下游走,排查问题时能直接串起一整条调用链。看起来只是多了一行日志,线上排障的时间里,它能帮你少熬好几个夜。