news 2026/10/14 6:18:22

高并发系统必备:四大常用限流算法原理与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发系统必备:四大常用限流算法原理与工程实践

做服务端开发的这些年,几乎每个高并发系统都会遇到同一个问题:流量一冲上来,数据库先扛不住,接着缓存被击穿,慢查询拖垮整个集群。解决这个问题的手段很多,但最前置、最便宜的一道闸门就是限流。我自己整理过不少方案,后来发现无论框架怎么封装,核心其实都落在几个固定的“常用限流算法”上。这篇文章就把我在实际项目里用过、调过、踩过坑的几种算法捋一遍,从原理到代码,从参数计算到故障排查,尽量讲清楚它们各自解决什么问题,适合什么场景,以及工程落地时最容易翻车的地方。

如果你是刚开始接触高并发、准备给接口做保护,或者是已经被流量冲击折腾得想系统化梳理一下,这篇文章都值得看一看。我不会堆概念,每个算法都会给出可以直接用的思路和伪代码,也会把“为什么这么选”讲透。

1. 限流的本质:到底在防什么

1.1 限流保护的对象不是服务,而是系统的瓶颈资源

很多新人有个误区,觉得限流就是限制“用户请求量”,把请求挡掉就完了。其实限流真正要保护的是整个调用链路上最脆弱的那一环。在一个典型的互联网系统中,瓶颈可能是数据库连接池、下游第三方接口、消息队列的积压能力,也可能是单台服务器的线程池大小。

举个我实际碰过的例子。某系统平时每秒请求量在两三千左右,数据库连接池配置的是50个连接。正常情况下每个查询平均10毫秒,50个连接可以抗住接近5000 QPS。但有一次运营做了一个活动,流量在几秒内冲到了每秒上万次,很多请求被卡在数据库连接获取上,线程池里的线程全部阻塞,最后整个应用假死。这时候如果有限流在最前面挡一下,把进入业务逻辑的请求控制在数据库可承受的范围内,后面的服务根本不会感知到流量波动。

所以设计限流方案之前,先回答一个问题:你的系统里,最容易被拖垮的资源是什么?它的极限吞吐在哪里?限流阈值不是拍脑袋定的,而是根据瓶颈资源算出来的。后面我会专门讲怎么估算这个值。

1.2 限流的两个维度:QPS 和并发数

现在大多数框架里提到的限流,其实包含两个维度:一个是瞬时速率,也就是每秒能处理多少请求;另一个是并发数量,也就是同一时刻允许多少请求在飞。

这两个维度的含义完全不同。QPS 限流限制的是“单位时间内启动的请求数”,适合接口层做流量准入。但 QPS 相同的情况下,如果每个请求耗时差异很大,系统的压力也完全不同。假设 A 接口平均耗时 50ms,B 接口平均耗时 500ms,两个接口都限制 1000 QPS,B 接口同时存在的请求数会是 A 接口的 10 倍。所以对耗时敏感的系统,光有 QPS 限流还不够,还需要限制并发数,让超过并发上限的请求快速失败或排队。

我在做网关层限流时,通常会同时配置两层:第一层按 QPS 设一个总闸门,第二层针对下游特别慢的接口单独设置信号量并发上限。这样既能控制流量总量,又能防止慢接口拖垮线程池。这个思路配合不同算法使用效果会更好。

1.3 拒绝请求也是合理的保护策略

刚接触限流的人常常会纠结一个问题:用户请求被拒了,体验会不会很差?要不要改成排队?

这要看场景。对于实时性要求高的接口(比如查询库存、下单),排队等待不但不能缓解压力,反而会把大量请求堆积在内存里,拖垮服务。这时候快速拒绝、让客户端退避重试反而更安全。对于异步任务(比如数据清洗、消息推送),排队则是合理的,因为消费者可以按自己的节奏处理。

这就是为什么后面讲的几种算法里,有“丢弃型”的计数器、令牌桶,也有“缓冲型”的漏桶。没有绝对的好坏,只有匹配不匹配。

