手撕Redis底层2-网络模型深度剖析

简介: 本文主要介绍了Linux系统的用户态与内核态空间划分、IO网络模型及Redis网络模型实现。首先,Linux将进程寻址空间分为用户空间和内核空间,通过权限隔离实现安全访问。其次,详细分析了阻塞IO、非阻塞IO、IO多路复用(select/poll/epoll)等网络模型的特点及性能差异,其中epoll模式效率最高。最后,重点解析了Redis的单线程网络模型实现,包括其选择单线程的原因、基于epoll的事件处理机制以及内存回收策略(过期淘汰和内存淘汰)。Redis通过IO多路复用技术实现高性能,并采用惰性删除

 1.用户态空间和内核态空间

这里我们以Linux系统为例去讲解,Linux有许多发行版操作系统,如CentOS,Ubantu,其系统内核均为Linux,我们计算机的应用程序需要通过操作系统内核来和硬件进行交互。

image.gif 编辑

内核操作硬件需要不同设备的驱动,有了驱动之后,内核就可以对计算机进行内存管理,文件系统管理,进程管理等,内核想要应用软件来访问,就会对外暴露一些接口,用户通过调用接口从而实现对内核的操作,但是内核本身也是一个应用,所以内核的运行也需要内存,CUP等设备资源,而用户应用本身也在消耗这些资源,为了避免用户应用导致的冲突,用户应用和内核应用是分离的。我们把进程的寻址空间分为内核空间和用户空间。

寻址空间的概念:无论是应用程序还是内核空间都没法直接访问物理内存,我们的内核和应用程序去访问虚拟内存时,就需要一个虚拟地址,这个地址是一个无符号的整数,比如一个32位的操作系统,他的带宽就是32,他的虚拟地址就是2的32次方,也就是说他寻址的范围就是0~2的32次方, 这片寻址空间对应的就是2的32个字节,就是4GB,这个4GB,会有3个GB分给用户空间,会有1GB给内核系统。

在Linux中,他们权限分成两个等级,0和3,用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问内核空间可以执行特权命令(Ring0),调用一切系统资源,所以一般情况下,用户的操作是运行在用户空间,而内核运行的数据是在内核空间的,而有的情况下,一个应用程序需要去调用一些特权资源,去调用一些内核空间的操作,所以此时他俩需要在用户态和内核态之间进行切换。

比如:Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区

写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备

读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

针对这个操作:我们的用户在写读数据时,会去向内核态申请,想要读取内核的数据,而内核数据要去等待驱动程序从硬件上读取数据,当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中,然后再将数据拷贝到用户态的buffer中,然后再返回给应用程序,整体而言,速度慢,就是这个原因,为了加速,我们希望read也好,还是wait for data也最好都不要等待,或者时间尽量的短。 image.gif 编辑

2.IO网络模型

2.1 阻塞IO

应用程序想要读取数据是无法直接从磁盘中读取数据的,需要先到操作系统内核等待内核操作拿到数据,等到内核从磁盘上把数据加载出来后,再把数据写到用户空间缓冲区,如果是阻塞IO,那么整个过程,从用户发起请求开始,一直到读取数据,整个过程是一个阻塞状态。

image.gif 编辑

IO阻塞模式下,用户进程在两个阶段均为阻塞状态

image.gif 编辑

阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,此时用户进程处于阻塞状态。

阶段二:数据到达并拷贝到内核缓冲区,代表已就绪,将内核数据拷贝进入用户缓冲区,拷贝过程中,用户进程依然阻塞等待,拷贝完成,用户进程解除阻塞,处理数据。

2.2 非阻塞IO

非阻塞IO模式下,recvfrom操作会立即结果而不是阻塞用户进程。

image.gif 编辑

阶段一:用户进程尝试读取数据,此时数据尚未到达,内核需要等待数据,返回异常给用户应用,用户进程拿到异常后,会再次尝试读取,循环往复,直到数据就绪

阶段二:数据就绪,将内核数据拷贝到用户缓冲区,拷贝过程中用户进程依然阻塞等待,拷贝完成,用户进程解除阻塞,处理数据

非阻塞IO模型中,用户进程在第一阶段是非阻塞状态,第二阶段是阻塞状态,虽然是非阻塞,但是性能并没有得到提高,而且忙等机制会导致CPU空转,CPU使用率暴增。

2.2 IO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在第一阶段都需要调用recvfrom来获取数据,差别在于有无数据的处理方案:如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据。因此,上述两种模式性能都不好。

在单线程的情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪,线程就会阻塞,所有的IO事件就必须阻塞,这样性能自然很差。

提高效率有两种方式:一种是增加线程数,使用多线程。另一种是等待数据就绪后,用户应用就去读取数据,在此之下,我们引入了多路复用模型。

