限流是高并发系统里最常见的稳定性手段之一。
一句话解释:
限流就是在流量超过系统承载能力时,主动拒绝或延迟一部分请求,保护核心服务不被打爆。
它解决的问题不是“让系统处理无限流量”,而是让系统在流量过载时有边界、有秩序地退化。
为什么需要限流
一个服务能承受的资源是有限的:
- 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?
因为 INCR 和 EXPIRE 需要保证原子性。否则可能出现计数加了,但过期时间没设置成功,导致 key 永久存在。
分布式限流的核心问题:
- 全局一致性和性能的权衡。
- 热点 key 问题。
- Redis 故障时怎么降级。
- 多机房延迟。
- 限流配置如何动态下发。
工程上很多系统会采用混合方案:
网关做粗粒度全局限流
服务本地做细粒度限流
下游客户端再做保护性限流
限流后怎么处理
限流不是只会返回错误。
常见处理方式:
- 直接拒绝:返回
429 Too Many Requests。 - 排队等待:适合后台任务,不适合强交互请求。
- 降级返回:返回缓存、默认结果、热门结果。
- 延迟重试:告诉客户端稍后再试。
- 动态降级:关闭非核心功能,保核心链路。
推荐系统里的例子:
个性化召回限流
-> 返回热门召回
精排模型服务限流
-> 使用粗排结果兜底
实时特征服务限流
-> 使用离线特征或默认特征
用户侧不一定要看到“系统繁忙”,可以返回一个质量略低但可用的结果。
限流和熔断、降级的区别
这三个概念经常一起出现。
| 概念 | 解决什么问题 |
|---|---|
| 限流 | 流量太大,主动控制进入系统的请求量 |
| 熔断 | 下游持续失败,暂时停止调用,避免故障扩散 |
| 降级 | 牺牲非核心能力,保证核心功能可用 |
一句话区分:
限流处理流量过大,熔断处理依赖故障,降级处理能力不足或依赖不可用后的兜底体验。
面试怎么选型
可以直接背这张表:
| 场景 | 推荐算法 |
|---|---|
| 简单接口限流 | 固定窗口 |
| 单用户精确限流 | 滑动日志 |
| 大多数接口 QPS 限流 | 滑动窗口计数器 |
| 需要稳定输出保护下游 | 漏桶 |
| 允许突发的在线请求 | 令牌桶 |
| 保护线程池 / 连接池 / GPU | 并发数限制 |
| 多实例全局限流 | Redis / 网关 / 配额下发 |
高频面试回答:
固定窗口简单但有边界突刺;滑动日志精确但内存开销大;滑动窗口计数器是折中方案;漏桶强调稳定输出,适合保护下游;令牌桶允许突发,同时控制平均速率,是在线服务最常见的选择;如果瓶颈是连接池、线程池或 GPU 资源,还需要配合并发数限制。
系统设计里怎么说
如果面试官问“如何设计一个高并发接口的限流”,可以按下面结构回答:
1. 先明确限流目标
全局 QPS、单用户 QPS、接口 QPS、下游 QPS、并发数?
2. 选择限流位置
网关、本服务、本地客户端、下游入口?
3. 选择算法
在线请求用令牌桶
下游保护用漏桶或并发数限制
用户维度用滑动窗口
4. 设计限流后的处理
返回 429、排队、降级、缓存兜底、热门结果兜底
5. 做监控和动态配置
限流次数、通过率、拒绝率、P99、下游错误率、配置热更新
不要只讲算法,要讲完整工程闭环。
最后总结
限流算法可以这样记:
固定窗口:简单,但边界突刺。
滑动日志:精确,但内存开销大。
滑动窗口计数器:平滑和成本之间的折中。
漏桶:稳定输出,保护下游。
令牌桶:允许突发,在线服务常用。
并发数限制:保护线程池、连接池、GPU 等有限资源。
工程里不会只用一种限流。
更常见的是:
网关全局限流
+ 服务本地限流
+ 用户维度限流
+ 下游客户端限流
+ 并发数限制
+ 降级兜底
限流的本质不是拒绝请求,而是让系统在流量超过承载能力时,仍然能稳定、可控、优先保证核心链路。