阅读时间:约 45 分钟 适合人群:Java 中高级工程师、架构师 前置知识:分布式系统基础、Java 并发编程、Redis 基础
一、从一个线上事故说起
2022 年某电商大促期间,秒杀服务在没有限流保护的情况下,瞬时流量从平时的 500 QPS 飙升到 50000 QPS。数据库连接池耗尽、缓存击穿、下游服务全部超时,最终整个交易链路雪崩。事后复盘发现:如果在网关层、服务层、数据库层分别部署限流策略,完全可以避免这次事故。
这个案例揭示了限流的本质:在资源有限的前提下,通过控制请求速率来保护系统稳定性。它不是"能处理多少就处理多少",而是"我告诉你我能处理多少,超过的请排队或被拒绝"。
限流是分布式系统稳定性治理中最基础也最核心的手段之一,与熔断、降级并称为"服务治理三件套"。本文将深入剖析五种核心限流算法的原理、实现和适用场景。

二、限流基础概念
2.1 限流的三个维度
限流不是单一维度的概念,它通常涉及三个层面:

| 维度 | 说明 | 示例 |
|---|---|---|
| 资源维度 | 保护什么资源 | API 接口、数据库、消息队列、线程池 |
| 流量维度 | 限制谁的流量 | 全局限流、IP 限流、用户限流、租户限流 |
| 时间维度 | 在什么时间窗口内限制 | 每秒、每分钟、每秒 + 每分钟同时限制 |
2.2 限流的关键指标
QPS(Queries Per Second):每秒请求数,最常用的限流指标。例如"该接口限制 1000 QPS"。
TPS(Transactions Per Second):每秒事务数,关注业务层面的完整操作。一个事务可能包含多个请求。
并发数(Concurrency):同时处理的请求数。与 QPS 的关系:QPS = 并发数 / 平均响应时间(Little 定律)。
阈值(Threshold):限流的临界值。超过此值的请求将被拒绝或排队。
2.3 限流策略分类
硬限流 vs 软限流:硬限流直接拒绝超额请求(返回 429 Too Many Requests);软限流将超额请求排队,在系统恢复后处理。
同步限流 vs 异步限流:同步限流在请求处理线程中直接判断;异步限流通过独立的限流中间件判断。
单机限流 vs 分布式限流:单机限流只控制单台机器的流量;分布式限流控制整个集群的总流量。
三、固定窗口算法
3.1 算法原理
固定窗口算法是最简单直观的限流算法:将时间轴划分为固定大小的窗口(如 1 秒),每个窗口内维护一个计数器。请求到达时,检查当前窗口的计数器是否超过阈值,超过则拒绝。

时间轴: |--- 窗口 1 ---|--- 窗口 2 ---|--- 窗口 3 ---|
计数器: [0..N] [0..N] [0..N]
3.2 Java 实现
public class FixedWindowRateLimiter {
private final int maxRequests; // 窗口内最大请求数
private final long windowSizeMs; // 窗口大小(毫秒)
private long windowStart; // 当前窗口起始时间
private final AtomicLong counter = new AtomicLong(0);
public FixedWindowRateLimiter(int maxRequests, long windowSizeMs) {
this.maxRequests = maxRequests;
this.windowSizeMs = windowSizeMs;
this.windowStart = System.currentTimeMillis();
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 如果进入新窗口,重置计数器
if (now - windowStart >= windowSizeMs) {
windowStart = now;
counter.set(0);
}
// 检查是否超过阈值
if (counter.incrementAndGet() <= maxRequests) {
return true;
}
return false;
}
}
3.3 致命缺陷:临界突变问题
固定窗口最大的问题是临界突变:在两个窗口的边界处,实际通过的请求数可能是限制值的两倍。
窗口 1(0s-1s):前 900ms 空闲,后 100ms 突发 100 个请求(达到上限)
窗口 2(1s-2s):前 100ms 突发 100 个请求(达到上限)
结果:在 0.9s-1.1s 这 200ms 内,实际通过了 200 个请求!
这意味着系统在这 200ms 内承受了设计容量 2 倍的流量压力。对于敏感系统,这个"两倍"足以触发雪崩。
3.4 适用场景
固定窗口算法虽然简单,但在以下场景仍然可用:
- 对流量突刺不敏感的场景(如日志采集、非关键查询接口)
- 作为粗粒度的第一层限流(配合下游更精细的限流)
- 对实现复杂度要求极低的场景
四、滑动窗口算法
4.1 算法原理
滑动窗口算法是固定窗口的改进版:它将一个大的时间窗口进一步细分为多个小格子(slot),窗口随时间滑动,统计的是最近 N 个格子的请求总数。

