ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis源码阅读指南:六条主线带你快速定位核心实现

Redis源码阅读指南:六条主线带你快速定位核心实现

先讲个场景。某次服务半夜出现大面积缓存穿透,我排查了配置、看了监控、试了几个网上推荐的参数组合都不见起色,最后只能硬着头皮打开Redis源码,顺着命令分发链路一路往下追,才在一个不太起眼的函数里找到症结。那之后我养成了一个习惯:凡是Redis行为没法解释的,就去源码里翻答案,而不是继续猜测。真正开始读源码之后发现,它并没有想象中那么可怕,但要是不做分类,很容易迷失在文件海洋里。

Redis源码分类,本质上就是把src目录下那一大摊C代码按照"它们各自要解决的问题"分成若干条清晰的线索。我自己的读法分六条主线:数据结构线、事件驱动与网络线、命令分发线、内存管理线、持久化线、主服务框架线。这篇文章就把我按这套思路摸索出的模块地图、入口文件、核心函数、推荐阅读顺序全部列出来,给准备啃Redis源码的同学一条能走通的捷径。

这套方法适合两类人。一类是已经在用Redis做缓存、队列、分布式锁,但在排查延迟抖动、大key阻塞、内存碎片这些深入问题时觉得文档不够用的人;另一类是想拿Redis当C语言和高性能服务端范本,搞清楚一个网络服务器到底怎么跑起来的人。如果你刚会用命令行,可能还需要再熟悉一阵子,可一旦你开始琢磨"它到底怎么实现的",就可以按这个分类切入源码了。

1. 为什么我坚持按模块拆解,而不是从头读到尾

1.1 先搞清Redis源码的体积和边界

Redis的核心代码量并没有很多人想的那么大。除去deps依赖库,真正需要读的源码集中在src目录,大约几十万行C代码,几十个核心文件。按一个文件一个文件从头翻的读法,第一周就会迷失在宏定义、函数指针和各种结构体之间,因为你缺少一张"地图"。

我的经验是必须带着问题去读,并且先把模块边界划清楚。比如你要搞懂"为什么内存会涨到无法控制",就去读内存和过期淘汰相关的那几个文件;你要搞懂"为什么有时一个命令会把整个实例卡住",就去读大key删除、RDB持久化fork以及事件循环里可能阻塞主线程的路径。源码分类最大的价值不是让你背下每个函数,而是让你在遇到问题的时候能在几十秒内定位到具体文件和具体函数,然后顺着调用链把问题看穿。

1.2 源码分类的六条主线

我习惯把Redis源码分成六条主线,每条线都有清晰的物理文件对应:

主线核心文件解决的问题
数据结构层sds.c、dict.c、ziplist.c、quicklist.c、skiplist.c、intset.c、listpack.cRedis自研的数据结构为什么省内存、为什么快
网络与事件驱动ae.c、ae_epoll.c、networking.c单线程如何支撑高并发,连接和请求怎么流转
命令系统server.c、db.c、t_string.c、t_list.c、t_hash.c、t_zset.c一条命令从Socket数据到执行完写回的全过程
内存管理expire.c、evict.c、object.c、mem.c过期删除、淘汰策略、引用计数和内存统计
持久化rdb.c、aof.c快照生成、日志追加、启动恢复
主框架与多线程server.c、bio.c、threaded.c、lazyfree.c启动流程、后台线程、多线程IO、慢任务异步化

这张表建议存下来,每次读源码之前先看一眼,确定自己正在研究的函数属于哪条线。我后面所有章节,都围绕这张表逐步展开。

2. 源码根目录和src主战场:先看懂一张地图

2.1 顶层目录分工先过一遍

在跳进src之前,我建议把仓库顶层结构扫一遍,它决定了你后续搜索代码的范围。Redis仓库总共就那么几个大目录,每个的作用非常清晰:

目录作用阅读优先级
srcRedis核心源代码所在地,服务器、客户端、网络、数据结构、持久化全在这里最高
deps第三方依赖,包括内存分配器、命令行编辑库、客户端库等用到再看
tests单元测试、集成测试、集群测试的脚本验证理解时很有用
utils辅助脚本、容器化配置、工具低
runtest-* 系列脚本各种测试入口低

