Redis 源码学习

事件循环(ae)

Redis 没有使用 libevent / libuv 等,而是自己实现了一个极简的事件驱动库 ae(A simple Event library),核心文件只有 ae.c / ae.h 约 400 行.

事件循环本体是 aeEventLoop. 它把文件事件(fd)和时间事件(定时任务)统一收口,后面所有逻辑都围绕这张“登记表”转.

typedef struct aeEventLoop {
    int maxfd;                      // 当前注册的最大 fd
    int setsize;                    // 可追踪的 fd 上限(maxclients)
    long long timeEventNextId;      // 时间事件自增 ID
    aeFileEvent *events;            // 已注册的文件事件数组,以 fd 为索引
    aeFiredEvent *fired;            // 就绪事件数组,poll 返回后填充
    aeTimeEvent *timeEventHead;     // 时间事件链表头(单链表)
    int stop;                       // 事件循环终止标志
    void *apidata;                  // 底层多路复用的私有数据(epoll_fd 等)
    aeBeforeSleepProc *beforesleep; // 每次阻塞前的回调
    aeBeforeSleepProc *aftersleep;  // 每次唤醒后的回调
} aeEventLoop;

events 数组以 fd 为下标直接寻址,O(1) 查找,这是 Redis 能处理大量连接的基础. ae.c 在编译期通过 #ifdef 选择最合适的多路复用后端, 支持 evport、 epoll、kqueu、select.

#ifdef HAVE_EVPORT
#include "ae_evport.c"   // Solaris
#elif defined(HAVE_EPOLL)
#include "ae_epoll.c"    // Linux
#elif defined(HAVE_KQUEUE)
#include "ae_kqueue.c"   // macOS / BSD
#else
#include "ae_select.c"   // UNIX
#endif

每个后端只需实现 5 个函数:aeApiCreate, aeApiAddEvent, aeApiDelEvent, aeApiPoll, aeApiFree. 以 epoll 为例:

// ae_epoll.c
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
    int retval, numevents = 0;
    retval = epoll_wait(state->epfd, state->events, eventLoop->setsize,
                        tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);
    if (retval > 0) {
        numevents = retval;
        for (int j = 0; j < numevents; j++) {
            struct epoll_event *e = state->events + j;
            int mask = 0;
            if (e->events & EPOLLIN)  mask |= AE_READABLE;
            if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
            eventLoop->fired[j].fd = e->data.fd;
            eventLoop->fired[j].mask = mask;
        }
    }
    return numevents;
}

事件循环的主干非常短,基本就是“睡前做点收尾 → 等待就绪 → 处理读写 → 跑定时任务”:

aeMain(eventLoop)
  └─ while (!stop)
       └─ aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_BEFORE_SLEEP | AE_CALL_AFTER_SLEEP)
            ├─ beforesleep()          // 关键!处理挂起的写操作、AOF flush、集群消息
            ├─ aeApiPoll(shortest)    // 阻塞等待,超时 = 最近时间事件的剩余时间
            ├─ aftersleep()
            ├─ 处理就绪的文件事件      // 先读后写(AE_READABLE → AE_WRITABLE)
            └─ 处理到期的时间事件      // serverCron 等周期任务

其中最值得关注的是 beforesleep. 它把一堆“最好别放在命令执行路径里、但又必须尽快做完”的工作集中到一起,减少事件循环里的分散开销,也是理解 AOF、集群、IO 线程协作的关键入口之一:

文件 event 之外,Redis 还需要周期性地做一些其他工作. 它的时间 event 实现也很克制:默认只挂一个周期性时间事件 serverCron(10Hz,即 100ms 一次),负责统计更新、客户端超时清理、AOF/RDB 后台任务检查、渐进式 rehash、过期键主动淘汰等. 时间event 用单链表管理,因为数量极少(通常就 1 个),遍历开销可忽略.

把这些拼起来,就能得到一个连接从建立到完成一次请求的大致轨迹(下面这条链路在后面的 RESP / 线程模型里会反复出现):

