高并发下的流量控制:四种限流算法解析
高并发下的流量控制四种限流算法解析目录为什么需要限流固定窗口计数器滑动窗口漏桶令牌桶四种算法对比怎么选小结很多接口在正常运行时压力并不大但当请求量突然上涨时系统的瓶颈很快就会暴露出来。这种情况可能发生在活动期间用户集中访问也可能是某个接口被异常流量持续调用。如果入口没有任何流量控制请求会持续进入业务系统最终可能导致线程池耗尽、数据库连接池打满甚至影响正常请求的处理。为什么需要限流一种常见的解决方式是在系统入口增加限流机制。限流并不是单纯拒绝请求而是在系统入口增加流量控制根据当前处理能力决定请求是否继续进入。例如一个服务经过压测后发现稳定处理能力约为 500 QPS那么入口层就需要限制请求进入速度避免超过系统承载范围。以下介绍四种限流算法固定窗口计数器固定窗口是最基础的限流算法之一。它将时间划分为多个固定长度的窗口并分别统计每个窗口内的请求数量。比如限制每分钟最多 100 个请求系统会维护一个一分钟窗口00:00 - 01:00 count 80 01:00 - 02:00 count 35请求到达后先确定当前所属窗口然后增加该窗口的计数。如果计数超过限制则拒绝请求。publicclassFixedWindowLimiter{privatefinalintlimit;privatefinallongwindowSize;privatelongwindowStart;privateintcount;publicsynchronizedbooleantryAcquire(){longnowSystem.currentTimeMillis();if(now-windowStartwindowSize){windowStartnow;count0;}if(countlimit){returnfalse;}count;returntrue;}}实现非常简单通常只需要维护一个计数器和窗口开始时间。但固定窗口存在一个明显问题窗口边界可能导致流量突增。假设限制一分钟最多 100 个请求。如果请求集中在窗口末尾和下一个窗口开始的位置固定窗口可能允许短时间内通过两倍于限制的请求。对于普通查询接口或者后台管理接口这种误差通常可以接受。但对于支付、交易等对流量精度要求较高的场景窗口突变可能带来风险。滑动窗口固定窗口的问题在于它只看当前窗口内的计数不关心窗口边界附近的请求。滑动窗口的思路是任何时刻都统计最近一段时间内的请求数量窗口随着时间滑动。日志版最准确的做法是保存每一次请求的时间戳。当前时间 12:01:30窗口大小一分钟那就统计 12:00:30 到 12:01:30 之间有多少请求。publicclassSlidingWindowLogLimiter{privatefinalintlimit;privatefinallongwindowSizeMs;privatefinalLinkedListLongtimestampsnewLinkedList();publicsynchronizedbooleantryAcquire(){longnowSystem.currentTimeMillis();longwindowStartnow-windowSizeMs;while(!timestamps.isEmpty()timestamps.getFirst()windowStart){timestamps.removeFirst();}if(timestamps.size()limit){timestamps.addLast(now);returntrue;}returnfalse;}}精度没问题但内存开销是个现实问题。如果接口 QPS 是 10000一分钟窗口意味着链表里常驻 60 万个时间戳。单机限流还好分布式限流拿 Redis 存的话这个内存成本就比较高了。计数器版工程中更常用的是折中方案把窗口切成几个小段只保存每个小段的计数用加权计算近似滑动窗口内的总量。计算公式请求数 ≈ 上一个窗口计数 × (1 - 当前窗口已过时间占比) 当前窗口计数比如当前时间是 01:00:30上一个窗口计数 80当前窗口计数 30已过时间占比 30秒 / 60秒 0.5 请求数 ≈ 80 × 0.5 30 7070 没超过阈值 100放行。这种方式只需要两个计数器和一个时间戳内存固定精度比固定窗口高大部分场景够用。实际项目中Sentinel 的限流统计用的就是滑动窗口计数器的思路把一个窗口切成多个样本sample每个样本记录计数和起始时间滑动时丢弃过期样本、加入新样本。漏桶前面两种方案都是在数请求数量。漏桶换个角度控制请求的处理速率。水从桶上方灌进去桶底有个小孔水以固定速率往外漏。灌水速度不管多快漏水速度始终恒定。桶满了水溢出。漏桶的特点是输出速率恒定不管上游怎么突发下游看到的永远是匀速流量。这种能力叫流量整形。实现上和令牌桶很像也是记录上次处理时间和当前水量但逻辑是反过来的令牌桶是往里加令牌漏桶是从里往外漏水。publicclassLeakyBucketLimiter{privatefinalintcapacity;privatefinaldoubleleakRate;// 每秒漏出几个privatedoublewater;privatelonglastLeakTime;publicsynchronizedbooleantryAcquire(){longnowSystem.currentTimeMillis();doubleleaked(now-lastLeakTime)/1000.0*leakRate;waterMath.max(0,water-leaked);lastLeakTimenow;if(watercapacity){water1;returntrue;}returnfalse;}}漏桶适合的场景是下游处理能力固定。比如数据库连接池最多处理 100 个并发不管上游来了多少请求都得排成匀速队列进去否则连接池直接被打满。但漏桶也有个明显的缺点它不区分突发的合理请求和恶意刷流量。系统明明有余力处理短时突发漏桶也会把输出削平让请求在外面排队。对用户来说就是明明系统没压力我的请求却被限流了。令牌桶令牌桶解决的正是漏桶过于保守的问题。思路是反过来的桶里装的不是请求而是令牌。系统以固定速率往桶里放令牌桶有容量上限。请求到达时拿一个令牌就放行拿不到就拒绝。和漏桶的关键区别在这里如果桶里积累了令牌突发请求可以一次性消耗掉所有存量。举个例子。令牌桶容量 50每秒生成 10 个令牌。平时流量不大桶里慢慢积了 50 个令牌。突然来了一波活动瞬间到了 50 个请求。这 50 个请求各自拿到一个令牌全部放行。漏桶做不到这一点——它会把 50 个请求排成匀速队列慢慢处理。publicclassTokenBucketLimiter{privatefinalintcapacity;privatefinaldoublerefillRate;privatedoubletokens;privatelonglastRefillTime;publicTokenBucketLimiter(intcapacity,doublerefillRate){this.capacitycapacity;this.refillRaterefillRate;this.tokenscapacity;this.lastRefillTimeSystem.currentTimeMillis();}publicsynchronizedbooleantryAcquire(){longnowSystem.currentTimeMillis();doublenewTokens(now-lastRefillTime)/1000.0*refillRate;tokensMath.min(capacity,tokensnewTokens);lastRefillTimenow;if(tokens1){tokens-1;returntrue;}returnfalse;}}Java 生态里最常用的令牌桶实现是 Guava 的RateLimiter// 每秒 100 个请求RateLimiterlimiterRateLimiter.create(100.0);if(limiter.tryAcquire()){// 放行}else{// 拒绝}Guava 的RateLimiter还有一个容易被忽略的特性预消费warmup。RateLimiter.create(100.0)创建的是平滑限流器令牌生成速率恒定。但RateLimiter.create(100.0, Duration.ofSeconds(10))创建的是预热限流器启动阶段令牌生成速率会从低到高逐渐爬升10 秒后达到满速。预热的意义在于服务刚启动时各种缓存都是冷的JIT 还没优化处理能力比稳态差很多。如果一开始就放满流量很容易被直接打挂。预热限流器让流量逐渐增加给服务一个缓冲期。这个细节在很多限流文章里不会提到但线上踩过坑的人都知道它的重要性。四种算法对比维度固定窗口滑动窗口漏桶令牌桶突发处理边界处可能 2 倍突变精确控制严格匀速允许突发受桶容量限制输出速率不稳定不稳定恒定平均恒定允许瞬时波动内存开销极低日志版高计数器版低低低实现复杂度最简单中等中等中等典型应用简单接口防护Sentinel、API 网关流量整形、带宽控制Guava RateLimiter、API 限流四种算法怎么选实际项目中限流方案的选择通常不是从算法本身出发而是先分析系统需要解决什么问题。如果只是限制接口调用频率例如后台管理接口防刷、Open API 调用次数控制固定窗口通常已经足够。它实现简单性能开销低窗口边界带来的误差在这类场景下影响有限。如果需要精确控制用户、IP 等维度的访问频率例如限制某个用户一分钟最多调用 60 次滑动窗口会更加合适。相比固定窗口它能够减少窗口边界导致的流量突增问题。像 Sentinel 这类限流组件也提供了基于滑动窗口的统计方式。如果重点是保护下游资源例如数据库连接池、第三方接口调用配额等更关注的是控制请求进入速度。漏桶可以将不稳定的流量转换为稳定的输出避免下游服务被瞬间压垮。如果希望限制长期平均速率同时允许业务存在一定程度的突发流量令牌桶通常是更常见的选择。它既能控制整体访问速度又能利用桶中积累的令牌承接短时间流量波动。实际生产环境中限流通常也不是只使用一种算法。比如一个 API 网关可能会同时设置多层限制全局使用令牌桶控制整体 QPS用户维度使用滑动窗口限制访问频率针对特殊接口再增加固定窗口作为保护措施。小结每种限流算法都在精确度、突发处理能力和实现复杂度之间做了不同取舍。实际选择时需要结合系统面对的问题是需要应对突发流量还是保护下游资源或者限制单个用户的访问频率。明确业务目标后算法选择自然会更加清晰。