跳至主要內容

Redis-基础篇-②线程模型

holic-x...大约 16 分钟RedisRedis

Redis-基础篇-②线程模型

学习核心

  • 为什么Redis这么快?

    • Redis的单线程设计机制&多路复用机制
  • Redis多线程解决什么问题?

  • 理解命令处理 VS IO处理,进一步理解Redis架构

  • Redis 6.0 新特性:进一步掌握多线程模型

学习资料

Redis的单线程设计机制和多路复用机制

​ 通常说的 Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的(指的是**「接收客户端请求->解析请求 ->进行数据读写等操作->发生数据给客户端」这个过程是由一个线程(主线程)来完成的**),这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外创建的线程异步执行的。

​ 严格来说,Redis 并不是单线程,但是一般会把 Redis 称为单线程高性能,这样显得“酷”些。思考:为什么使用单线程?为什么单线程可以这么快?

如何理解其他线程的引入?

​ 严格意义上来说,Redis 程序并不是单线程的,Redis 在启动的时候,是会**启动后台线程(BIO)**的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,优点在于不会导致 Redis 主线程卡顿。因此,当要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此应该使用 unlink 命令来异步删除大 key。

​ 之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求。这些后台线程相当于一个个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

image-20240717152007137

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

  • BIO_CLOSE_FILE(关闭文件任务队列):当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC(AOF 刷盘任务队列):当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
  • BIO_LAZY_FREE(lazy free 任务队列):当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

Redis 单线程的性能

​ 一般业界认知是Redis的性能可以达到10多w的QPS,但针对不同的服务厂商性能有所偏差,参考单节点测试性能。2核8GB的机器读性能可以达到10w/s左右,写性能也有7-8w/s

Redis 实例规格连接数QPS 值
内存版(标准架构)8GB100008w-10w
内存版(集群架构)8GB(单分片)100008w-10w
CKV版(标准架构)8GB120008w-12w

1.Redis为什么使用单线程?

结合多线程场景的应用问题分析Redis为什么选择使用单线程,反向思考如果使用了多线程Redis会面临着什么问题?=》为了避免多线程编程模式面临的资源共享的并发访问控制问题和性能问题,Redis选择使用单线程来支撑高性能应用

​ 在编写多线程程序应用时,理想情况下“使用多线程,可以增加系统吞吐率,或是可以增加系统扩展性”。对于一个多线程的系统来说,在有合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率(理想效果参考下左图所示)

​ 但是通常情况下,在采用多线程后,如果没有良好的系统设计,实际得到的结果参考下右图所示。在刚开始增加线程数时,系统吞吐率会增加,但是,再进一步增加线程时,系统吞吐率就增长迟缓了,有时甚至还会出现下降的情况。

image-20240717142242558

​ 一个关键的瓶颈在于,系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。

​ 以 Redis 的 List 数据类型为例,List 并提供出队(LPOP)和入队(LPUSH)操作。假设 Redis 采用多线程设计,现在有两个线程 A 和 B,线程 A 对一个 List 做 LPUSH 操作,并对队列长度加 1。同时,线程 B 对该 List 执行 LPOP 操作,并对队列长度减 1。为了保证队列长度的正确性,Redis 需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行,才能确保Redis 可以无误地记录它们对 List 长度的修改。否则,可能就会得到错误的长度结果。这就是多线程编程模式面临的共享资源的并发访问控制问题

​ 并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,例如只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式

2.为什么单线程可以这么快?

​ 通常来说,单线程的处理能力要比多线程差很多,但是 Redis 却能使用单线程模型达到每秒数十万级别的处理能力,主要是基于 Redis 多方面设计选择的一个综合结果:内存、数据结构、多路复用、避免多线程缺陷

  • Redis 的大部分操作在内存上完成,且采用了高效的数据结构(例如哈希表和跳表)来适配不同的应用场景,这是其实现高性能的一个重要原因
  • Redis 采用单线程模型避免了多线程之间的竞争,节省了多线程切换的时间和性能开销,也不会导致死锁问题
  • Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

​ 基于此,需进一步了解网络操作的基本 IO 模型和潜在的阻塞点。毕竟,Redis 采用单线程进行 IO,如果线程被阻塞了,就无法进行多路复用,进而无法达到预期的高性能效果

