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

想要用户的应用来访问,计算机就必须要通过对外暴露的一些接口才能访问到,从而间接的实现对内核的操控,但是内核本身上来说也是一个应用,所以他本身也需要一些内存,cpu等设备资源,用户应用本身也在消耗这些资源。
为了避免用户应用导致冲突甚至内存崩溃,用户应用和内核是分开的
- 进程的寻址空间划分成两部分:内核空间 、用户空间
- 用户空间只能执行受限的命令,而且不能直接调用系统资源,必须通过内核提供的接口来访问
- 内核空间可以执行特权命令,调用一切系统资源

Linux系统为了提高IO效率,会在用户空间和内存空间都加入缓冲区:
- 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
- 读数据时,要从设备读取到内核缓冲区,然后拷贝到用户缓冲区

五种IO模型:
- 阻塞IO
- 非阻塞IO
- IO多路复用
- 信号驱动IO
- 异步IO

任何一次网络读写操作都分为两个阶段:
- 等待数据就绪:数据从网卡传输到内核缓冲区
- 数据拷贝:数据从内核缓冲区拷贝到用户空间(应用程序)
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拉出来排成一队,从头走到尾一个个问。
- 用户态准备 :把所有要监听的 FD 塞进一个数组 (
fd_set)里。 - 拷贝 :调用
select时,把这个数组完整地拷贝到内核空间。 - 内核遍历:内核拿着这个数组,从头到尾遍历每一个 FD,检查它有没有数据。
- 注意:内核会修改这个数组,把没数据的 FD 剔除掉(或者标记一下)。
- 返回与再遍历 :内核把修改后的数组拷贝回 用户态。用户拿到数组后,再次遍历 整个数组,通过位运算(
FD_ISSET)找出到底是哪个 FD 就绪了。
select模式存在的问题:
- 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
- select无法得知具体是哪个fd就绪,需要遍历整个fd_set fd_set
- 监听的fd数量不能超过1024
poll

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

核心逻辑 :内核里有个红黑树所有FD。数据就绪回调,内核只把就绪的数据告诉用户
epoll 的过程分为"注册"和"等待"两个阶段:
第一阶段:注册 (epoll_ctl)
-
建立连接 :调用
epoll_create在内核创建一个红黑树 (存所有 FD)和一个就绪链表。 -
添加监听:调用
epoll_ctl把 FD 添加到红黑树中,并注册一个回调函数
- 关键点: 告诉内核,"如果这个 FD 有数据了,请执行这个回调函数"。
第二阶段:等待 (epoll_wait)
- 阻塞等待 :调用
epoll_wait,进程直接阻塞,不需要拷贝 FD 集合,也不需要遍历。 - 回调唤醒:
- 当网卡收到数据,触发硬件中断。
- 内核处理完数据后,自动调用之前注册的回调函数。
- 回调函数把这个 FD 加入到就绪链表中。
- 唤醒正在等待的进程。
- 直接获取 :
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、高性能网关 |
- ET模式避免了LT模式可能出现的惊群现象
- ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一点
2.6基于epoll的服务端流程

- 在服务端调用epoll_create创建epoll实例,在内核中创建红黑树和就绪列表
- 创建serverSocket,得到一个服务端的套接字文件描述符,记为ssfd。
- 调用epoll_ctl将监听套接字添加到红黑树中,并指定监听的事件类型(如EPOLLIN表示读就绪事件),同时注册fd就绪时的回调函数。
- 进入事件循环,使用epoll_wait函数等待ssfd上有事件发生。
- 等待指定时间后若无事件发生,则再次调用epoll_wait。
当被监听的fd上有事件发生时,根据epoll_wait返回的事件类型,进行相应的处理。 - 如果ssfd发生读就绪事件,则说明有客户端进行连接,调用accpt()函数接收客户端socket,得到对应的fd,并调用epoll_ctl为客户端socket添加监听。
- 如果发生的是客户端socket的读就绪事件,则使用read()或recv()函数读取客户端发送的数据,进行处理后返回响应。
- 如果客户端连接关闭或发生错误,则使用close()函数关闭客户端套接字,并从epoll树中删除。
- 循环处理事件,重复步骤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
- 如果大小为0,则表示空字符串:
-
数组:首字节是
*,后面跟上数组元素个数,再跟上元素,元素数据类型不限
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删除以释放更多内存的流程
什么时候进行淘汰?
内存淘汰的触发时机非常明确,必须同时满足以下两个条件:
- 内存达到上限 :Redis 当前使用的内存量达到了你配置的
maxmemory阈值。 - 执行了写操作 :客户端执行了需要消耗新内存的命令(如
SET、LPUSH等)。

注意 :当这两个条件满足时,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)。
- 过程:同样采用随机采样的方式,对比计数器的大小。
- 特点 :为了防止"旧热点数据"长期占据内存,计数器会随时间衰减。如果一个数据以前很热,但最近没人访问,它的计数会慢慢降低,最终被淘汰。