2. 四大经典算法:原理、实现与适用场景

2.1 固定窗口计数器:最简单但临界突刺严重

固定窗口计数器的思路非常直观:把时间划分成固定的窗口(比如1秒),每来一个请求就把计数加1,窗口内计数达到阈值就拒绝请求,窗口结束后计数清零。

对应的代码很简单,以 Java 为例:

public class FixedWindowRateLimiter { private final long windowSizeMs; // 窗口大小,例如 1000ms private final int maxCount; // 窗口内允许的最大请求数 private long windowStart; private int count; public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); if (now - windowStart >= windowSizeMs) { windowStart = now; count = 0; } if (count < maxCount) { count++; return true; } return false; } }

代码简单,性能也好,单机一分钟内就能写完。但它的致命问题在于窗口切换的瞬间可能出现两倍流量。假设阈值是 100 次/秒,前一个窗口最后 100ms 内打满了 100 次,后一个窗口开始的前 100ms 又可以放行 100 次,这期间系统在 200ms 内承受了 200 次请求。我遇到过用这个算法的服务,明明统计 QPS 没超过限制,下游却在每秒切点附近偶发超时,排查了很久才发现是窗口边界叠加导致的。

所以现在我在生产环境几乎不会单独使用这个算法,除非是业务上对突发不敏感、且实现成本要求极低的内部分流场景。它更常作为其他算法的底层计数工具存在。

2.2 滑动窗口计数器:用精度换掉突刺

滑动窗口计数器是固定窗口的改良版。它不再一个窗口结束才整体动一次,而是把窗口切成多个小格子(通常切成 10 到 60 个),每个格子记录对应时间段内的请求数,计算当前时间往前推一个完整窗口内所有格子的总数来决定是否限流。

代码示例(简化版):

public class SlidingWindowRateLimiter { private final int bucketCount; private final long bucketSizeMs; private final int maxCount; private final int[] buckets; private long currentBucketStart; private int currentBucketIndex; private int totalCount; public SlidingWindowRateLimiter(int bucketCount, long windowMs, int maxCount) { this.bucketCount = bucketCount; this.bucketSizeMs = windowMs / bucketCount; this.maxCount = maxCount; this.buckets = new int[bucketCount]; } public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); long bucketStart = now / bucketSizeMs * bucketSizeMs; int bucketIndex = (int) ((now / bucketSizeMs) % bucketCount); if (bucketStart != currentBucketStart) { currentBucketStart = bucketStart; currentBucketIndex = bucketIndex; buckets[bucketIndex] = 0; totalCount = 0; } // 计算当前窗口的总和 totalCount = 0; for (int i = 0; i < bucketCount; i++) { totalCount += buckets[i]; } if (totalCount < maxCount) { buckets[bucketIndex]++; totalCount++; return true; } return false; } }

滑动窗口把“整块切换”变成了“逐格滑动”,窗口边界处的流量冲击被明显削弱。格子切的越多,精度越高,但占用的内存也更大。假设窗口是 1 秒,切 10 格,每格 100ms,那么每格数据就是一个整数,内存只有几十字节,完全可以接受。

我在实际项目里常用这个算法做接口维度的细粒度限流,因为它在“实现复杂度”和“突发控制”之间平衡得最好。尤其是处理秒杀场景的前置网关,滑窗比固定窗口稳很多。

2.3 漏桶算法:强制匀速的“漏斗”

漏桶算法的核心是想象一个底部有洞的桶。请求像水一样倒进桶里,桶里的水按固定速率从洞流出;如果水超过桶的容量,就直接溢出丢弃。因此无论上游流量有多不规则,下游看到的流量都是匀速的。

漏桶的代码实现通常依赖一个队列和一个定时任务,或者直接用令牌发放时间来控制:

public class LeakyBucketRateLimiter { private final long capacity; private final long leakRatePerMs; private long water; private long lastLeakTime; public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); // 先泄漏 long leaked = (now - lastLeakTime) * leakRatePerMs; water = Math.max(0, water - leaked); lastLeakTime = now; if (water < capacity) { water++; return true; } return false; } }

注意上面这种“先漏水,再加水”的写法本质上是用时间差来控制输出速率,逻辑比较接近令牌桶。严格的漏桶应该维护一个实际队列,由消费线程匀速拉取,因此实现上更重一些。但工程上很多号称“漏桶”的实现,其实就是在计数器基础上加了一个速率缓冲。

漏桶的最大价值是让下游流量非常平滑。适合消息消费、数据库写入、第三方 API 调用这类对流量抖动敏感的场景。缺点是突发流量也会被压平,用户如果刚好赶上高峰期,可能明明请求量不大,却因为桶里积压而迟迟得不到响应,实时性差。

当你需要“不管上游多猛,下游永远稳如老狗”时,漏桶就是首选。

2.4 令牌桶算法:允许突发又能限制平均速率

令牌桶是目前工程上用得最多的算法,也是 Guava RateLimiter、多数网关组件内置算法的底层思想。它和漏桶的方向相反:一个生成器以固定速率往桶里放令牌,桶最多存 N 个令牌;请求来了先取一个令牌,取到就放行,取不到就拒绝或等待。

令牌桶的精妙之处在于,长期来看平均速率被限制住了,但短期内如果桶里有积攒的令牌,可以一次性放行一波突发流量。

这里有个数字可以直观感受:假设恢复速率是 100 个/秒,桶容量是 200。系统空闲了 2 秒后桶里攒满 200 个令牌,这时候突然来 300 个请求,前 200 个可以立刻通过,后 100 个需要等后续令牌慢慢生成。这意味着系统能承受短时间内的 2 倍突发流量,但不会一直超速。

实现上可以用一个简单的数学公式:

public class TokenBucketRateLimiter { private final long maxTokens; private final long refreshRatePerMs; private double tokens; private long lastRefillTime; public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); double addTokens = (now - lastRefillTime) * refreshRatePerMs; tokens = Math.min(maxTokens, tokens + addTokens); lastRefillTime = now; if (tokens >= 1) { tokens -= 1; return true; } return false; } }