很多人一上来就直奔src里的函数实现,忽略了顶层结构,结果在deps里乱翻浪费时间。src目录才是我们研究的主战场,deps只有在你看jemalloc如何分配内存、hiredis如何发送请求时才有必要打开。

2.2 src目录下的文件模块表

src目录里文件的命名其实已经自带分类信息,但为了让模块边界更清楚,我把它梳理成下面这张表:

类别代表文件你会在里面看到什么
入口和主框架server.c、server.hmain函数、redisServer结构体、serverCron周期任务
网络与事件ae.c、ae_epoll.c、ae_select.c、networking.cepoll封装、客户端连接管理、输入输出缓冲区
数据结构sds.c、dict.c、ziplist.c、quicklist.c、skiplist.c、intset.c、listpack.c所有自研数据结构的增删改查
类型命令t_string.c、t_list.c、t_hash.c、t_set.c、t_zset.c、t_stream.c每种Redis数据类型对应的命令实现
内存管理expire.c、evict.c、object.c、mem.c、db.c过期、淘汰、robj对象系统、键空间操作
持久化rdb.c、aof.cRDB快照读写、AOF日志追加与重写
多线程与后台bio.c、threaded.c、lazyfree.c后台线程、IO线程、惰性释放大key内存
复制与集群replication.c、cluster.c主从复制、集群通信、槽位管理
基础工具util.c、endianconv.c、crc64.c、sha1.c字符串转换、字节序、校验和

拿到这张表以后,我做的事很简单:把每个文件的头注释、include块和文件里最顶层的几个函数名扫一遍,然后在server.h里搜一下对应文件操作的全局变量。这个过程花不了两小时,但它能帮你建立一个"哪个文件大概管什么事"的直觉。后面遇到问题,先想到文件,再想到函数,定位速度能快好几倍。

3. 数据结构文件虽然在最底层,却是最佳入门入口

3.1 sds.c:字符串为什么要带头部长度

很多人读源码第一站选sds,因为它简单且无处不在。sds不是一个裸的char数组,而是带长度记录的动态字符串,结构上分为header(包含len和alloc等字段)和实际的字符数组两部分,取字符串长度是O(1)。我读sds.c的时候重点看了三个函数:sdsnewlen负责分配内存和初始化header;sdsMakeRoomFor负责扩容,目标是让每次追加操作尽量少触发realloc;sdscat和sdscmp则是使用频率最高的追加和比较API。

关于扩容,Redis的策略很有代表性:当字符串长度小于1MB时,扩容后直接翻倍预留空间;超过1MB时,每次追加只额外预留约1MB用于增长。这背后的逻辑是,小字符串频繁追加时翻倍能减少系统调用次数,而大字符串翻倍会造成明显的内存浪费,所以采用线性预留。这个设计在C语言项目里非常经典,值得反复体会。

3.2 dict.c:渐进式rehash到底渐进在哪里

dict.c是Redis最值得精读的文件之一,因为它把"哈希表如何在单线程模型下平滑扩容"变成了教科书级的代码。Redis的dict不是一张哈希表,而是两个哈希表交替使用:正常情况下数据都在ht[0],rehash时数据逐步迁移到ht[1],迁移完成后交换身份,旧的释放掉。

为什么必须渐进式?因为Redis主线程是单线程的,如果rehash一次性跑完,几百万个key的迁移会让整个实例卡顿非常久。源码里的做法是让每个增删改查操作都顺带执行一小段dictRehash,每次只迁移固定数量的哈希桶,同时通过rehashidx记录迁移进度。我在读dictAdd、dictFind、dictDelete三个核心函数时,把每个函数里对rehashidx的判断全部标注出来,然后自己画了一张状态迁移图,这才真正理解"渐进式"三个字的含义。还有一个容易被忽略的细节:当Redis用scan命令遍历哈希表时,rehash的进度控制会变得复杂,因为迭代器在两张表上都要能工作,这部分在dictScan里是一段非常精巧的代码。

3.3 ziplist、quicklist、listpack:紧凑内存的取舍