时间轴:|---|---|---|---|---|---|---|---|---|---|---|
格子: [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
|<--- 滑动窗口(10个格子)--->|
窗口大小固定(如 1 秒),但窗口边界随时间平滑移动,不再是固定边界。这样消除了临界突变问题。
4.2 Java 实现(基于数组的循环滑动窗口)
public class SlidingWindowRateLimiter {
private final int maxRequests; // 窗口内最大请求数
private final long windowSizeMs; // 总窗口大小(毫秒)
private final int slotCount; // 格子数量
private final long slotSizeMs; // 每个格子的时间跨度
private final AtomicLong[ ] slots; // 每个格子的计数器
private final AtomicLong slotStartTime = new AtomicLong(0);
private final AtomicInteger currentSlot = new AtomicInteger(0);
public SlidingWindowRateLimiter(int maxRequests, long windowSizeMs, int slotCount) {
this.maxRequests = maxRequests;
this.windowSizeMs = windowSizeMs;
this.slotCount = slotCount;
this.slotSizeMs = windowSizeMs / slotCount;
this.slots = new AtomicLong[slotCount];
for (int i = 0; i < slotCount; i++) {
slots[i] = new AtomicLong(0);
}
this.slotStartTime.set(System.currentTimeMillis());
}
public boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsed = now - slotStartTime.get();
// 计算当前应该在第几个格子
int slotIndex = (int) ((elapsed / slotSizeMs) % slotCount);
// 如果时间推进了足够多,需要重置过期的格子
long slotsPassed = elapsed / slotSizeMs;
if (slotsPassed > slotCount) {
// 整个窗口都过期了,重置所有格子
synchronized (this) {
if (elapsed > slotCount * slotSizeMs) {
for (int i = 0; i < slotCount; i++) {
slots[i].set(0);
}
slotStartTime.set(now);
slotIndex = 0;
currentSlot.set(0);
}
}
} else if (slotsPassed > 0) {
// 部分格子过期,重置它们
int oldSlot = currentSlot.get();
for (int i = 1; i <= slotsPassed && i <= slotCount; i++) {
int expiredSlot = (oldSlot + i) % slotCount;
slots[expiredSlot].set(0);
}
}
currentSlot.set(slotIndex);
slots[slotIndex].incrementAndGet();
// 统计窗口内总请求数
long total = 0;
for (AtomicLong slot : slots) {
total += slot.get();
}
return total <= maxRequests;
}
}
4.3 滑动窗口的精度与代价
滑窗精度取决于格子数量。格子越多 → 精度越高 → 内存和计算开销越大。在 Sentry 和 Resilience4j 中,格子数量通常在 10-100 之间。
相比之下,固定窗口算法相当于 slotCount = 1 的特例。
4.4 Redis 实现(滑动窗口日志)
生产环境中,滑动窗口通常借助 Redis 的有序集合(ZSet)实现:
public class RedisSlidingWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
/**
* @param key 限流 key(如 "rate_limit:api:user_123")
* @param maxRequests 窗口内最大请求数
* @param windowSeconds 窗口大小(秒)
*/
public boolean isAllowed(String key, int maxRequests, int windowSeconds) {
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000L;
// 使用 pipeline 保证原子性
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
// 1. 移除窗口外的过期记录
connection.zRemRangeByScore(key.getBytes(), 0, windowStart);
// 2. 添加当前请求
connection.zAdd(key.getBytes(), now, String.valueOf(now).getBytes());
// 3. 设置 key 过期时间
connection.expire(key.getBytes(), windowSeconds + 1);
return null;
});
// 统计窗口内请求数
Long count = redisTemplate.opsForZSet().count(key, windowStart, now);
return count != null && count <= maxRequests;
}
}
这个实现本质上是**滑动窗口日志(Sliding Window Log)**算法,每个请求记录一条时间戳日志,精确度最高但也最耗内存。
五、漏桶算法
5.1 算法原理
漏桶算法灵感来自物理世界的水桶:无论水流进来的速度多快,出水口的大小是固定的,水以恒定速率流出。如果桶满了,新来的水就会溢出(被丢弃)。

