Java - 限流

1. 为什么需要限流?

1.1 现实场景痛点

  • 防止服务被大量请求压垮(如恶意攻击、突发流量)
  • 保证系统稳定性(防止高并发时资源耗尽)
  • 服务器资源耗尽(CPU/内存/连接数)
  • 提高资源利用率(保证关键请求优先执行)
  • 防止雪崩效应(避免一个节点过载导致系统崩溃)

1.2 限流的本质

通过控制请求通过的速率(Rate Limiting),保证系统在可承受范围内运行。其核心目标是:

  • 保护系统稳定性:防止突发流量压垮服务
  • 公平分配资源:避免少数用户占用全部资源
  • 实现优雅降级:超限请求快速失败而非堆积

2. 常见限流算法有哪些?

简单介绍 4 种非常好理解并且容易实现的限流算法!

建议阅读:常见限流算法

我觉得这篇文章已经讲的很详细了。

2.1 固定窗口限流算法

固定窗口限流算法又称为计数器算法

固定窗口其实就是时间窗口,其原理是将时间划分为固定大小的窗口,在每个窗口内限制请求的数量或速率,即固定窗口计数器算法规定了系统单位时间处理的请求数量。

  • 当次数少于限流阀值,就允许访问,并且计数器+1。
  • 当次数大于限流阀值,就拒绝访问。
  • 当前的时间窗口过去之后,计数器清零。

假设单位时间是1秒,限流阀值为3。在单位时间1秒内,每来一个请求,计数器就加1,如果计数器累加的次数超过限流阀值3,后续的请求全部拒绝。等到1s结束后,计数器清0,重新开始计数。如下图:

示例代码:

/**
 * @author 念心卓
 * @version 1.0
 * @description: 计数器(固定窗口)限流
 * @date 2025/2/9 11:50
 */
public class CounterRateLimiter {
    private long windowStart;
    private final long windowSize; // 时间窗口大小,单位毫秒
    private final long limit; // 限制次数
    private long count; // 计数器

    public CounterRateLimiter(long windowSize, long limit) {
        this.windowStart = System.currentTimeMillis();
        this.windowSize = windowSize;
        this.limit = limit;
        this.count = 0;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowSize) {
            // 重置窗口
            count = 0;
            windowStart = now;
        }

        if (count < limit) {
            count++;
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        CounterRateLimiter rateLimiter = new CounterRateLimiter(1000, 3);
        for (int i = 0; i < 5; i++) {
            System.out.println(String.format("当前时间【%s】- 窗口开始时间【%s】- 时间窗口【%s】- 限流阈值【%s】- 计数器【%s】;",
                    System.currentTimeMillis(),
                    rateLimiter.windowStart,
                    rateLimiter.windowSize,
                    rateLimiter.limit,
                    rateLimiter.count)
            );
            if (!rateLimiter.tryAcquire()) {
                System.out.println("哎呀,被限流啦~");
            }
        }
    }
}

执行结果

优点:实现简单,易于理解。

缺点:

  • 限流不够平滑。例如,我们限制某个接口每分钟只能访问 30 次,假设前 30 秒就有 30 个请求到达的话,那后续 30 秒将无法处理请求,这是不可取的,用户体验极差!

  • 无法保证限流速率,因而无法应对突然激增的流量(即流量突刺)。例如,我们限制某个接口1分钟只能访问1000次,该接口的QPS为500,前55s这个接口 1个请求没有接收,后1s突然接收了1000个请求。然后,在当前场景下,这 1000 个请求在1s内是没办法被处理的,系统直接就被瞬时的大量请求给击垮了。

  • 临界问题:假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s,则并发数高达10,已经超过单位时间1s不超过5阀值的定义啦。

    临界问题

2.2 滑动窗口限流算法

滑动窗口计数器算法算的上是固定窗口限流算法的升级版,限流的颗粒度更小。

滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片

例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。

滑动窗口限流解决固定窗口临界值的问题。它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。

一张图解释滑动窗口算法,如下:

滑动窗口