accept() → createClient() → 注册 AE_READABLE 回调 readQueryFromClient
  → 读取数据到 client->querybuf
  → processInputBuffer() 解析命令
  → processCommand() 执行
  → addReply() 写入 client->buf 或 client->reply 链表
  → beforesleep 中统一刷出(或注册 AE_WRITABLE 回调 sendReplyToClient)

写操作采用”延迟写”策略:先缓冲到 client->buf(16KB 固定缓冲)或 client->reply(链表,应对大回复),在 beforesleep 阶段统一尝试 write(). 只有写不完时才注册 WRITABLE 事件,避免 epoll 频繁触发.

内存分配(zmalloc)

Redis 封装了一层轻量的分配器接口 zmalloc.h / zmalloc.c,核心目的不是造一个新 malloc,而是 精确统计已用内存,把“内存去哪儿了”变成可观测的指标.

Redis 在编译期选择底层分配器, 支持 tcmaclloc, jemalloc, ptmalloc

#if defined(USE_TCMALLOC)
    #include <gperftools/tcmalloc.h>
#elif defined(USE_JEMALLOC)           // 默认
    #include <jemalloc/jemalloc.h>
#elif defined(__GLIBC__)
    #include <malloc.h>               // glibc ptmalloc
#endif

Redis 默认编译带 jemalloc(deps/jemalloc/),因为它在碎片控制和多线程扩展性上优于 glibc 的 ptmalloc.

统计的核心思路很朴素:分配/释放时把实际占用的字节数累加到一个全局计数器里. 为了做到这一点,Redis 要么在返回给用户的指针前面塞一个“前缀头”记录大小,要么在分配器支持时直接查询可用大小:

static _Atomic size_t used_memory = 0;   // 全局原子计数器

void *zmalloc(size_t size) {
    void *ptr = malloc(size + PREFIX_SIZE);  // PREFIX_SIZE 保存实际大小
    if (!ptr) zmalloc_oom_handler(size);     // OOM 直接 abort
    *((size_t*)ptr) = size;                  // 前 8 字节存大小
    update_zmalloc_stat_alloc(size + PREFIX_SIZE);  // 原子加
    return (char*)ptr + PREFIX_SIZE;
}

void zfree(void *ptr) {
    size_t oldsize = *((size_t*)((char*)ptr - PREFIX_SIZE));
    update_zmalloc_stat_free(oldsize + PREFIX_SIZE);  // 原子减
    free((char*)ptr - PREFIX_SIZE);
}

当底层分配器提供了 malloc_size()(如 jemalloc 的 je_malloc_usable_size)时,Redis 不再使用 PREFIX_SIZE 头,而是直接查询实际分配大小,避免额外内存开销.

读到这里你就能理解 INFO memory 的几个关键字段是怎么来的:

函数说明
zmalloc(size)分配 + 统计
zcalloc(size)清零分配
zrealloc(ptr, size)重分配
zfree(ptr)释放 + 统计
zmalloc_used_memory()返回已用字节数(INFO memory 的数据来源)
zmalloc_get_rss()读取 /proc/self/stat 获取 RSS
zmalloc_get_fragmentation_ratio()RSS / used_memory,碎片率

INFO memory 中的 used_memory 就来自这个原子计数器,mem_fragmentation_ratio = RSS / used_memory. 碎片率 > 1.5 通常说明有严重碎片,Redis 4.0+ 支持 activedefrag 在线碎片整理.


Redis协议 RESP

Redis 用自定义的 RESP (REdis Serialization Protocol) 做客户端-服务端通信. 它的设计哲学很务实:人类可读、实现简单、解析高效;并且天然适配“命令数组”的调用形式. (完全基于字符串, 应该没有 Pb 性能好)

RESP2 最简洁

+OK\r\n                          → Simple String(状态回复)
-ERR unknown command\r\n         → Error
:1000\r\n                        → Integer
$6\r\nfoobar\r\n                 → Bulk String(二进制安全,先声明长度)
$-1\r\n                          → Null Bulk String
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n  → Array(客户端发送命令的格式)

Redis 6.0+ 增加了 RESP3,类型更丰富,客户端可用 HELLO 3 切换:

_\r\n                  → Null
#t\r\n / #f\r\n        → Boolean
,3.14\r\n              → Double
(3492890328409238509\r\n → Big Number
=15\r\ntxt:Some text\r\n → Verbatim String
%2\r\n+key1\r\n:1\r\n+key2\r\n:2\r\n  → Map
~3\r\n+a\r\n+b\r\n+c\r\n              → Set
|1\r\n+key\r\n+val\r\n                 → Attribute(元数据,客户端可忽略)
>3\r\n+message\r\n+ch\r\n+data\r\n    → Push(服务端主动推送,Pub/Sub 用)

为了兼容 telnet 这类工具,Redis 还支持 inline 格式(例如 SET key value\r\n). 在源码里,processInlineBuffer() 会把它转成标准 RESP 数组再走统一路径.

解析流程集中在 networking.c,入口是连接可读回调 readQueryFromClient,读入 querybuf 后交给增量解析器:

void readQueryFromClient(connection *conn) {
    // 读入 client->querybuf
    nread = connRead(conn, buf, readlen);
    // ...
    processInputBuffer(c);
}

void processInputBuffer(client *c) {
    while (c->qb_pos < sdslen(c->querybuf)) {
        if (c->reqtype == PROTO_REQ_INLINE)
            processInlineBuffer(c);      // telnet 风格
        else
            processMultibulkBuffer(c);   // RESP 标准格式
        // 解析出 c->argc / c->argv 后
        processCommand(c);
    }
}

processMultibulkBuffer 是增量解析器——如果数据还没读全(TCP 分包),直接返回等下次读事件,不会阻塞. 这是 Redis 能用单线程处理高并发的关键之一.

请求能被增量解析,回复同样要尽量减少分配与系统调用. addReply 会优先写入固定大小的 client->buf(16KB),写满才扩展到链表:

// networking.c
void addReply(client *c, robj *obj) {
    if (prepareClientToWrite(c) != C_OK) return;
    // 优先写固定缓冲 c->buf[PROTO_REPLY_CHUNK_BYTES](16KB)
    // 写满后追加到 c->reply 链表(clientReplyBlock 节点)
}

回复缓冲区分两级:client->buf(栈式固定 16KB,零分配开销)和 client->reply(链表,处理大回复如 LRANGE 百万元素). 通过 client-output-buffer-limit 可以限制每个客户端的输出缓冲上限,防止慢消费者吃光内存.


数据结构(string|list|hash|set|stream)

Redis 对外暴露的类型(String, List, Hash, Set, Sorted Set, Stream)底层由多种编码实现,根据数据规模自动切换.

先从统一的对象封装开始:所有 Redis 值都会被包装成 redisObject(也叫 robj),类型与编码被显式记录下来,后续很多优化(共享对象、小对象编码切换、淘汰策略)都依赖这两个字段.

typedef struct redisObject {
    unsigned type:4;      // OBJ_STRING, OBJ_LIST, OBJ_HASH, OBJ_SET, OBJ_ZSET, OBJ_STREAM
    unsigned encoding:4;  // OBJ_ENCODING_INT, _EMBSTR, _RAW, _ZIPLIST, _LISTPACK, ...
    unsigned lru:24;      // LRU 时间或 LFU 计数(淘汰策略用)
    int refcount;         // 引用计数(共享对象如小整数 0-9999)
    void *ptr;            // 实际数据结构指针
} robj;  // 16 字节

SDS(Simple Dynamic String) 是 Redis 最基础的字符串结构,用来替代 C 字符串:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;      // 已用长度
    uint8_t alloc;    // 分配长度(不含 header 和 \0)
    unsigned char flags; // 类型标志(sdshdr5/8/16/32/64)
    char buf[];       // 柔性数组,实际数据
};

设计要点:O(1) 获取长度、二进制安全(可存 \0)、预分配减少 realloc(< 1MB 翻倍,>= 1MB 加 1MB)、兼容部分 C 字符串函数(末尾保留 \0). 按字符串长度选择 sdshdr5/8/16/32/64,极致节省内存.

embstr 优化:当字符串 <= 44 字节时,robjsdshdr8 在同一次 zmalloc 中分配(连续内存),cache 友好且减少一次分配:

[robj 16B | sdshdr8 3B | data ≤44B | \0]  → 共 64 字节,恰好一个 jemalloc size class

