2. 限流基础:常见限流算法(令牌桶、漏桶、滑动窗口)原理与 Spring Boot 实现

限流是分布式系统抵御流量洪峰的第一道防线。在缓存一致性治理场景中,限流不仅保护下游资源,更防止因突发请求导致缓存穿透、雪崩等连锁故障。本节深入剖析三种经典限流算法的数学原理与工程实现,并给出 Spring Boot 中的可落地代码。

2.1 算法核心对比

算法 核心思想 允许突发 平滑度 内存开销 典型场景
令牌桶 固定速率生成令牌,请求消耗令牌 是(桶容量控制峰值) 中等 低(2个变量) API 网关、缓存入口
漏桶 请求进入队列,固定速率漏出 否(强制削峰) 高 中(队列) 数据库连接池、消息消费
滑动窗口 将时间窗口细分为小格,统计请求数 有限(窗口内) 低(粒度决定) 高(数组/环形缓冲区) 秒杀、热点 Key 防护
缓存一致性视角: 令牌桶允许短时突发,适合缓存预热后的正常流量;漏桶强制平滑,适合写缓存场景防止数据库压力;滑动窗口精度高,适合对缓存 Key 级别做细粒度限流。

2.2 令牌桶算法(Token Bucket)

2.2.1 原理与数学模型

令牌桶以恒定速率 r 向容量为 b 的桶中放入令牌。每个请求必须获取一个令牌才能通过。若桶满则令牌丢弃;若桶空则请求被拒绝或等待。

  • 关键参数: 速率 r(令牌/秒)、容量 b(最大突发量)
  • 瞬时特性: 允许最大突发流量为 b,长期平均速率不超过 r
  • 数学表达: 当前令牌数 = min(容量, 上次令牌数 + (当前时间 - 上次时间) × r)
令牌桶算法原理图 令牌生成器 速率 r = 10/s 放入令牌 令牌桶 容量 b = 20 当前令牌: 5 请求到达 消耗 1 令牌 ✅ 通过 ❌ 拒绝 算法特性 • 允许突发:桶满时可连续消费 • 长期平滑:平均速率 = r • 适合缓存入口突发读场景

2.2.2 Spring Boot 实现(基于 Guava RateLimiter)

import com.google.common.util.concurrent.RateLimiter;
import org.springframework.stereotype.Component;

@Component
public class TokenBucketLimiter {

    // 每秒生成 10 个令牌,桶容量 20
    private final RateLimiter rateLimiter = RateLimiter.create(10.0);

    /**
     * 尝试获取令牌,非阻塞
     * @return true 表示获取成功,请求允许通过
     */
    public boolean tryAcquire() {
        return rateLimiter.tryAcquire();
    }

    /**
     * 阻塞获取令牌,最多等待 timeout 毫秒
     */
    public boolean tryAcquire(long timeout, java.util.concurrent.TimeUnit unit) {
        return rateLimiter.tryAcquire(timeout, unit);
    }
}
缓存场景建议: 对热点缓存 Key 设置独立的 RateLimiter 实例,避免全局限流误伤正常 Key。例如:ConcurrentHashMap<String, RateLimiter> 按 Key 前缀隔离。

2.3 漏桶算法(Leaky Bucket)

2.3.1 原理与数学模型

漏桶将请求放入一个固定容量的队列(桶),以恒定速率从队列底部漏出处理。当桶满时,新请求被直接丢弃。漏桶强制输出速率恒定,是 “削峰填谷” 的典型代表。

  • 关键参数: 漏出速率 r(请求/秒)、桶容量 b(最大排队数)
  • 核心特性: 无论入口流量多陡峭,出口流量永远平滑
  • 与令牌桶区别: 漏桶不允许突发,令牌桶允许突发
漏桶算法原理图 突发请求 漏桶(队列) 容量 b = 8 请求排队中 (当前 5 个) 恒定速率漏出 处理单元 ❌ 丢弃 桶满 算法特性 • 强制削峰:输出完全平滑 • 无突发能力:桶满即丢 • 适合写缓存/数据库保护 • 队列等待可能增加延迟

2.3.2 Spring Boot 实现(基于阻塞队列)

import org.springframework.stereotype.Component;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