假设单位时间还是1s,滑动窗口算法把它划分为5个小周期,也就是滑动窗口(单位时间)被划分为5个小格子。每格表示0.2s。每过0.2s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是0.83s到达的,0.8~1.0s对应的计数器就会加1。

我们来看下滑动窗口是如何解决临界问题的?

假设我们1s内的限流阀值还是5个请求,0.8~1.0s内(比如0.9s的时候)来了5个请求,落在黄色格子里。时间过了1.0s这个点之后,又来5个请求,落在紫色格子里。如果是固定窗口算法,是不会被限流的,但是滑动窗口的话,每过一个小周期,它会右移一个小格。过了1.0s这个点后,会右移一小格,当前的单位时间段是0.2~1.2s,这个区域的请求已经超过限定的5了,已触发限流啦,实际上,紫色格子的请求都被拒绝啦。

[!tip]

很显然, 当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

示例代码:

/**
 * @author 念心卓
 * @version 1.0
 * @description: 滑动窗口限流算法
 * @date 2025/2/9 12:25
 */
public class SlidingWindowRateLimiter {
    //滑动窗口,元素的值为时间戳,元素的个数表示 当前已经请求了多少次了
    private final LinkedList<Long> slidingWindow;
    private final long windowSize; // 时间窗口大小,单位毫秒
    private final long limit; // 限制次数

    public SlidingWindowRateLimiter(long windowSize, long limit) {
        this.slidingWindow = new LinkedList<>();
        this.windowSize = windowSize;
        this.limit = limit;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();

        // 移除已过期的时间戳
        while (!slidingWindow.isEmpty() && now - slidingWindow.getFirst() > windowSize) {
            slidingWindow.removeFirst();
        }

        if (slidingWindow.size() < limit) {
            slidingWindow.addLast(now);
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        SlidingWindowRateLimiter rateLimiter = new SlidingWindowRateLimiter(1000, 5);
        for (int i = 0; i < 7; i++) {
            System.out.println(String.format("当前时间【%s】- 时间窗口【%s】- 限流阈值【%s】- 计数器【%s】;",
                    System.currentTimeMillis(),
                    rateLimiter.windowSize,
                    rateLimiter.limit,
                    rateLimiter.slidingWindow.size())
            );
            if (!rateLimiter.tryAcquire()) {
                System.out.println("哎呀,被限流啦~");
            }
        }
    }
}

执行结果

优点:

  • 相比于固定窗口算法,滑动窗口计数器算法可以应对突然激增的流量
  • 相比于固定窗口算法,滑动窗口计数器算法的颗粒度更小,可以提供更精确的限流控制

缺点:

  • 与固定窗口计数器算法类似,滑动窗口计数器算法依然存在限流不够平滑的问题。
  • 相比较于固定窗口计数器算法,滑动窗口计数器算法实现和理解起来更复杂一些。
  • 需要维护时间窗口数据,增加内存消耗。

2.3 漏桶限流算法

我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。

如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。

漏桶示意图

  • 流入的水滴,可以看作是访问系统的请求,这个流入速率是不确定的。
  • 桶的容量一般表示系统所能处理的请求数。
  • 如果桶的容量满了,就达到限流的阀值,就会丢弃水滴(拒绝请求)
  • 流出的水滴,是恒定过滤的,对应服务按照固定的速率处理请求。

示例代码:

/**
 * @author 念心卓
 * @version 1.0
 * @description: 漏桶限流算法
 * @date 2025/2/9 15:18
 */
public class LeakyBucketRateLimiter {
    private final long capacity;    // 桶的容量
    private final double leakRate;  // 漏出速率(请求/秒)
    private long water;            // 当前水量
    private long lastLeakTime;     // 上次漏水时间

    public LeakyBucketRateLimiter(long capacity, double leakRate) {
        this.capacity = capacity;
        this.leakRate = leakRate;
        this.water = 0;
        this.lastLeakTime = System.currentTimeMillis();
    }

    public synchronized boolean tryAcquire() {
        leak();
        if (water < capacity) {
            water++;
            return true;
        }
        return false;
    }

