186|限流算法:固定窗口、滑动窗口、漏桶、令牌桶和分布式限流

微服务与服务治理约 11 分钟阅读5,996 字符
知识库文档 188 / 251

阅读时间:约 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 秒),每个窗口内维护一个计数器。请求到达时,检查当前窗口的计数器是否超过阈值,超过则拒绝。

文章配图
文章配图

code
时间轴:  |--- 窗口 1 ---|--- 窗口 2 ---|--- 窗口 3 ---|
计数器:       [0..N]         [0..N]         [0..N]

3.2 Java 实现

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 致命缺陷:临界突变问题

固定窗口最大的问题是临界突变:在两个窗口的边界处,实际通过的请求数可能是限制值的两倍。

code
窗口 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 个格子的请求总数。

文章配图
文章配图

code
时间轴:|---|---|---|---|---|---|---|---|---|---|---|
格子:  [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]
                             |<--- 滑动窗口(10个格子)--->|

窗口大小固定(如 1 秒),但窗口边界随时间平滑移动,不再是固定边界。这样消除了临界突变问题。

4.2 Java 实现(基于数组的循环滑动窗口)

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)实现:

java
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 算法原理

漏桶算法灵感来自物理世界的水桶:无论水流进来的速度多快,出水口的大小是固定的,水以恒定速率流出。如果桶满了,新来的水就会溢出(被丢弃)。

文章配图
文章配图

code
        请求流入
          ↓ ↓ ↓ ↓ ↓ (突发流量,速率不定)
    ┌─────────────────┐
    │                 │
    │   漏桶(队列)   │ ← 桶容量固定
    │                 │
    └────────┬────────┘
             ↓ (恒定速率流出)
        请求处理

5.2 核心特性

强制平滑:漏桶的流出速率是恒定的,无论上游流量如何波动,下游看到的都是平滑的流量。这非常适合保护下游服务不被突发流量冲击。

有界队列:桶容量有限,超出容量的请求直接丢弃。

流量整形:漏桶本质上是一个**流量整形(Traffic Shaping)**算法,而非简单的"通过/拒绝"判断。

5.3 Java 实现

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 漏桶的局限性

  1. 不支持突发流量:即使下游系统有能力处理突发流量,漏桶也会强制平滑。在某些场景下(如秒杀),这会导致不必要的延迟。
  2. 延迟累积:当流量持续超过处理速率时,排在队列后面的请求会有很长时间的延迟。
  3. 队列满后的行为:有两种策略——丢弃新请求(tail drop)或丢弃队首请求(head drop,处理最新请求)。大多数实现选择 tail drop。

5.5 适用场景

  • 保护下游数据库等对流量波动敏感的资源
  • 消息队列的消费端限流(如 RocketMQ 的消费端限流本质就是漏桶)
  • 网络流量整形(如 Linux 的 TC 模块)

六、令牌桶算法

6.1 算法原理

令牌桶算法是漏桶的"镜像":系统以恒定速率向桶中放入令牌,请求到达时从桶中取令牌,取到令牌的请求被处理,取不到的等待或被拒绝。

文章配图
文章配图

code
        令牌生成(恒定速率)
          ↓ ↓ ↓
    ┌─────────────────┐
    │   令牌桶(容量 N)│
    └────────┬────────┘
             ↓ 取令牌
        请求到达 → 取到令牌?→ 是 → 处理
                    ↓ 否
                  拒绝/等待

6.2 与漏桶的关键区别

特性 漏桶 令牌桶
控制对象 请求流出速率 请求流入速率
突发流量 强制平滑,不支持突发 支持突发(桶中有积攒的令牌)
流量整形 是 否
实现复杂度 需要队列和定时器 只需计数器和定时器
典型应用 流量整形 API 限流

最关键的区别:令牌桶支持突发流量。假设令牌生成速率是 100/s,桶容量是 500,那么当桶满时,系统可以瞬间处理 500 个请求(突发),然后恢复到 100/s 的稳态。而漏桶只能以恒定速率处理,无法利用积攒的"容量"。

6.3 Java 实现(基于 Guava RateLimiter)

Guava 的 RateLimiter 是基于令牌桶的经典实现,支持两种模式:

SmoothBursty(平滑突发):

java
// 创建每秒 100 个令牌的限流器(支持突发)
RateLimiter limiter = RateLimiter.create(100.0);  // 100 permits/s

// 预热后,可以立即消耗积攒的令牌
limiter.acquire(50);  // 立即返回,消耗 50 个令牌(桶中积攒了 50 个)
limiter.acquire(50);  // 需要等待约 0.5s,因为令牌被消耗完了

SmoothWarmingUp(平滑预热):

java
// 创建每秒 100 个令牌的限流器,预热期 5 秒
RateLimiter limiter = RateLimiter.create(100.0, 5, TimeUnit.SECONDS);

// 冷启动时,令牌生成速率较低,逐渐增加到 100/s
// 适合需要预热的资源(如数据库连接池)

6.4 自实现令牌桶

java
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。但问题在于:

文章配图
文章配图

  1. 流量不均:负载均衡不可能完美均匀,某些机器可能先达到限流阈值
  2. 全局视角缺失:无法知道整个集群已经处理了多少请求
  3. 动态扩缩容:机器数变化时需要重新计算单机限流阈值

