redis线程模型 Redis 多线程网络模型全面揭秘

栏目:民生 2021-10-09 21:40:11
分享到:

简介|Redis本质上是一个网络服务器,对于一个网络服务器来说,网络模型就是它的本质。如果你理解了网络服务器的网络模型,你也会理解它的本质。遵循本文的视角,彻底了解Redis整个核心网络模型的原理和源代码。

在目前的技术选择中,Redis已经成为系统高性能缓存方案的事实标准,所以现在Redis已经成为后端开发的基本技能树之一,Redis的底层原理自然也就成为了必须要学习的知识。

Redis本质上是一个网络服务器,对于一个网络服务器来说,网络模型就是它的本质。如果你了解网络服务器的网络模型,你就会明白它的本质。

本文以递进的方式介绍了Redis网络模型的版本变化过程,分析了其从单线程进化到多线程的工作原理。此外,还对Redis网络模型诸多选择背后的思考进行了分析和解答,有助于读者对Redis网络模型的设计有更深的理解。

1.Redis有多快?

根据官方基准测试,一般来说,在硬件配置普通、处理简单命令的Linux机器上运行单个Redis实例,QPS可以达到8w+,而使用流水线批处理功能,QPS可以达到100w。

从性能层面来看,Redis堪称高性能缓存方案。

二、Redis为什么快?

Redis的高性能得益于以下基础:

C语言实现,虽然C有助于Redis的性能,但语言不是核心因素。

纯内存I/O,相比其他基于磁盘的DB,Redis的纯内存操作有着天然的性能优势。

I/O复用是基于epoll/select/kqueue等I/O复用技术,实现高吞吐量的网络I/O..

在单线程模型中,单线程无法利用多核的优势,但另一方面避免了多线程频繁的上下文切换以及锁等同步机制带来的开销。

3.Redis为什么选择单线程?

Redis的核心网络模型是单线程实现的,一开始就引起了很多人的困惑。Redis的官方回答是:

中央处理器成为Redis的瓶颈并不常见,因为通常Redis要么是内存限制,要么是网络限制。例如,使用运行在普通Linux系统上的流水线Redis每秒甚至可以传递100万个请求,所以如果您的应用程序主要使用O命令,它几乎不会使用太多的CPU。

核心意思是,CPU通常不是数据库的瓶颈,因为大多数请求不是CPU密集型的,而是I/O密集型的。说到Redis,如果不考虑RDB/AOF等持久化方案,Redis是纯内存操作,执行速度非常快,所以这部分操作通常不是性能瓶颈。Redis真正的性能瓶颈在于网络I/O,即客户端和服务器之间的网络传输延迟。因此,Redis选择单线程I/O复用来实现其核心网络模型。

以上是一般官方回答。事实上,选择单线程的更具体原因可以总结如下:

1.避免过多的上下文切换开销

在多线程调度过程中,需要在CPU之间切换线程上下文,这涉及到程序计数器、堆栈指针和程序状态字等一系列寄存器替换、程序堆栈复位,甚至是CPU缓存和TLB快表的替换。如果是进程内的多线程切换就更好了,因为单个进程内的多线程共享进程地址空,所以线程上下文比进程上下文小很多。如果是跨流程调度,需要切换。

如果是单线程,可以避免进程中频繁的线程切换开销,因为进程中程序总是在单线程中运行,不存在多线程切换的场景。

2.避免同步机制的开销

如果Redis选择多线程模型,并且由于Redis是数据库,必然会涉及到底层数据的同步,必然会引入锁等一些同步机制。我们知道Redis不仅提供了简单的键值数据结构,还提供了其他丰富的数据结构,如列表、集合和哈希。但是不同的数据结构对于同步访问有不同的锁定粒度,这可能会导致在操作数据的过程中产生大量的锁定和解锁费用,这会增加程序的复杂度,同时也会降低性能。

3.简单且可维护

《Redis》的作者Salvatore Sanfilippo对Redis的设计和代码的简单性有着近乎偏执的想法。在阅读Redis的源代码或者向Redis提交PR的时候就能感受到这种偏执。因此,代码的简单可维护性必然是Redis早期的核心原则之一,多线程的引入必然会导致代码复杂度的增加和可维护性的下降。

事实上,多线程编程并不是那么完美。首先,多线程的引入会使程序不再保持代码的串行逻辑,代码执行的顺序会变得不可预测。稍有不慎就会导致各种并发编程问题;其次,多线程模式也使得程序调试更加复杂和麻烦。网上有一张有趣的图片,生动地描述了并发编程面临的困境。