    private void leak() {
        long now = System.currentTimeMillis();
        long deltaTime = now - lastLeakTime;
        long leakedWater = (long) (deltaTime / 1000.0 * leakRate);
        water = Math.max(0, water - leakedWater);
        lastLeakTime = now;
    }

    public static void main(String[] args) throws InterruptedException {
        //容量为5,每秒可以处理3个请求
        LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(5, 3);
        for (int i = 0; i < 15; i++) {
            System.out.println(String.format("当前时间【%s】- 上次漏水时间【%s】- 桶容量【%s】- 漏出速率【%s】- 当前水量【%s】;",
                    System.currentTimeMillis(),
                    rateLimiter.lastLeakTime,
                    rateLimiter.capacity,
                    rateLimiter.leakRate,
                    rateLimiter.water)
            );
            if (i % 5 == 0) Thread.sleep(500); //如果效果不明显,这里可以控制一下速率
            if (!rateLimiter.tryAcquire()) {
                System.out.println("哎呀,被限流啦~");
            }
        }
    }
}

执行结果

优点:

  • 实现简单,易于理解。
  • 可以控制限流速率,避免网络拥塞和系统过载。

缺点:

  • 无法应对突然激增的流量,因为只能以固定的速率处理请求,对系统资源利用不够友好。
  • 桶流入水(发请求)的速率如果一直大于桶流出水(处理请求)的速率的话,那么桶会一直是满的,一部分新的请求会被丢弃,导致服务质量下降。

[!note]

实际业务场景中,基本不会使用漏桶算法。

2.4 令牌桶限流算法

令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶(这限流算法和桶过不去啊)。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌,请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。如果桶装满了,就不能继续往里面继续添加令牌了。

令牌桶算法示意图

示例代码:

/**
 * @author 念心卓
 * @version 1.0
 * @description: 令牌桶限流算法
 * @date 2025/2/9 15:39
 */
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.currentTimeMillis();
    }

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

    private void refill() {
        long now = System.currentTimeMillis();
        double tokensToAdd = (now - lastRefillTime) / 1000.0 * refillRate;
        tokens = Math.min(capacity, tokens + tokensToAdd);
        lastRefillTime = now;
    }

    public static void main(String[] args) throws InterruptedException {
        TokenBucketRateLimiter rateLimiter = new TokenBucketRateLimiter(5, 3);
        for (int i = 0; i < 15; i++) {
            System.out.println(String.format("当前时间【%s】- 上次填充时间【%s】- 桶容量【%s】- 令牌填充速率【%s】- 当前令牌数量【%s】;",
                    System.currentTimeMillis(),
                    rateLimiter.lastRefillTime,
                    rateLimiter.capacity,
                    rateLimiter.refillRate,
                    rateLimiter.tokens)
            );
            if (i % 5 == 0) Thread.sleep(1000); //如果效果不明显,这里可以控制一下速率
            if (!rateLimiter.tryAcquire()) {
                System.out.println("哎呀,被限流啦~");
            }
        }
    }
}

执行结果


2.5 四种限流算法总结

  1. 计数器限流(固定窗口)

    原理:在固定时间窗口内计数,超过阈值则拒绝请求

    优点:实现简单,内存占用小

    缺点:可能出现临界问题,即在窗口切换时可能突破限制

    适用场景:简单的限流场景,对突发流量不敏感的场景

  2. 滑动窗口限流

    原理:使用一个队列记录请求的时间戳,动态滑动时间窗口

    优点:比固定窗口更平滑,没有临界问题

    缺点:需要存储请求记录,内存占用较大

    适用场景:需要更精确流量控制的场景

  3. 漏桶算法

    原理:请求以固定速率处理,超过容量的请求被丢弃

    优点:可以严格控制请求处理速率

    缺点:对突发流量处理不够灵活

    适用场景:需要严格控制处理速率的场景,如消息队列消费

  4. 令牌桶算法

    原理:系统以固定速率产生令牌,请求需要获取令牌才能放行

    优点:可以应对突发流量,具有一定缓冲能力

    缺点:初始令牌数会影响系统启动初期的表现

    适用场景:需要处理突发流量的场景,如API网关

