所有文章

高并发限流算法:固定窗口、滑动窗口、漏桶与令牌桶

面试和工程都常见的限流算法总结,讲清楚固定窗口、滑动窗口、漏桶、令牌桶、并发数限制以及分布式限流如何落地。

限流是高并发系统里最常见的稳定性手段之一。

一句话解释:

限流就是在流量超过系统承载能力时,主动拒绝或延迟一部分请求,保护核心服务不被打爆。

它解决的问题不是“让系统处理无限流量”,而是让系统在流量过载时有边界、有秩序地退化

为什么需要限流

一个服务能承受的资源是有限的:

  • CPU 有上限。
  • 线程池有上限。
  • 数据库连接数有上限。
  • Redis / MQ / 下游 RPC 都有上限。
  • 模型推理服务的 GPU 资源也有上限。

如果没有限流,高峰流量可能会造成连锁反应:

请求量暴涨
  -> 线程池被打满
  -> 请求排队变长
  -> 超时增加
  -> 调用方重试
  -> 流量进一步放大
  -> 下游服务雪崩

所以限流的目标是:

  • 保护本服务。
  • 保护下游服务。
  • 保证核心链路优先可用。
  • 避免重试风暴。
  • 在过载时尽早失败,而不是让请求一直排队到超时。

限流限的是什么

常见限流维度:

维度例子
全局限流整个服务最多 10 万 QPS
接口限流/recommend/feed 最多 2 万 QPS
用户限流单用户每秒最多 10 次请求
IP 限流单 IP 每分钟最多 100 次请求
租户限流每个租户独立配额
下游限流调用某个 RPC 最多 5000 QPS
并发数限流同时最多处理 1000 个请求

限流不一定只按 QPS,也可以按:

  • QPS / RPS。
  • 并发请求数。
  • 请求成本。
  • Token 数。
  • 数据库连接数。
  • GPU 推理 batch 数。

比如 LLM 服务经常按 token 限流,而不是只按请求数限流,因为一个请求可能生成 100 token,也可能生成 10000 token。

固定窗口计数器

固定窗口是最简单的限流算法。

思路:

把时间切成固定窗口
每个窗口内维护一个计数器
请求来了计数 +1
如果计数超过阈值,就拒绝
窗口结束后计数清零

例如限制每秒最多 100 个请求:

第 1 秒:允许 100 个
第 2 秒:允许 100 个
第 3 秒:允许 100 个

伪代码:

class FixedWindowLimiter:
    def __init__(self, limit: int, window_seconds: int):
        self.limit = limit
        self.window_seconds = window_seconds
        self.window_start = 0
        self.count = 0

    def allow(self, now: int) -> bool:
        if now - self.window_start >= self.window_seconds:
            self.window_start = now
            self.count = 0

        if self.count >= self.limit:
            return False

        self.count += 1
        return True

优点:

  • 实现简单。
  • 内存占用低。
  • 适合粗粒度限流。

缺点:

  • 有窗口边界突刺问题。

比如限制 1 秒 100 次:

00:00:00.900 - 00:00:01.000 来了 100 次
00:00:01.000 - 00:00:01.100 又来了 100 次

从固定窗口看,每秒都没有超过 100 次;但从真实 200ms 时间片看,系统承受了 200 次请求,瞬时流量翻倍。

面试一句话:

固定窗口实现简单,但窗口边界可能出现两倍流量突刺,因此适合简单限流,不适合对流量平滑性要求高的核心链路。

滑动日志

滑动日志是更精确的滑动窗口。

思路:

记录每次请求的时间戳
请求到来时,删除窗口外的旧时间戳
如果窗口内时间戳数量小于阈值,则允许
否则拒绝

例如限制最近 1 秒最多 100 次请求:

from collections import deque

class SlidingLogLimiter:
    def __init__(self, limit: int, window_seconds: float):
        self.limit = limit
        self.window_seconds = window_seconds
        self.requests = deque()

    def allow(self, now: float) -> bool:
        while self.requests and self.requests[0] <= now - self.window_seconds:
            self.requests.popleft()

        if len(self.requests) >= self.limit:
            return False

        self.requests.append(now)
        return True

优点:

  • 精度高。
  • 没有固定窗口边界突刺问题。

缺点:

  • 每个请求都要存时间戳。
  • 高并发下内存开销大。
  • 清理旧时间戳有额外开销。

