Redis 线程 I/O 模型
Redis 是个单线程程序! 这点必须铭记。
Redis 单线程为什么这么快?
因为它所有的数据都在内存中,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。另一方面,就是Redis采用了多路复用机制,使其在网络IO操作中能并发处理大量的客戶端请求,实现高吞吐率。接下来,我们就重点学习下多路复用机制。
单线程如何处理并发客户端?
多路复用
为什么选择单线程?
It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
核心意思就是,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。
真的是单线程?
在讨论这个问题之前,我们要先明确『单线程』这个概念的边界:它的覆盖范围是核心网络模型,抑或是整个 Redis?如果是前者,那么答案是肯定的,在 Redis 的 v6.0 版本正式引入多线程之前,其网络模型一直是单线程模式的;如果是后者,那么答案则是否定的,Redis 早在 v4.0 就已经引入了多线程。
因此,当我们讨论 Redis 的多线程之时,有必要对 Redis 的版本划出两个重要的节点:
- Redis v4.0(引入多线程处理异步任务)
- Redis v6.0(正式在网络模型中实现 I/O 多线程)
(1) 单线程事件循环
从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端:
(2) 多线程异步任务
非阻塞 IO
当我们调用套接字的读写方法,默认它们是阻塞的,比如read
方法要传递进去一个参数n
,表示最多读取这么多字节后再返回,如果一个字节都没有,那么线程就会卡在那里,直到新的数据到来或者连接关闭了,read
方法才可以返回,线程才能继续处理。而write
方法一般来说不会阻塞,除非内核为套接字分配的写缓冲区已经满了,write
方法就会阻塞,直到缓存区中有空闲空间挪出来了。
非阻塞 IO 在套接字对象上提供了一个选项Non_Blocking
,当这个选项打开时,读写方法不会阻塞,而是能读多少读多少,能写多少写多少。能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区的空闲空间字节数。读方法和写方法都会通过返回值来告知程序实际读写了多少字节。
有了非阻塞 IO 意味着线程在读写 IO 时可以不必再阻塞了,读写可以瞬间完成然后线程可以继续干别的事了。
事件轮询 (多路复用)
非阻塞 IO 有个问题,那就是线程要读数据,结果读了一部分就返回了,线程如何知道何时才应该继续读。也就是当数据到来时,线程如何得到通知。写也是一样,如果缓冲区满了,写不完,剩下的数据何时才应该继续写,线程也应该得到通知。
事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API 是select
函数,它是操作系统提供给用户程序的 API。输入是读写描述符列表read_fds
& write_fds
,输出是与之对应的可读可写事件。同时还提供了一个timeout
参数,如果没有任何事件到来,那么就最多等待timeout
时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,我们把这个死循环称为事件循环,一个循环为一个周期。
每个客户端套接字socket
都有对应的读写文件描述符。
read_events, write_events = select(read_fds, write_fds, timeout)
for event in read_events:
handle_read(event.fd)
for event in write_events:
handle_write(event.fd)
handle_others() # 处理其它事情,如定时任务等
因为我们通过select
系统调用同时处理多个通道描述符的读写事件,因此我们将这类系统调用称为多路复用 API。现代操作系统的多路复用 API 已经不再使用select
系统调用,而改用epoll(linux)
和kqueue(freebsd & macosx)
,因为 select
系统调用的性能在描述符特别多时性能会非常差。它们使用起来可能在形式上略有差异,但是本质上都是差不多的,都可以使用上面的伪代码逻辑进行理解。
服务器套接字serversocket
对象的读操作是指调用accept
接受客户端新连接。何时有新连接到来,也是通过select
系统调用的读事件来得到通知的。
事件轮询 API 就是 Java 语言里面的 NIO 技术
基于多路复用的高性能 I/O 模型
Linux中的IO多路复用机制是指一个线程处理多个IO流,就是我们经常听到的select
/epoll
机制。简单来说,在Redis只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。
下图就是基于多路复用的Redis IO模型。图中的多个FD就是刚才所说的多个套接字。Redis网络框架调用epoll
机制,让内核监听这些套接字。此时,Redis线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客戶端请求处理上。正因为此,Redis可以同时和多个客戶端连接并处理请求,从而提升并发性。
那么,回调机制是怎么工作的呢?其实,select
/epoll
一旦监测到 FD 上有请求到达时,就会触发相应的事件。
这些事件会被放进一个事件队列,Redis单线程对该事件队列不断进行处理。这样一来,Redis无需一直轮询是否有请求实际发生,这就可以避免造成CPU资源浪费。同时,Redis在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为Redis一直在对事件队列进行处理,所以能及时响应客戶端请求,提升Redis的响应性能。
指令队列
Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行顺序处理,先到先服务。
响应队列
Redis 同样也会为每个客户端套接字关联一个响应队列。Redis 服务器通过响应队列来将指令的返回结果回复给客户端。 如果队列为空,那么意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前的客户端描述符从 write_fds
里面移出来。等到队列有数据了,再将描述符放进去。避免select
系统调用立即返回写事件,结果发现没什么数据可以写。出这种情况的线程会飙高 CPU。
缓冲区
客戶端输入和输出缓冲区
为了避免客戶端和服务器端的请求发送和处理速度不匹配,服务器端给每个连接的客戶端都设置了一个输入缓冲区和输出缓冲区,我们称之为客戶端输入缓冲区和输出缓冲区。
缓冲区内存使用情况
CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client
qbuf
,表示输入缓冲区已经使用的大小。这个例子中的CLIENT命令已使用了26字节大小的缓冲区。qbuf-free
,表示输入缓冲区尚未使用的大小。这个例子中的CLIENT命令还可以使用32742字节的缓冲区。qbuf
和qbuf-free
的总和就是,Redis服务器端当前为已连接的这个客戶端分配的缓冲区总大小。这个例子中总共分配了 26 + 32742 = 32768字节,也就是32KB的缓冲区。
客戶端输入缓冲区溢出,Redis的处理办法就是把客戶端连接关闭,结果就是业务程序无法进行数据存取了。
应对缓冲区溢出
- 避免
bigkey
操作返回大量数据结果; - 避免在线上环境中持续使用MONITOR命令。
- 使用
client-output-buffer-limit
设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。
复制缓冲区的溢出问题
在全量复制过程中,主节点在向从节点传输RDB文件的同时,会继续接收客戶端发送的写命令请求。这些写命令就会先保存在复制缓冲区中,等RDB文件传输完成后,再发送给从节点去执行。主节点上会为每个从节点都维护一个复制缓冲区,来保证主从节点间的数据同步。
所以,如果在全量复制时,从节点接收和加载RDB较慢,同时主节点接收到了大量的写命令,写命令在复制缓冲区中就会越积越多,最终导致溢出。
其实,主节点上的复制缓冲区,本质上也是一个用于和从节点连接的客戶端(我们称之为从节点客戶端),使用的输出缓冲区。复制缓冲区一旦发生溢出,主节点也会直接关闭和从节点进行复制操作的连接,导致全量复制失败。那如何避免复制缓冲区发生溢出呢?
为了避免复制缓冲区累积过多命令造成溢出,引发全量复制失败,我们可以控制主节点保存的数据量大小,并设置合理的复制缓冲区大小。同时,我们需要控制从节点的数量,来避免主节点中复制缓冲区占用过多内存的问题。
增量复制积压缓冲区的溢出问题
主节点在把接收到的写命令同步给从节点时,同时会把这些写命令写入复制积压缓冲区。一旦从节点发生网络闪断,再次和主节点恢复连接后,从节点就会从复制积压缓冲区中,读取断连期间主节点接收到的写命令,进而进行增量同步,如下图所示:
首先,复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,会覆盖缓冲区中的旧命令数据。如果从节点还没有同步这些旧命令数据,就会造成主从节点间重新开始执行全量复制。
其次,为了应对复制积压缓冲区的溢出问题,我们可以调整复制积压缓冲区的大小,也就是设置 repl_backlog_size
这个参数的值。
定时任务
服务器处理要响应 IO 事件外,还要处理其它事情。比如定时任务就是非常重要的一件事。如果线程阻塞在 select
系统调用上,定时任务将无法得到准时调度。那 Redis 是如何解决这个问题的呢?
Redis 的定时任务会记录在一个称为最小堆的数据结构中。这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期,Redis 都会将最小堆里面已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是 select
系统调用的timeout
参数。因为 Redis 知道未来timeout
时间内,没有其它定时任务需要处理,所以可以安心睡眠timeout
的时间。