【Redis|原理篇2】Redis网络模型、通信协议、内存回收

Redis作为一款高性能的内存数据库,其卓越的性能表现源于一系列精妙的设计。本文将深入剖析其核心机制,从支撑其高并发的网络模型入手,详解用户空间与内核空间的交互、阻塞与非阻塞I/O的区别,并重点探讨epoll多路复用机制及其在Redis中的应用与演进。同时,我们还将了解Redis高效的通信协议,并揭示其在内存管理上的两大法宝:针对过期Key的智能处理与内存满载时的淘汰策略,带你全面理解Redis的底层工作原理。

文章目录

2.Redis网络模型

2.1用户空间和内核空间

任何Linux发行版,其系统的内核都是Linux。应用需要通过Linux内核与硬件交互

想要用户的应用来访问,计算机就必须要通过对外暴露的一些接口才能访问到,从而间接的实现对内核的操控,但是内核本身上来说也是一个应用,所以他本身也需要一些内存,cpu等设备资源,用户应用本身也在消耗这些资源。

为了避免用户应用导致冲突甚至内存崩溃,用户应用和内核是分开的

  • 进程的寻址空间划分成两部分:内核空间用户空间
  • 用户空间只能执行受限的命令,而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间可以执行特权命令,调用一切系统资源

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

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

五种IO模型:

  1. 阻塞IO
  2. 非阻塞IO
  3. IO多路复用
  4. 信号驱动IO
  5. 异步IO

任何一次网络读写操作都分为两个阶段:

  1. 等待数据就绪:数据从网卡传输到内核缓冲区
  2. 数据拷贝:数据从内核缓冲区拷贝到用户空间(应用程序)

2.2阻塞IO

两个阶段都必须阻塞等待

  • 流程 :当你调用 recvfrom 读取数据时,如果内核缓冲区没有数据,你的线程就会一直挂起(阻塞),直到数据到达并拷贝完成。
  • 缺点:在等待期间,线程什么都干不了。如果 Redis 使用这种模型,处理一个客户端连接就需要一个线程,高并发下会导致线程资源耗尽。

2.3非阻塞IO

非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程

  • 流程 :你调用 recvfrom,如果没数据,内核不会让你等,而是立刻返回一个错误(如 EAGAIN)。你的线程必须不停地循环轮询,问内核"有数据了吗?有数据了吗?"。
  • 缺点:虽然线程没被阻塞,但大量的 CPU 时间都浪费在"询问"这个动作上(CPU 空转),效率依然很低。

2.4IO多路复用

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

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

监听FD、通知的方式有多种实现方式:

  • select
  • poll
  • epoll
特性 select poll epoll
底层数据结构 数组 (fd_set) 链表 (pollfd) 红黑树 + 就绪链表
最大连接数 1024 (受限于 FD_SETSIZE) 无上限 (受限于系统内存) 无上限 (支持百万级)
监听效率 O(n) (每次都要遍历所有 FD) O(n) (每次都要遍历所有 FD) O(1) (只处理就绪的 FD)
内存拷贝 每次都需将 FD 集合从用户态拷贝到内核态 每次都需将 FD 集合从用户态拷贝到内核态 仅需初始化时拷贝一次
触发模式 仅支持水平触发 (LT) 仅支持水平触发 (LT) 支持水平触发 (LT) 和 边缘触发 (ET)
Redis 采用 兼容旧系统时使用 不推荐 Linux 下的默认首选
select

核心逻辑: 每次都要把所有FD拉出来排成一队,从头走到尾一个个问。

  1. 用户态准备 :把所有要监听的 FD 塞进一个数组fd_set)里。
  2. 拷贝 :调用 select 时,把这个数组完整地拷贝到内核空间。
  3. 内核遍历:内核拿着这个数组,从头到尾遍历每一个 FD,检查它有没有数据。
    • 注意:内核会修改这个数组,把没数据的 FD 剔除掉(或者标记一下)。
  4. 返回与再遍历 :内核把修改后的数组拷贝回 用户态。用户拿到数组后,再次遍历 整个数组,通过位运算(FD_ISSET)找出到底是哪个 FD 就绪了。

