资讯动态

限流器全解析:算法、工程实现与分布式方案

发布时间:2026/10/19 13:52:44 来源:尧图企业网站定制
搞了好几年后端几乎每到活动节点或者系统抖动复盘限流器都会被拎出来反复讨论。有人觉得它简单不就是计数器加一个判断嘛有人觉得它难因为它在高并发下出的问题经常是线上事故级别。我自己接过好几个被限流器坑过的系统也亲手做过从单机到分布式的多套限流方案这篇就把这些积累整理成一份能直接照着做的东西从算法本身到工程落地从阈值设定到故障处理一次性讲透。适合刚开始接触服务端保护机制的开发者也适合正在为压测通过率或者稳定性发愁的团队。1. 先想清楚限流器到底在保护什么1.1 限流是保护系统自己不是限制用户很多团队设计限流器提出的第一个问题就是方向错误的——“我要限制调用方每秒最多请求多少次”。正确的问题应该是“我的系统在某一秒到底能处理多少个请求”。这两个问题的差别决定了限流器是做成对外限制的挡板还是做成对内保护的闸门。限流的本质是把进入系统的流量约束在资源能够承受的范围之内通过放弃一部分请求换取整体服务的可用性。可以拿食堂打饭来类比厨师一分钟最多炒十份菜但门口排队的人可能一分钟来一百个。限流器就是那个拦在门口的工作人员只放十个人进食堂剩下的人要么去别处吃要么等下一波名额。关键是工作人员存在的目的从来不是让人吃不上饭而是让食堂永不超过负荷地运转否则厨师累倒这顿饭谁都吃不上。这个“保护自己”的视角直接影响技术选型。如果你只是想限制某个第三方调用方的速度可能一个简单的按IP计数就够用了但如果是为了保护数据库连接池、保护下游服务、保护整个调用链那么就必须要考虑限流算法的平滑性、突发容忍度、以及阈值如何跟随容量动态调整。1.2 动手之前先回答三个问题写一个计数器只是最后一步真正难点在于以下三件事想不清楚限流器就是一颗定时炸弹。第一个限什么指标。最常说的是QPS也就是每秒请求数。但不同场景下指标完全不同数据库连接池限制的是并发连接数CDN边缘节点限制的可能是带宽短信网关限制的是每分钟投放条数批量导出任务限制的是单用户并发任务数。高并发接口限流看QPS没问题但保护慢服务时真正要盯的是并发数因为100个慢请求比1000个快请求更危险。实际项目里经常需要QPS限流和并发数限流同时做。第二个怎么计数。这里决定了算法的选择。是统计一个时间窗口内的总量也就是固定窗口和滑动窗口还是按速率持续补充令牌比如令牌桶还是按恒定速率消费请求比如漏桶。各有各的适用场景第二章展开讲。第三个阈值定多少。拍脑袋写个“反正够用”的值是最常见的故障来源。合理做法是从容量评估入手再用压测校准最后留出足够的余量。第五章会专门讲一套可以操作的流程。1.3 一个常见误区限流器不是万能保护我必须先泼一盆冷水。限流器能做的事情只有“拒绝”或者“放行”它不会帮你缓存、不会帮你扩容、不会补偿已经失败的业务。数据库写入超时、下游服务周期性抖动、代码本身的性能缺陷这些问题限流器统统解决不了。它和负载均衡、缓存、熔断是完全不同维度的保护机制。负载均衡解决的是把流量分散到多台机器缓存解决的是减少后端计算量熔断解决的是故障在下游扩散而限流解决的是让流量总量不超载。正确姿势是把缓存和容量规划先做好再用限流器兜底。见过不少系统上线即挂不是没配限流器而是限流器配得太粗糙正常流量被误伤、真正的异常流量又漏进来。2. 四种经典算法直觉理解与硬核对比限流算法在工程上已经高度收敛主流的无非就是固定窗口、滑动窗口、漏桶、令牌桶这四种。每个算法都曾经在特定背景下被大规模使用理解它们的差异比背概念重要得多。2.1 固定窗口最简单但临界流量能瞬间打穿固定窗口的逻辑很直白把时间切成固定大小的窗口比如一分钟或者一秒钟每个窗口里有一个计数器。来一个请求计数器加一计数超过阈值就拒绝窗口结束就清零重置。import time class FixedWindowCounter: def __init__(self, limit, window_seconds): self.limit limit self.window_seconds window_seconds self.count 0 self.window_start time.monotonic() def allow(self): now time.monotonic() if now - self.window_start self.window_seconds: self.window_start now self.count 0 self.count 1 return self.count self.limit这种实现有个非常经典的临界问题。假设限流阈值是每秒100个请求第59秒来了100个请求第61秒又来了100个请求虽然它们分别属于两个不同的窗口但实际时间轴上这200个请求只隔了1秒甚至不到1秒系统承受的压力直接翻倍限流形同虚设。这也是为什么在秒杀、热点事件这类流量脉冲场景下固定窗口是踩坑重灾区。后面会在踩坑章节里详细讲一个真实事故。2.2 滑动窗口把窗口切成片解决边界问题滑动窗口本质上是对固定窗口的修补。做法是把窗口切分成若干个子窗口比如60秒的窗口切成6个10秒的小块每一个小块单独计数。请求到达时统计最近60秒内所有小块的和超过阈值则拒绝同时把已经滑出窗口的小块计数丢掉。import time class SlidingWindowCounter: def __init__(self, limit, sub_windows6, total_seconds60): self.limit limit self.buckets [0] * sub_windows self.bucket_seconds total_seconds / sub_windows self.total_seconds total_seconds self.window_start time.monotonic() def allow(self): now time.monotonic() elapsed now - self.window_start # 当前请求应该落在哪个槽位 index int(elapsed / self.bucket_seconds) % len(self.buckets) # 清理过期的滑动逻辑每个槽位记录实际时间这里为了可读性做简化 if elapsed self.total_seconds: self.window_start now self.buckets [0] * len(self.buckets) self.buckets[index] 1 return sum(self.buckets) self.limit这个实现的代价也很直观内存从1个计数器变成了N个子窗口计数器每个请求都要做一次加法和求和。但在一般业务规模下窗口切成60个甚至100个槽位毫无压力换来的是对临界问题的完整解决。第59秒的流量在第61秒时已经被滑出窗口不可能再和下一波的流量叠加。2.3 漏桶把突发削平换成恒定速率漏桶的思路和上面完全不同。想象一个底部有洞的桶水从上面倒进去无论桶里积了多少水流出去的速度都受限于洞口的大小是恒定不变的桶满了以后再进来的水直接溢出。对应到请求上就是请求先进入一个队列限流器以固定的速率从队列里取请求处理队列满则拒绝新请求。这个模型的好处是输出流量绝对平滑不会因为入口流量忽高忽低而把下游冲垮。import time from collections import deque class LeakyBucket: def __init__(self, capacity, rate_per_sec): self.capacity capacity self.rate rate_per_sec self.water 0.0 self.last_time time.monotonic() def allow(self): now time.monotonic() self.water max(0.0, self.water - (now - self.last_time) * self.rate) self.last_time now if self.water self.capacity: self.water 1 return True return False漏桶的代价在于短时突发流量会被排队请求延迟增高。比如桶容量100、速率每秒10个一次冲进来1000个请求最后一个请求要等接近100秒才能被处理。对绝大多数需要低延迟的在线接口来说这是不可接受的。所以漏桶更有价值的地方是在批处理、数据同步这类下游消费速率固定的场景本质上就是“削峰填谷”。2.4 令牌桶允许突发当前接口限流的主流选择令牌桶是漏桶的“反向操作”以固定速率往桶里放令牌桶有最大容量请求想要通过必须先拿到一个令牌桶里没有令牌就拒绝。它允许突发的原因很关键——桶里最多可以积攒capacity个令牌所以短时突发最多能一次性取走capacity个令牌不会把请求强制排队拉长延迟。import time class TokenBucket: def __init__(self, rate, capacity): self.rate rate self.capacity capacity self.tokens capacity self.last_refill time.monotonic() def allow(self): now time.monotonic() 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网关、接口限流这类场景绝大多数都选令牌桶因为它既能平滑限制平均速率又能容忍业务上的瞬时爆发。这里有一个工程细节需要特别注意令牌桶默认初始化是满桶意思服务刚启动时允许的瞬时流量会很偏高。如果此时下游缓存是空的请求直接穿透到数据库冷启动瞬间就能把系统压垮。所以成熟的限流组件一般都会提供预热机制让速率从某个较小值在几十秒内慢慢爬到目标值这个细节后面展开。2.5 一张表看清四个算法的取舍算法核心机制容忍突发解决临界问题适用场景固定窗口整段时间计数窗口结束清零否否简单接口保护、按自然时间统计滑动窗口子窗口细分滑动统计最近窗口否是精准限流、秒杀场景漏桶队列排队恒定速率消费否是批处理、消息消费、削峰填谷令牌桶固定速率补令牌桶容量限突发是是网关限流、在线接口保护这里再补一句经验实际工程里很少只用一种算法。我看到过的稳定方案大多是入口网关用滑动窗口做总量控制服务进程内用令牌桶做平滑保护消费队列天然用漏桶模式。不同位置有不同流量特征强行统一成一种算法反而不合适。3. 单机限流器工程实现原子性、性能和几个细节纸上谈兵的算法代码大概率在线下能跑通一到线上就被并发问题按在地上摩擦。单机限流器的工程量往往比算法本身多得多。3.1 计数器必须原子这条红线不能碰先看第二章那段演示代码一个count 1在多线程环境下有两个线程同时读到0同时改成1结果实际放行了两个请求限流失效。放在限流器上这种竞态条件本身就是故障源。生产环境的正确做法Java体系用LongAdder或者AtomicLong走CASGo要么用atomic包要么干脆用channel自己实现一个互斥的令牌发放逻辑Python多线程场景老老实实加threading.Lock或改用asyncio单线程。一句话总结限流器这个模块的每一次判断和计数必须是在没有竞态的条件下完成的哪怕它会影响一点性能也好过超发。3.2 滑动窗口的工程形态环形队列滑动窗口如果每次请求都做一次全量求和QPS高了就很浪费。工程实现基本都用环形队列一个定长数组存N个子窗口的计数再用一个指针标记当前时间所在槽位请求进来时先算该落在哪个槽位把旧槽位的计数从总量里扣掉然后当前槽位加一再和阈值比较。单次请求的时间复杂度是O(1)和窗口大小无关。我实际做某个网关项目时把60秒窗口切成60个槽位每槽位一个AtomicLong并发更新不同槽位时互不干扰合成总量时做一次分段求和。压测数据很稳定单机可以扛非常高的QPS而且实现逻辑大概只有几十行。唯一的坑是槽位不能切太细比如切成毫秒级会带来巨大的内存和更新开销秒级或者百毫秒级已经足够精准。3.3 高并发下的优化思路分段与分离限流器的check请求本身也是流量它不能成为系统的性能瓶颈。读多写少的场景首选的优化路径是让不同请求尽量落到不同的计数器碎片上。一个常用的做法是分段计数器把QPS限流拆成多个分片每个分片有自己的计数器请求按某个维度哈希到分片上只更新自己的分片总阈值按分片数等比切分。这样带来的代价是全局精度的轻微下降但吞吐量提升非常可观。另一种思路是读写分离。配置信息的读取用本地缓存的快照只有更新的线程去写全局配置计数操作全部走原子变量。这些优化在单机几千QPS时感受不明显但上了几万QPS之后限流器本身是否高效会直接体现到机器CPU水位上。3.4 预热为什么必须做回到令牌桶默认满桶带来的冷启动问题。新服务上线时缓存是空的热数据还没来得及加载如果限流器一上来就放开最大速率请求穿透到数据库未命中的缓存反而会变成压垮系统的元凶。预热机制做的事情就是让允许速率在一个可配置的时间段内从较小值逐步爬升到目标值。大部分开源限流组件都有这个参数只是名字各不相同有的直接叫warmupPeriod。有一次我们上线一个新接口限流阈值按压测结论配得很高结果一上线数据库连接池被打满。查下来发现限流器确实没有限制但压测时缓存里是热的线上缓存是冷的流量一放全部穿透。之后凡是新接口上线我都会把预热参数列为必需品。4. 分布式限流Redis方案、降级与配额下发的取舍单机限流只解决了一台机器自己的问题。流量分散在多台机器上每台各自限1000 QPS总量可能已经5000了而系统最薄弱的环节可能只扛得住3000这就必须做分布式限流。4.1 Redis计数方案朴素但有用的起步点多机共享一个Redis key每个请求执行一次INCR命令计数超阈值就拒绝。这个方案实现太简单了以至于很多团队直接从这起步。它的三个硬伤需要心里有数。第一固定窗口的边界问题原封不动继承。第二窗口重置逻辑要处理干净通常靠第一次INCR之后追加EXPIRE实现但一不留神就会出现窗口过期后计数器没清干净、阈值被错误放大的情况。第三每次请求都多一次网络往返Redis自身也会变成高压力点。即便如此在请求量不大的内部系统中这个朴素方案是完全能用的不要为了追求技术先进去做过度设计。4.2 Lua脚本保证原子性比想象中更重要“先查计数、再判断有没有超、然后决定放行”这种流程在并发下根本不安全。两个线程同时查到19当前阈值是20都会认为自己没超限然后放行。解决方式是把这段判断逻辑放到Redis服务端用Lua脚本整体执行脚本运行期间不会有其他命令插进来。下面是一个滑动窗口的Lua脚本框架local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local now tonumber(ARGV[3]) local uid ARGV[4] -- 清理窗口外的过期记录 redis.call(ZREMRANGEBYSCORE, key, 0, now - window) -- 当前窗口内的请求数 local cnt redis.call(ZCARD, key) if cnt limit then return 0 end -- 把当前请求写入窗口用uid拼接now保证唯一性 local member uid .. : .. now redis.call(ZADD, key, now, member) redis.call(PEXPIRE, key, window) return 1这个方案用有序集合存储窗口内的所有请求每次请求都先清理过期成员再统计数量再决定是否写入。用唯一拼接的原因很实际同一毫秒内多个请求的score是相同的直接用now作为member会被覆盖导致漏计。就算有Lua也要注意性能。ZADD和ZREMRANGEBYSCORE在超大流量下会让Redis负载很高一个能跑的优化是改用Hash固定槽位把窗口切成若干子窗口每个槽位一个HINCRBY只统计所有槽位的和这样Redis的操作次数从每次三个降到一个。4.3 配额下发让Redis从每个请求退到每秒级别高并发场景下每请求打一次Redis不现实常见做法是中心配额加本地配额池。协调者或者每个实例周期性地从全局配额池取一批配额下发到本地实例在自己的本地令牌桶里消费这些配额用完了再去中心申请。比如全局上限20000 QPS10台机器先各自拿2000某台机器配额快用完时去协调者那补充。这套方案要处理三个问题。配额下发的粒度不能太大太大导致某实例浪费、其他实例不够用也不能太小太小中心又变成瓶颈折算下来每100毫秒补充一批比较稳妥。机器数变化时配额要重新计算扩容后不更新每台配额就会出大事故。实例拿到配额后如果一段时间内没用完还要有归还机制否则全局总额会慢慢失真。但我要说一句实在话分布式限流不是高并发系统的默认选择。很多业务流量都经过统一网关完全可以在网关层做全局限流再在应用进程内做单机令牌桶两级配合比直接搞分布式限流更简单也更稳。只有流量确实不经过统一入口、机器数量又在不断变化的时候才需要认真考虑中心配额方案。4.4 Redis挂了怎么办fail-open和fail-closed的取舍Redis是不可用的限流器就面临一个选择放行还是拒绝。放行叫fail-open系统可用性优先代价是可能被突发流量打挂拒绝叫fail-closed保护了下游不被冲垮但业务直接不可用。纯走一边都不够聪明。核心链路建议fail-closed但要搭配一个本地的兜底限流防止Redis不可用时连“拒绝”这个动作都要依赖Redis。普通链路建议fail-open同时本地单机限流仍然生效把最坏情况控制在“少限了流”而不是“系统彻底不可用”。这层的设计思路要经过压测验证不要等到故障了才临场改。5. 限流阈值怎么定容量评估、压测与动态调优限流器写得再漂亮阈值配错了也是白搭。配大了等于没限配小了业务受损。阈值怎么来是有固定流程的。5.1 从链路容量出发不是拍脑袋限流阈值的意思是“系统承受多少流量不出问题”不是“系统目标承受多少流量”。评估的起点是链路中最脆弱的环节。数据库连接池80个连接单个SQL执行平均5毫秒那么最多能支撑的单类读请求约16000 QPS写请求因为锁和索引更新还要再打折扣下游HTTP服务的P99是100毫秒线程池200个线程单实例约2000 RPS外部短信服务商每分钟最多1000条这就是硬阈值。取所有环节的最小值再留四到六成余量作为初始限流阈值。比如最弱环节每秒1000限流就设600先确保稳定再慢慢上调。拍脑袋写个10000上限等于没有保护。5.2 用压测反向校准找到RT拐点容量评估是理论值真实值要靠压测找。正确方法是从低并发开始逐步加压每增加一档就记录吞吐量和响应时间。当RT曲线出现明显拐点比如从20毫秒跳到200毫秒那个位置的QPS就是系统饱和点限流阈值应该再打折到这个数值的七到八成。压测环境要接近生产。数据不能全是热点缓存要有冷数据和热点数据的混合网络延迟不能和生产差太多CPU核数、磁盘性能这些基础资源差异也会让结果失真。见过太多压测报告虚高就是因为压测数据全是默认请求命中缓存率接近百分之百测出来的容量根本不是真实容量。5.3 动态限流让阈值跟着系统健康度走静态阈值应对不了流量形态的变化所以有了动态限流。它的核心不是数请求而是盯被保护系统的健康指标包括CPU使用率、平均RT、P99延迟、线程池排队深度、GC停顿时间。健康信号恶化自动下调限流阈值恢复之后再缓慢把阈值抬回去。本质是一个反馈控制器。我在一个消息推送服务上做成过简化版本每次请求时检测P99二如果比基线高百分之二十就把当前限流阈值乘0.9下一个周期再看是否恢复恢复后按0.1的步长慢慢回升。效果不错但引入动态调整也要付出代价阈值变得不可预期对账类、定时任务类这些需要严格确定吞吐的业务还是保持静态阈值更稳妥。5.4 阈值配置的精细度决定保护效果别把所有接口都共用一个限流阈值。实际项目里至少按接口分组对核心交易链路给高配额对批量任务压低配额对明显的爬虫流量直接拒绝。必要的时候还要按调用方分组给内部核心系统的配额和对公网开放接口的配额彻底分开。这些策略调整比单纯把全局阈值调大调小有效得多。6. 限流器与熔断、降级的协同保护机制的完整链路限流器很少单独工作系统保护需要限流、熔断、降级三者配合它们各自管一段生命线。6.1 三者各管一段缺一不可限流是流量入口的闸门做的是事前拦截在流量还没进来的时候控制总量。熔断是调用链路上的保险丝事中生效当下游接口错误率或延迟超过阈值快速短路不让故障扩散。降级是业务层面的最后兜底真打不过了就返回一个降级响应比如缓存数据或默认结果保住主流程的体验。三者保护的时间尺度完全不同不能互相替代。6.2 一次完整调用链的保护实例拿订单查询服务举例网关入口同时做全局限流、每用户限流、每IP限流峰值流量先被拦住一大半订单服务内部再做线程池并发数限流和RT健康度限流当订单服务发现存储超时率整体超标就熔断掉占资源的慢请求快速失败主流程失败时降级到缓存中的订单状态摘要返回给用户。这一套下来入口峰值不会直接打爆服务服务内部的异常不会外溢到整条链路最坏情况下用户看到的是“稍后再试”而不是整个页面不可用。6.3 拒绝的姿势也有讲究丢弃、排队还是降级很多团队把限流拒绝做得很粗暴直接返回错误码。更精细的做法是对短时突发先排队比如秒杀第一秒的流量可以进队列等几毫秒超过队列深度再去丢弃对非核心请求直接给一个降级空数据让用户还能看到页面而不是得到一个失败提示。这个策略常常配合令牌桶实现令牌不足时允许进入一个小队列队列满才拒绝。这个细节在大促场景下的用户体感差异很大值得多花两行代码。7. 做限流器这些年踩过的坑五个典型事故复盘这些坑都不是从文档里学来的全是我线上流汗换来的教训。写出来供你对照排查。7.1 固定窗口被临界流量打穿某次活动大促我用一个固定窗口限流器保护秒杀接口阈值配了5000 QPS。压测完全通过线上却被一条流量曲线打穿。复盘才发现59分59秒攒了一波5000窗口一翻转又进来5000内核看到的是短时间内两倍的流量。修复方式很简单改成滑动窗口把窗口粒度从1秒改成100毫秒同时保留总量控制。这个改动本身容易但排查过程很磨人因为监控图上的流量像波浪一样一高一低不像固定形状。7.2 重试风暴把限流器自己打死限流器拒绝请求返回429之后调用方的SDK会自动重试三次。结果429响应又变成大量重试请求把限流器所在的网关节点CPU打满进而拒绝更多请求雪上加霜。解决方法是限流响应的重试必须加指数退避和随机抖动同时把重试请求单独放到一个较低优先级的限流分组里防止重试和正常流量抢配额。这个事情给我们的教训是限流器不仅要管第一波请求还要管第一波请求引发的重试流量。7.3 机器扩容导致分布式限流失灵原来10台机器本地配额每台1000总量10000。扩容到20台时配置中心没有及时更新每台还是1000总量悄悄变成20000限流阈值无形中翻倍系统在扩容后反而先被流量压垮。教训是涉及本地配额的全局限流方案必须把机器数量做成配置联动项或者在中心控制下发逻辑里自动感知节点数变化、重新计算每台配额。手动改配置的方式在线上扩容时几乎一定会漏。7.4 限流返回码不统一客户端把429当正常曾经在两个网关节点上一个返回自定义的“444”另一个返回标准的“429”客户端只识别444。结果另一个节点的限流结果被当成正常业务数据继续处理流量进到了后端限流形同虚设。这个问题的解法很简单全平台固定限流语义统一返回标准429在服务框架层直接约定好并且错误码必须在监控系统里能一眼圈出被限流量方便查看被限的次数和来源。7.5 没有限流日志和告警故障排查全靠猜限流器一旦开始拒绝服务本身的CPU、内存、RT都是正常的因为拒绝请求不算异常。但如果不关注被拒绝的数量可能整个活动期间一直在丢流量而不自知。必须有被限流次数指标、被限流请求的来源Top N、每个分组阈值变化的审计日志。调阈值的时候还要用可观测性数据对比“限流阈值”和“真实通过量”的偏差才能发现是否存在过度限流。限流器不是配完就丢的一次性工程它需要像业务系统一样被观测和审计。做了这么多年限流器最大的体会是别把它当成一个写完就跑的东西。每次大促、每次架构调整、每次下游容量变化限流器的参数都要重新审视一遍。算法本身可以写得很优雅但对线上系统来说知道什么时候该限、限多少、限了之后会发生什么才是真正值钱的。希望这份梳理能帮你在下次讨论限流方案的时候少踩两个我踩过的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