限流的本质:从限流算法到分布式流控的架构思考
一、为什么你的系统需要限流
每个系统都有容量边界。缓存解决读的问题,降级解决非核心链路的问题,但当写操作高并发、稀缺资源被争抢、昂贵查询集中涌入时——只有限流能保护你。
| 手段 | 解决的问题 | 核心机制 | 局限性 |
|---|---|---|---|
| 缓存 | 提速 | 将高频数据放入更快的存储层 | 对写操作无能为力 |
| 降级 | 止损 | 放弃非核心功能保核心链路 | 前提是有东西可降,秒杀场景无法降级 |
| 限流 | 控流 | 主动丢弃/延迟超量请求 | 需要准确的容量评估,否则误杀或漏放 |
但"限流"这两个字过于笼统。请求涌入太快是限流问题,同时处理的请求太多也是限流问题,同样叫限流,控制的东西完全不同。限流不是一个算法,而是一套控制体系。 要选对方案,首先要搞清楚:你到底在控制什么?
二、你到底在控制什么——四种限流模型
大多数人一提"限流"就想到令牌桶、漏桶。但算法只是手段,在选算法之前要先回答一个更根本的问题:你要控制的是什么物理量?
现实中的限流需求可以归纳为四种控制模型,每种控制着不同的"物理量",适用不同的场景,也对应不同的算法家族:
到达速率控制——控制"多快进来"
本质:单位时间内允许通过的请求数量。
典型场景:API 接口限制每秒 1000 次调用、用户登录接口限制每分钟 5 次尝试、短信验证码 60 秒内只能发一次。
这是最常见的限流需求。它的核心关注点是"单位时间的请求数"——不管每个请求要跑多久、占多少资源,只要单位时间内的数量不超标就放行。
并发占用控制——控制"同时多少个"
本质:任意时刻正在处理的请求数量。
典型场景:数据库连接池最多 50 个连接、报表导出接口最多同时执行 3 个、文件上传同时只允许 10 个。
与速率控制的区别:速率控制不关心每个请求"待多久",并发控制则相反——一个跑 10 分钟的报表任务,速率限制器根本管不住它。如果你有 10 个这样的任务同时运行,速率限制器显示"每秒只进来 1 个"一切正常,但系统已经被压垮了。
长期配额控制——控制"总共多少次"
本质:一个较长周期内允许消耗的总量。
典型场景:免费用户每天 100 次 API 调用、每月 10GB 流量配额、每个租户每月 100 万次查询。
配额控制关注的是"累计消耗",时间尺度从小时到月不等。它与速率控制看似相似(都是"一段时间内的请求数"),但有本质区别:速率控制关注的是"瞬时压力"——保护系统不被打垮;配额控制关注的是"商业资源"——控制成本或实现产品差异化。一个配额为每天 1000 次的用户,完全可以在第一秒就用完所有配额,速率限制器不会拦他。
执行节奏控制——控制"多快出去"
本质:请求被处理和释放的速率,确保输出均匀平稳。
典型场景:消息队列消费速率控制、音视频流恒定码率传输、对接物理设备接口(打印机、传感器)。
前面三种都是在"入口"做控制:请求来了,判断能不能进。节奏控制不同,它控制的是"出口"——请求已经被接受,但要排队按固定节奏释放。即使系统空闲、令牌充裕,也不会加速处理。
为什么不能互相替代
| 控制模型 | 控制的物理量 | 如果只用速率限制… |
|---|---|---|
| 到达速率 | 单位时间请求数 | ✅ 这正是它干的事 |
| 并发占用 | 同时在处理的请求数 | ❌ 10 个慢请求各跑 10 分钟,速率上看只有"1 个/分钟",但并发已爆 |
| 长期配额 | 累计消耗总量 | ❌ 速率 100/s 的限制管不住"每天只许用 1000 次"的商业规则 |
| 执行节奏 | 输出的均匀程度 | ❌ 令牌桶允许突发消费,下游设备收到脉冲流量就炸了 |
需求 → 算法决策总表
在进入具体算法之前,先给出一张导航图。后续章节会逐一展开每种算法的原理和实现:
| 你的需求 | 控制模型 | 推荐算法 | 章节 |
|---|---|---|---|
| API 限制每秒 N 次调用 | 到达速率 | 固定窗口 / 滑动窗口计数器 | 3.1 |
| 精确统计每个请求的时间分布 | 到达速率 | 滑动窗口日志 | 3.1 |
| 允许突发但限制平均速率 | 突发 + 速率 | 令牌桶 / GCRA | 3.2 |
| 下游绝对不能承受波动 | 执行节奏 | 漏桶 | 3.3 |
| 限制同时处理的请求数 | 并发占用 | 信号量 / Bulkhead | 3.4 |
| 每天/每月 N 次调用额度 | 长期配额 | 固定窗口长周期 / 滚动配额 | 3.5 |
三、限流算法详解与工程实现
下面给出五种经典限流算法的 Java 实现——纯 JDK、per-key、线程安全,不依赖任何第三方库。所有实现遵循统一接口:
interface RateLimiter {
boolean allow(String key);
}
3.1 速率控制家族——控制"多快进来"
这一族算法的共同目标:在一个时间窗口内,限制请求的通过数量。区别在于如何定义和计算"窗口"。
固定窗口计数器(Fixed Window Counter)
核心原理
在一个固定时间窗口内维护计数器,超过阈值就拒绝,窗口结束时归零。
|← 窗口1 (0-1s) →|← 窗口2 (1-2s) →|
count=0→100 count=0→...
阈值=100 阈值=100
Java 实现
固定窗口和滑动窗口共用一个 Window 状态类:
class Window {
long windowStart;
int count;
int preCount; // 滑动窗口用:上一个窗口的计数
Window(long windowStart) {
this.windowStart = windowStart;
}
}
class FixedWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowNanos;
private final ConcurrentHashMap<String, Window> map = new ConcurrentHashMap<>();
// limit: 窗口内最大请求数, windowMillis: 窗口大小(毫秒)
FixedWindowRateLimiter(int limit, long windowMillis) {
this.limit = limit;
this.windowNanos = windowMillis * 1_000_000L;
}
@Override
public boolean allow(String key) {
long now = System.nanoTime();
Window w = map.computeIfAbsent(key, _ -> new Window(now));
synchronized (w) {
long elapsed = now - w.windowStart;
// 窗口过期:对齐到窗口边界(而不是 windowStart = now)
if (elapsed >= windowNanos) {
long periods = elapsed / windowNanos;
w.windowStart += periods * windowNanos;
w.count = 0;
}
if (w.count < limit) {
w.count++;
return true;
}
return false;
}
}
}
注意窗口过期时用 windowStart += periods * windowNanos 对齐到窗口边界,而不是直接 windowStart = now。后者会导致窗口漂移——每次重置都把窗口起点推到当前时间,使得窗口大小不再固定。
经典问题:窗口边界的 2 倍峰值
|← 窗口1 →|← 窗口2 →|
↑
最后100ms涌入100个 最前100ms涌入100个
→ 200ms 内实际通过了 200 个请求(2 倍于阈值)
适用场景
- 精度要求不高的简单限流(大部分业务场景)
- 需要快速实现的场景
- 阈值本身留有足够余量(2 倍偶发峰值可承受)
工程判断:在很多场景中,固定窗口的精度已经足够。边界处偶尔的 2 倍峰值,对于留有余量的系统来说不是问题。不要为理论上的完美过度工程化。
滑动窗口计数器(Sliding Window Counter)
核心原理
固定窗口的边界问题源于"硬切割"——窗口一旦翻页,上个窗口的计数彻底清零。滑动窗口计数器的思路是:保留上一个窗口的计数,用加权平均来近似"滑动"效果。
|← 上一个窗口 →|← 当前窗口 →|
preCount=80 count=20
↑ now(已过 30% 窗口)
估算值 = count + preCount × (1 - 30%) = 20 + 80 × 0.7 = 76
窗口刚翻页时(elapsed ≈ 0),上个窗口的权重接近 100%,相当于还在"旧窗口"里计数;窗口快结束时(elapsed ≈ windowSize),上个窗口的权重接近 0%,退化为固定窗口。这种线性插值在大多数场景下精度足够,且存储开销和固定窗口一样是 O(1)。
与固定窗口的对比
| 维度 | 固定窗口 | 滑动窗口计数器 |
|---|---|---|
| 精度 | 存在边界 2 倍峰值 | 加权平滑,消除边界效应 |
| 实现复杂度 | 一个计数器 | 两个计数器 + 加权计算 |
| 存储开销 | O(1) | O(1) |
| 适用场景 | 精度要求低、快速实现 | 精度要求高、阈值接近系统极限 |
Java 实现
class SlidingWindowRateLimiter implements RateLimiter {
private final int limit;
private final long windowNanos;
private final ConcurrentHashMap<String, Window> map = new ConcurrentHashMap<>();
SlidingWindowRateLimiter(int limit, long windowMillis) {
this.limit = limit;
this.windowNanos = windowMillis * 1_000_000L;
}
@Override
public boolean allow(String key) {
long now = System.nanoTime();
Window w = map.computeIfAbsent(key, _ -> new Window(now));
synchronized (w) {
long elapsed = now - w.windowStart;
if (elapsed >= windowNanos) {
long periods = elapsed / windowNanos;
// 跨了 2 个以上窗口,上个窗口数据已无意义
w.preCount = (periods >= 2) ? 0 : w.count;
w.count = 0;
w.windowStart += periods * windowNanos;
elapsed = now - w.windowStart;
}
// 加权估算:当前计数 + 上一窗口计数 × 未过期比例
double weight = (double) elapsed / windowNanos;
double estimated = w.count + w.preCount * (1.0 - weight);
if (estimated < limit) {
w.count++;
return true;
}
return false;
}
}
}
工程实践:更精细的子窗口方案
上面的两窗口加权方案简洁高效,在大多数场景下已足够。如果需要更精细的滑动效果,可以将窗口划分为多个子窗口(slot),例如阿里巴巴的 Sentinel 框架使用 LeapArray 数据结构:
- 将 1 秒划分为若干个
WindowWrap(默认 2 个,即 500ms 一个子窗口) - 每个子窗口维护独立的 pass/block/exception 等计数器
- 通过环形数组 + 时间戳判断实现窗口滑动,避免频繁创建销毁对象
存储开销从 O(1) 变为 O(N)(N 为子窗口数),精度更高,但实现复杂度也相应增加。
滑动窗口日志(Sliding Window Log)
核心原理
记录每一个请求的精确时间戳,判断时移除窗口外的过期记录,然后统计剩余记录数。这是精度最高的速率控制算法——没有子窗口的近似,每个请求的时间位置都被精确记录。
窗口大小 = 1s,阈值 = 5
请求日志: [0.1, 0.3, 0.5, 0.8, 0.9]
↑ 当前时间 = 1.2s
移除 < 0.2 的记录 → [0.3, 0.5, 0.8, 0.9]
当前窗口内 4 个请求 < 阈值 5 → 放行,记录 1.2
伪代码
def sliding_log_allow(key, threshold, window_size):
now = current_time()
# 移除窗口外的过期记录
store.remove_before(key, now - window_size)
# 统计当前窗口内的请求数
count = store.count(key)
if count < threshold:
store.add(key, now) # 记录本次请求时间戳
return True
return False
优势与代价
| 维度 | 说明 |
|---|---|
| 精度 | 完美——没有任何窗口边界问题 |
| 存储开销 | O(N),N 为窗口内的请求数。QPS 1000 + 1s 窗口 = 1000 条记录 |
| 适用场景 | 低 QPS + 高精度要求(如 API 计费、安全审计) |
| 不适用 | 高 QPS 场景——存储和清理开销过大 |
工程判断:滑动窗口日志的精度是三种窗口算法中最高的,但存储成本也最高。在 Redis 中通常用 Sorted Set 实现(第四章会详细展示)。对于大多数业务场景,滑动窗口计数器是更好的平衡点。
3.2 突发与平均速率——控制"允许多大的脉冲"
窗口类算法有一个共同的局限:它们只看"窗口内的总量",不区分"均匀到达"和"一瞬间全来"。令牌桶和 GCRA 解决的正是这个问题——允许一定程度的突发,但限制长期平均速率。
令牌桶算法(Token Bucket)
核心原理
令牌桶的理念:在空闲时积蓄能力,在繁忙时释放能力。
令牌生成器 ──恒定速率──→ [ 令牌桶(有容量上限) ]
↓
请求到达 → 取令牌 → 有令牌则通过
→ 无令牌则拒绝/等待
- 系统以恒定速率向桶中放入令牌
- 每个请求消耗一个(或多个)令牌
- 令牌充足时请求立即通过
- 令牌耗尽时请求被拒绝或阻塞等待
- 桶有容量上限,多余令牌溢出
核心参数
| 参数 | 含义 | 设计考量 |
|---|---|---|
| 令牌生成速率 | 系统的持续处理能力 | 对应系统稳态吞吐上限 |
| 桶容量 | 允许的最大突发量 | 编码了对突发流量的容忍度 |
Java 实现
class TokenBucketRateLimiter implements RateLimiter {
private final double capacity; // 桶容量(最大突发量)
private final double refillPerNano; // 每纳秒补充的令牌数
private final boolean warmUp; // true = 冷启动从 0 开始
private static class Bucket {
double tokens;
long lastRefillTime;
}
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
/**
* @param permitsPerSecond 每秒放入的令牌数
* @param burst 桶容量(最大突发量)
* @param warmUp true = 新 key 从 0 令牌开始(冷启动)
*/
TokenBucketRateLimiter(double permitsPerSecond, int burst, boolean warmUp) {
this.capacity = burst;
this.refillPerNano = permitsPerSecond / 1_000_000_000.0;
this.warmUp = warmUp;
}
@Override
public boolean allow(String key) {
long now = System.nanoTime();
Bucket b = map.computeIfAbsent(key, _ -> {
Bucket bucket = new Bucket();
bucket.tokens = warmUp ? 0 : capacity; // 冷启动 vs 满桶启动
bucket.lastRefillTime = now;
return bucket;
});
synchronized (b) {
long elapsed = now - b.lastRefillTime;
if (elapsed > 0) {
// 懒填充:按经过的时间补充令牌
b.tokens = Math.min(capacity, b.tokens + elapsed * refillPerNano);
b.lastRefillTime = now;
}
if (b.tokens >= 1.0) {
b.tokens -= 1.0;
return true;
}
return false;
}
}
}
关键实现细节:"懒填充"(lazy refill)。不需要一个后台线程不断往桶里放令牌,只需在每次请求到来时,根据距上次填充的时间差计算应补充的令牌数。这让实现变得高效。warmUp 参数控制新 key 是从满桶开始(适合 API 限流)还是从空桶开始(适合缓存预热)。
适用场景
- 互联网 API 限流(绝大多数场景的首选)
- 允许合理突发的业务场景(秒杀、热点事件引发的流量脉冲)
- 需要区分长期速率和瞬时峰值的场景
工程实践:Guava RateLimiter 的两种模式
Guava 提供了两种令牌桶实现,对应两种不同的业务需求:
// 模式一:SmoothBursty —— 允许突发
// 以每秒 100 个令牌的速率生成,桶容量等于 1 秒的产量(100)
RateLimiter limiter = RateLimiter.create(100.0);
// 场景:API 网关限流
// 特点:空闲期积累的令牌可以一次性消费,应对突发
if (limiter.tryAcquire()) {
processRequest();
} else {
return Response.status(429).build();
}
// 模式二:SmoothWarmingUp —— 冷启动预热
// 速率 100/s,预热期 3 秒
RateLimiter limiter = RateLimiter.create(100.0, 3, TimeUnit.SECONDS);
// 场景:数据库连接池、缓存冷启动
// 特点:系统刚启动时不会全速放量,给下游一个"热身"时间
// 预热期内速率从低到高线性增长,避免冷系统被瞬时流量打垮
SmoothBursty vs SmoothWarmingUp 的选择
| 维度 | SmoothBursty | SmoothWarmingUp |
|---|---|---|
| 突发处理 | 允许消费积累的令牌,支持突发 | 冷启动期间限制突发 |
| 典型场景 | API 限流、消息推送 | 数据库预热、缓存预热 |
| 核心关注 | 流量的峰谷平衡 | 系统的冷热状态转换 |
关键注意:Guava RateLimiter 是单机限流。它只能控制当前 JVM 进程的流量,在分布式环境下需要配合 Redis 方案使用。
GCRA(Generic Cell Rate Algorithm)
核心原理
GCRA 是令牌桶的数学等价形式,但实现更精简。它不维护"当前令牌数",而是维护一个"理论到达时间"(TAT,Theoretical Arrival Time)——下一个请求最早应该在什么时候到达。
核心思想:如果请求到达得比预期频率更快,TAT 会不断后推;如果请求到达得比预期慢,TAT 会被拉回到当前时间附近(但不会超前太多,受突发容量限制)。
参数:
emission_interval = 1/rate -- 每个请求的理论间隔
burst_tolerance = burst * emission_interval -- 允许的最大提前量
判断逻辑:
TAT = max(TAT, now) + emission_interval
如果 TAT - now > burst_tolerance → 拒绝(超前太多,突发已耗尽)
否则 → 放行,更新 TAT
Java 实现
与前面的算法不同,GCRA 使用 AtomicLong + CAS 实现无锁设计,天然适合高并发场景:
class GcraRateLimiter implements RateLimiter {
private final long T; // 请求间隔 (ns)
private final long tau; // 突发容忍窗口 (ns) = burstPermits × T
// 无锁设计:CAS 自旋
private final ConcurrentHashMap<String, AtomicLong> tatByKey = new ConcurrentHashMap<>();
GcraRateLimiter(double permitsPerSecond, int burstPermits) {
this.T = (long) (TimeUnit.SECONDS.toNanos(1) / permitsPerSecond);
this.tau = burstPermits * T;
}
@Override
public boolean allow(String key) {
long now = System.nanoTime();
AtomicLong tatRef = tatByKey.computeIfAbsent(key, _ -> new AtomicLong(now));
while (true) {
long tat = tatRef.get();
if (now < tat - tau) {
return false; // 请求来得太早,拒绝
}
long newTat = Math.max(now, tat) + T;
if (tatRef.compareAndSet(tat, newTat)) {
return true;
}
Thread.onSpinWait(); // CAS 失败,降低自旋 CPU 开销
}
}
}
判断逻辑先于更新执行:now < tat - tau 直接拒绝,避免了"先更新 TAT 再判断是否超限"的回滚问题。Thread.onSpinWait()(Java 9+)在 CAS 失败时降低 CPU 空转开销。
为什么 GCRA 值得关注
| 优势 | 说明 |
|---|---|
| 状态极简 | 只需存储一个值(TAT),对比令牌桶需要存 tokens + last_refill |
| 天然适合分布式 | 一个 Redis key 存一个时间戳,原子 CAS 即可更新 |
| 数学精确 | 与令牌桶行为完全等价,但无浮点累积误差 |
工程实践:GCRA 广泛用于网络设备的 ATM 流量控制(这也是它名字中"Cell Rate"的来源),在互联网领域被 Cloudflare、Stripe 等公司采用作为 API 限流的核心算法。
3.3 流量整形——控制"多快出去"
漏桶算法(Leaky Bucket)
核心原理
漏桶的逻辑可以用一句话概括:无论流入多快,流出永远恒定。
请求流入 → [ 桶(有容量上限) ] → 恒定速率流出 → 下游处理
↓
桶满则丢弃
- 请求以任意速率流入桶中
- 桶底以固定速率流出(处理请求)
- 桶有容量上限,溢出的请求被直接丢弃
核心参数
| 参数 | 含义 | 设计考量 |
|---|---|---|
| 流出速率 | 下游能承受的恒定处理能力 | 取决于下游系统的稳态吞吐上限 |
| 桶容量 | 允许暂存的最大请求数 | 过大导致延迟积累,过小导致突发流量全被丢弃 |
Java 实现
下面的实现用"下一次允许通过的时间"来建模漏桶的恒定流出:每放行一个请求,nextAllowedTime 就往后推一个 intervalNanos。如果请求到达时已经超前太多(超出 burst 容忍量),直接拒绝。
class LeakyBucketRateLimiter implements RateLimiter {
private final long intervalNanos; // 每个请求的理论间隔
private final long burstNanos; // 突发容忍量(纳秒)
private static class Bucket {
long nextAllowedTime;
Bucket(long t) { this.nextAllowedTime = t; }
}
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
LeakyBucketRateLimiter(double permitsPerSecond, int burstPermits) {
this.intervalNanos = (long) (TimeUnit.SECONDS.toNanos(1) / permitsPerSecond);
this.burstNanos = burstPermits * intervalNanos;
}
@Override
public boolean allow(String key) {
long now = System.nanoTime();
Bucket b = map.computeIfAbsent(key, _ -> new Bucket(now));
synchronized (b) {
long allowAt = b.nextAllowedTime - burstNanos;
if (now < allowAt) {
return false; // 桶满了,拒绝
}
b.nextAllowedTime = Math.max(now, b.nextAllowedTime) + intervalNanos;
return true;
}
}
}
当 burstPermits = 0 时,漏桶不允许任何突发,请求严格按 intervalNanos 的间隔逐个放行——这正是"恒定速率流出"的语义。
与令牌桶的本质区别
令牌桶控制的是"允许进入的速率"(入口),漏桶控制的是"实际处理的速率"(出口)。令牌桶在空闲后可以突发放行一批请求,漏桶永远不会——即使桶里积攒了很多请求,也只能按固定速率一个个出去。
适用场景
- 对接物理设备或硬件接口(严格不允许任何突发)
- 需要绝对平滑的输出流量(如音视频流的恒定码率传输)
- 流量整形(traffic shaping)场景
不适用场景
- 互联网业务的 API 限流(真实流量天然是突发的,漏桶的死板会浪费系统空闲容量)
- 需要快速响应突发请求的场景
工程实践:Nginx 的 limit_req 就是漏桶实现
# 定义限流区域:10MB 共享内存,每个 IP 每秒 10 个请求
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
# burst=20:桶容量为 20,超出的排队
# nodelay:排队请求不延迟,立即处理(占用 burst 配额)
limit_req zone=api burst=20 nodelay;
# 超限返回 429 而非默认的 503
limit_req_status 429;
}
}
这里有个常见误区:burst=20 nodelay 不是"允许突发 20 个请求"那么简单。nodelay 的含义是突发请求立即转发(不排队等待),但每个突发请求会"占用"一个 burst 槽位,槽位按 rate 的速率恢复。实际效果是:瞬间可以通过 30 个请求(rate + burst),但之后必须等槽位恢复。
3.4 并发控制——控制"同时多少个"
前面所有算法都在控制"速率"——单位时间通过多少个请求。但有些场景下,速率不是瓶颈,并发才是。
问题场景:一个报表导出接口,每次调用需要 30 秒完成,消耗大量 CPU 和内存。即使限制为每秒 1 个请求,如果 30 秒内每秒都来一个,就有 30 个同时在执行——足以打垮服务。
信号量 / Bulkhead
核心原理
维护一个并发计数器。请求进入时 +1,请求完成时 -1。计数器达到上限时,新请求被拒绝或排队。
请求进入 → 计数器 +1 → [正在处理:当前 3/5] → 完成 → 计数器 -1
↓
计数器 = 5 → 拒绝/排队
伪代码
class ConcurrencyLimiter:
def __init__(self, max_concurrent):
self.max_concurrent = max_concurrent
self.current = 0 # 当前并发数
self.lock = Lock()
def acquire(self):
with self.lock:
if self.current >= self.max_concurrent:
return False
self.current += 1
return True
def release(self):
with self.lock:
self.current -= 1
关键区别:为什么速率限制替代不了并发控制?
| 场景 | 速率限制(10 req/s) | 并发控制(max=5) |
|---|---|---|
| 快请求(10ms) | 正常工作 | 不会触发(并发始终很低) |
| 慢请求(30s) | 30s 内放入 300 个请求,全部同时在跑 | 只允许 5 个同时执行,第 6 个等待 |
| 资源保护效果 | 慢请求场景下完全失效 | 精确保护下游并发能力 |
工程实践
- Java:
Semaphore、Resilience4j 的Bulkhead - 数据库:连接池本质就是并发控制
- Nginx:
limit_conn限制并发连接数 - Hystrix/Sentinel:线程池隔离(每个下游依赖独立的并发上限)
Bulkhead(舱壁隔离)模式:把不同依赖的并发限制隔离开,A 服务的慢查询把自己的并发额度用完,不会影响 B 服务的调用。
3.5 配额控制——控制"总共多少次"
固定窗口长周期 / 滚动配额
核心原理
配额控制在技术实现上往往就是一个大窗口的固定窗口计数器——窗口大小从分钟级变成天/月级。但它的设计意图完全不同:速率控制保护系统不被打垮,配额控制实现商业规则。
典型实现
def check_quota(user_id, tier):
quotas = {
"free": {"daily": 100, "monthly": 1000},
"pro": {"daily": 10000, "monthly": 100000},
}
daily_key = f"quota:daily:{user_id}:{today()}"
monthly_key = f"quota:monthly:{user_id}:{this_month()}"
daily_count = store.get(daily_key, 0)
monthly_count = store.get(monthly_key, 0)
limits = quotas[tier]
if daily_count >= limits["daily"] or monthly_count >= limits["monthly"]:
return False, remaining(limits, daily_count, monthly_count)
store.increment(daily_key)
store.increment(monthly_key)
return True, remaining(limits, daily_count + 1, monthly_count + 1)
工程要点
- 配额通常需要返回剩余量(
X-RateLimit-Remainingheader),方便调用方规划使用 - 长周期配额的窗口边界(如月初重置)是产品决策,不是技术决策
- 配额超限的拒绝策略通常比速率限制更"温和"——返回明确的额度信息和升级引导,而非简单的 429
算法对比总表
| 算法 | 控制模型 | 核心特征 | 突发处理 | 存储开销 | 推荐场景 |
|---|---|---|---|---|---|
| 固定窗口 | 到达速率 | 简单计数 | 边界 2 倍峰值 | O(1) | 快速实现、精度要求低 |
| 滑动窗口计数器 | 到达速率 | 双窗口加权 | 平滑 | O(1) | 精度要求高、阈值紧 |
| 滑动窗口日志 | 到达速率 | 精确时间戳 | 完美 | O(N) 请求数 | 低 QPS + 高精度 |
| 令牌桶 | 突发 + 速率 | 积蓄+释放 | 允许有限突发 | O(1) | API 限流(首选) |
| GCRA | 突发 + 速率 | 单时间戳 | 允许有限突发 | O(1) | 分布式 API 限流 |
| 漏桶 | 执行节奏 | 恒定输出 | 不允许突发 | O(1) | 流量整形、硬件接口 |
| 信号量 | 并发占用 | 进出计数 | 不涉及 | O(1) | 慢请求、连接池 |
| 固定窗口长周期 | 长期配额 | 累计统计 | 不涉及 | O(1) | 商业配额、计费 |
选择策略:先确定你的控制模型(速率/并发/配额/节奏),再在对应的算法家族中选择。如果没有特殊需求,令牌桶是互联网业务的默认选择。
四、从单机到分布式:最关键的认知跃迁
单机限流为什么在集群中失效
一个团队用 Guava RateLimiter 限制短信 API 调用为 400 QPS,本地测试完美。代码部署到 4 个节点后,4 个节点各自以 400 QPS 发送,服务商实际承受 1600 QPS,接口再次崩溃。
根因:单机限流只能控制单个进程的流量,对其他节点一无所知。
直觉的修复是均分配额:4 个节点各分 100 QPS。但这引入新问题:
理想中:
节点A: 100 QPS → 25%
节点B: 100 QPS → 25%
节点C: 100 QPS → 25%
节点D: 100 QPS → 25%
现实中(负载不均):
节点A: 240 QPS → 只放行 100,拒绝 140 ✗
节点B: 120 QPS → 只放行 100,拒绝 20 ✗
节点C: 30 QPS → 只用了 30,浪费 70
节点D: 10 QPS → 只用了 10,浪费 90
总放行:240 QPS(理论可放 400,实际只放了 240)
→ 系统实际吞吐远低于理论上限
动态调整配额(根据节点负载实时重新分配)?复杂度爆炸——你需要协调机制感知节点上下线、收集实时负载、计算下发配额,这本身就是一个分布式系统问题。
标准答案:将限流状态提升到共享的集中存储中。
分布式限流的核心原则
限流的粒度决定了它的准确性。
| 保护对象 | 限流粒度 | 方案 |
|---|---|---|
| 本机 CPU/内存 | 进程级 | Guava RateLimiter、Sentinel |
| 外部 API 配额 | 系统级(全集群) | Redis 分布式计数器 |
| 业务规则(如用户发送频率) | 用户级 | Redis + 用户维度 key |
Redis 分布式限流:为什么是标准答案
Redis 之所以成为分布式限流的事实标准,是因为它的特性精确匹配了限流的每一个核心需求:
| 限流需求 | Redis 特性 | 为什么匹配 |
|---|---|---|
| 原子性:"读取-判断-递增"必须原子 | INCR 原子命令 + Lua 脚本 | 单线程模型,天然无并发冲突 |
| 极致性能:每个请求都要过限流 | 内存操作,亚毫秒级延迟 | 不成为业务瓶颈 |
| 共享状态:所有节点看到同一个计数器 | 独立服务,集群可访问 | 分布式协调问题消失 |
| 自动过期:时间窗口结束后计数器清零 | Key 级别 TTL | 无需额外清理逻辑 |
工程实践:基于 Redis + Lua 的固定窗口限流
为什么必须用 Lua 脚本?
不用 Lua 的伪代码:
count = redis.GET(key) -- 步骤1:读取
if count < threshold: -- 步骤2:判断
redis.INCR(key) -- 步骤3:递增
return ALLOW
else:
return REJECT
并发问题:两个节点同时读到 count=399(阈值 400),都判断"未超限",都执行 INCR。最终 count=401,但两个请求都通过了。高并发下,这种竞态条件被急剧放大,限流形同虚设。
Lua 脚本实现(原子操作)
-- KEYS[1]: 限流 key,如 "rate_limit:sms_api:1609459200"
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口过期时间(秒)
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local expire_time = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or "0")
if current + 1 > threshold then
return 0 -- 拒绝
else
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, expire_time)
end
return 1 -- 放行
end
Key 设计规范
格式:rate_limit:{业务标识}:{维度}:{时间窗口}
示例:
rate_limit:sms_api:global:1609459200 -- 全局短信 API 限流
rate_limit:login:user:12345:1609459200 -- 用户维度登录限流
rate_limit:order:tenant:abc:1609459200 -- 租户维度下单限流
工程实践:基于 Redis 的滑动窗口限流
当固定窗口的边界问题不可接受时,可以用 Redis Sorted Set 实现滑动窗口:
-- KEYS[1]: 限流 key
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一请求ID
local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local request_id = ARGV[4]
-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < threshold then
-- 添加当前请求,score 为时间戳
redis.call('ZADD', key, now, request_id)
redis.call('PEXPIRE', key, window)
return 1 -- 放行
else
return 0 -- 拒绝
end
两种 Redis 方案的对比
| 维度 | 固定窗口(String + INCR) | 滑动窗口(Sorted Set) |
|---|---|---|
| 存储开销 | O(1),一个 key 一个计数器 | O(N),N 为窗口内请求数 |
| 时间复杂度 | O(1) | O(log N) |
| 精度 | 边界可能 2 倍峰值 | 精确 |
| 适用 | 大部分场景 | 阈值紧、精度要求高 |
工程建议:优先用固定窗口方案。只有当阈值非常接近系统极限(余量 < 20%)时,才需要滑动窗口的精度。
关于时钟同步
分布式系统中,各节点用本地时间计算 Redis key 中的时间窗口标识,时钟偏移可能导致不同节点在不同窗口中计数。严格做法是用 Redis 服务端时间 redis.call('TIME')。但现代服务器通过 NTP 同步后的时钟偏差通常在毫秒级,对秒级窗口几乎无影响。
工程判断:对于秒级窗口,使用本地时间戳即可。对于百毫秒级窗口或对精度有极端要求的场景,使用 Redis 服务端时间。
五、多层限流:纵深防御架构
一个常见误区是试图在某一层解决所有限流问题。良好的限流架构应该是分层的——每一层保护不同的东西,承担不同的职责。
请求流入
↓
┌──────────────────────────────────────────┐
│ 第一层:接入层(Nginx / CDN) │ ← 挡住恶意流量和 DDoS
│ 基于 IP 的连接数和请求速率限制 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第二层:API 网关(Gateway) │ ← 业务感知型限流
│ 基于用户/租户/API 维度的差异化限流 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第三层:业务层 │ ← 业务规则型限流
│ 业务语义的频率控制(发帖/下单/发短信) │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 第四层:数据层 │ ← 最后一道防线
│ 连接池 / 线程池隔离 / 熔断器 │
└──────────────────────────────────────────┘
各层详细对比
| 层级 | 保护对象 | 限流维度 | 典型工具 | 算法 |
|---|---|---|---|---|
| 接入层 | 基础设施 | IP、连接数 | Nginx limit_req/limit_conn |
漏桶 |
| API 网关 | 服务处理能力 | 用户 ID、API Key、租户 | Redis + Lua、Sentinel | 令牌桶/滑动窗口 |
| 业务层 | 业务规则 | 业务实体(用户行为频率) | Redis + 业务代码 | 固定窗口 |
| 数据层 | 存储和依赖 | 并发连接数 | 连接池、Hystrix、Resilience4j | 信号量/熔断 |
各层工程实践
接入层:Nginx 配置示例
http {
# IP 维度的请求速率限制
limit_req_zone $binary_remote_addr zone=ip_rate:10m rate=100r/s;
# IP 维度的并发连接数限制
limit_conn_zone $binary_remote_addr zone=ip_conn:10m;
server {
# API 接口:每 IP 100r/s,突发 50
location /api/ {
limit_req zone=ip_rate burst=50 nodelay;
limit_conn ip_conn 50;
limit_req_status 429;
}
# 登录接口:更严格的限制
location /api/login {
limit_req zone=ip_rate burst=5;
limit_req_status 429;
}
}
}
API 网关层:差异化限流
// 不同级别用户的限流配置
public class RateLimitConfig {
// 免费用户:60 次/分钟
// 付费用户:600 次/分钟
// 企业用户:6000 次/分钟
public int getThreshold(User user) {
return switch (user.getTier()) {
case FREE -> 60;
case PREMIUM -> 600;
case ENTERPRISE -> 6000;
};
}
// 不同 API 端点的限流配置
// 重查询接口:50 QPS
// 轻量读接口:5000 QPS
// 写操作接口:200 QPS
public int getThreshold(String endpoint) {
return switch (endpoint) {
case "/api/report/generate" -> 50; // 计算密集
case "/api/user/info" -> 5000; // 轻量读
case "/api/order/create" -> 200; // 写操作
default -> 1000;
};
}
}
业务层:业务规则型限流
// 业务限流的阈值来自产品需求,不是压测
public class BusinessRateLimiter {
// 防骚扰:每用户每分钟最多 5 条短信
public boolean allowSendSms(long userId) {
String key = "biz:sms:" + userId + ":" + currentMinute();
return redisRateLimiter.tryAcquire(key, 5, 60);
}
// 反垃圾:新账号 24 小时内最多发 10 条帖子
public boolean allowPost(long userId, boolean isNewAccount) {
if (!isNewAccount) return true;
String key = "biz:post:new:" + userId + ":" + today();
return redisRateLimiter.tryAcquire(key, 10, 86400);
}
// 运营策略:商家每天最多创建 100 个促销活动
public boolean allowCreatePromotion(long merchantId) {
String key = "biz:promo:" + merchantId + ":" + today();
return redisRateLimiter.tryAcquire(key, 100, 86400);
}
}
数据层:隐式限流
数据层的"限流"通常不以限流的名义出现,但本质上发挥着同样的作用:
- 连接池:连接池满时新请求排队等待 → 并发度上限
- 线程池隔离:为每个下游依赖分配独立线程池 → 故障隔离
- 熔断器:错误率超阈值时直接停止调用 → 自适应限流
每一层保护不同的东西。 接入层保护基础设施不被滥用流量冲垮;API 网关保护服务处理能力不被超载;业务层保护业务规则不被绕过;数据层保护最脆弱的存储和依赖。
六、限流的工程闭环
限流架构设计完了,还差两个关键环节:阈值从哪来?被拒绝的请求去哪了?这两个问题不解决,限流就是半成品。
6.1 阈值从哪来
所有限流工程中最难的问题不是技术实现,而是:阈值应该设多少?
四步确定阈值
| 步骤 | 方法 | 产出 |
|---|---|---|
| 1. 压测基线 | 逐步加压,观察 P99 延迟和错误率的拐点 | 系统实际容量边界 |
| 2. 安全系数 | 阈值 = 容量边界 × 70%~80% | 留出余量应对突发波动 |
| 3. 持续监控 | 监控 P99、错误率、CPU、内存 | 发现容量变化及时调整 |
| 4. 渐进调整 | 从保守值开始,观察线上表现后逐步放宽 | 避免上线即翻车 |
自适应限流
更高级的形态是基于实时指标的自动限流。以 Sentinel 为例:
// 基于系统负载的自适应限流
SystemRule rule = new SystemRule();
rule.setHighestCpuUsage(0.8); // CPU > 80% 时触发限流
rule.setHighestSystemLoad(2.5); // System Load > 2.5 时触发限流
rule.setAvgRt(200); // 平均 RT > 200ms 时触发限流
// 优点:省去人为猜测阈值
// 风险:正常流量波动可能触发误限,需仔细调试灵敏度
阈值是业务决策
限流阈值不是纯技术参数,而是一个业务决策。
它编码的是"我们愿意承受多大负载,以及拒绝超额流量的业务成本是什么"。
- 面向消费者的核心交易链路:拒绝一个请求 = 损失一笔订单 → 阈值宜宽
- 内部数据分析任务:晚执行几分钟无损失 → 阈值可严
- 计算密集的报表接口:单个请求消耗大量资源 → 阈值必须严
阈值设定必须综合技术容量和业务容忍度,需要工程团队和产品团队协同决策。
6.2 被拒绝的请求去哪了
大多数限流讨论都集中在"如何拒绝",很少有人思考"拒绝之后怎么办"。而在真实业务中,后者往往更重要。
| 策略 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 直接拒绝 | 返回 429 + Retry-After | 开放 API、程序化调用方 | 用户体验差 |
| 排队等待 | 写入 MQ,消费者限速消费 | 异步操作(短信、邮件、报表) | 队列积压导致延迟不可控 |
| 降级响应 | 返回缓存/兜底数据 | 推荐、搜索、详情页非核心模块 | 数据时效性降低 |
| 引流分担 | 导向备用路径(CDN/只读副本) | 读多写少的场景 | 需要备用链路的维护成本 |
关键原则:限流策略和拒绝策略必须配套设计。
回到短信发送事故:被限流的短信不能直接丢弃,必须进入重试队列。秒杀请求被限流?直接告知"已售罄"比让用户苦等体验更好。商品详情页被限流?返回缓存数据即可,用户感知的是"数据没那么新"而不是"服务挂了"。
只设计了限流而没考虑拒绝后的处理,就像只安装了闸门却没修泄洪渠——水是拦住了,但迟早会溃坝。
七、金融场景:限流 ≠ 正确性
在金融、支付等对正确性有极高要求的领域,限流只是防御体系的一环。很多团队犯的错误是:觉得"加了限流就安全了"。现实是,限流解决的是流量问题,不是正确性问题。
三层防护:限流 + 并发控制 + 幂等
考虑一个支付场景:用户点了两次"付款"按钮。
| 防护层 | 解决的问题 | 如果只有这一层 |
|---|---|---|
| 限流 | 防止支付接口被高频调用打垮 | 两次点击间隔 100ms,速率限制 10/s → 都放行,扣两次款 |
| 并发控制 | 同一笔订单同一时刻只允许一个支付请求在处理 | 第二次被排队/拒绝,但如果第一次失败后重试呢? |
| 幂等 | 同一笔支付操作无论执行几次,结果只生效一次 | 无论重试多少次、并发多少个,最终只扣一次款 |
三层必须配合使用:
- 限流是外围护栏——挡住异常流量,保护系统不被打垮
- 并发控制是执行调度——同一资源同一时刻只有一个操作在执行
- 幂等是正确性保障——即使前两层被突破,最终结果仍然正确
伪代码:PaymentProtection 组合示例
class PaymentProtection:
def __init__(self):
self.rate_limiter = TokenBucket(rate=100, capacity=200) # 限流
self.locks = DistributedLockManager() # 并发控制
self.idempotency = IdempotencyStore() # 幂等
def process_payment(self, order_id, idempotency_key, amount):
# 第一层:限流 —— 保护系统不被打垮
if not self.rate_limiter.allow():
return Error("RATE_LIMITED", "系统繁忙,请稍后重试")
# 第二层:幂等检查 —— 如果这个操作已经成功过,直接返回之前的结果
existing = self.idempotency.get(idempotency_key)
if existing:
return existing # 重复请求,返回之前的结果
# 第三层:并发控制 —— 同一订单同一时刻只处理一个支付请求
lock = self.locks.acquire(f"payment:{order_id}", timeout=10)
if not lock:
return Error("CONCURRENT", "订单正在处理中")
try:
# 再次检查幂等(拿到锁之后的 double-check)
existing = self.idempotency.get(idempotency_key)
if existing:
return existing
# 执行实际支付
result = do_payment(order_id, amount)
# 记录幂等结果
self.idempotency.store(idempotency_key, result)
return result
finally:
lock.release()
核心认知:限流是流量层面的保护,幂等才是业务正确性的最后防线。在金融场景中,这三层缺一不可。
八、总结:限流是一种系统思维
限流从表面看是算法选择题,但真正落地到生产环境时,它是一个系统设计问题:
| 维度 | 核心问题 |
|---|---|
| 控制对象 | 你在控制什么?速率、并发、配额还是节奏?控制模型选错,算法再精妙也解决不了问题 |
| 容量 | 系统到底能承受多少?需要压测和监控,不是拍脑袋 |
| 优先级 | 必须拒绝时,拒绝谁?VIP vs 普通、核心 vs 边缘、写 vs 读 |
| 失败模式 | 限流触发后怎么办?报错、排队、降级还是引流 |
| 权衡 | 平滑性 vs 响应性、精确性 vs 性能、简单性 vs 灵活性 |
最好的限流系统是你感觉不到它存在的系统。流量平稳时安静旁观,突增时默默吸收合理突发,真正超限时优雅拒绝——确保已接受的请求仍能正常处理。它不是一堵墙,而是一个阀门:精确控制流量进出,让系统在极端压力下保持可控、可预测、可依赖。
限流的本质,是对系统能力边界的敬畏,以及在边界之内追求最大价值的工程智慧。