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 线程协作的关键入口之一:
handleClientsWithPendingWritesUsingThreads() — 将积攒的回复刷给客户端(含 IO 线程分发)handleClientsWithPendingReadsUsingThreads() — IO 线程协助解析请求flushAppendOnlyFile() — 根据 fsync 策略写 AOFclusterBeforeSleep() — 发送集群 gossip 消息文件 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 频繁触发.
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 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 可以限制每个客户端的输出缓冲上限,防止慢消费者吃光内存.
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 字节时,robj 和 sdshdr8 在同一次 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 使用 SipHash(dict.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 存储多条连续消息).
最后把“何时切编码”的规则汇总一下会更直观(这些阈值都可以通过配置调整):
| 类型 | 小编码 | 大编码 | 切换条件 |
|---|---|---|---|
| String | int / embstr | raw | 整数溢出 / > 44 字节 |
| List | listpack | quicklist | > 128 元素 或 单元素 > 64B |
| Hash | listpack | hashtable | > 128 字段 或 单字段 > 64B |
| Set | intset / listpack | hashtable | > 128 元素 或 非整数 |
| Sorted Set | listpack | skiplist + dict | > 128 元素 或 单元素 > 64B |
这些阈值均可通过 *-max-*-entries / *-max-*-value 配置调整.
当你把事件循环、协议解析、对象编码这些“在线路径”看顺之后,持久化就会显得更好理解:它们大多不在命令执行的热路径里,而是通过后台子进程/线程、以及 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 persistence 的 latest_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 片段.
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).
前面的内容基本都在“单机一进程”范围内. 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(replid 和 replid2),用于处理故障转移场景. 当 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 高性能的基础.