摘要:本篇聚焦 Redis 核心底层原理与关键机制,系统讲解了 String、List、Hash 等常用数据类型的底层实现,同时深入剖析 Redis 过期删除策略、AOF 与 RDB 持久化机制。


第一部分
SDS(动态字符串)

SDS(Simple Dynamic String)是 Redis 用于替代 C 语言原生字符串的实现,它通过在字符串头部增加元数据,解决了 C 字符串在效率和安全性上的不足。
++SDS 的结构与核心字段++
|------|-------------------------------------|
| 字段 | 作用 |
| len | 记录 buf 数组中已使用的字节数,即字符串的实际长度。 |
| free | 记录 buf 数组中未使用的字节数,代表剩余可扩展空间。 |
| buf | 字节数组,保存字符串内容,末尾会自动添加空字符 \0,支持二进制安全 |

O (1) 时间复杂度获取长度 :C 字符串需要遍历整个字符串,而 SDS 直接读取 len 字段即可,效率更高。

预分配空间,减少内存 重分配 :通过 free 字段预分配未使用空间,在字符串增长时可直接利用剩余空间,避免频繁的内存扩容和拷贝。

二进制安全 :C 字符串以 \0 作为结束标志,无法存储包含 \0 的二进制数据;而 SDS 用 len 来判断长度,支持存储任意二进制数据。
dict(字典)
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:


给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
第一个≥「当前数据量 ×2」的 2 次幂。比如当前装了 5 个数据,5×2=10,第一个≥10 的 2 次幂是 16,新盒子就 16 个格子
将「哈希表 1 」的数据迁移到「哈希表 2」 中;
迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。
此过程看似简单,但其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」时,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了++渐进式 rehash++,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

渐进式 rehash 步骤如下:
-
初始化 :为哈希表 ht [1] 分配符合规则的新空间,初始化全局变量
rehashidx=0(标记当前待迁移的 ht [0] 桶索引),正式开启 rehash; -
渐进式迁移 :每次处理哈希表的增 / 删 / 改 / 查请求时,除执行业务操作,还会将 ht [0] 中
rehashidx索引桶的所有键值对重新计算哈希后迁移至 ht [1],迁移完成后rehashidx+1;同时 Redis 后台定时任务会批量迁移桶,加速流程; -
读写兼容处理:读请求先查 ht [1]、再查 ht [0],保证数据不遗漏;写请求直接操作 ht [1],让 ht [0] 数据只减不增;
-
收尾完成 :当 ht [0] 所有桶迁移完毕,将
rehashidx置为 - 1,释放 ht [0] 内存,把 ht [1] 设为新的 ht [0],并重置 ht [1] 为空表,rehash 完成。
这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。
zskiplist(跳表)
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。
跳表的本质是多层级的有序 链表------ 在原始链表(最底层 L0)之上,构建若干层 "索引",每一层索引节点是下一层节点的子集,层级越高,节点越少。