Redis并不是所有数据类型都用哈希表或跳表,它有一套很务实的编码策略。当t_hash和t_zset里的元素较少时,底层用的是ziplist这样的紧凑连续内存结构,目的是省内存并提升CPU缓存命中率;当元素数量超过阈值,或者单个元素变大后,才转换成dict加skiplist的结构。你会经常在命令文档里看到"编码转换"这个概念,转换逻辑就藏在t_hash.c、t_zset.c等文件对应的hashTypeSet、zsetAdd这类函数里。

ziplist的节点布局在旧版本里用prevlen记录前一个节点的长度,从而支持从尾部反向遍历,但当prevlen超过254字节时会扩展为5字节,这个变长字段是ziplist容易踩坑的地方。新版本正在用listpack替代ziplist,listpack取消了prevlen对前一项长度的反向依赖,改成用固定长度的尾部字段管理遍历,结构更稳定。quicklist则是多个ziplist通过双向链表串起来,它在list类型的演进中承担过重要角色。

3.4 intset和skiplist:小集合与有序集合的底层细节

intset是一个有序的整数数组,当集合的元素全是整数且数量不多时,用intset存储非常省空间。它内部有一个升级机制:当新增的整数超过当前编码范围时,整个数组会扩展到更大的整数类型并重新分配。这个升级不可逆,所以你会看到大整数进入之后,编码再也不会降回去,这是保证有序、紧凑和简单之间的折中。

skiplist看起来像多层链表,每层都是稀疏的索引。Redis在zset元素多时同时使用dict和skiplist,dict负责O(1)按成员查分值,skiplist负责按分值排序和范围查询。跳表的层数通过随机函数生成,所以它的期望时间复杂度是O(log n),但实现起来比平衡树简单得多。读skiplist.c时建议把zslInsert从头到尾走一遍,你会看到从创建新节点到逐层更新forward和span指针的全过程,看得懂这部分,就能理解范围查询、排名查询是怎么通过span字段加速的。

从工程层面说,数据结构层是理解"Redis为什么快"的第一手材料,也是后面所有上层模块的基础。把这些文件读熟,后面看命令实现会非常顺畅。

4. 事件循环与网络层:单线程高性能的真相

4.1 ae事件循环的抽象方式

Redis单线程却扛住高并发的核心在ae.c。我理解的ae就是一个跨平台的事件循环封装,它把epoll、kqueue、select这些底层多路复用接口统一成一组叫aeApi的函数。aeEventLoop结构体里维护着文件事件表、时间事件链表、唤醒管道和平台相关的私有数据。aeMain是那个永远不返回的死循环,每一轮都调用aeProcessEvents处理当前有事件可读可写的fd,以及到期的定时任务,然后根据最近的定时任务时间计算epoll_wait的超时,进入下一次等待。

最有意思的设计是epoll_wait的超时为什么不能直接填-1。因为Redis内部有大量的周期性任务,比如过期key采样、客户端超时检查、后台任务调度,这些都在serverCron里按hz频率执行。aeMain必须保证时钟事件到期时能立即被处理,而不是一直等在I/O上,所以每次都要算出"距离下一个定时任务还有多久",把这个值作为epoll_wait的阻塞时间。这个机制把I/O事件和时间事件完美地缝在一起,是整个事件循环的精髓。

4.2 networking.c:客户端连接和缓冲区的完整管理

事件循环只负责告诉你"哪个fd可以读了",真正把数据变成命令的是networking.c。看这个文件时主线别打乱:acceptTcpHandler接受新连接,readQueryFromClient读取socket数据,processInputBuffer按RESP协议解析命令,processCommandAndResetClient去命令表找到处理函数,最后addReply和writeToClient把响应写回去。

我对networking.c印象最深的部分是客户端缓冲区管理。每个人的连接都是一个client结构体,里面包含querybuf输入缓冲区、reply输出缓冲区和固定大小的静态回复buf。当客户端产出慢、输出缓冲区持续积压时,Redis不会傻傻地继续往内存里塞,而是会进入一种特殊的延迟回复状态,甚至主动断开连接。这块逻辑在源码注释里被写得很详细,强烈建议读一读。

4.3 从一次connect到一条命令的完整路径