请求流入
↓ ↓ ↓ ↓ ↓ (突发流量,速率不定)
┌─────────────────┐
│ │
│ 漏桶(队列) │ ← 桶容量固定
│ │
└────────┬────────┘
↓ (恒定速率流出)
请求处理
5.2 核心特性
强制平滑:漏桶的流出速率是恒定的,无论上游流量如何波动,下游看到的都是平滑的流量。这非常适合保护下游服务不被突发流量冲击。
有界队列:桶容量有限,超出容量的请求直接丢弃。
流量整形:漏桶本质上是一个**流量整形(Traffic Shaping)**算法,而非简单的"通过/拒绝"判断。
5.3 Java 实现
public class LeakyBucketRateLimiter {
private final int capacity; // 桶容量(队列最大长度)
private final long leakRateMs; // 漏水速率(处理一个请求的间隔,毫秒)
private final BlockingQueue<Object> bucket;
private final ScheduledExecutorService scheduler;
private final AtomicInteger currentSize = new AtomicInteger(0);
public LeakyBucketRateLimiter(int capacity, long leakRateMs) {
this.capacity = capacity;
this.leakRateMs = leakRateMs;
this.bucket = new LinkedBlockingQueue<>(capacity);
this.scheduler = Executors.newSingleThreadScheduledExecutor();
// 定时以恒定速率 "漏水"(处理请求)
scheduler.scheduleAtFixedRate(this::leak, leakRateMs, leakRateMs, TimeUnit.MILLISECONDS);
}
/**
* 尝试放入请求到桶中
*/
public boolean tryAcquire(Runnable task) {
if (currentSize.get() >= capacity) {
return false; // 桶满,拒绝
}
boolean offered = bucket.offer(task);
if (offered) {
currentSize.incrementAndGet();
}
return offered;
}
/**
* 以恒定速率处理桶中的请求
*/
private void leak() {
Runnable task = (Runnable) bucket.poll();
if (task != null) {
currentSize.decrementAndGet();
task.run();
}
}
}
5.4 漏桶的局限性
- 不支持突发流量:即使下游系统有能力处理突发流量,漏桶也会强制平滑。在某些场景下(如秒杀),这会导致不必要的延迟。
- 延迟累积:当流量持续超过处理速率时,排在队列后面的请求会有很长时间的延迟。
- 队列满后的行为:有两种策略——丢弃新请求(tail drop)或丢弃队首请求(head drop,处理最新请求)。大多数实现选择 tail drop。
5.5 适用场景
- 保护下游数据库等对流量波动敏感的资源
- 消息队列的消费端限流(如 RocketMQ 的消费端限流本质就是漏桶)
- 网络流量整形(如 Linux 的 TC 模块)
六、令牌桶算法
6.1 算法原理
令牌桶算法是漏桶的"镜像":系统以恒定速率向桶中放入令牌,请求到达时从桶中取令牌,取到令牌的请求被处理,取不到的等待或被拒绝。