跳表的层数不是均匀分配的,而是++随机生成++,保证层级分布的平衡性,具体规则:
-
每个新节点默认从 1 层开始;
-
生成 [0,1) 的随机数,若小于 0.25(25% 概率),层数 + 1,重复此过程;
-
直到随机数 > 0.25 停止,最终层数不超过 64 层;
++查找过程++:
-
从跳表头节点的实际最高层级开始,用一个指针指向头节点,同时记录当前排名(用于计算元素的排名)。
-
++针对当前层级++ ,先对比指针指向节点的++下一个节点的权重值++ 与++目标元素对应的权重值++(注:Redis 中可先通过哈希表快速获取目标元素的权重值):
-
若下一个节点的权重值 < 目标权重值:说明++目标在更后方++,将指针移至该节点,累加节点间的跨度(更新排名),继续在当前层级比较;
-
若下一个节点的权重值 > 目标权重值:当前层级无法定位到目标,++层级减 1++,到下一层继续比较;
-
若下一个节点的权重值 == 目标权重值:++直接对比该节点的元素实际值与目标元素值,一致则找到目标;不一致则沿当前层级继续往后遍历(相同权重值的元素按字典序排列),直到匹配到目标元素值或遍历完同权重节点。++
-
-
++当层级降到最底层(原始链表)后,停止跨层跳跃,逐个遍历指针指向节点的下一个节点:++
-
先核对节点权重值是否与目标权重值一致,再核对元素实际值是否匹配;
-
找到完全匹配的节点则返回结果,遍历至链表尾部仍未匹配则确认目标不存在。
-
Redis为什么使用跳表而不是用B+树?
核心原因可以总结为以下几点:
|--------|----------------|--------------------|
| 特性 | 跳表 (Skip List) | B+ 树 (B+ Tree) |
| 主要应用场景 | 内存数据库 (Redis) | 磁盘数据库 (MySQL) |
| 查找复杂度 | 平均 O(log N) | O(log N) (底数大,高度低) |
| 实现难度 | 低 (几十到几百行) | 高 (需处理分裂、合并) |
| 写操作代价 | 局部指针修改,无全局重平衡 | 可能引发连锁分裂/合并 |
- 内存 vs. 磁盘(B + 树的核心优势完全失效):
B + 树的设计初衷是优化磁盘 IO(比如 MySQL 索引):通过 "矮胖" 的树结构(多层节点聚合 + 叶子节点链表)减少磁盘寻道次数,这是它的核心价值。
但 Redis 是纯 内存数据库:
++内存中指针跳转的耗时(纳秒级)远低于磁盘++ ++IO++ ++(++ ++毫秒++ ++级)++,跳表哪怕层数多、多几次指针跳转,开销可忽略;
B + 树为了减少磁盘 IO 设计的 "页管理、节点分裂 / 合并" 机制,在内存中反而成了 "冗余负担"------ 比如 B + 树++插入时的页分裂需要移动大量数据,而++ ++跳表++ ++只需修改少量指针++,内存场景下跳表的操作效率远高于 B + 树。→ 结论:B + 树的核心优势(IO 优化)在 Redis 场景下用不上,反而暴露了自身复杂度的缺点,而跳表完全适配内存的高效特性。
- 写入性能与重平衡代价:
B+ 树的写入抖动: 当插入数据导致页分裂时,可能需要移动大量数据或改变树结构,这会产生性能抖动。
跳表的局部性: 跳表的插入和删除操作是局部的。插入一个节点只需要修改前后节点的指针,并根据概率随机生成层高。它不需要像 B+ 树那样进行全局的旋转或复杂的结构调整。
- 跳表对 Zset 的核心需求支持更优:
对 Zset 的增删改查、范围查询(找到起始节点后,沿 L0 层链表直接遍历)、排名计算(Zset 的 Zrank/Zrevrank 命令是核心需求,跳表天然支持)等核心操作,跳表不仅实现更简单,性能和稳定性也远优于 B+树(B + 树的节点分裂 / 合并会导致性能抖动,且排名计算逻辑复杂)。
ziplist(压缩列表)

压缩列表是 Redis 为省内存设计的连续内存型数据结构,用于 Hash、List、Zset 等数据类型的 "小数据场景"。
压缩列表是一段连续的内存空间,整体分为 "表头 + 节点 + 结束标记" 三部分:(实现 O (1) 定位头尾节点)
|---------|---------------------|--------------------------|
| 表头字段 | 作用 | 核心价值 |
| zlbytes | 记录整个压缩列表的总字节数 | 扩容 / 缩容时快速计算内存大小,无需遍历 |
| zltail | 记录尾节点距离列表起始地址的字节偏移量 | O (1) 时间定位到最后一个节点,支持反向遍历 |
| zllen | 记录列表中的节点总数 | O (1) 获取节点数量 |
| zlend | 固定值 0xFF,标记列表结束 | 作为边界标识,避免越界 |
每个压缩列表节点由 3 部分组成,核心是 "按数据大小 / 类型动态分配空间",避免内存浪费:
-
prevlen(前节点长度):记录前一个节点的字节长度,支持反向遍历(从尾节点往前找);
-
encoding(编码字段):同时记录 "当前节点数据的类型(字符串 / 整数)" 和 "数据长度";
-
data(实际数据):存储节点的实际内容(字符串 / 整数),长度和类型完全由 encoding 决定;
优点:由于是连续内存型数据结构**,**因此内存利用率极高