select模式存在的问题:

  • 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  • select无法得知具体是哪个fd就绪,需要遍历整个fd_set fd_set
  • 监听的fd数量不能超过1024
poll

核心逻辑: 不再排队,而是放在链表上,但还是要从头走到尾一个个问。

  1. 用户态准备 :创建一个 pollfd 结构体数组(本质是链表),每个节点包含 FD 和关注的事件。
  2. 拷贝 :调用 poll 时,把这个链表完整地拷贝到内核空间。
  3. 内核遍历 :内核从头到尾遍历 链表中的每一个节点,检查状态,并把结果(就绪事件)写回节点的 revents 字段。
  4. 返回与再遍历 :内核把链表拷贝回 用户态。用户再次遍历 整个链表,查看哪个节点的 revents 不为 0。
epoll

核心逻辑 :内核里有个红黑树所有FD。数据就绪回调,内核只把就绪的数据告诉用户

epoll 的过程分为"注册"和"等待"两个阶段:

第一阶段:注册 (epoll_ctl)

  1. 建立连接 :调用 epoll_create 在内核创建一个红黑树 (存所有 FD)和一个就绪链表

  2. 添加监听:调用 epoll_ctl

    把 FD 添加到红黑树中,并注册一个回调函数

    • 关键点: 告诉内核,"如果这个 FD 有数据了,请执行这个回调函数"。

第二阶段:等待 (epoll_wait)

  1. 阻塞等待 :调用 epoll_wait,进程直接阻塞,不需要拷贝 FD 集合,也不需要遍历。
  2. 回调唤醒:
    • 当网卡收到数据,触发硬件中断。
    • 内核处理完数据后,自动调用之前注册的回调函数。
    • 回调函数把这个 FD 加入到就绪链表中。
    • 唤醒正在等待的进程。
  3. 直接获取epoll_wait 返回,直接把就绪链表 中的数据拷贝给用户。用户不需要遍历,拿到的全是已经就绪的 FD。

总结

select模式存在的三个问题:

  • 能监听的FD最大不超过1024
  • 每次select都需要把所有要监听的FD都拷贝到内核空间
  • 每次都要遍历所有FD来判断就绪状态

poll模式的问题:

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

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

  • 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降
  • 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  • 内核会将就绪的FD直接拷贝到用户空间的指定位置,用户进程无需遍历所有FD就能知道就绪的FD是谁

2.5epoll的ET模式和LT模式

事件通知机制

当FD有数据可读时,调用epoll_wait就可以得到通知

事件通知的模式有两种:

  • LevelTriggered:LT,当FD有数据可读时,会重复通知多次,直至数据处理完成(epoll的默认方式)
  • EdgeTriggered:ET,当FD有数据可读时,只会被通知依稀,不管数据是否处理完成
特性 水平触发 (LT) 边缘触发 (ET)
通知频率 持续通知(只要缓冲区有数据) 一次性通知(仅状态变化时)
数据未读完 下次 epoll_wait 会继续通知 下次 epoll_wait 不再通知(数据可能丢失)
IO 模式要求 支持阻塞 / 非阻塞 必须使用非阻塞 IO
编程难度 简单,不易出错 复杂,需严格处理所有数据
性能 稍低(通知次数多) 极高(通知次数少)
典型应用 普通应用、Redis(默认配置) Nginx、高性能网关
  1. ET模式避免了LT模式可能出现的惊群现象
  2. ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一点

2.6基于epoll的服务端流程

  1. 在服务端调用epoll_create创建epoll实例,在内核中创建红黑树和就绪列表
  2. 创建serverSocket,得到一个服务端的套接字文件描述符,记为ssfd。
  3. 调用epoll_ctl将监听套接字添加到红黑树中,并指定监听的事件类型(如EPOLLIN表示读就绪事件),同时注册fd就绪时的回调函数。
  4. 进入事件循环,使用epoll_wait函数等待ssfd上有事件发生。
  5. 等待指定时间后若无事件发生,则再次调用epoll_wait。
    当被监听的fd上有事件发生时,根据epoll_wait返回的事件类型,进行相应的处理。
  6. 如果ssfd发生读就绪事件,则说明有客户端进行连接,调用accpt()函数接收客户端socket,得到对应的fd,并调用epoll_ctl为客户端socket添加监听。
  7. 如果发生的是客户端socket的读就绪事件,则使用read()或recv()函数读取客户端发送的数据,进行处理后返回响应。
  8. 如果客户端连接关闭或发生错误,则使用close()函数关闭客户端套接字,并从epoll树中删除。
  9. 循环处理事件,重复步骤4-9‌,继续等待并处理下一个事件,直到服务器进程被终止。