@Component
public class LeakyBucketLimiter {

    private final BlockingQueue<Runnable> queue;
    private final int capacity = 10;

    public LeakyBucketLimiter() {
        this.queue = new LinkedBlockingQueue<>(capacity);
        // 启动漏出线程,固定速率 2 请求/秒
        startLeaking(2);
    }

    private void startLeaking(int ratePerSecond) {
        new Thread(() -> {
            while (true) {
                try {
                    Runnable task = queue.take(); // 阻塞直到有任务
                    task.run();
                    TimeUnit.MILLISECONDS.sleep(1000 / ratePerSecond);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }, "leaky-bucket-worker").start();
    }

    public boolean tryAcquire(Runnable task) {
        return queue.offer(task); // 队列满时返回 false
    }
}
注意: 漏桶的队列实现会占用内存,且任务可能长时间等待。在缓存一致性场景中,建议将超时时间纳入考量,避免请求在队列中等待过久导致缓存数据已失效。

2.4 滑动窗口算法(Sliding Window)

2.4.1 原理与数学模型

滑动窗口将固定时间窗口(如 1 秒)细分为 N 个小格子(如 10 个 100ms 格子)。每个格子独立计数,窗口整体滑动时,丢弃过期格子,加入新格子。相比固定窗口,滑动窗口能有效避免 “窗口边界突发” 问题。

  • 关键参数: 窗口大小 W(秒)、格子数 N、阈值 T(请求数)
  • 精度: 格子越多,精度越高,内存开销也越大
  • 优势: 无临界突变,适合秒杀等瞬时高并发场景
对比项 固定窗口(1s) 滑动窗口(10格)
边界 0.9s~1.1s 突发 可能翻倍通过 被精确限制
内存开销 1 个计数器 N 个计数器
实现复杂度 低 中
适用场景 一般 API 限流 热点 Key、秒杀

2.4.2 Spring Boot 实现(基于环形数组)

import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicIntegerArray;

@Component
public class SlidingWindowLimiter {

    private final int windowSize;      // 窗口大小(秒)
    private final int slotCount;       // 格子数
    private final int threshold;       // 阈值
    private final AtomicIntegerArray slots;
    private volatile long startTime;   // 窗口起始时间戳(毫秒)

    public SlidingWindowLimiter() {
        this.windowSize = 1;    // 1 秒窗口
        this.slotCount = 10;    // 10 个格子,每个 100ms
        this.threshold = 100;   // 每秒最多 100 请求
        this.slots = new AtomicIntegerArray(slotCount);
        this.startTime = System.currentTimeMillis();
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        long elapsed = now - startTime;
        // 如果超过窗口大小,重置整个窗口
        if (elapsed >= windowSize * 1000L) {
            startTime = now;
            for (int i = 0; i < slotCount; i++) {
                slots.set(i, 0);
            }
        }
        // 计算当前格子索引
        int slotIndex = (int) ((elapsed / (windowSize * 1000L / slotCount)) % slotCount);
        // 统计窗口内总请求数
        int total = 0;
        for (int i = 0; i < slotCount; i++) {
            total += slots.get(i);
        }
        if (total >= threshold) {
            return false; // 限流
        }
        slots.incrementAndGet(slotIndex);
        return true;
    }
}
缓存一致性增强: 滑动窗口可与缓存 Key 绑定,实现 Key 级别限流。例如:cacheKey:user:12345 在 1 秒内最多访问 50 次,防止单个热点 Key 打爆缓存节点。

2.5 三种算法选型决策树

在实际项目中,应根据流量特征和缓存一致性要求选择算法:

  1. 需要允许突发 + 缓存读多写少 → 令牌桶(Guava RateLimiter)
  2. 需要强制平滑 + 缓存写/数据库保护 → 漏桶(阻塞队列)
  3. 需要高精度 + 热点 Key 防护 → 滑动窗口(环形数组)
  4. 分布式场景 + 统一配置 → 基于 Redis + Lua 的令牌桶或滑动窗口
最佳实践: 在 Spring Boot 项目中,建议将限流器封装为注解 + AOP 切面,通过 @RateLimit(algorithm = "token-bucket", rate = 10, capacity = 20) 声明式使用,便于统一治理和监控。