高并发下的流量控制:四种限流算法解析
目录
- 为什么需要限流
- 固定窗口计数器
- 滑动窗口
- 漏桶
- 令牌桶
- 四种算法对比
- 怎么选
- 小结
很多接口在正常运行时压力并不大,但当请求量突然上涨时,系统的瓶颈很快就会暴露出来。这种情况可能发生在活动期间用户集中访问,也可能是某个接口被异常流量持续调用。如果入口没有任何流量控制,请求会持续进入业务系统,最终可能导致线程池耗尽、数据库连接池打满,甚至影响正常请求的处理。
为什么需要限流
一种常见的解决方式是在系统入口增加限流机制。
限流并不是单纯拒绝请求,而是在系统入口增加流量控制,根据当前处理能力决定请求是否继续进入。
例如,一个服务经过压测后发现稳定处理能力约为 500 QPS,那么入口层就需要限制请求进入速度,避免超过系统承载范围。以下介绍四种限流算法:
固定窗口计数器
固定窗口是最基础的限流算法之一。它将时间划分为多个固定长度的窗口,并分别统计每个窗口内的请求数量。
比如限制每分钟最多 100 个请求,系统会维护一个一分钟窗口:
00:00 - 01:00 count = 80 01:00 - 02:00 count = 35请求到达后,先确定当前所属窗口,然后增加该窗口的计数。如果计数超过限制,则拒绝请求。
publicclassFixedWindowLimiter{privatefinalintlimit;privatefinallongwindowSize;privatelongwindowStart;privateintcount;publicsynchronizedbooleantryAcquire(){longnow=System.currentTimeMillis();if(now-windowStart>=windowSize){windowStart=now;count=0;}if(count>=limit){returnfalse;}count++;returntrue;}}实现非常简单,通常只需要维护一个计数器和窗口开始时间。但固定窗口存在一个明显问题:窗口边界可能导致流量突增。
假设限制一分钟最多 100 个请求。如果请求集中在窗口末尾和下一个窗口开始的位置,固定窗口可能允许短时间内通过两倍于限制的请求。
对于普通查询接口或者后台管理接口,这种误差通常可以接受。但对于支付、交易等对流量精度要求较高的场景,窗口突变可能带来风险。
滑动窗口
固定窗口的问题在于它只看当前窗口内的计数,不关心窗口边界附近的请求。滑动窗口的思路是:任何时刻都统计最近一段时间内的请求数量,窗口随着时间"滑动"。
日志版
最准确的做法是保存每一次请求的时间戳。当前时间 12:01:30,窗口大小一分钟,那就统计 12:00:30 到 12:01:30 之间有多少请求。
publicclassSlidingWindowLogLimiter{privatefinalintlimit;privatefinallongwindowSizeMs;privatefinalLinkedList<Long>timestamps=newLinkedList<>();publicsynchronizedbooleantryAcquire(){longnow=System.currentTimeMillis();longwindowStart=now-windowSizeMs;while(!timestamps.isEmpty()&×tamps.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(){longnow=System.currentTimeMillis();doubleleaked=(now-lastLeakTime)/1000.0*leakRate;water=Math.max(0,water-leaked);lastLeakTime=now;if(water<capacity){water+=1;returntrue;}returnfalse;}}漏桶适合的场景是下游处理能力固定。比如数据库连接池最多处理 100 个并发,不管上游来了多少请求,都得排成匀速队列进去,否则连接池直接被打满。
但漏桶也有个明显的缺点:它不区分"突发的合理请求"和"恶意刷流量"。系统明明有余力处理短时突发,漏桶也会把输出削平,让请求在外面排队。对用户来说就是"明明系统没压力,我的请求却被限流了"。
令牌桶
令牌桶解决的正是漏桶"过于保守"的问题。
思路是反过来的:桶里装的不是请求,而是令牌。系统以固定速率往桶里放令牌,桶有容量上限。请求到达时拿一个令牌就放行,拿不到就拒绝。
和漏桶的关键区别在这里:如果桶里积累了令牌,突发请求可以一次性消耗掉所有存量。
举个例子。令牌桶容量 50,每秒生成 10 个令牌。平时流量不大,桶里慢慢积了 50 个令牌。突然来了一波活动,瞬间到了 50 个请求。这 50 个请求各自拿到一个令牌,全部放行。漏桶做不到这一点——它会把 50 个请求排成匀速队列,慢慢处理。
publicclassTokenBucketLimiter{privatefinalintcapacity;privatefinaldoublerefillRate;privatedoubletokens;privatelonglastRefillTime;publicTokenBucketLimiter(intcapacity,doublerefillRate){this.capacity=capacity;this.refillRate=refillRate;this.tokens=capacity;this.lastRefillTime=System.currentTimeMillis();}publicsynchronizedbooleantryAcquire(){longnow=System.currentTimeMillis();doublenewTokens=(now-lastRefillTime)/1000.0*refillRate;tokens=Math.min(capacity,tokens+newTokens);lastRefillTime=now;if(tokens>=1){tokens-=1;returntrue;}returnfalse;}}Java 生态里最常用的令牌桶实现是 Guava 的RateLimiter:
// 每秒 100 个请求RateLimiterlimiter=RateLimiter.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,用户维度使用滑动窗口限制访问频率,针对特殊接口再增加固定窗口作为保护措施。
小结
每种限流算法都在精确度、突发处理能力和实现复杂度之间做了不同取舍。实际选择时,需要结合系统面对的问题:是需要应对突发流量,还是保护下游资源,或者限制单个用户的访问频率。明确业务目标后,算法选择自然会更加清晰。