2.7信号驱动IO及异步IO

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

  • 流程 :给 Socket 开启信号驱动功能,并注册一个信号处理函数。当数据准备好时,内核会发送一个 SIGIO 信号通知你,你收到信号后再去拷贝数据。
  • 缺点:当连接数巨大时,信号处理函数可能来不及处理,导致信号队列溢出,且调试困难。

异步IO:整个过程都是非阻塞的,用户进程调用完异步API就可以去做其他事情,内核等待数据就绪并拷贝到用户空间才会递交信号,通知用户进程

  • 流程:这是真正的"异步"。告诉内核"帮我读这个文件",然后你就去处理别的事。内核负责等待数据、拷贝数据,全部完成后通知你搞定了。
  • 现状:虽然理论上效率最高,但 Linux 下的 AIO 实现(libaio)在早期版本中对网络 Socket 的支持并不完美,且实现极其复杂。

2.8Redis单线程及多线程网络模型变更

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理),答案是单线程
  • 如果聊整个Redis,答案是多线程

为什么Redis要选择单线程?

  • 抛开持久化不谈,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而非执行速度,因此多线程并不会带来巨大的性能提升
  • 多线程会导致过多的上下文切换,带来不必要的开销
  • 引入多线程会面临线程安全问题,需要引入线程锁,实现复杂度高,性能也会降低

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

  • Redis v4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率

Redis网络模型

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

3.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''。

    单行字符串的数据中只能包含普通字符串,不允许包含\r\n,是非二进制安全的。通常用于服务端返回的信息

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

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

  • 多行字符串:首字节是 $ ,表示二进制安全的字符串,最大支持512MB。记录时保存字符串长度和字符串本身

    • 如果大小为0,则表示空字符串:$0\r\n\r\n
    • 如果大小为-1,则表示不存在:$-1\r\n
  • 数组:首字节是*,后面跟上数组元素个数,再跟上元素,元素数据类型不限

4.内存回收

Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。

我们可以通过修改配置文件来设置Redis的最大内存。当内存使用达到上限时,就无法存储更多数据了。

4.1过期key处理

Redis中可以通过expire命令给Redis的key设置TTL(存活时间):

当key的TTL到期之后,再次访问返回的是nil,说明这个key已经不存在了,对应的内存也得到释放,从而实现内存回收

Redis是如何知道一个key是否过期的?

  • 利用两个Dict分别记录key-value对及key-ttl对
  • Redis 内部维护了一个**"过期字典",Redis 判断 Key 是否过期,本质上就是拿 当前服务器时间去和过期字典中记录的时间戳**做比对

是不是TTL到期就立即删除了呢?

  • 不是。如果 Redis 采用"定时删除"策略(即每个 Key 到期瞬间立刻删除),当大量 Key 同时过期时,会消耗巨大的 CPU 资源去处理删除任务,导致 Redis 卡顿
  • 惰性删除 周期删除

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

如果很多key过期后很长时间没被访问,若只采用惰性删除这些内存就无法被释放,此时就需要周期删除

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

执行周期有两种:

  • Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,模式为SLOW
  • Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST

SLOW模式规则:

  • 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
  • 执行清理耗时不超过一次执行周期的25%.
  • 逐个遍历db,逐个遍历db中的bucket(相当于dict中哈希表的一个角标下的链表,每次遍历一部分会逐渐遍历所有数据),抽取20个key判断是否过期
  • 如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,否则结束