适合:

  • 单用户限流。
  • 管理后台限流。
  • 安全风控类限流。
  • QPS 不大但精度要求高的场景。

滑动窗口计数器

滑动窗口计数器是固定窗口和滑动日志之间的折中方案。

思路:

把一个大窗口拆成多个小窗口
每个小窗口维护一个计数
请求到来时统计最近 N 个小窗口的总和
超过阈值则拒绝

例如限制最近 1 分钟最多 6000 次,可以把 1 分钟拆成 60 个 1 秒小窗口:

window = 60s
bucket = 1s
limit = 6000

每次请求只需要统计最近 60 个 bucket 的总和。

优点:

  • 比固定窗口平滑。
  • 比滑动日志省内存。
  • 工程中很常用。

缺点:

  • 精度取决于小窗口大小。
  • 小窗口越小越精确,但维护成本越高。

面试可以这样讲:

滑动窗口计数器通过把大窗口拆成多个小桶,统计最近一段时间内的请求总数。它比固定窗口更平滑,又比滑动日志更省内存,是实际系统里常用的折中方案。

漏桶算法

漏桶算法的核心思想是:请求先进桶,再以固定速率流出

请求进入桶
  -> 桶满则拒绝
  -> 桶不满则排队
  -> 系统按固定速率处理请求

形象理解:

水龙头往桶里倒水:请求进入
桶底小孔匀速漏水:系统处理
桶满溢出:请求被拒绝

优点:

  • 输出速率稳定。
  • 对下游很友好。
  • 适合削峰填谷。

缺点:

  • 不能很好支持突发流量。
  • 请求可能排队,增加延迟。
  • 如果队列太长,用户体验会变差。

适合:

  • 保护下游服务。
  • 写数据库。
  • 写消息队列。
  • 发送短信、邮件、推送等需要稳定速率的场景。

面试一句话:

漏桶算法强调稳定输出,不管入口流量多突发,出口都按固定速率处理;它适合保护下游,但对突发流量不够友好。

令牌桶算法

令牌桶是工程中最常用的限流算法之一。

思路:

系统按固定速率生成令牌
令牌放入桶中,桶满则丢弃多余令牌
请求到来时先拿令牌
拿到令牌就允许
拿不到令牌就拒绝或等待

令牌桶和漏桶最大的区别:

  • 漏桶限制的是请求流出的速度
  • 令牌桶限制的是请求进入时必须拿到令牌

令牌桶允许一定程度的突发。

例如:

令牌生成速率:100 个 / 秒
桶容量:500 个

如果前几秒流量很低,桶里会积累令牌。突然来一波 500 个请求,只要桶里有令牌,就可以一次性放行。

伪代码:

import time

class TokenBucketLimiter:
    def __init__(self, rate: float, capacity: int):
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity
        self.last_refill = time.time()

    def allow(self) -> bool:
        now = time.time()
        elapsed = now - self.last_refill
        self.last_refill = now

        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)

        if self.tokens < 1:
            return False

        self.tokens -= 1
        return True

优点:

  • 支持突发流量。
  • 平均速率可控。
  • 实现相对简单。
  • 工程实践成熟。

缺点:

  • 突发流量可能瞬间打到下游。
  • 桶容量需要合理配置。

适合:

  • API 网关限流。
  • 用户请求限流。
  • 推荐主链路限流。
  • RPC 客户端限流。

面试一句话:

令牌桶按固定速率生成令牌,请求必须拿到令牌才能通过。它既能控制长期平均速率,又能允许短时间突发,所以比漏桶更适合大多数在线请求场景。

并发数限制

前面几种算法主要限制的是速率,比如每秒多少请求。

但有些场景更应该限制同时正在处理的请求数

例如:

  • 数据库连接池最多 100 个连接。
  • 线程池最多 200 个 worker。
  • GPU 推理服务最多同时处理 32 个 batch。
  • 下游 RPC 最多允许 1000 个 in-flight 请求。

这时可以用信号量限制并发:

import threading

class ConcurrencyLimiter:
    def __init__(self, max_concurrency: int):
        self.sem = threading.Semaphore(max_concurrency)

    def handle(self, fn):
        if not self.sem.acquire(blocking=False):
            return "too many requests"

        try:
            return fn()
        finally:
            self.sem.release()

并发数限制适合保护资源池。

面试一句话:

