4. 限流算法:从计数器到令牌桶

限流算法,说白了就是决定「谁先过、谁等着、谁走人」的那套规则。我刚开始做采集系统时,觉得限流嘛,搞个计数器不就行了?结果线上被教做人——计数器在边界上的毛刺,能把整个系统打穿。

这一章,我把四种主流算法掰开揉碎讲。每种我都会说清楚原理、适用场景,以及我踩过的坑。

4.1 计数器算法:简单但危险

计数器是最直观的限流方式。你设定一个时间窗口,比如1秒,窗口内最多处理100个请求。来了请求就加1,超过100就拒绝。

// 伪代码:计数器限流
class CounterLimiter {
    int maxRequests = 100;
    int currentCount = 0;
    long windowStart = System.currentTimeMillis();
    long windowSize = 1000; // 1秒

    boolean allow() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowSize) {
            // 新窗口,重置
            windowStart = now;
            currentCount = 0;
        }
        if (currentCount < maxRequests) {
            currentCount++;
            return true;
        }
        return false;
    }
}

代码看着没问题吧?但实际跑起来,你会遇到一个经典问题——临界突变。

我曾经踩过的坑: 计数器在窗口边界处,流量可以瞬间翻倍。比如窗口是0-1000ms,你在999ms时来了100个请求,然后在1001ms又来了100个请求。实际上这200个请求只隔了2ms,但系统却认为它们是两个窗口的「合法流量」。这就是计数器算法的硬伤。

所以计数器只适合对精度要求不高的场景,比如后台报表、非实时监控。你要是做支付接口的限流,千万别用这个。

4.2 滑动窗口算法:把窗口切细

滑动窗口解决了计数器的边界问题。思路很简单:把一个大窗口切成多个小格子,每个格子独立计数。窗口滑动时,只保留最近N个格子的数据。

举个例子。1秒的窗口,切成10个100ms的格子。每个格子维护自己的计数器。当时间前进时,最老的格子被丢弃,新格子加入。

// 伪代码:滑动窗口限流
class SlidingWindowLimiter {
    int windowSize = 10; // 10个格子
    long slotSize = 100; // 每个格子100ms
    int[] slots = new int[windowSize];
    int maxPerSlot = 10; // 每个格子最多10个请求

    boolean allow() {
        int currentSlot = (int)(System.currentTimeMillis() / slotSize % windowSize);
        // 检查当前窗口内所有格子的总和
        int total = 0;
        for (int count : slots) {
            total += count;
        }
        if (total < maxPerSlot * windowSize) {
            slots[currentSlot]++;
            return true;
        }
        return false;
    }
}

滑动窗口的精度取决于格子数量。格子越多,精度越高,但内存开销也越大。我个人习惯是切成10-20个格子,既能平滑流量,又不至于太耗资源。

小技巧: 如果你用Redis实现滑动窗口,可以用ZSET(有序集合)来存储每个请求的时间戳。每次请求时,删除窗口外的旧数据,再统计集合大小。这样既简单又精确。

4.3 漏桶算法:匀速流出

漏桶算法的思路很形象。想象一个桶,上面进水,下面漏水。不管上面倒得多猛,下面始终以固定速率滴水。桶满了,水就溢出去(请求被拒绝)。

这个算法的核心是强制匀速。它不管请求到达的节奏,只管处理速率是恒定的。

// 伪代码:漏桶限流
class LeakyBucketLimiter {
    int capacity = 100; // 桶容量
    int water = 0;      // 当前水量
    long leakRate = 10; // 每秒漏10滴
    long lastLeakTime = System.currentTimeMillis();

    boolean allow() {
        long now = System.currentTimeMillis();
        // 先漏水
        long elapsed = now - lastLeakTime;
        int leaked = (int)(elapsed * leakRate / 1000);
        water = Math.max(0, water - leaked);
        lastLeakTime = now;

        if (water < capacity) {
            water++;
            return true;
        }
        return false;
    }
}

漏桶适合需要平滑的场景。比如你对接第三方API,对方要求每秒不超过50次,而且必须均匀分布。这时候漏桶就是最佳选择。

但漏桶也有缺点。它不能应对突发流量——即使系统有空闲资源,漏桶也不会加快处理。说白了,它有点「死板」。

4.4 令牌桶算法:允许突发

令牌桶是我最常用的算法。它跟漏桶相反——不是控制流出,而是控制流入。系统以固定速率往桶里放令牌,请求来了就拿令牌。有令牌就处理,没有就等或拒绝。

关键区别在于:令牌桶允许一定程度的突发。如果桶里攒了100个令牌,突然来100个请求,可以一次性全部处理。

// 伪代码:令牌桶限流
class TokenBucketLimiter {
    int capacity = 100;      // 桶容量
    int tokens = 100;        // 当前令牌数
    double refillRate = 10;  // 每秒补充10个令牌
    long lastRefillTime = System.currentTimeMillis();

    boolean allow() {
        long now = System.currentTimeMillis();
        // 补充令牌
        long elapsed = now - lastRefillTime;
        int newTokens = (int)(elapsed * refillRate / 1000);
        tokens = Math.min(capacity, tokens + newTokens);
        lastRefillTime = now;

        if (tokens > 0) {
            tokens--;
            return true;
        }
        return false;
    }
}
为什么我偏爱令牌桶? 因为实际业务中,流量天然就有波峰波谷。比如电商大促,用户抢购时瞬间流量暴增。令牌桶允许你「透支」一部分令牌,让系统在短时间内扛住峰值,然后慢慢恢复。这种弹性,是漏桶给不了的。

4.5 四种算法对比

我把四种算法的核心差异整理成了一张表。你选型时可以直接参考。

算法 核心思想 是否允许突发 精度 适用场景
计数器 固定窗口计数 是(有边界毛刺) 低 非关键监控、后台报表
滑动窗口 多格子滑动统计 是(可控) 中 通用场景,精度要求一般
漏桶 固定速率流出 否 高 第三方API对接、流量整形
令牌桶 固定速率补充令牌 是(可控) 高 高并发采集、突发流量场景

4.6 算法选型决策流程

你可能会问:那我到底该用哪个?别急,我画了一张决策流程图,你跟着走一遍就知道答案了。

限流算法选型决策流程 开始选型 需要平滑的流出速率? 是 漏桶算法 否 需要支持突发流量? 是 令牌桶算法 否 精度要求高吗? 是 滑动窗口 否 计数器算法 提示:令牌桶是大多数高并发采集场景的首选,兼顾平滑与突发

跟着图走一遍,你会发现大部分高并发采集场景都会落到令牌桶上。这也是为什么Guava的RateLimiter、Redis的令牌桶实现,在实际项目中用得最多。

我的建议: 如果你刚开始做限流,直接上令牌桶。它容错率高,调参也简单——调桶大小和补充速率就行。等你对业务流量有了感觉,再考虑要不要换成漏桶或滑动窗口。

嗯,这一章的内容就到这里。四种算法各有千秋,但记住一点:没有银弹。选型时多想想你的业务场景,别盲目追求「高级」算法。