令牌生成(恒定速率)
↓ ↓ ↓
┌─────────────────┐
│ 令牌桶(容量 N)│
└────────┬────────┘
↓ 取令牌
请求到达 → 取到令牌?→ 是 → 处理
↓ 否
拒绝/等待
6.2 与漏桶的关键区别
| 特性 | 漏桶 | 令牌桶 |
|---|---|---|
| 控制对象 | 请求流出速率 | 请求流入速率 |
| 突发流量 | 强制平滑,不支持突发 | 支持突发(桶中有积攒的令牌) |
| 流量整形 | 是 | 否 |
| 实现复杂度 | 需要队列和定时器 | 只需计数器和定时器 |
| 典型应用 | 流量整形 | API 限流 |
最关键的区别:令牌桶支持突发流量。假设令牌生成速率是 100/s,桶容量是 500,那么当桶满时,系统可以瞬间处理 500 个请求(突发),然后恢复到 100/s 的稳态。而漏桶只能以恒定速率处理,无法利用积攒的"容量"。
6.3 Java 实现(基于 Guava RateLimiter)
Guava 的 RateLimiter 是基于令牌桶的经典实现,支持两种模式:
SmoothBursty(平滑突发):
// 创建每秒 100 个令牌的限流器(支持突发)
RateLimiter limiter = RateLimiter.create(100.0); // 100 permits/s
// 预热后,可以立即消耗积攒的令牌
limiter.acquire(50); // 立即返回,消耗 50 个令牌(桶中积攒了 50 个)
limiter.acquire(50); // 需要等待约 0.5s,因为令牌被消耗完了
SmoothWarmingUp(平滑预热):
// 创建每秒 100 个令牌的限流器,预热期 5 秒
RateLimiter limiter = RateLimiter.create(100.0, 5, TimeUnit.SECONDS);
// 冷启动时,令牌生成速率较低,逐渐增加到 100/s
// 适合需要预热的资源(如数据库连接池)
6.4 自实现令牌桶
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量(最大令牌数)
private final double refillRate; // 令牌填充速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefillTime; // 上次填充时间
public TokenBucketRateLimiter(long capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity; // 初始满桶
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
return tryAcquire(1);
}
public synchronized boolean tryAcquire(int permits) {
refill(); // 先补充令牌
if (tokens >= permits) {
tokens -= permits;
return true;
}
return false;
}
/**
* 补充令牌:根据时间差计算应补充的令牌数
*/
private void refill() {
long now = System.nanoTime();
double elapsedSeconds = (now - lastRefillTime) / 1_000_000_000.0;
double newTokens = elapsedSeconds * refillRate;
tokens = Math.min(capacity, tokens + newTokens);
lastRefillTime = now;
}
/**
* 阻塞获取(等待直到有足够令牌)
*/
public synchronized double acquire(int permits) {
refill();
if (tokens >= permits) {
tokens -= permits;
return 0; // 无需等待
}
// 计算需要等待的时间
double shortage = permits - tokens;
double waitSeconds = shortage / refillRate;
tokens = 0;
return waitSeconds;
}
}
6.5 令牌桶的容量设计
令牌桶的容量(capacity)是一个关键参数,它决定了系统能承受的最大突发流量。设计原则:
- 容量太小:退化为接近漏桶,不支持突发流量
- 容量太大:可能给下游造成过大压力,失去限流的意义
- 经验值:
capacity = refillRate × 预期突发持续时间。例如允许 2 秒的突发,则capacity = 2 × refillRate
七、分布式限流
7.1 为什么需要分布式限流
单机限流只能控制单台机器的流量。在分布式系统中,假设限流阈值为 1000 QPS,部署了 10 台机器,每台机器各限 100 QPS,总流量仍为 1000 QPS。但问题在于:

- 流量不均:负载均衡不可能完美均匀,某些机器可能先达到限流阈值
- 全局视角缺失:无法知道整个集群已经处理了多少请求
- 动态扩缩容:机器数变化时需要重新计算单机限流阈值
7.2 基于 Redis 的分布式限流
Redis 是实现分布式限流最常用的方案,核心是利用 Redis 的单线程特性和原子操作。
固定窗口 + Redis:
public class RedisFixedWindowLimiter {
private final StringRedisTemplate redisTemplate;
public boolean isAllowed(String key, int maxRequests, int windowSeconds) {
String redisKey = "rate_limit:" + key + ":" + (System.currentTimeMillis() / 1000 / windowSeconds);
Long count = redisTemplate.opsForValue().increment(redisKey);
if (count == 1) {
redisTemplate.expire(redisKey, windowSeconds, TimeUnit.SECONDS);
}
return count != null && count <= maxRequests;
}
}
滑动窗口 + Redis(ZSet 方案):
public class RedisSlidingWindowLimiter {
private final StringRedisTemplate redisTemplate;
private static final String SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_req = tonumber(ARGV[3])
-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
-- 统计窗口内请求数
local count = redis.call('ZCARD', key)
if count < max_req then
-- 添加当前请求记录
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('EXPIRE', key, window + 1)
return 1
else
return 0
end
""";
public boolean isAllowed(String key, int maxRequests, int windowSeconds) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
new DefaultRedisScript<>(SCRIPT, Long.class),
List.of(key),
String.valueOf(now), String.valueOf(windowSeconds), String.valueOf(maxRequests)
);
return result != null && result == 1;
}
}
7.3 令牌桶 + Redis(Lua 脚本)
public class RedisTokenBucketLimiter {
private final StringRedisTemplate redisTemplate;
private static final String TOKEN_BUCKET_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
-- 计算补充的令牌
local elapsed = math.max(0, now - last_refill)
local refill = math.floor(elapsed / 1000 * rate)
tokens = math.min(capacity, tokens + refill)
last_refill = last_refill + refill * 1000 / rate
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, 60)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
return 0
end
""";
public boolean tryAcquire(String key, long capacity, double rate, int permits) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(TOKEN_BUCKET_SCRIPT, Long.class),
List.of(key),
String.valueOf(capacity), String.valueOf(rate),
String.valueOf(permits), String.valueOf(System.currentTimeMillis())
);
return result != null && result == 1;
}
}
7.4 分布式限流的性能考量
| 方案 | 每次请求的网络开销 | 精度 | 适用场景 |
|---|---|---|---|
| 纯 Redis | 1 次 Redis 调用 | 高 | 中小规模,限流精度要求高 |
| 本地 + 定期同步 | 0 次(本地判断) | 中 | 大规模,性能敏感 |
| Redis + 本地缓存 | 1 次 Redis(miss 时) | 中高 | 折中方案 |
混合方案(推荐):
public class HybridRateLimiter {
private final TokenBucketRateLimiter localLimiter;
private final RedisTokenBucketLimiter redisLimiter;
private final long syncIntervalMs = 200; // 200ms 同步一次全局配额
public boolean tryAcquire(String key) {
// 1. 本地限流快速判断(减少 Redis 调用)
if (!localLimiter.tryAcquire()) {
return false;
}
// 2. 定期同步到 Redis 验证全局配额
if (needSync()) {
boolean globalAllowed = redisLimiter.tryAcquire(key, ...);
if (!globalAllowed) {
// 全局配额不足,回退本地令牌
localLimiter.refund();
return false;
}
}
return true;
}
}
这种方案兼具了本地限流的低延迟和分布式限流的全局一致性。
八、生产实践
8.1 主流框架的限流实现
| 框架 | 算法 | 特点 |
|---|---|---|
| Guava RateLimiter | 令牌桶 | 单机、支持预热、简单易用 |
| Sentinel | 滑动窗口 + 漏桶 | 支持多种规则、控制台、集群限流 |
| Resilience4j | 令牌桶 | 函数式风格、与 Spring Boot 集成 |
| Nginx limit_req | 漏桶 | 网关层限流、性能极高 |
| Nginx limit_conn | 并发数限流 | 连接数限制 |
| Redis + Lua | 任意算法 | 分布式限流、灵活 |
8.2 Sentinel 集群限流原理
Sentinel 的集群限流通过 Token Server 实现:
Client 1 ──→ 请求令牌 ──→ Token Server (全局令牌桶)
Client 2 ──→ 请求令牌 ──→ │
Client 3 ──→ 请求令牌 ──→ 全局配额管理
│
分配额度给各 Client
Token Server 将全局 QPS 额度分配给各个 Client,Client 在本地额度内直接放行,额度用完后向 Server 申请新额度。这样既保证了全局一致性,又减少了网络开销。
8.3 网关层限流最佳实践
网关层是限流的第一道防线,推荐多层限流策略:
请求 → 全局限流(Nginx/网关)→ 服务限流(Sentinel)→ 方法限流(注解)→ 业务限流(代码)
Nginx 配置示例:
# 限制每个 IP 每秒最多 10 个请求
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# 限制每个 server 每秒最多 1000 个请求
limit_req_zone $server_name zone=perserver:10m rate=1000r/s;
server {
location /api/ {
limit_req zone=perip burst=20 nodelay;
limit_req zone=perserver burst=1000 nodelay;
proxy_pass http://backend;
}
}
8.4 自适应限流
传统限流算法使用固定阈值,但在实际系统中,系统的处理能力是动态变化的(GC、网络波动、依赖服务变慢等)。自适应限流能根据系统当前状态动态调整阈值。
Bred 算法(TCP Vegas 启发):
public class AdaptiveRateLimiter {
private double currentRate; // 当前限流速率
private final double minRate; // 最小速率
private final double maxRate; // 最大速率
private final double adjustmentFactor = 0.1;
public void adjust(long avgRtt, long baseRtt, double errorRate) {
// 如果积压队列在增长(avgRtt > baseRtt),降低限流阈值
if (avgRtt > baseRtt * 1.5) {
currentRate = Math.max(minRate, currentRate * (1 - adjustmentFactor));
}
// 如果系统健康,逐步恢复限流阈值
else if (errorRate < 0.01 && avgRtt < baseRtt * 1.2) {
currentRate = Math.min(maxRate, currentRate * (1 + adjustmentFactor));
}
}
}
九、常见陷阱与避坑指南
9.1 陷阱一:用固定窗口做精确限流
固定窗口的临界突变问题会导致实际流量达到限制值的 2 倍。如果业务对流量精度有要求,必须使用滑动窗口或令牌桶。
9.2 陷阱二:限流阈值设置不当
设置过高:限流失效,起不到保护作用 设置过低:误杀正常请求,影响用户体验
正确做法:阈值 = 压测得到的极限容量 × 安全系数(通常 0.7-0.8),留出缓冲空间。
9.3 陷阱三:忘记设置默认限流
// 危险:所有接口都通过这个网关,但只配置了部分接口的限流规则
@Bean
public RateLimiter rateLimiter() {
// 应该设置默认限流规则,防止未配置的接口被无限调用
return RateLimiter.create(1000); // 默认 QPS 上限
}
9.4 陷阱四:Redis 分布式限流的热 Key 问题
高流量场景下,所有请求都打到同一个 Redis Key,会导致热点问题。解决方案:
// 方案一:本地预取额度(Sentinel 的做法)
// 方案二:使用多槽位(按机器 IP 哈希分片)
String key = "rate_limit:" + resource + ":" + (hash(ip) % shardCount);
// 每个槽位独立计数,总和 = 分片数 × 单槽位阈值
9.5 陷阱五:限流与降级的顺序
限流应该在降级之前执行。如果先降级后限流,可能已经产生了不必要的降级开销。正确的请求处理链:
请求 → 限流检查 → 熔断检查 → 降级检查 → 业务处理
十、算法选型决策树
选择限流算法时,按以下顺序考虑:

