Java常用的网络IO模型与限流算法总结

什么是IO(Input/Output)?

IO(输入/输出)指的是计算机系统中数据的输入和输出操作。它涉及从外部设备(如硬盘、网络、键盘、鼠标)读取数据(输入)和将数据发送到这些设备(输出)。IO操作在计算机编程中非常重要,因为它允许程序与外部世界进行交互,并处理数据。

什么是同步、异步、阻塞、非阻塞

  • 如果立即去执行此函数,这称为同步。
  • 如果没有去执行此函数,而是将执行此函数的时机安排在未来的某个时间,然后马上继续执行刚才的代码块,这称为异步。
  • 当执行此函数时,直至获得完整的资源之前,都暂停执行当前的代码块,这称为阻塞。
  • 当执行此函数时,立即获得瞬时的结果,然后马上继续执行当前的代码块。如果获得的瞬时资源不是完整的资源,之后周期性发送类似的请求,直至获得完整的资源,这称为非阻塞。

什么是BIO?

BIO全称是同步阻塞I/O模型,英文为:Synchronous Blocking Input/Output,这里BIO中只有一个B,你可能会将其理解为Blocking。但这只是简称,不用太过深究。

  1. 实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理。
  2. 但如果这个连接不做任何事情会造成不必要的线程开销,并且线程在进行IO操作期间是被阻塞的,无法进行其他任务。
  3. 在高并发环境下,BIO的性能较差,因为它需要为每个连接创建一个线程,而且线程切换开销较大,不过可以通过线程池机制改善。BIO适合一些简单的、低频的、短连接的通信场景,例如HTTP请求。

image.png
优点:

  1. 简单易用: BIO模型的编程方式相对简单,易于理解和使用。
  2. 可靠性高: 由于阻塞特性,IO操作的结果是可靠的。

缺点:

  1. 阻塞等待: 当一个IO操作被阻塞时,线程会一直等待,无法执行其他任务,导致资源浪费。
  2. 并发能力有限: 每个连接都需要一个独立的线程,当连接数增加时,线程数量也会增加,造成资源消耗和性能下降。
  3. 由于I/O操作是同步的,客户端的连接需要等待服务器响应,会降低系统的整体性能。

什么是NIO?

NIO称为同步非阻塞IO(Non-Blocking Input/Output),它提供了一种基于事件驱动的方式来处理I/O操作。

相比于传统的BIO模型,NIO采用了Channel、Buffer和Selector等组件,线程可以对某个IO事件进行监听,并继续执行其他任务,不需要阻塞等待。当IO事件就绪时,线程会得到通知,然后可以进行相应的操作。
这样就实现了非阻塞式的高伸缩性网络通信。在NIO模型中,数据总是从Channel读入Buffer,或者从Buffer写入Channel,这种模式提高了IO效率,并且可以充分利用系统资源。
Channel是一个可以进行数据读写的对象,所有的数据都通过Buffer来处理,这种方式避免了直接将字节写入通道中,而是将数据写入包含一个或者多个字节的缓冲区。在多线程模式下,一个线程可以处理多个请求,这是通过将客户端的连接请求注册到多路复用器上,然后由多路复用器轮询到连接有I/O请求时进行处理

NIO适用于连接数目多且连接比较短(轻操作)的架构,例如聊天服务器、弹幕系统、服务器间通讯等。它通过引入非阻塞通道的概念,提高了系统的伸缩性和并发性能。同时,NIO的使用也简化了程序编写,提高了开发效率。
image.png
优点:

  1. 高并发性: 使用选择器(Selector)和通道(Channel)的NIO模型可以在单个线程上处理多个连接,提供更高的并发性能。
  2. 节省资源: 相对于BIO,NIO需要更少的线程来处理相同数量的连接,节省了系统资源。
  3. 灵活性: NIO提供了多种类型的ChannelBuffer,可以根据需要选择适合的类型。NIO允许开发人员自定义协议、编解码器等组件,从而提高系统的灵活性和可扩展性。
  4. 高性能: NIO采用了基于通道和缓冲区的方式来读写数据,这种方式比传统的流模式更高效。可以减少数据拷贝次数,提高数据处理效率。
  5. 内存管理:NIO允许用户手动管理缓冲区的内存分配和回收,避免了传统I/O模型中的内存泄漏问题。

