浅谈Redis原理
前言
众所周知,redis是单线程,性能极高,处理速度极快。单机的redis已经可以扛起上万的QPS,集群模式下,可以支撑十万级别的读写并发。为什么redis性能这么快,下面我们一起来探究一下
性能
为什么单线程模型也能效率这么高
- 纯内存操作
- 核心是基于非阻塞的IO多路复用机制
- 单线程避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题
工作原理
文件事件处理器(file event handler)
由4部分组成:
- socket
- IO多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
工作流程:采用IO多路复用机制同时监听多个socket,将产生事件的socket压入内存队列中,时间分派器根据socket上的时间类型选择对应的事件处理器进行处理。
线程模型
- redis服务端进程初始化,将server socket的 AE_READABLE事件与连接应答处理器关联
- socket01向redis 进程的 server socket 请求建立连接,server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中
- 文件事件分派器从队列中获取 socket,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联
- 客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列,此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联
- 此时客户端准备好接收返回结果,redis 中的 socket01 会产生一个 AE_WRITABLE 事件,压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。
I/O模型
阻塞I/O
用伪代码模拟socket的交互
从图上可以看出,阻塞I/O,服务端的线程阻塞在两个地方,一个是accept函数,一个是read函数。accept是无法避免的,只能从read上去做优化,先来分析下read函数
read操作分为两步
- 数据从网卡拷贝到内核缓冲区(阻塞)
- 内核缓冲区拷贝到用户缓冲区(阻塞)
连接的客户端一直不发数据,服务端的线程将会一直阻塞在read函数上。
思考:有没有办法可以令read函数不阻塞?
有! 肯定有,新开一条线程,异步去调用read函数是不是能达到目的了?
但仔细想,其实这只是使用了多线程手段,使得主线程没有卡在read函数上,操作系统提供的read函数仍然是阻塞的。所以,我们是需要一个非阻塞的read函数
非阻塞I/O
操作系统为我们提供了一个非阻塞的read 函数,他的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。
而且,我们发现了一个问题,每一个客户端连接过来,服务端就会创建一个线程为其服务,会导致服务器端的线程资源很容易被耗光。
IO 多路复用
定义:多路指的是多个网络连接,复用指的是复用同一个线程
我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里,
fdlist.add(connfd),然后开一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。
while(true) {
for(fd : fdlist) {
if(read(fd) != -1) {
doSomeThing();
}
}
}
这时会发现一个性能问题:因为read是系统调用,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用,相当于在while循环里做rpc调用外部系统。
如果操作系统提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题
select
此时出现了select()函数,可以将一个文件描述符数组发送给操作系统,让操作系统去遍历,减少用户态到内核态的开销
while(true) {
connfd = accept(listenfd);
fdlist.add(connfd);
}
while(1) {
nready = select(list);
// 用户层依然要遍历,只不过少了很多无效的系统调用
for(fd <-- fdlist) {
if(fd != -1) {
// 只读已就绪的文件描述符
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(ready == 0) break;
}
}
}
一个线程不断接受客户端请求,把socket放到一个数组里
另外一个线程不再自己遍历,而是调用select函数,将这批文件描述符一次性发个操作系统遍历。当select函数返回后,用户依然要遍历刚刚去提交给操作系统的list(操作系统会对准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销)
可以看出几个细节:
- select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
- select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
- select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
poll
select支持的文件描述符数量太小了,默认是1024
epoll
epoll主要解决了select的三个不足:
- 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
内核使用红黑树(时间复杂度O(logn))来跟踪所有待检测的fd,把需要监控的fd通过epoll_ctl加入到红黑树中
后语
多路复用之所以效率高
- 一个线程就可以监控多个文件描述符
- 操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符(重点)