我建议你动手把一次SET命令的网络路径画出来,大概是这样:

  1. 客户端TCP连接到达,epoll_wait返回可读事件;
  2. acceptTcpHandler调用accept创建client结构,挂进server.clients链表;
  3. 该连接下一次可读时,readQueryFromClient把数据搬进c->querybuf;
  4. processInputBuffer从querybuf里按协议拆出命令和参数;
  5. processCommand执行合法性检查,call执行真正的命令函数;
  6. 命令结果写入回复缓冲区,注册写事件;
  7. 写事件触发writeToClient把回复发回去。

这条链路是所有Redis请求都会走的路,你只要把每一步对应的函数名背下来,以后看什么问题都能顺藤摸瓜。

5. 命令系统:注册表、参数校验与分发逻辑

5.1 redisCommandTable:一张表管理全部命令

server.c里的redisCommandTable数组就是整个命令系统的中枢。每行对应一条命令,背后是一个redisCommand结构体,里面包括命令名、处理函数指针、参数个数要求、命令flag、以及和集群分片、ACL权限相关的firstkey、lastkey、key_step等元信息。我在读这个表的时候发现,它相当于一张"命令说明书",几乎所有上层逻辑都在依赖这些元数据做判断,而不是简单调用处理函数。

5.2 processCommand:命令执行前的层层关卡

真正执行命令之前,processCommand要过非常多的检查关卡。常见的有:命令是否存在、ACL权限允许不允许、当前内存是否达到maxmemory、是否需要先触发淘汰、实例是否还在加载持久化文件、从节点是否允许执行写命令、集群模式下key属于哪个槽、参数个数够不够。这一连串检查非常适合用来理解一个成熟的服务端框架在请求入口要做哪些保护,对做后端系统设计的人特别有启发。

5.3 call函数:命令执行的中转站

所有命令最终都会汇总到call函数。它会在调用处理函数之前做命令监控、慢日志判断、统计计数,在执行完成之后处理脏数据标记、PubSub消息、watch通知等副作用。这里有一个dirty变量特别关键——它记录当前命令修改了多少个key。AOF重写和主从复制缓冲区都在依赖这个计数,判断一条命令是否需要传播给从节点或者写进AOF文件。

5.4 一个实战定位案例:查一条陌生命令怎么落地

假设你想知道一个没见过的命令内部是怎么实现的,我的做法是先在redisCommandTable里搜到它的名字,然后看它的proc字段指向哪个函数,再用gdb打断点或者加日志验证。这个入口比从其他博客翻答案更快,而且你永远在看当前这个版本的真相。通过这种方式,我排查过scan游标的实现、debug对象的执行逻辑、以及某些命令在多线程IO开启后的行为差异。学会了这套定位法,你对Redis的认知就不再受二手资料约束。

6. 过期、淘汰与内存:缓存核心的源码落点

6.1 expire.c:惰性删除和定期删除的配合

Redis的过期删除不是靠一个定时器逐个扫描所有key,而是惰性删除加定期删除的组合拳。惰性删除发生在每次lookupKeyRead读取key的时候,通过expireIfNeeded判断是否过期,过期就删除并返回为空。定期删除由serverCron周期性调用activeExpireCycle,它并不会全量扫描,而是每次抽一部分带过期时间的库和key,在限定时间内尽量多清理几个。

读expire.c的重点就在activeExpireCycle的循环控制:它用局部变量记录本轮进度,每个库最多扫多少key、每轮最多执行多长时间,都有硬性限制。这样设计是为了避免清理过期key的耗时影响了正常请求。把这些限制条件读懂,你就能理解为什么Redis在某些场景下会存在"过期key明明到了时间却还在内存里"的现象,也能回答"为什么不能把所有过期key实时删掉"这个经典问题。

6.2 evict.c:maxmemory淘汰策略的具体实现

当内存达到maxmemory,Redis会进入逐出逻辑,由evict.c来裁决哪些key要被牺牲。它支持多套策略,总体分allkeys和volatile两大类,再细化成LRU、LFU、random、TTL等变体。所有策略最终都会汇聚到performEvictions这个函数:先估算当前使用内存,超限就循环执行逐出,每次通过dictGetSomeKeys采样一批候选key,按策略排序后删掉最合适的那个,然后继续检查内存是否降到限制内。

