限流模式:令牌桶、漏桶、滑动窗口与计数器

限流这个话题,说白了就是「别让流量把系统冲垮」。我做了十几年高并发系统,见过太多因为没做限流而翻车的案例。有一次线上大促,某个接口突然被刷了上千万次请求,数据库连接池瞬间打满,整个服务挂了半小时。嗯,从那以后,限流就成了我架构设计里的标配。

今天咱们聊聊四种经典的限流算法,以及两个工业级的限流工具——Guava RateLimiter 和 Sentinel。我会结合自己的实战经验,把每个方案的优缺点、适用场景都讲透。

一、计数器限流:最简单,但最粗糙

计数器限流,思路很直白:在固定时间窗口内,统计请求次数,超过阈值就拒绝。

// Java 示例:简单计数器限流
public class CounterLimiter {
    private final int maxRequests;      // 最大请求数
    private final long windowSize;      // 窗口大小(毫秒)
    private long windowStart;           // 窗口开始时间
    private int counter;                // 当前计数

    public CounterLimiter(int maxRequests, long windowSize) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize;
        this.windowStart = System.currentTimeMillis();
        this.counter = 0;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowSize) {
            // 新窗口
            windowStart = now;
            counter = 0;
        }
        if (counter < maxRequests) {
            counter++;
            return true;
        }
        return false;
    }
}
⚠️ 注意:计数器限流有个经典问题——「临界突变」。比如窗口是1秒,阈值是100。如果在第0.9秒来了100个请求,第1.0秒又来了100个,实际上在0.1秒内处理了200个请求。我曾经在一个网关项目里踩过这个坑,后来换成了滑动窗口。

二、滑动窗口:解决临界突变

滑动窗口把时间窗口切分成更小的格子,每个格子独立计数。窗口滑动时,只保留最近的N个格子。

// Java 示例:滑动窗口限流(简化版)
public class SlidingWindowLimiter {
    private final int maxRequests;
    private final long windowSize;
    private final int subWindows;       // 子窗口数量
    private final long subWindowSize;   // 每个子窗口大小
    private final AtomicInteger[] counters;
    private volatile int currentIndex;

    public SlidingWindowLimiter(int maxRequests, long windowSize, int subWindows) {
        this.maxRequests = maxRequests;
        this.windowSize = windowSize;
        this.subWindows = subWindows;
        this.subWindowSize = windowSize / subWindows;
        this.counters = new AtomicInteger[subWindows];
        for (int i = 0; i < subWindows; i++) {
            counters[i] = new AtomicInteger(0);
        }
        this.currentIndex = 0;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        int index = (int) ((now % windowSize) / subWindowSize);
        // 如果跨窗口,重置旧窗口计数
        if (index != currentIndex) {
            counters[index].set(0);
            currentIndex = index;
        }
        int total = 0;
        for (AtomicInteger c : counters) {
            total += c.get();
        }
        if (total < maxRequests) {
            counters[index].incrementAndGet();
            return true;
        }
        return false;
    }
}

你想想看,滑动窗口相当于把「粗粒度」的统计变成了「细粒度」的统计。子窗口越多,精度越高,但内存开销也越大。我个人习惯用10个子窗口,精度和性能比较均衡。

三、漏桶算法:削峰填谷,稳如老狗

漏桶的核心思想是「匀速处理」。请求先进入桶里,然后以固定速率漏出。桶满了就丢弃请求。

// C++ 示例:漏桶限流
class LeakyBucket {
private:
    int capacity;           // 桶容量
    int water;              // 当前水量
    int leakRate;           // 漏水速率(请求/秒)
    std::chrono::steady_clock::time_point lastLeakTime;

public:
    LeakyBucket(int cap, int rate) 
        : capacity(cap), water(0), leakRate(rate) {
        lastLeakTime = std::chrono::steady_clock::now();
    }

    bool tryAcquire() {
        auto now = std::chrono::steady_clock::now();
        auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - lastLeakTime).count();
        // 先漏水
        int leaked = static_cast<int>(elapsed * leakRate / 1000.0);
        water = std::max(0, water - leaked);
        lastLeakTime = now;

        if (water < capacity) {
            water++;
            return true;
        }
        return false;
    }
};
💡 实战经验:漏桶适合需要「绝对平滑」的场景,比如数据库写入、消息队列发送。我曾经在日志采集系统里用漏桶,把突发写入量从每秒10万条平滑到每秒5000条,后端存储一点压力都没有。

四、令牌桶:允许突发,更灵活

令牌桶和漏桶正好相反。它以固定速率生成令牌,请求需要拿到令牌才能通过。桶里可以积累令牌,所以允许一定程度的突发流量。

// Java 示例:令牌桶限流
public class TokenBucketLimiter {
    private final int capacity;         // 桶容量
    private final double refillRate;    // 令牌生成速率(个/秒)
    private double tokens;              // 当前令牌数
    private long lastRefillTime;

    public TokenBucketLimiter(int capacity, double refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.tokens = capacity;
        this.lastRefillTime = System.nanoTime();
    }

