缓存模型演进前言作为

Redis缓存IO模型的演进教程示例精讲

数据库教程 2021-11-29 13:53:11 29

导读

前言 redis作为应用最广泛的nosql数据库之一,大大小小也经历过很多次升级。在4.0版本之前,单线程+IO多路复用使得redis的性能已经达到一个非常高的高度了。作者也说过,之所以设计成单线程是因为redis的瓶颈不在cpu上,而且单线程也不需要考虑多线程带来的锁开销问题。然而……

前言

redis作为应用最广泛的nosql数据库之一,大大小小也经历过很多次升级。在4.0版本之前,单线程+IO多路复用使得redis的性能已经达到一个非常高的高度了。作者也说过,之所以设计成单线程是因为redis的瓶颈不在cpu上,而且单线程也不需要考虑多线程带来的锁开销问题。然而随着时间的推移,单线程越来越不满足一些应用场景了,比如针对大key删除会造成主线程阻塞的问题,redis4.0出了一个异步线程。针对单线程由于无法利用多核cpu的特性而导致无法满足更高的并发,redis6.0也推出了多线程模式。所以说redis是单线程越来越不准确了。

事件模型

redis本身是个事件驱动程序,通过监听文件事件和时间事件来完成相应的功能。其中文件事件其实就是对socket的抽象,把一个个socket事件抽象成文件事件,redis基于Reactor模式开发了自己的网络事件处理器。那么Reactor模式是什么?

通信

思考一个问题,我们的服务器是如何收到我们的数据的?首先双方先要建立TCP连接,连接建立以后,就可以收发数据了。发送方向socket的缓冲区发送数据,等待系统从缓冲区把数据取走,然后通过网卡把数据发出去,接收方的网卡在收到数据之后,会把数据copy到socket的缓冲区,然后等待应用程序来取,这是大概的发收数据流程。

Redis缓存IO模型的演进教程示例精讲

copy数据的开销

因为涉及到系统调用,整个过程可以发现一份数据需要先从用户态拷贝到内核态的socket,然后又要从内核态的socket拷贝到用户态的进程中去,这就是数据拷贝的开销。

数据怎么知道发给哪个socket

内核维护的socket那么多,网卡过来的数据怎么知道投递给哪个socket?

答案是端口,socket是一个四元组:

ip(client)+ port(client)+ip(server)+port(server)

注意千万不要说一台机器的理论最大并发是65535个,除了端口,还有ip,应该是端口数*ip数

这也是为什么一台电脑可以同时打开多个软件的原因。

socket的数据怎么通知程序来取

当数据已经从网卡copy到了对应的socket缓冲区中,怎么通知程序来取?假如socket数据还没到达,这时程序在干嘛?这里其实涉及到cpu对进程的调度的问题。从cpu的角度来看,进程存在运行态、就绪态、阻塞态。

  • 就绪态:进程等待被执行,资源都已经准备好了,剩下的就等待cpu的调度了。
  • 运行态:正在运行的进程,cpu正在调度的进程。
  • 阻塞态:因为某些情况导致阻塞,不占有cpu,正在等待某些事件的完成。

当存在多个运行态的进程时,由于cpu的时间片技术,运行态的进程都会被cpu执行一段时间,看着好似同时运行一样,这就是所谓的并发。当我们创建一个socket连接时,它大概会这样:

sockfd = socket(AF_INET, SOCK_STREAM, 0)
connect(sockfd, ....)
recv(sockfd, ...)
doSometing()

操作系统会为每个socket建立一个fd句柄,这个fd就指向我们创建的socket对象,这个对象包含缓冲区、进程的等待队列...。对于一个创建socket的进程来说,如果数据没到达,那么他会卡在recv处,这个进程会挂在socket对象的等待队列中,对cpu来说,这个进程就是阻塞的,它其实不占有cpu,它在等待数据的到来。

Redis缓存IO模型的演进教程示例精讲

当数据到来时,网卡会告诉cpu,cpu执行中断程序,把网卡的数据copy到对应的socket的缓冲区中,然后唤醒等待队列中的进程,把这个进程重新放回运行队列中,当这个进程被cpu运行的时候,它就可以执行最后的读取操作了。这种模式有两个问题:

recv只能接收一个fd,如果要recv多个fd怎么办?

通过while循环效率稍低。

进程除了读取数据,还要处理接下里的逻辑,在数据没到达时,进程处于阻塞态,即使用了while循环来监听多个fd,其它的socket是不是因为其中一个recv阻塞,而导致整个进程的阻塞。

针对上述问题,于是Reactor模式和IO多路复用技术出现了。

Reactor

Reactor是一种高性能处理IO的模式,Reactor模式下主程序只负责监听文件描述符上是否有事件发生,这一点很重要,主程序并不处理文件描述符的读写。那么文件描述符的可读可写谁来做?答案是其他的工作程序,当某个socket发生可读可写的事件后,主程序会通知工作程序,真正从socket里面读取数据和写入数据的是工作程序。这种模式的好处就是就是主程序可以扛并发,不阻塞,主程序非常的轻便。事件可以通过队列的方式等待被工作程序执行。通过Reactor模式,我们只需要把事件和事件对应的handler(callback func),注册到Reactor中就行了,比如:

