本文从服务端实现视角切入,沿「一条命令从网线到返回结果」的主线,剖析 Redis 7.x 的源码布局、核心数据结构、对象系统、事件循环、持久化、高可用与内存管理。
1. 背景与定位
1.1 为什么要读 Redis 源码
Redis 是 C 语言实现的经典开源项目,被广泛用作缓存、消息队列、分布式锁、实时计数器的载体。对工业数采项目而言,Redis 承担「实时最新值 / 命令下发队列 / 多网关互斥锁 / 实时告警」的内存实时层角色,与 Kafka(历史流)、TDengine(时序落库)构成双路径存储模型。读服务端源码的价值在于:
- 理解行为边界:单线程模型、过期删除时机、内存淘汰策略、持久化丢数据窗口,这些"坑"的根源都在服务端代码里,客户端无论怎么封装都无法规避。
- 参数调优有依据:maxmemory-policy、io-threads、appendfsync、repl-backlog-size、hash-max-listpack-entries 等配置项的作用机制全部落在 server.c / evict.c / aof.c / replication.c 中。
- 源码级排障:高延迟、内存翻倍、AOF 损坏、主从全量同步频繁、集群 MOVED 风暴,都能在源码里找到准确因果。
1.3 Redis 总体架构速览
TCP 6379 (RESP) Cluster Bus (+10000, gossip)
│ │
┌────────────────────▼────────────────────────▼──────────────────────┐
│ networking.c:acceptTcpHandler → createClient → 事件循环注册 │
│ ae.c 事件循环:aeMain → aeProcessEvents(文件事件+时间事件) │
│ ├─ 读事件:readQueryFromClient → processInputBuffer → │
│ │ processCommand → call() → 命令实现 │
│ ├─ 写事件:sendReplyToClient(输出缓冲刷新) │
│ └─ 时间事件:serverCron(100ms 周期任务) │
├────────────────────────────────────────────────────────────────────┤
│ db.c:16 个 database(server.db),db->dict 键空间 + db->expires │
│ object.c:redisObject 对象系统(type/encoding/refcount/lru) │
│ t_string.c / t_list.c / t_hash.c / t_set.c / t_zset.c:命令实现 │
│ dict.c / sds.c / listpack.c / quicklist.c / intset.c / t_zset.c │
│ (skiplist):五大数据结构底层编码 │
├────────────────────────────────────────────────────────────────────┤
│ 持久化:rdb.c(快照+COW)/ aof.c(追加+rewrite)/ bio.c(后台IO) │
│ 复制:replication.c(PSYNC 全量/部分重同步) │
│ 集群:cluster.c(16384 槽 CRC16 + gossip + MOVED/ASK) │
│ 淘汰:evict.c(8 策略近似 LRU/LFU pool) 过期:expire.c │
│ 模块:module.c(RedisModule_* API 扩展) │
└────────────────────────────────────────────────────────────────────┘
2. 源码布局与构建
2.1 关键文件地图
| 文件 | 职责 |
|---|---|
| server.c / server.h | main 入口、initServer、全局配置 server 结构体、命令表 |
| networking.c | 客户端管理、RESP 解析、输出缓冲、io-threads |
| ae.c + ae_epoll.c / ae_select.c | 事件循环抽象与各平台后端 |
| db.c | 键空间、过期键处理、WATCH/事务的键监控 |
| dict.c | 哈希表与渐进式 rehash |
| sds.c | 简单动态字符串 |
| listpack.c / quicklist.c / intset.c | 紧凑编码、List 底层、整数集合 |
| t_zset.c | zskiplist 跳表 + ZSet 命令 |
| t_string.c / t_list.c / t_hash.c / t_set.c | 各类型命令实现 |
| object.c | 对象创建、共享对象、编码选择 |
| expire.c | 过期(惰性 + 定期抽样) |
| evict.c | 内存淘汰 |
| rdb.c / aof.c / rio.c | 持久化与 I/O 抽象 |
| replication.c / sentinel.c / cluster.c | 高可用 |
| multi.c / scripting.c / pubsub.c / lazyfree.c / bio.c | 事务 / Lua / 发布订阅 / 异步释放 / 后台线程 |
| zmalloc.c | 内存分配统计(jemalloc 优先) |
| config.c | 配置文件解析 |
2.2 构建
bash
git clone https://github.com/redis/redis
cd redis && make -j # 默认 jemalloc
make MALLOC=libc # 换 libc 分配器
./src/redis-server --port 6379 --maxmemory 512mb --maxmemory-policy allkeys-lru
调试建议:make MALLOC=libc DEBUG=1 关闭优化保留符号,配合 gdb 断点 processCommand、call、setGenericCommand 观察单条命令完整链路。
3. 核心数据结构源码解析
3.1 SDS(Simple Dynamic String,sds.c)
字符串键、命令名、客户端查询缓冲全部由 SDS 承载。核心设计:header 与数据同块内存,二进制安全,O(1) 取长度。
cpp
// 5 种 header,按字符串长度选择,减少元数据开销
struct __attribute__((__packed__)) sdshdr8 {
uint8_t len; // 已用长度
uint8_t alloc; // 已分配容量(不含 header 与 '\0')
unsigned char flags; // 3 位类型标记 + 3 位保留
char buf[]; // 柔性数组,数据紧跟 header
};
关键函数:sdsnewlen(分配 header + buf)、sdslen(O(1) 读 header)、sdscatlen(追加,容量不足时 sdsMakeRoomFor 扩容------扩容策略:小于 1MB 翻倍,大于 1MB 每次多分配 1MB,与 std::vector 异曲同工)、sdsfree(从 buf 指针回退 header 首地址释放)。
坑位提醒:sds 类型本质是 char*,指向 buf 而非 header 起始,所以释放时必须用宏 sdsfree 而不是 free,否则从 buf 偏移回 header 的指针计算错了会崩。
3.2 dict(哈希表,dict.c)
哈希表是键空间(server.db->dict)与 Hash/Set 大对象(OBJ_ENCODING_HT)的底层。核心结构:
cpp
typedef struct dictEntry {
void *key;
union { void *val; uint64_t u64; int64_t s64; double d; } v;
struct dictEntry *next; // 链地址法
} dictEntry;
typedef struct dictht {
dictEntry **table; // 桶数组
unsigned long size; // 桶数(2 的幂)
unsigned long sizemask; // size-1,与 hash & sizemask 定位桶
unsigned long used; // 已用节点数
} dictht;
typedef struct dict {
dictType *type; // hash 函数、keyDup、valDup 等回调
dictht ht[2]; // ht[0] 主表,ht[1] rehash 目标表
long rehashidx; // -1 表示不在 rehash
...
} dict;
渐进式 rehash :当 used/size > 5(dict_can_resize 且 ht_usedratio)时触发扩容,ht1 分配新表,rehashidx 从 0 开始,每次增删改查操作推进 rehashStep(1 个桶) ,把旧桶节点迁移到新桶;迁移完置 -1,释放旧表。期间 dictAdd 只写新表,dictFind 先查 ht0 再查 ht1。这样把 O(N) 搬迁摊到每次操作 O(1),避免一次性卡顿------但代价是rehash 期间内存双份。
dictScan 用游标(高位进位翻转)实现 rehash 期间无状态安全遍历,SCAN 命令底层就是它。
3.3 listpack(紧凑列表,listpack.c)
Redis 7.0 起 listpack 全面替代 ziplist(hash 小对象、quicklist 节点内部)。listpack 是连续内存上的紧凑编码序列:
- 每个元素:encoding(1~5字节) + data + backlen(1~5字节),backlen 记录整条 entry 长度,用于从后向前反向遍历(定位上一个元素起点)。
- 元素级联更新:listpack 在元素中间插入变大时,只影响后续元素的 backlen,级联传播被限制在少数相邻 entry(相比 ziplist 的 O(N) 级联更新是本质改进)。
- 小 Hash 配置:hash-max-listpack-entries 128、hash-max-listpack-value 64,超过则升级为 OBJ_ENCODING_HT(dict)。
3.4 quicklist(压缩双向链表,quicklist.c)
List 底层(OBJ_ENCODING_QUICKLIST):双向链表 + 每个节点是一个 listpack。
cpp
typedef struct quicklistNode {
struct quicklistNode *prev, *next;
unsigned char *entry; // 指向内部 listpack
unsigned int count; // listpack 内元素数
unsigned int sz; // listpack 字节数
unsigned int encoding; // RAW / LZF 压缩
unsigned int container; // PLAIN / PACKED
...
} quicklistNode;
- list-max-listpack-size:正数表示每个节点最多元素数(默认 -2 表示 8KB 字节上限,负数按字节)。
- list-compress-depth:两端各保留 N 个不压缩节点,中间节点 LZF 压缩,兼顾头部/尾部高频访问与内存。
LPUSH/LPOP 只碰头尾节点,LINDEX 从较近的一端遍历查找,LINSERT 中间插入会拆节点/合并节点。
3.5 intset(整数集合,intset.c)
Set 全为整数且数量少时用 OBJ_ENCODING_INTSET:连续内存、有序、二分查找。
cpp
typedef struct intset {
uint32_t encoding; // INTSET_ENC_INT16 / INT32 / INT64
uint32_t length;
int8_t contents[]; // 实际按 encoding 解释
} intset;
升级机制 :插入超出当前位宽的元素时,整体升级到更宽编码并原地重排;升级不可逆------删掉大整数后不会自动降级。set-max-intset-entries 512,超过转 OBJ_ENCODING_HT。
3.6 zskiplist(跳表,t_zset.c)
ZSet 的底层是 skiplist + dict 双结构(大对象时):
cpp
#define ZSKIPLIST_MAXLEVEL 32
typedef struct zskiplistNode {
sds ele; // 成员
double score; // 分值
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span; // 到下一节点跨越的层数 → ZRANK O(logN)
} level[];
} zskiplistNode;
- 随机层数:zslRandomLevel 按 1/4 概率递增(幂等分布),期望层数低,内存可控。
- span 字段是 ZSet 性能关键:ZRANK / ZREVRANK / ZRANGE BY RANK 靠 span 累计 O(logN) 定位,而不必逐个数节点。
- 双结构同步:ZADD 时 skiplist 插入节点 + dict 写入 member→score;查分用 dict O(1),按序遍历用 skiplist。zset-max-listpack-entries 128 内先用 listpack 编码,超过升级 skiplist。
4. 对象系统与命令分发
4.1 redisObject(object.c)
所有 value 都包一层 redisObject(OBJ_STRING/LIST/HASH/SET/ZSET + 编码字段):
cpp
typedef struct redisObject {
unsigned type:4; // OBJ_STRING 0 / LIST 1 / SET 2 / ZSET 3 / HASH 4
unsigned encoding:4; // OBJ_ENCODING_RAW/INT/EMBSTR/QUICKLIST/LISTPACK/HT/INTSET/SKIPLIST
unsigned lru:LRU_BITS; // 24 位:LRU 时钟 或 LFU 计数(8bit 对数计数器+16bit 递减时钟)
int refcount;
void *ptr;
} robj;
共享对象:0~9999 的整数通过 shared.integers 共享(refcount 计数),避免重复分配;server.maxmemory 为 0 时某些常用字符串也共享(shared.crlf 等)。这也解释了为什么 INCR 对整数键有"快路径"。
编码选择快照(Redis 7.x 默认):
| 类型 | 小/紧凑编码 | 大/常规编码 |
|---|---|---|
| String | INT(能用整数表示)、EMBSTR(≤44 字节,与 header 同块内存一次分配) | RAW |
| List | QUICKLIST(节点内 listpack) | 同左 |
| Hash | LISTPACK(entries≤128 且 value≤64) | HT |
| Set | INTSET(全整数且≤512) | HT |
| ZSet | LISTPACK(entries≤128) | SKIPLIST |
4.2 命令表与分发(server.c)
命令注册表 server.commands 中的 redisCommand 结构:proc(实现函数)、arity(参数个数,负数为最少个数)、flags(写/读/管理/阻塞/去广告等)、firstkey/lastkey/keystep(供集群与 ACL 定位 key 参数)。
processCommand 是命令进入执行的总闸门,顺序大致为:
- lookupCommand 查命令表(找不到 → unknown command);
- 参数个数校验(arity);
- ACL 校验、maxmemory 淘汰触发、cluster 重定向(MOVED/ASK);
- 只读命令对过期键先 expireIfNeeded(lookupKeyRead 内部触发);
- 若在 MULTI 中:命令只入队(queueMultiCommand)不执行;
- 正常路径:call(c, c->cmd) → 执行 proc → 键空间通知/慢日志/AOF 追加/主从传播。
call() 统一完成:执行前快照(watching 键)、执行、脏计数、通知 notifyKeyspaceEvent、追加 AOF(feedAppendOnlyFile)、复制传播(replicationFeedSlaves)。所以"一条命令 = 执行 + AOF + 从库"在单线程内原子完成,无需分布式锁。
5. 命令到实现的源码路径(API 说明)
本节以最常用命令为例,展示"命令 → 实现函数 → 数据结构操作"的源码链路,可直接对照 gdb 断点验证。
5.1 SET / GET(t_string.c)
SET key value
→ setGenericCommand(c, flags, key, val, expire, ...)
→ setKey(c, db, key, val, 0)
→ lookupKeyWrite 不存在则 dbAdd:dictAdd 到 db->dict
→ 存在则 dbOverwrite + signalModifiedKey
→ 带 EX/PX:setExpire(c, db, key, expire)
→ 过期键删除(新值替换旧过期时间)、键空间通知
→ addReply(c, shared.ok) // "+OK"
GET key
→ lookupKeyRead(c, db, key)
→ expireIfNeeded(db, key):过期则删除并返回 NULL(惰性过期)
→ getStringObjectFromObject / tryObjectEncoding(必要时转整数)
→ addReplyBulk("$len\r\nvalue\r\n")
要点:GET 自身不分配字符串对象 (直接读 SDS 写回),GETRANGE 走 getrangeCommand 直接在 SDS 上切片;SET key value EX 100 与 SETEX 底层同函数。整数编码键 INCR 走 incrDecrCommand 的整数快路径(tryObjectEncoding 后直接 long 加减),这也是"INCR 极快"的源码解释。
5.2 LPUSH / LPOP / LRANGE(t_list.c)
LPUSH key v1 v2
→ pushGenericCommand(c, LIST_HEAD)
→ lookupKeyWrite → 无键则创建 quicklist
→ listTypePush(quicklist, val, head) → quicklistPushHead
→ 头节点放不下则新建节点(head 方向);能放下则 listpack 头插
→ signalModifiedKey / notify
LPOP 走 popGenericCommand:quicklist 头节点弹出元素,节点空则删除节点;LRANGE 走 listTypeConvert 保证 raw 后 listTypeIterator 双向迭代。阻塞版 BLPOP 在 blocking.c(handleClientsBlockedOnKeys)------值存在立即返回,否则把客户端挂进 db->blocking_keys,直到 push 或超时唤醒,这是"阻塞命令不占事件循环"的关键。
5.3 HSET / HGET(t_hash.c)
HSET key field value
→ hsetCommand → hashTypeLookupWriteOrCreate
→ hashTypeSet:按当前编码走
- LISTPACK:listpackReplace / listpackInsert
- HT:dictAdd / dictReplace
→ 超过阈值 hashTypeConvert → OBJ_ENCODING_HT(listpack→dict,一次性搬移)
HGET key field
→ hashTypeGetValue:LISTPACK 线性扫描(小对象 O(N) 可接受) / HT dictFind O(1)
坑位根源:小 Hash 是 listpack 线性扫描,HSET 大量字段触发升级为 dict 的一次性 O(N),所以"小对象批量写入后再访问"性能抖动来自编码转换。
5.4 SADD / SMEMBERS / SCARD(t_set.c)
SADD 走 setTypeAdd:INTSET 时 intsetAdd(升级或插入),超出 set-max-intset-entries 转 HT;SMEMBERS 走 setTypeIterator 全量返回(O(N));SCARD 直接读 setTypeSize(INTSET 的 length 或 dict 的 used,O(1))。
5.5 ZADD / ZRANGE / ZRANK(t_zset.c)
ZADD key score member
→ zsetAdd(按编码分派)
- LISTPACK:listpack 有序插入(O(N))
- SKIPLIST:
zslInsert(skiplist 插入,更新 span)
+ dictAdd(member → score)
→ 更新 score 时:先 dict 找到旧 score,zslDelete 再 zslInsert(不原地改,保证 skiplist 有序性)
ZRANGE key start stop
→ zrangeGenericCommand
- 正序:zslGetElementByRank(span 累计定位)
- 逆序:backward 指针从尾部走
ZRANK key member → zslGetRank(span 累计)O(logN)
ZADD 更新 score 是"删旧插新"------这是"为什么 ZSet 更新高频成员要小心"的源码原因(每次 O(logN) 两次树操作 + 分配释放节点)。
5.6 EXPIRE / TTL(expire.c)
EXPIRE key seconds
→ expireGenericCommand → setExpire(c, db, key, when)
→ dictAdd 到 db->expires(键空间 db->dict 与过期表 db->expires 并行)
TTL key
→ lookupKeyReadWithFlags(LOOKUP_NOTSERVER) → 惰性过期检查
→ dictFind(db->expires, key) 得过期时间 - now(秒/毫秒)
删除路径三处:expireIfNeeded(惰性,命令访问时)、activeExpireCycle(定期,抽样)、slave 侧只删主同步的 DEL(从库不自主过期,见 9.1)。
5.7 MULTI / EXEC(multi.c)
MULTI → flag CLIENT_MULTI,后续命令只入队不执行(语法错误即报 QUEUED 失败)
EXEC → execCommand
→ 逐个执行已入队命令(unwatch 前置检查)
→ 事务期间命令不做"一次性"回滚:运行期错误仅当前命令失败,其余继续
源码事实:MULTI/EXEC 是"命令队列批量执行",不是 ACID 事务。执行中任何一条失败不会回滚前面成功的命令;只有入队阶段语法错误才整体不执行。需要真正原子性用 Lua/函数(scripting.c 的 evalGenericCommand,整个脚本单线程内原子执行)。
5.8 PUBLISH / SUBSCRIBE(pubsub.c)
PUBLISH channel msg → 遍历 server.pubsub_channels 中订阅该频道的客户端 → 逐条 addReply(当前线程直接写)→ pubsubPublishMessage 同时触发 keyspace 通知(notify.c)。SUBSCRIBE 把客户端从"普通命令模式"切到"发布订阅模式"(CLIENT_PUBSUB),此后只响应订阅相关命令。发布订阅不落 AOF、不复制,消息实时即弃------这是"PubSub 不能当消息队列"的源码级证据。
6. 事件循环与网络层
6.1 ae 事件循环(ae.c)
cpp
typedef struct aeEventLoop {
aeFileEvent *events; // fd → 读写回调(rfileProc/wfileProc)
aeFiredEvent *fired; // 本轮就绪事件
aeTimeEvent *timeEventHead;// 定时事件链表
int stop;
...
} aeEventLoop;
主循环:
cpp
aeMain(server.el) {
while (!server.el->stop) {
aeProcessEvents(server.el, AE_ALL_EVENTS|AE_CALL_BEFORE_SLEEP|AE_CALL_AFTER_SLEEP);
}
}
// aeProcessEvents 内:
// 1. beforeSleep:处理写缓冲等收尾工作
// 2. aeApiPoll:epoll_wait/select 阻塞等待文件事件(timeout 由最近时间事件决定)
// 3. 分发文件事件:调用 rfileProc/wfileProc(如 readQueryFromClient / sendReplyToClient)
// 4. 按时间戳触发到期的时间事件(如 serverCron)
// 5. afterSleep
时间事件示例:serverCron 每 100ms 执行------客户端超时清理、activeExpireCycle 抽样过期、内存超限检查、RDB/AOF 触发检查、集群心跳等。Redis 单线程 = 所有命令在事件循环同一线程内串行执行(io-threads 只并行网络读写,见 6.3)。
6.2 一条命令的网络链路(networking.c)
TCP accept → acceptTcpHandler → createClient
→ 注册 AE_READABLE:aeCreateFileEvent(fd, AE_READABLE, readQueryFromClient)
客户端发来 "SET k v\r\n"
→ readQueryFromClient:
read(2) 最多 16KB(PROTO_IOBUF_LEN)到 c->querybuf(SDS)
→ processInputBuffer:
按 \r\n 拆命令(RESP 数组头 *N,逐条解析 argv/argc,长度上限 PROTO_MAX_BULK_LEN 512MB)
逐条 processCommandAndResetClient → processCommand → call()
→ 命令产生的回复写入 c->buf(16KB 静态缓冲)或 c->reply 链表(大回复/阻塞期间)
→ 事件循环下一轮写事件:sendReplyToClient 刷出
输出缓冲三条限制(client-output-buffer-limit,server.c 解析):普通客户端默认无硬限(normal 0 0 0)、从库复制缓冲 256MB 、PubSub 客户端 32MB------超限断开连接,这是"大 HGETALL 把客户端踢下线"的根源。
6.3 io-threads 多线程 IO(server.c / networking.c)
io-threads 4 开启后:processInputBuffer 阶段由多个线程并行解析 各客户端查询缓冲(读线程),写回阶段由写线程并行刷输出缓冲 ;但命令执行仍单线程 (解析后的队列在主线程逐条 processCommand)。配置 io-threads-do-reads yes 才启用读线程。所以 io-threads 优化的是网络读写与协议解析的吞吐,不是命令执行并发------这是最常见的误读。
7. 过期与内存淘汰
7.1 过期删除(expire.c)
- 惰性:expireIfNeeded 在每次键访问时检查 db->expires,过期即删除(写 DEL 到 AOF/从库)。
- 定期 :activeExpireCycle 由 serverCron 调用,每次抽样最多 20 个过期键(ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP),快/慢两种模式,全局有 CPU 时间占比限制。因此过期键可能短暂"超时未删",内存中残留直到被抽样删除或再次访问。
- 主从语义:从库不自主删除过期键,只等主库同步 DEL;从库返回过期键时按 master 时间戳 判断并返回空(expireIfNeeded 中 server.masterhost 分支)。
7.2 内存淘汰(evict.c)
maxmemory 触发后 performEvictions:
- maxmemory-policy noeviction:写命令直接 OOM 报错;
- 抽样策略(allkeys-lru/allkeys-lfu/allkeys-random/volatile-*):用近似 LRU/LFU ------不是全键排序,而是 evictionPool 维护一个采样池(默认每次采样 5 个键,MAXMEMORY_EVICTION_POOL_SIZE),淘汰池中空闲最久的键;
- volatile-ttl 淘汰剩余 TTL 最短的键(同样采样)。
源码事实:淘汰是"尽力而为"的采样近似 ,且一次性回收直到 used_memory <= maxmemory;LFU 计数器用对数递减(16bit 时钟 + 8bit 计数),访问高频键计数增长慢、衰减周期长。maxmemory 设为 0 表示不限内存(此时 LRU 时钟仍在更新,但无淘汰)。
8. 持久化:RDB 与 AOF
8.1 RDB 快照(rdb.c)
BGSAVE → rdbSaveBackground:
fork() 子进程 → rdbSave(rdbSaveRio) 写临时文件 → rename 原子替换 dump.rdb
父进程返回;子进程利用 COW 快照数据
- RDB 文件结构:"REDIS" + 版本号 + 辅助字段(rdb-version/ctime/used-mem) + 各库 SELECTDB + 键值对(类型字节 + 键 SDS + 值编码) + EOF + CRC64。
- COW 代价 :fork 后父子共享页表,子进程写 RDB 期间父进程任何写入都触发页复制(内存膨胀)------bgsave 期间内存峰值 ≈ 数据量 × 写集比例,这是"大实例 bgsave 内存翻倍 OOM"的根源,需给 maxmemory 留 COW 余量。
- save 900 1 300 10 60 10000 是或语义:任一条件满足即触发(由 serverCron 检查 dirty 计数与时间)。
- 自动 save 流程在 serverCron → checkChildrenDone(回收子进程)→ rdbSaveInfoAuxFields 更新。
8.2 AOF 追加(aof.c)
命令执行成功后(call() 内)→ feedAppendFileOnlyFile:
命令序列化进 server.aof_buf(SDS)
事件循环 beforeSleep / serverCron:
flushAppendOnlyFile:
write(2) 到 aof 文件(aof_fd)
appendfsync 策略:
always → 每次 write 后 fsync(最安全最慢)
everysec → 后台线程每秒 fsync 一次(默认,崩溃最多丢 1 秒)
no → 交给内核(丢窗口最大)
AOF 写入在主线程 write,fsync 在 bio 后台线程(bio.c 的 BIO_AOF_FSYNC)。always 慢是因为每次命令都同步 fsync 落盘。
8.3 AOF rewrite 与混合持久化
BGREWRITEAOF → rewriteAppendOnlyFileBackground:
fork 子进程 → 基于当前内存重建最小命令集写临时 AOF → rename
期间新命令追加到 rewrite buffer,子进程完成后父进程把 buffer 尾追
Redis 7.0+:AOF 多文件 + manifest(server.aof_manifest),rewrite 生成新 base + incr
- aof-use-rdb-preamble yes(默认):rewrite 后的 AOF 头部是 RDB 格式二进制(紧凑加载快),后段才是增量命令文本------所以"打开 AOF 看到乱码开头"是正常现象不是损坏。
- 加载顺序:loadDataFromDisk 优先 AOF(存在则 AOF 优先),否则 RDB。
9. 高可用与集群
9.1 主从复制(replication.c)
- 从库 SLAVEOF → replicaofCommand → 握手 → PSYNC replid offset:
- 部分重同步:主库 server.repl_backlog(环形缓冲,默认 1MB)里还留有从库落后的 offset → 直接补发增量;
- 全量重同步:backlog 无数据(落后超过 backlog 大小/首次)→ 生成 RDB 传从库 + 后续命令流。
- repl-backlog-size 太小会导致频繁全量重同步(offset 差一点就被挤出 backlog),这是生产最常踩的复制坑。
- 心跳:从库每秒 REPLCONF ACK <offset>,主库据此检测断连与清理 server.repl_ctx.
9.2 哨兵(sentinel.c)
独立进程:每 10 秒 PING 主从、每 1 秒 PING 其他哨兵、订阅 +sentinel 频道交换信息;主观下线(S_DOWN,本节点判定)→ 客观下线(O_DOWN,quorum 个哨兵确认)→ leader 选举(Raft 风格)→ failover(选从库升主、改配置、通知客户端)。
9.3 集群(cluster.c)
- 16384 个哈希槽:key 经 CRC16(key) & 16383 定位({} hash tag 只对括号内内容 CRC16,保证同 tag 键同槽)。
- 节点间通过 cluster bus(端口 +10000)gossip 交换状态(clusterCron 每 100ms 发送 ping/pong)。
- 槽迁移期:MIGRATING/IMPORTING 状态,客户端拿到 MOVED(永久,重定向到新节点)或 ASK(仅本次请求,且需发 ASKING 再执行)------ASK 与 MOVED 语义不同是集群客户端实现的高频坑。
10. 源码阅读方法与实践
10.1 阅读路线
- 主线:main() → initServer()(建事件循环、打开端口、加载数据)→ aeMain(),然后跟踪一条 SET 命令的全生命周期(5.1 节链路)。
- 结构线:sds → dict → listpack → quicklist → intset → skiplist,按"为什么需要这种结构"驱动。
- 机制线:expire.c(过期)、evict.c(淘汰)、rdb.c/aof.c(持久化)、replication.c(复制)。
- 调试验证:
bash
# 编译带调试符号
make MALLOC=libc DEBUG=1
# gdb 断点
gdb --args ./src/redis-server --port 6380
(gdb) b setGenericCommand
(gdb) b call
(gdb) b readQueryFromClient
(gdb) b activeExpireCycle
(gdb) b rdbSaveBackground
(gdb) run
# 另开终端 redis-cli -p 6380 SET k v,观察调用栈 bt
- 源码级实验:INFO memory/commandstats、DEBUG OBJECT key(看 refcount/encoding/lru)、OBJECT ENCODING key(验证编码选择表)、redis-cli --bigkeys(扫描大键)、CONFIG SET maxmemory-policy 观察淘汰行为、MONITOR 看命令流。
10.2 结合工业数采场景的建议
| 场景 | 源码依据 | 配置建议 |
|---|---|---|
| 点位最新值缓存(Hash+TTL) | 编码阈值与过期语义 | hash-max-listpack-entries 按点位规模调大,减少 dict 转换 |
| 多网关互斥锁 | Lua 原子性(scripting.c) | 锁统一走 Lua/函数,勿用 MULTI |
| 命令下发队列(BRPOP) | blocking.c 挂起机制 | 独立连接跑 BRPOP,勿用 pubsub 替代 |
| 采集高峰写放大 | 持久化路径 | appendfsync everysec + 避免高峰 BGSAVE(COW 内存翻倍) |
| 网关断线重连 | 主从复制 backlog | repl-backlog-size 按消息速率估算,防频繁全量同步 |
| 内存水位 | evict 采样近似 | maxmemory 留 20% 余量给 COW 与 rehash |
11. 常错点 / 坑(20 条)
- 单线程执行 ≠ 无阻塞:任何 O(N) 命令(KEYS、HGETALL、SMEMBERS、ZRANGE 大范围、DEL 大键)都会阻塞全部客户端;UNLINK 才走 lazyfree.c 异步释放(bio 线程)。
- 渐进式 rehash 期间内存双份 :扩容后到迁移完成前 ht0+ht1 并存,INFO memory 会看到 used_memory 阶梯;且大键集中的 dict 扩容是"一触即发"的搬迁(每次操作只搬 1 桶,但整体搬迁时间被摊长)。
- 过期是惰性+抽样,不是精确到点:EXPIRE 到期后键可能残留数秒(activeExpireCycle 未抽到、且无人访问该键),误以为 TTL 一到立即消失。
- 共享对象只覆盖 0~9999 整数:SET k 123 与 SET k abc 内存差异巨大;SET k 100000 不共享。
- EMBSTR 边界 44 字节:≤44 字节字符串一次分配(header+data 同块),>44 变 RAW 两次分配;SETRANGE 修改后也会转 RAW。
- listpack 级联更新虽比 ziplist 好但仍存在:中间插入大元素触发相邻 entry 的 backlen 重写;LINSERT 在 8KB 节点满时还伴随 quicklist 节点拆分。
- intset 升级不可逆:Set 插入大整数升级到 64 位后删掉也不会降级,误以为内存会回落。
- ZSet 是 skiplist+dict 双结构 :ZADD 每次两写;更新 score 是"删旧插新",高频改分产生节点分配/释放,zset-max-listpack-entries 内的小 ZSet 反而更快。
- bgsave fork 的 COW 内存翻倍:maxmemory 设置没留余量时,BGSAVE 期间写多直接 OOM------生产用 bgsave 前看 INFO memory 的 mem_fragmentation_ratio 与写入速率。
- AOF everysec 最多丢 1 秒:误以为 everysec 与 always 一样安全;always 每次 fsync 会把写吞吐打到个位数万级。
- 混合持久化 AOF 开头是 RDB 二进制:aof-use-rdb-preamble yes(默认)下用文本工具看 AOF 是乱码,误判文件损坏而误删数据。
- MULTI/EXEC 无回滚:运行期错误不撤销已执行命令;"Redis 事务像 MySQL 事务"是错误认知,原子性需求用 Lua/函数。
- hash tag 只对 {} 内 CRC16:{user}1 与 {user}2 同槽,user1 与 user2 不同槽------CROSSSLOT 错误源于误解 hash 规则。
- ASK ≠ MOVED:MOVED 永久重定向(客户端需更新路由表);ASK 只对下一条命令生效且要 ASKING,redis-cli -c 自动处理但自定义客户端常漏。
- repl-backlog-size 太小引发全量风暴:从库短暂断线后 offset 被挤出 backlog 就退化全量同步,大库反复全量拖垮主从。
- io-threads 不并行执行命令:只并行网络读写/解析,命令执行仍在单线程;io-threads-do-reads 默认关闭,调大线程数不解决 CPU 密集命令。
- client-output-buffer-limit 三类独立:PubSub 客户端 32MB、从库 256MB、普通默认不限制;大返回(HGETALL 巨型 hash)可能断开 PubSub 客户端。
- Lua/函数脚本阻塞全局:EVAL 里 O(N) 或死循环让所有命令停摆;redis.conf 的 busy-reply-threshold 只提供 SLOWLOG 类提示不中断(SCRIPT KILL 可救)。
- 淘汰是采样近似不是全键 LRU:maxmemory-policy 每策略都只抽样(默认 5 个),LFU 用对数衰减------误以为内存超限会精确淘汰最久未用键。
- save 多条件是"或"不是"与":save 900 1 300 10 60 10000 任一条件满足即 bgsave;反过来"写 10 万键后 1 分钟内又写了 1 键"不会触发 60s 那条(dirty 已归零语义需理解 server.dirty)。
12. 总结
- 一条命令 = 事件循环单线程串行 + 字典/SDS 数据结构 + AOF/复制同步传播:理解 processCommand → call 这条链路,就理解了 Redis 全部一致性保证与性能边界。
- 五大数据结构的编码切换(listpack/quicklist/intset/skiplist/ht)是"小对象快、大对象省"的工程平衡,也是众多性能坑的根源(编码转换一次性 O(N)、intset 不可逆升级)。
- 持久化三件套(RDB COW 快照、AOF everysec、混合持久化)各有丢数据窗口与内存代价,按业务容忍度选择。
- 过期与淘汰都是"近似":惰性删除 + 定期抽样、LRU/LFU 采样池,别期待精确语义。
- 高可用的坑集中在复制 backlog 与集群重定向语义(MOVED/ASK/hash tag),客户端实现需逐条处理。
- 源码阅读五条主线(命令链路 / 数据结构 / 过期淘汰 / 持久化 / 复制集群)配合 gdb 断点,是吃透 Redis 的最短路径;本文与 go-redis、hiredis 两篇客户端篇构成「客户端 API → 协议 → 服务端实现」完整闭环。
13. FAQ 速查表
- Q:Redis 为什么快? 内存哈希 + 单线程避免锁竞争 + 事件循环(epoll)+ 紧凑编码(SDS/listpack/intset),源码层面各有对应。
- Q:单线程为何还能用多核? io-threads 并行网络读写;模块与后台任务(bio、fork 子进程)不占主线程;多实例/集群横向扩展。
- Q:为什么 SET key value EX 10 10 秒后键还在? 惰性删除 + activeExpireCycle 抽样未命中,属正常"过期残留",再次访问即删。
- Q:bgsave 期间内存暴涨? fork COW:父进程任何写入触发页复制,给 maxmemory 留 20% 余量或错峰。
- Q:AOF 每次命令都 fsync 吗? 只有 appendfsync always;默认 everysec 后台线程每秒 fsync,崩溃最多丢 1 秒。
- Q:MULTI 事务安全吗? 命令队列批量执行,无回滚;原子需求用 Lua/函数(整个脚本单线程原子执行)。
- Q:为什么主从经常全量同步? repl-backlog-size 太小,从库落后 offset 被挤出环形缓冲;按消息速率估算扩容。
- Q:PubSub 能当消息队列吗? 不能------消息实时即弃、不持久化(pubsub.c 只遍历订阅者列表写回复),用 Stream 或 Kafka。
- Q:淘汰策略怎么选? 缓存用 allkeys-lru(或 LFU 热点更稳);业务键不可丢用 noeviction 让写报错;volatile-* 只淘汰带 TTL 的键。
- Q:怎么快速验证某个行为的源码? make DEBUG=1 + gdb 断 call/对应命令函数,配合 redis-cli MONITOR 与 INFO commandstats。