你期待多线程编程与实际多线程编程:

我们提到应该引入多线程所必需的同步机制。如果Redis采用多线程模式,所有底层数据结构都必须是线程安全的,这无疑让Redis的实现更加复杂。

综上所述,Redis选择单线程可以说是多方博弈后的一种取舍:使用单线程在保证足够性能的同时,保持代码的简单性和可维护性。

4.Redis真的是单线程吗?

在讨论这个问题之前,首先要厘清“单线程”概念的边界:是覆盖核心网模型还是整个Redis?如果是前者,那么答案是肯定的。在Redis v6.0正式推出多线程之前,其网络模式一直是单线程模式;如果是后者,答案是否定的。Redis早在v4.0就引入了多线程

因此,当我们讨论Redis的多线程时,有必要为Redis的版本绘制两个重要节点:

Redis v4.0

Redis v6.0

1.单线程事件循环

我们先来分析一下Redis的核心网络模型。从Redis的v1.0到v6.0,Redis的核心网络模型一直是典型的单Reactor模型:利用epoll/select/kqueue等复用技术,在单线程事件循环中不断处理事件,最后将响应数据写回客户端:

有几个核心概念需要学习:

客户端:客户端对象。Redis是典型的CS架构。客户端通过socket与服务器建立网络通道,然后发送请求命令。服务器执行请求的命令并回复。Redis使用结构客户端存储客户端的所有相关信息,包括但不限于封装套接字连接- *conn、当前选中的数据库指针- *db、读取buffer - querybuf、写入buffer - buf、写入数据链表- reply等。

aeippoll:I/o复用API,基于对epoll_wait/select/kevent等系统调用的封装,监视和等待读写事件的触发,然后进行处理。它是事件循环中的核心功能,是事件驱动操作的基础。

AcceptTcpHandler:连接响应处理器。底层使用系统调用accept接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,用于新客户端TCP连接的后续处理。除了这个处理器之外,还有相应的用于Unix域套接字的acceptUnixHandler和用于TLS加密连接的acceptTLSHandler。

ReadQueryFromClient:命令读取处理器,解析并执行客户端的请求命令。

BeforeSleep:在事件循环中输入AEAPpoll,等待函数在事件到达前执行,包括一些日常任务,比如在client->buf或client->回复客户端中写入响应,将AOF缓冲区中的数据持久化到磁盘等。还有一个对应的afterSleep函数要在AEAPpoll之后执行。

SendReplyToClient:命令回复处理器。当一个事件周期后,写输出缓冲区中还有数据时,处理器将被注册并绑定到相应的连接。当连接触发写就绪事件时,它会将写输出缓冲区中的剩余数据写回客户端。

在Redis中实现了一个高性能的事件库AE。基于epoll/select/kqueue/evport四种事件驱动技术,实现了Linux/MacOS/FreeBSD/Solaris的高性能事件周期模型。Redis的核心网络模型正式建立在AE之上,包括I/O复用和各种处理器的注册绑定,都是基于此。

此时,我们可以描述客户端如何向Redis发送请求命令:

Redis服务器启动,启动主线程事件周期,将acceptTcpHandler连接响应处理器对应的文件描述符注册到用户配置的监听端口,等待新的连接到来;

在客户端和服务器之间建立网络连接;

调用acceptTcpHandler,主线程利用AE的API将readQueryFromClient命令读取器绑定到新连接对应的文件描述符,并初始化一个客户端绑定这个客户端连接;

客户端发送请求命令触发read ready事件,主线程调用readQueryFromClient通过socket读取客户端发送的命令,并存储在client->querybuf读取缓冲区;

然后调用processInputBuffer,其中processInlineBuffer或processMultibulkBuffer根据Redis协议对命令进行分析,最后调用processCommand执行命令;

根据请求命令的类型,分配相应的命令执行器执行,最后调用addReply函数族的一系列函数将响应数据写入相应客户端的写出缓冲区:client->buf或client->reply,client->buf是首选的16KB固定大小的写出缓冲区,一般可以缓冲足够的响应数据。但是如果客户端在时间窗口内需要响应的数据量很大,会自动切换到客户端->回复链表,理论上可以保存无限的数据,最后将客户端添加到一个LIFO队列clients _ pending _ write

在事件循环中,主线程执行before sleep-> handleclientswipending writes,遍历clients_pending_write队列,并调用writeToClient将客户端写输出缓冲区中的数据写回客户端。如果写输出缓冲区中还有剩余数据,那么注册sendReplyToClient命令,回复处理器对连接的写就绪事件,等待客户端写,然后继续回写事件循环中剩余的响应数据。