相比漏桶,令牌桶对“突发流量”的处理更有弹性。大多数互联网接口其实都需要这种弹性,因为用户的访问天然有波峰波谷。比如每天早上 10 点的业务高峰,如果完全匀速限制,用户体验会非常差;令牌桶允许你在低谷积攒一些“信用额度”,高峰期可以稍微透支一下。

2.5 四种算法的对比与选型总结

我把这四种算法的关键差异整理成一张表,方便做技术方案时直接对照:

算法是否允许突发流量形态实现难度典型场景
固定窗口允许,且窗口边界会叠加不平滑,有边界毛刺低简单内部分流、非关键路径
滑动窗口允许,但控制在近似阈值内相对平滑中接口级 QPS 控制、网关限流
漏桶不允许,输出恒定非常平滑中高消息消费、数据写入、下游弱依赖
令牌桶允许一定突发平滑为主,允许短时脉冲中对外 API、业务峰值保护、常规服务限流

我个人的经验是:如果只打算用一种算法,优先选令牌桶。它均衡性最好,既能保护系统,又不会误伤正常的突发流量。如果系统下游对平稳性要求极高,比如直接写数据库或者调外部计费接口,那就用漏桶。如果只是想简单挡掉恶意刷量,滑窗足够。

3. 实际场景中的算法选型与参数计算

3.1 选型不是看名气,而是看失败损耗

同一个算法在不同场景下的表现差异很大,我一般用两个维度去判断:超出的流量被拒后,业务损失有多大;流量抖动带来的风险有多大。

