限流:保护系统的流量闸门
任何系统的处理能力都是有限的。一台服务器每秒能处理的请求数、一个数据库能承受的并发连接数、一个下游接口允许的调用频率,都存在上限。当流量超过这个上限时,系统的表现往往不是"多出来的请求处理不了"这么简单,而是整体性能急剧恶化:请求排队、响应变慢、超时增多、重试加剧,最终可能导致服务完全不可用,甚至拖垮依赖它的其他系统。
限流(Rate Limiting)是应对这一问题的基本手段:主动控制进入系统的请求速率,将超出处理能力的部分拒绝或延后,以保证系统在高负载下仍能稳定地服务于其能够处理的那部分请求。
本文介绍限流的目的与应用场景,详细分析固定窗口、滑动窗口、漏桶、令牌桶等经典算法,讨论分布式环境下的限流实现,以及自适应限流、并发控制等相关技术。
一、为什么需要限流
过载时的性能崩塌
直观上,系统接收的请求超过处理能力时,多出来的请求只是处理不了而已,已有的请求应该还能正常处理。但实际情况往往并非如此。
当请求到达速率超过处理速率时,请求会在队列中堆积。排队时间不断增长,导致所有请求的响应时间都在变长。客户端等待超时后,往往会发起重试,进一步增加请求量。与此同时,大量积压的请求占用着内存、线程、连接等资源,处理每个请求的效率也随之下降:线程切换更频繁,缓存命中率更低,垃圾回收更频繁。
最终的结果是:系统的有效吞吐量不升反降,在过载状态下,系统可能连原本能够正常处理的请求量都无法完成。更糟的是,即使流量回落,积压的请求和持续的重试也可能使系统长时间无法恢复。
限流的作用,就是在流量达到系统承受极限之前主动拒绝一部分请求,让系统始终工作在能够高效处理的负载区间内。拒绝一部分请求,是为了保证其余请求能被正常服务。
常见应用场景
- 保护自身服务:防止突发流量、异常调用或恶意攻击压垮服务;
- 保护下游依赖:数据库、缓存、第三方服务都有各自的容量限制,调用方需要主动控制调用速率;
- 公平分配资源:在多租户系统中,防止某个用户或租户占用过多资源,影响其他用户;
- 防止滥用:限制登录尝试、短信发送、接口调用的频率,防止暴力破解、资源滥用和数据爬取;
- 商业策略:根据用户的付费等级提供不同的调用配额。
二、限流的基本要素
一条限流规则通常包含以下几个要素:
- 限流对象:按什么维度进行限流,例如整个服务、单个接口、单个用户、单个客户端地址、单个租户,或者它们的组合;
- 限流阈值:在一定时间内允许通过的请求数量或数据量,例如每秒 100 次请求、每分钟 1000 次调用;
- 时间粒度:统计请求的时间单位,例如秒、分钟、小时、天;
- 超限处理:请求超出限制时如何处理,常见的方式有直接拒绝、排队等待、降级处理等。
对于 HTTP 服务,被拒绝的请求通常返回状态码 429(请求过多),并可以在响应头中告知客户端剩余配额和建议的重试等待时间,以便客户端合理地调整请求节奏。
三、固定窗口计数器
固定窗口计数器(Fixed Window Counter)是最简单的限流算法。
原理
将时间划分为固定长度的窗口,例如每一秒为一个窗口。为每个窗口维护一个计数器:
- 请求到达时,判断其所属的时间窗口;
- 如果当前窗口的计数小于阈值,计数加 1,允许请求通过;
- 否则,拒绝请求;
- 进入新的窗口时,计数器重置为 0。
优点
- 实现极其简单,只需要一个计数器和一个窗口起始时间;
- 内存占用极小;
- 在分布式环境中,借助支持原子递增和过期时间的存储系统即可轻松实现。
缺点:窗口边界问题
固定窗口计数器存在一个明显的缺陷。假设限流规则是每秒最多 100 次请求:
- 在第 1 秒的最后 100 毫秒内,到达 100 次请求,全部通过;
- 在第 2 秒的最初 100 毫秒内,又到达 100 次请求,由于进入了新窗口,计数器已重置,这 100 次请求也全部通过。
结果是,在跨越窗口边界的 200 毫秒内,系统实际接收了 200 次请求,是设定阈值的两倍。如果系统的真实承载能力恰好是每秒 100 次,这种短时间的突发就可能造成过载。
此外,固定窗口在窗口开始时允许大量请求瞬间涌入,在窗口后段则可能因配额耗尽而全部拒绝,流量分布很不平滑。
四、滑动窗口
为了解决固定窗口的边界问题,出现了基于滑动窗口的算法。滑动窗口的核心思想是:统计的始终是"以当前时刻为终点、向前回溯一个窗口长度"这段时间内的请求数,而不是一个固定起止时间的窗口。
滑动窗口日志
滑动窗口日志(Sliding Window Log)是最精确的实现方式:
- 为每个限流对象维护一个有序的时间戳列表,记录每个已通过请求的时间;
- 请求到达时,先删除列表中所有早于"当前时间减去窗口长度"的时间戳;
- 如果列表中剩余的时间戳数量小于阈值,将当前时间加入列表,允许请求通过;
- 否则,拒绝请求。
这种方式能够精确保证在任意一个窗口长度的时间段内,通过的请求都不超过阈值,彻底消除了边界问题。
但它的代价是内存开销大:需要为每个请求存储一个时间戳。如果阈值是每小时 10000 次,每个限流对象就需要存储最多 10000 个时间戳。当限流对象数量众多时,例如按用户限流,内存消耗相当可观。
滑动窗口计数器
滑动窗口计数器(Sliding Window Counter)是一种在精确度和开销之间折中的方法,有两种常见实现。
分段计数。 将一个大窗口划分为若干个更小的子窗口,例如将 1 秒划分为 10 个 100 毫秒的子窗口,为每个子窗口维护一个计数器。统计时,累加最近 10 个子窗口的计数。随着时间推移,最旧的子窗口滑出,新的子窗口加入。子窗口划分得越细,结果越精确,但需要维护的计数器也越多。
加权估算。 只维护当前窗口和上一个窗口两个计数器,根据当前时刻在窗口中的位置,估算滑动窗口内的请求数:
估算请求数 = 上一窗口计数 × (1 − 当前窗口已过去的比例) + 当前窗口计数
例如,窗口长度为 1 分钟,当前时刻在本窗口内已过去了 30%,上一窗口有 80 次请求,本窗口已有 30 次请求,则估算值为 80 × 0.7 + 30 = 86。
这种方法假设上一窗口内的请求是均匀分布的,因此结果是一个近似值。但它只需要两个计数器,内存开销与固定窗口相当,同时显著缓解了边界突发问题,在实践中被广泛使用。
五、漏桶算法
漏桶算法(Leaky Bucket)从另一个角度看待限流:它关注的不是在一段时间内允许多少请求,而是以恒定的速率处理请求。
原理
想象一个底部有一个小孔的桶:
- 请求像水一样从上方倒入桶中;
- 桶底以恒定的速率漏水,代表请求被处理;
- 桶有固定的容量,如果水倒入的速度过快导致桶满,多余的水就会溢出,代表请求被拒绝。
在实现上,漏桶通常对应一个有界队列和一个以固定速率从队列中取出请求进行处理的消费者:
- 请求到达时,如果队列未满,放入队列;
- 如果队列已满,拒绝请求;
- 处理端以固定的时间间隔从队列中取出请求执行。
特点
漏桶算法最大的特点是输出速率恒定。无论输入流量如何波动,突发也好,平稳也好,到达下游的流量始终是平滑、均匀的。这对于保护处理能力固定、无法承受突发的下游系统非常有用,例如某些对调用频率有严格限制的外部接口。
漏桶的容量决定了系统能够缓冲多大的突发。容量越大,能吸收的突发越多,但请求在队列中等待的时间也可能越长。
局限
漏桶的恒定输出速率在某些场景下反而是缺点。当系统实际有空闲处理能力时,漏桶也不允许请求以更快的速度通过。面对短时的流量突增,即使系统完全有能力处理,请求也必须在队列中排队,增加了延迟。
此外,漏桶在实际应用中有两种不同的理解方式。一种是上述的队列形式,请求会被缓冲并延迟处理;另一种是计量形式,只用于判断请求是否超限,不进行排队,超出部分直接拒绝。后者在行为上与令牌桶有许多相似之处。
六、令牌桶算法
令牌桶算法(Token Bucket)是目前应用最广泛的限流算法之一。
原理
- 系统以恒定的速率向一个桶中放入令牌,例如每秒放入 100 个;
- 桶有一个最大容量,例如 200 个,桶满后新产生的令牌被丢弃;
- 每个请求到达时,需要从桶中取出一个令牌才能通过;
- 如果桶中有令牌,取出令牌,请求通过;
- 如果桶中没有令牌,请求被拒绝或等待。
实现方式
令牌桶并不需要真的有一个后台线程定期往桶里放令牌。常见的实现方式是惰性计算:
- 记录桶中当前的令牌数和上次更新的时间;
- 每当请求到达时,根据距离上次更新经过的时间,计算这段时间内应当新增的令牌数,加到当前令牌数上,但不超过桶的容量;
- 更新"上次更新时间"为当前时间;
- 判断令牌是否足够,足够则扣减并放行,否则拒绝。
这样,每个限流对象只需要存储两个数值,计算也只涉及简单的算术,开销极小。
允许突发
令牌桶与漏桶最重要的区别在于:令牌桶允许一定程度的突发流量。
当系统一段时间内请求较少时,令牌会在桶中积累,最多积累到桶的容量。此时如果突然到来一批请求,它们可以立即消耗积累的令牌,瞬间通过,而不需要排队等待。
以每秒 100 个令牌、容量 200 为例:
- 长期平均速率被限制为每秒 100 次;
- 短时突发最多可以达到 200 次。
令牌桶通过两个参数,分别控制了长期平均速率和允许的最大突发量。这种特性非常符合实际业务的需要:真实流量很少是完全均匀的,适度的突发通常是正常的,系统也往往有一定的余量来应对。完全禁止突发,反而会让正常的用户请求被不必要地拒绝。
变体
令牌桶有多种扩展用法:
- 按权重消耗令牌:不同的请求可以消耗不同数量的令牌。例如,一个批量查询请求消耗 10 个令牌,一个普通请求消耗 1 个;或者按照请求的数据量消耗令牌,用于限制带宽;
- 预消费:允许请求透支令牌,即令牌数可以变为负数,后续请求需要等待令牌恢复为正后才能通过。这让一个大请求不至于因为令牌暂时不足而被无限期拒绝;
- 预热:系统刚启动或长时间空闲后,缓存尚未建立、连接尚未建立,处理能力低于正常水平。此时可以让令牌产生速率从一个较低的值逐渐增加到正常值,避免冷启动时被突发流量压垮。
七、算法对比
| 算法 | 精确度 | 内存开销 | 是否允许突发 | 输出是否平滑 | 实现复杂度 |
|---|---|---|---|---|---|
| 固定窗口计数器 | 低,存在边界突发 | 极低 | 边界处可达两倍阈值 | 否 | 极低 |
| 滑动窗口日志 | 精确 | 高,与阈值成正比 | 否 | 否 | 中 |
| 滑动窗口计数器 | 近似 | 低 | 轻微 | 否 | 低 |
| 漏桶(队列形式) | 精确 | 与队列容量相关 | 缓冲后平滑输出 | 是 | 中 |
| 令牌桶 | 精确 | 极低 | 允许,可配置 | 否 | 低 |
选择算法时,可以参考以下原则:
- 需要严格控制下游调用速率、输出必须平滑时,选择漏桶;
- 需要限制平均速率、同时允许合理突发时,选择令牌桶,这也是大多数场景的默认选择;
- 需要按较长时间粒度计数,例如每天的调用配额,并且对精确度要求不高时,选择固定窗口或滑动窗口计数器;
- 需要严格保证任意时间段内不超限,且请求量不大时,选择滑动窗口日志。
八、分布式限流
在单机环境下,限流状态保存在本地内存中,实现简单且没有额外的网络开销。但现代服务通常部署在多个实例上,限流需要在整个集群范围内生效,这带来了新的挑战。
本地限流
最简单的做法是将全局阈值平均分配到每个实例上。例如,全局限制为每秒 1000 次,有 10 个实例,则每个实例本地限制为每秒 100 次。
这种方式没有任何额外的网络调用,性能最好,可靠性也最高。但它的准确性依赖于负载均衡的均匀程度:
- 如果流量在实例间分配不均,某些实例会提前触发限流,而其他实例仍有余量,导致全局实际通过量低于阈值;
- 实例数量变化时,需要重新调整每个实例的阈值;
- 对于按用户限流的场景,同一用户的请求可能分散到不同实例,每个实例只能看到一部分请求。
尽管存在这些不足,在许多对精确度要求不高的场景下,本地限流仍然是一个实用的选择。
集中式限流
另一种做法是将限流状态保存在一个集中的存储中,所有实例共享。最常见的方式是使用一个高性能的内存数据库:
- 固定窗口计数器可以借助原子递增操作和过期时间实现;
- 令牌桶和滑动窗口需要读取、计算、更新多个值,为保证原子性,通常将整个逻辑封装为一个在存储端原子执行的脚本。
集中式限流能够精确地实现全局限制,但也带来了新的问题:
- 延迟增加:每个请求都需要一次网络往返来检查限流状态;
- 存储成为瓶颈:所有限流判断都集中到存储上,在高流量下可能成为性能瓶颈;
- 单点风险:存储不可用时,限流功能失效。此时需要决定是放行所有请求还是拒绝所有请求。通常的选择是放行,即限流失效时退化为不限流,避免限流组件的故障导致整个服务不可用;但对于以防止滥用为目的的限流,可能需要做出不同的选择。
优化方式
为了平衡精确度和性能,实践中常采用一些折中方案:
- 批量申请配额:每个实例不是每个请求都访问集中存储,而是一次性申请一批令牌缓存在本地,用完后再申请。这大幅减少了网络调用次数,代价是精确度略有下降;
- 异步同步计数:各实例在本地进行限流判断,并定期将本地计数上报到集中存储,同时获取全局计数,据此动态调整本地阈值;
- 分层限流:在网关层进行粗粒度的全局限流,在服务实例内进行细粒度的本地限流,各层分别承担不同的职责。
九、自适应限流
以上所有算法都依赖一个预先设定的阈值。但确定合适的阈值本身就很困难:
- 系统的处理能力会随着代码版本、硬件配置、数据规模、依赖服务状态的变化而变化;
- 不同类型请求的处理成本差异巨大,简单地以请求数计量并不准确;
- 阈值设置过低,浪费了系统能力,影响正常用户;设置过高,又起不到保护作用。
自适应限流的思路是:不依赖固定阈值,而是根据系统的实时运行状态动态判断是否应当限流。
基于系统指标
一种直接的方式是监控系统指标,例如 CPU 使用率、系统负载、内存使用率。当这些指标超过设定的水位时,开始拒绝部分请求。这种方式简单直观,但系统指标往往是滞后的,并且与请求处理能力之间的关系并不总是线性的。
基于排队理论
更精细的方法借鉴了利特尔法则:在稳定状态下,系统中同时处理的请求数,等于请求到达速率乘以平均处理时间。
据此,系统的最大处理能力可以通过以下方式估算:在最近一段时间内,观测到的最大成功处理速率乘以最小响应时间,得到系统在不排队状态下能够同时处理的请求数上限。当实际正在处理的请求数超过这个上限时,说明请求开始排队,系统接近过载,此时拒绝新请求。
这种方法能够随系统能力的变化自动调整,无需人工设定阈值。
基于延迟的并发控制
另一类方法借鉴了网络拥塞控制的思想,将服务视为网络链路,将并发请求数视为拥塞窗口:
- 当请求的响应时间保持稳定时,逐步增加允许的并发上限;
- 当响应时间开始上升,说明出现了排队,减小并发上限。
这类算法能够自动探测服务的最佳并发度,在负载变化时快速调整,近年来在服务治理领域得到了越来越多的应用。
客户端自适应
限流也可以在调用方实现。一种常见的方法是:客户端统计最近一段时间内发出的请求数和被服务端接受的请求数。当服务端开始频繁拒绝请求时,客户端以一定概率在本地直接丢弃新请求,而不是发送到已经过载的服务端。拒绝比例越高,本地丢弃的概率越大。
这种方式减轻了服务端处理拒绝请求本身的负担。即使是拒绝一个请求,服务端也需要消耗一定的资源来接收连接、解析请求、返回响应;在极端过载时,仅仅是处理拒绝也可能耗尽服务端的资源。
十、并发控制:另一种限流
前面讨论的算法都是对请求速率进行限制。还有一种与之互补的方式:限制同时正在处理的请求数量,即并发控制。
速率限制与并发限制的区别
- 速率限制关心单位时间内进入多少请求;
- 并发限制关心同一时刻有多少请求正在被处理。
两者在系统正常时大致等价,但在系统变慢时表现截然不同。假设每秒允许 100 个请求,正常情况下每个请求处理 10 毫秒,同时处理的请求只有 1 个左右。但如果下游变慢,每个请求需要 10 秒,速率限制仍然每秒放行 100 个请求,同时处理的请求将累积到 1000 个,耗尽线程和连接。
并发限制则天然能够应对这种情况:当处理变慢时,正在处理的请求无法及时完成,新请求自然就被阻挡在外。
实现方式
并发控制通常使用信号量实现:设置一个许可数量,请求开始处理时获取一个许可,处理完成后释放。没有可用许可时,请求被拒绝或排队等待。
线程池的大小和队列长度,本质上也是一种并发控制。为不同的下游依赖分配独立的资源池,可以避免某个下游变慢时占满所有资源、波及其他功能,这种做法称为舱壁隔离。
与熔断的配合
熔断是与限流密切相关的另一种保护机制。当检测到对某个下游的调用失败率或慢调用比例超过阈值时,熔断器"打开",在一段时间内直接拒绝所有对该下游的调用,而不再实际发起请求。经过一段时间后,熔断器进入"半开"状态,放行少量试探请求;如果试探成功,熔断器关闭,恢复正常调用;否则继续保持打开。
限流保护的是自身不被过量请求压垮,熔断保护的是自身不被故障的下游拖垮,同时也给下游留出恢复的时间。两者通常结合使用。
十一、实践建议
限流应当分层实施。 在接入层或网关进行粗粒度的全局限流,抵御大规模的突发流量和攻击;在服务内部针对具体接口和资源进行细粒度限流;在调用下游时进行客户端限流和并发控制。各层各司其职,共同构成完整的防护体系。
通过压测确定阈值。 固定阈值应当基于压力测试的结果设定,而非凭经验猜测。压测应当覆盖真实的请求混合比例,并观察系统在不同负载下的吞吐量和延迟变化,找出性能开始明显下降的拐点,将阈值设置在拐点之下留有一定余量。随着系统变化,阈值也需要定期重新评估。
区分请求的优先级。 并非所有请求都同等重要。在过载时,应当优先保证核心功能,例如支付和下单,而优先拒绝非核心请求,例如推荐和统计。可以为不同优先级的请求设置不同的限流规则,或在过载时按优先级逐级丢弃。
给客户端明确的反馈。 被限流的请求应当返回明确的错误码和提示,并尽可能告知客户端应当在多长时间后重试。客户端在收到限流响应后,应当采用指数退避加随机抖动的方式重试,避免所有客户端在同一时刻集中重试,形成新的流量高峰。
监控限流的触发情况。 限流的触发次数、被拒绝的请求比例、各限流对象的配额使用情况,都应当纳入监控。频繁触发限流可能意味着阈值设置不当、系统容量不足,或者正在遭受攻击,需要及时分析和处理。
限流组件本身要可靠。 限流逻辑处于每个请求的处理路径上,必须足够高效,并且在自身故障时有明确的降级策略,不能因为限流组件的问题导致整个服务不可用。
限流不能替代扩容。 限流是在系统容量不足时的保护手段,而不是解决容量问题的根本方法。如果正常业务流量经常触发限流,说明需要优化系统性能或增加资源。
结语
限流的本质,是在系统能力有限的前提下,通过主动拒绝一部分请求来保证整体的稳定性。它体现了一个在系统设计中反复出现的原则:一个有节制地拒绝服务的系统,远比一个试图服务所有请求却最终崩溃的系统更可靠。
从固定窗口到滑动窗口,从漏桶到令牌桶,从单机限流到分布式限流,再到根据系统状态动态调整的自适应限流,各种方案在精确度、性能开销、突发处理和实现复杂度之间做出了不同的取舍。在实际系统中,它们往往与并发控制、熔断、降级、优先级调度等机制组合使用,共同构成服务在高负载下的防护体系。
理解这些机制的原理和适用场景,有助于在设计系统时提前规划过载保护策略,而不是等到故障发生后才被动应对。
- 点赞
- 收藏
- 关注作者
评论(0)