所以接下来就需要详细的来解决多路复用模型是如何知道到底怎么知道内核数据是否就绪的问题了

文件描述符:简称FD,是一个从0开始的无符号整数,用于关联Linux中的一个文件,在Linux中,一切皆文件,例如常规文件,视频,硬件设备,网络套接字(Socket等)。

IO多路复用:利用单个线程来同时监听多个FD,并在某个FD可读可写时得到通知,从而避免无效等待,充分利用CPU资源。

image.gif 编辑

阶段一:用户进程调用select,指定要监听的FD集合,内核监听FD对应的多个Socket,任意一个或多个Socket数据就绪就返回readable,此过程中用户进程阻塞等待。

阶段二:用户进程找到就绪的Socket,依次调用recvfrom读取数据,内核将数据拷贝到用户空间,用户进程处理数据。

IO多路复用监听FD的方式,通知有多种实现,常见的有select,poll,epoll。select和poll只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认。epoll则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间。

2.2.1 Select模式

image.gif 编辑

我们把需要处理的数据封装成FD,然后在用户态时创建一个fd的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据,比如要监听的数据,是1,2,5三个数据,此时会执行select函数,然后将整个fd发给内核态,内核态会去遍历用户态传递过来的数据,如果发现这里边都数据都没有就绪,就休眠,直到有数据准备好时,就会被唤醒,唤醒之后,再次遍历一遍,看看谁准备好了,然后再将处理掉没有准备好的数据,最后再将这个FD集合写回到用户态中去,此时用户态就知道了,有人准备好了,但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求,我们会发现,这种模式下他虽然比阻塞IO和非阻塞IO好,但是依然有些麻烦的事情, 比如说频繁的传递fd集合,频繁的去遍历FD等问题。

2.2.2  Poll模式

image.gif 编辑

2.2.3  epoll模式

image.gif 编辑

epoll模式是对select和poll的改进,它提供了三个函数:

第一个是:eventpoll的函数,他内部包含两个东西

一个是:

1、红黑树-> 记录的事要监听的FD

2、一个是链表->一个链表,记录的是就绪的FD

紧接着调用epoll_ctl操作,将要监听的数据添加到红黑树上去,并且给每个fd设置一个监听函数,这个函数会在fd数据就绪时触发,就是准备好了,现在就把fd把数据添加到list_head中去

3、调用epoll_wait函数

就去等待,在用户态创建一个空的events数组,当就绪之后,我们的回调函数会把数据添加到list_head中去,当调用这个函数的时候,会去检查list_head,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等, 如果在此过程中,检查到了list_head中有数据会将数据添加到链表中,此时将数据放入到events数组中,并且返回对应的操作的数量,用户态的此时收到响应后,从events中拿到对应准备好的数据的节点,再去调用方法去拿数据。

小总结:

select模式存在的三个问题:

能监听的FD最大不超过1024

每次select都需要把所有要监听的FD都拷贝到内核空间

每次都要遍历所有FD来判断就绪状态

poll模式的问题:

poll利用链表解决了select中监听FD上限的问题,但依然要遍历所有FD,如果监听较多,性能会下降

epoll模式中如何解决这些问题的?

基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高

每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间

利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

epoll模式下的事件通知机制:

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:

  • LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

举例说明:

  • 假设一个客户端socket对应的FD已经注册到了epoll实例中
  • 客户端socket发送了2kb的数据
  • 服务端调用epoll_wait,得到通知说FD就绪
  • 服务端从FD读取了1kb数据回到步骤3(再次调用epoll_wait,形成循环)

结论

如果我们采用LT模式,因为FD中仍有1kb数据,则第⑤步依然会返回结果,并且得到通知 如果我们采用ET模式,因为第③步已经消费了FD可读事件,第⑤步FD状态没有变化,因此epoll_wait不会返回,数据无法读取,客户端响应超时。

基于epoll的服务端流程:

image.gif 编辑

服务器启动后,服务端会调用epoll_create创建一个poll实例,实例包含两个数据:

1、红黑树(为空):rb_root 用来去记录需要被监听的FD

2、链表(为空):list_head,用来存放已经就绪的FD

创建好了之后,会去调用epoll_ctl函数,此函数会会将需要监听的数据添加到rb_root中去,并且对当前这些存在于红黑树的节点设置回调函数,当这些被监听的数据一旦准备完成,就会被调用,而调用的结果就是将红黑树的fd添加到list_head中去(但是此时并没有完成)

