限流算法全景:从计数器到令牌桶的工程实现 — editorial cover photo

限流算法全景:计数器、漏桶和令牌桶,到底用哪个

计数器、滑动窗口、漏桶、令牌桶——四种算法各对应一类场景,是工具箱而不是单选题。

2025年4月20日·返回文章列表
文章大纲

某次大促出过一次至今还被拿来讲的故障。网关层配了限流,单机每台放 1000 QPS,看起来很稳妥。结果上线 12 台机器,每台都按自己的计数器放 1000,后端 DB 实际承受的是 12000 QPS,直接被打挂。限流配了等于没配,根因不是阈值错,是限流维度和流量入口对不上。单机限流只算自己的账,没有全局视角,机器越多漏洞越大。

限流这件事,难的不在算法,在选型和部署。算法本身四种就够用,计数器、滑动窗口、漏桶、令牌桶。真正的工程问题是,这个限流该放在哪一层、用单机还是集群、保护的是上游体验还是下游系统。配反了,要么伤用户体验要么伤后端。这篇把这四种算法和它们的适用边界讲透。

图:四种限流算法——漏桶强制匀速保护下游,令牌桶容忍合法突发

限流算法回答的两个问题

所有限流算法都在回答同一个问题,单位时间内允许多少请求通过。差异在两个维度上,怎么定义单位时间,怎么处理突发流量。

计数器把时间切成固定窗口,窗口内计数,到点清零,简单粗暴。滑动窗口把一个大窗口切成多个小格,统计时滑动着看,比计数器平滑。漏桶强制匀速输出,削峰填谷。令牌桶匀速发令牌,允许预支存量令牌应付突发。这四种不是谁取代谁,是各自针对不同约束的工具。

固定窗口最简单也最危险

固定窗口的实现最简单,每秒一个计数器,超过阈值就拒绝,到点清零重新数。但它的致命缺陷叫临界突发。假设限流 1000 QPS,第 0.9 秒来了 1000 个请求,第 1.0 秒窗口清零,第 1.1 秒又来 1000 个请求。两个窗口各自都没超阈值,但 0.2 秒内实际打了 2000 QPS,后端照样崩。问题出在窗口边界,计数器只看窗口内的总和,看不到窗口交界处的真实瞬时压力。

固定窗口适合那些对平滑度不敏感的场景,或者作为最粗粒度的兜底。但凡是怕突发的链路,都不能只靠它。

滑动窗口用空间换精度

滑动窗口是对固定窗口临界问题的直接改进。把 1 秒切成 10 个 100 毫秒的小格,统计当前时刻往前推 1 秒覆盖的所有格子之和。这样无论请求落在窗口的哪个位置,统计的永远是一个滑动覆盖的真实区间,没有清零突变。格子切得越细,统计越平滑,内存开销也越大。Sentinel 默认就是这套思路。

// 滑动窗口的简化实现,每个格子一个独立计数
class SlidingWindow {
int slots = 10;
int[] counts = new int[slots];
long windowMs = 1000;
boolean tryAcquire(long now) {
int idx = (int)((now / (windowMs / slots)) % slots);
int sum = Arrays.stream(counts).sum();
if (sum >= limit) return false;
counts[idx]++;
return true;
}
}

滑动窗口解决了临界突发,但它解决的是统计精度问题,骨子里还是窗口内总量的思路,对瞬时突发的容忍度依然有限。需要更精细的流量整形,要靠下面两个桶。

漏桶和令牌桶方向相反

这两个算法经常被混为一谈,其实方向完全相反,适用场景也相反。

漏桶模型里,请求像水滴进桶,桶以恒定速率漏水,水满了就溢出拒绝。它的输出永远是匀速的,强制把入口的任何流量形态都整形为恒定速率流出。这意味着即使入口突然来了一大批请求,出口还是匀速,多余的全部拒绝。漏桶强制平滑,适合保护下游脆弱的系统,比如 DB,因为 DB 最怕瞬时大批量请求打过来。

令牌桶模型里,系统以恒定速率往桶里放令牌,请求拿到令牌才能通过,桶满了令牌就丢弃。关键差别在于,如果桶里积累了存量令牌,一个请求可以一次拿走多个,这就允许了瞬间的突发。入口突然来一批请求,只要桶里有令牌存量就能放行,不被强制匀速。令牌桶容忍合法突发,适合面向用户的入口网关,因为用户流量天然有突发,刷卡瞬间、整点抢购,这些突发是合理的,不该被强制平滑掉。