缺点:

  1. 编程复杂: 相对于BIO,NIO的编程方式更加复杂,需要理解选择器和缓冲区等概念,也需要考虑多线程处理和同步问题。
  2. 可靠性较低: NIO模型中,一个连接的读写操作是非阻塞的,无法保证IO操作的结果是可靠的,可能会出现部分读写或者错误的数据。

什么是AIO?

AIO(Asynchronous I/O)是异步非阻塞IO编程模型。
相比于NIO模型,AIO模型更进一步地实现了异步非阻塞IO,提高了系统的并发性能和伸缩性。在NIO模型中,虽然可以通过多路复用器处理多个连接请求,但仍需要在每个连接上进行读写操作,这仍然存在一定的阻塞。而在AIO模型中,所有的IO操作都是异步的,不会阻塞任何线程,可以更好地利用系统资源。
AIO模型有以下特性:

  1. 异步能力:AIO模型的最大特性是异步能力,对于socket和I/O操作都有效。读写操作都是异步的,完成后会自动调用回调函数。
  2. 回调函数:在AIO模型中,当一个异步操作完成后,会通知相关线程进行后续处理,这种处理方式称为“回调”。回调函数可以由开发者自行定义,用于处理异步操作的结果。
  3. 非阻塞:AIO模型实现了完全的异步非阻塞IO,不会阻塞任何线程,可以更好地利用系统资源。
  4. 高性能:由于AIO模型的异步能力和非阻塞特性,它可以更好地处理高并发、高伸缩性的网络通信场景,进一步提高系统的性能和效率。
  5. 操作系统支持:AIO模型需要操作系统的支持,因此在不同的操作系统上可能会有不同的表现。在Linux内核2.6版本之后增加了对真正异步IO的实现。

优点:

  1. 非阻塞:AIO的主要优点是它是非阻塞的。这意味着在读写操作进行时,程序可以继续执行其他任务。这对于需要处理大量并发连接的高性能服务器来说是非常有用的。
  2. 高效:由于AIO可以处理大量并发连接,因此它通常比同步I/O(例如Java的传统I/O和NIO)更高效。
  3. 简化编程模型:AIO使用了回调函数,这使得编程模型相对简单。当一个操作完成时,会自动调用回调函数,无需程序员手动检查和等待操作的完成。

缺点:

  1. 复杂性:虽然AIO的编程模型相对简单,但是由于其非阻塞的特性,编程复杂性可能会增加。例如,需要处理操作完成的通知,以及可能的并发问题。
  2. 资源消耗:AIO可能会消耗更多的系统资源。因为每个操作都需要创建一个回调函数,如果并发连接数非常大,可能会消耗大量的系统资源。
  3. 可移植性:AIO在某些平台上可能不可用或者性能不佳。因此,如果需要跨平台的可移植性,可能需要考虑使用其他I/O模型。

限流算法有哪些?

了解限流策略之前得知道限流的作用是什么,这能够帮助我们更好的了解限流策略。
限流是对某一时间窗口内的请求数进行限制,保持系统的可用性稳定性,防止因流量暴增而导致的系统运行缓慢或宕机。
常见的四种限流算法,分别是:固定窗口算法、滑动窗口算法、漏桶算法、令牌桶算法。

什么是固定窗口算法?

固定窗口又称固定窗口(又称计数器算法,Fixed Window)限流算法,是最简单的限流算法。
**实现原理:**在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时进行访问次数的清零。

public class FixedWindow {

    private long time = new Date().getTime();

    private Integer count = 0; // 计数器
    private final Integer max = 100; // 请求阈值
    private final Integer interval = 1000; // 窗口大小