FAST模式规则(过期key比例小于10%不执行):

  • 执行频率受beforesleep()调用频率影响,但两次FAST模式间隔不低于2ms
  • 执行清理耗时不超过1ms
  • 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期
  • 如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束

4.2内存淘汰策略

内存淘汰 :就是当Redis内存使用达到设置的阈值时,Redis主动挑选部分key删除以释放更多内存的流程

什么时候进行淘汰?

内存淘汰的触发时机非常明确,必须同时满足以下两个条件:

  1. 内存达到上限 :Redis 当前使用的内存量达到了你配置的 maxmemory 阈值。
  2. 执行了写操作 :客户端执行了需要消耗新内存的命令(如 SETLPUSH 等)。

注意 :当这两个条件满足时,Redis 会先尝试删除所有已过期的 Key(主动删除),如果删完过期的内存还不够,才会真正触发内存淘汰策略 ,开始删除那些没过期的数据

淘汰哪些内存?

取决于配置的 maxmemory-policy 参数。Redis 提供了 8 种策略,主要分为三大类:

1. 针对"设置了过期时间"的 Key(Volatile 系列)

如果你希望保留那些没有设置过期时间的重要数据(比如核心配置),只牺牲那些临时缓存数据,就选这类。

  • 淘汰范围 :只在设置了过期时间的 Key 中挑选。
  • 常用策略:
    • volatile-lru:淘汰最近最少使用的。
    • volatile-ttl:淘汰快要过期的(剩余时间最短的)。
    • volatile-random:随机淘汰一个。
    • volatile-lfu:淘汰访问频率最低的。

2. 针对"所有"Key(Allkeys 系列)

如果你把 Redis 纯粹当作缓存使用,所有数据地位平等,不在乎丢数据,就选这类(最常用)。

  • 淘汰范围 :在所有 Key(无论是否设置过期时间)中挑选。
  • 常用策略:
    • allkeys-lru:淘汰最近最少使用的(生产环境最推荐)。
    • allkeys-random:随机淘汰一个。
    • allkeys-lfu:淘汰访问频率最低的。

3. 不淘汰(Noeviction)

  • 策略noeviction(默认策略)。
  • 行为 :当内存满时,不删除任何数据,直接拒绝新的写入请求并报错(OOM command not allowed),但读操作不受影响。

怎么淘汰?

Redis 并不会维护一个精确的链表来记录所有数据的使用情况(那样太耗内存且慢),而是采用了近似算法

LRU(最近最少使用)的实现

  • 原理:Redis 会在每个 Key 的元数据中记录一个"最近访问时间戳"。
  • 过程 :当需要淘汰时,Redis 会随机抽取 N 个 Key (默认 N=5,可通过 maxmemory-samples 配置),然后对比这 N 个 Key 的时间戳,把最老的那个删掉。
  • 特点:速度极快,虽然不如标准 LRU 精准,但在统计学上非常接近,且节省内存。

LFU(最不经。常使用)的实现

  • 原理 :Redis 为每个 Key 维护一个访问计数器(8位,0-255)。
  • 过程:同样采用随机采样的方式,对比计数器的大小。
  • 特点 :为了防止"旧热点数据"长期占据内存,计数器会随时间衰减。如果一个数据以前很热,但最近没人访问,它的计数会慢慢降低,最终被淘汰。
相关推荐
VOOHU_20181 小时前
VOOHU沃虎:音频变压器的主要作用是什么?什么情况下必须使用?
网络·物联网·音视频·电子元器件
遇见你的雩风2 小时前
网络原理(一)
java·网络
952362 小时前
Spring IoC&DI
java·数据库·spring
十六年开源服务商2 小时前
游戏与设计驱动WordPress建站2026
java·前端·游戏
Johnstons2 小时前
网络诊断工具怎么选:从看到异常到真正定位根因的实战方法
网络·wireshark·抓包分析
前进吧-程序员2 小时前
C++ 内存到底分配在哪?
java·jvm·c++
NWU_白杨2 小时前
VoiceMockInterview项目MVP开发
java·ai
RDCJM2 小时前
Springboot的jak安装与配置教程
java·spring boot·后端
呱牛do it2 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 4)
java·vue