QPS 限流控制单位时间内的请求数量,并发数限制控制同时正在执行的请求数量。对于数据库连接池、线程池、GPU 推理这类资源,限制并发数往往比单纯限制 QPS 更直接。

分布式限流

单机限流只在当前进程内生效。

如果服务有 100 个实例,每个实例限流 1000 QPS,那么全局就是 10 万 QPS。

如果想做全局限流,就需要分布式限流。

常见方案:

方案优点缺点
本地限流快,依赖少只能控制单机
Redis 计数实现简单,全局共享Redis 成为热点
Redis + Lua原子性好高并发下仍有 Redis 压力
网关限流入口统一控制网关压力大
配额下发本地高性能配额分配和动态调整复杂

Redis 固定窗口示意:

key = rate_limit:user:123:2026-06-09-12:00:01
INCR key
EXPIRE key 1s
count > limit 则拒绝

为什么用 Lua?

因为 INCREXPIRE 需要保证原子性。否则可能出现计数加了,但过期时间没设置成功,导致 key 永久存在。

分布式限流的核心问题:

  • 全局一致性和性能的权衡。
  • 热点 key 问题。
  • Redis 故障时怎么降级。
  • 多机房延迟。
  • 限流配置如何动态下发。

工程上很多系统会采用混合方案:

网关做粗粒度全局限流
服务本地做细粒度限流
下游客户端再做保护性限流

限流后怎么处理

限流不是只会返回错误。

常见处理方式:

  • 直接拒绝:返回 429 Too Many Requests
  • 排队等待:适合后台任务,不适合强交互请求。
  • 降级返回:返回缓存、默认结果、热门结果。
  • 延迟重试:告诉客户端稍后再试。
  • 动态降级:关闭非核心功能,保核心链路。

推荐系统里的例子:

个性化召回限流
  -> 返回热门召回

精排模型服务限流
  -> 使用粗排结果兜底

实时特征服务限流
  -> 使用离线特征或默认特征

用户侧不一定要看到“系统繁忙”,可以返回一个质量略低但可用的结果。

限流和熔断、降级的区别

这三个概念经常一起出现。

概念解决什么问题
限流流量太大,主动控制进入系统的请求量
熔断下游持续失败,暂时停止调用,避免故障扩散
降级牺牲非核心能力,保证核心功能可用

一句话区分:

限流处理流量过大,熔断处理依赖故障,降级处理能力不足或依赖不可用后的兜底体验。

面试怎么选型

可以直接背这张表:

场景推荐算法
简单接口限流固定窗口
单用户精确限流滑动日志
大多数接口 QPS 限流滑动窗口计数器
需要稳定输出保护下游漏桶
允许突发的在线请求令牌桶
保护线程池 / 连接池 / GPU并发数限制
多实例全局限流Redis / 网关 / 配额下发

高频面试回答:

固定窗口简单但有边界突刺;滑动日志精确但内存开销大;滑动窗口计数器是折中方案;漏桶强调稳定输出,适合保护下游;令牌桶允许突发,同时控制平均速率,是在线服务最常见的选择;如果瓶颈是连接池、线程池或 GPU 资源,还需要配合并发数限制。

系统设计里怎么说

如果面试官问“如何设计一个高并发接口的限流”,可以按下面结构回答:

1. 先明确限流目标
   全局 QPS、单用户 QPS、接口 QPS、下游 QPS、并发数?

2. 选择限流位置
   网关、本服务、本地客户端、下游入口?

3. 选择算法
   在线请求用令牌桶
   下游保护用漏桶或并发数限制
   用户维度用滑动窗口

4. 设计限流后的处理
   返回 429、排队、降级、缓存兜底、热门结果兜底

5. 做监控和动态配置
   限流次数、通过率、拒绝率、P99、下游错误率、配置热更新

不要只讲算法,要讲完整工程闭环。

最后总结

限流算法可以这样记:

固定窗口:简单,但边界突刺。
滑动日志:精确,但内存开销大。
滑动窗口计数器:平滑和成本之间的折中。
漏桶:稳定输出,保护下游。
令牌桶:允许突发,在线服务常用。
并发数限制:保护线程池、连接池、GPU 等有限资源。

工程里不会只用一种限流。

更常见的是:

网关全局限流
  + 服务本地限流
  + 用户维度限流
  + 下游客户端限流
  + 并发数限制
  + 降级兜底

限流的本质不是拒绝请求,而是让系统在流量超过承载能力时,仍然能稳定、可控、优先保证核心链路。