dict(哈希表) 是 Hash、Set、ZSet(配合 skiplist)等结构的底座. 实现上用两张表支持渐进式 rehash:

typedef struct dict {
    dictType *type;       // 类型特定的函数指针(hash、compare、key/val 析构)
    dictEntry **ht_table[2];  // 两张哈希表,ht[1] 仅在 rehash 时使用
    unsigned long ht_used[2];
    long rehashidx;       // -1 表示未 rehash,>= 0 表示当前 rehash 进度(桶索引)
    // ...
} dict;

typedef struct dictEntry {
    void *key;
    union { void *val; uint64_t u64; int64_t s64; double d; } v;
    struct dictEntry *next;   // 链地址法解决冲突
} dictEntry;

渐进式 rehash:当负载因子过高(used/size > 1,或 BGSAVE 时 > 5)时触发扩容. Redis 不会一次性迁移所有桶,而是在每次字典操作(增删改查)时迁移 1 个桶(dictRehashStep),同时在 serverCron 中每次迁移 100 个桶(限时 1ms). rehash 期间,查找会同时搜索 ht[0] 和 ht[1],新插入只写 ht[1].

哈希函数:Redis 使用 SipHashdict.c 中的 dictGenHashFunction),抵抗 hash-flooding 攻击.

接下来是紧凑编码的演进. 你会在 List / Hash / ZSet 的小对象编码里频繁看到 ziplist/listpack 的身影:

ziplist(Redis 7.0 前):连续内存的紧凑列表,每个 entry 包含 prevlen + encoding + data. 问题是 级联更新:修改一个 entry 的长度可能导致后续所有 entry 的 prevlen 字段变化(从 1 字节变 5 字节),最坏 O(N²).

listpack(Redis 7.0 全面替代 ziplist):去掉了 prevlen,每个 entry 只存自己的长度信息,通过 backlen(变长编码自身大小)实现反向遍历,彻底消除级联更新.

listpack 布局:
[total-bytes 4B] [num-elements 2B] [entry1] [entry2] ... [EOF 0xFF]

每个 entry:
[encoding + data] [backlen]

skiplist(跳表) 则是 Sorted Set 的核心实现之一(当元素 > 128 或单元素 > 64 字节时):

typedef struct zskiplistNode {
    sds ele;                     // 成员值
    double score;                // 分值
    struct zskiplistNode *backward;  // 后退指针(仅第 0 层)
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;      // 跨度(用于 ZRANK O(logN) 计算)
    } level[];                   // 柔性数组,随机层数
} zskiplistNode;

层数随机生成:每层有 25% 概率升到下一层(ZSKIPLIST_P = 0.25),最高 32 层. 平均每个节点 1.33 个指针,比红黑树更节省. 跳表的优势:范围查询天然有序(ZRANGEBYSCORE)、实现简单(相比红黑树)、并发友好(虽然 Redis 单线程,但简单意味着不易出 bug).

intset(整数集合) 用来优化“全是整数的小集合”:

typedef struct intset {
    uint32_t encoding;   // INTSET_ENC_INT16 / INT32 / INT64
    uint32_t length;
    int8_t contents[];   // 有序整数数组(二分查找)
} intset;

升级机制:当插入一个更大的整数(如 INT16 集合插入 INT32 值)时,整个数组从尾部开始扩展并转换编码,不支持降级.

quicklist 是 List 类型在 Redis 3.2+ 的主力实现:用 listpack 组成的双向链表,既能把小元素压得很紧,又能避免“单一大连续内存”带来的频繁拷贝.

typedef struct quicklist {
    quicklistNode *head, *tail;
    unsigned long count;        // 所有 entry 总数
    unsigned long len;          // quicklistNode 个数
    signed int fill : QL_FILL_BITS;   // 每个节点的 listpack 最大大小
    unsigned int compress : QL_COMP_BITS; // 两端不压缩的节点数
    // ...
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev, *next;
    unsigned char *entry;       // listpack 数据(或压缩后的 LZF 数据)
    size_t sz;
    unsigned int count : 16;    // listpack 中的 entry 数
    unsigned int encoding : 2;  // RAW=1, LZF=2
    // ...
} quicklistNode;

