帮助中心/最新通知

质量为本、客户为根、勇于拼搏、务实创新

< 返回文章列表

【服务器相关】Redis缓存IO模型的演进教程示例精讲

发表时间:2025-09-24 16:09:00 小编:主机乐-Yutio

前言

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的缓冲区,然后等待应用程序来取,这是大概的发收数据流程。

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连接时,它大概会这样:

当数据到来时,网卡会告诉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中就行了,比如:

单线程到多线程的演进

单线程

结合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 nolazyfree-lazy-expire nolazyfree-lazy-server-del noreplica-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线程要么同时在读,要么同时在写,不会同时读或写。主线程也只会在所有的子线程的任务处理完毕之后,才会尝试再次分配任务。同时最终的命令执行还是由主线程自己来完成,整个过程不涉及到锁。

本篇文章到此结束,如果您有相关技术方面疑问可以联系我们技术人员远程解决,感谢大家支持本站!


联系我们
返回顶部