当第二步完成后,就会调用epoll_wait函数,这个函数会去校验是否有数据准备完毕(因为数据一旦准备就绪,就会被回调函数添加到list_head中),在等待了一段时间后(可以进行配置),如果等够了超时时间,则返回没有数据,如果有,则进一步判断当前是什么事件,如果是建立连接时间,则调用accept() 接受客户端socket,拿到建立连接的socket,然后建立起来连接,如果是其他事件,则把数据进行写出

2.3 信号驱动IO

image.gif 编辑

信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。

阶段一:

  • 用户进程调用sigaction,注册信号处理函数
  • 内核返回成功,开始监听FD
  • 用户进程不阻塞等待,可以执行其它业务
  • 当内核数据就绪后,回调用户进程的SIGIO处理函数

阶段二:

  • 收到SIGIO回调信号
  • 调用recvfrom,读取
  • 内核将数据拷贝到用户空间
  • 用户进程处理数据

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

2.4 异步IO

image.gif 编辑

IO模型对比: image.gif 编辑

3.Redis网络模型实现

Redis是单线程还是多线程?

如果仅聊的是Redis的核心业务部分,即命令处理操作,答案是单线程;如果聊的是整个Redis,那答案是多线程。

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

*  Redis v4.0:引入多线程异步处理一些耗时较旧的任务,例如异步删除命令unlink

*  Redis v6.0:在核心网络模型中引入 多线程,进一步提高对于多核CPU的利用率

因此,对于Redis的核心网络模型,在Redis 6.0之前确实都是单线程。是利用epoll(Linux系统)这样的IO多路复用技术在事件循环中不断处理客户端情况。

为什么Redis选择使用单线程?

1. Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,因此多线程并不会带来巨大的性能提升。

2. 多线程会导致过多的上下文切换,会带来不必要的开销。

3. 引入多线程会面临线程安全性问题,比如需要引入锁机制等安全手段,实现复杂度提高且性能也会受严重影响。

Redis网络模型深度剖析

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装, 提供了统一的高性能事件库API库 AE: image.gif 编辑

Redis线程网络模型执行流程: image.gif 编辑

image.gif 编辑

当我们的客户端想要去连接我们服务器,会去先到IO多路复用模型去进行排队,会有一个连接应答处理器,他会去接受读请求,然后又把读请求注册到具体模型中去,此时这些建立起来的连接,如果是客户端请求处理器去进行执行命令时,他会去把数据读取出来,然后把数据放入到client中, clinet去解析当前的命令转化为redis认识的命令,接下来就开始处理这些命令,从redis中的command中找到这些命令,然后就真正的去操作对应的数据了,当数据操作完成后,会去找到命令回复处理器,再由他将数据写出。

核心源码剖析:

image.gif 编辑

image.gif 编辑

4.Redis通信协议

Redis是一个CS架构的软件,通信一般分两步(不包括pipeline和PubSub):

客户端(client)向服务端(server)发送一条命令

服务端解析并执行命令,返回响应结果给客户端

因此客户端发送命令的格式、服务端响应结果的格式必须有一个规范,这个规范就是通信协议。

而在Redis中采用的是RESP(Redis Serialization Protocol)协议:

Redis 1.2版本引入了RESP协议

Redis 2.0版本中成为与Redis服务端通信的标准,称为RESP2

Redis 6.0版本中,从RESP2升级到了RESP3协议,增加了更多数据类型并且支持6.0的新特性--客户端缓存

但目前,默认使用的依然是RESP2协议,也是我们要学习的协议版本(以下简称RESP)。

在RESP中,通过首字节的字符来区分不同数据类型,常用的数据类型包括5种:

单行字符串:首字节是 ‘+’ ,后面跟上单行字符串,以CRLF( "\r\n" )结尾。例如返回"OK": "+OK\r\n"

错误(Errors):首字节是 ‘-’ ,与单行字符串格式一样,只是字符串是异常信息,例如:"-Error message\r\n"

数值:首字节是 ‘:’ ,后面跟上数字格式的字符串,以CRLF结尾。例如:":10\r\n"

多行字符串:首字节是 ‘$’ ,表示二进制安全的字符串,最大支持512MB:

如果大小为0,则代表空字符串:"$0\r\n\r\n"

如果大小为-1,则代表不存在:"$-1\r\n"

数组:首字节是 ‘*’,后面跟上数组元素个数,再跟上元素,元素数据类型不限: image.gif 编辑

5. Redis内存回收策略

Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。 我们可以通过修改配置文件来设置Redis的最大内存: image.gif 编辑

当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收。

5.1 过期策略

我们可以通过expire命令给Redis的key设置TTL(存活时间),当key的TTL到期以后,再次访问对应key时返回的是nil,说明这个key已经不存在了,对应的内存也得到释放。从而起到内存回收的目的。