list-max-ziplist-size 控制每个节点的 listpack 大小,list-compress-depth 控制中间节点的 LZF 压缩深度.

rax(基数树 / radix tree) 常出现在你不太容易联想到的地方:Stream 的底层存储(消息 ID → 消息体的映射)以及 Cluster 的 slot-to-key 映射都用它. 对于 Stream 而言,key 是消息 ID(毫秒时间戳 + 序号),value 是指向 listpack 的指针(一个 listpack 存储多条连续消息).

最后把“何时切编码”的规则汇总一下会更直观(这些阈值都可以通过配置调整):

类型小编码大编码切换条件
Stringint / embstrraw整数溢出 / > 44 字节
Listlistpackquicklist> 128 元素 或 单元素 > 64B
Hashlistpackhashtable> 128 字段 或 单字段 > 64B
Setintset / listpackhashtable> 128 元素 或 非整数
Sorted Setlistpackskiplist + dict> 128 元素 或 单元素 > 64B

这些阈值均可通过 *-max-*-entries / *-max-*-value 配置调整.


持久化:RDB 与 AOF

当你把事件循环、协议解析、对象编码这些“在线路径”看顺之后,持久化就会显得更好理解:它们大多不在命令执行的热路径里,而是通过后台子进程/线程、以及 beforesleep 的集中刷盘,把性能与可靠性做权衡.

RDB(快照)

RDB 将整个数据库在某一时刻的状态序列化为二进制文件.

触发方式SAVE(阻塞主线程,生产禁用)、BGSAVE(fork 子进程)、配置的自动规则(save 900 1)、主从全量同步时自动触发.

fork + COW 机制

主进程 fork() → 子进程
  ├─ 子进程:遍历所有 db → 序列化 key-value → 写入 temp-{pid}.rdb → rename
  └─ 主进程:继续处理请求
      ↓ 写操作触发 Copy-On-Write
      OS 按页(4KB)复制被修改的页面

子进程通过 pipe 向主进程报告 COW 内存用量(rdb.c:rdbSaveRio 中周期性写 pipe). INFO persistencelatest_fork_usec 记录 fork 耗时——数据量大时 fork 可能造成几百毫秒的延迟.

RDB 文件格式(简化):

[REDIS][版本号]
[AUX 字段: redis-ver, ctime, used-mem, ...]
[DB 选择器 + resize信息]
  [过期时间(可选)] [类型] [key] [value 编码后的数据]
  [过期时间(可选)] [类型] [key] [value]
  ...
[EOF]
[8字节 CRC64 校验]

AOF(追加日志)

AOF 将每个写命令以 RESP 格式追加到文件末尾.

写入流程

processCommand()
  → call()
    → propagate()
      → feedAppendOnlyFile()
        → 将命令追加到 server.aof_buf(内存缓冲)

beforesleep()
  → flushAppendOnlyFile()
    → aofWrite() 系统调用(write 到内核 page cache)
    → 根据 appendfsync 策略决定是否 fsync:
        always  → 每次都 fsync(最安全,最慢)
        everysec → 后台线程每秒 fsync(默认,折中)
        no      → 交给 OS 决定(最快,可能丢较多数据)

AOF 重写(BGREWRITEAOF):

主进程 fork() → 子进程
  ├─ 子进程:遍历数据库,将当前状态用最少命令重新生成 AOF
  └─ 主进程:新写命令同时追加到 aof_buf 和 aof_rewrite_buf
      ↓ 子进程完成后
      主进程将 aof_rewrite_buf 追加到新 AOF → rename 替换旧文件

Redis 7.0 引入 Multi-part AOF:基础 AOF(RDB 格式的快照)+ 增量 AOF(RESP 命令),不再需要重写整个 AOF,而是生成新的增量片段. 由 appendonly.aof.manifest 文件管理多个 AOF 片段.


线程模型:主线程、BIO 与 I/O 线程

Redis 的“单线程”更多是指 命令执行单线程:它尽量把需要串行语义的部分收敛到主线程,而把可能阻塞或可并行的工作交给后台线程/子进程处理.

主线程(命令执行)

