浅谈Redis原理

  • 前言
  • 性能
  • 为什么单线程模型也能效率这么高
  • 工作原理
  • 文件事件处理器(file event handler)
  • 线程模型
  • I/O模型
  • 阻塞I/O
  • 非阻塞I/O
  • IO 多路复用
  • select
  • poll
  • epoll
  • 后语


前言

众所周知,redis是单线程,性能极高,处理速度极快。单机的redis已经可以扛起上万的QPS,集群模式下,可以支撑十万级别的读写并发。为什么redis性能这么快,下面我们一起来探究一下

性能

为什么单线程模型也能效率这么高

  • 纯内存操作
  • 核心是基于非阻塞的IO多路复用机制
  • 单线程避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题

工作原理

文件事件处理器(file event handler)

由4部分组成:

  • socket
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

工作流程:采用IO多路复用机制同时监听多个socket,将产生事件的socket压入内存队列中,时间分派器根据socket上的时间类型选择对应的事件处理器进行处理。

线程模型

redis作原理及流程 redis nio原理_多路复用

  1. redis服务端进程初始化,将server socket的 AE_READABLE事件与连接应答处理器关联
  2. socket01向redis 进程的 server socket 请求建立连接,server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中
  3. 文件事件分派器从队列中获取 socket,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 的 AE_READABLE 事件与命令请求处理器关联
  4. 客户端发送了一个 set key value 请求,此时 redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列,此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联
  5. 此时客户端准备好接收返回结果,redis 中的 socket01 会产生一个 AE_WRITABLE 事件,压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。

I/O模型

阻塞I/O

用伪代码模拟socket的交互

redis作原理及流程 redis nio原理_系统调用_02

redis作原理及流程 redis nio原理_多路复用_03

从图上可以看出,阻塞I/O,服务端的线程阻塞在两个地方,一个是accept函数,一个是read函数。accept是无法避免的,只能从read上去做优化,先来分析下read函数

redis作原理及流程 redis nio原理_文件描述符_04


read操作分为两步

  • 数据从网卡拷贝到内核缓冲区(阻塞)
  • 内核缓冲区拷贝到用户缓冲区(阻塞)

连接的客户端一直不发数据,服务端的线程将会一直阻塞在read函数上。

思考:有没有办法可以令read函数不阻塞?

有! 肯定有,新开一条线程,异步去调用read函数是不是能达到目的了?

redis作原理及流程 redis nio原理_多路复用_05

但仔细想,其实这只是使用了多线程手段,使得主线程没有卡在read函数上,操作系统提供的read函数仍然是阻塞的。所以,我们是需要一个非阻塞的read函数

非阻塞I/O

操作系统为我们提供了一个非阻塞的read 函数,他的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。

redis作原理及流程 redis nio原理_java_06


而且,我们发现了一个问题,每一个客户端连接过来,服务端就会创建一个线程为其服务,会导致服务器端的线程资源很容易被耗光。

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(操作系统会对准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销)

可以看出几个细节:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

poll

select支持的文件描述符数量太小了,默认是1024

epoll

epoll主要解决了select的三个不足:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

内核使用红黑树(时间复杂度O(logn))来跟踪所有待检测的fd,把需要监控的fd通过epoll_ctl加入到红黑树中

后语

多路复用之所以效率高

  • 一个线程就可以监控多个文件描述符
  • 操作系统提供了这样的系统调用,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符(重点)