LFU的实现比较精彩,它会用16位记录访问频次、8位记录时间衰减,天生就适合做热点数据的精准识别。LRU也不是全量扫描,而是维护一个淘汰候选池做近似LRU,这样控制住了逐出操作本身的CPU开销。读evict.c时你可以顺手看看serverCron是如何周期性地给采样池做维护的,那部分代码能让你理解"近似LRU"和"严格LRU"之间的取舍。

6.3 object.c与db.c:对象系统与键空间

object.c负责Redis对象系统的实现,核心是robj结构体以及它的refcount引用计数引用计数相关操作。每个Redis值都是一个robj,里面包含type、encoding、ptr和lru字段。type决定它是字符串、列表还是哈希,encoding决定底层具体是哪一种数据结构,ptr指向真正的数据内存。引用计数的目的是让内存可以被安全共享,比如多个key指向同一个字符串对象,减少重复分配。读object.c时你会看到createStringObject、createIntObject等工厂函数,以及各种编码转换逻辑。

db.c管的是键空间字典。Redis每个逻辑数据库都是一个redisDb结构体,里面有一个主dict存储key和value的映射,还有一个expires字典单独存key和过期时间。dbAdd向键空间插入一个新键,lookupKey是每次读请求必经之路,那个红黑树般的过期检查逻辑就在这里和expire.c配合。读db.c会让很多模糊的概念落地:什么叫做"键空间",为什么过期时间是单独存的,以及删除一个key为什么有时会同步触发过期字典里的清理。

7. 持久化双通道:RDB与AOF的模块边界

7.1 rdb.c:快照的生成与加载

rdb.c负责把整个数据集序列化到磁盘。核心流程是rdbSaveRio:从所有数据库和所有key开始遍历,按照类型调用对应的rdbSaveObject,把数据写成RDB的二进制格式。加载时rdbLoadRio做相反的事,还需要处理EOF、CRC64校验、过滤过期key,以及与AOF混合格式的分流。

我读rdb.c时特别关注的是fork与写时复制。Redis生成RDB时会fork一个子进程,子进程把数据写入磁盘,主进程继续服务请求。在写快照过程中,主进程修改的key会触发copy-on-write复制,父子进程各自拥有一份内存页。这个机制决定了RDB生成期间内存会升高,也决定了为什么极端情况下边写快照边改数据可能导致内存翻倍。代码里对应着rdbSaveRio前面那段fork准备逻辑,很有代表性。

7.2 aof.c:追加日志的写回与重写

AOF的机制是每次写命令都追加进aof_buf,再根据appendfsync配置决定什么时候真正fsync到磁盘。aof.c里我最常读三个部分:命令传播是feedAppendOnlyFile,它把写命令格式化为RESP文本追加到AOF缓冲;AOF重写是rewriteAppendOnlyFileBackground,它基于当前数据集生成一组最小增删命令,而不是把所有历史命令重放一遍;恢复流程是loadAppendOnlyFile,逐条读取命令执行。

AOF重写最精巧的地方在于后台化。Redis会fork子进程执行重写,主进程继续服务,同时把新的写命令累积到aof_rewrite_buf。子进程写完临时AOF后,主进程再把增量命令补写进去,最后原子替换正式文件。这样重写过程中新增的数据也不会丢,还能做到不阻塞主线程。

7.3 混合持久化:RDB与AOF的取长补短

新版Redis支持aof-use-rdb-preamble,也就是AOF文件开头先放一个RDB格式的快照,后面追加增量命令。重启时先快速加载RDB快照,再重放少量增量命令,既享受到RDB的启动速度,又保留了AOF的数据完整性。我在源码分类里把持久化单独划出来,是想提醒大家Redis的高性能不只是内存操作快,它对磁盘IO的异步化和对持久化文件格式的紧凑设计同样功不可没。理解这一层,再去看那些线上故障案例和经验帖,会清晰很多。

8. server.c与多线程骨架:从启动到日常调度

8.1 main函数与initServer的初始化顺序

Redis进程从main开始,依次经过initServerConfig、initServer、initServerLast,最后进入aeMain。initServerConfig负责把默认配置写入server结构体,initServer则创建事件循环、监听端口、初始化数据库、为持久化做准备。我在读main函数时最推荐的方法是按调用栈顺序列出启动步骤,你会发现从命令行参数解析、随机种子生成、信号处理注册、日志初始化,到真正accept请求,中间隔着一大段环环相扣的准备工作。