7.2 基于 Redis 的分布式限流

Redis 是实现分布式限流最常用的方案,核心是利用 Redis 的单线程特性和原子操作。

固定窗口 + Redis:

java
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 方案):

java
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 脚本)

java
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 时) 中高 折中方案

混合方案(推荐):

java
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 实现:

code
Client 1 ──→ 请求令牌 ──→ Token Server (全局令牌桶)
Client 2 ──→ 请求令牌 ──→        │
Client 3 ──→ 请求令牌 ──→   全局配额管理
                              │
                         分配额度给各 Client

Token Server 将全局 QPS 额度分配给各个 Client,Client 在本地额度内直接放行,额度用完后向 Server 申请新额度。这样既保证了全局一致性,又减少了网络开销。

8.3 网关层限流最佳实践

网关层是限流的第一道防线,推荐多层限流策略:

code
请求 → 全局限流(Nginx/网关)→ 服务限流(Sentinel)→ 方法限流(注解)→ 业务限流(代码)

Nginx 配置示例:

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 启发):

java
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 陷阱三:忘记设置默认限流

java
// 危险:所有接口都通过这个网关,但只配置了部分接口的限流规则
@Bean
public RateLimiter rateLimiter() {
    // 应该设置默认限流规则,防止未配置的接口被无限调用
    return RateLimiter.create(1000); // 默认 QPS 上限
}

9.4 陷阱四:Redis 分布式限流的热 Key 问题

高流量场景下,所有请求都打到同一个 Redis Key,会导致热点问题。解决方案:

java
// 方案一:本地预取额度(Sentinel 的做法)
// 方案二:使用多槽位(按机器 IP 哈希分片)
String key = "rate_limit:" + resource + ":" + (hash(ip) % shardCount);
// 每个槽位独立计数,总和 = 分片数 × 单槽位阈值

9.5 陷阱五:限流与降级的顺序

限流应该在降级之前执行。如果先降级后限流,可能已经产生了不必要的降级开销。正确的请求处理链:

code
请求 → 限流检查 → 熔断检查 → 降级检查 → 业务处理

十、算法选型决策树

选择限流算法时,按以下顺序考虑:

文章配图
文章配图

  1. 是否需要分布式限流? 是 → Redis + 令牌桶/滑动窗口
  2. 是否需要支持突发流量? 是 → 令牌桶;否 → 漏桶
  3. 精度要求高吗? 高 → 滑动窗口(slot 数 ≥ 10);低 → 固定窗口
  4. 是否需要预热? 是 → 令牌桶(SmoothWarmingUp)
  5. 性能要求极高? 单机 → 本地令牌桶 + 定期同步;允许一定网络开销 → Redis 方案

十一、面试追问

Q1:固定窗口和滑动窗口的本质区别是什么?为什么滑动窗口能解决临界突变问题?

固定窗口的边界是"硬"的——时间一到边界就重置计数器,两个窗口之间没有重叠。滑动窗口的边界是"软"的——窗口随时间平滑移动,每个时刻统计的都是"最近一段时间"的请求数,不会有边界处的突变。用数学语言描述:固定窗口的流量函数在窗口边界处可能不连续(跳变),而滑动窗口的流量函数是连续的。

Q2:令牌桶的容量为什么要有限制?如果设成无限大会怎样?

令牌桶容量无限大意味着系统可以积攒无限多的令牌,理论上可以处理无限大的突发流量——这完全失去了限流的意义。容量限制的本质是限制突发规模,防止系统在短时间内承受超过设计容量的压力。

Q3:在分布式限流中,如何平衡精度和性能?

核心思路是本地预取 + 全局配额。每个节点从全局配额中心(如 Redis)领取一批配额,在本地消耗,配额用完后再申请。预取数量决定了精度和性能的平衡:预取越多,性能越好(减少网络调用),但精度越低(可能出现配额浪费或不均)。实际场景中,200ms 同步一次,每次预取 1/5 秒的配额,是一个常见的平衡点。

Q4:如果 Redis 宕机,分布式限流应该怎么处理?

降级策略分两种:fail-open(不限制,放行所有请求)适用于对可用性要求高于稳定性的场景;fail-closed(拒绝所有请求)适用于对稳定性要求极高的场景。更推荐的是降级到本地限流:Redis 不可用时,各节点使用本地令牌桶限流,总阈值为 全局阈值 / 节点数。这是最平衡的方案。

Q5:漏桶和令牌桶分别适合什么场景?为什么消息队列的消费端限流通常用漏桶?

漏桶适合保护下游系统——下游是一台数据库,它希望看到的是平稳的流量,而不是突发。令牌桶适合API 限流——API 允许一定程度的突发(用户连续点击),但需要限制长期平均速率。消息队列消费端用漏桶,是因为消费端将消息写入数据库,而数据库是典型的"怕突发"资源——恒定速率写入可以最大化吞吐量,同时避免数据库连接池耗尽。

Q6:如何设计一个支持动态调整阈值的限流系统?

核心思路是配置中心 + 热更新。将限流阈值存储在配置中心(如 Nacos、Apollo),限流组件监听配置变更事件,实时更新本地阈值。具体实现:

java
@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|熔断、降级与隔离:状态机、舱壁模式、线程池/信号量隔离

文章配图
文章配图