选型的关键判断

保护下游脆弱服务,比如 DB、慢接口,用漏桶强制匀速,宁可牺牲入口的响应速度也要保下游。面向用户的入口,用令牌桶容忍合法突发,宁可后端偶尔多扛一点也要保体验。配反了,要么后端被打穿,要么用户体验被无谓地牺牲。这个判断比选哪个算法本身重要得多。

PlantUML
正在加载图表…
PlantUML diagram

图:漏桶与令牌桶方向相反——漏桶强制匀速保护下游脆弱系统,令牌桶容忍合法突发保障用户入口体验

单机和集群是两套问题

前面这些算法都是单机视角,单台机器怎么限自己的流。但生产是集群,每台机器独立计数,总量就乘以实例数。12 台机器每台 1000,全局就是 12000,这正是开头那个故障的根因。要做到全局 1000 QPS,单机算法不够用,得换思路。

第一种是集中计数器,用 Redis 做全局计数器,每次限流判断都走 Redis 的 INCR 加 Lua 原子操作。简单直接,但每次判断都要走一次网络,Redis 成了限流的单点,吞吐受 Redis 制约。我在 Matrix 用 Lua 脚本做分布式限流走的就是这条路,因为营销活动的入口流量集中,Redis 扛得住。

第二种是网关统一限流,所有流量先过一层网关,在网关做令牌桶,过了网关的才算放行。最干净,限流逻辑集中,但网关自身要扛住全部流量,成了新的关键路径。

第三种是配额分配,把全局配额按权重分给各实例,每台只限自己的本地配额,实例扩缩容时动态调整。Sentinel 的集群流控走的是这个思路。它避免了集中计数器的网络开销,代价是配额分配的动态性较难做到精确,实例数变化时会有短暂的配不准。

PlantUML
正在加载图表…
PlantUML diagram

图:集群限流三种方案——集中计数器(Redis)简单但有单点,网关统一最干净但网关成关键路径,配额分配无网络开销但扩缩容时短暂配不准

被限的请求去哪是关键

很多人配完限流规则就觉得完事了,其实限流只是手段,被限流的请求去哪才是关键。

要么排队等待,适合可延迟处理的请求,比如 Sentinel 的排队等待模式,超时才拒绝。要么降级返回兜底数据,被限的请求走降级逻辑返回缓存或默认值。要么走异步重试,把请求转成异步任务稍后处理。光拒绝不降级,用户看到的全是报错,限流做再准也救不了体验。限流和降级是配套的,缺一不可。

PlantUML
正在加载图表…
PlantUML diagram

图:被限请求的三种处理——排队等待适合可延迟请求,降级返回兜底数据保体验,异步重试转后台任务;光拒绝不降级则用户体验崩塌

几个常见的坑

第一个坑是用 Guava RateLimiter 做网关限流。它是单机令牌桶,多实例下配额翻倍,生产慎用,除非网关本身就是单点。

第二个坑是限流维度单一,只限 QPS 不限并发。一个慢请求占住线程不放,QPS 没超阈值,但线程池已经被慢请求打满了。并发数限流和 QPS 限流要组合用。

第三个坑是阈值拍脑袋,不压测直接配 1000 QPS,实际 DB 承受极限可能只有 300。我接手过的几个系统,慢 SQL 治理后核心链路提升了 30% 的吞吐,对应的限流阈值才有上调的依据。限流阈值必须基于压测出的真实承受线来设,留出 20% 到 30% 余量,不能凭感觉。

第四个坑是限流后无监控。拒绝率多少、哪个接口触发最多、阈值是否合理,没有看板等于盲飞。限流配置上线后,监控面板必须跟上,根据实际拒绝率持续调整阈值。

限流阈值要先压测再设

先压测定出后端真实承受线,限流阈值设在承受线的 70% 到 80% 留余量。入口用令牌桶容突发,出口用漏桶或并发数限流保护 DB。被限的请求必须有明确的降级路径,不能裸拒。这套流程比纠结用哪个算法重要得多。

Continue Reading

关联文档推荐

查看全部
限流降级治理:统一规则中心与执行链路 — editorial cover photo熔断降级:Hystrix 与 Sentinel 的容错哲学 — editorial cover photo库存系统:预占、扣减与一致性保障 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。