Redis 源码深度解析:从常用命令到内部实现

本文从服务端实现视角切入,沿「一条命令从网线到返回结果」的主线,剖析 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 是命令进入执行的总闸门,顺序大致为:

  1. lookupCommand 查命令表(找不到 → unknown command);
  2. 参数个数校验(arity);
  3. ACL 校验、maxmemory 淘汰触发、cluster 重定向(MOVED/ASK);
  4. 只读命令对过期键先 expireIfNeeded(lookupKeyRead 内部触发);
  5. 若在 MULTI 中:命令只入队(queueMultiCommand)不执行;
  6. 正常路径: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:

  1. maxmemory-policy noeviction:写命令直接 OOM 报错;
  2. 抽样策略(allkeys-lru/allkeys-lfu/allkeys-random/volatile-*):用近似 LRU/LFU ------不是全键排序,而是 evictionPool 维护一个采样池(默认每次采样 5 个键,MAXMEMORY_EVICTION_POOL_SIZE),淘汰池中空闲最久的键;
  3. 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 阅读路线

  1. 主线:main() → initServer()(建事件循环、打开端口、加载数据)→ aeMain(),然后跟踪一条 SET 命令的全生命周期(5.1 节链路)。
  2. 结构线:sds → dict → listpack → quicklist → intset → skiplist,按"为什么需要这种结构"驱动。
  3. 机制线:expire.c(过期)、evict.c(淘汰)、rdb.c/aof.c(持久化)、replication.c(复制)。
  4. 调试验证:
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
  1. 源码级实验: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 条)

  1. 单线程执行 ≠ 无阻塞:任何 O(N) 命令(KEYS、HGETALL、SMEMBERS、ZRANGE 大范围、DEL 大键)都会阻塞全部客户端;UNLINK 才走 lazyfree.c 异步释放(bio 线程)。
  2. 渐进式 rehash 期间内存双份 :扩容后到迁移完成前 ht0+ht1 并存,INFO memory 会看到 used_memory 阶梯;且大键集中的 dict 扩容是"一触即发"的搬迁(每次操作只搬 1 桶,但整体搬迁时间被摊长)。
  3. 过期是惰性+抽样,不是精确到点:EXPIRE 到期后键可能残留数秒(activeExpireCycle 未抽到、且无人访问该键),误以为 TTL 一到立即消失。
  4. 共享对象只覆盖 0~9999 整数:SET k 123 与 SET k abc 内存差异巨大;SET k 100000 不共享。
  5. EMBSTR 边界 44 字节:≤44 字节字符串一次分配(header+data 同块),>44 变 RAW 两次分配;SETRANGE 修改后也会转 RAW。
  6. listpack 级联更新虽比 ziplist 好但仍存在:中间插入大元素触发相邻 entry 的 backlen 重写;LINSERT 在 8KB 节点满时还伴随 quicklist 节点拆分。
  7. intset 升级不可逆:Set 插入大整数升级到 64 位后删掉也不会降级,误以为内存会回落。
  8. ZSet 是 skiplist+dict 双结构 :ZADD 每次两写;更新 score 是"删旧插新",高频改分产生节点分配/释放,zset-max-listpack-entries 内的小 ZSet 反而更快。
  9. bgsave fork 的 COW 内存翻倍:maxmemory 设置没留余量时,BGSAVE 期间写多直接 OOM------生产用 bgsave 前看 INFO memory 的 mem_fragmentation_ratio 与写入速率。
  10. AOF everysec 最多丢 1 秒:误以为 everysec 与 always 一样安全;always 每次 fsync 会把写吞吐打到个位数万级。
  11. 混合持久化 AOF 开头是 RDB 二进制:aof-use-rdb-preamble yes(默认)下用文本工具看 AOF 是乱码,误判文件损坏而误删数据。
  12. MULTI/EXEC 无回滚:运行期错误不撤销已执行命令;"Redis 事务像 MySQL 事务"是错误认知,原子性需求用 Lua/函数。
  13. hash tag 只对 {} 内 CRC16:{user}1 与 {user}2 同槽,user1 与 user2 不同槽------CROSSSLOT 错误源于误解 hash 规则。
  14. ASK ≠ MOVED:MOVED 永久重定向(客户端需更新路由表);ASK 只对下一条命令生效且要 ASKING,redis-cli -c 自动处理但自定义客户端常漏。
  15. repl-backlog-size 太小引发全量风暴:从库短暂断线后 offset 被挤出 backlog 就退化全量同步,大库反复全量拖垮主从。
  16. io-threads 不并行执行命令:只并行网络读写/解析,命令执行仍在单线程;io-threads-do-reads 默认关闭,调大线程数不解决 CPU 密集命令。
  17. client-output-buffer-limit 三类独立:PubSub 客户端 32MB、从库 256MB、普通默认不限制;大返回(HGETALL 巨型 hash)可能断开 PubSub 客户端。
  18. Lua/函数脚本阻塞全局:EVAL 里 O(N) 或死循环让所有命令停摆;redis.conf 的 busy-reply-threshold 只提供 SLOWLOG 类提示不中断(SCRIPT KILL 可救)。
  19. 淘汰是采样近似不是全键 LRU:maxmemory-policy 每策略都只抽样(默认 5 个),LFU 用对数衰减------误以为内存超限会精确淘汰最久未用键。
  20. 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 速查表

  1. Q:Redis 为什么快? 内存哈希 + 单线程避免锁竞争 + 事件循环(epoll)+ 紧凑编码(SDS/listpack/intset),源码层面各有对应。
  2. Q:单线程为何还能用多核? io-threads 并行网络读写;模块与后台任务(bio、fork 子进程)不占主线程;多实例/集群横向扩展。
  3. Q:为什么 SET key value EX 10 10 秒后键还在? 惰性删除 + activeExpireCycle 抽样未命中,属正常"过期残留",再次访问即删。
  4. Q:bgsave 期间内存暴涨? fork COW:父进程任何写入触发页复制,给 maxmemory 留 20% 余量或错峰。
  5. Q:AOF 每次命令都 fsync 吗? 只有 appendfsync always;默认 everysec 后台线程每秒 fsync,崩溃最多丢 1 秒。
  6. Q:MULTI 事务安全吗? 命令队列批量执行,无回滚;原子需求用 Lua/函数(整个脚本单线程原子执行)。
  7. Q:为什么主从经常全量同步? repl-backlog-size 太小,从库落后 offset 被挤出环形缓冲;按消息速率估算扩容。
  8. Q:PubSub 能当消息队列吗? 不能------消息实时即弃、不持久化(pubsub.c 只遍历订阅者列表写回复),用 Stream 或 Kafka。
  9. Q:淘汰策略怎么选? 缓存用 allkeys-lru(或 LFU 热点更稳);业务键不可丢用 noeviction 让写报错;volatile-* 只淘汰带 TTL 的键。
  10. Q:怎么快速验证某个行为的源码? make DEBUG=1 + gdb 断 call/对应命令函数,配合 redis-cli MONITOR 与 INFO commandstats。
相关推荐
冬奇Lab13 小时前
一天一个开源项目(第232篇):DSH Desktop —— 把桌面壳本身也做成一个插件,30k+ Stars 的 DeepSeek Harness 桌面客户端
人工智能·开源·资讯
禾小西13 小时前
07丨Redis 哨兵机制:主库故障后,如何恢复服务?
java·开发语言·redis
徐小黑ACG14 小时前
Golang 基础05 结构体struct
开发语言·算法·golang
写的都是BUG15 小时前
我开源了一个 Android/iOS 加固工具,顺便聊聊加固到底能防住什么
开源·掘金社区
骉马代驾15 小时前
代驾系统实战(三):抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做
redis
caoerzhong15 小时前
JeeWMS 开源 WMS 部署避坑指南:Java 仓库管理系统的环境基线、四类根因与可复现交付
java·开发语言·开源
北冥you鱼16 小时前
Go 语言空接口(interface{})使用场景与最佳实践
开发语言·windows·golang
行百里er17 小时前
Redis Streams——有确认、能回溯的消息队列
redis
小马同学-17 小时前
Redis消息队列与客户端编程
数据库·redis