Redis 之所以能够提供超高的执行效率,主要从以下几个维度实现:
- 存储模式:基于内存实现,而非磁盘
- 数据结构:基于不同业务场景的高效数据结构及合理的数据编码
- 线程模型:网络 IO 及键值对指令读写是由单个线程来执行的,避免了不必要的 contextswitch 和竞选
- I/O 模型:基于多路复用和非阻塞的 I/O 模型
单线程模型
Redis 的单线程主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,在处理客户端的请求时,包括获取(socket 读)、解析、执行、内容返回(socket 写)等都由一个顺序串行的主线程处理
但 Redis 的其他功能,如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。即:Redis 工作线程是单线程的,但是整个 Redis 是多线程的
Redis 在启动时,会启动后台线程(BIO):
- Redis 2.6:启动 2 个后台线程,分别处理关闭文件、AOF 刷盘
- Redis 4.0:新增了一个新的后台线程,用来异步释放 Redis 内存
- 例如执行
unlink key
、flushdb async
、flushall async
等命令
- 例如执行
之所以为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都很耗时,如果把这些任务都放在主线程,那么 Redis 主线程就很容易发生阻塞,导致无法处理后续请求
后台线程相当于一个消费者,生产者(主线程)把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的操作
三个任务都有各自的任务队列:
- 关闭文件任务队列(BIO_CLOSE_FILE):当队列有任务后,后台线程会调用
close(fd)
,将文件关闭 - AOF 刷盘任务队列(BIO_AOF_FSYNC):当 AOF 日志配置成
everysec
选项,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用fsync(fd)
,将 AOF 文件刷盘 - 异步释放任务队列(BIO_LAZY_FREE):当队列有任务后,后台线程会
free(obj)
释放对象、free(dict)
删除数据库所有对象、free(skiplist)
释放跳表对象
主线程运行流程
图中的蓝色部分是一个事件循环,由主线程负责,可以看到网络 I/O 和命令处理都是单线程
Redis 初始化时,会:
- 调用
epoll_create()
创建一个epoll
对象,调用socket()
创建一个服务端 socket - 调用
bind()
绑定端口,调用listen()
监听该 socket - 调用
epoll_ctl()
将 listen socket 加入到epoll
,同时注册「连接事件」处理函数
初始化完后,主线程就进入到一个事件循环函数,主要会:
- 调用
epoll_wait
函数等待事件的到来:- 连接事件:调用
accpet
获取已连接的 socket,调用epoll_ctl
将已连接的 socket 加入到 epoll,注册「读事件」处理函数 - 读事件:调用
read
接收客户端发送的数据,解析命令,处理命令,将命令操作添加到写发送队列,将执行结果写到发送缓存区等待发送 - 写事件:调用
write
函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待epoll_wait
发现可写后再处理
- 连接事件:调用
单线程的好处
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒
- 避免线程创建过多导致的性能消耗,反而降低整体吞吐量
- 避免上下文切换引起的 CPU 额外开销
- 避免线程之间的竞争问题,如加锁、解锁、死锁等,都会造成性能损耗
- 无需额外考虑多线程带来的程序复杂度,代码更清晰,处理逻辑简单
单线程是否有效利用 CPU?
CPU 通常不会是 Redis 的瓶颈,因为大多数请求不会是 CPU 密集型的。Redis 真正的性能瓶颈在网络 IO(或内存),也就是客户端和服务端之间的网络传输延迟
Redis 通过 AE 事件模型以及 IO 多路复用等技术,拥有超高的处理性能,因此没有使用多线程的必要
I/O 多路复用模型
服务端网络编程常见的 I/O 模型有四种:
- 同步阻塞 IO(Blocking IO)
- 同步非阻塞 IO(Non-blocking IO)
- IO 多路复用(IO Multiplexing)
- 信号驱动 IO(signal driven IO)
- 异步 IO(Asynchronous IO)
Redis 采用的是 I/O 多路复用技术,并发处理连接,它的多路复用程序函数有 select
、poll
、epoll
、kqueue
。以 epoll
(目前最新最好的多路复用技术)函数为例,当客服端执行 read
、write
、accept
、close
等操作命令时,会将命令封装成一个个事件,然后利用 epoll
多路复用的特性来避免 I/O 阻塞
IO 多路复用:多个 socket 连接复用一个线程。这种模式下,内核不会去监视应用程序的连接,而是监视文件描述符(FD, File Descriptor)—— 每个 Socket 连接都是一个文件(Linux:一切皆文件)
IO 多路复用:一种同步的 IO 模型,实现一个线程监视多个文件描述符,一旦某个文件句柄就绪就能够通知到对应应用程序进行相应的读写操作,没有文件句柄就绪时就会阻塞应用程序,从而释放 CPU 资源
将用户 socket 对应的文件描述符注册进 epoll,epoll 监听哪些 socket 上有消息到达。socket 是非阻塞模式,整个过程只在调用 select
、poll
、epoll
这些函数时才会阻塞,收发客户消息是不会阻塞的,整个进程或线程就被充分利用起来,这就是事件驱动,所谓的 reactor 反应模式
nginx 就是使用 epoll 接收请求,有很多请求进来时, epoll 会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。Redis 类似
客户端发起请求时,会生成不同事件类型的套接字。服务端使用 I/O 多路复用技术,不是阻塞式的同步执行,而是将消息放入 socket 队列,然后通过 File event Dispatcher 将其转发到不同的事件处理器上,如 accept
、read
、send
综上:
- 单线程模式,内核持续监听 socket 的连接及数据请求,将消息放入 socket 队列,达到单个线程处理多个 I/O 流的效果
- 不阻塞任一客户端发起的请求,所以可以同时和多个客户端连接并处理请求,具备并发执行能力
- epoll 提供了基于事件的回调机制,不同事件调用对应的事件处理器,可以根据不同优先级高效地处理事件
追求性能极致
Redis 6.0 的多线程模型
近年来随着底层网络硬件性能越来越好,Redis 的性能瓶颈有时也会体现在网络 I/O 的读写上,单个线程处理网络 I/O 读写的速度可能跟不上底层网络硬件执行的速度
改善网络 I/O 性能,可以考虑:
- 优化协议栈,如:使用 DPDK 来替代内核网络栈
- 使用多线程,可以充分利用多核 CPU,同类实现案例如 Memcached
协议栈优化方式跟 Redis 关系不大,最便捷高效的方式就是支持多线程:
- 可以充分利用服务器 CPU 的多核资源
- 多线程任务可以分摊 Redis 同步 I/O 读写负荷,降低耗时
所以为了提高网络 I/O 的性能,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理
Redis 6.0 之前:
I/O 的读和写本身是堵塞的,当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再进行处理,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的
Redis 6.0 之后的网络 I/O 多线程处理:
将主线程 IO 读写的这个操作独立出来,单独交给一个 I/O 线程组处理。这样多个 socket 读写可以并行执行,将最耗时的 Socket 读取、请求解析、写入单独外包出去,剩下的命令执行仍然由主线程串行执行并和内存的数据交互
具体步骤:
- 主线程建立连接,并接受数据,并将获取的 socket 数据放入等待队列
- 通过轮询的方式将 socket 读取出来并分配给 IO 线程
- 之后主线程保持阻塞,一直等到 IO 线程完成 socket 读取和解析
- I/O 线程读取和解析完成之后,返回给主线程 ,主线程开始执行 Redis 命令
- 执行完命令后,主线程阻塞,直到 IO 线程完成结果回写到 socket 的工作
- 主线程清空已完成的队列,等待客户端新的请求
默认 I/O 多线程只针对发送响应数据(write client socket),多线程处理读请求(read client socket)是禁用的,如需开启需要修改 redis.conf
配置文件:
io-threads-do-reads yes
同时,支持配置 IO 多线程个数:
io-threads 4
关于线程数的设置,官方的建议是:
- 线程数一定要小于机器核数:4 核的 CPU 建议设置为 2 或 3 个线程;8 核的建议设置为 6 个线程
- 线程数并不是越大越好:超过 8 个就很难继续提效,没什么意义
Redis 6.0 之后,在启动时除了创建之前提到的几个后台线程外,还需要创建 n 个 I/O 线程
Redis 6.0 的客户端缓存
客户端缓存最核心的问题:当 Redis 中的缓存变更或失效后,如果能够及时有效的通知到客户端缓存,来保证数据的一致性
Redis 6.0 实现 Tracking 功能,提供了 3 种方案来实现数据的一致性保证:
- RESP3 协议版本的普通模式(默认)
- RESP3 协议版本的广播模式
- RESP2 协议版本的转发模式