找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4052

积分

0

好友

558

主题
发表于 2 小时前 | 查看: 4| 回复: 0

随着互联网业务的蓬勃发展,尤其在秒杀、大促活动或被恶意攻击等高并发场景下,系统的平稳运行面临着巨大挑战。为了应对瞬时洪峰流量,保护系统及下游服务,限流成为了一种关键的技术手段。除了限流,还有熔断、降级、隔离等方案,本文将聚焦于几种常见的限流算法,深入剖析其原理与实现。

所谓限流,就是对请求或并发数进行限制,通过控制单位时间内的请求量,来保障系统的核心服务能力。主流的限流算法包括计数器算法滑动窗口计数算法漏桶算法令牌桶算法

计数器算法

计数器算法是原理最简单的一种限流方式。其核心思想是:在一个固定的时间窗口内,通过计数器累加访问次数。我们预先设定一个周期(即时间窗口)内系统最多能处理的请求数量(即阈值)。当计数器达到该阈值时,便触发拒绝策略;待时间窗口结束,计数器归零,重新开始计数。因此,它也被称为固定窗口算法

例如,假设系统设定的阈值是1秒内最多处理100个请求。在计数器算法中,我们需要将时间划分为固定大小的窗口(这里以1秒为一个窗口),并用一个计数器记录当前窗口内的请求数。一旦请求数超过100,后续请求将被直接拒绝,直到这1秒结束,计数器重置。

固定窗口限流算法示意图

计数器算法的优点是实现简单、占用内存小、性能高。但它存在一个明显的缺陷:临界问题。由于时间窗口是固定的,流量在窗口内并非均匀分布。考虑一个极端情况:在 1.9秒2.1秒 这两个时间点,系统分别遭受了 100次 并发请求。从 1.9秒2.9秒 这1秒内,系统实际承受了 200次 请求,远超阈值。尽管 窗口2窗口3 各自的计数器并未超标,但这瞬间的流量冲击仍可能导致系统崩溃。

计数器算法临界问题示意图

以下是一个简单的Java实现Demo(其他语言可参考改写):

public class MyFixedWindowRateLimiterDemo {
    // 阈值
    private static Integer QPS = 2;
    // 时间窗口(毫秒)
    private static long TIME_WINDOWS = 1000;
    // 计数器
    private static AtomicInteger counter = new AtomicInteger();

    //上一个窗口的开始时间
    private static long START_TIME = System.currentTimeMillis();

    public synchronized static boolean tryAcquire() {
        if ((System.currentTimeMillis() - START_TIME) > TIME_WINDOWS) {
            counter.set(0);
            START_TIME = System.currentTimeMillis();
        }
        return counter.incrementAndGet() <= QPS;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread.sleep(250);
            LocalTime now = LocalTime.now();
            if (!tryAcquire()) {
                System.out.println(now + " 请求被限流");
            } else {
                System.out.println(now + " 请求通过");
            }
        }
    }
}

滑动窗口计数算法

滑动窗口算法 源自网络传输层的流量控制技术,它是对固定窗口算法的一种优化,旨在解决其临界问题。该算法不再使用固定大小的窗口,而是将一个大的时间窗口进一步划分为多个更小的周期。每个小周期都独立记录请求次数。随着时间推移,窗口会向后“滑动”(每次移动一个小周期),并删除过期的周期数据。判断是否限流时,需要累加当前窗口内所有小周期的请求总数,再与阈值进行比较。

滑动窗口算法示意图

沿用上文的例子,我们将1秒的时间窗口划分为5个200毫秒的小周期。当在 1.9秒2.1秒 分别出现100次请求时,滑动到第5个小周期时,请求量为100,未超阈值。但当窗口滑动到第6个小周期时,覆盖的请求量变为200,此时将触发限流。

滑动窗口划分得越细,窗口的滑动就越平滑,限流统计也越精确。但随之而来的是需要更多的存储空间来记录每个小周期的数据,实现上也比计数器算法更复杂一些。

哇 贼恐怖

以下是一个参考实现的Demo:

public class MySlidingWindowRateLimiterDemo {

