7、限流模式:令牌桶算法、漏桶算法、计数器算法、滑动窗口、Guava RateLimiter与Sentinel

限流这个话题,说白了就是「别让流量把系统冲垮」。我早年做电商大促时吃过这个亏——流量一上来,数据库连接池直接打满,整个系统雪崩。从那以后,限流就成了我设计高可靠系统时的必备手段。

今天咱们把限流这块掰开揉碎讲清楚。从算法原理到工程落地,我会结合自己的踩坑经历,帮你建立一套完整的限流知识体系。

核心观点:限流不是拒绝用户,而是保护系统。好的限流策略,能让系统在洪峰下依然稳定服务。

7.1 四种经典限流算法

先看算法层面。我习惯把这四种算法分成两类:计数器类队列类。计数器类简单粗暴,队列类平滑优雅。

7.1.1 计数器算法

这是最朴素的思路。我设定一个时间窗口(比如1秒),窗口内最多处理100个请求。超过就拒绝。

// 伪代码示例
public class CounterLimiter {
    private long windowStart = System.currentTimeMillis();
    private int count = 0;
    private final int limit = 100;
    private final long windowSize = 1000; // 1秒

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowSize) {
            windowStart = now;
            count = 0;
        }
        if (count < limit) {
            count++;
            return true;
        }
        return false;
    }
}

嗯,这里要注意。计数器算法有个经典问题——临界突变。举个例子:窗口是1秒100次,如果第0.9秒来了100次,第1.0秒又来了100次,那实际上0.2秒内就处理了200次请求。这显然不合理。

我曾经在生产环境用过纯计数器限流,结果发现流量曲线像锯齿一样剧烈抖动。后来才意识到是临界突变导致的。

7.1.2 滑动窗口算法

滑动窗口就是为了解决临界突变而生的。它把大窗口切成多个小格子,每个格子独立计数。窗口滑动时,只保留最近N个格子的数据。

// 滑动窗口核心逻辑
public class SlidingWindowLimiter {
    private final int windowSize = 10; // 10个格子
    private final int[] counts = new int[windowSize];
    private int currentIndex = 0;
    private final int limitPerWindow = 100;

    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        int index = (int)(now / 100) % windowSize; // 每100ms一个格子
        
        if (index != currentIndex) {
            // 切换到新格子,重置旧格子
            currentIndex = index;
            counts[index] = 0;
        }
        
        int total = sum(counts);
        if (total < limitPerWindow) {
            counts[index]++;
            return true;
        }
        return false;
    }
}

你想想看,格子切得越细,流量就越平滑。我一般建议切成10~20个格子,太细了反而增加计算开销。

7.1.3 漏桶算法

漏桶的思路很形象——想象一个桶,底部有个小洞。水(请求)以恒定速率从洞口流出。如果桶满了,新来的水就溢出(被拒绝)。

我个人习惯用漏桶处理突发流量。它能把不规则的请求流,变成平滑的输出流。但缺点也很明显:如果桶容量设置太小,正常流量也会被限。

// 漏桶算法示例
public class LeakyBucket {
    private long lastLeakTime = System.currentTimeMillis();
    private int water = 0;
    private final int capacity = 100;
    private final int leakRate = 10; // 每秒漏10个

    public synchronized boolean tryAcquire() {
        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;
    }
}

7.1.4 令牌桶算法

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

令牌桶的好处是允许一定程度的突发。比如桶里有100个令牌,瞬间来100个请求都能通过。这在处理秒杀场景时特别有用。

// 令牌桶算法示例
public class TokenBucket {
    private long lastRefillTime = System.currentTimeMillis();
    private int tokens = 0;
    private final int capacity = 100;
    private final int refillRate = 10; // 每秒放10个令牌

    public synchronized boolean tryAcquire() {
        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;
    }
}

我的建议:如果业务允许突发流量,优先选令牌桶。如果要求绝对平滑,选漏桶。滑动窗口适合对精度要求高的场景。