- 是否需要分布式限流? 是 → Redis + 令牌桶/滑动窗口
- 是否需要支持突发流量? 是 → 令牌桶;否 → 漏桶
- 精度要求高吗? 高 → 滑动窗口(slot 数 ≥ 10);低 → 固定窗口
- 是否需要预热? 是 → 令牌桶(SmoothWarmingUp)
- 性能要求极高? 单机 → 本地令牌桶 + 定期同步;允许一定网络开销 → Redis 方案
十一、面试追问
Q1:固定窗口和滑动窗口的本质区别是什么?为什么滑动窗口能解决临界突变问题?
固定窗口的边界是"硬"的——时间一到边界就重置计数器,两个窗口之间没有重叠。滑动窗口的边界是"软"的——窗口随时间平滑移动,每个时刻统计的都是"最近一段时间"的请求数,不会有边界处的突变。用数学语言描述:固定窗口的流量函数在窗口边界处可能不连续(跳变),而滑动窗口的流量函数是连续的。
Q2:令牌桶的容量为什么要有限制?如果设成无限大会怎样?
令牌桶容量无限大意味着系统可以积攒无限多的令牌,理论上可以处理无限大的突发流量——这完全失去了限流的意义。容量限制的本质是限制突发规模,防止系统在短时间内承受超过设计容量的压力。
Q3:在分布式限流中,如何平衡精度和性能?
核心思路是本地预取 + 全局配额。每个节点从全局配额中心(如 Redis)领取一批配额,在本地消耗,配额用完后再申请。预取数量决定了精度和性能的平衡:预取越多,性能越好(减少网络调用),但精度越低(可能出现配额浪费或不均)。实际场景中,200ms 同步一次,每次预取 1/5 秒的配额,是一个常见的平衡点。
Q4:如果 Redis 宕机,分布式限流应该怎么处理?
降级策略分两种:fail-open(不限制,放行所有请求)适用于对可用性要求高于稳定性的场景;fail-closed(拒绝所有请求)适用于对稳定性要求极高的场景。更推荐的是降级到本地限流:Redis 不可用时,各节点使用本地令牌桶限流,总阈值为 全局阈值 / 节点数。这是最平衡的方案。
Q5:漏桶和令牌桶分别适合什么场景?为什么消息队列的消费端限流通常用漏桶?
漏桶适合保护下游系统——下游是一台数据库,它希望看到的是平稳的流量,而不是突发。令牌桶适合API 限流——API 允许一定程度的突发(用户连续点击),但需要限制长期平均速率。消息队列消费端用漏桶,是因为消费端将消息写入数据库,而数据库是典型的"怕突发"资源——恒定速率写入可以最大化吞吐量,同时避免数据库连接池耗尽。
Q6:如何设计一个支持动态调整阈值的限流系统?
核心思路是配置中心 + 热更新。将限流阈值存储在配置中心(如 Nacos、Apollo),限流组件监听配置变更事件,实时更新本地阈值。具体实现:
@Component
public class DynamicRateLimiter {
private volatile RateLimiter rateLimiter;
@NacosValue(value = "${rate.limit.qps:1000}", autoRefreshed = true)
private int qps;
@NacosConfigListener(dataId = "rate-limit-config")
public void onConfigChange(String newConfig) {
// 解析新配置,创建新的 RateLimiter
this.rateLimiter = RateLimiter.create(parseQps(newConfig));
}
public boolean tryAcquire() {
return rateLimiter.tryAcquire();
}
}
Q7:令牌桶算法在分布式环境下,如何保证"总令牌数"不会超过全局容量?
这是分布式令牌桶的核心挑战。如果每个节点各自维护一个全局阈值的令牌桶,总流量就是 N × 阈值。正确做法是使用 Redis 作为全局令牌桶的唯一数据源,所有节点共享同一个桶。性能优化时可以考虑"本地预取 + 全局扣减",但必须保证全局令牌不超过容量——这需要分布式锁或原子操作(Lua 脚本)来保证。
十二、延伸阅读
- Google Guava RateLimiter 源码:
com.google.common.util.concurrent.RateLimiter - Sentinel 官方文档:https://sentinelguard.io/zh-cn/docs/introduction.html
- 《Designing Data-Intensive Applications》第 12 章:The Future of Data Systems
- Redis 官方文档:Rate Limiting 相关模式
- Nginx 限流模块:
ngx_http_limit_req_module
下一考点预告:187|熔断、降级与隔离:状态机、舱壁模式、线程池/信号量隔离
