资讯动态

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

发布时间:2026/10/13 17:33:51 来源:尧图企业网站定制
先讲个场景。某次服务半夜出现大面积缓存穿透我排查了配置、看了监控、试了几个网上推荐的参数组合都不见起色最后只能硬着头皮打开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负责分配内存和初始化headersdsMakeRoomFor负责扩容目标是让每次追加操作尽量少触发reallocsdscat和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替代ziplistlistpack取消了prevlen对前一项长度的反向依赖改成用固定长度的尾部字段管理遍历结构更稳定。quicklist则是多个ziplist通过双向链表串起来它在list类型的演进中承担过重要角色。3.4 intset和skiplist小集合与有序集合的底层细节intset是一个有序的整数数组当集合的元素全是整数且数量不多时用intset存储非常省空间。它内部有一个升级机制当新增的整数超过当前编码范围时整个数组会扩展到更大的整数类型并重新分配。这个升级不可逆所以你会看到大整数进入之后编码再也不会降回去这是保证有序、紧凑和简单之间的折中。skiplist看起来像多层链表每层都是稀疏的索引。Redis在zset元素多时同时使用dict和skiplistdict负责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命令的网络路径画出来大概是这样客户端TCP连接到达epoll_wait返回可读事件acceptTcpHandler调用accept创建client结构挂进server.clients链表该连接下一次可读时readQueryFromClient把数据搬进c-querybufprocessInputBuffer从querybuf里按协议拆出命令和参数processCommand执行合法性检查call执行真正的命令函数命令结果写入回复缓冲区注册写事件写事件触发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.cmaxmemory淘汰策略的具体实现当内存达到maxmemoryRedis会进入逐出逻辑由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的代码整体上清晰直白只要你找对入口剩下的就只是顺着函数指针一条条往下跳的事情。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