    /** 队列id和队列的映射关系,队列里面存储的是每一次通过时候的时间戳,这样可以使得程序里有多个限流队列 */
    private volatile static Map<String, List<Long>> MAP = new ConcurrentHashMap<>();

    //阈值
    private static int QPS = 2;
    //时间窗口总大小(毫秒)
    private static long WindowSize = 10 * 1000;

    /**
     * 滑动时间窗口限流算法
     * 在指定时间窗口,指定限制次数内,是否允许通过
     *
     * @param listId     队列id
     * @param count      限制次数
     * @param timeWindow 时间窗口大小
     * @return 是否允许通过
     */
    public static synchronized boolean tryAcquire(String listId, int count, long timeWindow) {
        // 获取当前时间
        long nowTime = System.currentTimeMillis();
        // 根据队列id,取出对应的限流队列,若没有则创建
        List<Long> list = MAP.computeIfAbsent(listId, k -> new LinkedList<>());
        // 如果队列还没满,则允许通过,并添加当前时间戳到队列开始位置
        if (list.size() < count) {
            list.add(0, nowTime);
            return true;
        }

        // 队列已满(达到限制次数),则获取队列中最早添加的时间戳
        Long farTime = list.get(count - 1);
        // 用当前时间戳 减去 最早添加的时间戳
        if (nowTime - farTime <= timeWindow) {
            // 若结果小于等于timeWindow,则说明在timeWindow内,通过的次数大于count
            // 不允许通过
            return false;
        } else {
            // 若结果大于timeWindow,则说明在timeWindow内,通过的次数小于等于count
            // 允许通过,并删除最早添加的时间戳,将当前时间添加到队列开始位置
            list.remove(count - 1);
            list.add(0, nowTime);
            return true;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 20; i++) {
            Thread.sleep(1000);
            LocalTime now = LocalTime.now();
            if (!tryAcquire("ip", QPS, WindowSize)) {// 任意10秒内,只允许2次通过;自定义限流规则“ip”
                System.out.println(now + " 请求被限流");
            } else {
                System.out.println(now + " 请求通过");
            }
        }
    }
}

滑动窗口算法虽然解决了临界突变问题,但它依然存在一个特点:一旦窗口内流量达到阈值,后续请求会被瞬间切断。在实际生产环境中,我们更希望流量能平滑地进入系统,而非如此“粗暴”地被拒绝。

挠头......

漏桶算法

漏桶算法 是一个非常形象的比喻。想象一个底部有孔的桶:水(代表请求/流量)从进水口(生产端)流入桶中(一个队列),桶会以恒定的速率漏水(消费端持续处理请求)。如果进水速度过快,超过了漏水速度,桶内的水就会逐渐累积。一旦桶的容量(固定的)被装满,后续流入的水就会溢出(请求被拒绝)。这种模型能很好地实现流量整形和平滑限流。

漏桶算法原理图

这里“漏水”的速率就是限流的阈值,例如QPS为100,表示系统每秒最多能处理100个请求。如果生产端的速率持续超过阈值,请求就会在桶中堆积,最终因桶满而触发拒绝策略。

漏桶算法的优点在于实现简单、易于理解,并且能严格限制请求的处理速率,有效防止网络拥塞和系统过载。但它也有明显的缺点:

  1. 无论当前系统负载如何,请求都必须等待漏桶处理,这会导致固定的延迟,不适用于对实时性要求很高的场景。
  2. 由于处理速率是恒定的,即使网络空闲,也无法充分利用系统资源处理突发流量。

兔子眨眼表情动图

以下是一个简化的漏桶算法Demo(生产环境慎用):

public class MyLeakyBucketRateLimiterDemo {
    // 桶的容量
    private final int capacity;
    // 漏出速率
    private final int rate;
    // 剩余水量
    private long leftWater;
    // 上次注入时间
    private long timeStamp = System.currentTimeMillis();

    public MyLeakyBucketRateLimiterDemo(int rate, int capacity) {
        this.capacity = capacity;
        this.rate = rate;
    }