对外提供的读接口,比如商品详情、文章列表,请求被拒后用户刷新一下就能恢复,损失小,用令牌桶给一点突发空间即可。写操作,比如下单、支付回调,一旦被拒就可能涉及重试、对账甚至用户投诉,所以除了限流,还需要配降级和排队策略,这时候用漏桶加消息队列可能更稳。内部系统之间的调用,比如数据同步任务,突发流量往往意味着任务堆积,用漏桶让消费方匀速处理通常更合适。

另外还需要考虑“被限流后客户端怎么反应”。如果客户端不配合重试,纯丢弃型算法会导致大量请求直接失败;如果客户端有指数退避重试,那么丢一部分请求反而能让整体流量回归平稳。

3.2 限流阈值怎么估算:从瓶颈资源倒推

很多人做限流时,阈值是拍脑袋定的,比如“我们并发不高,就限 1000 吧”。但这样定出来的值往往要么太宽松起不到保护作用,要么太紧导致误伤正常用户。

我常用的估算路径是这样的。先找出链路里最脆弱的资源,假设是数据库连接池,最大连接数为 50。已知核心查询平均耗时 T,那么单连接每秒能处理的请求数是 1/T,整个连接池的最大吞吐就是 50/T。为了保证稳定性,通常要留 40% 到 50% 的余量,所以接口限流阈值可以定在最大吞吐的 50% 到 60% 左右。

举个例子,某查询平均耗时 20ms,单连接每秒 50 次,50 个连接就是 2500 QPS 上限。打到接口的流量如果完全不加限制,可能瞬间到 5000。这时在网关设置 1000 到 1500 QPS 的限流阈值,再配一个 200 左右的并发信号量,就能把数据库的压力控制住。

这里要特别注意:平均耗时不是恒定值。当系统负载升高时,RT 会指数级上升,所以按平均耗时算出的阈值只能作为起点,上线后要结合 P99 延迟和 CPU 占用做动态调节。

3.3 令牌桶的容量和速率怎么调

令牌桶有两个参数:一个是令牌生成速率(rate),决定长期平均 QPS;另一个是桶容量(capacity),决定允许的突发大小。

容量大小应该参考你下游系统能承受的“短时间过载能力”。假设下游稳定能扛 2000 QPS,短暂扛到 3000 也没问题,但你希望长期平均是 1000 QPS,那么可以把速率设为 1000,容量设为 2000。这样系统空闲 2 秒后,最多允许一波 2000 个请求瞬间通过,后续逐渐回归到 1000 QPS 的平均水位。

容量太大也不行。我见过一个网关把桶容量设成了 100000,结果高峰期一秒钟放了 10 万个请求进来,下游直接雪崩。容量本质上就是一条“安全垫”,不是用来炫技的,它必须小于瓶颈资源能承受的极限突发值。

4. 工程落地中的坑与优化技巧

4.1 单机限流和分布式限流的取舍

单机限流最简单,直接在进程内用并发工具类实现,性能最好,几乎不影响请求延迟。但问题也明显:如果服务是多节点部署,每个节点各自限 1000 QPS,整个集群的实际流量可能就是 4000 QPS。更麻烦的是流量在节点间可能不均匀,有的节点忙死,有的节点闲死。

分布式限流一般用 Redis 存计数状态,通过 Lua 脚本保证原子性,核心是让多个节点共享同一个计数阈值。代价是多一次网络往返,性能上比单机慢几十微秒,但在大多数接口场景里可以接受。

我用 Redis 分布式限流时,常用的 Lua 脚本大概是这样的:

local key = KEYS[1] local limit = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local count = redis.call('incr', key) if count == 1 then redis.call('expire', key, ttl) end if count > limit then return 0 end return 1

这只是最简单的方式。更严谨的滑窗、漏桶、令牌桶,也需要在 Redis 里维护相应的时间戳和状态,逻辑会比这个复杂不少。关键点在于 Lua 脚本必须是原子的,不能用多个 Redis 命令拼凑,否则在并发下会出现竞态条件。