缺点:查找效率低,中间节点需逐个遍历(O (N)),节点多了性能暴跌;同时也会引发++连锁更新++问题,因为 prevlen 字段的长度是动态的(1/5 字节),若某个节点的长度变化(比如从 253 字节变 254 字节),会导致后一个节点的 prevlen 字段需要扩容(从 1 字节变 5 字节),进而触发后续节点的 prevlen 连续修改,最坏情况下导致多次内存重分配,影响性能。
listpack(紧凑列表)
listpack 是 Redis 5.0 版本为++彻底解决压缩列表(ziplist)的连锁更新问题++而设计的新数据结构,核心目标是:保留压缩列表 "连续内存、省内存" 的优势,同时根除连锁更新的性能隐患,最终用于替代压缩列表,适配 Hash、List 等小数据量场景。
listpack 的节点结构是解决连锁更新的核心,每个节点仅包含 3 部分,彻底去掉了压缩列表的 prevlen字段:
|----------|------------------------------|--------------------------|
| 节点字段 | 作用 | 核心价值 |
| encoding | 标记当前节点数据的类型(整数 / 字符串)和长度 | 动态适配数据大小,小数据用少字节存储,省内存 |
| data | 存储节点的实际数据(整数 / 字符串) | 数据长度和类型由 encoding 决定,无冗余 |
| len | 记录当前节点 "encoding+data" 的总字节数 | 替代 prevlen 实现遍历(正向 / 反向) |
而 listpack 的节点只记录自身的总长度(len),节点长度变化仅影响自身,完全不会波及其他节点 ------ 这是根除连锁更新的核心逻辑。
第二部分
Redis过期删除策略

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded 函数实现,代码如下:
java
int expireIfNeeded(redisDb *db, robj *key) {
// 判断 key 是否过期
if (!keyIsExpired(db,key)) return 0;
....
/* 删除过期键 */
....
// 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
Redis 在访问或修改 key 之前,都会调用++expireIfNeeded++函数对其进行检查,检查 key 是否过期:
-
如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
-
如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
惰性删除的流程图如下:

Redis 的++定期删除++是每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
Redis 主线程会按hz配置的频率(默认 10 次 / 秒)执行定期删除,流程如下:
-
随机抽样:从「过期字典」中随机抽取 20 个 Key;
-
检查并删除:逐个判断这 20 个 Key 是否过期,删除所有已过期的 Key;
-
判断是否继续抽样:
-
若删除的过期 Key 数量≥5 个(占比 25%),说明当前过期 Key 比例高,重复步骤 1-2;
-
若占比<25%,说明过期 Key 已清理得差不多,停止本轮删除;
-
-
时间兜底:整个过程中,若耗时达到 25ms,无论是否完成抽样,立即停止,避免阻塞主线程。
Redis的缓存失效会不会立即删除,为什么?
不会,Redis 的过期删除策略是选择「惰性删除+定期删除」这两种策略配和使用。
-
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
-
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
AOF与RDB持久化机制

Redis 是纯内存数据库,数据默认只存放在内存中,一旦重启 / 宕机数据会全部丢失。持久化机制的核心目的是「将内存中的数据异步 / 同步落地到磁盘」,保证数据的持久性(兼顾性能和可靠性)。Redis 提供两种核心持久化方案:RDB(快照持久化)和 AOF(追加日志持久化),两者可以单独使用,也可以组合使用。
RDB
RDB全称Redis数据备份文件,也被叫做Redis数据快照,RDB通过快照的形式保存某一时刻的数据状态。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
执行时机:save,bgsave,停机,触发RDB条件

RDB的执行原理:

主进程无法直接操作物理内存,只能通过自身页表获取并访问物理内存中的数据。
在执行 bgsave 时,主进程会通过 fork 创建子进程,此时子进程会复制主进程的页表。
当主进程执行读操作时,访问共享内存;当主讲程执行写操作时,则会拷贝一份数据,执行写操作。
这种方式借助了操作系统的读时共享,写时复制(Copy-on-Write)机制,实现了在不中断主进程正常运行的情况下完成数据持久化
AOF

AOF全称为++追加文件++ 。Redis处理的++每一个写命令都会记录在AOF文件++ ,可以看做是命令日志文件。 AOF默认是关闭的,可以++通过配置文件开启AOF以及AOF的命令记录的频率。++
bash
# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
AOF文件重写
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
RDB与AOF对比
RDB和AOF各有优缺点,如果对数据安全性要求较高,在实际开发中往往会++结合++两者使用。
AOF:
-
优点:首先,AOF提供了更好的数据安全性,因为它默认每接收到一个写命令就会追加到文件末尾。即使Redis服务器宕机,也只会丢失最后一次写入前的数据。其次,AOF支持多种同步策略(如everysec、always等),可以根据需要调整数据安全性和性能之间的平衡。
-
缺点:因为记录了每一个写操作,所以AOF文件通常比RDB文件更大,消耗更多的磁盘空间。并且,频繁的磁盘IO操作(尤其是同步策略设置为always时)可能会对Redis的写入性能造成一定影响。而且,当问个文件体积过大时,AOF会进行重写操作,AOF如果没有开启AOF重写或者重写频率较低,恢复过程可能较慢,因为它需要重放所有的操作命令。
RDB:
-
优点: RDB通过快照的形式保存某一时刻的数据状态,是二进制文件,文件体积小,备份和恢复的速度非常快。并且,RDB是在主线程之外通过fork子进程来进行的,不会阻塞服务器处理命令请求,对Redis服务的性能影响较小。最后,由于是定期快照,RDB文件通常比AOF文件小得多。
-
缺点:RDB方式在两次快照之间,如果Redis服务器发生故障,这段时间的数据将会丢失。并且,如果在RDB创建快照到恢复期间有写操作,恢复后的数据可能与故障前的数据不完全一致。
Redis中的所有操作都是单线程吗?
Redis不是所有操作都完全单线程,它的核心网络 IO 和数据操作(如读写键值对)是单线程的,但存在一些辅助线程处理非核心任务:
-
核心流程(单线程):接收客户端请求、执行命令、返回结果等核心逻辑,都由主线程顺序执行(这也是 Redis 要依赖 I/O 多路复用的原因)。
-
辅助线程(多线程):像持久化(RDB/AOF 刷盘)、网络 IO 的数据读写(Redis 6.0+)等操作,会由专门的辅助线程处理,避免阻塞主线程。
Redis 单线程模型

Redis 被称为 "单线程模型",核心是其文件事件处理器(file event handler) 以单线程方式运行,而这套处理器基于 Reactor 模式设计,结合 I/O 多路复用技术,既保持了单线程设计的简单性,又实现了高性能的网络通信。
核心定位:单线程的 "核心对象" 是文件事件处理器
Redis 并非所有操作都是单线程(比如持久化、集群同步等由辅助线程处理),但处理网络请求的核心模块 ------ 文件事件处理器是单线程的,这也是我们说 Redis 是单线程模型的核心依据。
核心设计:Reactor 模式 + I/O 多路复用
文件事件处理器的工作逻辑完全贴合 Reactor 模式(高性能 IO 的核心模式):
-
文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
-
当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
- 单线程的优势:简单性 + 高性能
高性能的原因:I/O 多路复用让单线程能高效处理大量并发连接(避免了多线程的上下文切换开销),且 Redis 操作的是内存数据(读写速度极快),单线程足以支撑高并发;
简单性的原因:单线程无需处理多线程的锁竞争、数据一致性问题,大幅降低了 Redis 内部设计的复杂度。
恭喜你学习完本节内容!✿