什么是IO(Input/Output)?
IO(输入/输出)指的是计算机系统中数据的输入和输出操作。它涉及从外部设备(如硬盘、网络、键盘、鼠标)读取数据(输入)和将数据发送到这些设备(输出)。IO操作在计算机编程中非常重要,因为它允许程序与外部世界进行交互,并处理数据。
什么是同步、异步、阻塞、非阻塞
- 如果立即去执行此函数,这称为同步。
- 如果没有去执行此函数,而是将执行此函数的时机安排在未来的某个时间,然后马上继续执行刚才的代码块,这称为异步。
- 当执行此函数时,直至获得完整的资源之前,都暂停执行当前的代码块,这称为阻塞。
- 当执行此函数时,立即获得瞬时的结果,然后马上继续执行当前的代码块。如果获得的瞬时资源不是完整的资源,之后周期性发送类似的请求,直至获得完整的资源,这称为非阻塞。
什么是BIO?
BIO全称是同步阻塞I/O模型,英文为:Synchronous Blocking Input/Output,这里BIO中只有一个B,你可能会将其理解为Blocking。但这只是简称,不用太过深究。
- 实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理。
- 但如果这个连接不做任何事情会造成不必要的线程开销,并且线程在进行IO操作期间是被阻塞的,无法进行其他任务。
- 在高并发环境下,BIO的性能较差,因为它需要为每个连接创建一个线程,而且线程切换开销较大,不过可以通过线程池机制改善。BIO适合一些简单的、低频的、短连接的通信场景,例如HTTP请求。
优点:
- 简单易用: BIO模型的编程方式相对简单,易于理解和使用。
- 可靠性高: 由于阻塞特性,IO操作的结果是可靠的。
缺点:
- 阻塞等待: 当一个IO操作被阻塞时,线程会一直等待,无法执行其他任务,导致资源浪费。
- 并发能力有限: 每个连接都需要一个独立的线程,当连接数增加时,线程数量也会增加,造成资源消耗和性能下降。
- 由于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的使用也简化了程序编写,提高了开发效率。
优点:
- 高并发性: 使用选择器(Selector)和通道(Channel)的NIO模型可以在单个线程上处理多个连接,提供更高的并发性能。
- 节省资源: 相对于BIO,NIO需要更少的线程来处理相同数量的连接,节省了系统资源。
- 灵活性: NIO提供了多种类型的Channel和Buffer,可以根据需要选择适合的类型。NIO允许开发人员自定义协议、编解码器等组件,从而提高系统的灵活性和可扩展性。
- 高性能: NIO采用了基于通道和缓冲区的方式来读写数据,这种方式比传统的流模式更高效。可以减少数据拷贝次数,提高数据处理效率。
- 内存管理:NIO允许用户手动管理缓冲区的内存分配和回收,避免了传统I/O模型中的内存泄漏问题。
缺点:
- 编程复杂: 相对于BIO,NIO的编程方式更加复杂,需要理解选择器和缓冲区等概念,也需要考虑多线程处理和同步问题。
- 可靠性较低: NIO模型中,一个连接的读写操作是非阻塞的,无法保证IO操作的结果是可靠的,可能会出现部分读写或者错误的数据。
什么是AIO?
AIO(Asynchronous I/O)是异步非阻塞IO编程模型。
相比于NIO模型,AIO模型更进一步地实现了异步非阻塞IO,提高了系统的并发性能和伸缩性。在NIO模型中,虽然可以通过多路复用器处理多个连接请求,但仍需要在每个连接上进行读写操作,这仍然存在一定的阻塞。而在AIO模型中,所有的IO操作都是异步的,不会阻塞任何线程,可以更好地利用系统资源。
AIO模型有以下特性:
- 异步能力:AIO模型的最大特性是异步能力,对于socket和I/O操作都有效。读写操作都是异步的,完成后会自动调用回调函数。
- 回调函数:在AIO模型中,当一个异步操作完成后,会通知相关线程进行后续处理,这种处理方式称为“回调”。回调函数可以由开发者自行定义,用于处理异步操作的结果。
- 非阻塞:AIO模型实现了完全的异步非阻塞IO,不会阻塞任何线程,可以更好地利用系统资源。
- 高性能:由于AIO模型的异步能力和非阻塞特性,它可以更好地处理高并发、高伸缩性的网络通信场景,进一步提高系统的性能和效率。
- 操作系统支持:AIO模型需要操作系统的支持,因此在不同的操作系统上可能会有不同的表现。在Linux内核2.6版本之后增加了对真正异步IO的实现。
优点:
- 非阻塞:AIO的主要优点是它是非阻塞的。这意味着在读写操作进行时,程序可以继续执行其他任务。这对于需要处理大量并发连接的高性能服务器来说是非常有用的。
- 高效:由于AIO可以处理大量并发连接,因此它通常比同步I/O(例如Java的传统I/O和NIO)更高效。
- 简化编程模型:AIO使用了回调函数,这使得编程模型相对简单。当一个操作完成时,会自动调用回调函数,无需程序员手动检查和等待操作的完成。
缺点:
- 复杂性:虽然AIO的编程模型相对简单,但是由于其非阻塞的特性,编程复杂性可能会增加。例如,需要处理操作完成的通知,以及可能的并发问题。
- 资源消耗:AIO可能会消耗更多的系统资源。因为每个操作都需要创建一个回调函数,如果并发连接数非常大,可能会消耗大量的系统资源。
- 可移植性: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 次这个限制,如下图所示:
什么是滑动窗口算法?
- 滑动窗口为固定窗口的改良版,解决了固定窗口在窗口切换时会受到两倍于阈值数量的请求。
- 在滑动窗口算法中,窗口的起止时间是动态的,窗口的大小固定。这种算法能够较好地处理窗口边界问题,但是实现相对复杂,需要记录每个请求的时间戳。
实现原理:
- 滑动窗口在固定窗口的基础上,将时间窗口进行了更精细的分片,将一个窗口分为若干个等份的小窗口,每次仅滑动一小块的时间。
- 每个小窗口对应不同的时间点,拥有独立的计数器,当请求的时间点大于当前窗口的最大时间点时,则将窗口向前平移一个小窗口(将第一个小窗口的数据舍弃,第二个小窗口变成第一个小窗口,当前请求放在最后一个小窗口),整个窗口的所有请求数相加不能大于阈值。其中,Sentinel 就是采用滑动窗口算法来实现限流的。
使用zset实现滑动窗口
通过使用springAOP和redis中的zset进行实现滑动窗口限流
- 定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimiter {/*** 限流时间,单位秒*/int time() default 5;/*** 限流次数*/int count() default 10;
}
- 对应注解限流处理切面(主要)
@Aspect
@Component
public class RateLimiterAspect {private static final Logger log = LoggerFactory.getLogger(RateLimiterAspect.class);@Autowiredprivate 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(可选)和接口方法,构造keyString 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();}
}
- 使用定义的注解实现限流
@RateLimiter(time = 1, count = 10)
@GetMapping("/testIndex")
public String index(){System.out.println("处理请求");return "finish";
}
滑动窗口解决了固定窗口算法的窗口边界问题,避免突发流量压垮服务器。
但是还是存在限流不够平滑的问题。例如:限流是每秒 3 个,在第一毫秒发送了 3 个请求,达到限流,剩余窗口时间的请求都将会被拒绝,体验不好。
什么是漏桶算法?
漏桶限流算法是一种常用的流量整形(Traffic Shaping)和流量控制(Traffic Policing)的算法,它可以有效地控制数据的传输速率以及防止网络拥塞。
主要的作用:控制数据注入网络的速度、平滑网络上的突发流量。
实现原理:
- 将其理解为漏桶,外部的请求如同水,如果请求的来的速率小于漏桶漏的速率那么漏桶是不会被装满的
- 一旦请求激增后,导致了漏桶容量填满,那么此时漏桶装不下了就会拒绝请求。
- 等到漏桶根据流率出水后,有空间了就可以继续接受和处理请求
简单实现:
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; // 请求被拒绝,水量已满}}}
优点
- 平滑流量。由于漏桶算法以固定的速率处理请求,可以有效地平滑和整形流量,避免流量的突发和波动(类似于消息队列的削峰填谷的作用)。
- 防止过载。当流入的请求超过桶的容量时,可以直接丢弃请求,防止系统过载。
缺点
- 无法处理突发流量:由于漏桶的出口速度是固定的,无法处理突发流量。例如,即使在流量较小的时候,也无法以更快的速度处理请求。
- 可能会丢失数据:如果入口流量过大,超过了桶的容量,那么就需要丢弃部分请求。在一些不能接受丢失请求的场景中,这可能是一个问题。
- 不适合速率变化大的场景:如果速率变化大,或者需要动态调整速率,那么漏桶算法就无法满足需求。
- 资源利用率:不管当前系统的负载压力如何,所有请求都得进行排队,即使此时服务器的负载处于相对空闲的状态,这样会造成系统资源的浪费。
什么是令牌桶算法?
令牌桶算法是基于漏桶算法的一种改进,主要在于令牌桶算法能够在限制服务调用的平均速率的同时,还能够允许一定程度内的突发调用。
实现原理:
- 系统以固定的速率向桶中添加令牌
- 当有请求到来时,会尝试从桶中移除一个令牌,如果桶中有足够的令牌,则请求可以被处理或数据包可以被发送
- 如果桶中没有令牌,那么请求将被拒绝
- 桶中的令牌数不能超过桶的容量,如果新生成的令牌超过了桶的容量,那么新的令牌会被丢弃
主要是因为桶的容量可以根据极限峰值设定,从而应对突发流量。当桶中有足够的令牌时,可以一次性处理多个请求,这对于需要处理突发流量的应用场景非常有用。但是又不会无限制的增加处理速率导致压垮服务器,因为桶内令牌数量是有限制的。
简单实现:
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);}
}
优点:
- 可以处理突发流量:令牌桶算法可以处理突发流量。当桶满时,能够以最大速度处理请求。这对于需要处理突发流量的应用场景非常有用。
- 限制平均速率:在长期运行中,数据的传输率会被限制在预定义的平均速率(即生成令牌的速率)。
- 灵活性:与漏桶算法相比,令牌桶算法提供了更大的灵活性。例如,可以动态地调整生成令牌的速率。
缺点:
- 可能导致过载:如果令牌产生的速度过快,可能会导致大量的突发流量,这可能会使网络或服务过载。
- 需要存储空间:令牌桶需要一定的存储空间来保存令牌,可能会导致内存资源的浪费。