    public synchronized boolean tryAcquire(int permits) {
        refill();
        if (tokens >= permits) {
            tokens -= permits;
            return true;
        }
        return false;
    }

    private void refill() {
        long now = System.nanoTime();
        double elapsed = (now - lastRefillTime) / 1_000_000_000.0;
        tokens = Math.min(capacity, tokens + elapsed * refillRate);
        lastRefillTime = now;
    }
}

令牌桶是我个人最常用的限流算法。为什么?因为它既能保证平均速率,又能应对突发。比如接口平时QPS是1000,突然来了2000的流量,只要桶里有足够的令牌,就能全部放行。但长期来看,平均速率还是1000。

五、四种算法对比

算法 核心思想 突发处理 平滑度 实现复杂度 典型场景
计数器 固定窗口计数 差(临界突变) 简单限流,不要求精度
滑动窗口 细粒度窗口统计 较好 中等 API网关、接口限流
漏桶 匀速处理 差(丢弃突发) 最好 数据库写入、消息队列
令牌桶 积累令牌,允许突发 较好 通用限流、微服务

六、Guava RateLimiter:开箱即用的令牌桶

Guava 的 RateLimiter 实现了令牌桶算法,而且做了很多优化。比如它支持「预热」模式——刚启动时速率较低,慢慢提升到目标速率。这在应对冷启动场景时特别有用。

// Java 示例:Guava RateLimiter
import com.google.common.util.concurrent.RateLimiter;

public class GuavaRateLimiterDemo {
    public static void main(String[] args) {
        // 每秒生成10个令牌
        RateLimiter limiter = RateLimiter.create(10.0);
        
        // 带预热:前5秒从1/3速率逐渐提升到10
        // RateLimiter limiter = RateLimiter.create(10.0, 5, TimeUnit.SECONDS);

        for (int i = 0; i < 20; i++) {
            // 获取1个令牌,阻塞直到获取成功
            double waitTime = limiter.acquire();
            System.out.println("请求 " + i + " 等待了 " + waitTime + " 秒");
        }
    }
}
🔑 关键点:RateLimiter.acquire() 是阻塞的,如果令牌不够,线程会等待。如果你不想阻塞,可以用 tryAcquire() 方法,它会立即返回 true/false。

七、Sentinel:企业级限流降级方案

Sentinel 是阿里开源的流量控制组件,它不只是限流,还支持熔断降级、系统保护等。我参与过的一个电商项目,就是用 Sentinel 做全链路限流,效果非常好。

// Java 示例:Sentinel 限流
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;

import java.util.ArrayList;
import java.util.List;

public class SentinelDemo {
    public static void initFlowRules() {
        List<FlowRule> rules = new ArrayList<>();
        FlowRule rule = new FlowRule();
        rule.setResource("orderService");
        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule.setCount(100);  // QPS 限制为 100
        rules.add(rule);
        FlowRuleManager.loadRules(rules);
    }

    public static void main(String[] args) {
        initFlowRules();
        while (true) {
            try (Entry entry = SphU.entry("orderService")) {
                // 业务逻辑
                System.out.println("请求通过");
            } catch (BlockException e) {
                // 限流了,执行降级逻辑
                System.out.println("请求被限流,返回降级结果");
            }
        }
    }
}

Sentinel 支持多种限流模式:直接拒绝、Warm Up(预热)、排队等待。它还支持调用链路限流,比如限制某个接口的调用深度。我记得有一次排查线上问题,发现是某个服务循环调用自己,Sentinel 的链路限流直接把它掐断了。

八、避坑指南

我曾经在限流配置上犯过一个低级错误:把令牌桶的容量设得太大,结果突发流量直接打满了后端连接池。后来我总结了几条经验:

  • 容量不要超过后端处理能力的2倍。比如后端能扛1000 QPS,令牌桶容量设2000就够了。
  • 限流一定要配合降级。被限流的请求不能直接抛异常,要返回友好的提示或走缓存。
  • 分布式限流要考虑时钟同步。如果多台机器各自限流,总流量可能超标。可以用 Redis 做分布式计数器。
  • 监控限流效果。我习惯在限流日志里记录被拒绝的请求数,如果这个数字突然飙升,说明系统有异常流量。

九、知识体系总览

下面这张图总结了限流模式的核心知识点,方便你快速回顾:

限流模式 计数器 滑动窗口 漏桶 令牌桶 固定窗口计数 有临界突变问题 细粒度窗口 精度可调 匀速处理 削峰填谷 允许突发 最常用 Guava RateLimiter Sentinel 基于令牌桶,支持预热 阻塞/非阻塞两种模式 支持限流+熔断+降级 全链路流量控制
📌 我的建议:如果你是刚开始做限流,先从 Guava RateLimiter 入手,它简单易用,能满足大部分场景。等系统复杂了,再引入 Sentinel 做更精细的控制。记住,限流不是目的,保证系统稳定才是。
公众号:蓝海资料掘金营,微信deep3321