《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
本章目标 本章是整个系列的"地图"与"坐标系"。它不急着钻进某一个子系统,而是先把 `` 这棵源码树讲成一张可以随手翻阅的地图:版本从哪来、
make之后到底发生了什么、一个 Redis 进程的内存里到底有哪些全局状态、从一个.c文件的main()到可以接受连接的服务器之间究竟走过了哪些必经之路。读完本章你应该能够回答:
- Redis 8.10.3 到底是一个"什么"------它在 Redis 自身产品线里处于什么位置,许可证意味着什么;
- 面对 130+ 个源文件、20 万行代码,应该按什么顺序读、哪些文件是"主心骨"、哪些是"枝叶";
redisServer这 668 行结构体如何分区,为什么这么分,哪些字段是 8.10 时代新引入的;- 一次进程启动的完整调用序列,以及其中每一步"为什么必须在这个位置";
- 如何在本机上验证本系列后续每一章的论断(编译开关、单元测试、TCL 集成测试、调试器断点)。
0. 写在最前面:我们要写的是"能验证的技术",而不是"看起来很深的技术"
在正式开始前,先交代本系列的三条写作纪律,因为它们决定了这本书的读法:
第一条纪律:一切论断可回溯到行号。 本系列引用的每一段 C 代码,都会标注它在 src/ 下的文件名与行号(形如 server.c:8111)。你可以打开这个文件跳到那一行核对。如果某个说法属于业界共识而不是本仓库独有(例如"渐进式 rehash 的思想"),我会明确说明它是通用知识;如果某个行为是本版本的特定实现,我会给出代码位置。
第二条纪律:拒绝注水,但也拒绝"压缩过度"。 深度的含义不是把所有细节堆上去,而是把因果关系讲清楚:为什么要有这个字段、为什么这段代码必须这么写、不这么写会发生什么。因此本书会大量使用"如果删掉这行会怎样""早期版本这里不是这样"的反事实推理。这种分析本身是有信息量的,它让你获得修改代码的能力,而不只是记忆代码的能力。
第三条纪律:承认"不知道"与"未验证了'"。 Redis 8.10 引入了一批本书作者也无法凭先验知识覆盖的子系统(estore.c、keymeta.c、mstr.c、sparsearray.c、t_array.c、cluster_asm.c、client_comp.c 等)。遇到这些,本系列一律采用"先读源码,再陈述"的方式:把头文件开篇的设计注释、数据结构的内存布局图、API 列表抄录下来,再逐层分析。凡是没有读透的地方,会直接标注"未验证"而不是含糊带过。
一个必要的提醒:Redis 8.10 是一个较新的版本(本仓库 src/version.h 定义为 8.10.3),其中包含了一些你可能从未在公开资料里见过的内部结构。如果你此前读过 Redis 6.x/7.x 的源码,会发现很多熟悉的东西变样了:dict 被 kvstore 包了一层;过期字典不再是简单的 dict *expires,而多出了一个 estore *subexpires;keyspace 的键不再是裸 sds,而是被称为 kvobj 的对象;config 系统从几百个全局变量变成了 createIntConfig/createBoolConfig 的声明式注册。这些变化背后的驱动力,我们在第 1.4 节开始会逐步展开。
1. 版本定位:Redis 8.10.3 究竟是哪个版本
1.1 从 version.h 开始的最小事实集
一切从最没有争议的事实开始。src/version.h 的完整内容是:
c
#define REDIS_VERSION "8.10.3"
#define REDIS_VERSION_NUM 0x00080a03
这个文件短到没有任何解释的余地,但它提供了三个值得深挖的信息点。
(1)版本号的"三段十进制 + 一段十六进制"双编码。 REDIS_VERSION 是给人看的字符串,REDIS_VERSION_NUM 是给程序看的机器值。后者的编码规则是:
ini
0x 00 08 0a 03
└┬┘ └┬┘ └┬┘ └┬┘
│ │ │ └── patch = 3
│ │ └─────── minor = 0x0a = 10
│ └──────────── major = 0x0008 = 8
└──────────────── 保留高位(历史遗留)
也就是说 REDIS_VERSION_NUM = (major << 16) | (minor << 8) | patch。这个编码在 cluster.c 的消息兼容性判断、RM_GetServerVersion()(供模块使用)、以及某些模块的"最低 Redis 版本"检查中会被用到。之所以用十六进制书写 minor 而非十进制,是因为这里的每一字节都必须能被独立解读------0x0a 明确表示"第十个次版本",避免了 8103 这种十进制写法在解析时的歧义(那样无法区分 8.10.3 和 81.0.3)。
(2)8.10 这个次版本号意味着这是一个"偶数+n"的多特性版本。 回顾 Redis 的版本史:2.8 → 3.0 → 3.2 → 4.0 → 5.0 → 6.0 → 6.2 → 7.0 → 7.2 → 7.4 → 8.0 → 8.2 → 8.4 → 8.6 → 8.8 → 8.10。可以看到自 7.x 起 Redis 采用了"每年一个 y.0 + 快速次版本迭代"的节奏,8.10 是延续这一节奏的产物,而不是一个 LTS 分支。这意味着你不能假设它是一个"保守稳定"的版本 ------恰恰相反,从 00-RELEASENOTES 看,8.10 引入了大量结构性改动。
(3).3 是补丁级别,且这是一个"紧急程度 SECURITY"的补丁版本。 这一点必须严肃对待。我们来看 00-RELEASENOTES 的第一屏:
sql
Redis 8.10.3 Released Mon 26 Oct 2026 10:00:00 IST
Update urgency: `SECURITY`: There are security fixes in the release.
Update urgency: SECURITY 是 Redis 发布说明体系里的最高档之一。文件开头的图例给出了完整分级:
| 紧急程度 | 含义 | 运维含义 |
|---|---|---|
LOW |
除非你需要某个新特性,否则不必升级 | 可跳过 |
MODERATE |
安排一次升级,但不紧急 | 纳入月度窗口 |
HIGH |
有一个可能影响部分用户的关键缺陷,升级! | 尽快安排 |
CRITICAL |
影响大多数用户的关键缺陷,尽快升级 | 立即 |
SECURITY |
本版本包含安全修复 | 立即,且需评估是否已被利用 |
注意 HIGH 与 CRITICAL 的区别被明确定义为"影响面"的区别,而 SECURITY 则是另一个维度:它意味着这些 Bug 可能被攻击者主动触发,而不是随机的稳定性问题。对于运行 8.10.0/8.10.1/8.10.2 的生产集群,升级到 8.10.3 不是可选動作,而是必须动作。 我们在 1.2 节会逐条分析这些安全漏洞的技术成因------这是理解 Redis 内部机制最锋利的切入点之一,因为每一个安全修复都对应着一段"当时写代码的人没有想到的执行路径"。
1.2 8.10 的安全修复:五个漏洞,五堂短小对立的内核课
00-RELEASENOTES 记录了 8.10.3、8.10.2、8.10.1 三个补丁版本的安全修复。把它们摊开读,你会发现它们几乎覆盖了 Redis 内核的每一个主要子系统。这不是巧合,而是"攻击面天然集中在边界之处"的自然结果------哪里有跨边界的数据交换(网络、RDB、复制链路、集群总线),哪里就有解析器 bug 的可能。
1.2.1 8.10.3:集群总线 4GB PUBLISH 堆溢出
Heap buffer overflow when propagating a
PUBLISH/SPUBLISHmessage larger than 4GB over the cluster bus in cluster mode
先建立背景。Redis Cluster 有两种总线:
- 客户端总线(默认 6379):处理普通客户端命令;
- 集群总线(client port + 10000,默认 16379):节点之间的八卦协议通信。
PUBLISH 在集群模式下需要走集群总线的原因是语义决定的:在不分片 Pub/Sub 中,一个频道的所有订阅者可能分布在任意节点,因此消息必须**洪泛(flood)**到所有节点。SPUBLISH(Sharded Pub/Sub)则不同,它通过槽位计算精确路由到唯一的目标节点。
关键在"larger than 4GB"这个量词。4GB 是 2^32,而 (unsigned int) 的上限正好是 2^32 - 1。这几乎是在明示:某处把消息长度从 64 位截断成了 32 位,然后用这个被截断的长度去写缓冲区。这类 bug 的标准形态是:
c
// 危险模式示意(非仓库原文):
uint32_t len = (uint32_t)some_64bit_length; // 高位被丢弃
memcpy(dest, src, len); // 实际拷贝远小于声明的 len 所暗示的范围,
// 或者反过来:dest 按 len 分配 → 溢出
具体到 Redis:一条消息的长度被写进集群总线报文的某个字段,而接收端(或发送端)用 32 位容器去接收一个本应 64 位的长度。当长度为 5GB 时,截断后的值变成约 1GB,接收方按这个值分配缓冲区,但实际数据有 5GB,于是写越界。
为什么这条修复值得运营者注意? 因为触发条件是集群模式下任意一个有 PUBLISH 权限的客户端发一条 >4GB 的消息 。虽然单个 Redis 值本身有
proto-max-bulk-len(默认 512MB)的限制,但 Pub/Sub 消息走的是另一条路径(它不是一个 key 的 value),且需要攻击者先具备该频道的发布权限。这提示了一条运维加固路径:尽量用SPUBLISH+ sharded pub/sub(减少洪泛面),并对业务账号做 ACL 频道前缀限制。
1.2.2 8.10.2:事务中的 ACL 权限绕过(#15673)
Commands queued in a transaction could still access keys whose ACL permissions were revoked before the transaction was executed
这是一个权限检查时机(TOCTOU, Time-of-Check-to-Time-of-Use) 的经典案例,也是理解 Redis 事务执行模型的好材料。
Redis 的事务(MULTI/EXEC)模型是"排队---批量执行":MULTI 之后的命令被放进 multiState.commands 数组(server.h 中定义),真正的执行发生在 EXEC 时。问题在于:命令入队时做的权限检查,是否足以保护执行时的访问?
答案是不能,因为中间存在时间窗口:
vbnet
t0: 用户 U 有 key:K 的读权限
t1: U 执行 MULTI
t2: U 入队 GET K ← 此处检查:通过(因为此时 U 还有权限)
t3: 管理员撤销 U 对 K 的读权限
t4: U 执行 EXEC ← 如果这里不再检查,GET K 就会成功读到数据
修复的必然位置是在 EXEC 时对每条已入队命令重新 做 ACL 检查。这带来一个有意思的设计权衡:如果每次 EXEC 都重查所有命令的权限,那么事务执行期间权限字典可能被并发修改(虽然 Redis 主线程单线程执行 EXEC 逻辑,但 ACL 规则可以在 beforeSleep 或 cron 里被改动吗?实际上不会在同一执行点内),因此 Redis 可以选择"轻量重查"而不是"完全重算"。这类修复的一般教训是:任何"延后执行"的机制(事务、脚本、延迟队列、模块 post-notification job)都必须重新校验执行时的权限,而不能只依赖入队时快照。 本仓库里 #15337 New SCRIPT_RUNNER command flag 正是类似思路的产物------用显式的 flag 告知引擎"此命令会执行脚本"。
1.2.3 8.10.2:集群总线默认无认证(#15722)
The cluster bus protocol has no authentication of its own unless
tls-clusteris enabled, so any host able to reach a node's bus port could join the cluster and threaten it.
这条不是一个"缓冲区越界"型 bug,而是一个默认安全姿势(secure-by-default)的缺失。它的价值在于让我们重新审视 Redis Cluster 的信任模型:
- 打开集群总线的 TCP 端口(默认 16379)意味着任何人只要能连上,就能发送
CLUSTER MEET-级别的八卦报文; - 历史上 Redis 假定集群总线处于一个隔离网络内(VPC / 内网),因此没有在总线协议里内建认证;
- 只有当开启
tls-cluster时,TLS 客户端证书才提供了一层认证------认证是 TLS 的副产品,而不是总线协议自身的能力。
修复方案非常"Redis 风格":不破坏兼容性,而是增加一个显式开关并默认关闭(保持向后兼容),同时给出警告:
- 启动时若检测到总线端口处于无认证状态,打印
LL_WARNING级别警告; - 新增配置
cluster-bus-port-protected-mode(默认no); - 设为
yes后,节点只在tls-cluster提供认证的情况下才启动。
这种"默认关闭 + 显式 opt-in + 启动告警"的三段式策略,是基础设施软件处理"既要修复安全问题、又不能直接打断老用户的部署"的标准手法。对比另一种做法(强制开启),前者把风险的决定权交给了运维,代价是需要运维主动阅读警告日志。这是一条可以直接写进运维规范的经验:任何运行 Redis Cluster 的实例,上线前必须检查启动日志中是否存在总线端口未认证警告。
1.2.4 8.10.1:一组内存破坏类漏洞
8.10.1 的修复清单最长,也最能说明"RDB/RESTORE 载荷是最大的攻击面"这一判断:
| 漏洞 | 技术性质 | 暴露的信息 |
|---|---|---|
CMSketch RDB 加载时缓冲区大小算错 → 堆 OOB 写 (CVE-2026-62356) |
整数计算错误导致的分配不足 | 结构化的 length/auth 中间层 [ ] |
| TopK 堆清理路径越界访问 (MOD-15410) | 下标未校验 | 循环边界 |
RDB 中越界的 SLOT_INFO slot id → 内存破坏 → 可能 RCE |
输入验证缺失 + 用外部整数做数组下标 | 这是最严重的一条 |
| TLS pending-data 链表 UAF(当一个命令关闭了另一个 pending 连接) | 生命周期管理错误 | 对象释放后仍被引用 |
| 阻塞客户端链表 UAF(重处理命令时驱逐了另一个阻塞在同键上的客户端)(#15594) | 迭代器失效 | 遍历中删除元素 |
| Vector Set 从 RDB 加载时缺少节点层级校验 | 外部数据的结构校验缺失 | 图结构的 level 字段 |
VREM 在后台 VSIM 线程运行时修改 HNSW 图 → UAF |
并发访问控制缺失 | 跨线程生命周期 |
hnsw_search() 返回负值被当作巨大无符号计数 |
有符号/无符号转换错误 | 返回值语义 |
请注意最后两条属于 Vector Sets(Redis 8 引入的向量集合,基于 HNSW 图)。它们揭示了一个新子系统从诞生到成熟必经的阶段:当一个原本单线程的数据结构开始支持后台线程并发访问时,之前所有隐含的"单线程下安全"假设都会失效。
VREM 修改 HNSW 图的同时后台 VSIM 线程可能正在遍历这张图,这是一个典型的读写竞态。可行的修复方向包括(本系列在第 N 章讨论 Vector Sets 时会验证实际做法):引用计数推迟释放、读写锁、把删除操作排进同一 worker 线程队列、或者用 epoch-based reclamation。
1.2.5 8.10.1:TLS 客户端证书 CN 内嵌 NUL 绕过
TLS client certificate authentication bypass: a Common Name containing an embedded NUL byte was truncated, allowing a client to authenticate as another (possibly privileged) ACL user
这条值得单独讲,因为它是一个语言层面的陷阱,在 C 项目里反复出现。
背景:当配置 tls-auth-clients yes 并使用证书 CN 映射到 ACL 用户名时,Redis 从 X.509 证书的 subject CN 字段取出字符串,用它与 ACL 用户名比对。如果一个攻击者能申请到 CN 为 admin\0evil.example.com 的证书,那么:
- C 字符串处理会把它当作
"admin"(因为\0是终止符); - 某些 CA 签发流程却接受了这个 CN,并把它作为完整字节序列写进证书。
于是"看到的"与"验证的"不一致:TLS 层看到的 CN 是完整串(合法签发),应用层比对时截断成了 "admin"(获得管理员权限)。
防御方法在 C 里很朴素:任何来自外部的长度已知的字节序列,在当作 C 字符串使用前必须检查 ① 内部是否含 \0 ,② 长度是否与 strlen 一致 。ASN.1 字符串本身允许内嵌 NUL,所以这个检查必须在"从 OpenSSL 结构体取出 → 转成 char *"的边界处做。
本节小结(可直接转为代码-review checklist):
- 任何外部输入长度,用 64 位接收,禁止在中途截断为 32 位;
- 任何外部整数,作为数组下标前必须先做范围检查(
slot id、level、count);- 任何"延迟执行"路径,执行前必须重新做一次权限校验;
- 任何引入了后台线程的数据结构,必须重新审视每个字段的生命周期;
- 任何来自 X.509 / DNS / 网络的"字符串",当作 C 字符串使用前必须验证无内嵌 NUL 且 strlen == 声明长度;
- 有符号函数的返回值,在作为无符号计数使用前必须显式判负。
1.3 Redis 8.10 的功能主线:从 release notes 反推架构变迁
如果说安全修复是"防御性证据",那么 8.10 GA 的新特性清单 就是"进攻性证据"------它直接告诉我们在这一版里,Redis 团队把工程精力投向了哪里。把 00-RELEASENOTES 中 8.10-RC1 的 Headlines 段落完整摘录(这是我们判断架构重心的一手材料):
bash
### New Features (compared to 8.8)
- #15364 Compact hashes - a new hash encoding that reduces memory usage by storing
hash field names just once for keys that share a schema
- #15366 Replication stream compression to reduce bandwidth consumption between
primaries and replicas
- #15364 New command: `HIMPORT` - high-throughput compact hash bulk insertion
- #15405 New commands: `LMOVEM`, `BLMOVEM` - move multiple elements between lists
- #14893 New command: `SUNIONCARD` - get the cardinality of the union of multiple sets
- #15278 New command: `SDIFFCARD` - get the cardinality of the difference between sets
- #15441 New command: `BACKUP` - node-side implementation for backup and restore
based on multi-part AOF (MP-AOF)
- #15282 `XREAD`, `XREADGROUP` - new `MAXCOUNT` and `MAXSIZE` arguments to cap the
cumulative reply entries and size
- #15337 New `SCRIPT_RUNNER` command flag
- #15347 `SLOWLOG GET` - new reply argument: total argument count
再加上 GA 段落中的:
diff
- TLS peer certificate-based server-to-server authentication
- FT.ALIASLIST - get all aliases for the index
- Stemmer support for Malay and Tagalog languages
- JSONPath extensions
- TS.NRANGE, TS.NREVRANGE, TS.READ, TS.QUERYLABELS, TS.MRANGE/MREVRANGE EXCLUDEEMPTY
从这份清单里,可以归纳出四条清晰的工程主线:
主线一:内存效率(Compact Hashes, HIMPORT, MEMORY USAGE 修正)。 Compact hash 是这一版的头号特性。它的核心思想在 release notes 里一句话说清了:多个共享同一份 schema(字段名集合)的 hash key,把字段名只存一份。这听起来简单,但它意味着 Redis 的存储引擎从"每个 key 完全自包含"向"跨 key 共享元数据"迈出了一步------这是一次范式迁移。要支撑它,Redis 必须:
- 有一个地方登记"schema"(字段名集合)并去重 → 需要新的元数据注册与管理设施;
- 有办法在 key 上挂着"我用了哪个 schema" → 这就是
keymeta.c登场的理由(每个 kvobj 有 8 个 metabit,可挂 8 类元数据,见keymeta.h); - 有办法把多个逻辑上独立的数据塞进一次分配里 → 这就是
mstr.c(带元数据的不可变字符串)与entry.c(把 field+value+optional TTL 打包成一个块)登场的理由; - hash 内部字段级的 TTL(HFE)需要新的过期索引 → 这就是
estore.c+ebuckets.c+fwtree.c三件套登场的理由。
这一主线的洞察是:你不能在"2007 年的 dict + dictEntry + robj"之上实现 compact hash,所以 Redis 8.10 必然会重构对象层。 这也解释了为什么本仓库的 redisDb 已经变成了这样(server.h:1226):
c
typedef struct redisDb {
kvstore *keys; /* The keyspace for this DB. As metadata, holds keysizes histogram */
kvstore *expires; /* Timeout of keys with a timeout set */
estore *subexpires; /* Timeout of sub-keys with a timeout set. (Currently only used for hashes) */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
...
} redisDb;
注意 estore *subexpires 这一行------它是全新出现的 。8.10 之前的 Redis,expires 字典管理 key 级 TTL 就够了;现在有了 hash 字段级 TTL(HFE),需要一个独立的、支持"一个容器里多个条目有不同过期时间"的索引结构,于是有了 estore(Expiration Store)。我们将在第 2 卷专门用两章讲它。
主线二:网络带宽与延迟成本(Replication stream compression, MAXCOUNT/MAXSIZE)。 #15366 的复制流压缩直接对标"云上 Redis 跨可用区复制流量"这一真实账单项。它的实现落点就是 client_comp.c(879 行)------一个可插拔的 per-client 流压缩层:
c
/* client_comp.h */
struct client;
typedef enum {
CD_INVALID,
COMPRESS,
DECOMPRESS,
} compressionDirection;
int clientCreateCompressionState(struct client *c, compressionDirection dir);
int clientEnableCompression(struct client *c, compressionDirection dir);
void clientDisableCompression(struct client *c);
int clientCompressAndWrite(struct client *c, int *tot_written, int force_flush);
int clientFlushCompressedData(struct client *c);
int clientReadBufAndDecompress(struct client *c, char *input_buf, size_t input_len,
char *output_buf, size_t output_len, size_t *consumed);
头文件的注释已经把设计难点讲透了,值得逐句读:
c
/* Per-event-loop pending decompressed data handling. A decompressor may hold
* data in its internal buffers that hasn't been delivered to the query buffer
* yet, with no socket event to wake us up. These drive that drain from the
* event loop's beforeSleep, alongside connTypeProcessPendingData. */
int clientCompressionHasPendingData(struct aeEventLoop *el);
int clientCompressionProcessPendingData(struct aeEventLoop *el);
这段话揭示了一个非平凡的异步陷阱:压缩/解压中间件会在自己内部缓冲数据。设想这样一个场景:
- 从 socket 读到了 10KB 压缩数据;
- 解压器(比如 LZF/zstd 的流式 API)需要更多输入才能吐出一个字节的输出,或者它吐出了输出但还有残留;
- 此时 socket 缓冲区空了,epoll 不会再说"这个 fd 可读";
- 但数据其实还在解压器的内部 buffer 里------如果没人再去调用它,这条数据就永久卡住了,表现为"客户端命令莫名超时"。
解决方案是把"还有 pending 解压数据的客户端"挂到一个 per-event-loop 链表上,在每次 beforeSleep 时主动排空(clientCompressionProcessPendingData)。这个模式与 Redis 里 TLS 的 pending data 完全一致------这也解释了为什么 8.10.1 会有那条"TLS pending-data 链表 UAF"的修复:两条异步 pending 链表,两个几乎同名的处理函数,很容易在一处修好、在另一处漏掉。
顺带注意 clientComp.c 里还有一个很"工程化"的函数:
c
/* Bytes used by the client's compression state (struct + input/output
* buffers), or 0 if compression is not active for the client. */
size_t clientCompressionMemoryUsage(struct client *c);
它对应的是 8.10 GA 的这条 bug 修复:#15499 Memory usage reported for compressed replication clients could be lower than the actual memory consumed 。也就是说,加了压缩之后,如果不把这些中间缓冲区的内存记账进去,maxmemory 与 INFO memory 就会失真,进而可能导致 OOM。这是"引入优化 → 破坏另一处的正确性假设"的经典连锁反应。
主线三:可观测性(hotkeys、SLOWLOG 扩展、key memory histograms、slot stats)。 本仓库新增了 hotkeys.c、cluster_slot_stats.c、chk.c(一个 TopK 概率数据结构),kvstoreDictMetadata 里出现了 cpu_usec / network_bytes_in / network_bytes_out。这意味着 Redis 正在把自己从"一个快的键值存储"升级为"一个可观测的数据平面"。我在 server.h 里看到的这个结构可以直接作为证据:
c
/* Like kvstoreMetadata, this one per dict */
typedef struct {
kvstoreDictMetaBase base; /* must be first in struct ! */
size_t alloc_size; /* Total memory used (in bytes) by this slot */
uint64_t cpu_usec; /* CPU time (in microseconds) spent on given slot */
uint64_t network_bytes_in; /* Network ingress (in bytes) received for given slot */
uint64_t network_bytes_out; /* Network egress (in bytes) sent for given slot */
} kvstoreDictMetadata;
在 **每个 slot ** 上统计 CPU 时间与进出字节数,这是给"云厂商按 slot 计费"或者"找出热点 slot 做迁移"提供的基础设施。而 hotkeys.c 则是更激进的一步:直接用 count-Min-Heavy-Keepers 之类的 sketch 找出最热的 key。
主线四:可靠性原语(BACKUP、cluster_asm 原子槽迁移)。 BACKUP 命令基于 MP-AOF 实现"节点侧备份",redisServer 里为此新增了一整组字段(我们在 §5 会逐个读)。而 cluster_asm.c(3889 行)则是把槽迁移从"多个非原子的 MIGRATE 步骤"变成"原子任务"的实现------它是 cluster_legacy.c(6747 行)之外的第二套集群逻辑。
为什么这里会有两套 Cluster 实现? 这是一个非常有代表性的工程决策。
cluster_legacy.c是历史包袱:它的槽所有权数据结构(2048 字节的迁移位图migrating_slots_to/importing_slots_from)与状态机(完全由 Gossip 驱动)已经很难再叠加"原子迁移"这种强需求。既然不能破坏兼容性,就只能并行地写一套新的,用一个开关(通常是 config 或 cluster 层的配置项)来切换。代价是代码量翻倍,收益是可以在旧逻辑不受影响的前提下验证新逻辑。我们将在第 N 章专门分析这两套实现如何共存。
1.4 许可证:三选一的现实含义
本仓库几乎每个新文件的头部都写着同一段话(以 estore.h 为例):
c
/*
* Copyright (c) 2011-Present, Redis Ltd.
* All rights reserved.
*
* Licensed under your choice of (a) the Redis Source Available License 2.0
* (RSALv2); or (b) the Server Side Public License v1 (SSPLv1); or (c) the
* GNU Affero General Public License v3 (AGPLv3).
* ...
*/
要理解这段话,需要区分文件的许可证 与仓库的整体许可证:
- 仓库根有
LICENSE.txt,那是"默认适用"的许可证; - 具体到某个新文件,作者可以给出不同的 terms。上面这段是"三选一":你可以在 RSALv2、SSPLv1、AGPLv3 之中任选其一遵守。
这三种许可证的共同点是:都限制了"把 Redis 作为托管服务对外提供"的行为,而都允许内部使用、修改、二次开发。不同点在于限制的范围与语言表述方式:
| 许可证 | 核心限制 | 对普通用户的影响 |
|---|---|---|
| RSALv2(Redis Source Available License 2.0) | 不能把本软件作为"数据库/缓存/消息服务"对外出售/托管 | 内部业务使用不受影响;做 Redis 云服务需要商业授权 |
| SSPLv1(Server Side Public License) | 若对外提供服务,须开放完整服务栈源码 | 比 RSAL 更宽:连周边服务也要开源 |
| AGPLv3 | GPL 系列的网络条款版:修改后对外提供服务须开放改动 | 最"传统"的开源许可证,但传染性强 |
还有一类文件头值得注意,例如 hotkeys.c、client_comp.h:
c
/*
* Copyright (c) 2009-Present, Redis Ltd.
* All rights reserved.
*
* Copyright (c) 2024-present, Valkey contributors.
* All rights reserved.
*
* Portions of this file are available under BSD3 terms; see REDISCONTRIBUTIONS for more information.
*/
这是 Redis 与 Valkey 分叉之后留下的痕迹:Redis 承认了 Valkey 作者的贡献,并保留他们的版权声明 。仓库根的 REDISCONTRIBUTIONS.txt 记录了哪些部分以 BSD-3 条款提供(例如 Valkey cluster.c 的某些部分)。这个细节对使用者意味着:某些"剔除 剔除 Valkey 贡献部分后"的组合可能受到更宽松的 BSD-3 约束------但对于绝大多数工程师,最实用的结论只有一条:不要把本仓库的代码片段copy到自己公司的闭源产品里而不做合规审查。
2. 源码树解剖:130 个文件该怎么读
2.1 顶层结构
css
redis-8.10.3/
├── 00-RELEASENOTES ← 版本变更史,必读
├── BUGS ← 已知问题的报告模板
├── CONTRIBUTING.md
├── LICENSE.txt
├── Makefile ← 顶层构建入口(≠ src/Makefile)
├── MANIFESTO
├── README.md
├── SECURITY.md
├── TLS.md ← TLS 配置专章文档
├── redis.conf ← 与 configs 同步生成的默认配置
├── sentinel.conf
├── runtest / runtest-* ← 测试入口脚本
├── deps/ ← 第三方依赖(jemalloc、lua、hiredis 等)
├── docker/ ← Docker 构建文件
├── docs/ ← 文档(当前为空,本系列将在此生成)
├── modules/ ← 内联模块:vector-sets + 四个可拉取的模块
├── scripts/ ← 构建/发布脚本
├── src/ ← **核心:本系列的主战场**
├── tests/ ← TCL 集成测试 + 少量 C/forwards 测试
├── tools/ ← 辅助工具
└── utils/ ← 代码生成、压测、发布工具
2.2 src/ 的角色分类表(按"代码的性质"而非"字母序")
面对 130 个源文件,最无效的读法是按文件名从 acl.c 读到 zmalloc.c。有效的读法是按层次,因为 Redis 的代码有非常清晰的分层:基础设施层 → 数据结构层 → 存储引擎层 → 命令实现层 → 网络/并发层 → 集群/复制层 → 扩展层。
下表按层次归并了主要文件(行数为本仓库实测,wc -l):
第一层:基础设施(Infrastructure)
| 文件 | 行数 | 职责 | 何时读 |
|---|---|---|---|
zmalloc.c/h |
1308 | 内存分配封装、RSS/碎片统计、page size 抽象 | 第 2 卷 |
sds.c/h |
1599 | Simple Dynamic String,Redis 的字符串基石 | 第 2 卷 |
mstr.c/h |
--- | 8.10 新增:带元数据的不可变字符串 | 第 2 卷(compact hash 章) |
util.c/h |
1871 | 字符串转数值、格式化、编码工具 | 随用随读 |
anet.c/h |
812 | 网络 IO 的 POSIX 封装(listen/accept/keepalive) | 第 3 卷 |
ae.c/h + ae_*.c |
--- | 事件循环抽象与 4 种后端 | 第 2 章 |
monotonic.c/h |
--- | 单调时钟抽象(本版本新增,取代裸 gettimeofday) |
第 2 章/第 2 卷 |
debugmacro.h |
--- | serverAssert* 系列宏 |
随时 |
config.c/h |
4094 | 声明式配置系统 | 第 2 卷 |
atomicvar.h |
--- | 原子变量封装(跨编译器) | 随时 |
第二层:数据结构(Data Structures)
| 文件 | 行数 | 职责 |
|---|---|---|
dict.c/h |
2397 | 哈希表 + 渐进式 rehash + scan |
kvstore.c/h |
1174 | 包在 dict 之上的"按 slot 分片"层 |
rax.c/h |
2993 | Radix tree(基数树),Cluster/Stream 的基石 |
quicklist.c/h |
3658 | List 的物理实现(linked list of listpacks) |
listpack.c/h |
3588 | ziplist 的替代品,小集合的紧凑编码 |
ziplist.c |
2665 | 历史遗留的紧凑列表(仍在=用于兼容旧 RDB) |
intset.c/h |
--- | 整数集合编码 |
t_zset.c |
5033 | 跳表(skiplist)实现 + zset 命令 |
hyperloglog.c/h |
2128 | 概率基数统计 |
sparsearray.c/h |
2080 | 新增:64 位索引空间的稀疏数组 |
fwtree.c/h |
--- | 新增:Fenwick tree(二叉索引树) |
ebuckets.c/h |
2725 | 新增:过期时间分桶结构 |
estore.c/h |
--- | 新增:过期存储(ebuckets + fwtree) |
chk.c/h |
822 | Count-Min 类 sketch(hotkeys 用) |
crc16.c / crc64.c / crccombine.c |
--- | 校验和与 CRC 组合数学 |
siphash.c |
--- | dict 的哈希函数(抗哈希碰撞攻击) |
mt19937-64.c |
--- | Mersenne Twister PRNG(LRANDOMKEY 等) |
第三层:对象与存储引擎(Object & Storage Engine)
| 文件 | 行数 | 职责 |
|---|---|---|
object.c/h |
2007 | robj/kvobj 对象系统、编码转换、LRU/LFU |
keymeta.c/h |
940 | 新增:key 元数据框架(8 个 slot,slot 0 保留给 TTL) |
db.c |
4022 | keyspace CRUD、SCAN、RESTORE/DUMP、TTL 逻辑 |
entry.c/h |
--- | 新增:hash field+value(+TTL) 的打包布局 |
expire.c |
934 | 惰性过期 + 主动过期周期 |
evict.c |
764 | maxmemory 淘汰策略与淘汰池 |
defrag.c |
2187 | 主动碎片整理 |
t_hash.c |
6613 | Hash 命令与 compact hash 编码 |
t_list.c |
1754 | List 命令(含新增 LMOVEM/BLMOVEM) |
t_set.c |
2047 | Set 命令(含新增 SUNIONCARD/SDIFFCARD) |
t_string.c |
1683 | String 命令 |
t_stream.c |
6409 | Stream 命令与消费者组 |
t_array.c |
2021 | 新增:Array 类型(基于 sparsearray) |
bitops.c |
2201 | bitmap/bitfield 操作 |
geo.c |
1006 | GEO 命令(基于 zset + geohash) |
第四层:网络与命令分发(Networking & Dispatch)
| 文件 | 行数 | 职责 |
|---|---|---|
networking.c |
6027 | 客户端全生命周期、读写、命令分发 |
connection.c/h |
--- | 连接类型抽象(TCP/Unix/TLS) |
socket.c / unix.c / tls.c |
1432(TLS) | 三种连接类型的实现 |
resp_parser.c/h |
--- | RESP 协议解析器 |
commands.c/h + commands.def |
--- | 生成物:命令表 |
iothread.c |
1080 | IO 多线程 |
timeout.c |
--- | 阻塞客户端的超时表 |
blocked.c |
853 | 阻塞命令(BLPOP 等)的唤醒 |
client_comp.c/h |
879 | 新增:per-client 流压缩 |
tracking.c |
761 | 客户端侧缓存(tracking) |
pubsub.c |
785 | 发布订阅 |
notify.c |
--- | keyspace notification |
logreqres.c |
--- | LOG_REQ_RES 请求/响应日志 |
memory_prefetch.c/h |
--- | 命令预取(pipeline lookahead) |
第五层:持久化与复制(Persistence & Replication)
| 文件 | 行数 | 职责 |
|---|---|---|
rdb.c/h |
5556 | RDB 读写、RDB opcode、CRC |
aof.c |
4027 | MP-AOF、新增 BACKUP |
replication.c |
5598 | 主从复制全流程 |
rio.c/h |
--- | 统一的 IO 抽象(file/buffer/fd/socket) |
childinfo.c |
--- | fork 子进程的 COW 统计上报 |
redis-check-rdb.c / redis-check-aof.c |
--- | 离线检查工具 |
mstr.c |
--- | (见上层,也参与持久化) |
第六层:集群与高可用(Cluster & HA)
| 文件 | 行数 | 职责 |
|---|---|---|
cluster.c/h |
2402 | Cluster 公共层 / 抽象接口 |
cluster_legacy.c/h |
6747 | 传统 Gossip 实现 |
cluster_asm.c/h |
3889 | 新增:原子槽迁移 |
cluster_slot_stats.c/h |
--- | per-slot 统计 |
sentinel.c |
5528 | Sentinel |
hotkeys.c |
--- | 新增:热点 key 追踪 |
第七层:脚本与扩展(Scripting & Modules)
| 文件 | 行数 | 职责 |
|---|---|---|
module.c |
16084 | 最大的文件:Module API 实现 |
redismodule.h |
1951 | Module API 头文件 |
script.c/h + script_lua.c/h |
710 + 1767 | Lua 脚本运行时基础设施 |
eval.c |
1762 | EVAL/EVALSHA |
functions.c |
1138 | Redis Functions(FUNCTION LOAD) |
function_lua.c |
--- | Functions 的 Lua 引擎 |
call_reply.c/h |
--- | RM_Call 的返回值封装 |
multi.c |
--- | 事务(MULTI/EXEC/WATCH) |
第八层:外围工具与运维辅助
redis-cli.c(11533 行,第二大文件)、redis-benchmark.c(2043)、latency.c(721)、slowlog.c、lolwut*.c(版本 ASCII art)、threads_mngr.c、syscheck.c、setcpuaffinity.c、setproctitle.c、memtest.c、bio.c(后台 IO 线程)、lazyfree.c、acl.c(3376)、sha1.c/sha256.c、sort.c、sparkline.c、gcra.c(限流算法)。
阅读顺序建议(给不同目标的读者)
- 想理解"Redis 为什么快" :
ae.c→networking.c→iothread.c→object.c→dict.c- 想理解"Redis 为什么省内存" :
zmalloc.c→sds.c→object.c→listpack.c/quicklist.c→t_hash.c(compact hash)→mstr.c/entry.c- 想理解"Redis 为什么可靠" :
rdb.c→aof.c→replication.c→cluster*.c→sentinel.c- 想给 Redis 写模块 :
redismodule.h→module.c(不必通读,按 API 检索)→functions.c- 想排查线上问题 :
latency.c→slowlog.c→hotkeys.c→cluster_slot_stats.c→debug.c
2.3 一个非常有用的观察:文件大小分布暴露了复杂度所在
把 src/*.c 按行数排序,前 15 名是:
yaml
16084 module.c
11533 redis-cli.c
8415 server.c
6747 cluster_legacy.c
6613 t_hash.c
6409 t_stream.c
6027 networking.c
5598 replication.c
5556 rdb.c
5528 sentinel.c
5033 t_zset.c
4094 config.c
4027 aof.c
4022 db.c
3889 cluster_asm.c
这份榜单本身就是一份"复杂度地图",有三处尤值深思:
① module.c 以 16084 行稳居第一,是第二名 redis-cli.c 的 1.4 倍。 这说明 Redis 的"核心"已经不再只是那套键值引擎了。Module API 表面只暴露了不到 2000 行头文件(redismodule.h),但它背后要把 Redis 内部的每一种数据结构、每一个生命周期事件都"再表达一次",还要处理 8 个 key metadata slot、Promise API、线程池 RM_Call、cluster messages 过滤......模块层是 Redis 事实上的"扩展内核 ABI"。
② redis-cli.c 11533 行排第二。 大多数人以为 redis-cli 是个玩具,但它实际上实现了一套独立的完备客户端:cluster redirect 跟随、TLS、命令自动补全(linenoise)、JSON 输出、--cluster 子命令(含重新分片!)、latency 探测、intrinsic latency 测量、--memkeys-samples 内存分析。读 redis-cli.c 是学习"如何正确写一个 Redis 客户端(含集群语义)"的最佳教材。
③ t_hash.c 6613 行超过 t_stream.c(6409),成为最大的 typed file。 传统印象里 Redis 的 hash 是"最简单的类型之一",但在支持了 listpack/hashtable/compact-hash 三种编码 + HFE(字段级过期)+ ebuckets 索引 + HIMPORT 批量导入之后,它一跃成为复杂度之最。如果你在 8.10 里只准备深挖一个数据类型,选 hash------它是这一版 design effort 的集中体现。
3. 构建体系:从 make 到二进制发生了什么
3.1 顶层 Makefile 的设计意图
顶层 Makefile 开头的注释直接告诉了我们它的分工:
makefile
# Top level makefile, the real stuff is at ./src/Makefile and in ./modules/Makefile
# Explicitly export $(MAKE) so recipe shells (scripts/build.sh, deploy.sh, ...)
# can read it as $MAKE and use the same make binary the user invoked. GNU make
# only auto-propagates MAKE to recursive `$(MAKE) -C ...` sub-makes --- recipes
# that are shell scripts get an EMPTY $MAKE env var by default. Without this,
# `MAKE_BIN="${MAKE:-make}"` in scripts falls back to PATH-lookup of `make`,
# which can resolve to Apple's stock /usr/bin/make 3.81 in non-interactive
# recipe shells (different PATH ordering than the user's interactive shell).
export MAKE
这段注释本身就是一份很好的技术文档,它解释了一个真实存在过的 bug :GNU make 只把 MAKE 自动传播给递归的 $(MAKE) -C ... 调用,但 recipe 如果是一个 shell 脚本,脚本内部拿到的 $MAKE 是空的。于是脚本里的 ${MAKE:-make} 会 fallback 到 PATH 查找,而在 macOS 上,非交互式 shell 的 PATH 顺序与交互式不同,可能找到 Apple 自带的 /usr/bin/make 3.81(太旧,不支持某些 GNU 扩展语法)。解决方案是在顶层显式 export MAKE。
这类注释是 Redis 代码库的一个优良传统:他们会把"为什么这么写"连同"不这么写会发生什么"一起记录下来。 本系列在讲解各处设计时也会沿用这个风格。
3.2 顶层 Makefile 的目标分发机制
makefile
.DEFAULT_GOAL := build
SUBDIRS = src
include modules/manifest.mk
GOALS_WITH_ARGS := \
modules-update:MODULES_ARGS \
modules-shallow:SHALLOW_ARGS \
run:RUN_ARGS \
build:BUILD_ARGS \
clean:CLEAN_ARGS \
bootstrap:BOOTSTRAP_ARGS \
deploy:DEPLOY_ARGS \
test:TEST_ARGS \
sync-redis-conf:SYNC_ARGS
这里有一个精巧的 Make 技巧值得学习:位置参数捕获。
Make 本身不支持 make modules-update redisjson redisbloom 这种"一个目标 + 一串参数"的语法------它会被解析为三个并列的目标。GOALS_WITH_ARGS 的设计解决了这个问题:把第一个,MODULES_ARGS,然后把其余 token 变成 no-op 的 .PHONY 目标,防止它们被 .DEFAULT 规则递归到子目录。
生成这段逻辑的是一个 define/eval 模板:
makefile
define _capture_goal_args
ifeq ($$(firstword $$(MAKECMDGOALS)),$(1))
$(2) := $$(filter-out $(1),$$(MAKECMDGOALS))
$$(foreach m,$$($(2)),$$(if $$(findstring :,$$(m)),,$$(eval .PHONY: $$(m))$$(eval $$(m): ; @:)))
endif
endef
注意 (if(findstring :,$m),, ...) 这一句:含 : 的 token 会被跳过,因为 Make 的静态模式解析器会因为 : 崩溃。这是防御性编程的细节。
3.3 src/Makefile:开关矩阵
真正把 .c 变成 .o 的是 src/Makefile。它的核心是一组可组合的开关,我把它整理成矩阵:
| 变量 | 取值 | 效果 |
|---|---|---|
MALLOC |
libc(默认)/jemalloc/tcmalloc/tcmalloc_minimal |
选择内存分配器。Linux 上自动升级为 jemalloc(见下) |
SANITIZER |
address/undefined/thread/memory |
开启 Sanitizer,且强制 MALLOC=libc |
BUILD_TLS |
yes/module/no |
build TLS 静态链接或动态模块 |
USE_SYSTEMD |
yes/no | systemd notify 支持 |
OPTIMIZATION |
默认 -O3(若为 -O3 会追加额外 flags) |
优化级别 |
C11_ATOMIC |
yes/no | 是否用 C11 <stdatomic.h> |
BUILD_COMPRESSION |
yes/no | 编译复制/客户端压缩支持 |
LOG_REQ_RES |
(加入 REDIS_CFLAGS) |
记录所有请求和响应到日志文件 |
DEBUG_DEFRAG |
force | 强制走碎片整理代码路径 |
SKIP_VEC_SETS |
yes/no | 是否跳过 vector sets 模块编译 |
几个关键点:
**(1)Linux 上 jemalloc 是默认生效果。 **
makefile
MALLOC=libc
ifeq ($(uname_S),Linux)
MALLOC=jemalloc
endif
这个"按平台抬升默认值"的做法很重要:libc malloc(glibc ptmalloc)在多 Arena、碎片、及malloc_usable_size 语义上与 jemalloc 差别很大;而 Redis 的内存统计高度依赖 malloc_usable_size() 的准确性,并且 Redis 的 mem_fragmentation_ratio 计算需要知道"实际分配了多少 size class 字节"。jemalloc 在这方面可预测得多。在非 Linux 平台(如 macOS)默认用 libc malloc,这也是为什么 macOS 上的 Redis 内存行为与 Linux 生产环境不完全一致的一个原因。
(2)Sanitizer 与 jemalloc 互斥。
makefile
ifdef SANITIZER
ifeq ($(SANITIZER),address)
MALLOC=libc
...
ifeq ($(SANITIZER),memory)
ifeq (clang, $(CLANG))
MALLOC=libc # MSan provides its own allocator so make sure not to use jemalloc as they clash
注释直接点明:MSan 提供自己的分配器,与 jemalloc 冲突。这是实践中必须知道的------想跑 Sanitizer,就要接受"内存行为与生产不一致"(因为换成了 libc malloc + sanitizer allocator),因此Sanitizer 只验证"逻辑正确性"(越界、UAF、数据竞争),不验证"内存占用"。
(3)FINAL_LIBS 与平台分支。 FINAL_LIBS=-lm 起步,然后按 uname_S(Linux/Darwin/SunOS/AIX/OpenBSD/NetBSD/FreeBSD/DragonFly/Haiku)追加。这提醒我们:Redis 的"可移植性"不是通过抽象层(如 APR)实现的,而是直接在 Makefile 里列举平台差异 + 在 /*.c 里 #ifdef。 相比之下 ae.c(事件循环)与 connection.c(连接抽象)是少见的、真正做了多态抽象的角落------因为它们必须在一套 API 下塞进 4 个 epoll/kqueue/evport/select 后端。
(4)命令表的代码生成。
makefile
$(COMMANDS_DEF_FILENAME).def: commands/*.json ../utils/generate-command-code.py
$(QUIET_GEN)$(PYTHON) ../utils/generate-command-code.py $(GEN_COMMANDS_FLAGS)
这条规则说清了 src/commands.def 的地位:它是生成物,不是源文件。 真正的源是 src/commands/*.json(本仓库有 459 个 JSON 文件)+ utils/generate-command-code.py。当你在 src/ 里看到一个 .def 被 #include(通常是 #include "commands.def" 在 commands.c 里),要知道它不是手写的。
这一点对阅读代码极其重要 :如果你发现磁盘上没有 commands.def 而你 clone 后立即 make 会生成它------那么所有涉及命令表的代码分析都必须先跑一次 make 才能看到真实内容。本仓库目前已经有 生成的 commands.def(含 458 条 MAKE_CMD 宏调用),这可能意味着构建已经跑到过这一步。
3.4 模块清单:modules.yaml + manifest.mk 的清单驱动构建
Redis 8 之后,"Redis 发行版"不再只是一个二进制,而是一捆(Query Engine/JSON/TimeSeries/Bloom/Vector Sets)。本仓库把这一捆的管理做成了清单驱动:
yaml
# modules.yaml --- release manifest for bundled Redis modules.
#
# Single source of truth for which upstream repo and ref each module is
# pinned to. Consumed by:
# - `make modules-update` (top-level Makefile) to clone/refresh sources
# - `modules/docker-install-bundled-module-deps.sh` (Docker image build only)
# - modules/common.mk's per-module clone-on-build fallback
# - `make tarball` to clone modules at their pinned ref into the release tarball
清单内容(四个可拉取模块):
| name | repo | ref | target_module | loadmodule | build_env |
|---|---|---|---|---|---|
redisbloom |
redisbloom/redisbloom |
v8.10.1 |
redisbloom.so |
./modules/redisbloom/redisbloom.so |
--- |
redisearch |
redisearch/redisearch |
v8.10.1 |
search-community/redisearch.so |
./modules/redisearch/redisearch.so |
LTO=1 REDISEARCH_GENERATE_HEADERS=0 INLINE_LSE_ATOMICS=0 |
redisjson |
redisjson/redisjson |
v8.10.0 |
rejson.so |
./modules/redisjson/rejson.so |
--- |
redistimeseries |
redistimeseries/redistimeseries |
v8.10.1 |
redistimeseries.so |
./modules/redistimeseries/redistimeseries.so |
--- |
注释里明确说明了 ref 的三种形态及其解析优先级:
sql
1. tag --- matches `refs/tags/<ref>` on the remote (preferred for releases; immutable)
2. branch --- matches `refs/heads/<ref>` on the remote
3. commit --- looks like a hex SHA (7--40 chars) when neither of the above matched
即:先当 tag 试,再当 branch 试,最后当 commit SHA 试。并且强调 "Format (kept deliberately simple so a small awk helper can parse it without needing yq/python; see modules/manifest.mk)" ------这是很有意思的工程取舍:为了减少一个 Python/yq 依赖,宁可让 YAML 格式极度受限(一条字段一行,禁止行尾注释)。理由可能是:Linux 发行版打包脚本要在最小化环境里解析这个清单,Python 未必存在,而 awk 一定存在。
还有一个细节值得一提:
ini
- name: redisearch
build_env: LTO=1 REDISEARCH_GENERATE_HEADERS=0 INLINE_LSE_ATOMICS=0
INLINE_LSE_ATOMICS=0 的注释说明了原因:避免在 Armv8.1-a 之前的 ARM 核(Cortex-A72、Graviton1、Raspberry Pi 4)上触发 SIGILL(非法指令) 。LSE(Large System Extensions)原子指令在 Armv8.1-a 才引入;如果编译器内联了它们,而运行机器不支持,进程直接崩溃并且可不是崩溃在初始化阶段,而是随机在某次原子操作时。这是 ARM 生态里非常典型的坑:"编译时知道目标ISA" 与 "运行时实际CPU" 不匹配。
至于 modules/vector-sets,注释特意指出它是 in-tree 的:
markdown
- `vector-sets` is intentionally absent --- it lives in-tree under
modules/vector-sets/ and is not cloned.
这与 SKIP_VEC_SETS Makefile 开关呼应(这一版的 vector sets 在 src/Makefile 里参与构建)。
3.5 一次典型的构建:命令与产物
把上面的机制串起来,几种典型构建方式:
bash
# ① 默认构建(Linux 上自动 jemalloc)
make -j$(nproc)
# ② 带 TLS
make BUILD_TLS=yes -j$(nproc)
# ③ 带能对外 atingido 的完整发行(包含四个外部模块)
make bootstrap && make -j$(nproc)
# (bootstrap 会 clone modules/*/src,然后 build 会按 modules.yaml 编译)
# ④ 调试构建
make OPTIMIZATION="-O0" MALLOC=libc -j$(nproc)
# ⑤ ASan 构建(自动 MALLOC=libc)
make SANITIZER=address -j$(nproc)
# ⑥ 单元测试
./src/redis-server test all
# 或单个子系统:./src/redis-server test dict / t_hash / reply_schema_linter ...
# ⑦ TCL 集成测试
./runtest --single unit/expire
第 ⑥ 条值得展开,因为它直接支撑本系列的"可验证性"承诺。REDIS_TEST 编译开关下,每个子系统都有自己的 test 入口,src/server.c:8056 起的这段就是分发器:
c
#ifdef REDIS_TEST
if (argc >= 3 && !strcasecmp(argv[1], "test")) {
int flags = 0;
for (j = 3; j < argc; j++) {
char *arg = argv[j];
if (!strcasecmp(arg, "--accurate")) flags |= REDIS_TEST_ACCURATE;
else if (!strcasecmp(arg, "--large-memory")) flags |= REDIS_TEST_LARGE_MEMORY;
else if (!strcasecmp(arg, "--valgrind")) flags |= REDIS_TEST_VALGRIND;
else if (!strcasecmp(arg, "--verbose")) flags |= REDIS_TEST_VERBOSE;
}
...
}
#endif
redisTests[] 数组里注册了所有可用的 test group。这意味着你可以在不启动服务器的情况下,单独验证 dict 的 rehash、ziplist 的连锁更新、fwtree 的前缀和、sparsearray 的编码切换等纯逻辑。本系列后续每一章都会给出对应的 test 命令。
4. 全局状态:redisServer 的 668 行密码本
4.1 为什么它值得单独一整章的一节
Redis 是单线程为主的架构,这带来一个直接后果:几乎所有子系统都通过同一个 struct redisServer server; 全局实例来共享状态 。这在 1990 年代的 UNIX 程序里很常见,但在今天的系统里显得"不够干净"------然而它有一个巨大的好处:只要把这个结构体读懂,你就掌握了整个服务器的状态空间。
server 的定义在 src/server.h:2041,结束于 src/server.h:2708,共 668 行。它由两部分构成:
- 数据字段:真正的运行时状态;
- 注释分区 :形如
/* General */、/* Modules */、/* Networking */的区块标记------这些不是装饰,而是 Redis 团队在长期演进中形成的非正式分区协议。
反过来说,如果你要给 Redis 加一个新子系统,你的第一步通常是在这个结构体里找一个分区(或新建一个)放下你的字段。理解这些分区,就是理解 Redis 内核的模块边界。
先给一张全局分区表(按结构体出现顺序):
| # | 分区标记 | 起始行 | 主要关注点 | 本版本新增/变化的信号 |
|---|---|---|---|---|
| 1 | /* General */ |
2042 | 进程身份、event loop 句柄、命令表、LRU 时钟、关停标志 | execution_nesting、firing_keyed_post_notif_jobs 等 module job 相关计数是新风格 |
| 2 | /* Modules */ |
1084-ish | 模块 API 字典、加载队列、GIL 获取标志 | busy_module_yield_flags 在 General 区(见下) |
| 3 | /* Networking */ |
2094 | 监听端口、客户端链表、pause 状态、IO 线程 | cmd_pool(pendingCommand 池) |
| 4 | /* RDB / AOF loading information */ |
2160 | 加载进度 | 无 |
| 5 | /* Fields used only for stats */ |
2169 | 统计数据区块 | stat_expired_subkeys 、stat_net_repl_*uncompressed* 是 8.10 新增 |
| 6 | /* Hotkey tracking */ |
2267 | 仅一个指针:hotkeyStats *hotkeys |
全新分区 |
| 7 | /* Configuration */ |
2270 | 配置项 | key_memory_histograms、lookahead、cluster_compatibility_sample_ratio 新 |
| 8 | /* AOF persistence */ |
2314 | MP-AOF 状态机 | 无大的结构变化 |
| 9 | /* Backup (MP-AOF based) */ |
2354 | BACKUP 子状态 | 全新分区 |
| 10 | /* RDB persistence */ |
2366 | savepoints、RDB 参数 | 无 |
| 11 | /* Pipe and data structures for child -> parent */ |
2401 | COW 统计管道 | 无 |
| 12 | /* Logging */ |
2407 | syslog、crashlog | 无 |
| 13 | /* Shutdown */ |
2417 | 优雅关停 | shutdown_timeout 概念更新 |
| 14 | /* Replication (master) */ |
2422 | 复制 backlog、diskless、rdb channel | repl_full_sync_buffer* 新 |
| 15 | /* Replication (slave) */ |
2459 | 主从连接状态机 | repl_rdb_ch_state/repl_main_ch_state 新 |
| 16 | /* Limits */ |
2510 | maxmemory 等 | compression_max_latency 新 |
| 17 | /* Blocked clients */ |
2524 | 阻塞命令 | 无 |
| 18 | /* Client side caching */ |
2529 | tracking | 无 |
| 19 | /* Zip structure config */ |
2540 | 紧凑编码阈值 | hash_min_template_entries 等一批新字段 |
| 20 | /* time cache */ |
2593 | 时间缓存 | 无 |
| 21 | /* Pubsub */ |
2604 | pubsub kvstore | 无 |
| 22 | /* Cluster */ |
2612 | 集群 | asm_* 四个字段、cluster_bus_port_protected_mode 新 |
| 23 | /* Scripting */ |
2652 | Lua | lua_arena、key_memory_histograms |
| 24 | /* Lazy free */ |
2659 | 惰性释放五开关 | 无 |
| 25 | /* Latency monitor */ |
2665 | 延迟监控 | 无 |
| 26 | /* ACLs */ |
2668 | 权限 | 无 |
| 27 | /* System hardware info */ |
2678 | system_memory_size |
无 |
| 28 | /* TLS Configuration */ |
2680 | TLS | 无 |
| 29 | /* cpu affinity */ |
2685 | 绑核 | 无 |
| 30 | /* Sentinel config */ |
2690 | sentinel | 无 |
| 31 | /* Local environment */ |
2705 | locale_collate、dbg_assert_flags |
dbg_assert_flags 新 |
这张表本身就是一份"8.10 变化清单":带"新增"标记的行,就是本系列后续要逐个展开的主题。 下面挑最值得说的分区,逐组讲解。
4.2 General 区:进程身份与全局句柄
c
struct redisServer {
/* General */
pid_t pid; /* Main process pid. */
pthread_t main_thread_id; /* Main thread id */
char *configfile; /* Absolute config file path, or NULL */
char *executable; /* Absolute executable file path. */
char **exec_argv; /* Executable argv vector (copy). */
int dynamic_hz; /* Change hz value depending on # of clients. */
...
dict *commands; /* Command table */
dict *orig_commands; /* Command table before command renaming. */
aeEventLoop *el;
rax *errors; /* Errors table */
unsigned int lruclock; /* Clock for LRU eviction */
redisAtomic int shutdown_asap; /* Shutdown ordered by signal handler. */
...
char runid[CONFIG_RUN_ID_SIZE+1]; /* ID always different at every exec. */
int sentinel_mode; /* True if this instance is a Sentinel. */
...
executable 与 exec_argv 为什么要保存? 因为 Redis 支持 DEBUG RELOAD(等价于重启:把当前数据集 dump 成 RDB,然后 fork+exec 自己再加载回来),以及某些运维命令需要在运行时"重启自身"。如果没有保存 argv,execv() 就无法还原启动参数。main() 里紧接着就有这段:
c
server.executable = getAbsolutePath(argv[0]);
server.exec_argv = zmalloc(sizeof(char*)*(argc+1));
server.exec_argv[argc] = NULL;
for (j = 0; j < argc; j++) server.exec_argv[j] = zstrdup(argv[j]);
注意它深拷贝 了 argv(zstrdup),而不是保存指针------因为 argv 指向的内存理论上可能被修改,而 Redis 会长时间持有它。这是一个防御性的好习惯,但也是一道考场题:如果有人 future持之以恒优化这个地方改成浅拷贝,就会引入 use-after-free 或内容漂移。
commands 与 orig_commands 为何是两张表? 因为 Redis 支持 rename-command 配置项:把 FLUSHALL 改名为一个随机串,让普通用户无法调用。rename-command 会修改 commands 字典(把 key 换成新名字),但:
- ACL 系统需要依据"原始命令"来做权限判断吗?不需要------ACL 依据命令结构体里的 id;
- 但如果万一配置被再次修改(或者rename到空串表示禁用),需要一个"原始视图"来回溯。
orig_commands 保存的就是重命名前的快照。这是"可变配置 + 权威源"模式的典型实现。
lruclock 的精度陷阱。 它是一个 unsigned int,存放的是"秒级时钟的低 21 位"(LRU_CLOCK_RESOLUTION 相关宏控制,通常是秒或毫秒级分辨率下截断到 21/24 位)。为什么要截断?因为对象里留给 LRU 信息的位数有限(robj.lru 是 24 位)。这导致:
- LRU 时间戳会回绕 :24 位毫秒大约是 4.6 小时;如果用秒,则是 194 天。Redis 用
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1)并在比较时做回绕安全的差值计算; - 这意味着 LRU 淘汰不是精确的 LRU,而是一个"128 采样池 + 近似时间戳"的近似 LRU(第 4 卷详述)。
shutdown_asap 为什么是 redisAtomic? 因为它由信号处理函数写、由主循环读 。信号处理器可以是任何线程(在 Linux 上通常还是主线程,但从语言模型角度它是异步上下文),因此必须用原子类型 + volatile 语义,否则编译器可能把主循环里的读取优化成寄存器缓存,导致永远看不到信号。同样的写法出现在 server.crashing、server.last_sig_received、server.running。这是 Redis 里"异步上下文与主线程通信"的唯一安全方式。
dynamic_hz 与 hz。 hz 是实际生效的 cron 频率,config_hz 是配置文件里的值,dynamic_hz 决定是否按客户端数量自动上调。逻辑是:客户端多 → 需要更频繁地处理后台任务(如过期、超时)→ 临时提升 hz(最高到 CONFIG_MAX_HZ 量级)。这是一个自适应限流的朴素实现。
4.3 Modules 区:一小撮字段背后的大 ABI
c
/* Modules */
dict *moduleapi; /* Exported core APIs dictionary for modules. */
dict *sharedapi; /* Like moduleapi but containing the APIs that
modules share with each other. */
dict *module_configs_queue; /* Unmapped configs are queued here, assumed to be module config.
Applied after modules are loaded during startup or arguments
to loadex. */
list *loadmodule_queue; /* List of modules to load at startup. */
int module_pipe[2]; /* Pipe used to awake the event loop by module threads. */
...
redisAtomic int module_gil_acquring; /* Indicates whether the GIL is being acquiring by the main thread. */
module_configs_queue 解决的是一个真实的"鸡生蛋"问题 : 配置文件里可能出现模块自定义的配置项,但此时模块还没加载(配置项是未知的)。如果直接报 "Unknown config directive",那用户就永远无法在 redis.conf 里配置模块了。解法是:解析配置时遇到未知项,先塞进队列;模块加载后(moduleLoadFromQueue()),再回头用队列里的项去匹配模块注册的配置名。
这是一个非常通用的模式 ,值得记住:"先收集未知实体,后注册者接管" ------ Lua script flags、某些 cluster module flags 也用了类似思路。
module_pipe[2] 是模块线程与主线程通信的机制。 模块可以创建自己的线程,并在其中调用 RM_ThreadSafeContextLock() 获取 GIL 来操作数据;反过来,模块线程完成了工作要通知主线程时,往这个 pipe 写一个字节,主线程的 event loop 在 pipe 的可读事件里醒来并处理。这是 Redis 里"外部线程唤醒事件循环"的标准做法 ------同样的机制出现在 ae.c 的 setsize 限制之外任何需要跨线程唤醒的地方。module_gil_acquring 则标志主线程正在争抢 GIL,用于避免某些竞态死锁。
4.4 Networking 区:客户端链表体系与"当前是谁在执行"
c
/* Networking */
int port; /* TCP listening port */
int tls_port; /* TLS listening port */
int tcp_backlog; /* TCP listen() backlog */
char *bindaddr[CONFIG_BINDADDR_MAX]; /* Addresses we should bind to */
int bindaddr_count;
char *bind_source_addr; /* Source address to bind on for outgoing connections */
char *unixsocket;
unsigned int unixsocketperm;
connListener listeners[CONN_TYPE_MAX]; /* TCP/Unix/TLS even more types */
...
connListener clistener; /* Cluster bus listener */
list *clients; /* List of active clients */
list *clients_to_close; /* Clients to close asynchronously */
list *clients_pending_write; /* There is to write or install handler. */
list *clients_pending_read; /* Client has pending read socket buffers. */
list *clients_with_pending_ref_reply; /* Clients with referenced reply objects. */
list *slaves, *monitors; /* List of slaves and MONITORs */
client *current_client; /* The client that triggered the command execution (External or AOF). */
client *executing_client; /* The client executing the current command (possibly script or module). */
这一组字段里藏着本系列第 3 卷的核心骨架,这里先做全景介绍。
(1)五种客户端链表的分工。 为什么不是一个链表搞定?
| 链表 | 语义 | 为什么必须独立 |
|---|---|---|
clients |
所有活跃客户端 | 用于 CLIENT LIST、INFO clients、超时清理遍历 |
clients_to_close |
待异步关闭 | 在遍历 clients 的过程中不能直接 free client(迭代器失效)。8.10.1 的 UAF 修复(#15594)就与这类问题相关。所以关闭动作被延后到一个独立链表,在安全点统一处理 |
clients_pending_write |
有待写数据/需安装写处理器 | aeMain 的 beforeSleep() 会遍历它批量写。这是 Redis "写回延迟到事件循环末尾"优化的核心数据结构 |
clients_pending_read |
有未读 socket 缓冲(IO 线程已读到数据) | IO 多线程模式下,读发生在 IO 线程,完成后交给主线程执行,链表就是交接队列 |
clients_with_pending_ref_reply |
回复里持有共享对象的引用 | 引用计数对象的释放必须延迟到所有引用者写完,因此需要单独记账 |
这张表的每一行都对应一个"如果合并成一条会出现的 bug"。它们是性能与正确性共同约束下的产物。
(2)current_client vs executing_client:一个容易被忽略但极重要的区别。
c
client *current_client; /* The client that triggered the command execution (External or AOF). */
client *executing_client; /* The client executing the current command (possibly script or module). */
设想这个场景:一个 Lua 脚本里调用了 redis.call('SET', ...),而这个脚本本身是一个客户端 EVAL 触发的,而这个 EVAL 又是通过 AOF 重放读入的。那么:
executing_client:当前真正在执行SET的实体 ------ 是那个"伪客户端"(fake client,脚本引擎创建的临时 client);current_client:整个调用链的源头 ------ 那个真实的外部客户端(或 AOF fake client)。
区分它们的实际用途:
- ACL 检查 要用
executing_client(因为脚本内的权限由脚本自己承载); - 统计与
CLIENT相关命令 、以及"这个命令该产不该传播"的判断,往往需要current_client; isInsideYieldingLongCommand()、ACLCheckAllPerm这类逻辑依赖两者配合。
如果你在读代码时发现某处取的是 server.current_client 而某处是 c,请停下来想一想它到底想表达谁------这两者的混用是 Redis 历史上多次 bug 的来源(包括 8.10.1 里那条 blocked client 相关修复)。
(3)connListener listeners[CONN_TYPE_MAX] 与 clistener 分离。 普通的监听(TCP/Unix/TLS)放在数组里统一管理,而集群总线单独一个 clistener------因为它有独立的端口约定(client port + 10000)、独立的消息格式(不是 RESP)、以及 8.10.2 新增的 cluster-bus-port-protected-mode 检查。这种"物理上是另一个服务"的东西就应该在数据结构层面分开表达。
4.5 Stats 区:Redis 的可观测性接口,也是它的"变化史"
统计区(server.h:2169 起,约 90 行)它既是最枯燥的一节,也是最能反映版本演进的一节------每当 Redis 引入一个新机制,几乎必然要在这里补一个或多个计数器,因为运维必须能看到它。
挑几个有代表性的:
c
long long stat_expiredkeys; /* Number of expired keys */
long long stat_expiredkeys_active; /* Number of expired keys by active expire */
long long stat_expired_subkeys; /* Number of expired subkeys (Currently only hash-fields) */
long long stat_expired_subkeys_active; /* Number of expired subkeys by active expire */
stat_expired_subkeys 是 8.10 独有 的。它的存在直接证明了 HFE(Hash Field Expiration)这一新特性:既然有了字段级 TTL,就必须有字段级过期计数。而 _active 后缀区分的是"主动过期循环清理的"与"访问时惰性发现的"------这是评估过期机制健康度的关键比值 :如果 expired_subkeys_active 长期接近 0,说明你的 HFE 字段几乎全靠惰性删除,内存回收滞后。
c
long long stat_net_repl_input_bytes; /* Bytes read during replication, added to stat_net_input_bytes in 'info'. */
long long stat_net_repl_output_bytes; /* Bytes written during replication, ... */
redisAtomic long long stat_net_repl_output_uncompressed_bytes; /* Bytes read from repl buffer before being compressed and written during replication. */
redisAtomic long long stat_net_repl_input_decompressed_bytes; /* Decompressed bytes after reading compressed data during replication. */
这四个字段是复制压缩(#15366)的直接产物。它们的价值在于让你可以直接算出压缩比:
压缩比 = stat_net_repl_output_uncompressed_bytes / stat_net_repl_output_bytes
如果这个比值接近 1,说明压缩几乎无效(可能是数据本身就随机,或压缩窗口太小),那么压缩带来的 CPU 开销就是纯亏。这就是"可观测性字段"应该有的样子:不是为了好看,而是为了支撑一个决策。
c
long long stat_evictedkeys; /* Number of evicted keys (maxmemory) */
long long stat_evictedclients; /* Number of evicted clients */
long long stat_evictedscripts; /* Number of evicted lua scripts. */
long long stat_total_eviction_exceeded_time; /* Total time over the memory limit, unit us */
monotime stat_last_eviction_exceeded_time; /* Timestamp of current eviction start, unit us */
注意这里有三个"被驱逐的东西":key、client、script。它们分别对应三种不同的内存压力源,以及三种不同的驱逐策略(maxmemory-clients、maxmemory policy、scripts LRU)。stat_evictedclients 的存在提醒我们:Redis 的内存压力不一定来自数据,也可能来自连接缓冲(查询缓冲/输出缓冲)。 这是排查"为什么内存满了但 dbsize 很小"的第一线索。
c
size_t stat_current_cow_peak; /* Peak size of copy on write bytes. */
size_t stat_current_cow_bytes; /* Copy on write bytes while child is active. */
monotime stat_current_cow_updated; /* Last update time of stat_current_cow_bytes */
size_t stat_current_save_keys_processed; /* Processed keys while child is active. */
size_t stat_current_save_keys_total; /* Number of keys when child started. */
COW(写时复制)是 fork-based 持久化(RDB/AOF rewrite)最重要的性能指标。stat_current_cow_bytes 是通过读取 /proc/<pid>/smaps 里 Private_Dirty 之类字段(Linux 下)由子进程或父进程统计出来的。在实践中,latest_fork_usec 与 COW 峰值共同决定了"这台机器能不能安全做 BGSAVE":如果 COW 峰值接近可用内存的 50%,就要警惕 fork 失败或 OOM。
4.6 Replication 区:从"单通道"到"双通道"的演进
复制区是本版本的另一个大变化点。先看主侧:
c
/* Replication (master) */
char replid[CONFIG_RUN_ID_SIZE+1]; /* My current replication ID. */
char replid2[CONFIG_RUN_ID_SIZE+1]; /* replid inherited from master*/
long long master_repl_offset; /* My current replication offset */
long long second_replid_offset; /* Accept offsets up to this for replid2. */
...
replBacklog *repl_backlog; /* Replication backlog for partial syncs */
long long repl_backlog_size; /* Backlog circular buffer size */
...
long long repl_full_sync_buffer_limit; /* Accumulated repl data limit during rdb channel replication */
replDataBuf repl_full_sync_buffer; /* Accumulated replication data for rdb channel replication */
replid / replid2 双 ID 机制是 Redis 4 引入的重要设计,值得在这里讲透:
- 当一个 replica 被提升为 master,它不能沿用旧 master 的 replid(否则其他 replica 会误以为"这就是原来的主,只是继续同步");
- 但它又需要"接纳"那些还认旧主的 replica 的增量同步请求;
- 于是保留两个 ID:
replid是自己的新身份,replid2是继承来的旧身份,second_replid_offset标明"旧身份对应的 offset 上限"; - PSYNC 请求来时,先按
replid匹配,失败则按replid2匹配并检查 offset 是否 ≤second_replid_offset。
这是一个优雅的版本戳 + 继承链设计,代价只有两个 41 字节的字符串。
新增的 repl_full_sync_buffer* 与 repl_type 相关字段揭示了一个新机制:rdb channel replication(见 slave 侧的 repl_rdb_ch_state / repl_main_ch_state)。 传统全量同步的痛点是:RDB 传输期间新产生的写命令 怎么办?旧方案是把这些写缓存在客户端的输出缓冲里,等 RDB 传完再接着发------这会导致 replica 客户端的输出缓冲暴涨(甚至被 client-output-buffer-limit 断开,触发全量重来,即 Full sync under heavy write load,见 8.10 GA 的 bug 修复 #15447)。
新方案(本仓库已实现)把全量同步拆成两条通道:
- rdb channel:专门传 RDB 文件;
- main channel:照常传增量命令。
slave 侧对应维护 repl_rdb_ch_state 与 repl_main_ch_state 两个状态机,以及两个连接指针:
c
connection *repl_transfer_s; /* Slave -> Master SYNC connection */
connection *repl_rdb_transfer_s; /* Slave -> Master FULL SYNC connection (RDB download) */
这样增量数据就不必被"缓冲等待",而是与 RDB 并行流动。这是一个典型的"用连接数换缓冲区"的架构权衡,我们在第 5 卷详述。
4.7 Hash Template 区:8.10 最核心新特性的配置入口
c
/* Hash template config */
size_t hash_min_template_entries; /* Field-count threshold at which regular
* hashes are auto-converted to template
* encoding. Applies to the live command
* path (e.g. HSET), not RDB load (which has
* its own hash_rdb_load_* configs below).
* 0 disables auto-convert. */
size_t hash_max_template_entries; /* Upper field-count bound for that
* auto-convert: hashes with more fields than
* this are not converted. 0 disables the
* upper bound. */
size_t hash_rdb_load_min_template_entries;
size_t hash_rdb_load_max_template_entries;
size_t hash_rdb_load_template_disassembly_threshold; /* Relevant only when
* RDB-load auto-convert is on ...
* That auto-convert turns plain hashes into
* templates as the RDB loads; afterwards a
* template still shared by fewer than this
* many keys ("few-key") is disassembled back
* to a plain hash, so rarely-shared
* templates don't waste memory. ... */
struct hashTemplateRegistry *htemplates; /* Global template registry */
这几个字段的信息密度极高,几乎可以直接当作 compact hash 的功能规格书:
- 存在"模板(template)"这个全局注册表 (
htemplates)------即"字段名集合"的共享池; - 转换有两条路径:在线写入路径(HSET 等)与 RDB 加载路径,各自有独立阈值;这说明两个场景的最优策略不同:在线路径要避免"频繁创建模板"的开销,而 RDB 加载是一次性批处理,可以更激进;
- 有上界也有下界 :
hash_max_template_entries说明"字段太多的 hash 不适合模板化"(因为共享字段名的收益被摊薄,而管理成本上升); - 有反向操作
disassembly:如果一个模板只被极少数的 key 引用,把它拆回普通 hash。这非常重要------它防止了"为了共享而共享"导致的负优化,是真正的工程成熟度体现。
把上面这段配置与 OBJ_ENCODING_* 列表(在 src/object.h:75-90)对照:
c
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
...
#define OBJ_ENCODING_LISTPACK 11 /* Encoded as a listpack */
#define OBJ_ENCODING_LISTPACK_EX 12 /* Encoded as listpack, extended with metadata */
#define OBJ_ENCODING_SLICED_ARRAY 13 /* Encoded as sliced array */
#define OBJ_ENCODING_TMPL_LP 14 /* Hash with shared template, values in listpack */
#define OBJ_ENCODING_TMPL_ARRAY 15 /* Hash with shared template, values in sds array */
可以看到 8.10 引入了 4 个新编码 :LISTPACK_EX(带 TTL 元数据的 listpack,用于 HFE)、SLICED_ARRAY(t_array 的编码)、TMPL_LP / TMPL_ARRAY(compact hash 的两种物理形态)。一个编码号就是一个新 world。 我们在第 2 卷会用整章的时间拆它们。
4.8 Cluster 区:ASM 的四个字段名已泄露了一半设计
c
/* Cluster */
int cluster_enabled;
int cluster_bus_port_protected_mode; /* Refuse to run a cluster node whose bus
port is unauthenticated. See
cluster-bus-port-protected-mode. */
...
long long asm_handoff_max_lag_bytes; /* Maximum lag in bytes before pausing writes for ASM handoff. */
long long asm_write_pause_timeout; /* Timeout in milliseconds to pause writes during ASM handoff. */
long long asm_sync_buffer_drain_timeout; /* Timeout in milliseconds for sync buffer to drain during ASM. */
int asm_max_archived_tasks; /* Maximum number of archived ASM tasks to keep in memory. */
"原子槽迁移(Atomic Slot Migration, ASM)" 的设计意图,从这四个字段的字面就能推断出来:
- 迁移的最后阶段(handoff)需要短暂暂停写入 (
asm_write_pause_timeout),以便把残留的同步缓冲排干净(asm_sync_buffer_drain_timeout); - 暂停不能无限长,而且要等双向 lag 降到阈值以下(
asm_handoff_max_lag_bytes)------否则所谓的"迁移完成"就不成立; - 迁移任务本身会被归档留档(
asm_max_archived_tasks),便于查询历史任务状态。
这与旧 MIGRATE 的区别是决定性的 :旧的迁移是"逐 key 迁移 + 期间重定向 ASK",整个过程对外界不是原子的,某些 key 可能处在中间状态;ASM 追求的是"在一个时间点上,槽的所有权原子地从 A 跳到 B"。要做到这一点,就必须有"暂停写入并排空管道"这一步骤。这就是那四个字段所描述的动作。
4.9 那些只占一行、但值得单独记住的字段
有些字段虽小,但揭示了重要的机制,这里集中列出:
| 字段 | 位置 | 揭示的机制 |
|---|---|---|
int lookahead; |
Configuration 区 | 命令预取 :在一次客户端 pipeline 里提前解码并 prefetch 后续命令的 key/对象的 dictEntry,用后续系统调用的重叠来掩盖内存延迟(memory_prefetch.c)。这是针对现代 CPU 访存瓶颈的优化 |
int lazylazy_* 五个开关 |
Lazy free 区 | 惰性释放分别覆盖了:eviction、expire、server del、user del、flush。注意 lazyfree_lazy_server_del 是 Redis 内部删除(如 RENAME 覆盖目标)而 lazyfree_lazy_user_del 是用户 DEL ------ 这种"按触发来源区分"的做法很细致 |
int reply_copy_avoidance_enabled; |
末尾 | 回复"零拷贝"优化开关:让大型聚合回复之间共享同一个对象而不深拷贝(配合引用计数) |
int reply_buffer_resizing_enabled; |
末尾 | 输出缓冲区是否启用动态伸缩(峰值后回落),与 reply_buffer_peak_reset_time 配合 |
unsigned int dbg_assert_flags; |
末尾 | 调试断言钩子 ,DBG_ASSERT_KEYSIZES(断言 keysizes 直方图正确性) 与 DBG_ASSERT_ALLOC_SLOT(断言 per-slot alloc_size)。生产关闭、测试开启 |
int key_memory_histograms; |
Scripting 区 | 是否启用 key 大小直方图(MAX_KEYSIZES_BINS=60 bins × 5 种基本类型)------这是 MEMORY 诊断与热度分析的原料 |
mstime_t cmd_time_snapshot; |
time cache 区 | 一次命令执行期间使用的时间快照,避免同一命令内多次取时间导致"时间不一致"(比如 EXPIRE 的先后 EXPIREAT 判定) |
int accum_call_count_since_ustime; |
time cache 区 | 多久更新一次微秒缓存的计数器 |
4.10 读写这些全局状态的"正确姿势"
想在 Redis 里安全地操作全局状态,有三条不成文的规矩:
- 时间不要随手取。 Redis 把
server.unixtime/server.mstime/server.ustime做成了每 cron 更新的缓存,原因是clock_gettime()在某些系统调用/虚拟化环境下开销不小。正确做法是直接用server.mstime;只有需要更高精度时才用ustime()宏重新采样并调用updateCachedTime()。 - 跨线程的字段必须原子。 观察
redisAtomic出现的位置:stat_net_*(IO 线程写)、shutdown_asap(信号处理)、next_client_id(并发客户端)、aof_bio_fsync_status(BIO 线程写)。如果你新加的字段会被 IO 线程/BIO 线程/信号处理器访问,就必须加这个限定符。 - 修改后要知道谁在缓存它。 例如修改了 LRU/LFU 相关参数,可能已有对象在用旧行为;修改了
zraint/resizing类开关,正在进行的 rehash 可能受影响。Redis 用了大量"只对新建对象生效,存量对象按旧方式处理"的策略来规避这类问题。
5. redisDb:从"一个 dict"到"kvstore + estore + 六个辅助 dict"
上一节讲全局,这一节讲"数据到底放在哪"。完整定义(server.h:1226):
c
typedef struct redisDb {
kvstore *keys; /* The keyspace for this DB. As metadata, holds keysizes histogram */
kvstore *expires; /* Timeout of keys with a timeout set */
estore *subexpires; /* Timeout of sub-keys with a timeout set. (Currently only used for hashes) */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *blocking_keys_unblock_on_nokey; /* Keys with clients waiting for
* data, and should be unblocked if key is deleted (XREADEDGROUP).
* This is a subset of blocking_keys*/
dict *stream_claim_pending_keys; /* Keys with clients waiting to claim pending entries */
dict *stream_idmp_keys; /* Stream keys with IDMP tracking */
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
} redisDb;
5.1 三层数据组织的职责划分
可以把 redisDb 看成"一个主数据平面 + 六个索引/辅助平面":
python
┌────────────────── redisDb ───────────────────┐
│ │
主数据 ───▶ │ kvstore *keys (key → robj/kvobj) │
TTL索引 ───▶ │ kvstore *expires (key → expire time) │
子键TTL ───▶ │ estore *subexpires (hash field → TTL) │
│ │
阻塞(BLPOP)▶ │ dict *blocking_keys │
"键消失也唤醒"▶ dict *blocking_keys_unblock_on_nokey │
Stream claim▶ │ dict *stream_claim_pending_keys │
Stream IDMP ▶ │ dict *stream_idmp_keys │
就绪键 ───▶ │ dict *ready_keys │
WATCH ───▶ │ dict *watched_keys │
└──────────────────────────────────────────────┘
为什么 key 级 TTL 与 sub-key 级 TTL 用完全不同的结构(kvstore *expires vs estore *subexpires)?
因为它们的访问模式完全不同:
- key 级 :一个 key 最多一个 TTL;过期时需要"取一批到期的 key"(按 key 随机/顺序采样)+ "惰性检查某个 key 是否过期"。这是一种"点查为主、采样为辅"的模式 → 哈希表(
kvstore/dict)合适。 - sub-key 级 :一个容器(hash)里可能有成千上万个 字段各自到期;我们需要"高效地找到最早到期的那一批"------这是一个典型的时间有序查询 → 需要按时间分桶的结构(
ebuckets)+ 快速定位非空桶的辅助索引(fwtree)。
estore.h 头部的注释把这一点讲得非常清楚:
markdown
* Index-based expiration store implementation. Similar to kvstore, but built
* on top of ebuckets instead of dict. Items stored in estore must embed an
* ExpireMeta, enabling efficient active-expiration.
注意这一句:"Items stored in estore must embed an ExpireMeta" ------ 也就是说时间信息是被嵌入到条目自身里的(而不是存在一个外部映射里)。这是为了减少一次指针跳转与一次分配,属于典型的"为热路径省一次 cache miss"的做法。
5.2 kvstore 为什么要在 dict 之外再包一层
kvstore.c(1174 行)是 Redis 7.x 时代引入并在 8.x 强化的结构。它的存在理由来自集群模式:
- 在 standalone 模式下,一个 DB 就是一个 keyspace;
- 在 cluster 模式下,一个节点可以拥有任意数量的槽(slot),而很多操作需要"按槽迭代"(
CLUSTER GETKEYSINSLOT、槽迁移、per-slot 内存统计)。
如果在 standalone 下用一个大 dict,在 cluster 下就必须切成 16384 个 dict(那会浪费大量内存),两种模式的代码就得写两份。 kvstore 的解法是:内部维护一组 dict(数量是 2 的幂,由 slot_count_bits 决定),并提供统一的按 hash slot 路由 API。
这一点在 initServer() 里看得很清楚(server.c:3094):
c
int slot_count_bits = 0;
int flags = KVSTORE_ALLOCATE_DICTS_ON_DEMAND;
if (server.cluster_enabled) {
slot_count_bits = CLUSTER_SLOT_MASK_BITS;
flags |= KVSTORE_FREE_EMPTY_DICTS;
}
for (j = 0; j < server.dbnum; j++) {
server.db[j].keys = kvstoreCreate(&kvstoreExType, &dbDictType, slot_count_bits, flags);
server.db[j].expires = kvstoreCreate(&kvstoreBaseType, &dbExpiresDictType, slot_count_bits, flags);
server.db[j].subexpires = estoreCreate(&subexpiresBucketsType, slot_count_bits);
...
}
逐句读:
- standalone 时
slot_count_bits = 0→kvstore内部只有 1 个 dict,几乎零开销; - cluster 时
slot_count_bits = CLUSTER_SLOT_MASK_BITS(14,因为 2^14 = 16384)→ 每个槽一个 dict; KVSTORE_ALLOCATE_DICTS_ON_DEMAND:dict 按需分配,不是一次性全建(因为绝大多数槽是空的);KVSTORE_FREE_EMPTY_DICTS(仅集群):dict 变空后释放掉。这个 flag 不能无条件开启,因为在 standalone 单 dict 模式下"释放唯一的 dict 再重建"会带来无意义的抖动。
这是一段教科书级别的"同一份代码服务两种拓扑"的实现。 顺带 keys 用的是 kvstoreExType(扩展型,带 per-dict 元数据 kvstoreDictMetadata),而 expires 用的是 kvstoreBaseType(基础型)------因为只有 keyspace 需要 per-slot 的内存/CPU/网络统计。
5.3 六个辅助 dict 中藏着的语义差异
blocking_keys 与 blocking_keys_unblock_on_nokey 的关系被注释点明:后者是前者的子集。为什么需要子集?
BLPOP mylist 0阻塞后,如果mylist被删掉、RPUSH一个新 list 进来,客户端应该继续等待(因为"键存在"这个条件满足了)------这类不需要因键消失而唤醒;XREADGROUP阻塞消费者组时,如果 key 消失(比如 TTL 到期或被 DEL),应当唤醒客户端并返回 nil ------ 因为它等待的是某个特定 stream 的新消息,而这个 stream 已不存在。
"同一个'阻塞等待'概念,在不同命令下有截然不同的唤醒 semantics" ------ 这是 Redis 里的常态。用两个 dict + 子集关系表达,比在一个结构里塞 flag 更清晰。
stream_claim_pending_keys 与 stream_idmp_keys 是较新的 Stream 特性相关结构(IDMP = idempotent message processing,幂等消息处理),我们在第 3 卷 Stream 章节详述。这里只需注意:redisDb 的结构正在随 Stream 特性的丰富而膨胀,这也侧面说明 Stream 是 Redis 里第二个"活得很重"的数据结构(第一个是 hash)。
6. 命令表:如何表达命令的一切
6.1 命令从哪来:JSON → commands.def → redisCommand[]
Redis 的命令表是元数据中心化 + 代码生成的产物。三步:
(1)元数据层: src/commands/<command>.json ------ 每个命令一个文件,描述 arity、命令 flag、ACL 类别、key specs、参数结构、@since/@history/@summary 等。本仓库有 459 个 JSON 文件。
(2)生成层: utils/generate-command-code.py,由 src/Makefile 的规则驱动:
makefile
$(COMMANDS_DEF_FILENAME).def: commands/*.json ../utils/generate-command-code.py
$(QUIET_GEN)$(PYTHON) ../utils/generate-command-code.py $(GEN_COMMANDS_FLAGS)
(3)编译层: 生成的 commands.def(本仓库已存在,527 KB,含约 458 条 MAKE_CMD 宏调用)被 #include 进 commands.c,展开成一个静态数组 redisCommandTable[]。
为什么要用 JSON 而不是直接在 C 里写? 因为同一份元数据要被至少四个消费者使用:
- C 编译期的命令表;
- 官方文档站的命令参考页;
- 各种语言客户端的命令元数据自动生成;
redis-cli的自动补全与 help。如果散落在 C 宏里,就得写四个不同的解析器。单一事实来源(single source of truth)+ 代码生成是对的选择。代价是你修改命令操作 qualquer de seus json 时必须记住"这会影响别人的生成器"。
6.2 redisCommand 结构体:命令的一切
c
struct redisCommand {
/* Declarative data */
const char *declared_name;
const char *summary;
const char *complexity;
const char *since;
int doc_flags;
const char *replaced_by;
const char *deprecated_since;
redisCommandGroup group;
commandHistory *history;
int num_history;
const char **tips;
int num_tips;
redisCommandProc *proc; /* Command implementation */
int arity; /* Number of arguments, it is possible to use -N to say >= N */
uint64_t flags; /* Command flags, see CMD_*. */
uint64_t acl_categories;
keySpec *key_specs;
int key_specs_num;
redisGetKeysProc *getkeys_proc;
int num_args;
struct redisCommand *subcommands;
struct redisCommandArg *args;
...
/* Runtime populated data */
long long microseconds, calls, rejected_calls, failed_calls;
long long slowlog_count, slowlog_time_us_sum, slowlog_time_us_max;
int id;
sds fullname;
struct hdr_histogram* latency_histogram;
keySpec legacy_range_key_spec;
dict *subcommands_dict;
struct redisCommand *parent;
struct RedisModuleCommand *module_cmd;
};
这个结构体清晰地区分了声明式数据 (编译期常量,来自 JSON)与运行时数据(累积统计、ID、父命令指针)。几个值得注意的点:
(1)id 是 ACL 的基础。 注释写明:"A connection is able to execute a given command if the user associated to the connection has this command bit set in the bitmap of allowed commands." ------ ACL 用位图 表示"这个用户可以执行哪些命令",而位图的下标就是 id。这意味着:
- ACL 检查是 O(1) 的位测试;
- 命令总数受限于位图宽度(Redis 用动态增长的 bitmap);
- 模块添加的命令也必须拿到 id ,因此模块的
RM_CreateCommand会分配新 id。
这也解释了 8.10.1 那条事务 ACL 修复:EXEC 时重查权限,本质上就是"对每个 multiState.commands[i]->cmd->id 再做一次位测试"------成本低到几乎可以忽略。
(2)microseconds/calls/rejected_calls/failed_calls 就在命令结构体里。 这是 INFO commandstats 的数据来源。设计上很聪明:把计数器放在命令对象里而不是一个单独的哈希表,免除了一次哈希查找。代价是每个命令结构体都变大了几十字节------对于几百个命令,这完全可接受。
注意这里有四个计数,它们的区别很重要:
| 字段 | 含义 |
|---|---|
calls |
成功执行次数 |
rejected_calls |
被拒绝(ACL 拒绝、集群重定向、内存超限等)次数 |
failed_calls |
执行了但失败(如类型错误、参数错误)次数 |
microseconds |
累计执行耗时(用于 usec_per_call 的计算) |
线上排查时,rejected_calls 异常升高往往比 failed_calls 更值得关注:它通常意味着权限/集群/内存层面的系统性问题,而不是用户的用法错误。
(3)latency_histogram 指向 hdr_histogram。 这是 HDR 直方图(来自 deps/hdr_histogram),用于记录命令延迟分布。为什么要直方图而不是平均值?因为平均值会被长尾掩盖------"平均 1ms"可能意味着"99% 的请求 0.5ms,1% 的请求 100ms"。直方图保留了分布的形状,这才是做延迟分析的正确数据结构。
(4)key_specs 与 legacy_range_key_spec 并存。 populateCommandLegacyRangeSpec()(server.c:3363)的注释把这段历史讲得极为清楚:早年用 (first, last, step) 三元组描述"哪些位置是 key",后来发现这不够表达(例如某些 key 是可变位置的),于是引入了新的 key specs 结构。为了兼容 COMMAND GETKEYS 的老输出格式,仍要派生出 legacy 三元组,且这个过程是"尽力而为"的:
"This function uses very basic heuristics and is 'best effort' ... If this functions fails it means that the legacy (first,last,step) spec used by COMMAND will show 0,0,0."
它还列出了粘合规则(仅处理 KSPEC_BS_INDEX + KSPEC_FK_RANGE、keystep 必须为 1、必须连续升序)。这是一处典型的"为了向前兼容而保留的次优实现",阅读时不要误以为它是权威数据。
6.3 命令 flags 与 8.10 新增的 SCRIPT_RUNNER
uint64_t flags 承载了 Redis 命令语义的全部标记(@write/@read/@fast/@admin/@noscript/@stale/@skip-monitor 等)。8.10 新增了 SCRIPT_RUNNER:
bash
- #15337 New `SCRIPT_RUNNER` command flag: flag commands that execute scripts or functions
这个 flag 的作用是标记"此命令会执行脚本/函数" (EVAL、EVALSHA、FCALL、FUNCTION CALL 等)。它是给谁用的?至少有三个消费者:
- 代理/与被监控工具:需要知道这条命令可能长时间主占线程,从而可以做超时或路由决策;
busy_reply_threshold相关逻辑:脚本超时控制;- 命令改建与
SCRIPT子系统的一致性维护。
有意思的是,它与前面讨论的 8.10.1 事务 ACL 修复(#15673)是同一类问题族 :都属于"命令分类信息不充分导致策略无法实施"。这提示了一个 Redis 架构上的持续张力:命令元数据的粒度,决定了上层能做多细的控制。
7. 启动链路:从 main() 的第一行到 Ready to accept connections
这一节把 1.1~1.6 的所有静态结构"动起来"。我们逐阶段走一遍 src/server.c 的 main()(起始于 src/server.c:8051),并解释每一段为什么必须在这个位置。
7.0 一张总图
scss
argv/extreme
┌────────────────────────────────────────────────────────────────────┐
│ Phase 0 测试/工具模式分流(REDIS_TEST / redis-check-* / --version) │
├────────────────────────────────────────────────────────────────────┤
│ Phase 1 进程级初始化:随机数种子、熵、umask、哈希种子、socket\t │
├────────────────────────────────────────────────────────────────────┤
│ Phase 2 静态子系统构造:initServerConfig / ACLInit / module system │
│ / connTypeInitialize / keyMetaInit / initSentinelConfig │
├────────────────────────────────────────────────────────────────────┤
│ Phase 3 配置解析:命令行 → 文件 → stdin(优先级由解析顺序决定) │
├────────────────────────────────────────────────────────────────────┤
│ Phase 4 运行期环境自检:THP、Xen clocksource、ARM64 COW bug │
├────────────────────────────────────────────────────────────────────┤
│ Phase 5 daemonize + 日志首帧(version/commit/pid) │
├────────────────────────────────────────────────────────────────────┤
│ Phase 6 initServer() ------ 建立一切运行期数据结构 │
├────────────────────────────────────────────────────────────────────┤
│ Phase 7 pidfile / proc title / backlog 校验 / cluster init │
├────────────────────────────────────────────────────────────────────┤
│ Phase 8 模块加载(内部模块 + 队列中的外部模块)+ ACL 用户文件 │
├────────────────────────────────────────────────────────────────────┤
│ Phase 9 InitServerLast() ------ bioInit / initThreadedIO / jemalloc bg │
├────────────────────────────────────────────────────────────────────┤
│ Phase 10 initListeners() ------ 真正开始监听端口 │
├────────────────────────────────────────────────────────────────────┤
│ Phase 11 loadDataFromDisk() + AOF 收尾 + 集群数据校验 │
├────────────────────────────────────────────────────────────────────┤
│ Phase 12 CPU 亲和性 / oom_score_adj / systemd READY=1 │
├────────────────────────────────────────────────────────────────────┤
│ Phase 13 aeMain(server.el) ------ 进入事件循环(永不返回,直到 stop) │
└────────────────────────────────────────────────────────────────────┘
7.1 Phase 0:一个二进制,五种身份
c
if (strstr(exec_name,"redis-check-rdb") != NULL)
redis_check_rdb_main(argc,argv,NULL);
else if (strstr(exec_name,"redis-check-aof") != NULL)
redis_check_aof_main(argc,argv);
以及更早的:
c
server.sentinel_mode = checkForSentinelMode(argc,argv, exec_name);
checkForSentinelMode 同时检查 argv 里有没有 --sentinel 以及 argv0 是否叫 redis-sentinel 。这就是 Redis "一个二进制多种身份"的实现原理:通过可执行文件名与 argv 分流。好处是发行包只需要一个 ~10MB 的二进制;代价是启动时必须先把这两件事判断完,才能决定后续走哪条配置解析路径 (sentinel 模式下,initSentinelConfig() 会把默认配置改成 sentinel 的默认值,因为 maintainer 发现让 sentinel 复用普通 Redis 的配置解析更省事,只需改默认值)。
P.S. 文档式的学习点:在 Docker/K8s 里通过软链接或
command: redis-sentinel覆盖 argv0 是可行的,但如果用exec redis-server /etc/sentinel.conf --sentinel,两条命中任意一条也生效。
7.2 Phase 1:熵与安全
c
gettimeofday(&tv,NULL);
srand(time(NULL)^getpid()^tv.tv_usec);
srandom(time(NULL)^getpid()^tv.tv_usec);
init_genrand64(((long long) tv.tv_sec * 1000000 + tv.tv_usec) ^ getpid());
crc64_init();
/* Store umask value. Because umask(2) only offers a set-and-get API we have
* to reset it and restore it back. We do this early to avoid a potential
* race condition with threads that could be creating files or directories. */
umask(server.umask = umask(0777));
uint8_t hashseed[16];
getRandomBytes(hashseed,sizeof(hashseed));
dictSetHashFunctionSeed(hashseed);
三个要点:
- 三套随机数生成器同时初始化 ,因为它们用途不同:
srand()供平台rand(),srandom()是 BSD 版,init_genrand64()是 Redis 自带的 MT19937-64(用于LRANDOMKEY之类需要高质量随机的地方)。 umask的读法 :umask(2)只有"设置并返回旧值"这一种 API,所以要"设一个新值 → 拿到旧值 → 再设回去"。注释特别说明要在线程创建之前做这件事------否则其他线程(此时尚无线程,但 defensive)创建的文件可能用了错误的 umask。这是典型的"先读后原子恢复"模式。- 哈希种子随机化 (
dictSetHashFunctionSeed)是防御哈希碰撞攻击的关键 :如果没有随机种子,攻击者可以构造大量产生相同哈希的 key,把 dict 退化成链表,从而用少量请求把 CPU 打满(HashDoS)。Redis 用 SipHash(src/siphash.c,并非依赖 deps)作为 keyed hash 函数,随机种子在进程启动时生成,永不导出。
运维提醒 :由于种子是随机的,
dict的迭代顺序在不同进程、不同重启之间是不稳定的 (也无法预测)。任何依赖 "SCAN 顺序" 或 "HGETALL 顺序" 的业务代码都是错误的。Redis 官方只对SMEMBERS之外某些类型做了有限的顺序承诺。
7.3 Phase 2:静态子系统的构造顺序不能随意调换
c
initServerConfig();
ACLInit(); /* The ACL subsystem must be initialized ASAP because the
basic networking code and client creation depends on it. */
moduleInitModulesSystem();
connTypeInitialize();
keyMetaInit();
这几行的顺序是被依赖关系锁定的,每一条注释都点明了原因:
initServerConfig()必须先于一切:它把所有配置字段设为默认值。注意此时server.db还是 NULL ------initServerConfig只填标量,不建数据库。ACLInit()必须"尽早"(ASAP):因为创建客户端对象时要立即带上 ACL 用户/选择器,而网络层的初始化会创建一些内部 client(如 fake client)。如果 ACL 晚于网络层,就会出现 NULL 权限上下文。connTypeInitialize():注册 TCP/Unix/TLS 三种ConnectionType,形成一个"连接多态层"。这也是较少见的 Redis 抽象手法(我们在第 2 章会看到ae.c是另一个)。keyMetaInit():8.10 新增,登记 key metadata 类的全局管理。必须在任何 key 被创建之前完成------因为 class id 是要占用 0 号槽位(TTL)的。
这一小段代码其实是一份"启动期依赖拓扑的手写拓扑排序"。 想验证的话:把 ACLInit() 与 moduleInitModulesSystem() 调换,大概率不会崩,但把 initServerConfig() 放到最后一定崩。这种"部分顺序其实是松的"的情况,正是大型 C 项目最难维护的地方之一------它无法被类型系统表达,只能靠注释保护。
7.4 Phase 3:配置的多源合并与优先级
c
/* Precedence wise, File, stdin, explicit options -- last config is the one that matters. */
这句注释是整个配置系统的核心规则:后解析的覆盖先解析的 ,且解析顺序是 config file → stdin → 命令行选项。也就是说命令行显式选项优先级最高。
实现手法很有意思:命令行里的 --port 6380 被转写成一行配置文本 "port 6380\n" 追加到最终的配置解析流末尾:
c
else if (handled_last_config_arg && argv[j][0] == '-' && argv[j][1] == '-') {
/* Option name */
if (sdslen(options)) options = sdscat(options,"\n");
/* argv[j]+2 for removing the preceding `--` */
options = sdscat(options,argv[j]+2);
options = sdscat(options," ");
"把所有输入统一成一种格式再处理" 是编译器前端式的思路(预处理 → 单一 IR → 后端)。好处是只有一套解析器;代价是要小心命令行 shell 已经剥掉引号的情况,所以 Redis 用了 sdssplitargs() 来重新切分:
c
argv_tmp = sdssplitargs(argv[j], &argc_tmp);
if (argc_tmp == 1) { ... }
紧接着是一组"历史兼容补丁",注释里保留了 PR 号(#10660,7.0.1):
c
if ((j != argc-1) && argv[j+1][0] == '-' && argv[j+1][1] == '-' &&
!strcasecmp(argv[j], "--save"))
{
/* Special case: handle some things like `--save --config value`.
* In this case, if next argument starts with `--`, we will reset
* handled_last_config_arg flag and append an empty "" config value
* to the options, so it will become `--save "" --config value`.
* We are doing it to be compatible with pre 7.0 behavior (which we
* break it in #10660, 7.0.1), since there might be users who generate
* a command line from an array and when it's empty that's what they produce. */
options = sdscat(options, "\"\"");
handled_last_config_arg = 1;
}
这段在解决什么?用户用脚本生成命令行 redis-server --save $SAVE_OPTS --other val,当 $SAVE_OPTS 为空时,命令行变成 --save --other val。如果不特殊处理,--other 会被当成 --save 的值,配置就错了。Redis 选择插入一个空字符串 "" 来保持语义正确。
这类代码看起来"脏",但它是真实世界兼容性的代价。本系列的态度是:理解它为什么存在,而不是嘲笑它。 顺便说一句,--sentinel 也有几乎一样的处理逻辑(因为 sentinel 是一个"无值"的伪配置项)。
7.5 Phase 4:系统自检------把"环境问题"挡在服务之前
c
#ifdef __linux__
linuxMemoryWarnings();
sds err_msg = NULL;
if (checkXenClocksource(&err_msg) < 0) {
serverLog(LL_WARNING, "WARNING %s", err_msg);
sdsfree(err_msg);
}
#if defined (__arm64__)
int ret;
if ((ret = checkLinuxMadvFreeForkBug(&err_msg)) <= 0) {
...
if (!checkIgnoreWarning("ARM64-COW-BUG")) {
serverLog(LL_WARNING,"Redis will now exit to prevent data corruption. "
"Note that it is possible to suppress this warning by setting the following config: ignore-warnings ARM64-COW-BUG");
exit(1);
}
}
#endif /* __arm64__ */
#endif /* __linux__ */
syscheck.c(通常通过 redis-server --check-system 单独调用,见 §7.1 提到的 Option)是一个非常值得学习的小子系统:它把历史上踩过的、难以复现的环境坑固化成启动时的一次检查。
checkXenClocksource:Xen 虚拟化环境下某些 clocksource(如 xen)会导致时钟跳跃/回退,而 Redis 大量依赖单调时间,时钟异常会导致过期时间/阻塞超时行为错误。checkLinuxMadvFreeForkBug(ARM64) :在部分 ARM64 内核上,MADV_FREE的内存页在 fork 之后的行为有 bug,可能导致子进程的 RDB 保存过程中读到被修改的数据 ------这是数据损坏级别的问题!所以 Redis 在检测到该 bug 时直接 exit(1) ,除非用户显式ignore-warnings ARM64-COW-BUG。
注意这里的设计取舍:默认情况下宁可拒绝启动也不冒数据损坏的风险。这是一个正确的默认值------因为数据损坏往往在数周后才被发现,而无法启动是立刻可见的。
7.6 Phase 5/6:daemonize 与 initServer()
c
if (background) daemonize();
serverLog(LL_NOTICE, "oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo");
serverLog(LL_NOTICE,
"Redis version=%s, bits=%d, commit=%s, modified=%d, pid=%d, just started",
REDIS_VERSION, (sizeof(long) == 8) ? 64 : 32,
redisGitSHA1(), strtol(redisGitDirty(),NULL,10) > 0, (int)getpid());
注意日志顺序 :daemonize() 之后才打第一行日志。如果在此之前出任何错,错误会直接打到终端(这正是运维希望的:早期失败要有最直接的反馈)。
initServer()(src/server.c:2999)是本节的重头戏。完整走一遍它的关键动作:
c
void initServer(void) {
int j;
signal(SIGHUP, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
setupSignalHandlers();
ThreadsManager_init();
makeThreadKillable();
...
- 忽略
SIGPIPE:这是网络编程的基本礼节。如果对端关闭了连接而你继续 write,内核会发 SIGPIPE 默认杀死进程。一个缓存数据库因为一个客户端断开而退出,是不可接受的。 所有 Redis 的 write 路径都必须检查返回值(connWrite返回 -1)而不是依赖信号。 ThreadsManager_init()/makeThreadKillable():8.x 引入的线程管理设施,允许模块创建的线程被"注册",从而在需要时能被安全地暂停/终止(配合RM_Yield与 busy module 的处理)。
c
server.aof_state = server.aof_enabled ? AOF_ON : AOF_OFF;
server.fsynced_reploff = server.aof_enabled ? 0 : -1;
server.hz = server.config_hz;
server.pid = getpid();
server.in_fork_child = CHILD_TYPE_NONE;
...
注意 server.fsynced_reploff 的三态语义:0 表示"AOF 已开启且尚无数据落盘",-1 表示"AOF 关闭、该值无意义" 。用一个哨兵值而不是额外 bool,是 C 项目里常见的省空间写法,但阅读时需要留意 -1 意味着"无此概念"。
c
if (setlocale(LC_COLLATE,server.locale_collate) == NULL) {
serverLog(LL_WARNING, "Failed to configure LOCALE for invalid locale name.");
exit(1);
}
hashTemplatesInit();
createSharedObjects();
adjustOpenFilesLimit();
const char *clk_msg = monotonicInit();
serverLog(LL_NOTICE, "monotonic clock: %s", clk_msg);
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
setlocale(LC_COLLATE, ...)失败直接退出 :因为SORT命令的字符串比较依赖 collation,如果 locale 无效,比较结果不确定会导致数据顺序错误(进而在 zset 语义下影响更多)。这是"快速失败"原则的体现。hashTemplatesInit():8.10 新增,初始化 compact hash 的全局模板注册表。createSharedObjects():创建那些被无数命令复用的"共享小对象"(shared.ok、shared.crlf、小整数对象 0~9999 等)。这是一个纯粹的内存优化:同样的字符串 reply 不再每次分配 。它依赖引用计数(robj.refcount),也正因为如此,clients_with_pending_ref_reply链表才需要存在(§4.4)。adjustOpenFilesLimit():尽量把 RLIMIT_NOFILE 提到最大值,因为maxclients受其限制。这是 Redis 里少数"主动改自己资源限制"的地方。monotonicInit():初始化单调时钟抽象。注意它会返回一个字符串描述实际使用了哪种时钟源,并打到日志里------这条日志在排查"为什么延时忽大忽小"时价值极高 (某些云主机的clock_gettime可能走 syscall 而非 vDSO,导致性能退化)。aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR):事件循环的容量就是maxclients加上一些预留 fd(用于监听 socket、管道等)。这是一个隐含约束:事件循环是静态大小的结构 (aeEventLoop里有按最大 fd 数预先分配的两个数组events/fired),所以 fd 数不能超过这个上限。
c
server.db = zmalloc(sizeof(redisDb)*server.dbnum);
int slot_count_bits = 0;
int flags = KVSTORE_ALLOCATE_DICTS_ON_DEMAND;
if (server.cluster_enabled) { slot_count_bits = CLUSTER_SLOT_MASK_BITS; flags |= KVSTORE_FREE_EMPTY_DICTS; }
for (j = 0; j < server.dbnum; j++) { ... }
evictionPoolAlloc(); /* Initialize the LRU keys pool. */
这段我们在 §5.2 已经逐句分析过。这里补充一点:evictionPoolAlloc() 预留了 16 个槽的淘汰候选池 (EVPOOL_SIZE),说明 maxmemory 淘汰是"采样 + 池内最优",而不是全量扫描。这是它与"理想 LRU"之间的另一处近似。
7.7 Phase 7~10:监听、模块、后台线程
c
initServer();
if (background || server.pidfile) createPidFile();
if (server.set_proc_title) redisSetProcTitle(NULL);
redisAsciiArt();
checkTcpBacklogSettings();
if (server.cluster_enabled) {
/* clusterCommonInit() initializes slot-stats required by clusterInit() */
clusterCommonInit();
clusterInit();
}
if (!server.sentinel_mode) {
moduleInitModulesSystemLast();
moduleLoadInternalModules();
moduleLoadFromQueue();
}
ACLLoadUsersAtStartup();
initListeners();
if (server.cluster_enabled) {
clusterInitLast();
}
InitServerLast();
顺序里的三个隐含依赖:
clusterCommonInit()必须先于clusterInit():注释明说前者会初始化后者所需的 slot-stats。这是"两个 init 函数之间存在数据依赖"的显式声明------如果把顺序写反,后续就会踩到未初始化的 slot-stats。moduleLoadFromQueue()在ACLLoadUsersAtStartup()之前:因为模块可能会注册自定义 ACL 类别(category),而 ACL 用户文件里可能引用这些类别。先后顺序错了就会出现"未知 category"错误。initListeners()必须在最后:一旦开始监听,就可能收到连接。所有准备工作必须在监听之前完成,否则一个请求进来时可能发现某个子系统还没就绪。
c
void InitServerLast(void) {
bioInit();
initThreadedIO();
set_jemalloc_bg_thread(server.jemalloc_bg_thread);
server.initial_memory_usage = zmalloc_used_memory();
}
InitServerLast() 只有四行,但每一行都是"启动了某个后台线程"的副作用:
bioInit():启动 BIO(Background I/O)线程,负责close()、AOFfsync、惰性释放这些"不该阻塞主线程"的操作;initThreadedIO():启动 IO 多线程(如果io-threads > 1);set_jemalloc_bg_thread():开启 jemalloc 的后台垃圾回收线程(用于把脏页归还 OS,降低 RSS);- 最后记录基线内存------
INFO memory里的used_memory_startup就是这个数字。
为什么要有单独的 InitServerLast()? 因为这些线程会立刻开始访问 server 结构,必须等所有数据结构都构造完。把"启动后台线程"集中到一个函数里,比散落在 initServer 各处更容易保证正确性。
7.8 Phase 11:加载数据
c
serverLog(LL_NOTICE,"Server initialized");
aofLoadManifestFromDisk();
loadDataFromDisk();
/* Make the on-disk AOF match the preloaded in-memory dataset so
* subsequent writes are appended to the correct local INCR. */
aofSetupAfterPreloadFile();
aofOpenIfNeededOnServerStart();
aofDelHistoryFiles();
applyAppendOnlyConfig();
if (server.cluster_enabled) {
serverAssert(verifyClusterConfigWithData() == C_OK);
}
aofLoadManifestFromDisk()必须在loadDataFromDisk()之前:MP-AOF 的 manifest 文件记录了 BASE/INCR 文件的列表与顺序,必须先读 manifest 才知道该加载哪些文件。aofSetupAfterPreloadFile():把磁盘上的 AOF 状态与刚加载完的内存数据对齐,确保后续写入追加到正确的 INCR 文件。(注释很直白。)verifyClusterConfigWithData():集群模式下校验"配置的槽所有权"与"实际数据里存在的槽"是否一致。如果不一致直接 assert 失败------这是防止"带着错误数据进入服务状态"的最后一道闸门。
注意这里加载数据是在监听之前还是之后 ?答案是:先 initListeners()(Phase 10),后 loadDataFromDisk()(Phase 11) 。也就是说端口已经打开了,但还不能服务(因为在加载)。这会造成一个现象:在数据加载期间,客户端可以完成 TCP 三次握手,但会一直阻塞在读取命令的响应上 。这正是你在 INFO 里看到 loading:1 时客户端 hang 住的原因。如果需要"加载期间拒绝连接",通常要在前置代理层处理。
7.9 Phase 13:进入事件循环,然后永不返回
c
aeMain(server.el);
aeDeleteEventLoop(server.el);
return 0;
aeMain(src/ae.c:497)的结构简单到几乎是"正确就应该长这样":
c
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS|
AE_CALL_BEFORE_SLEEP|
AE_CALL_AFTER_SLEEP);
}
}
一次事件循环 = 一次 aeProcessEvents(),它内部大致:
scss
┌──────────── aeProcessEvents 的一次迭代 ────────────┐
│ 1. 计算距离最近的时间事件还有多久 → 作为 poll 超时 │
│ 2. beforeSleep() ← 收尾上一轮的写/异步 │
│ 3. aeApiPoll() ← epoll_wait/kqueue 阻塞│
│ 4. afterSleep() ← 唤醒后的即时处理 │
│ 5. 处理文件事件(读/写) │
│ 6. 处理时间事件(serverCron 的各种后台任务) │
└────────────────────────────────────────────────────┘
第 2 章会对这里做逐行的详解。这里只需要建立一个直觉:Redis 的"每轮事件循环"是一个包含若干阶段的批处理流水线 ,很多"应该在命令执行后立刻做的事"其实被推迟到了 beforeSleep() 里批量做------这是 Redis 高性能的关键手法之一(把 N 次系统调用合并成 1 次、把随机写变成批处理)。
以 beforeSleep()(src/server.c:1947)为例,它的执行顺序被注释严格保护,我们摘最有代表性的几处:
c
void beforeSleep(struct aeEventLoop *eventLoop) {
UNUSED(eventLoop);
updatePeakMemory();
if (ProcessingEventsWhileBlocked) {
/* Just call a subset of vital functions in case we are re-entering
* the event loop from processEventsWhileBlocked(). */
...
return;
}
/* Handle pending data(typical TLS). (must be done before flushAppendOnlyFile) */
connTypeProcessPendingData(server.el);
/* Drain any buffered decompressed replication data (see client_comp.c). */
clientCompressionProcessPendingData(server.el);
int dont_sleep = connTypeHasPendingData(server.el) ||
clientCompressionHasPendingData(server.el);
三处呼应前面的分析:
ProcessingEventsWhileBlocked分支 :当主线程因为某个命令(如 Lua 脚本超时后 Redis 内部的processEventsWhileBlocked()调用)重入事件循环时,只做"保命子集",避免重入造成的递归复杂性。这是 Redis 处理"阻塞中仍需响应一小部分命令"的方式。connTypeProcessPendingData必须在flushAppendOnlyFile之前 :注释 (must be done before flushAppendOnlyFile) 明写------因为 TLS 层可能还有未解密完的数据,这些数据会触发写命令,进而产生 AOF 记录;如果顺序反了,本轮的 AOF 就会丢内容。clientCompressionProcessPendingData:这就是 §1.3 讲的"解压器内部缓冲"排空,与 TLS pending data 处于同一处理模式中 。二者谁的说法武装到牙齿(8.10.1 的 UAF 修复)说明这个模式容易出错。
再看后半段(注意注释里大量的 "must be done before X" 约束):
c
if (server.io_threads_num > 1) {
if (processClientsOfAllIOThreads() > 0) {
/* If there are clients that are processed, it means IO thread is busy to
* trafer clients to main thread, so the main thread does not sleep. */
dont_sleep = 1;
}
if (!dont_sleep) {
atomicSetWithSync(server.running, 0); /* Not running if going to sleep. */
/* Try to process the clients from IO threads again, since before setting running
* to 0, some clients may be transferred without notification. */
processClientsOfAllIOThreads();
}
}
...
/* Before we are going to sleep, let the threads access the dataset by
* releasing the GIL. Redis main thread will not touch anything at this
* time. */
if (moduleCount()) moduleReleaseGIL();
/********************* WARNING ********************
* Do NOT add anything below moduleReleaseGIL !!! *
***************************** ********************/
}
这段揭示了 IO 线程与主线程之间的"三次握手式"交接协议:
dont_sleep标志决定是否在下一次aeApiPoll()里真正阻塞(如果还有活儿要干就不睡);server.running是一个原子标志,用来告诉 IO 线程"主线程要睡了,你可以不用通知我"--- ㅡ 而注释指出在把它置 0 之后必须再试一次processClientsOfAllIOThreads(),因为可能存在"设置 running=0 之前的最后一刻刚被交接过来的客户端",否则它们会被漏掉一整个睡眠周期。这是一个典型的竞态窗口补丁;- 最后释放 GIL 让模块线程访问数据,并且用注释块明确禁止在它之后再加任何代码。这种"用醒目的文字划定禁区"的做法,在无法用类型系统表达约束的 C 项目里是必要的。
7.10 一个"反推练习":如果我要加一个新子系统,应该改哪几处?
把上面所有观察汇总,假设你要给 Redis 加一个新子系统(例如一个"二级索引管理层"),从本章学到的模式可以推出必须触碰的点:
| 步骤 | 位置 | 依据 |
|---|---|---|
| 1 | src/<新>.c / .h |
独立文件,自包含 |
| 2 | server.h 的 struct redisServer 合适分区内加字段 |
§4 |
| 3 | 若跨线程访问 → 用 redisAtomic 限定符 |
§4.10 |
| 4 | initServerConfig() 里给配置默认值 |
Phase 2 |
| 5 | config.c 里用 createXxxConfig() 注册 |
Phase 3 的配置系统 |
| 6 | initServer() 里构造数据结构 |
Phase 6 |
| 7 | 若有后台线程 → InitServerLast() |
Phase 9 |
| 8 | 若需每轮回调 → beforeSleep() 的合适位置(遵守其顺序约束!) |
Phase 13 |
| 9 | 若需定期任务 → serverCron() |
第 2 章 |
| 10 | 若需持久化 → rdbSaveXXX/rdbLoadXXX + 注册 opcode |
第 4 卷 |
| 11 | 若需暴露给用户 → 新命令 + commands/<cmd>.json |
§6 |
| 12 | 统计量 → INFO 节 + stat_* 字段 |
§4.5 |
| 13 | 单元测试 → #ifdef REDIS_TEST + redisTests[] |
§8 |
这张表就是本章的实用输出。 它比记住任何单个字段都重要。
8. 如何验证本系列的每一个论断
前文反复强调"可回溯"。这一节给出具体工具。
8.1 构建与二期验证
bash
# 1) 标准构建(Linux 自动 jemalloc)
make -j$(nproc)
# 2) 确认二进制的生成属性
./src/redis-server --version
# Redis server v=8.10.3 sha=xxxx:0 malloc=jemalloc-5.3.0 bits=64 build=xxxx
# 3) 只看 WARN级别的启动自检输出
./src/redis-server --check-system ; echo $? # 0 = OK
# 4) 内存骚扰测试(上线前/换机型后 recommend)
./src/redis-server --test-memory 1024
第 2) 条里的 malloc=jemalloc-5.3.0 是最重要的一个字段:它确认了你的二进制真的用了 jemalloc,而不是 libc malloc。如果你在生产上发现内存碎片率异常高,第一件事就是看这个字段。
8.2 单元测试(REDIS_TEST 模式)
bash
./src/redis-server test all # 全部
./src/redis-server test dict # 单个子系统
./src/redis-server test zipmap --accurate # 更高强度
./src/redis-server test fwtree # 8.10 新增的 Fenwick tree
./src/redis-server test reply_schema_linter # 回复 schema 校验(LOG_REQ_RES 相关)
支持的 flag:
| flag | 作用 |
|---|---|
--accurate |
增大迭代次数与采样规模,跑得更慢更严格 |
--large-memory |
允许测试申请大量内存(如 ziplist 的极端长度) |
--valgrind |
在 Valgrind 下跑(会调整断言行为) |
--verbose |
打印每个子测试项 |
本系列每张 corruption-avoidance 的数据结构章节,都会给出对应的 test 子命令。 例如讲 dict 渐进式 rehash 时,会建议你跑 test dict 并观察 rehash 期间的迭代器正确性断言。
8.3 集成测试(TCL)
bash
./runtest # 全部
./runtest --single unit/expire # 单文件
./runtest --single unit/expire --accurate # 高强度
./runtest --clients 8 --verbose # 指定并行数
./runtest-cluster / runtest-sentinel # 集群/哨兵专项
tests/ 下按 unit/、integration/、cluster/、sentinel/、moduleapi/ 等分组。测试框架本身在 tests/test_helper.tcl、tests/support/util.tcl。
写 TCL 集成测试的一个技巧 :wait_for_condition 与 assert_encoding 等 helper 让你能断言内部状态(比如某个 key 当前是什么 encoding)。本系列在讲每种编码转换阈值时,会引用这些 helper 的实际用法。
8.4 调试器与性能剖析
bash
# 断点在命令入口:这是最常用的一招
(gdb) b call
(gdb) b lookupCommand
(gdb) b serverAssert
# 观察一条命令的完整生命周期
(gdb) b readQueryFromClient
(gdb) b processCommand
(gdb) b addReply
# 打印 server 全局状态
(gdb) p server.mstime
(gdb) p server.stat_numcommands
(gdb) p *server.db[0].keys
几个特别有用的断点与技巧:
processCommand(src/networking.c/server.c)是观察"命令被执行前的所有检查"的最佳单点:ACL、集群重定向、内存超限、Lua busy、cluster down 等判断都在这里或其调用的下游。call()与c->cmd->proc之间是所有统计、传播、monitors、slowlog 的夹层。想知道"为什么这条命令被复制到了从库但没写 AOF",看这里。DEBUG命令族 (src/debug.c,2969 行)提供了大量内部操作:DEBUG JMAP(查看 jemalloc 状态)、DEBUG SLEEP(模拟慢查询)、DEBUG SET-ACTIVE-EXPIRE、DEBUG QUICKLIST-PACKED-THRESHOLD、DEBUG STRINGMATCH-LEN等。
性能剖析建议组合:perf record -g + perf script | stackcollapse-perf.pl | flamegraph.pl,以及 Redis 自带的 LATENCY DOCTOR 与 latency-monitor-threshold 配置。
8.5 版本对照验证
本系列很多论断是"8.10 相对旧版变了"。要验证这些,最有效的方法是:
bash
# 在另一个目录 clone 一个对照版本(如 7.4),diff 同名文件
diff -u ../redis-7.4/src/db.c src/db.c | less
diff -u ../redis-7.4/src/server.h src/server.h | head -200
diff 是读懂 Redis 演进的最快途径,因为它把"改了什么"从"全部代码"里剥离了出来。
9. 附录
9.1 redisServer 分区速查(按行号)
| 行号范围 | 分区 |
|---|---|
| 2042--2083 | General(进程身份、命令表、event loop、关停标志) |
| 2084--2093 | Modules |
| 2094--2118 | Networking(端口、客户端链表、pause) |
| 2119--2158 | 流水/计算`` explanations(IO 线程、prefetch、protected configs) |
| 2160--2168 | RDB/AOF loading |
| 2169--2266 | Stats(含 instant metric 与其涵盖调度 duration_stats) |
| 2267--2269 | Hotkeys |
| 2270--2313 | Configuration |
| 2314--2352 | AOF persistence |
| 2353--2364 | Backup(MP-AOF based) |
| 2366--2400 | RDB persistence |
| 2401--2406 | child→parent pipe / propagation |
| 2407--2420 | Logging / Shutdown |
| 2422--2502 | Replication(master + slave) |
| 2503--2509 | Synchronous replication(WAIT/WAITAOF) |
| 2510--2523 | Limits |
| 2524--2539 | Blocked clients / client-side caching / sort |
| 2540--2592 | 紧凑编码配置 + hash template + stream/array 参数 |
| 2593--2603 | time cache |
| 2604--2611 | Pubsub |
| 2612--2651 | Cluster(含 ASM) |
| 2652--2658 | Scripting |
| 2659--2664 | Lazy free |
| 2665--2675 | Latency / ACL |
| 2676--2708 | Watchdog / hardware / TLS / cpu affinity / failover / reply buffer / locale |
9.2 常用 make 变量速查
| 变量 | 说明 |
|---|---|
| `MALLOC=jemalloc | libc |
| `SANITIZER=address | undefined |
| `BUILD_TLS=yes | module` |
USE_SYSTEMD=yes |
systemd notify |
OPTIMIZATION |
默认 -O3;调试常用 -O0 |
BUILD_COMPRESSION |
复制/客户端压缩(8.10 新) |
SKIP_VEC_SETS |
跳过 vector sets 编译 |
LOG_REQ_RES |
记录请求/响应(需加入 REDIS_CFLAGS) |
DEBUG_DEFRAG=force |
强制走碎片整理路径 |
C11_ATOMIC |
使用 C11 <stdatomic.h> |
9.3 本章涉及的文件清单
| 文件 | 本章用途 |
|---|---|
src/version.h |
版本号与编码 |
00-RELEASENOTES |
版本史、安全修复、新特性 |
src/server.h:1226 |
redisDb |
src/server.h:2041--2708 |
redisServer |
src/server.h:3058--3110 |
redisCommand |
src/server.h:75--90(object.h) |
OBJ_ENCODING_* |
src/server.c:8051+ |
main() |
src/server.c:2999+ |
initServer() |
src/server.c:3326 |
InitServerLast() |
src/server.c:1947+ |
beforeSleep() |
src/ae.c:497 |
aeMain() |
src/client_comp.h |
复制压缩 API |
src/estore.h / keymeta.h / mstr.h / entry.h / sparsearray.h / fwtree.h |
8.10 新增子系统设计注释 |
modules/modules.yaml |
模块清单 |
Makefile / src/Makefile |
构建系统 |
10. 本章小结与下一章预告
本章做了五件事:
- 确立了事实基线 :给出了本仓库的精确版本(
8.10.3/0x00080a03)、许可证形态(RSALv2/SSPLv1/AGPLv3 三选一,部分 Valkey 代码 BSD-3),以及五个 sparkling 的安全修复所暴露的系统性问题------它们是后续所有章节的"反面教材"。 - 给出了源码地图 :按层次 (基础设施 → 数据结构 → 对象/存储引擎 → 网络/分发 → 持久化/复制 → 集群/HA → 脚本/扩展 → 工具)而非字母序组织,并附上了每个主文件的行数与职责。特别指出了三个"复杂度信号":
module.c16084 行(扩展 ABI 才是真正的内核)、redis-cli.c11533 行(客户端教科书)、t_hash.c6613 行(本版本的焦点类型)。 - 拆解了构建体系 :从顶层 Makefile 的
export MAKE陷阱,到src/Makefile的开关矩阵(MALLOC平台抬升、Sanitizer 与 jemalloc 互斥、命令表代码生成),再到清单驱动的模块构建(modules.yaml的 tag/branch/commit 三级解析、为去依赖而限制 YAML 语法、ARM64 LSE 的 SIGILL 规避)。 - 通读了全局状态 :
redisServer的 31 个分区、668 行;重点分析了 General/Modules/Networking/Stats/Replication/Hash-Template/Cluster 七区,提炼出"每处新增字段都对应一个新特性"这一阅读方法(特别是subexpires、asm_*、hash_*template*、stat_net_repl_*四组)。 - 走完了启动链路 :从
main()的 Phase 0 到 Phase 13,解释了其中至少八处"顺序不可调换"的依赖关系,并在最后给出了一张"给 Redis 加新子系统要改哪些地方"的实用清单。
如果只允许记住一句话,那就是:Redis 8.10 不是 7.x 的增量演进,而是一次"对象层与索引层的重构"------kvstore/estore/keymeta/mstr/entry 这一组新设施,是为了支撑 compact hash 与字段级过期而存在的。
下一章(第 2 章:事件循环与多路复用抽象层)我们将第一次真正进入热路径,逐行拆读:
src/ae.c的aeProcessEvents()内部到底按什么顺序处理时间事件与文件事件;ae_epoll.c/ae_kqueue.c/ae_evport.c/ae_select.c四份后端如何通过同一套aeApiState风格函数指针实现多态,以及 8.10 GA 修掉的 select 后端 bug(#15446)到底错在哪;serverCron()如何把"过期、扩容、timeout、迁移、统计采样"这些互不相关的任务塞进一个每毫秒级的函数里,以及hz与dynamic_hz的实际调节效果;beforeSleep()/afterSleep()的完整职责清单与顺序依赖关系(本章只做概览);processEventsWhileBlocked()的重入模型,以及它与 IO 线程、阻塞命令、模块 GIL 的交互。