一、为什么你的系统需要限流
每个系统都有容量边界。缓存解决读的问题,降级解决非核心链路的问题,但当写操作高并发、稀缺资源被争抢、昂贵查询集中涌入时——需要通过准入控制,把接受的工作限制在可处理范围内。 资源隔离、扩容和负载削减也应配合使用。
| 手段 | 解决的问题 | 核心机制 | 局限性 |
|---|---|---|---|
| 缓存 | 提速 | 将高频数据放入更快的存储层 | 不能单独保证写入容量和正确性 |
| 降级 | 止损 | 放弃非核心功能保核心链路 | 只能舍弃业务允许降级的部分,秒杀也可关闭非必要展示或功能 |
| 限流 | 控流 | 主动丢弃/延迟超量请求 | 需要准确的容量评估,否则误杀或漏放 |
但"限流"这两个字过于笼统。请求涌入太快是限流问题,同时处理的请求太多也是限流问题,同样叫限流,控制的东西完全不同。限流不是一个算法,而是一套控制体系。 要选对方案,首先要搞清楚:你到底在控制什么?
二、你到底在控制什么——四种限流模型
大多数人一提"限流"就想到令牌桶、漏桶。但算法只是手段,在选算法之前要先回答一个更根本的问题:你要控制的是什么物理量?
现实中的限流需求可以归纳为四种控制模型,每种控制着不同的"物理量",适用不同的场景,也对应不同的算法家族:
到达速率控制——控制"多快进来"
本质:单位时间内允许通过的请求数量。
典型场景: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 |
三、限流算法详解与工程实现
下面给出固定窗口、近似滑动计数、令牌桶、GCRA 和严格间隔准入的 Java 教学实现,采用 JDK 17 语法、按 key 维护状态。示例需要导入 ConcurrentHashMap、AtomicLong 等 JDK 类型;状态表未实现回收,只适用于 key 数量受控的实验。生产中需限制基数,并保证回收不会让同一个 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;
if (limit <= 0 || windowMillis <= 0) {
throw new IllegalArgumentException("positive limit and window required");
}
this.windowNanos = Math.multiplyExact(windowMillis, 1_000_000L);
if (windowNanos > Long.MAX_VALUE / 4) {
throw new IllegalArgumentException("window too large");
}
}
@Override
public boolean allow(String key) {
Window w = map.computeIfAbsent(key, ignored -> new Window(System.nanoTime()));
synchronized (w) {
long now = System.nanoTime();
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;
if (limit <= 0 || windowMillis <= 0) {
throw new IllegalArgumentException("positive limit and window required");
}
this.windowNanos = Math.multiplyExact(windowMillis, 1_000_000L);
if (windowNanos > Long.MAX_VALUE / 4) {
throw new IllegalArgumentException("window too large");
}
}
@Override
public boolean allow(String key) {
Window w = map.computeIfAbsent(key, ignored -> new Window(System.nanoTime()));
synchronized (w) {
long now = System.nanoTime();
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 + 1.0 <= 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) {
if (!Double.isFinite(permitsPerSecond) || permitsPerSecond <= 0 || burst < 1) {
throw new IllegalArgumentException("positive finite rate and burst required");
}
this.capacity = burst;
this.refillPerNano = permitsPerSecond / 1_000_000_000.0;
this.warmUp = warmUp;
}
@Override
public boolean allow(String key) {
Bucket b = map.computeIfAbsent(key, ignored -> {
Bucket bucket = new Bucket();
bucket.tokens = warmUp ? 0 : capacity; // 冷启动 vs 满桶启动
bucket.lastRefillTime = System.nanoTime();
return bucket;
});
synchronized (b) {
long now = System.nanoTime();
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 仅控制初始令牌为零或满桶,并未实现速率逐渐提高的预热曲线;空桶开始也不能等同于缓存预热完成。
适用场景
- 互联网 API 限流(绝大多数场景的首选)
- 允许合理突发的业务场景(秒杀、热点事件引发的流量脉冲)
- 需要区分长期速率和瞬时峰值的场景
工程实践:Guava RateLimiter 的两种模式
Guava 提供了两种令牌桶实现,对应两种不同的业务需求:
// 模式一:SmoothBursty —— 允许突发
// 以每秒 100 个令牌的速率生成,桶容量等于 1 秒的产量(100)
com.google.common.util.concurrent.RateLimiter limiter =
com.google.common.util.concurrent.RateLimiter.create(100.0);
// 场景:API 网关限流
// 特点:空闲期积累的令牌可以一次性消费,应对突发
if (limiter.tryAcquire()) {
processRequest();
} else {
return Response.status(429).build();
}
// 模式二:SmoothWarmingUp —— 冷启动预热
// 速率 100/s,预热期 3 秒
com.google.common.util.concurrent.RateLimiter limiter =
com.google.common.util.concurrent.RateLimiter.create(100.0, 3, TimeUnit.SECONDS);
// 场景:数据库连接池、缓存冷启动
// 特点:系统刚启动时不会全速放量,给下游一个"热身"时间
// 预热通过改变许可的等待成本逐步提升吞吐,不应概括成速率线性增长,避免冷系统被瞬时流量打垮
Guava 的许可预留和等待行为与本文的拒绝式教学实现不同,使用时以其 API 契约为准。Guava RateLimiter
SmoothBursty vs SmoothWarmingUp 的选择
| 维度 | SmoothBursty | SmoothWarmingUp |
|---|---|---|
| 突发处理 | 允许消费积累的令牌,支持突发 | 冷启动期间限制突发 |
| 典型场景 | API 限流、消息推送 | 数据库预热、缓存预热 |
| 核心关注 | 流量的峰谷平衡 | 系统的冷热状态转换 |
关键注意:Guava RateLimiter 是单机限流。它只能控制当前 JVM 进程的流量,跨节点共享配额需要额外协调,可以采用集中决策、分片或额度租赁等方案。
GCRA(Generic Cell Rate Algorithm)
核心原理
在单位请求成本和参数对应的条件下,GCRA 可表达与令牌桶相对应的准入约束,但实现更精简。它不维护"当前令牌数",而是维护一个"理论到达时间"(TAT,Theoretical Arrival Time)——下一个请求最早应该在什么时候到达。
核心思想:如果请求到达得比预期频率更快,TAT 会不断后推;如果请求到达得比预期慢,TAT 会被拉回到当前时间附近(但不会超前太多,受突发容量限制)。
参数:
emission_interval = 1/rate -- 每个请求的理论间隔
burst_tolerance = burst * emission_interval -- 允许的最大提前量
判断逻辑:
candidate = max(TAT, now) + emission_interval
如果 candidate - now > burst_tolerance → 拒绝,保留原 TAT
否则 → 放行,将 TAT 原子更新为 candidate
Java 实现
GCRA 的每个 key 用 AtomicLong 保存理论到达时间,并通过 CAS 更新。状态表的创建仍由 ConcurrentHashMap 管理,竞争激烈时还需评估 CAS 自旋成本:
class GcraRateLimiter implements RateLimiter {
private final long intervalNanos;
private final long burstWindowNanos;
private final ConcurrentHashMap<String, AtomicLong> tatByKey = new ConcurrentHashMap<>();
GcraRateLimiter(double permitsPerSecond, int burstPermits) {
if (!Double.isFinite(permitsPerSecond)
|| permitsPerSecond <= 0 || permitsPerSecond > 1_000_000_000.0
|| burstPermits < 1) {
throw new IllegalArgumentException("invalid rate or burst");
}
double interval = Math.ceil(1_000_000_000.0 / permitsPerSecond);
if (interval > Long.MAX_VALUE / 4.0) {
throw new IllegalArgumentException("rate too small");
}
this.intervalNanos = (long) interval;
this.burstWindowNanos = Math.multiplyExact(intervalNanos, burstPermits);
if (burstWindowNanos > Long.MAX_VALUE / 4) {
throw new IllegalArgumentException("burst window too large");
}
}
@Override
public boolean allow(String key) {
AtomicLong state = tatByKey.computeIfAbsent(
key, ignored -> new AtomicLong(System.nanoTime()));
while (true) {
long previous = state.get();
long now = System.nanoTime();
long base = now - previous >= 0 ? now : previous;
long candidate = base + intervalNanos;
if (candidate - now > burstWindowNanos) {
return false;
}
if (state.compareAndSet(previous, candidate)) {
return true;
}
Thread.onSpinWait();
}
}
}
先计算 candidate,再比较 candidate 与 now 的间隔;只有通过检查才提交 CAS。burstPermits 表示空闲后同一时刻可通过的总数,设为 1 时不能额外多放一个。纳秒间隔向上取整可能让实际速率略低于配置值;Thread.onSpinWait 只是处理器提示,不是等待或公平性保证。
为什么 GCRA 值得关注
| 优势 | 说明 |
|---|---|
| 状态极简 | 只需存储一个值(TAT),对比令牌桶需要存 tokens + last_refill |
| 天然适合分布式 | 一个 Redis key 存一个时间戳,原子 CAS 即可更新 |
| 整数计时 | 使用整数间隔,避免浮点累加;纳秒量化仍有误差 |
GCRA 起源于 ATM 流量控制;Redis 扩展 redis-cell 提供了一个公开实现,可用于对照参数语义。redis-cell
3.3 流量整形——控制"多快出去"
漏桶整形与准入限制不是同一个动作
漏桶可以描述一个有界等待队列:按设定节奏释放请求,队列满则拒绝。它约束的是派发节奏,不能保证任务完成时间也均匀。
前面的 allow(key) 接口只有放行或拒绝,无法表达“已接受、稍后派发”。即使返回 true 的时间被限制,线程调度和后续处理仍可能让实际派发聚集。真正整形需要有界队列、调度器、超时与取消机制。
可以用下面的严格间隔准入器说明“不积蓄突发额度”的行为。它复用 GCRA,并把一次可立即通过的请求数设为 1;调用者过早到达会被拒绝,它不排队,也不是完整整形器。
class SpacingRateLimiter implements RateLimiter {
private final GcraRateLimiter delegate;
SpacingRateLimiter(double permitsPerSecond) {
this.delegate = new GcraRateLimiter(permitsPerSecond, 1);
}
@Override
public boolean allow(String key) {
return delegate.allow(key);
}
}
若业务要求排队,应在真正派发的时刻控制下一次释放时间,并限制队列长度和最大等待时间;调度器延误后是否追赶需要明确,不能用补发一批请求破坏下游的突发约束。
Nginx 的 limit_req 使用漏桶方法,默认可以延迟超额请求;加 nodelay 后,允许的突发请求立即转发。因此“使用漏桶”不等于“所有配置都会匀速输出”。例如:
# 放在 http 上下文,server 中还需配置实际 upstream/proxy_pass
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
# 允许 burst 范围内的超额请求立即转发,超出则拒绝
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}
在空闲状态、同一时刻集中到达且没有时间恢复的理想化计数下,是基础请求加 20 个超额额度,不能把 10r/s 与 burst 相加得到“瞬间 30 个”。实际边界还受实现的时间精度影响。多实例 Nginx 的状态是否共享也需要单独配置与验证。Nginx limit_req 文档
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限制并发连接数 - 可使用独立线程池或信号量隔离;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)
上述伪代码仅表达业务判断;读取两种额度和同时递增必须放在一个原子操作或事务中,失败时不能只扣掉其中一种。Redis Cluster 多 key 脚本还要求相关 key 在同一 hash slot;长期计费额度需要持久账本与对账,不能只依赖可丢失的缓存计数。
工程要点
- 配额通常需要返回剩余量(
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)
→ 系统实际吞吐远低于理论上限
动态调整配额(根据节点负载实时重新分配)?复杂度爆炸——你需要协调机制感知节点上下线、收集实时负载、计算下发配额,这本身就是一个分布式系统问题。
共享存储是一种方案;也可租赁额度到节点、按 key 分片,或分别使用本地保护与全局配额。选择取决于精度、协调成本和故障策略。
分布式限流的核心原则
限流的粒度决定了它的准确性。
| 保护对象 | 限流粒度 | 方案 |
|---|---|---|
| 本机 CPU/内存 | 进程级 | Guava RateLimiter、Sentinel |
| 外部 API 配额 | 系统级(全集群) | Redis 分布式计数器 |
| 业务规则(如用户发送频率) | 用户级 | Redis + 用户维度 key |
Redis 分布式限流:共享状态的边界
Redis 可用于集中维护短周期计数,但需要同时评估网络时延、热点 key、故障恢复和预算误差:
| 限流需求 | Redis 特性 | 为什么匹配 |
|---|---|---|
| 原子性:"读取-判断-递增"必须原子 | INCR 原子命令 + Lua 脚本 | 脚本内检查与更新不被其他命令插入;多条客户端命令仍可能竞争 |
| 极致性能:每个请求都要过限流 | 内存操作,亚毫秒级延迟 | 需要实测每次远程调用和热点 key 的开销 |
| 共享状态:所有节点看到同一个计数器 | 独立服务,集群可访问 | 请求汇聚到同一状态,复制故障与结果未知仍需处理 |
| 自动过期:时间窗口结束后计数器清零 | Key 级别 TTL | 无需额外清理逻辑 |
工程实践:基于 Redis + 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 只由本限流器维护,时间窗口后缀由统一口径计算。TTL 用于清理,并非决定后缀所指窗口的边界;保留时间应覆盖窗口和允许的请求迟到范围。
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
滚动区间约定为 (now - window, now]。每次实际准入尝试必须使用唯一 member;重复 request_id 会更新旧记录而少计一次。该脚本不实现请求幂等,时间源回拨、脚本超时和重试需在协议中处理。
两种 Redis 方案的对比
| 维度 | 固定窗口(String + INCR) | 滑动窗口(Sorted Set) |
|---|---|---|
| 存储开销 | O(1),一个 key 一个计数器 | O(N),N 为窗口内请求数 |
| 时间复杂度 | O(1) | O(log N + M),M 为本次删除的过期记录数 |
| 精度 | 边界可能 2 倍峰值 | 精确 |
| 适用 | 大部分场景 | 阈值紧、精度要求高 |
固定窗口还是滚动窗口由契约决定,与“余量小于 20%”没有固定对应关系。Lua 的原子执行不保证故障切换后计数绝不丢失,也不会把外部 API 调用纳入同一事务。Redis 脚本语义
关于时钟同步
分布式系统中,各节点用本地时间计算 Redis key 中的时间窗口标识,时钟偏移可能导致不同节点在不同窗口中计数。严格做法是用 Redis 服务端时间 redis.call('TIME')。即使偏差较小,在窗口边界也可能把同一时间的请求分到不同窗口;时钟回拨和故障切换还需要考虑。
需要统一口径时,以同一决策节点的时间计算窗口;若允许客户端时间,必须明确偏差预算。单机经过时间使用单调时钟,日/月商业配额则按约定时区和日历边界计算。
五、多层限流:纵深防御架构
一个常见误区是试图在某一层解决所有限流问题。良好的限流架构应该是分层的——每一层保护不同的东西,承担不同的职责。
各层详细对比
| 层级 | 保护对象 | 限流维度 | 典型工具 | 算法 |
|---|---|---|---|---|
| 接入层 | 基础设施 | 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;
limit_req_zone $binary_remote_addr zone=login_rate:10m rate=5r/m;
# 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=login_rate burst=2;
limit_conn ip_conn 10;
limit_conn_status 429;
limit_req_status 429;
}
}
}
前述 Nginx 配置省略监听和 upstream,只展示限流指令。反向代理环境需仅信任已知代理提供的真实 IP;NAT 用户共享 IP,IP 限流不能替代用户配额。limit_conn 在 HTTP/2、HTTP/3 中按并发请求计数,不等于单纯的 TCP 连接数。Nginx limit_conn
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);
}
// 反垃圾:新账号每个自然日最多发 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. 安全系数 | 依据突发、扩容和故障接管留余量,不能固定套比例 | 留出余量应对突发波动 |
| 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);
SystemRuleManager.loadRules(java.util.List.of(rule)); // 平均 RT > 200ms 时触发限流
// 仍需按机器规格和入口类型校准阈值
// 风险:正常流量波动可能触发误限,需仔细调试灵敏度
阈值是业务决策
限流阈值不是纯技术参数,而是一个业务决策。
它编码的是"我们愿意承受多大负载,以及拒绝超额流量的业务成本是什么"。
- 面向消费者的核心交易链路:拒绝会影响转化,但超载也会损害已接受请求 → 采用优先级、容量预算和受控排队
- 内部数据分析任务:晚执行几分钟无损失 → 阈值可严
- 计算密集的报表接口:单个请求消耗大量资源 → 阈值必须严
阈值设定必须综合技术容量和业务容忍度,需要工程团队和产品团队协同决策。
6.2 被拒绝的请求去哪了
大多数限流讨论都集中在"如何拒绝",很少有人思考"拒绝之后怎么办"。而在真实业务中,后者往往更重要。
| 策略 | 做法 | 适用场景 | 风险 |
|---|---|---|---|
| 直接拒绝 | 返回 429 + Retry-After | 开放 API、程序化调用方 | 用户体验差 |
| 排队等待 | 写入 MQ,消费者限速消费 | 异步操作(短信、邮件、报表) | 队列积压导致延迟不可控 |
| 降级响应 | 返回缓存/兜底数据 | 推荐、搜索、详情页非核心模块 | 数据时效性降低 |
| 引流分担 | 导向备用路径(CDN/只读副本) | 读多写少的场景 | 需要备用链路的维护成本 |
关键原则:限流策略和拒绝策略必须配套设计。
短信是否重试取决于用途、有效期和重复发送成本;排队需有上限及过期策略。秒杀限流应提示繁忙或排队,不能伪报售罄。商品详情可在业务允许的新鲜度范围内使用缓存,价格与库存操作仍需校验。
只设计了限流而没考虑拒绝后的处理,就像只安装了闸门却没修泄洪渠——水是拦住了,但迟早会溃坝。
七、金融场景:限流 ≠ 正确性
在金融、支付等对正确性有极高要求的领域,限流只是防御体系的一环。很多团队犯的错误是:觉得"加了限流就安全了"。现实是,限流解决的是流量问题,不是正确性问题。
三层防护:限流 + 并发控制 + 幂等
考虑一个支付场景:用户点了两次"付款"按钮。
| 防护层 | 解决的问题 | 如果只有这一层 |
|---|---|---|
| 限流 | 防止支付接口被高频调用打垮 | 两次点击间隔 100ms,速率限制 10/s → 都放行,扣两次款 |
| 并发控制 | 同一笔订单同一时刻只允许一个支付请求在处理 | 第二次被排队/拒绝,但如果第一次失败后重试呢? |
| 幂等 | 同一笔支付操作无论执行几次,结果只生效一次 | 无论重试多少次、并发多少个,最终只扣一次款 |
三层必须配合使用:
- 限流是外围护栏——挡住异常流量,保护系统不被打垮
- 并发控制是执行调度——同一资源同一时刻只有一个操作在执行
- 幂等是正确性保障——即使前两层被突破,最终结果仍然正确
支付保护要覆盖进程退出和结果未知
仅在执行支付后保存幂等结果是不够的:扣款成功、进程退出、结果尚未落库时,再次请求仍可能扣款。分布式锁也可能因租约到期或网络分区失去保护。
一个可执行的业务协议应包含:
- 在本地事务中创建持久化支付意图,以订单及业务操作建立唯一约束,保存金额、币种和请求指纹
- 同一幂等键但参数不同的请求明确拒绝;不同键也不能绕过订单已支付状态
- 调用支付渠道时使用渠道支持的稳定幂等标识;渠道不支持时,需要先设计查询、对账和人工处理路径
- 超时保存为结果未知,按渠道协议查询或使用同一标识重试,不能直接创建新扣款
- 通过状态条件更新记录结果,回调与查询重复到达时不重复发放权益
- 积压、未知状态和账务差异进入对账,保留可追溯记录
并发控制可以减少冲突,正确性最终依赖持久化约束、状态机与支付渠道契约。限流保护容量,不替代这些保证。
八、总结:限流是一种系统思维
限流从表面看是算法选择题,但真正落地到生产环境时,它是一个系统设计问题:
| 维度 | 核心问题 |
|---|---|
| 控制对象 | 你在控制什么?速率、并发、配额还是节奏?控制模型选错,算法再精妙也解决不了问题 |
| 容量 | 系统到底能承受多少?需要压测和监控,不是拍脑袋 |
| 优先级 | 必须拒绝时,拒绝谁?VIP vs 普通、核心 vs 边缘、写 vs 读 |
| 失败模式 | 限流触发后怎么办?报错、排队、降级还是引流 |
| 权衡 | 平滑性 vs 响应性、精确性 vs 性能、简单性 vs 灵活性 |
最好的限流系统是你感觉不到它存在的系统。流量平稳时安静旁观,突增时默默吸收合理突发,真正超限时优雅拒绝——确保已接受的请求仍能正常处理。它不是一堵墙,而是一个阀门:精确控制流量进出,让系统在极端压力下保持可控、可预测、可依赖。
限流的本质,是对系统能力边界的敬畏,以及在边界之内追求最大价值的工程智慧。