这个顺序对排查启动慢、启动失败类问题特别有用。比如端口被占用、AOF文件损坏、配置项不合法,都会在不同阶段暴露出来。如果你想理解为什么某个参数必须在某个阶段生效,就得回看initServer的顺序。

8.2 bio线程与惰性删除

Redis表面上是单线程,实际上启动后会创建若干个后台线程。bio.c里面维护着三类后台任务队列:关闭文件描述符、AOF刷盘、惰性释放大对象。每一种任务由独立线程从队列里取任务执行,这样主线程可以把耗时的阻塞操作甩出去。典型场景就是unlink命令删除一个非常大的key,如果直接在主线程释放底层内存,删除期间所有请求都会卡住,但交给lazy_free线程后,主线程只做了标记和入队,秒回。

对应地,lazyfree.c实现了异步删除逻辑,dbAsyncDelete会把待释放的对象包成任务丢进bio队列。读这一部分时你会看到Redis对"单线程模型"的务实修正:核心命令执行路径保持单线程,但可异步化的重活通过后台线程消化,既保证一致性又避免卡顿。

8.3 多线程IO:让网络解析不再成为瓶颈

新版Redis引入的多线程IO,并不是让多条命令并发执行,而是把"读取socket到解析协议"这部分网络IO操作从主线程拆到多个IO线程。流程大体是:事件循环把有读事件的客户端放入pending队列,多个IO线程并发执行readQueryFromClient并解析命令,命令解析完成后主线程统一执行命令和写回。在threaded.c和networking.c里,你能看到线程组的初始化、任务队列、原子计数的配合方式。

这种设计很有启发:它保留了单线程执行命令时天然的一致性优势,又把CPU密集的网络解析分摊到多核上。我在压测里实测过,多线程IO在高并发下能明显拉高吞吐上限,但同时也带来了锁竞争和内存序方面的新问题,代码里那些atomic、memory barrier就是为此服务的。

9. 我给初学者的源码阅读排坑顺序

9.1 推荐的三阶段路线

我不建议一开始就去啃cluster.c或者模块系统那些复杂的东西,更实用的路径是分阶段推进:

第一阶段只读数据结构层,sds、dict、ziplist、quicklist、intset都过一遍,配合redis-cli的OBJECT ENCODING命令观察不同key的编码变化,比如往list里塞到超过阈值时编码从listpack转成quicklist,能直接看到触底转换的现场。

第二阶段读事件循环和网络层,目标是把accept到processCommand的所有关键函数列出来,画一张自己的请求流转图。等这一步做完,你已经能回答很多高难八股题,比如"单线程为什么能扛高并发""epoll_wait超时怎么算出来的"。

第三阶段带着伪命题去读源码,比如"SMEMBERS到底怎么遍历""一个大key的删除会不会卡住主线程""AOF重写期间来了新写命令怎么办"。这些问题会逼着你在server.c、t_zset.c、db.c、aof.c之间来回跳跃,跳跃越多,你对模块边界的感受就越深。

9.2 推荐的工具与调试技巧

工具方面,我最常用三个组合:gdb打断点看运行栈,在关键函数入口加日志,用gcov和lcov做覆盖率分析。还有一招很推荐:把源码改一改重新编译,故意把某个结构体的内容打印出来,然后起一个内存很小的实例,用redis-benchmark压一下,观察行为变化。比如临时改dict哈希表扩容阈值,再往里面塞大量数据,看看rehash进度和耗时是怎么变化的,这比单纯读代码建立的因果联系深刻得多。

9.3 过来人的几点体会

最后讲几条个人经验。第一,别想在第一次读的时候就把所有文件都看完,更现实的目标是把每条主线的入口函数记牢,遇到问题能快速定位到文件。第二,server.h里的注释其实是非常棒的文档,很多配置项的解释比网上博客更贴近实现原意。第三,善用git blame查看某个函数的改动历史,往往比读任何教程都能更快理解"为什么这段代码长这样"。源码分类不是为了把文件分完就完事,而是为了在你最需要答案的时候,能精确地找到那个函数并读懂它。Redis的代码整体上清晰直白,只要你找对入口,剩下的就只是顺着函数指针一条条往下跳的事情。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进