Redis 线程模型
Redis 为什么快?
- 单线程
- 基于内存的操作
- 高效的 IO 模型,非阻塞 IO
- RESP 协议简单高效
Reacrtor 模型
Reactor 反应器模型是一种事件驱动的编程模型,用于处理网络事件。
具体事件处理程序不调用反应器,而向反应器注册一个事件处理器,表示自己对某些事件感兴趣,有使件来了,具体事件处理程序通过事件处理器对某个指定的事件发生做出反应。
例如,路人甲去做男士 SPA,前台的接待小姐接待了路人甲,路人甲现在只对 10000 技师感兴趣,但是路人甲去的比较早,就告诉接待小姐,等 10000 技师上班了或者是空闲了,通知我。等路人甲接到接待小姐通知,做出了反应,把 10000 技师占住了。然后,路人甲想起上一次的那个 10000 号房间不错,设备舒适,灯光暧昧,又告诉前台的接待小姐,我对 10000 号房间很感兴趣,房间空出来了就告诉我,我现在先和 10000 这个小姐聊下人生,10000 号房间空出来了,路人甲再次接到接待小姐通知,路人甲再次做出了反应。路人甲就是具体事件处理程序,前台的接待小姐就是所谓的反应器,“10000技师上班了”和“10000号房间空闲了”就是事件,路人甲只对这两个事件感兴趣,其他,比如 10001 号技师或者 10002 号房间空闲了也是事件,但是路人甲不感兴趣。
前台的接待小姐不仅仅服务路人甲 1 人,他还可以同时服务路人乙、丙……..,每个人所感兴趣的事件是不一样的,前台的接待小姐会根据每个人感兴趣的事件通知对应的每个人。
单线程 Reactor 模式
服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用 Acceptor 事件处理器关注 ACCEPT
事件,这样 Reactor 会监听客户端向服务器端发起的连接请求事件 (ACCEPT
事件)。
- 客户端向服务器端发起一个连接请求,Reactor 监听到了该
ACCEPT
事件的发生并将该ACCEPT
事件派发给相应的 Acceptor 处理器来进行处理。 - 建立连接后关注
READ
事件,这样一来Reactor
就会监听该连接的READ
事件了。 - 当 Reactor 监听到有读
READ
事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过读取数据,此时read()
操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非 I/O 的业务操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。
单线程 Reactor,工作者线程池
与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor 线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
对于一些小容量应用场景,可以使用单线程模型,对于高负载、大并发或大数据量的应用场景却不合适,主要原因如下:
- 一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,即便 NIO 线程的 CPU 负荷达到 100%,也无法满足海量消息的读取和发送;
- 当 NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了 NIO 线程的负载,最终会导致大量消息积压和处理超时,成为系统的性能瓶颈;
多线程 Reactor 模式
Reactor 线程池中的每一个 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。
MainReactor 可以只有一个,但 SubReactor 一般会有多个。Main Reactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给 SubReactor,由 SubReactor 来完成和客户端的通信。
多 Reactor 线程模式将“接受客户端的连接请求”和“与该客户端的通信”分在了两个 Reactor 线程来完成。MainReactor 完成接收客户端连接请求的操作,它不负责与客户端的通信,而是将建立好的连接转交给 SubReactor 线程来完成与客户端的通信,这样一来就不会因为 read()
数据量太大而导致后面的客户端连接请求得不到即时处理的情况。并且多 Reactor 线程模式在海量的客户端并发请求的情况下,还可以通过实现 SubReactor 线程池来将海量的连接分发给多个 SubReactor 线程,在多核的操作系统中这能大大提升应用的负载和吞吐量。
Redis 的线程和 IO 概述
Redis 基于 Reactor 模式开发了自己的网络事件处理器 - 文件事件处理器(file event handler,后文简称为 FEH),而该处理器又是单线程的,所以 Redis 设计为单线程模型。
采用 I/O 多路复用同时监听多个 socket,根据 socket 当前执行的事件来为 socket 选择对应的事件处理器。
当被监听的 socket 准备好执行 accept
、read
、write
、close
等操作时,和操作对应的文件事件就会产生,这时 FEH 就会调用 socket 之前关联好的事件处理器来处理对应事件。
所以虽然 FEH 是单线程运行,但通过 I/O 多路复用监听多个 socket,不仅实现高性能的网络通信模型,又能和 Redis 服务器中其它同样单线程运行的模块交互,保证了 Redis 内部单线程模型的简洁设计。
Redis 6 中的多线程
Redis 6 之前的版本真的是单线程么?
Redis 在处理客户端的请求时,所有操作(网络 I/O、命令解析、执行、响应)均由单个主线程顺序处理,这就是所谓的 “单线程”。通过 epoll/kqueue 等系统调用实现非阻塞 I/O,监听多个客户端连接。
但如果严格来讲从 Redis 4 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些耗时的指令(比如 keys),一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
Redis 6 之前为什么一直不使用多线程?
官方曾做过类似问题的回复:使用 Redis 时,几乎不存在 CPU 成为瓶颈的情况,Redis 主要受限于内存和网络。例如在一个普通的 Linux 系统上,Redis 通过使用 pipeline 每秒可以处理 100 万个请求,所以如果应用程序主要使用 O(N)
或 O(log(N))
的命令,它几乎不会占用太多 CPU。
多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程上下文切换、甚至加锁解锁、死锁造成的性能损耗。Redis 通过 AE 事件模型以及 IO 多路复用等技术,处理性能非常高,因此没有必要使用多线程。
单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
Redis 6 为什么要引入多线程?
Redis 将所有数据放在内存中,内存的响应时长大约为 100 纳秒,对于小数据包,Redis 服务器可以处理 80,000 到 100,000 QPS,这也是 Redis 处理的极限了,对于 80% 的公司来说,单线程的 Redis 已经足够使用了。
但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如:
- 要管理的 Redis 服务器太多,维护代价大;
- 某些适用于单个 Redis 服务器的命令(
mset
)不适用于数据分区; - 数据分区无法解决热点读/写问题,例如:虽然使用了集群,但是只是将不同的数据分散在不同的节点上,但是如果某个 key 被频繁访问,这个 key 还是会被分配到同一个节点上,这样访问压力还是会集中在一个节点上。
- 数据偏斜,重新分配和放大/缩小变得更加复杂等等。Redis 集群虽然尽量平均分配槽位,但是每个 key 对应的 value 的数据大小不同。例如:一个 key 的值是 10 KB,而另一个 key 的值是 1MB,这就会导致数据倾斜,导致部分节点的内存占用率很高,而其他节点的内存占用率很低。
从 Redis 自身角度来说,因为读写网络的 read/write
系统调用占用了 Redis 执行期间大部分 CPU 时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:
- 提高网络 IO 性能,典型的实现比如使用 DPDK(跳过操作系统的网络栈,直接处理网卡的内容)来替代内核网络栈的方式。
- 使用多线程充分利用多核,典型的实现比如 Memcached。
DPDK 协议栈优化的这种方式相当复杂,Redis 没有选择,很明显支持多线程是一种最有效最便捷的操作方式。所以总结起来,Redis 支持多线程主要就是两个原因:
- 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核。
- 多线程任务可以分摊 Redis 同步 IO 读写负荷。
Redis 6 是否默认开启了多线程?
没有,默认是单线程,但是可以通过配置文件开启多线程。
# 开启多线程
io-threads-do-reads yes
# 线程数量,必须设置线程数,否则是不生效的。
io-threads 4
关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了 8 个基本就没什么意义。
Redis 6 多线程的实现机制
- 多线程网络 I/O:主线程负责接收连接,将就绪的 socket 分发给多个 I/O 线程(配置项 io-threads 控制线程数)。
- 单线程命令执行:命令解析、执行、响应构建仍由主线程处理,保持原子性。
流程简述如下:
- 主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列。
- 主线程处理完读事件之后,通过 RR (Round Robin) 将这些连接分配给这些 IO 线程。
- 主线程阻塞等待 IO 线程读取 socket 完毕。
- 主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行回写 socket。
- 主线程阻塞等待 IO 线程将数据回写 socket 完毕。
- 解除绑定,清空等待队列。
该设计有如下特点:
- IO 线程要么同时在读 socket,要么同时在写,不会同时读或写。
- IO 线程只负责读写 socket 解析命令,不负责命令处理。
可以看出 Redis 6 的多线程类似于多线程 Reactor 模式,不过它的 Sub Reactor 只负责读写 socket,不负责命令处理。
开启多线程后,是否会存在线程并发安全问题?
从上面的实现机制可以看出,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以不需要去考虑控制 key、lua、事务,LPUSH/LPOP
等等的并发及线程安全问题。
Redis 6 的多线程和 Memcached 多线程模型进行对比
Memcached 主线程负责接收建立连接请求,然后 socket 的读写,命令的执行,都交给子线程去处理。
Redis 主线程负责接收建立连接请求,然后 socket 的读写,交给子线程去处理。但是命令的执行还是由主线程来执行的。