Redis本身是一个典型的key-value内存存储数据库,因此所有的key、value都保存在之前学习过的Dict结构中。不过在其database结构体中,有两个Dict:一个用来记录key-value;另一个用来记录key-TTL。 image.gif 编辑

TTL过期后有两种删除方案:惰性删除和周期删除

惰性删除

顾明思议并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。

image.gif 编辑

周期删除

顾明思议是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。

执行周期有两种:

1.Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW

2.Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST

image.gif 编辑

image.gif 编辑

小总结:

RedisKey的TTL记录方式:

在RedisDB中通过一个Dict记录每个Key的TTL时间

过期key的删除策略:

惰性清理:每次查找key时判断是否过期,如果过期则删除

定期清理:定期抽样部分key,判断是否过期,如果过期则删除。

定期清理的两种模式:

SLOW模式执行频率默认为10,每次不超过25ms

FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

5.2 淘汰策略

内存淘汰:就是当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰: image.gif 编辑

Redis支持8种不同策略来选择要删除的key:

  • noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
  • volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
  • allkeys-lru: 对全体key,基于LRU算法进行淘汰
  • volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
  • allkeys-lfu: 对全体key,基于LFU算法进行淘汰
  • volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰 比较容易混淆的有两个:
  • LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

Redis的数据都会被封装为RedisObject结构: image.gif 编辑

LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:

  • 生成0~1之间的随机数R
  • 计算 (旧次数 * lfu_log_factor + 1),记录为P
  • 如果 R < P ,则计数器 + 1,且最大不超过255
  • 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1

image.gif 编辑

相关文章
|
18小时前
|
JSON 自然语言处理 算法
详解ElasticSearch2-进阶使用
Elasticsearch查询DSL分为叶子查询和复合查询两大类。叶子查询包括全文检索(match/multi_match)、精确查询(term/range)和地理查询等;复合查询则通过bool组合多个查询条件,或使用function_score修改相关性算分。查询结果支持排序、分页(注意深度分页问题)和高亮显示。Java RestClient实现查询时,通过QueryBuilders构建查询条件,SearchRequest组织请求参数,并逐层解析SearchResponse结果。聚合功能包括Bucket分
|
18小时前
|
JSON 自然语言处理 数据库
详解ElasticSearch1-基础使用
摘要:本文探讨了数据库模糊搜索的局限性及Elasticsearch(ES)的优势。数据库模糊查询存在性能低、功能单一等问题,而ES通过倒排索引技术实现高效搜索,支持复杂查询需求。文章详细介绍了ES的核心概念、安装部署、索引库操作(CRUD)、文档管理及Java API集成方法,并对比了ES与MySQL的适用场景。最后演示了批量导入文档的实践方案,为海量数据搜索场景提供了专业解决方案。(149字)
|
18小时前
|
机器学习/深度学习 监控 Ubuntu
Linux命令速查
Linux命令速查
|
18小时前
|
缓存 自然语言处理 数据处理
银行app怎么截图转账记录,数值快照与传输记录RPG模块
该项目为银行APP转账路由快速传输录入模块,用于高效处理转账路由信息传输与录入,采用RPG编程语言开发,集成于银行核心系统,提升业务处理效率。
|
18小时前
|
数据处理 Python
工商银行余额模拟器,工商银行数据计算引擎")
该项目用于工商银行母子公司数据计算,采用Whitespace引擎技术栈,实现高效的数据处理与分析功能。
|
18小时前
|
人工智能 监控 Linux
OpenClaw(小龙虾)进阶完全指南:17大高手技巧+阿里云/本地部署+大模型配置完整版
OpenClaw(小龙虾)作为轻量化开源AI Agent,已经成为本地部署、任务执行、多平台接入的主流框架。但绝大多数用户只停留在“安装启动、简单对话”的初级阶段,完全没有发挥其长期记忆、技能工程化、多Agent协作、稳定值守、人格管理等真正实力。
56 0
|
18小时前
|
自然语言处理 测试技术 数据处理
网银转账截图生成器免费,数据流快照生成器Verse库
该项目用于快速生成转账截图,支持自定义金额、收款人等信息,采用Python技术栈,结合图像处理库实现模板化生成与数据填充。
|
18小时前
|
监控 算法 开发工具
银行转账虚拟生成器在线制作,数值流生成器 Vim script 插件
该项目为Vim编辑器开发插件,用于在线生成银行转账流水单。技术栈基于VimScript,实现自动化生成符合格式要求的转账凭证文档。
|
18小时前
|
XML 数据处理 计算机视觉
银行回执单p图软件,数值回执单图像处理Mozart
该项目用于银行回单票据识别与图像处理,采用Mozart技术栈实现自动化数据提取与处理,提升财务工作效率。