对于那些想利用多核提升性能的用户来说,Redis给出的官方解决方案也非常简单粗暴:在同一台机器上运行多个Redis实例。实际上,为了保证高可用性,在线服务一般不太可能是单机模式,更常见的是使用Redis分布式集群多节点和数据分片负载均衡来提高性能,保证高可用性。

2.多线程异步任务

以上是Redis的核心网络模型。这种单线程网络模式直到Redis v6.0才转变为多线程模式,但这并不意味着整个Redis一直都是单线程。

Redis在v4.0中引入多线程来做一些异步操作,主要针对那些非常耗时的命令。通过异步执行这些命令,它可以避免阻塞单个线程的事件循环。

我们知道Redis的DEL命令用于删除存储在一个或多个键中的值。这是一个封锁命令。大多数情况下,存储在要删除的键中的值不会特别大,最多会有几十上百个对象,所以可以快速执行。但是,如果您想删除一个包含数百万个对象的超大键值对,该命令可能会阻塞至少几秒钟,并且由于事件循环是单线程的,它将在未来阻塞其他事件。

Redis的作者Antirez为解决这个问题想了很多。一开始,他想到了一个循序渐进的方案:使用定时器和数据游标,一次只删除一小部分数据,比如1000个对象,最后把所有的数据都抹掉。但是,这个方案有一个致命的缺陷。如果其他客户端继续将数据写入逐渐被删除的密钥中,而且删除的速度跟不上写入的数据,那么内存将被无限消耗。虽然后来通过一种巧妙的方式解决了,但这种实现让Redis变得更加复杂,多线程似乎是一种自然的解决方案:简单易懂。因此,antirez最终选择引入多线程来实现这种无阻塞命令。关于这方面的更多想法,antirez可以看他的博客:Lazy Redis更好Redis。

懒Redis更好Redis:

http://antirez.com/news/93

因此,在Redis v4.0之后,增加了一些非阻塞命令,如UNLINK、FLUSHALL ASYNC和FLUSHDB ASYNC。

UNLINK命令实际上是DEL的异步版本。它不是同步删除数据,而是只暂时从键空间中移除键,然后将任务添加到异步队列中,最后由后台线程删除它。但是,这里有一种情况需要考虑:如果您使用UNLINK删除一个小密钥,它将以异步方式花费更多,因此它将首先计算一个开销阈值。只有当该值大于64时,才会异步删除密钥。对于列表、集合和哈希等基本数据类型,阈值是存储在其中的对象数量。

动词 Redis多线程网络模型

Redis之所以最初选择上面提到的单线程网络模式,是因为CPU通常不会成为性能瓶颈,但瓶颈是内存和网络,所以单线程就足够了。那么为什么Redis现在要引入多线程呢?很简单,Redis的网络I/O瓶颈已经越来越明显。

随着互联网的快速发展,互联网业务系统处理的在线流量不断增加。Redis的单线程模式会导致系统在网络I/O上消耗大量的CPU时间,从而降低吞吐量。提高Redis的性能有两个方向:

优化网络输入/输出模块

提高读写机器内存的速度

后者依赖硬件的发展,暂时没有解决方案。所以我们只能从前者入手,而网络I/O的优化可以分为两个方向:

零拷贝技术或DPDK技术

利用多核技术

零拷贝技术有其局限性,不能完全适应Redis这样复杂的网络I/O场景。更多的网络I/O消耗CPU时间和Linux零拷贝技术。你可以看作者的另一篇文章:Linux I/O原理和零拷贝技术被充分揭示。而DPDK技术通过绕过网卡I/O绕过内核协议栈,过于复杂,需要内核甚至硬件的支持。

彻底揭示了Linux输入输出原理和零拷贝技术;

https://strikefreedom.top/linux-io-and-zero-copy

因此,利用多核成为优化网络I/O最具成本效益的解决方案。

6.0版本之后,Redis正式将多线程引入核心网络模型,这就是所谓的I/O线程化。到目前为止,Redis真的有多线程模型。在前一节中,我们了解了6.0版之前Redis的单线程事件循环模型,这实际上是一个非常经典的Reactor模型:

目前Linux平台上主流的高性能网络库/框架大多采用Reactor模式,如netty、libevent、libuv、POE、Twisted等。

反应器模式本质上是指使用输入输出复用+非阻塞输入输出的模式..