基本IO模型与阻塞点

(1)基本IO模型(阻塞模式)

​ 以 Get 请求为例,SimpleKV 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。结合图示(Redis基本IO模型)分析,其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作

image-20240717143556338

​ 但是基于此IO模型中的网络IO操作中存在潜在的阻塞点(分别是accept()、recv()),当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。

(2)非阻塞模式

​ Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。

​ 在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。

调用方法返回套接字类型非阻塞模式效果
socket()主动套接字
listen()监听套接字可设置accept()非阻塞
accept()已连接套接字可设置send()/recv()非阻塞

​ 针对监听套接字,可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis

​ 针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。

​ 基于非阻塞模式的应用确保Redis线程的正常执行,使其既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。

(3)基于多路复用的高性能I/O模型

​ Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流(即 select/epoll 机制)。简单来说:在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,进而实现了一个 Redis 线程处理多个 IO 流的效果。

​ 图示为基于多路复用的 Redis IO 模型,图中的多个 FD 即 多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,即不会阻塞在某一个特定的客户端请求处理上。基于此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

image-20240717145828011

​ 为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数

回调机制的工作原理

​ select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,进而实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

​ 以连接请求和读数据请求为例,进一步理解该过程。这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。

Redis将分别对请求事件注册相应的回调函数,内核监听事件请求并将其放入事件处理队列,Redis会不断对事件队列进行处理,并调用相应的事件回调函数

​ 以实际案例场景为例进行说明:病人去医院瞧病。在医生实际诊断前,每个病人(等同于请求)都需要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率就会很低。所以,医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于 Linux 内核监听请求),然后再转交给医生做实际诊断。这样即使一个医生(相当于 Redis 单线程),效率也能提升。

​ 此外,需要注意的是,即使应用场景中部署了不同的操作系统,多路复用机制也是适用的。因为这个机制的实现有很多种,既有基于 Linux 系统下的 select 和 epoll 实现,也有基于 FreeBSD 的 kqueue 实现,以及基于 Solaris 的 evport 实现,可以根据 Redis 实际运行的操作系统,选择相应的多路复用实现。

Redis 6.0 版本之前的单线程模式图示

image-20240717152414819

3.Redis6.0之后为什么引入多线程?

​ 首先理解Redis 6.0 之前为什么使用单线程?(结合上述单线程应用优势和多线程应用缺陷进行分析)再结合官方回答可知,CPU并不是制约Redis性能表现的瓶颈所在,更多况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,但基于单线程模型就无法利用到服务的多核 CPU。

​ 虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。所以为了提高网络请求处理的并行度,Redis 6.0 对于网络请求采用多线程来处理。但是对于命令执行,Redis 仍然使用单线程来处理。

​ 所以Redis6.0之后引入多线程模型的概念是针对网络I/O请求优化,它更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。即:

  • Redis6.0版本之前:核心工作 =》网络 I/O 和执行命令 都是单线程模型
  • Redis6.0版本之后:核心工作 =》网络 I/O 是多线程模型(以提升处理网络请求的能力),执行命令还是单线程模型

Redis 6.0 之后引入的多线程特性

【1】性能提升

​ Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上

【2】默认情况下I/O多线程只针对写操作,如果开启多线程读则需额外配置

​ Redis 6.0 版本支持的 I/O多线程特性,默认是 I/O 多线程只处理写操作(write client socket),并不会以多线程的方式处理读操作(read client socket)。要想开启多线程处理客户端读请求,就需要把Redis.conf配置文件中的 io-threads-do-reads 配置项设为 yes(io-threads-do-readsyes

【3】IO多线程个数的配置

​ Redis.conf配置文件中提供了IO 多线程个数的配置项(io-threads4 //io-threadsN,表示启用N-1个I/O多线程(主线程也算一个I/O线程)

​ 关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。因此, **Redis 6.0 版本之后,**Redis 在启动的时候,默认情况下会有 6 个线程:

  • Redis-server :Redis 的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力;

主线程与IO多线程是如何实现协作的?

核心思路将主线程 IO 读写任务拆分出来给一组独立的线程处理,使得多个 socket 读写可以并行化,但是 Redis 命令还是主线程串行执行

image-20240717155106304
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3