type Reactor interface{
   RegisterHandler(WriteCallback func(), "writeEvent");
   RegisterHandler(ReadCallback func(), "readEvent");
}

当一个客户端向redis发起set key value的命令,这时候会向socket缓冲区写入这样的命令请求,当Reactor监听到对应的socket缓冲区有数据了,那么此时的socket是可读的,Reactor就会触发读事件,通过事先注入的ReadCallback回调函数来完成命令的解析、命令的执行。当socket的缓冲区有足够的空间可以被写,那么对应的Reactor就会产生可写事件,此时就会执行事先注入的WriteCallback回调函数。当发起的set key value执行完毕后,此时工作程序会向socket缓冲区中写入OK,最后客户端会从socket缓冲区中取走写入的OK。在redis中不管是ReadCallback,还是WriteCallback,它们都是一个线程完成的,如果它们同时到达那么也得排队,这就是redis6.0之前的默认模式,也是最广为流传的单线程redis。

整个流程下来可以发现Reactor主程序非常快,因为它不需要执行真正的读写,剩下的都是工作程序干的事:IO的读写、命令的解析、命令的执行、结果的返回..,这一点很重要。

IO多路复用器

通过上面我们知道Reactor它是一个抽象的理论,是一个模式,如何实现它?如何监听socket事件的到来?。最简单的办法就是轮询,我们既然不知道socket事件什么时候到达,那么我们就一直来问内核,假设现在有1w个socket连接,那么我们就得循环问内核1w次,这个开销明显很大。

用户态到内核态的切换,涉及到上下文的切换(context),cpu需要保护现场,在进入内核前需要保存寄存器的状态,在内核返回后还需要从寄存器里恢复状态,这是个不小的开销。

由于传统的轮询方法开销过大,于是IO多路复用复用器出现了,IO多路复用器有select、poll、evport、kqueue、epoll。Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现:

// Include the best multiplexing layer supported by this system. The following should be ordered by performances, descending.
# ifdef HAVE_EVPORT
# include "ae_evport.c"
# else
    # ifdef HAVE_EPOLL
    # include "ae_epoll.c"
    # else
        # ifdef HAVE_KQUEUE
        # include "ae_kqueue.c"
        # else
        # include "ae_select.c"
        # endif
    # endif
# endif
 

我们这里主要介绍两种非常经典的复用器select和epoll,select是IO多路复用器的初代,select是如何解决不停地从用户态到内核态的轮询问题的?

select

既然每次轮询很麻烦,那么select就把一批socket的fds集合一次性交给内核,然后内核自己遍历fds,然后判断每个fd的可读可写状态,当某个fd的状态满足时,由用户自己判断去获取。

fds = []int{fd1,fd2,...}
for {
 select (fds)
 for i:= 0; i < len(fds); i++{
  if isReady(fds[i]) {
      read()
     }
  }
}

select的缺点:当一个进程监听多个socket的时候,通过select会把内核中所有的socket的等待队列都加上本进程(多对一),这样当其中一个socket有数据的时候,它就会把告诉cpu,同时把这个进程从阻塞态唤醒,等待被cpu的调度,同时会把进程从所有的socket的等待队列中移除,当cpu运行这个进程的时候,进程因为本身传进去了一批fds集合,我们并不知道哪个fd来数据了,所以只能都遍历一次,这样对于没有数据到来的fd来说,就白白浪费了。由于每次select要遍历socket集合,那么这个socket集合的数量过大就会影响整体效率,这原因也是select为什么支持最大1024个并发的。

epoll

如果有一种方法使得不用遍历所有的socket,当某个socket的消息到来时,只需要触发对应的socket fd,而不用盲目的轮询,那效率是不是会更高。epoll的出现就是为了解决这个问题:

epfd = epoll_create()
epoll_ctl(epfd, fd1, fd2...)
for {
  epoll_wait()
  for fd := range fds {
    doSomething()
  }
}
  • 首先通过epoll_create创建一个epoll对象,它会返回一个fd句柄,和socket的句柄一样,也是管理在fds集合下。
  • 通过epoll_ctl,把需要监听的socket fd和epoll对象绑定。
  • 通过epoll_wait来获取有数据的socket fd,当没有一个socket有数据的时候,那么此处会阻塞,有数据的话,那么就会返回有数据的fds集合。

epoll是怎么做到的?

首先内核的socket不在和用户的进程绑定了,而是和epoll绑定,这样当socket的数据到来时,中断程序就会给epoll的一个就绪对列添加对应socket fd,这个队列里都是有数据的socket,然后和epoll关联的进程也会被唤醒,当cpu运行进程的时候,就可以直接从epoll的就绪队列中获取有事件的socket,执行接下来的读。整个流程下来,可以发现用户程序不用无脑遍历,内核也不用遍历,通过中断做到"谁有数据处理谁"的高效表现。

Redis缓存IO模型的演进教程示例精讲

单线程到多线程的演进

单线程