Redis 的所有命令执行始终在主线程完成,这是其”单线程”承诺的核心——命令处理串行化,无锁无竞争.

后台线程(BIO — Background I/O) 用来处理可能阻塞主线程的任务:

// bio.c
#define BIO_CLOSE_FILE    0   // 关闭 AOF/RDB 的旧 fd(避免 close 阻塞)
#define BIO_AOF_FSYNC     1   // aof everysec 策略的异步 fsync
#define BIO_LAZY_FREE     2   // 异步释放大对象(UNLINK, FLUSHDB ASYNC)

每种类型一个线程,任务队列 + 条件变量驱动. UNLINK 替代 DEL 就是将释放操作投递到 BIO_LAZY_FREE 队列,主线程 O(1) 返回.

I/O 线程(Redis 6.0+) 则把网络收发与协议解析并行化,但刻意不碰命令执行:

// networking.c
// 主线程不独自做网络读写,而是分发给 io-threads
void handleClientsWithPendingReadsUsingThreads(void) {
    // 将 clients_pending_read 链表均匀分配给 N 个 IO 线程
    // 每个 IO 线程执行: readQueryFromClient → 解析命令(但不执行!)
    // 主线程等待所有 IO 线程完成,然后串行执行命令
}

void handleClientsWithPendingWritesUsingThreads(void) {
    // 将 clients_pending_write 链表均匀分配给 N 个 IO 线程
    // 每个 IO 线程执行: writeToClient → 将回复写给客户端
    // 主线程等待所有 IO 线程完成
}

关键设计:IO 线程只做网络读写和协议解析,命令执行仍在主线程. 这样保持了 Redis 的单线程语义(无需加锁),同时利用多核加速网络 I/O.

时间线:
  IO线程 ──读解析──┐         ┌──写回复──
  IO线程 ──读解析──┤         ├──写回复──
  主线程 ──等待──  ├─执行命令─┤  ──等待──
  IO线程 ──读解析──┤         ├──写回复──
  IO线程 ──读解析──┘         └──写回复──

线程同步用的是 自旋等待while(io_threads_pending[id] != 0)),而非 mutex,因为在高负载场景下自旋的延迟更低. IO 线程数通过 io-threads 配置(建议 CPU 核数 - 1,不超过 8).


复制与分布式:复制、Sentinel、Cluster

前面的内容基本都在“单机一进程”范围内. Redis 一旦进入高可用与分片场景,复制缓冲、故障转移、slot 迁移这些机制会和持久化、网络模型交织在一起. 阅读源码时建议先抓住复制主链路,再理解 Sentinel/Cluster 的状态机与消息类型.

主从复制

全量同步(FULLRESYNC)

Replica → Master:  PSYNC ? -1         // 第一次连接,无 replication ID
Master → Replica:  +FULLRESYNC <replid> <offset>
Master:            BGSAVE 生成 RDB
                   ↓ 同时将新写命令缓存到 replication buffer
Master → Replica:  发送 RDB 文件(bulk transfer)
Master → Replica:  发送缓积的写命令
Replica:           加载 RDB → 回放写命令 → 同步完成

部分重同步(CONTINUE)

Replica → Master:  PSYNC <replid> <offset>
Master:            检查 replid 匹配 && offset 在 repl_backlog 范围内
Master → Replica:  +CONTINUE
Master → Replica:  从 backlog 中发送缺失的命令

复制积压缓冲区(repl_backlog) 是一个固定大小的环形缓冲区(默认 1MB,repl-backlog-size 配置),保存最近的写命令. Replica 断线重连后如果偏移量还在 backlog 内就能部分重同步,否则需要全量.

Replication ID:主节点有两个 replid(replidreplid2),用于处理故障转移场景. 当 Replica 被提升为新 Master 时,它将旧 Master 的 replid 存入 replid2,生成新的 replid,这样其他 Replica 仍可通过 replid2 进行部分重同步.

Sentinel(哨兵)

Sentinel 是独立进程,监控 Redis 实例并自动故障转移:

