Redis之所以这么流行,就是因为它的性能贼高,之所以性能高一是因为它是内存数据库,数据的存取都是内存操作,速度很快,另一方面就是它的IO模型,下面讲讲它 的多路复用机制。
BIO
传统的IO模型是阻塞式的,服务端在接收一个连接请求和读取数据都会被阻塞,因此一个服务只能同时建立一个连接,只有本次服务完成,才能重新和别的客户端建立连接就像这样。
//伪代码
socket = new ServerSocket(port); 开辟一个socket
socket.accept(); 建立连接 阻塞
socket.read(); 读取数据 阻塞
为了解决这个问题,可以引入多线程机制,即为每一个连接,创建一个线程,这样新连接需要建立时,就重新new一个线程,这样同时就可以建立多个连接,就像这样。
//伪代码
while (true) {
new Thread(() -> {
socket = new ServerSocket(port); 开辟一个socket
socket.accept(); 建立连接
socket.read(); 读取数据
}).start();
}
但是因为线程的开辟是比较耗费资源,因此这个线程的数量也很有限,并且线程切换的开销对于性能要求比较高的场景,也是一个比较大的代价。
随着内核的发展,可以使用accept和read都是非阻塞的,只需要设置非阻塞模式即可,如果没有连接或数据返回,可以很快返回-1,而不是阻塞等待。因此,我们可以将连接放进一个数组fdList,专门用一个线程不停遍历这个fd列表,这样就可以将原来一个线程监听一个fd,转换为一个线程监听一个fd列表,如下图。
select和poll
内核很快又有了一个系统调用select。
int select(
int nfds,
fd_set *readfds, // 读数据到达fd
fd_set *writefds, // 写数据到达fd
fd_set *exceptfds, //异常fd
struct timeval *timeout);
// 返回就绪fd个数
select系统调用入参是fd列表,我们不再需要一个额外的线程遍历这个fd列表,而是将这个遍历过程转给内核,由内核遍历fd列表,随后将就绪的fd做上标记,返回就绪的文件描述符个数,然后由用户寻找标记就绪的fd即可,这个过程的优化主要在于,将用户的遍历转为内核的遍历,省去了用户态多次无效系统调用带来的用户态和内核态的之间的多次切换。poll和select基本相同,只不过优化了select文件描述符1024的限制。
epoll
epoll系统调用实际上可以分为三个函数,epoll_create创建一个epoll句柄,epoll_ctl添加修改或删除监听的fd,为了使平均复杂度均衡,fds在内核中采用的红黑树结构,epoll_wait等待就绪的fd列表。epoll在select的基础上,主要优化了以下几个方面
1.select如果一个fd迟迟没有就绪,那么将会被拷贝入内核多次,epoll只需增量添加一次
2.select内核需要主动遍历fd是否就绪,epoll采用异步通知机制
3.select返回就绪fd个数,用户自己遍历获取就绪标记的fd,而epoll直接返回就绪fd列表
Redis单机能够承受住单机十万的并发量,IO的多路复用功不可没。随着Redis6.0的发布,Redis的性能又得到进一步的提升。6.0最值得注意的新特性我觉得主要有两点
1.多IO线程
2.客户端缓存
因为随着Redis读写线程的性能改进,高并发情况下,网络IO也有可能成为Redis的性能瓶颈,因此IO线程引入了多线程来读取和解析网络请求,但是读写命令仍然是在主线程中执行的,因此数据的安全性依然可以得到保证。
客户端缓存就是客户端将数据缓存在客户端,当需要读取时如果缓存没有改变,可以直接本地读取,而不需要请求服务端,缓存最大的问题就是失效如何通知的问题。6.0实现了两种模式第一种是服务端记录客户端读取过的key,如果缓存失效,服务端会给客户端发送一个失效通知,但是普通模式只会通知一次,如果key又被修改失效,则不会发起通知。只有客户端再次读取服务端,服务端才会再次发起通知。另外一种是广播模式,广播模式服务端会将客户端所有失效的key广播给客户端,这种模式下,客户端会接收到大量的失效通知,因此需要客户端按需注册,只需要注册自己关注的key即可。
总结
IO模型进化历程
1.一个线程监听一个fd事件。
2.一个线程监听一个fd列表,遍历列表查找就绪的fd(多次系统调用)。
3.一个线程一次系统调用传入多个fd,遍历过程由内核完成。
4.一个线程不断修改监听的fd列表,和监听已就绪的fd列表,采用异步通知机制。