    public boolean trafficMonitoring() {
        long nowTime = new Date().getTime();
        if (nowTime < time + interval) {
            // 在时间窗口内
            count++;
            return max > count;
        } else {
            time = nowTime; // 开启新的窗口
            count = 1; // 初始化计数器,由于这个请求属于当前新开的窗口,所以记录这个请求
            return true;
        }
    }
}

可以发现这种算法理解起来非常简单,但是他有一个致命的缺点,可能会出现窗口边界效应,即在时间窗口的边界处可能会有大量的请求被允许通过,从而导致突发流量
比如:第 2 到 3 秒内产生了 150 次请求,而第 3 到 4 秒内产生了 150 次请求,那么其实在第 2 秒到第 4
秒这两秒内,就已经发生了 300 次请求了,远远大于我们要求的 3 秒内的请求不要超过 150 次这个限制,如下图所示:
image.png

什么是滑动窗口算法?

  • 滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求
  • 在滑动窗口算法中,窗口的起止时间是动态的,窗口的大小固定。这种算法能够较好地处理窗口边界问题,但是实现相对复杂,需要记录每个请求的时间戳。

实现原理:

  1. 滑动窗口在固定窗口的基础上,将时间窗口进行了更精细的分片,将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。
  2. 每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel 就是采用滑动窗口算法来实现限流的。

使用zset实现滑动窗口
通过使用springAOP和redis中的zset进行实现滑动窗口限流

  1. 定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {

    /**
     * 限流时间,单位秒
     */
    int time() default 5;

    /**
     * 限流次数
     */
    int count() default 10;
}
  1. 对应注解限流处理切面(主要)
@Aspect
@Component
public class RateLimiterAspect {

    private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 实现限流(新思路)
     * @param point
     * @param rateLimiter
     * @throws Throwable
     */
    @Before("@annotation(rateLimiter)")
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
        // 在 {time} 秒内仅允许访问 {count} 次。
        int time = rateLimiter.time();
        int count = rateLimiter.count();
        // 根据用户IP(可选)和接口方法,构造key
        String combineKey = getCombineKey(point);
        System.err.println(combineKey);
        // 记录本次访问的时间结点
        long currentMs = System.currentTimeMillis();
        redisTemplate.opsForZSet().add(combineKey, String.valueOf(currentMs), currentMs);
        // 这一步是为了防止一直存在于内存中
        redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
        // 移除{time}秒之前的访问记录(滑动窗口思想)
        redisTemplate.opsForZSet().removeRangeByScore(combineKey, 0, currentMs - time * 1000);

        // 获得当前窗口内的访问记录数
        Long currCount = redisTemplate.opsForZSet().zCard(combineKey);
        // 限流判断
        if (currCount !=null && currCount > count) {
            //返回异常提示
            log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'", count, currCount, combineKey);
            //todo 返回异常

        }
    }
    /**
     * @param point 切入点
     * @return 组合key
     */
    private String getCombineKey(JoinPoint point) {
        StringBuilder sb = new StringBuilder("rate_limit:");

        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        Class<?> targetClass = method.getDeclaringClass();
        // keyPrefix + "-" + class + "-" + method  //类名加方法名
        return sb.append("-").append( targetClass.getName() )
                .append("-").append(method.getName()).toString();
    }
}
  1. 使用定义的注解实现限流
@RateLimiter(time = 1, count = 10)
@GetMapping("/testIndex")
public String index(){
    System.out.println("处理请求");
    return "finish";
}

滑动窗口解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。
但是还是存在限流不够平滑的问题。例如:限流是每秒 3 个,在第一毫秒发送了 3 个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。

什么是漏桶算法?

漏桶限流算法是一种常用的流量整形(Traffic Shaping)和流量控制(Traffic Policing)的算法,它可以有效地控制数据的传输速率以及防止网络拥塞。
主要的作用:控制数据注入网络的速度、平滑网络上的突发流量。
image.png
实现原理:

  1. 将其理解为漏桶,外部的请求如同水,如果请求的来的速率小于漏桶漏的速率那么漏桶是不会被装满的
  2. 一旦请求激增后,导致了漏桶容量填满,那么此时漏桶装不下了就会拒绝请求。
  3. 等到漏桶根据流率出水后,有空间了就可以继续接受和处理请求

