Redis 线程模型
- 背景
- 简介
- [Redis 单线程](#Redis 单线程)
-
- [客户端发起 Redis 请求命令的工作原理](#客户端发起 Redis 请求命令的工作原理)
- 单线程面临的挑战及问题
- [Redis 多线程](#Redis 多线程)
-
- [Redis v4.0 多线程命令](#Redis v4.0 多线程命令)
- [Redis v6.0 多线程网络模型](#Redis v6.0 多线程网络模型)
- 总结
背景
随着年龄的增长,很多曾经烂熟于心的技术原理已被岁月摩擦得愈发模糊起来,技术出身的人总是很难放下一些执念,遂将这些知识整理成文,以纪念曾经努力学习奋斗的日子。本文内容并非完全原创,大多是参考其他文章资料整理所得,感谢每位技术人的开源精神。
简介
本文介绍 Redis 线程模型的原理及演进,主要介绍 Redis 核心网络模型。
Redis 单线程
早期的 Redis 版本(准确点说是 Redis v4.0 之前的版本)使用的是单线程模型,Redis 官方给出的解释是:Redis 的瓶颈通常不会是 CPU ,因为大多数请求都是 I/O 密集型而非 CPU 密集型,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度非常快。Redis 真正的性能瓶颈在网络 I/O ,也就是客户端与服务器之间的网络传输延迟。除此之外,单线程也可避免过多的上下文切换开销及线程同步机制开销,对 Redis 的设计、开发及维护也更加简单。
Redis v6.0 版本之前,核心网络模型一直是一个典型的 单 Reactor 模型,利用 select / epoll / kqueue 等 I/O 多路复用技术,在单线程事件循环中不断去处理客户端请求事件,最后回写响应数据到客户端。
核心概念:
client
:客户端对象,Redis 是典型的 CS 架构(Client - Server),客户端通过 Socket 与服务器建立连接并发送请求命令,服务器执行请求的命令并回写响应。Redis 使用结构体client
存储客户端所有相关信息,包括套接字连接(*conn
)、当前选择的数据库指针(*db
)、读入缓冲区(querybuf
)、写出缓冲区(buf
)、写出数据链表(reply
)等。aeApiPoll
:I/O 多路复用 API,是基于 select / epoll_wait / kevent 等系统调用的封装,监听等待读写事件触发,然后处理,它是事件循环(Event Loop)中的核心函数,是事件驱动得以运行的基础。acceptTcpHandler
:连接应答处理器,底层使用系统调用accept
接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接;除了这个处理器,还有对应的acceptUnixHandler
负责处理 Unix Domain Socket 以及acceptTLSHandler
负责处理 TLS 加密连接。readQueryFromClient
:命令读取处理器,解析并执行客户端的请求命令。beforeSleep
:事件循环中进入aeApiPoll
等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把client->buf
或者client->reply
中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个afterSleep
函数,在aeApiPoll
之后执行。sendReplyToClient
:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。
Redis 内部实现了一个高性能的事件库 --- AE,基于 epoll / select / kqueue / evport 四种事件驱动技术,实现 Linux / MacOS / FreeBSD / Solaris 多平台的高性能事件循环模型。Redis 的核心网络模型正式构筑在 AE 之上,包括 I/O 多路复用、各类处理器的注册绑定,都是基于此才得以运行。
客户端发起 Redis 请求命令的工作原理
- Redis 服务器启动,开启主线程事件循环(Event Loop),注册
acceptTcpHandler
连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接带来; - 客户端和服务器建立网络连接;
accpetTcpHandler
被调用,主线程使用 AE 的 API 将readQueryFromClient
命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个client
绑定这个客户端连接;- 客户端发送请求命令,触发读就绪事件,主线程调用
readQueryFromClient
通过 Socket 读取客户端发送过来的命令存入client->querybuf
读入缓冲区; - 调用
processInputBuffer
,在其中使用processInlineBuffer
或者processMultibulkBuffer
根据 Redis 协议解析命令,最后调用processCommand
执行命令; - 根据请求命令的类型(
SET
/GET
/DEL
/EXEC
/ ...),分配响应的命令执行器去执行,最后调用addReply
函数族的一系列函数将响应数据写入到对应client
的写出缓冲区:client->buf
或者client->reply
,client->buf
是首选的写出缓冲区,固定大小16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到client->reply
链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把client
添加进一个 LIFO 队列clients_pending_write
; - 在事件循环(Event Loop)中,主线程执行
beforeSleep
->handleClientsWithPendingWrites
,遍历clients_pending_write
队列,调用writeToClient
把client
的写出缓冲区理的数据回写到客户端,如果写出缓冲区还有数据遗留,,则注册sendReplyToClient
命令回写处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。
单线程面临的挑战及问题
Redis 中存在一些非常耗时的命令,这些命令的执行可能会阻塞单线程的事件循环,譬如 Redis 的 DEL
命令可用于删除一个或多个 key 存储的值,这是个阻塞命令,大多数情况下需要一次性删除的 key 中存储的值不会特别多,但如果遇到需要删除一个超大键值对的场景时,DEL
命令执行可能会阻塞,又因为事件循环是单线程的,所以也会同步阻塞后续的其他事件,导致吞吐量下降。
Redis 多线程
Redis v4.0 多线程命令
Redis v4.0 版本引入多线程,将一些可能耗时阻塞单线程事件循环的命令执行异步化,增加了一些非阻塞命令,如:UNLINK
、FLUSHALL ASYNC
、FLUSHDB ASYNC
。其中 UNLINK
命令其实就是 DEL
的异步版本,UNLINK
不会同步删除数据,知识将 key 从 keyspace 中暂时移除,然后将任务添加到一个异步队列,最后由后台线程去删除,但使用 UNLINK
去删除一个很小的 key 时开销反而更大,所以删除前会先计算一个开销阈值,只有当这个值大于 64 才会使用异步删除,对于基本数据类型,如 List
、Set
、Hash
,阈值就是其中存储的对象数量。
不过需要注意的是,Redis v4.0 的核心网络模型仍然是单线程的。
Redis v6.0 多线程网络模型
Redis v6.0 版本开始在核心网络模型中引入多线程,原因是 Redis 的网络 I/O 瓶颈已愈加明显。Redis 单线程模式导致系统消耗很多 CPU 时间在网络 I/O 上,从而降低了吞吐量,要提升 Redis 性能可以考虑两个方向:
- 优化网络 I/O 模块
- 提高机器内存读写速度(依赖于硬件发展)
Redis v6.0 版本引入了 I/O Threading 多线程,利用多核的优势优化网络 I/O。
- Redis 服务器启动,开启主线程事件循环(Event Loop),注册
acceptTcpHandler
连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接请求; - 客户端和服务器建立网络连接;
acceptTcpHandler
被调用,主线程使用 AE 的 API 将readQueryFromClient
命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个client
绑定这个客户端连接;- 客户端发送请求命令,触发读就绪事件,服务器主线程不会通过 Socket 去读取客户端的请求命令,而是先将
client
放入一个 LIFO 队列clients_pending_read
; - 在事件循环(Event Loop)中,主线程执行
beforeSleep
->handleClientsWithPendingReadsUsingThreads
,利用 Round-Robin 轮询负载均衡策略,把clients_pending_read
队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列io_threads_list[id]
和主线程自己,I/O 线程通过 Socket 读取客户端的请求命令,存入client->querybuf
并解析第一个命令,但不执行命令,主线程忙轮询,等待所有 I/O 线程完成读取任务; - 主线程和所有 I/O 线程都完成了读取任务,主线程结束忙轮询,遍历
clients_pending_read
队列,执行所有客户端连接的请求命令,先调用processCommandAndResetClient
执行第一条已经解析好的命令,然后调用processInputBuffer
解析并执行客户端连接的所有命令,在其中使用processInlineBuffer
或者processMultibulkBuffer
根据 Redis 协议解析命令,最后调用processCommand
执行命令; - 根据请求命令的类型(
SET
/GET
/DEL
/EXEC
/ ...),分配相应的命令执行器去执行,最后调用addReply
函数族的一系列函数将响应数据写入到对应client
的写出缓冲区:client->buf
或者client->reply
,client->buf
是首选的写出缓冲区,固定大小 16 KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到client->reply
链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把client
添加进一个 LIFO 队列clients_pending_write
; - 在事件循环(Event Loop)中,主线程执行
beforeSleep
->handleClientsWithPendingWritesUsingThreads
,利用 Round-Robin 轮询负载均衡策略,把clients_pending_write
队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列io_threads_list[id]
和主线程自己,I/O 线程通过调用writeToClient
把client
的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待所有 I/O 线程完成写出任务; - 主线程和所有 I/O 线程都完成了写出任务, 主线程结束忙轮询,遍历
clients_pending_write
队列,如果client
的写出缓冲区还有数据遗留,则注册sendReplyToClient
到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。
总结
- Redis v6.0 版本之前的核心网络模型都是单线程 ;
- Redis v4.0 为解决一些耗时命令阻塞单线程事件循环的问题,引入多线程并增加了一些非阻塞命令,但此时核心网络模型仍然为单线程;
- Redis v6.0 版本开始,核心网络模型使用多线程,以解决网络 I/O 方面的瓶颈问题;
- Redis 单线程模型和多线程模型的区别在于,多线程模型把读取客户端请求命令和回写响应数据的逻辑异步化,交给 I/O 线程去完成,但是 I/O 线程仅负责读取和解析客户端命令,并不会真正去执行命令,客户端的命令执行最终还是在主线程上完成的。