Sentinel 集群(至少 3 个,奇数)
  │  每秒 PING 主从节点
  │  每 2 秒通过 __sentinel__:hello 频道交换信息
  │
  ├─ 主观下线(SDOWN):单个 Sentinel 认为节点不可达(超过 down-after-milliseconds)
  ├─ 客观下线(ODOWN):quorum 个 Sentinel 都认为主节点不可达
  └─ 故障转移:
       1. Sentinel 选举出 leader(Raft-like 投票)
       2. Leader 选择最优 Replica(优先级 → 偏移量 → runid 字典序)
       3. SLAVEOF NO ONE → 提升为新 Master
       4. 通知其他 Replica 切换到新 Master
       5. 更新配置,旧 Master 恢复后变为 Replica

Redis Cluster

Redis Cluster 是 Redis 官方的分布式方案,数据自动分片、去中心化.

哈希槽分片

共 16384 个 slot
slot = CRC16(key) & 16383
每个主节点负责一部分 slot

{tag} 语法强制不同 key 落到同一 slot:CRC16("tag").

节点通信 — Gossip 协议

// cluster.h
typedef struct clusterMsg {
    char sig[4];       // "RCmb"
    uint16_t type;     // PING, PONG, MEET, FAIL, PUBLISH, ...
    uint32_t totlen;
    // ...
    clusterMsgData data;  // 包含发送者已知的部分节点信息(gossip section)
} clusterMsg;

每个节点每秒随机选择若干节点发送 PING(携带自身状态 + 随机抽取的其他节点信息),收到 PING 回复 PONG. 信息在集群中以指数速度传播,最终一致.

MOVED 重定向

Client → Node A:  GET key
Node A:           CRC16(key) → slot 12345 → 不在本节点
Node A → Client:  -MOVED 12345 192.168.1.2:6379
Client → Node B:  GET key(客户端缓存 slot 映射,后续直连正确节点)

ASK 重定向(迁移中):

slot 正在从 A 迁移到 B
Client → A:  GET key
A:           key 不在本地(已迁到 B)
A → Client:  -ASK 12345 192.168.1.2:6379
Client → B:  ASKING + GET key(一次性重定向,不更新映射)

故障检测与转移

与 Sentinel 类似但嵌入到每个节点中. 节点通过 gossip 交换 PFAIL(主观下线)标记,当多数主节点报告某主节点 PFAIL,标记为 FAIL(客观下线). 该主节点的 Replica 发起选举(需要多数主节点投票),胜选后接管 slot.

集群拓扑示例

┌──────────┐     ┌──────────┐     ┌──────────┐
│ Master A │     │ Master B │     │ Master C │
│ slot 0-  │────│ slot 5461│────│ slot 10923│
│    5460  │     │  -10922  │     │  -16383  │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │                │                │
┌────┴─────┐     ┌────┴─────┐     ┌────┴─────┐
│Replica A1│     │Replica B1│     │Replica C1│
└──────────┘     └──────────┘     └──────────┘

每个 Master 至少配 1 个 Replica,容忍单点故障. 集群需要 多数主节点存活 才能继续工作(cluster-require-full-coverage 可关闭此限制).


一个请求的完整生命周期

1. 客户端 TCP 连接 → accept → 创建 client 对象 → 注册 READABLE 事件

2. epoll 通知可读 → readQueryFromClient(IO线程可参与)
   → 数据进入 client->querybuf
   → RESP 协议解析 → client->argv[]

3. processCommand(主线程)
   → 权限检查、内存检查、OOM 策略
   → 查找命令表 → call(c, CMD_CALL_FULL)
   → 执行具体命令(如 t_string.c:setCommand)
   → propagate → feedAppendOnlyFile (AOF) + replicationFeedSlaves (复制)
   → addReply → 写入 client->buf / client->reply

4. beforesleep
   → flushAppendOnlyFile (AOF 写盘)
   → handleClientsWithPendingWrites(IO线程可参与)
   → write() 将回复发给客户端

5. 若数据写不完 → 注册 WRITABLE 事件 → 下次 epoll 继续写

这就是一个 Redis 命令从网络到达、解析、执行、持久化、复制、回复的完整旅程. 每个环节都经过精心设计,共同构成了 Redis 高性能的基础.

我的极简 emacs 配置
Codis vs redis cluster