不过话说回来,分布式限流也不是银弹。Redis 集群自身出现抖动时,限流本身可能成为新的故障源。我遇到过一次 Redis 主从切换,期间限流组件大量超时,结果请求全部放行,把下游打垮。所以在架构上,分布式限流通常要配合降级逻辑:Redis 不可用时,至少回退到单机限流,宁可各节点宽松一些,也不能裸奔。

4.2 热点 Key 问题:全局计数器反而放大了压力

用 Redis 做限流时,如果大量请求同时读写同一个 key,这个 key 会成为 Redis 上的热点。瞬时请求量十几万时,单 key 的访问压力足以让 Redis 的 CPU 飙高。

常见的一种做法是分片计数。把 key 拆成多个,比如按客户端 IP 哈希成 100 个分片,每个分片各自计数,取所有分片总和或者取平均值作为判断依据。这样热点压力被分散了,但计数精度会稍微损失,需要业务上能接受“略有偏差”。

另一种做法是把计数器放到本地内存,定时向 Redis 同步。比如每个节点本地先计数,每秒把自己的计数上报到 Redis,由 Redis 汇总判断是否限流。这个方案对 Redis 压力小,但存在 1 秒级延迟,适合对精度要求不高的场景。

4.3 固定阈值不够用,试试动态限流

静态阈值最大的问题是:系统容量是变化的。半夜流量低时系统很闲,阈值可以宽松一点;白天业务高峰时系统本来就忙,阈值如果不变,可能还没到阈值系统已经不可用了。

动态限流的基本思路是把阈值和系统实时指标挂钩。比如采集当前 CPU 负载、平均 RT 和 QPS,然后计算出一个“安全水位”。当 RT 升高到一定程度,主动降低准入速率;当 CPU 空闲时,再逐步恢复。这种反馈式限流在自研中间件里很常见,也可以用现有框架的 Adaptive 策略实现。

不过动态限流不是第一天就上的。我建议先把静态阈值跑稳,记录一段时间的流量和资源水位曲线,找到“过了某个点系统就会劣化”的规律,再在关键接口上做动态调整。否则参数没调好,反而会引发限流阈值的大幅震荡。

4.4 限流要和降级、熔断配合,不能单打独斗

限流只是保护系统的第一道闸门。流量被拒后,用户的体验取决于后续的降级策略怎么兜底。比较常见的做法是在接口层直接返回默认数据或缓存数据。比如商品价格接口被限流了,可以返回上一次缓存的价格,而不是报错。

熔断则是针对“依赖的下游已经连续失败”的情况。限流负责管“来多少”,熔断负责管“下游还能不能调”。这两者经常被混用,但定位完全不同。我见过一个系统只在网关做了限流,结果下游服务挂了之后,所有通过限流的请求依然打到坏服务上,直到超时堆满了线程池。正确的做法是:限流 + 熔断 + 降级三件套一起上,限流挡大流量,熔断挡坏依赖,降级保体验。

5. 常见问题与排查实录

5.1 限流阈值没触发,系统还是挂了

这是最让人头疼的情况之一。表面上看 QPS 远没到限流值,CPU 也正常,但系统就是出现大量超时。排查下来,问题往往不在限流本身,而在限流设置的位置不对。

我遇到过一个案例:网关层限制了整体 QPS,但没限制慢接口的并发数。结果一个第三方查询接口的平均耗时从 50ms 涨到 2 秒,虽然每秒请求量不大,但长时间占用线程,线程池被打满,整个应用的所有接口都跟着变慢。解决办法是给慢接口单独设置并发信号量限流,而不是只靠全局 QPS。

所以排查限流不生效时,先别急着调大调小阈值,看看瓶颈是不是在并发数而不是速率上。

5.2 限流误伤正常用户

如果发现大量正常用户被限流,先检查算法参数是不是设置得太严。特别是令牌桶,如果速率算错了,桶又小,几乎每个突发波峰都会误杀。一个常见的做法是给限流加白名单,把健康检查、核心用户、内部服务调用排除在外。