实际应用建议:

  1. 对于简单的接口保护,可以使用计数器限流
  2. 需要更精确的控制时,使用滑动窗口限流
  3. 需要严格控制处理速率时,使用漏桶算法
  4. 需要处理突发流量时,使用令牌桶算法

3. 针对什么来进行限流?

实际项目中,还需要确定限流对象,也就是针对什么来进行限流。常见的限流对象如下:

  • IP :针对 IP 进行限流,适用面较广,简单粗暴。
  • 业务ID:挑选唯一的业务 ID 以实现更针对性地限流。例如,基于用户ID进行限流。
  • 个性化:根据用户的属性或行为,进行不同的限流策略。例如, VIP 用户不限流,而普通用户限流。根据系统的运行指标(如 QPS、并发调用数、系统负载等),动态调整限流策略。例如,当系统负载较高的时候,控制每秒通过的请求减少。

针 IP进行限流是目前比较常用的一个方案。不过,实际应用中需要注意用户真实 IP 地址的正确获取。常用的真实 IP 获取方法有X-Forwarded-For和TCP Options字段承载真实源IP信息。虽然X-Forwarded-For字段可能会被伪造,但因为其实现简单方便,很多项目还是直接用的这种方法。

除了我上面介绍到的限流对象之外,还有一些其他较为复杂的限流对象策略,比如阿里的 Sentinel 还支持 基于调用关系的限流(包括基于调用方限流、基于调用链入口限流、关联流量限流等)以及更细维度的 热点参数限流(实时的统计热点参数并针对热点参数的资源调用进行流量控制)。

另外,一个项目可以根据具体的业务需求选择多种不同的限流对象搭配使用。

4. 单机限流怎么做?

单机限流针对的是单体架构应用。

单机限流可以直接使用Google Guava自带的限流工具类 RateLimiterRateLimiter 基于令牌桶算法,可以应对突发流量。

Guava地址:Github - Guava

除了最基本的令牌桶算法(平滑突发限流)实现之外,Guava的RateLimiter还提供了平滑预热限流的算法实现。

平滑突发限流就是按照指定的速率放令牌到桶里,而平滑预热限流会有一段预热时间,预热时间之内,速率会逐渐提升到配置的速率。

我们直接在项目中引入 Guava 相关的依赖即可使用:

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.4.0-jre</version>
</dependency>

下面是一个简单的Guava平滑突发限流的Demo:

public class GuavaRateLimiterDemo {
    public static void main(String[] args) {
        // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里
        RateLimiter rateLimiter = RateLimiter.create(5);
        for (int i = 0; i < 10; i++) {
            //acquire是阻塞获取,返回值是等待的时间,tryAcquire是非阻塞获取,范围值是布尔值
            double sleepingTime = rateLimiter.acquire(1);
            System.out.printf("get 1 tokens: %ss%n", sleepingTime);
        }
    }
}

执行结果

下面是一个简单的Guava平滑预热限流的Demo。

public class GuavaRateLimiterDemo {
    public static void main(String[] args) {
        // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里
        // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里
        RateLimiter rateLimiter = RateLimiter.create(5, 3, TimeUnit.SECONDS);
        for (int i = 0; i < 20; i++) {
            //acquire是阻塞获取,返回值是等待的时间,tryAcquire是非阻塞获取,范围值是布尔值
            double sleepingTime = rateLimiter.acquire(1);
            System.out.printf("get 1 tokens: %sds%n", sleepingTime);
        }
    }
}

执行结果

可见,获取令牌等待的时间是越来越短的,到达0.2s左右的时候,基本就稳定了,可以看出确实是预热的。

另外,Bucket4j是一个非常不错的基于令牌/漏桶算法的限流库。

Bucket4j地址:Github - Bucket4j

相对于,Guava的限流工具类来说,Bucket4j提供的限流功能更加全面。不仅支持单机限流和分布式限流,还可以集成监控,搭配Prometheus和Grafana使用。