7.2 工程实践:Guava RateLimiter

聊完算法,咱们看看工程落地。Guava的RateLimiter是我用得最多的限流组件。它基于令牌桶实现,但做了不少优化。

// Guava RateLimiter 使用示例
import com.google.common.util.concurrent.RateLimiter;

public class GuavaDemo {
    // 每秒放10个令牌
    private final RateLimiter limiter = RateLimiter.create(10.0);
    
    public void handleRequest() {
        // 等待获取令牌(阻塞)
        double waitTime = limiter.acquire();
        System.out.println("等待了 " + waitTime + " 秒");
        // 处理业务逻辑
    }
    
    public boolean tryHandle() {
        // 非阻塞尝试
        return limiter.tryAcquire(100, TimeUnit.MILLISECONDS);
    }
}

我记得有一次线上问题,就是RateLimiter的acquire()方法导致接口响应变慢。后来改成tryAcquire()加超时,问题就解决了。说白了,阻塞等待在某些场景下并不合适。

关键点:RateLimiter支持预热模式(WarmUp)。刚启动时令牌生成速率慢,逐渐加速到目标速率。这能避免冷启动时流量冲击。

7.3 企业级方案:Sentinel

如果系统规模大了,Guava就有点不够用了。这时候我推荐阿里开源的Sentinel。它不光做限流,还集成了熔断、降级、系统保护等功能。

Sentinel的限流策略更丰富:

  • QPS限流:按每秒请求数限流
  • 线程数限流:限制并发线程数
  • 关联限流:当关联资源达到阈值时,限流当前资源
  • 链路限流:针对调用链路限流
// Sentinel 限流配置示例
public class SentinelDemo {
    public static void main(String[] args) {
        // 定义限流规则
        FlowRule rule = new FlowRule();
        rule.setResource("orderService");
        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        rule.setCount(100); // QPS限制为100
        
        FlowRuleManager.loadRules(Collections.singletonList(rule));
        
        // 业务代码中埋点
        try (Entry entry = SphU.entry("orderService")) {
            // 处理订单
        } catch (BlockException e) {
            // 被限流了,执行降级逻辑
            System.out.println("请求被限流,请稍后重试");
        }
    }
}

我曾经在微服务架构中只用单机限流,结果某个节点挂了,流量全打到另一个节点上,直接打穿。后来改用Sentinel的集群限流,才真正稳下来。

7.4 算法对比与选型

我整理了一张对比表,方便你快速决策:

算法 允许突发 平滑度 实现复杂度 适用场景
计数器 简单场景,精度要求不高
滑动窗口 需要精确控制流量
漏桶 需要绝对平滑输出
令牌桶 允许突发,通用场景

7.5 知识体系总览

下面这张图,是我梳理的限流模式知识体系。你可以把它当作一个速查地图:

限流模式知识体系 限流模式 四种经典算法 计数器 滑动窗口 漏桶 令牌桶 工程实践 Guava RateLimiter Sentinel 选型建议:看业务场景 允许突发 → 令牌桶 | 绝对平滑 → 漏桶 | 精确控制 → 滑动窗口

7.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要全局限流:不同接口的流量特征不同,应该按接口粒度配置限流规则
  • 注意分布式一致性:多节点部署时,单机限流可能不准确,考虑用Redis实现分布式限流
  • 限流不等于降级:被限流后要有合理的降级策略,比如返回友好提示、走缓存等
  • 监控要跟上:限流触发时一定要有日志和告警,否则出了问题都不知道

我的习惯:每次上线限流策略,我都会先在灰度环境压测一周,观察流量曲线和响应时间。没问题了再全量发布。

限流这件事,说起来简单,做起来全是细节。希望今天的内容能帮你少走弯路。记住,限流的最终目的不是限制用户,而是让系统在极端情况下依然能提供稳定的服务。

公众号:蓝海资料掘金营,微信deep3321