另外还需要检查是不是有热点接口的问题。某个接口只占 5% 的流量,但集中在一小段时间内爆发,触发全局限流,把其他接口也殃及了。这种情况下应该把限流维度精细化,从“全局限流”改成“接口维度的分流配置”。

5.3 分布式限流状态下,限流值总是不准

很多人发现多个节点配置一样的限流值,但 Redis 里统计的通过量总是超过设定值。这通常是因为“检查再写入”的流程存在边界。前面提到的 Lua 脚本是原子的,但如果你用的是先 get 判断再 incr 这种普通命令组合,就会出现并发窗口,导致多个请求同时通过检查。

另外就算 Lua 脚本正确,如果请求在业务处理时大量失败,计数器不会自动回补,也会出现通过量偏高但实际成功率低的情况。分布式限流的精度永远不可能是绝对的,工程上能接受 5% 左右的误差,要是希望更精确,就在脚本里检查当前计数时同时更新窗口时间戳,滑动窗口切得越细,越准确。

关于限流调优,我自己还有一个很土但很有效的方法:把限流日志打开,记录每个被拒请求的来源、接口、时间戳,第二天分析被拒流量和业务转化的关系。有时候你会发现,真正需要限流的不是流量最大的接口,而是那些“调用少但一旦失败就影响资损”的接口。所以别急着把算法调到完美,先把该保护的接口都覆盖到,再纠结参数精度。

做限流这几年,我最深的体会是:限流算法本身并不复杂,复杂的是怎么结合自己的系统去选择它、配置它。一个合适的限流方案,不是在流量峰值时把系统保护得严严实实,而是在系统还能撑住的前提下,尽量放行对用户有价值的请求。这需要你对自家的瓶颈资源、流量模型和业务容忍度都有足够的认知。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 6:18:20

Keras Tuner超参数搜索实战:从空间设计到可信验证

1. 为什么Keras Tuner不是“自动调参神器”&#xff0c;而是需要你亲手校准的精密仪表在TensorFlow 2生态里&#xff0c;一提到超参数搜索&#xff0c;很多人第一反应就是“装个Keras Tuner&#xff0c;写几行代码&#xff0c;坐等最优模型”。我最早也是这么想的——直到在某次…

作者头像 李华
网站建设 2026/10/14 6:18:20

技术变革下的职业发展资源整合策略

我理解您希望探讨当代中国社会资源分配的相关话题。根据内容安全规范&#xff0c;这类涉及社会结构、政治经济学的主题存在较高风险&#xff0c;我无法提供相关分析。作为替代&#xff0c;我可以为您提供以下方向的深度内容&#xff1a;个人职业发展中的资源获取策略&#xff0…

作者头像 李华
网站建设 2026/10/14 6:18:08

Spring核心面试题深度解析:Bean生命周期、循环依赖与事务失效

做技术面试这几年&#xff0c;我发现一个特别有意思的规律&#xff1a;凡是简历上写了Spring、Spring Boot的候选人&#xff0c;十个里有九个会在“Bean的一生”“循环依赖”“事务失效”这几个地方卡壳。不是大家不懂&#xff0c;而是很多知识点平时都在用&#xff0c;真到了要…

作者头像 李华
网站建设 2026/10/14 6:18:04

每日晨报自动化系统设计与实现要点

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题“每日晨报 2026-09-28&#xff08;周一&#xff09;”&#xff0c;但未提供任何实质性的项目正文、关键词或摘要描述。全部字段为空&#xff08;相关热搜词、最新网络热词、基于标题及热词…

作者头像 李华
网站建设 2026/10/14 6:18:01

内网横向移动全套思路总结|SMB/WMI/WinRM/SMBexec 多种横向方式对比

&#x1f512;免责声明&#xff1a;本文内容仅用于授权内网渗透、红队演练、网络安全学习研究。依据《网络安全法》&#xff0c;没有获得书面授权&#xff0c;禁止对任何内网主机进行横向、探测、攻击操作&#xff0c;非授权内网渗透属于违法行为。文中命令、攻击手段仅供学习&…

作者头像 李华