结合Reactor的思想加上高性能epoll IO模式,redis开发出一套高性能的网络IO架构:单线程的IO多路复用,IO多路复用器负责接受网络IO事件,事件最终以队列的方式排队等待被处理,这是最原始的单线程模型,为什么使用单线程?因为单线程的redis已经可以达到10w qps的负载(如果做一些复杂的集合操作,会降低),满足绝大部分应用场景了,同时单线程不用考虑多线程带来的锁的问题,如果还没达到你的要求,那么你也可以配置分片模式,让不同的节点处理不同的sharding key,这样你的redis server的负载能力就能随着节点的增长而进一步线性增长。

异步线程

在单线程模式下有这样一个问题,当执行删除某个很大的集合或者hash的时候会很耗时(不是连续内存),那么单线程的表现就是其他还在排队的命令就得等待。当等待的命令越来越多,那么不好的事情就会发生。于是redis4.0针对大key删除的情况,出了个异步线程。用unlink代替del去执行删除,这样当我们unlink的时候,redis会检测当删除的key是否需要放到异步线程去执行(比如集合的数量超过64个...),如果value足够大,那么就会放到异步线程里去处理,不会影响主线程。同样的还有flushall、flushdb都支持异步模式。此外redis还支持某些场景下是否需要异步线程来处理的模式(默认是关闭的):

lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
replica-lazy-flush no

lazyfree-lazy-eviction:针对redis有设置内存达到maxmemory的淘汰策略时,这时候会启动异步删除,此场景异步删除的缺点就是如果删除不及时,内存不能得到及时释放。

lazyfree-lazy-expire:对于有ttl的key,在被redis清理的时候,不执行同步删除,加入异步线程来删除。

replica-lazy-flush:在slave节点加入进来的时候,会执行flush清空自己的数据,如果flush耗时较久,那么复制缓冲区堆积的数据就越多,后面slave同步数据较相对慢,开启replica-lazy-flush后,slave的flush可以交由异步现成来处理,从而提高同步的速度。

lazyfree-lazy-server-del:这个选项是针对一些指令,比如rename一个字段的时候执行RENAME key newkey, 如果这时newkey是b存在的,对于rename来说它就要删除这个newkey原来的老值,如果这个老值很大,那么就会造成阻塞,当开启了这个选项时也会交给异步线程来操作,这样就不会阻塞主线程了。

多线程

redis单线程+异步线程+分片已经能满足了绝大部分应用,然后没有最好只有更好,redis在6.0还是推出了多线程模式。默认情况下,多线程模式是关闭的。

# io-threads 4 # work线程数
# io-threads-do-reads no # 是否开启

多线程的作用点?

通过上文我们知道当我们从一个socket中读取数据的时候,需要从内核copy到用户空间,当我们往socket中写数据的时候,需要从用户空间copy到内核。redis本身的计算还是很快的,慢的地方那么主要就是socket IO相关操作了。当我们的qps非常大的时候,单线程的redis无法发挥多核cpu的好处,那么通过开启多个线程利用多核cpu来分担IO操作是个不错的选择。

So for instance if you have a four cores boxes, try to use 2 or 3 I/O threads, if you have a 8 cores, try to use 6 threads.

开启的话,官方建议对于一个4核的机器来说,开2-3个IO线程,如果有8核,那么开6个IO线程即可。

多线程的原理

需要注意的是redis的多线程仅仅只是处理socket IO读写是多个线程,真正去运行指令还是一个线程去执行的。

  1. redis server通过EventLoop来监听客户端的请求,当一个请求到来时,主线程并不会立马解析执行,而是把它放到全局读队列clients_pending_read中,并给每个client打上CLIENT_PENDING_READ标识。
  2. 然后主线程通过RR(Round-robin)策略把所有任务分配给I/O线程和主线程自己。
  3. 每个线程(包括主线程和子线程)根据分配到的任务,通过client的CLIENT_PENDING_READ标识只做请求参数的读取和解析(这里并不执行命令)。
  4. 主线程会忙轮询等待所有的IO线程执行完,每个IO线程都会维护一个本地的队列io_threads_list和本地的原子计数器io_threads_pending,线程之间的任务是隔离的,不会重叠,当IO线程完成任务之后,io_threads_pending[index] = 0,当所有的io_threads_pending都是0的时候,就是任务执行完毕之时。
  5. 当所有read执行完毕之后,主线程通过遍历clients_pending_read队列,来执行真正的exec动作。
  6. 在完成命令的读取、解析、执行之后,就要把结果响应给客户端了。主线程会把需要响应的client加入到全局的clients_pending_write队列中。
  7. 主线程遍历clients_pending_write队列,再通过RR(Round-robin)策略把所有任务分给I/O线程和主线程,让它们将数据回写给客户端。

多线程模式下,每个IO线程负责处理自己的队列,不会互相干扰,IO线程要么同时在读,要么同时在写,不会同时读或写。主线程也只会在所有的子线程的任务处理完毕之后,才会尝试再次分配任务。同时最终的命令执行还是由主线程自己来完成,整个过程不涉及到锁。

1253067 TFnetwork_cn