    public synchronized boolean tryAcquire() {
        //1. 计算剩余水量
        long now = System.currentTimeMillis();
        long timeGap = (now - timeStamp) / 1000;
        leftWater = Math.max(0, leftWater - timeGap * rate);
        timeStamp = now;

        // 如果未满,则放行;否则限流
        if (leftWater < capacity) {
            leftWater += 1;
            return true;
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        MyLeakyBucketRateLimiterDemo limiterDemo = new MyLeakyBucketRateLimiterDemo(2,5);
        for (int i = 0; i < 10; i++) {
            Thread.sleep(500);
            LocalTime now = LocalTime.now();
            if (!limiterDemo.tryAcquire()) {
                System.out.println(now + " 请求被限流");
            } else {
                System.out.println(now + " 请求通过");
            }
        }
    }
}

令牌桶算法

令牌桶算法 是漏桶算法的一种改进,也是网络流量整形和限流中最常用的算法。它同样有一个桶,但里面装的是固定数量的令牌。系统会以恒定的速率(阈值)向桶中投放令牌。当请求到达时,必须先从桶中获取一个令牌才能被处理,处理完毕后令牌被消耗。如果桶中没有令牌,则请求被拒绝或排队等待。投放令牌的过程是持续进行的,如果桶已满,新生成的令牌会被丢弃。

令牌桶算法架构图

表面上看,令牌桶和漏桶一个“加令牌”,一个“漏水”,方向相反。但令牌桶算法的精髓在于,它在限制平均流量的同时,允许一定程度的突发流量。这在大多数场景中是非常实用的,因为系统通常可以承受小批量的瞬时高峰。

到位表情包

当系统流量较平缓时,令牌桶会持续累积冗余的令牌。一旦出现突发流量,这些预存的令牌可以立即被消耗,使得请求能够快速通过。只有当突发流量超过桶的令牌储备时,超出的请求才会被限流。这使得系统既能保持稳定,又能充分利用资源应对合理的高峰。

当然,令牌桶算法的实现比漏桶更复杂,需要精确控制令牌的生成与消耗,并消耗更多内存来存储令牌状态。

在单机环境下,强烈推荐使用(或参考)Google Guava库中的RateLimiter工具类。首先引入依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.0-jre</version>
</dependency>

然后可以非常方便地使用:

public class MyTokenBucketRateLimiterDemo {
    public static void main(String[] args) throws InterruptedException {
        // 每1s发放5个令牌到桶里
        RateLimiter rateLimiter = RateLimiter.create(5);
        for (int i = 0; i < 10; i++) {
            Thread.sleep(100L);
            if(rateLimiter.tryAcquire()) {
                System.out.println("get one token");
            }else {
                System.out.println("no token");
            }
        }
    }
}

补充:分布式限流

以上示例均适用于单机场景。在分布式系统环境下,算法的核心思想不变,但需要将限流状态(如计数器、令牌数)存储到全局中间件中。常见方案有:

  • 使用 Redis + Lua 脚本保证原子性操作来实现分布式限流。
  • 使用 Redisson 等客户端,其内部封装了基于Redis的限流器。
  • 使用 Sentinel 等专业的流量控制组件,它提供了更完善的监控和控制台。

此外,也可以在流量入口层利用 Nginx 进行限流,它提供了 ngx_http_limit_req_module(限制请求速率)和 ngx_http_limit_conn_module(限制连接数)两个模块。

总结

本文介绍了四种主流的限流算法:计数器算法简单但存在临界问题;滑动窗口算法优化了临界问题但实现稍复杂;漏桶算法能平滑流量但无法应对突发;令牌桶算法则兼具平滑限制和应对突发流量的能力,是最为常用的方案。

理解这些限流算法的工作原理和特性,是进行高并发系统设计的基础。在实际项目中,需要根据业务特点(如是否允许突发)、性能要求和技术栈(如是否已使用Redis)来选择合适的限流方案,甚至可以组合使用。对于大部分基于 Java 技术栈的项目,Guava的RateLimiter或分布式下的Redis方案都是不错的起点。

希望这篇图解能帮助你更好地理解限流。想了解更多关于系统架构和性能优化的实战内容,欢迎访问云栈社区进行交流探讨。


参考资料:




上一篇:基于OpenClaw与全开源EDA工具,我成功设计了一颗RISC-V SoC芯片
下一篇:MySQL读写分离架构图解:原理剖析与高并发实践
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-18 06:03 , Processed in 0.632819 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表