《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路

《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路

本章目标 本章是整个系列的"地图"与"坐标系"。它不急着钻进某一个子系统,而是先把 `` 这棵源码树讲成一张可以随手翻阅的地图:版本从哪来、make 之后到底发生了什么、一个 Redis 进程的内存里到底有哪些全局状态、从一个 .c 文件的 main() 到可以接受连接的服务器之间究竟走过了哪些必经之路。

读完本章你应该能够回答:

  1. Redis 8.10.3 到底是一个"什么"------它在 Redis 自身产品线里处于什么位置,许可证意味着什么;
  2. 面对 130+ 个源文件、20 万行代码,应该按什么顺序读、哪些文件是"主心骨"、哪些是"枝叶";
  3. redisServer 这 668 行结构体如何分区,为什么这么分,哪些字段是 8.10 时代新引入的;
  4. 一次进程启动的完整调用序列,以及其中每一步"为什么必须在这个位置";
  5. 如何在本机上验证本系列后续每一章的论断(编译开关、单元测试、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/SPUBLISH message 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-cluster is 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):

  1. 任何外部输入长度,用 64 位接收,禁止在中途截断为 32 位;
  2. 任何外部整数,作为数组下标前必须先做范围检查(slot id、level、count);
  3. 任何"延迟执行"路径,执行前必须重新做一次权限校验;
  4. 任何引入了后台线程的数据结构,必须重新审视每个字段的生命周期;
  5. 任何来自 X.509 / DNS / 网络的"字符串",当作 C 字符串使用前必须验证无内嵌 NUL 且 strlen == 声明长度;
  6. 有符号函数的返回值,在作为无符号计数使用前必须显式判负。

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 必须:

  1. 有一个地方登记"schema"(字段名集合)并去重 → 需要新的元数据注册与管理设施;
  2. 有办法在 key 上挂着"我用了哪个 schema" → 这就是 keymeta.c 登场的理由(每个 kvobj 有 8 个 metabit,可挂 8 类元数据,见 keymeta.h);
  3. 有办法把多个逻辑上独立的数据塞进一次分配里 → 这就是 mstr.c(带元数据的不可变字符串)与 entry.c(把 field+value+optional TTL 打包成一个块)登场的理由;
  4. 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(if (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 行。它由两部分构成:

  1. 数据字段:真正的运行时状态;
  2. 注释分区 :形如 /* 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 的功能规格书:

  1. 存在"模板(template)"这个全局注册表 (htemplates)------即"字段名集合"的共享池;
  2. 转换有两条路径:在线写入路径(HSET 等)与 RDB 加载路径,各自有独立阈值;这说明两个场景的最优策略不同:在线路径要避免"频繁创建模板"的开销,而 RDB 加载是一次性批处理,可以更激进;
  3. 有上界也有下界 :hash_max_template_entries 说明"字段太多的 hash 不适合模板化"(因为共享字段名的收益被摊薄,而管理成本上升);
  4. 有反向操作 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 里安全地操作全局状态,有三条不成文的规矩:

  1. 时间不要随手取。 Redis 把 server.unixtime / server.mstime / server.ustime 做成了每 cron 更新的缓存,原因是 clock_gettime() 在某些系统调用/虚拟化环境下开销不小。正确做法是直接用 server.mstime;只有需要更高精度时才用 ustime() 宏重新采样并调用 updateCachedTime()。
  2. 跨线程的字段必须原子。 观察 redisAtomic 出现的位置:stat_net_*(IO 线程写)、shutdown_asap(信号处理)、next_client_id(并发客户端)、aof_bio_fsync_status(BIO 线程写)。如果你新加的字段会被 IO 线程/BIO 线程/信号处理器访问,就必须加这个限定符。
  3. 修改后要知道谁在缓存它。 例如修改了 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 里写? 因为同一份元数据要被至少四个消费者使用:

  1. C 编译期的命令表;
  2. 官方文档站的命令参考页;
  3. 各种语言客户端的命令元数据自动生成;
  4. 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 等)。它是给谁用的?至少有三个消费者:

  1. 代理/与被监控工具:需要知道这条命令可能长时间主占线程,从而可以做超时或路由决策;
  2. busy_reply_threshold 相关逻辑:脚本超时控制;
  3. 命令改建与 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);

三个要点:

  1. 三套随机数生成器同时初始化 ,因为它们用途不同:srand() 供平台 rand(),srandom() 是 BSD 版,init_genrand64() 是 Redis 自带的 MT19937-64(用于 LRANDOMKEY 之类需要高质量随机的地方)。
  2. umask 的读法 :umask(2) 只有"设置并返回旧值"这一种 API,所以要"设一个新值 → 拿到旧值 → 再设回去"。注释特别说明要在线程创建之前做这件事------否则其他线程(此时尚无线程,但 defensive)创建的文件可能用了错误的 umask。这是典型的"先读后原子恢复"模式。
  3. 哈希种子随机化 (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();

顺序里的三个隐含依赖:

  1. clusterCommonInit() 必须先于 clusterInit():注释明说前者会初始化后者所需的 slot-stats。这是"两个 init 函数之间存在数据依赖"的显式声明------如果把顺序写反,后续就会踩到未初始化的 slot-stats。
  2. moduleLoadFromQueue() 在 ACLLoadUsersAtStartup() 之前:因为模块可能会注册自定义 ACL 类别(category),而 ACL 用户文件里可能引用这些类别。先后顺序错了就会出现"未知 category"错误。
  3. 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()、AOF fsync、惰性释放这些"不该阻塞主线程"的操作;
  • 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);

三处呼应前面的分析:

  1. ProcessingEventsWhileBlocked 分支 :当主线程因为某个命令(如 Lua 脚本超时后 Redis 内部的 processEventsWhileBlocked() 调用)重入事件循环时,只做"保命子集",避免重入造成的递归复杂性。这是 Redis 处理"阻塞中仍需响应一小部分命令"的方式。
  2. connTypeProcessPendingData 必须在 flushAppendOnlyFile 之前 :注释 (must be done before flushAppendOnlyFile) 明写------因为 TLS 层可能还有未解密完的数据,这些数据会触发写命令,进而产生 AOF 记录;如果顺序反了,本轮的 AOF 就会丢内容。
  3. 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. 本章小结与下一章预告

本章做了五件事:

  1. 确立了事实基线 :给出了本仓库的精确版本(8.10.3 / 0x00080a03)、许可证形态(RSALv2/SSPLv1/AGPLv3 三选一,部分 Valkey 代码 BSD-3),以及五个 sparkling 的安全修复所暴露的系统性问题------它们是后续所有章节的"反面教材"。
  2. 给出了源码地图 :按层次 (基础设施 → 数据结构 → 对象/存储引擎 → 网络/分发 → 持久化/复制 → 集群/HA → 脚本/扩展 → 工具)而非字母序组织,并附上了每个主文件的行数与职责。特别指出了三个"复杂度信号":module.c 16084 行(扩展 ABI 才是真正的内核)、redis-cli.c 11533 行(客户端教科书)、t_hash.c 6613 行(本版本的焦点类型)。
  3. 拆解了构建体系 :从顶层 Makefile 的 export MAKE 陷阱,到 src/Makefile 的开关矩阵(MALLOC 平台抬升、Sanitizer 与 jemalloc 互斥、命令表代码生成),再到清单驱动的模块构建(modules.yaml 的 tag/branch/commit 三级解析、为去依赖而限制 YAML 语法、ARM64 LSE 的 SIGILL 规避)。
  4. 通读了全局状态 :redisServer 的 31 个分区、668 行;重点分析了 General/Modules/Networking/Stats/Replication/Hash-Template/Cluster 七区,提炼出"每处新增字段都对应一个新特性"这一阅读方法(特别是 subexpires、asm_*、hash_*template*、stat_net_repl_* 四组)。
  5. 走完了启动链路 :从 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 的交互。
相关推荐
用户400315043351 小时前
《从 skynet.timeout 到回调执行:Skynet 分层时间轮源码剖析》
后端
一条Go1 小时前
四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列
后端·设计模式·架构
70后写java的老码农1 小时前
docker-compose 部署 Redis Stack Server 6.2.6-v19
redis·docker
用户1996143559961 小时前
插件是怎么被安全装进系统的
后端
愚农搬码2 小时前
自研表单引擎、开源组件二开和商业产品,企业应该如何选择?
后端
用户298698530142 小时前
Python 自动化处理 Word 页眉页脚的几种典型场景
后端·python·api
愚农搬码2 小时前
一款企业级电子表单系统应该具备哪些功能?
后端
小南家的青蛙3 小时前
Redis监控与告警体系化实践
redis·监控方案·告警方案