简单实现:

public class LeakyBucket {
    private long capacity;  // 漏桶容量
    private long rate;      // 流出速率
    private long water;     // 当前水量
    private long lastTime;  // 上次请求时间

    public LeakyBucket(long capacity, long rate) {
        this.capacity = capacity;
        this.rate = rate;
        this.water = 0;
        this.lastTime = System.currentTimeMillis();
    }

    public synchronized boolean allow() {
        long now = System.currentTimeMillis();
        long elapsedTime = now - lastTime;
        lastTime = now;

        // 先漏水,根据流出速率计算漏掉的水量
        water = Math.max(0, water - elapsedTime * rate);

        // 检查水量是否超出了容量
        if (water < capacity) {
            water++;
            return true;  // 请求通过,水量增加
        } else {
            return false;  // 请求被拒绝,水量已满
        }
    }
    
}

优点

  • 平滑流量。由于漏桶算法以固定的速率处理请求,可以有效地平滑和整形流量,避免流量的突发和波动(类似于消息队列的削峰填谷的作用)。
  • 防止过载。当流入的请求超过桶的容量时,可以直接丢弃请求,防止系统过载。

缺点

  • 无法处理突发流量:由于漏桶的出口速度是固定的,无法处理突发流量。例如,即使在流量较小的时候,也无法以更快的速度处理请求。
  • 可能会丢失数据:如果入口流量过大,超过了桶的容量,那么就需要丢弃部分请求。在一些不能接受丢失请求的场景中,这可能是一个问题。
  • 不适合速率变化大的场景:如果速率变化大,或者需要动态调整速率,那么漏桶算法就无法满足需求。
  • 资源利用率:不管当前系统的负载压力如何,所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。

什么是令牌桶算法?

令牌桶算法是基于漏桶算法的一种改进,主要在于令牌桶算法能够在限制服务调用的平均速率的同时,还能够允许一定程度内的突发调用。
实现原理:

  1. 系统以固定的速率向桶中添加令牌
  2. 当有请求到来时,会尝试从桶中移除一个令牌,如果桶中有足够的令牌,则请求可以被处理或数据包可以被发送
  3. 如果桶中没有令牌,那么请求将被拒绝
  4. 桶中的令牌数不能超过桶的容量,如果新生成的令牌超过了桶的容量,那么新的令牌会被丢弃

主要是因为桶的容量可以根据极限峰值设定,从而应对突发流量。当桶中有足够的令牌时,可以一次性处理多个请求,这对于需要处理突发流量的应用场景非常有用。但是又不会无限制的增加处理速率导致压垮服务器,因为桶内令牌数量是有限制的。
简单实现:
Guava 中的 RateLimiter 就是基于令牌桶实现的,可以直接拿来使用。

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>19.0</version>
</dependency>
public void acquireTest() {
    //每秒固定生成5个令牌
    RateLimiter rateLimiter = RateLimiter.create(5);
    for (int i = 0; i < 10; i++) {
        double time = rateLimiter.acquire();
        logger.info("等待时间:{}s", time);
    }
}

优点:

  • 可以处理突发流量:令牌桶算法可以处理突发流量。当桶满时,能够以最大速度处理请求。这对于需要处理突发流量的应用场景非常有用。
  • 限制平均速率:在长期运行中,数据的传输率会被限制在预定义的平均速率(即生成令牌的速率)。
  • 灵活性:与漏桶算法相比,令牌桶算法提供了更大的灵活性。例如,可以动态地调整生成令牌的速率。

缺点:

  • 可能导致过载:如果令牌产生的速度过快,可能会导致大量的突发流量,这可能会使网络或服务过载。
  • 需要存储空间:令牌桶需要一定的存储空间来保存令牌,可能会导致内存资源的浪费。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值