目录
1.前言
2.Redis为什么快?
3.Redis 为何选择单线程?
3.1可维护性
3.2并发处理
3.3性能瓶颈
4.Reactor设计模式
5.Redis4.0前 单线程模型 - Event Loop
6.Redis4.0后 多线程异步任务
7.Redis6.0后 多线程网络模型
1.前言
这篇文章我们主要围绕这几个问题来展开:
- 什么是Redis线程模型?
- Redis4.0前的单线程模型是什么?
- 为什么Redis之前一直选择单线程?
- Redis4.0后为什么又加入了多线程异步执行?
- Redis6.0后引入的多线程模型是什么?
在目前的技术选型中,Redis 俨然已经成为了系统高性能缓存方案的事实标准,因此现在 Redis 也成为了后端开发的基本技能树之一,Redis 从本质上来讲是一个网络服务器,而对于一个网络服务器来说,网络模型是它的精华,搞懂了一个网络服务器的网络模型,你也就搞懂了它的本质。
Redis 作为广为人知的内存数据库,在玩具项目和复杂的工业级别项目中都看到它的身影,然而 Redis 却是使用单线程模型进行设计的,这与很多人固有的观念有所冲突,为什么单线程的程序能够抗住每秒几百万的请求量呢?这也是我们今天要讨论的问题之一。
除此之外,Redis 4.0 之后的版本却抛弃了单线程模型这一设计,原本使用单线程运行的 Redis 也开始选择性使用多线程模型,这一看似有些矛盾的设计决策是今天需要讨论的另一个问题。
翻译后就是:然而,在 Redis 4.0 中,我们开始使 Redis 更加线程化。目前,这仅限于在后台删除对象,以及阻止通过 Redis 模块实现的命令。对于下一个版本,计划是使 Redis 越来越线程化。
2.Redis为什么快?
根据官方的介绍,在一台普通硬件配置的 Linux 机器上跑单个 Redis 实例,处理简单命令(时间复杂度 O(N) 或者 O(log(N))),QPS 可以达到 8w+,而如果使用 pipeline 批处理功能,则 QPS 至高能达到 100w。仅从性能层面进行评判,Redis 完全可以被称之为高性能缓存方案。
Redis 的高性能得益于以下几个基础:
- C 语言实现,虽然 C 对 Redis 的性能有助力,但语言并不是最核心因素。
- 纯内存 I/O,相较于其他基于磁盘的 DB,Redis 的纯内存操作有着天然的性能优势。
- I/O 多路复用,基于 epoll/select/kqueue 等 I/O 多路复用技术,实现高吞吐的网络 I/O。
- 单线程模型,单线程无法利用多核,但是从另一个层面来说则避免了多线程频繁上下文切换,以及同步机制如锁带来的开销。
3.Redis 为何选择单线程?
核心意思就是,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis 的话,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis 真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。
其中最重要的几个原因如下:
- 使用单线程模型能带来更好的可维护性,方便开发和调试;
- 使用单线程模型也能并发的处理客户端的请求;
- Redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU;
上述三个原因中的最后一个是最终使用单线程模型的决定性因素,其他的两个原因都是使用单线程模型额外带来的好处
3.1可维护性
可维护性对于一个项目来说非常重要,如果代码难以调试和测试,问题也经常难以复现,这对于任何一个项目来说都会严重地影响项目的可维护性。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,代码的执行过程不再是串行的,多个线程同时访问的变量如果没有谨慎处理就会带来诡异的问题。
在网络上有一个调侃多线程模型的图片,就很好地展示了多线程模型带来的潜在问题:
避免了过多的上下文切换开销
多线程调度过程中必然需要在 CPU 之间切换线程上下文 context,而上下文的切换又涉及程序计数器、堆栈指针和程序状态字等一系列的寄存器置换、程序堆栈重置甚至是 CPU 高速缓存、TLB 快表的汰换,如果是进程内的多线程切换还好一些,因为单一进程内多线程共享进程地址空间,因此线程上下文比之进程上下文要小得多,如果是跨进程调度,则需要切换掉整个进程地址空间。
如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。
避免了同步机制的开销
引入了多线程,我们就必须要同时引入并发控制来保证在多个线程同时访问数据时程序行为的正确性,这就需要工程师额外维护并发控制的相关代码,例如,我们会需要在可能被并发读写的变量上增加互斥锁:
在访问这些变量或者内存之前也需要先对获取互斥锁,一旦忘记获取锁或者忘记释放锁就可能会导致各种诡异的问题,管理相关的并发控制机制也需要付出额外的研发成本和负担。
简单可维护
Redis 的开发者对 Redis 的设计和代码有着近乎偏执的简洁性理念,你可以在阅读 Redis 的源码时感受到这份偏执。因此代码的简单可维护性必然是 Redis 早期的核心准则之一,而引入多线程必然会导致代码的复杂度上升和可维护性下降。
3.2并发处理
使用单线程模型也并不意味着程序不能并发的处理任务,Redis 虽然使用单线程模型处理用户的请求,但是它却使用 I/O 多路复用机制并发处理来自客户端的多个连接,同时等待多个连接发送的请求。
在 I/O 多路复用模型中,最重要的函数调用就是select以及类似函数,该方法的能够同时监控多个文件描述符(也就是客户端的连接)的可读可写情况,当其中的某些文件描述符可读或者可写时,select方法就会返回可读以及可写的文件描述符个数。
使用 I/O 多路复用技术能够极大地减少系统的开销,系统不再需要额外创建和维护进程和线程来监听来自客户端的大量连接,减少了服务器的开发成本和维护成本。
3.3性能瓶颈
最后要介绍的其实就是 Redis 选择单线程模型的决定性原因 —— 多线程技术能够帮助我们充分利用 CPU 的计算资源来并发的执行不同的任务,但是 CPU 资源往往都不是 Redis 服务器的性能瓶颈。哪怕我们在一个普通的 Linux 服务器上启动 Redis 服务,它也能在 1s 的时间内处理 1,000,000 个用户请求。
如果这种吞吐量不能满足我们的需求,更推荐的做法是使用分片的方式将不同的请求交给不同的 Redis 服务器(同一台机器上的多个Redis实例/进程/服务,即单机集群)来处理,而不是在同一个 Redis 服务中引入大量的多线程操作。
Redis是网络IO密集型的服务,CPU不是它的性能瓶颈,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的。对于CPU计算使用多线程处理也仅仅是提高了计算部分的性能和CPU的利用率。
AOF 是 Redis 的一种持久化机制,它会在每次收到来自客户端的写请求时,将其记录到日志中,每次 Redis 服务器启动时都会重放 AOF 日志构建原始的数据集,保证数据的持久性。
然而整个Redis服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O。CPU计算本来就得等网络IO,使用多线程提高CPU利用率之后更得等网络IO更久,要显著提高性能也得对网络IO多线程处理,这即是Redis6.0后的多线程模型。
总结:
为什么Redis4.0前一直选择单线程模型?
- 单线程编程容易并且更容易维护;多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能
- 单线程也能使用IO多路复用处理并发用户请求
- Redis 的性能瓶颈不在 CPU ,主要在内存和网络IO
4.Reactor设计模式
Redis采用的是IO多路复用模式,所以我们重点来了解下多路复用这种模式,如何在更好的落地到我们系统中,不可避免的我们要聊下Reactor模式。
Reactor:类似Selector,负责I/O事件的派发;
Acceptor:接收到事件后,处理连接的那个分支逻辑;
Handler:消息读写处理等操作类。
处理流程
- Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
优点
- 模型比较简单,所有的处理过程都在一个连接里;
- 实现上比较容易,模块功能也比较解耦,Reactor负责多路复用和事件分发处理,Acceptor负责连接事件处理,Handler负责Scoket读写事件处理。
缺点
- 只有一个线程,连接处理和业务处理共用一个线程,无法充分利用CPU多核的优势。
- 在流量不是特别大、业务处理比较快的时候系统可以有很好的表现,当流量比较大、读写事件比较耗时情况下,容易导致系统出现性能瓶颈。
怎么去解决上述问题呢?既然业务处理逻辑可能会影响系统瓶颈,那我们是不是可以把业务处理逻辑单拎出来,交给线程池来处理,一方面减小对主线程的影响,另一方面利用CPU多核的优势。
这种模型相对单Reactor单线程模型,只是将业务逻辑的处理逻辑交给了一个线程池来处理。
处理流程
- Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
- Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
- 让主线程专注于通用事件的处理(连接、读、写),从设计上进一步解耦;
- 利用CPU多核的优势。
缺点
- 貌似这种模型已经很完美了,我们再思考下,如果客户端很多、流量特别大的时候,通用事件的处理(读、写)也可能会成为主线程的瓶颈,因为每次读、写操作都涉及系统调用。
有没有什么好的办法来解决上述问题呢?通过以上的分析,大家有没有发现一个现象,当某一个点成为系统瓶颈点时,想办法把他拿出来,交个其他线程来处理,那这种场景是否适用呢?
这种模型相对单Reactor多线程模型,只是将Scoket的读写处理从mainReactor中拎出来,交给subReactor线程来处理。
处理流程
- mainReactor主线程负责连接事件的监听和处理,当Acceptor处理完连接过程后,主线程将连接分配给subReactor;
- subReactor负责mainReactor分配过来的Socket的监听和处理,当有Socket事件过来时交个对应的Handler处理;
Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
- 让主线程专注于连接事件的处理,子线程专注于读写事件,从设计上进一步解耦;
- 利用CPU多核的优势。
缺点
- 实现上会比较复杂,在极度追求单机性能的场景中可以考虑使用。
5.Redis4.0前 单线程模型 - Event Loop
当我们讨论 Redis 的多线程之时,有必要对 Redis 的版本划出两个重要的节点:
- Redis v4.0(引入多线程处理异步任务)
- Redis v6.0(正式在网络模型中实现 I/O 多线程)
IO多路复用负责各事件的监听(连接、读、写等),当有事件发生时,将对应事件放入队列中,由事件分发器根据事件类型来进行分发;
如果是连接事件,则分发至连接应答处理器;GET、SET等redis命令分发至命令请求处理器。
命令处理完后产生命令回复事件,再由事件队列,到事件分发器,到命令回复处理器,回复客户端响应。
再来剖析一下 Redis 的核心网络模型,从 Redis 的 v1.0 到 v6.0 版本之前,Redis 的核心网络模型一直是一个典型的单 Reactor 模型:利用 epoll/select/kqueue 等多路复用技术,在单线程的事件循环中不断去处理事件(客户端请求),最后回写响应数据到客户端:
核心概念:
-
client
:客户端对象,Redis 是典型的 CS 架构(Client <---> Server),客户端通过 socket 与服务端建立网络通道然后发送请求命令,服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息 -
aeApiPoll
:I/O 多路复用 API,是基于 epoll_wait/select/kevent 等系统调用的封装,监听等待读/写事件触发,然后处理,它是事件循环(Event Loop) 中的核心函数,是事件驱动得以运行的基础。 -
acceptTcpHandler
:连接应答处理器,底层使用系统调用accept
接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接; -
readQueryFromClient
:命令读取处理器,解析并执行客户端的请求命令。 -
beforeSleep
:事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把client->buf
或者client->reply
(后面会解释为什么这里需要两个缓冲区)中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。 -
sendReplyToClient
:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。
至此,我们可以描绘出客户端向 Redis 发起请求命令的工作原理:
- Redis 服务器启动,开启主线程事件循环(Event Loop) ,注册
acceptTcpHandler
连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接到来; - 客户端和服务端建立网络连接;
acceptTcpHandler
被调用,主线程使用 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 实例。事实上,为了保证高可用,不太可能会是单机模式,更加常见的是利用 Redis 分布式集群多节点和数据分片负载均衡来提升性能和保证高可用。
6.Redis4.0后 多线程异步任务
以上便是 Redis 的核心网络模型,这个单线程网络模型一直到 Redis v6.0 才改造成多线程模式,但这并不意味着整个 Redis 一直都只是单线程。
Redis 在 v4.0 版本的时候就已经引入了的多线程来做一些异步操作,此举主要针对的是那些非常耗时的命令,通过将这些命令的执行进行异步化,避免阻塞单线程的事件循环。
我们知道 Redis 的 DEL
命令是用来删除掉一个或多个 key 储存的值,它是一个阻塞的命令,大多数情况下你要删除的 key 里存的值不会特别多,最多也就几十上百个对象,所以可以很快执行完,但是如果你要删的是一个超大的键值对,里面有几百万个对象,那么这条命令可能会阻塞至少好几秒,又因为事件循环是单线程的,所以会阻塞后面的其他事件,导致吞吐量下降。
Redis 的作者 antirez 为了解决这个问题进行了很多思考,一开始他想的办法是一种渐进式的方案:利用定时器和数据游标,每次只删除一小部分的数据,比如 1000 个对象,最终清除掉所有的数据,但是这种方案有个致命的缺陷,如果同时还有其他客户端往某个正在被渐进式删除的 key 里继续写入数据,而且删除的速度跟不上写入的数据,那么将会无止境地消耗内存,虽然后来通过一个巧妙的办法解决了,但是这种实现使 Redis 变得更加复杂,而多线程看起来似乎是一个水到渠成的解决方案:简单、易理解。于是,最终 antirez 选择引入多线程来实现这一类非阻塞的命令。更多 antirez 在这方面的思考可以阅读一下他发表的博客:Lazy Redis is better Redis。
于是,在 Redis v4.0 之后增加了一些的非阻塞命令如 UNLINK
、FLUSHALL ASYNC
、FLUSHDB ASYNC
。
UNLINK
命令其实就是 DEL
的异步版本,它不会同步删除数据,而只是把 key 从 keyspace 中暂时移除掉,然后将任务添加到一个异步队列,最后由后台线程去删除,不过这里需要考虑一种情况是如果用 UNLINK
去删除一个很小的 key,用异步的方式去做反而开销更大,所以它会先计算一个开销的阀值,只有当这个值大于 64 才会使用异步的方式去删除 key,对于基本的数据类型如 List、Set、Hash 这些,阀值就是其中存储的对象数量。
7.Redis6.0后 多线程网络模型
前面提到 Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。那么为什么现在 Redis 又要引入多线程呢?很简单,就是 Redis 的网络 I/O 瓶颈已经越来越明显了。
随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis 的性能有两个方向:
- 优化网络 I/O 模块
- 提高机器内存读写的速度
后者依赖于硬件的发展,暂时只能慢慢来。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:
- 零拷贝技术或者 DPDK 技术
- 利用多核优势
零拷贝技术有其局限性,无法完全适配 Redis 这一类复杂的网络 I/O 场景。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。
因此,利用多核优势成为了优化网络 I/O 性价比最高的方案。
我们先看一下 Redis 多线程网络模型的总体设计:
具体的分析就不展开,感兴趣的可以看看此篇文章:
Redis 多线程网络模型全面揭秘 - 编程札记 - SegmentFault 思否