不过,毕竟Guava也只是一个功能全面的工具类库,其提供的开箱即用的限流功能在很多单机场景下还是比较实用的。

Spring Cloud Gateway中自带的单机限流的早期版本就是基于Bucket4j实现的。后来,替换成了Resilience4j

Resilience4j是一个轻量级的容错组件,其灵感来自于 Hystrix。自Netflix 宣布不再积极开发 Hystrix 之后,Spring 官方和 Netflix 都更推荐使用 Resilience4j 来做限流熔断。

Resilience4j地址: Github - Resilience4j

Resilience4j的ratelimiter地址:Resilience4j之ratelimiter

一般情况下,为了保证系统的高可用,项目的限流和熔断都是要一起做的。

Resilience4j不仅提供限流,还提供了熔断、负载保护、自动重试等保障系统高可用开箱即用的功能。并且,Resilience4j的生态也更好,很多网关都使用 Resilience4j来做限流熔断的。

因此,在绝大部分场景下Resilience4j或许会是更好的选择。如果是一些比较简单的限流场景的话,Guava或者Bucket4j也是不错的选择。

5. 分布式限流怎么做?

分布式限流针对的分布式/微服务应用架构应用,在这种架构下,单机限流就不适用了,因为会存在多种服务,并且一种服务也可能会被部署多份。

分布式限流常见的方案:

  • 借助中间件限流:可以借助Sentinel或者使用Redis来自己实现对应的限流逻辑。
  • 网关层限流:比较常用的一种方案,直接在网关层把限流给安排上了。不过,通常网关层限流通常也需要借助到中间件/框架。就比如Spring Cloud Gateway的分布式限流实现RedisRateLimiter就是基于Redis+Lua来实现的,再比如Spring Cloud Gateway还可以整合Sentinel来做限流。

如果你要基于Redis来手动实现限流逻辑的话,建议配合Lua脚本来做。

为什么建议Redis+Lua的方式? 主要有两点原因:

  • 减少了网络开销:我们可以利用Lua脚本来批量执行多条Redis命令,这些Redis命令会被提交到Redis服务器一次性执行完成,大幅减小了网络开销。
  • 原子性:一段Lua脚本可以视作一条命令执行,一段Lua脚本执行过程中不会有其他脚本或Redis命令同时执行,保证了操作不会被其他指令插入或打扰。

我这里就不放具体的限流脚本代码了,网上也有很多现成的优秀的限流脚本供你参考,就比如Apache网关项目ShenYu的RateLimiter限流插件就基于Redis+ Lua实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。

ShenYu地址: ShenYu

ShenYu

另外,如果不想自己写Lua脚本的话,也可以直接利用Redisson中的RRateLimiter来实现分布式限流,其底层实现就是基于Lua代码+令牌桶算法。

Redisson是一个开源的Java语言Redis客户端,提供了很多开箱即用的功能,比如Java中常用的数据结构实现、分布式锁、延迟队列等等。并且,Redisson还支持Redis单机、Redis Sentinel、Redis Cluster等多种部署架构。

RRateLimiter的使用方式非常简单。我们首先需要获取一个RRateLimiter对象,直接通过Redisson客户端获取即可。然后,设置限流规则就好。

// 创建一个 Redisson 客户端实例
RedissonClient redissonClient = Redisson.create();
// 获取一个名为 "nxz.limiter" 的限流器对象
RRateLimiter rateLimiter = redissonClient.getRateLimiter("nxz.limiter");
// 尝试设置限流器的速率为每小时 100 次
// RateType 有两种,OVERALL是全局限流,ER_CLIENT是单Client限流(可以认为就是单机限流)
rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS);

接下来我们调用acquire()方法或tryAcquire()方法即可获取许可。

// 获取一个许可,如果超过限流器的速率则会等待
// acquire()是同步方法,对应的异步方法:acquireAsync()
rateLimiter.acquire(1);
// 尝试在 5 秒内获取一个许可,如果成功则返回 true,否则返回 false
// tryAcquire()是同步方法,对应的异步方法:tryAcquireAsync()
boolean res = rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS);