Redis 源码

发布于 2026-05-29

目录

Redis 源码架构总览

Redis 采用事件驱动单线程模型(主事件循环 + 多线程执行),核心架构分为以下几个层次:

┌─────────────────────────────────────────────────────────────┐
│                      main() 入口                            │
└─────────────────────┬───────────────────────────────────────┘

┌─────────────────────▼───────────────────────────────────────┐
│                 aeMain() 事件循环                           │
│  ┌─────────────┐  ┌─────────────┐                          │
│  │  文件事件    │  │  时间事件    │   ← ae.c/ae_*.c         │
│  └──────┬──────┘  └─────────────┘                          │
│         │                                                  │
│  ┌──────▼──────────────────────────────────────────┐       │
│  │            网络层 networking.c                   │       │
│  └──────┬──────────────────────────────────────────┘       │
│         │                                                  │
│  ┌──────▼──────────────────────────────────────────┐       │
│  │     协议解析 → 命令查找 → processCommand()       │       │
│  └──────┬──────────────────────────────────────────┘       │
│         │                                                  │
│  ┌──────▼──────────────────────────────────────────┐       │
│  │   数据类型实现 (t_string.c / t_list.c ...)     │       │
│  └──────┬──────────────────────────────────────────┘       │
│         │                                                  │
│  ┌──────▼──────────────────────────────────────────┐       │
│  │    数据结构层 (dict/sds/quicklist/ziplist)     │       │
│  └──────┬──────────────────────────────────────────┘       │
│         │                                                  │
│  ┌──────▼──────────────────────────────────────────┐       │
│  │            持久化层 (rdb.c / aof.c)               │       │
│  │            集群 (cluster.c)                       │       │
│  │            复制 (replication.c)                  │       │
│  │            后台IO (bio.c)                        │       │
│  └─────────────────────────────────────────────────┘       │
└─────────────────────────────────────────────────────────────┘

入口

src/server.c 作为启动,入口函数main() (第 7462-7807 行)

首先是

int main(int argc, char **argv) {

由操作系统在程序启动时自动传入:

./redis-server --port 6379 --daemonize yes

启动时,操作系统会把这些值填进 main 的参数:

argc = 3
argv = ["./redis-server", "--port", "6379", "--daemonize", "yes"]

为什么我要说上述这些,等会说


后续可以分为 7 个阶段:

  1. 基础环境初始化(随机种子、内存、哈希种子)
  2. 配置加载
  3. multi-call binary
  4. 命令行解析
  5. 系统检查 & 守护进程化
  6. 核心服务初始化(数据库、网络、集群、数据恢复)
  7. 进入事件循环 aeMain() —— 永不返回

基础环境初始化

挑出几个来说

    /* We need to initialize our libraries, and the server configuration. */
#ifdef INIT_SETPROCTITLE_REPLACEMENT
    spt_init(argc, argv);      // 进程标题替换的准备
#endif
tzset();                       // 初始化时区
zmalloc_set_oom_handler(...)   // 内存分配失败时的回调(OOM handler)

    // 随机种子——用 time ^ pid ^ 微秒,容器中也能保证足够随机
    srand(time(NULL)^getpid()^tv.tv_usec);
srandom(...);
init_genrand64(...);           // Mersenne Twister 64 位随机数

crc64_init();                  // CRC64 查表初始化

umask(server.umask = umask(0777));  // 保存当前 umask

// 字典哈希种子——防止哈希碰撞攻击
getRandomBytes(hashseed, 16);
dictSetHashFunctionSeed(hashseed);

spt_init

spt_init(argc, argv);是进程标题替换的准备,什么意思,是因为命令启动以后可读性差所以要改进程,我们需要修改 argv[0] 指向的那段内存

比如程序刚启动时ps 看到的是:

$ ps aux | grep redis
heriec  12345  0.0  0.1  ...  ./redis-server *:6379

但 Redis 启动后要处理很多客户端连接,每个客户端执行不同的命令。如果只看进程列表,你根本不知道它在干嘛——是空闲还是在处理慢命令?有多少连接?

改成 setproctitle 之后,ps 看到的是:

heriec  12345  0.0  0.1  ...  redis-server: 10.0.0.1:54321 [2 commands] | GET foo
heriec  12345  0.0  0.1  ...  redis-server: 10.0.0.2:54322 [1 command] | SET bar

一眼就能看到

所以在spt_init 里面就是获取到命令的地址,后续可以今天替换 args 地址里的值

getRandomBytes

哈希种子是每次启动随机生成的,攻击者无法预测,从而防止构造大量哈希碰撞导致 O(n) 退化。

可以直接看源码内容,并且注释有说明是调用/dev/urandom 的能力,如果 /dev/urandom is not available, a weaker seed is used.

配置加载

char *exec_name = strrchr(argv[0], '/');  // 取出程序名

// 判断是否 sentinel 模式
server.sentinel_mode = checkForSentinelMode(argc, argv, exec_name);

initServerConfig();        // 设置所有配置项的默认值
ACLInit();                 // 初始化 ACL 权限系统
moduleInitModulesSystem(); // 初始化模块系统
connTypeInitialize();      // 初始化连接类型(TCP/TLS/Unix socket)

// 如果是 sentinel,提前初始化
if (server.sentinel_mode) {
    initSentinelConfig();
    initSentinel();
}

multi-call binary

什么意思,其实本质上运行的还是一个 main 函数入口,但是是多个不同的命令

// 如果程序名包含 "redis-check-rdb" / "redis-check-aof"
// 直接进入对应的 main,不会启动 Redis 服务
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);        // 不返回

命令行解析

if (argc >= 2) {
      // --version / --help / --test-memory 等特殊选项,处理后直接 exit

      // 第一个参数不是 '-' 开头?当作配置文件路径
      if (argv[1][0] != '-') {
          server.configfile = getAbsolutePath(argv[1]);
      }

      // 后续所有 --xxx value 形式的参数,拼接成配置字符串
      // 例如 --port 6380 → "port 6380\n"
      while (j < argc) {
          // ... 拼接到 options 字符串
      }

      loadServerConfig(server.configfile, config_from_stdin, options);
      //                 ↑ 配置文件           ↑ stdin 输入     ↑ 命令行选项
  }

优先级:命令行参数 > stdin 配置 > redis.conf 文件

系统检查 & 守护进程化

// Linux 专属检查
linuxMemoryWarnings();         // 内存 overcommit 警告
checkXenClocksource(&err_msg); // Xen 时钟源警告
checkLinuxMadvFreeForkBug(...) // ARM64 COW bug 检测

// 守护进程化
server.supervised = redisIsSupervised(server.supervised_mode);
if (server.daemonize && !server.supervised)
  daemonize();  // fork 后父进程退出,子进程成为守护进程

// 打印启动日志
serverLog(LL_NOTICE, "oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo");

核心服务初始化

7734-7794 行

这是最重要的阶段,调用链:

initServer()           ─── 核心初始化

├── signal(SIGHUP/SIGPIPE, SIG_IGN)   信号处理
├── setupSignalHandlers()              注册 SIGTERM 等信号
├── ThreadsManager_init()              线程管理器

├── createSharedObjects()              预创建共享对象("OK","ERR",0-9999整数对象...)
├── adjustOpenFilesLimit()             调整 fd 上限
├── monotonicInit()                    单调时钟

├── aeCreateEventLoop()          ★ 创建事件循环(epoll/kqueue/select)

├── for (j=0; j<dbnum; j++)           创建所有数据库
│   ├── kvstoreCreate() → keys        键空间
│   ├── kvstoreCreate() → expires     过期字典
│   └── ...

├── evictionPoolAlloc()                LRU/LFU 淘汰池
├── server.pubsub_channels = ...       Pub/Sub 数据结构
└── ...

clusterInit()          ─── 集群初始化(如果开启)
moduleLoadFromQueue()  ─── 加载模块
ACLLoadUsersAtStartup()─── 加载 ACL 用户
initListeners()        ─── 绑定端口 6379,注册 accept handler

InitServerLast()       ─── 最后阶段
├── bioInit()                  ★ 启动后台 I/O 线程(关闭文件、AOF fsync、lazyfree)
├── initThreadedIO()           ★ 启动多线程 I/O(读写客户端请求)
└── set_jemalloc_bg_thread()     jemalloc 后台线程

// 数据恢复
loadDataFromDisk()     ─── ★ 加载 RDB 或 AOF,恢复数据到内存

initServer 函数核心:

void initServer(void) {
    // 1. 信号处理 - 忽略 SIGHUP(终端关闭),处理 SIGTERM/SIGINT(优雅关闭)
    signal(SIGHUP, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);
    setupSignalHandlers();

    // 2. 创建客户端队列、数据库、pubsub 等核心数据结构
    server.clients = listCreate();
    server.clients_index = raxNew();
    server.db = zmalloc(sizeof(redisDb) * server.dbnum);

    // 3. 为每个数据库创建数据字典
    for (j = 0; j < server.dbnum; j++) {
        server.db[j].keys = kvstoreCreate(...);   // 键空间
        server.db[j].expires = kvstoreCreate(...); // 过期时间
        server.db[j].blocking_keys = dictCreate(...); // BLPOP 阻塞键
    }

    // 4. 创建事件循环 - ae = "another event loop"
    server.el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);

    // 5. 注册定时任务:serverCron 每秒执行多次
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL);

    // 6. 注册睡眠前后的钩子
    aeSetBeforeSleepProc(server.el, beforeSleep);
    aeSetAfterSleepProc(server.el, afterSleep);
}

进入事件循环

7804 行 — 程序的”心脏”

aeMain(server.el);     // 进入主事件循环,永不返回

ae.c 中的实现

void aeMain(aeEventLoop *eventLoop) {
  eventLoop->stop = 0;
  while (!eventLoop->stop) {
      aeProcessEvents(eventLoop,
          AE_ALL_EVENTS |
          AE_CALL_BEFORE_SLEEP |   // 每轮循环前:处理待写客户端、AOF 刷盘...
          AE_CALL_AFTER_SLEEP);    // 每轮循环后:处理新的 I/O 事件
  }
}

aeDeleteEventLoop(server.el);  // 只有 stop=1 才会到这里
return 0;

事件循环是 Redis 的核心,它不断地:

  1. beforeSleep → 刷写客户端缓冲区、AOF fsync、处理异步任务
  2. epoll_wait / kqueue → 等待网络事件(新连接、客户端请求、可写事件)
  3. afterSleep → 处理被唤醒后的逻辑
  4. 处理时间事件 → 定时执行 serverCron()(1秒10次,负责过期清理、持久化触发、统计等)

源码数据结构

redisServer

这个结构体有 500+ 个字段,是整个 Redis 的”全局状态”,源码里用一个全局变量 server 来持有它。可以按功能分成 12 个区域:

struct redisServer {
  ┌─── ① 进程基本信息 ───────────────────────┐
  ├─── ② 数据库核心 (redisDb) ───────────────┤
  ├─── ③ 命令表 ─────────────────────────────┤
  ├─── ④ 事件循环 ───────────────────────────┤
  ├─── ⑤ 网络与客户端 ───────────────────────┤
  ├─── ⑥ 多线程 I/O ────────────────────────┤
  ├─── ⑦ RDB 持久化 ────────────────────────┤
  ├─── ⑧ AOF 持久化 ────────────────────────┤
  ├─── ⑨ 主从复制 ──────────────────────────┤
  ├─── ⑩ 内存管理与淘汰 ─────────────────────┤
  ├─── ⑪ 集群 ──────────────────────────────┤
  └─── ⑫ 统计信息 ──────────────────────────┘
};

进程基本信息

pid_t pid;                     // 当前进程 PID
pthread_t main_thread_id;      // 主线程 ID
char *configfile;              // 配置文件绝对路径
char *executable;              // 可执行文件绝对路径
char **exec_argv;              // 启动参数的副本(用于 restart)

int hz;                        // serverCron() 每秒执行次数,默认 10
int dynamic_hz;                // 是否根据客户端数量动态调整 hz
int cronloops;                 // serverCron() 已执行次数

char runid[41];                // 每次启动随机生成的 40 字符 ID
int sentinel_mode;             // 是否哨兵模式
int daemonize;                 // 是否后台运行

hz 很重要——它决定了 serverCron() 定时任务的频率,过期清理、持久化触发、统计采样都靠它。

数据库核心 — redisDb

redisDb *db;                   // 数据库数组,默认 16 个(db[0] ~ db[15])
int dbnum;                     // 数据库个数

每个 redisDb 的结构:

typedef struct redisDb {
  kvstore *keys;             // ★ 键空间:所有 key-value 都存这里
  kvstore *expires;          // ★ 过期字典:记录哪些 key 有 TTL
  estore *subexpires;        //   子key过期(比如 hash 的 field 级 TTL)

  dict *blocking_keys;       //   BLPOP 等阻塞命令等待的 key
  dict *ready_keys;          //   有新数据到达的阻塞 key(可以唤醒客户端了)
  dict *watched_keys;        //   WATCH 命令监视的 key(用于事务 CAS)

  int id;                    //   数据库编号(0-15)
  long long avg_ttl;         //   平均 TTL,统计用
  unsigned long expires_cursor; // 主动过期的扫描游标
} redisDb;

用一张图表示数据如何存储:

server.db[0]                      server.db[1]  ...  server.db[15]

  ├── keys (kvstore)
  │     ├── "user:1"robj(string, "Alice")
  │     ├── "user:2"robj(string, "Bob")
  │     ├── "scores"robj(zset, ...)
  │     └── "list:q"robj(quicklist, ...)

  └── expires (kvstore)
        ├── "user:1"1700000000000 (毫秒时间戳)
        └── "list:q"1700003600000

执行 SET key value 时写入 db->keys,执行 EXPIRE key 60 时同时在 db->expires 记一条。

命令表

dict *commands;                // ★ 命令哈希表:"GET" → getCommand 函数指针
dict *orig_commands;           //   重命名之前的原始命令表

Redis 启动时通过 populateCommandTable() 把所有命令注册进去。客户端发来 GET foo,就从这个字典查到 getCommand 函数来执行。

事件循环

aeEventLoop *el;               // ★ 事件循环(epoll/kqueue 的封装)

这是 Redis 的心脏。只有一个,主线程跑。所有的网络 I/O 事件和定时任务都注册在这里面。

网络与客户端

// 监听配置
int port;                      // TCP 端口,默认 6379
int tls_port;                  // TLS 端口
char *unixsocket;              // Unix 域套接字路径
char *bindaddr[16];            // 绑定地址列表
connListener listeners[...];   // 监听器数组

客户端管理

list *clients;                 // ★ 所有活跃客户端链表
list *clients_to_close;        //   待异步关闭的客户端
list *clients_pending_write;   //   有数据要写的客户端
list *clients_pending_read;    //   有数据要读的客户端
list *slaves, *monitors;       //   从节点 / MONITOR 客户端
rax *clients_index;            //   按 client ID 索引的基数树

client *current_client;        //   当前正在处理的客户端
uint64_t next_client_id;       //   下一个客户端 ID(自增)
unsigned int maxclients;       //   最大并发连接数

多线程 I/O

int io_threads_num;            // I/O 线程数量(默认 1 = 单线程)
int io_threads_do_reads;       // 是否用多线程处理读
int io_threads_active;         // I/O 线程当前是否激活

Redis 6.0 引入的多线程只处理网络读写和协议解析,命令执行仍然是单线程的。

RDB 持久化

long long dirty;               // 自上次保存以来的修改次数
struct saveparam *saveparams;  // 自动保存规则(如 "900 秒内有 1 次修改")
char *rdb_filename;            // RDB 文件名,默认 "dump.rdb"
int rdb_compression;           // 是否压缩
time_t lastsave;               // 上次成功保存的时间
pid_t child_pid;               // BGSAVE 子进程 PID
int lastbgsave_status;         // 上次 BGSAVE 结果

dirty 计数器是触发自动 BGSAVE 的关键——每次写命令 dirty++,serverCron() 定期检查是否满足 saveparams 的条件。

AOF 持久化

int aof_enabled;               // 是否开启 AOF
int aof_state;                 // AOF_ON / AOF_OFF / AOF_WAIT_REWRITE
int aof_fsync;                 // fsync 策略: always / everysec / no
sds aof_buf;                   // ★ AOF 缓冲区(每次写命令先追加到这里)
int aof_fd;                    // AOF 文件描述符
off_t aof_current_size;        // 当前 AOF 文件大小
int aof_rewrite_perc;          // 增长百分比触发重写
off_t aof_rewrite_min_size;    // 最小大小触发重写
aofManifest *aof_manifest;     // AOF 清单(多文件 AOF,Redis 7.0+)

写命令流程:命令执行 → 追加到 aof_buf → 事件循环 beforeSleep 时 write() → 根据 fsync 策略刷盘。

主从复制

// 作为 master
char replid[41];               // 复制 ID
long long master_repl_offset;  // 当前复制偏移量
replBacklog *repl_backlog;     // ★ 复制积压缓冲区(用于部分重同步 PSYNC)
long long repl_backlog_size;   // 积压缓冲区大小,默认 1MB

// 作为 slave
char *masterhost;              // 主节点地址
int masterport;                // 主节点端口
client *master;                // 指向主节点的客户端连接
int repl_state;                // 复制状态机
client *cached_master;         // 缓存的主节点(断线后用于 PSYNC)

内存管理与淘汰

unsigned long long maxmemory;       // 最大内存限制
int maxmemory_policy;               // 淘汰策略: noeviction/allkeys-lru/volatile-ttl/...
int maxmemory_samples;              // LRU/LFU 采样数量(默认 5)
unsigned int lruclock;              // ★ 全局 LRU 时钟(每 100ms 更新一次)
int lfu_log_factor;                 // LFU 对数因子
int lfu_decay_time;                 // LFU 衰减时间

lruclock 很巧妙——不是每个 key 都存真实时间戳,而是存一个低精度的”时钟值”,节省内存。

集群

int cluster_enabled;                // 是否开启集群模式
struct clusterState *cluster;       // ★ 集群状态(节点信息、槽位分配等)
mstime_t cluster_node_timeout;      // 节点超时判定时间
char *cluster_configfile;           // 集群配置文件(nodes.conf)

统计信息 (stat_*)

time_t stat_starttime;              // 启动时间
long long stat_numcommands;         // 处理的命令总数
long long stat_numconnections;      // 接受的连接总数
long long stat_expiredkeys;         // 过期删除的 key 总数
long long stat_evictedkeys;         // 内存淘汰的 key 总数
long long stat_keyspace_hits;       // 键命中次数
long long stat_keyspace_misses;     // 键未命中次数
size_t stat_peak_memory;            // 内存使用峰值
long long stat_fork_time;           // 最近一次 fork 耗时

这些就是 INFO 命令返回的那些数据的来源。


存储数据的数据结构

Redis 的数据结构分成 两层:

        用户看到的(Redis 类型)
┌────────┬──────┬──────┬──────┬──────┬──────┐
│ String │ List │ Set  │ZSet  │ Hash │Stream│
└────┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┘
     │      │      │      │      │      │
     ▼      ▼      ▼      ▼      ▼      ▼      ← 通过 redisObject 桥接
┌──────────────────────────────────────────┐
│  底层数据结构(实际内存中的实现)              │
│  SDS, dict, quicklist, listpack,         │
│  intset, skiplist, rax ...               │
└──────────────────────────────────────────┘

中间的桥梁就是 redisObject:

struct redisObject {
  unsigned type:4;        // 哪种 Redis 类型(STRING/LIST/SET/...)
  unsigned encoding:4;    // 用了哪种底层结构(见下表)
  unsigned lru:24;        // LRU/LFU 淘汰信息
  unsigned refcount:30;   // 引用计数
  void *ptr;              // ★ 指向真正的底层数据结构
};

同一种 Redis 类型,根据数据量大小会选择不同的底层编码,省内存:

┌────────────┬──────────────────────────────────┬────────────────────────┐
│ Redis 类型  │      数据量小时(encoding)        │ 数据量大时(encoding)    │  
├────────────┼──────────────────────────────────┼────────────────────────┤
│   String   │ int(纯整数)/ embstr(≤44字节)    │       raw(SDS)        │
├────────────┼──────────────────────────────────┼────────────────────────┤
│    List    │             listpack             │       quicklist        │
├────────────┼──────────────────────────────────┼────────────────────────┤
│    Set     │    intset(纯整数)/ listpack      │          dict          │
├────────────┼──────────────────────────────────┼────────────────────────┤
│    ZSet    │             listpack             │    skiplist + dict     │
├────────────┼──────────────────────────────────┼────────────────────────┤
│    Hash    │      listpack / listpack_ex      │          dict          │
├────────────┼──────────────────────────────────┼────────────────────────┤
│   Stream   │          rax + listpack          │     rax + listpack     │
└────────────┴──────────────────────────────────┴────────────────────────┘

SDS(Simple Dynamic String)

源文件:sds.h / sds.c

typedef char *sds;  // sds 就是 char*,指向 buf      24行

根据字符串长度选不同的 header,节省内存:

struct __attribute__((__packed__)) sdshdr8 {
  uint8_t len;       // 已用长度
  uint8_t alloc;     // 分配总量(不含 header 和 \0)
  unsigned char flags;// * 低3位表示类型(sdshdr8/16/32/64)
  char buf[];        // ★ 实际字符串数据,sds 指针指向这里
};
// 还有 sdshdr5, sdshdr16, sdshdr32, sdshdr64

内存布局:

        sds 指针指向这里

┌─────┬───────┬─────┬──────────────────┬───┐
│ len │ alloc │flags│  h e l l o       │\0
└─────┴───────┴─────┴──────────────────┴───┘
←── header ──→    ←──── buf[] ──────────→

与 C 字符串的区别:

dict(哈希表)

源文件:dict.h / dict.c

struct dict {
  dictType *type;            // 类型方法(hash函数、key比较、析构...)

  dictEntry **ht_table[2];   // ★ 两张哈希表(用于渐进式 rehash)
  unsigned long ht_used[2];  // 每张表已用的条目数

  long rehashidx;            // rehash 进度,-1 表示没在 rehash
  unsigned pauserehash;      // >0 暂停 rehash

  signed char ht_size_exp[2]; // 表大小的指数(size = 1 << exp)
};



核心设计——渐进式 rehash:

ht_table[0] (旧表)               ht_table[1] (新表)
┌───┬───┬───┬───┐         ┌───┬───┬───┬───┬───┬───┬───┬───┐
│ ● │   │ ● │ ● │   ──→   │   │ ● │   │   │ ● │   │   │ ● │
└───┴───┴───┴───┘         └───┴───┴───┴───┴───┴───┴───┴───┘
     ↑ rehashidx=2(已迁移到 bucket 2

每次 CRUD 操作时,顺便迁移一小部分 bucket

全部迁移完 → 释放 ht_table[0],ht_table[1] 变成 [0]

为什么不一次性 rehash:

Redis 是单线程的,一次性搬百万个 key 会阻塞服务。渐进式把搬迁分摊到每次操作中,用户无感知。

listpack(紧凑列表)

源文件:listpack.c(没有独立头文件里的 struct,纯字节流)

所以操作时候直接根据地址读取实际数据,非常紧凑

┌──────────┬─────────┬─────────┬─────────┬─────┐
│ total-len│ entry1  │ entry2  │ entry3  │ EOF │
│ (4 bytes)│         │         │         │0xFF
└──────────┴─────────┴─────────┴─────────┴─────┘

每个 entry:
┌──────────┬──────────┬──────────┐
│ encoding │ data     │ backlen  │
│(元素类型) │(实际数据) │(本entry  │
│          │          │ 总长度)  │
└──────────┴──────────┴──────────┘

特点:所有数据紧凑排列在一块连续内存里,没有指针,极致省内存。用 backlen 支持反向遍历。是老版 ziplist 的替代品(修复了级联更新问题)。

quicklist(List 的底层)

源文件:quicklist.h / quicklist.c

typedef struct quicklist {
  quicklistNode *head;
  quicklistNode *tail;
  unsigned long count;        // 所有 entry 的总数
  unsigned long len;          // node 的个数
  signed int fill : 16;      // 每个 node 最大容量
  unsigned int compress : 16; // 两端不压缩的 node 深度
} quicklist;

typedef struct quicklistNode {
  struct quicklistNode *prev;
  struct quicklistNode *next;
  unsigned char *entry;       // ★ 指向一个 listpack
  size_t sz;                  // listpack 的字节大小
  unsigned int count : 16;    // listpack 中的元素个数
  unsigned int encoding : 2;  // RAW=1 或 LZF压缩=2
  unsigned int container : 2; // PLAIN=1 或 PACKED=2
} quicklistNode;

本质是双向链表 + listpack 的组合:

head ──→ node1 ⇄ node2 ⇄ node3 ⇄ node4 ⇄ node5 ←── tail
          │        │        │        │        │
         [lp]   [LZF压缩] [LZF压缩] [LZF压缩]  [lp]
         不压缩   ←── compress=1 ──→            不压缩

intset(整数集合)

源文件:intset.h / intset.c

typedef struct intset {
  uint32_t encoding;   // 编码方式:int16 / int32 / int64
  uint32_t length;     // 元素个数
  int8_t contents[];   // ★ 有序整数数组
} intset;

encoding = INTSET_ENC_INT16, length = 5

contents: [ -20 | 1 | 3 | 50 | 100 ]
          ←── 有序排列,二分查找 ──→

特点:

skiplist(跳表)— ZSet 专用

源文件:server.h(结构定义)/ t_zset.c(实现)

typedef struct zskiplistNode {
  sds ele;                        // 成员值
  double score;                   // 分数
  struct zskiplistNode *backward; // 后退指针(只有第1层有)
  struct zskiplistLevel {
      struct zskiplistNode *forward;  // 前进指针
      unsigned long span;             // 跨度(用于计算 rank)
  } level[];                      // ★ 柔性数组,层高随机决定
} zskiplistNode;

typedef struct zskiplist {
  struct zskiplistNode *header, *tail;
  unsigned long length;           // 节点数
  int level;                      // 当前最大层数
} zskiplist;

level 3:  H ──────────────────────────→ 50 ──────────────→ NULL
level 2:  H ──────────→ 20 ──────────→ 50 ──────────────→ NULL
level 1:  H ───→ 10 ──→ 20 ──→ 30 ──→ 50 ──→ 70 ──→ 90NULL
                ↑      ↑      ↑      ↑      ↑      ↑
             backward 链(第1层双向)

ZSet 的完整结构:

typedef struct zset {
  dict *dict;       // member → score 的映射(O(1) 按成员查分数)
  zskiplist *zsl;   // 按 score 排序(O(log n) 范围查询)
} zset;

为什么用跳表不用红黑树:

rax(基数树)— Stream 底层

源文件:rax.h / rax.c

typedef struct raxNode {
  uint32_t iskey:1;    // 该节点是否包含一个 key
  uint32_t isnull:1;   // 关联的 value 是否为 NULL
  uint32_t iscompr:1;  // 是否压缩节点
  uint32_t size:29;    // 子节点数 或 压缩字符串长度
  unsigned char data[]; // 子节点字符 + 子节点指针 + value指针
} raxNode;

typedef struct rax {
  raxNode *head;
  uint64_t numele;     // key 总数
  uint64_t numnodes;   // 节点总数
} rax;

存储 "foo"=1, "foobar"=2, "footer"=3:

    [fo]                    ← 压缩节点 (iscompr=1)

    [o]                     ← 普通节点 (iscompr=0)
    / \
  [o] [t]
   │    │
(key=1) [er]               ← 压缩节点
  │       │
[bar]   (key=3)

(key=2)

用途:Stream 的消息 ID 是有序的,rax 按 ID 前缀压缩存储,每个叶子节点挂一个 listpack 存具体消息内容。


总结

底层结构源文件时间复杂度谁在用核心特点
SDSsds.hO(1)取长度所有字符串二进制安全,5种header按长度选
dictdict.hO(1)增删查Hash大、Set大、键空间渐进式rehash,两张表交替
quicklistquicklist.hO(1)头尾操作List双向链表 + listpack + LZF压缩
listpacklistpack.cO(n)遍历小Hash/小Set/小ZSet/quicklist内部连续内存,无指针,极致省内存
intsetintset.hO(log n)查找纯整数小Set有序数组 + 自动升级编码
skiplistserver.hO(log n)增删查ZSet大多层链表,范围查询高效
raxrax.hO(k)按key长度Stream、Cluster slots基数树,前缀压缩

持久化策略

RDB(Redis Database)— 快照

核心思想:某个时刻,把内存中所有数据序列化成一个二进制文件(dump.rdb)。

触发方式分为:

自动触发

serverCron() 里定期检查(server.c:1580):

for (j = 0; j < server.saveparamslen; j++) {
  struct saveparam *sp = server.saveparams+j;
  if (server.dirty >= sp->changes &&
      server.unixtime - server.lastsave > sp->seconds &&
      ...)
  {
      rdbSaveBackground(...);  // 满足条件,触发后台保存
      break;
  }
}

配置文件中的规则:

dirty 是计数器,每次写命令 dirty++,BGSAVE 成功后清零。

手动触发

SAVE(阻塞)或 BGSAVE(后台)

bgsave

BGSAVE 的实现(rdb.c:1676)

int rdbSaveBackground(...) {
  if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
      /* 子进程 */
      retval = rdbSave(req, filename, rsi, rdbflags);
      exitFromChild(...);
  } else {
      /* 父进程:继续服务客户端 */
      server.rdb_save_time_start = time(NULL);
  }
}

图解关系:


          主进程                          子进程
            │                               │
fork() ─────┼───────────────────────────→  创建
            │ 继续处理客户端请求              │ 遍历所有 db
            │                               │ 序列化 key-value 到 temp 文件
            │                               │ rename → dump.rdb
            │                               │ exit
            │ ←── 收到 SIGCHLD ─────────────┘
            │ 更新 lastsave, dirty=0

关键:利用操作系统的 Copy-On-Write(写时复制)。fork 后子进程和父进程共享同一块物理内存,只有父进程写某个页时才会复制该页。所以 fork 本身几乎是瞬时的,内存也不会翻倍。

save

RDB 的 save 流程(rdb.c:1633)

int rdbSave(...) {
  snprintf(tmpfile, 256, "temp-%d.rdb", (int)getpid());
  rdbSaveInternal(req, tmpfile, rsi, rdbflags);  // 写临时文件

  rename(tmpfile, filename);  // ★ 原子替换,保证文件完整性
  fsyncFileDir(filename);

  server.dirty = 0;
  server.lastsave = time(NULL);
}

先写临时文件,最后 rename 原子替换——即使中途崩溃,旧的 dump.rdb 也不会损坏。

AOF(Append Only File)— 追加日志

核心思想:每次写命令,都追加记录到日志文件,恢复时重放命令。

写入流程

分三步——追加 → 写入 → 刷盘:

SET foo bar


feedAppendOnlyFile()          命令 → RESP 格式 → 追加到 aof_buf


flushAppendOnlyFile()         aof_buf → write() 到内核缓冲区
    │                           (在事件循环的 beforeSleep 中调用)

fsync()                       内核缓冲区 → 磁盘

第①步:命令追加(aof.c:1409)

void feedAppendOnlyFile(int dictid, robj **argv, int argc) {
  sds buf = sdsempty();

  // 如果切了数据库,先写 SELECT
  if (dictid != server.aof_selected_db) {
      buf = sdscatprintf(buf, "*2\r\n$6\r\nSELECT\r\n$%lu\r\n%s\r\n", ...);
  }

  // 把命令转成 RESP 协议格式
  buf = catAppendOnlyGenericCommand(buf, argc, argv);

  // 追加到内存缓冲区(不是直接写磁盘)
  server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
}

AOF 文件内容示例:

*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n

就是 RESP 协议的文本格式,可读性强。


第②③步:写入+刷盘(aof.c:1147)

void flushAppendOnlyFile(int force) {
  // ② write() 到内核
  nwritten = aofWrite(server.aof_fd, server.aof_buf, sdslen(server.aof_buf));

  // ③ 根据策略 fsync
  if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
      redis_fsync(server.aof_fd);               // 每次都刷盘
  }
  else if (server.aof_fsync == AOF_FSYNC_EVERYSEC &&
           server.mstime - server.aof_last_fsync >= 1000) {
      aof_background_fsync(server.aof_fd);       // 后台线程每秒刷一次
  }
  // AOF_FSYNC_NO: 不主动 fsync,交给 OS 决定
}

这里有三种 fsync 策略,是 AOF 最核心的区别:

三种策略对比:

              write()          fsync()           丢失数据上限
                │                 │
always:     每次写命令 ──→ 每次写命令立刻刷盘     几乎不丢
                                                 (最安全,最慢)

everysec:   每次写命令 ──→ 后台线程每秒刷一次     最多丢 1
           (默认策略)                             (安全和性能的平衡)

no:         每次写命令 ──→ 交给操作系统决定       取决于 OS(通常 30 秒)
                                                 (最快,最不安全)

重写

AOF 重写(aof.c:2516)

AOF 文件会越来越大(同一个 key 反复修改,所有历史命令都记着),需要重写压缩:

int rewriteAppendOnlyFile(char *filename) {
  if (server.aof_use_rdb_preamble) {
      rdbSaveRio(...);       // ★ 混合持久化:前半部分用 RDB 格式
  } else {
      rewriteAppendOnlyFileRio(...);  // 纯 AOF:遍历数据库,生成最小命令集
  }
}

重写前后对比:

重写前(记录了所有历史命令):
    SET counter 1
    INCR counter
    INCR counter
    INCR counter
    DEL tmp
    SET counter 100

重写后(只保留最终状态):
    SET counter 100

重写流程(源码注释 aof.c:2582):

1) BGREWRITEAOF 触发
2) fork():
 2a) 子进程:遍历内存,生成新 AOF(临时文件)
 2b) 父进程:新的写命令追加到 INCR AOF 文件(Redis 7.0+
3) 子进程完成,退出
4) 父进程:
 4a) 把临时文件标记为新的 BASE 文件
 4b) 旧的 INCR 文件标记为 HISTORY
 4c) 更新 AOF manifest 文件
 4d) 后台删除 HISTORY 文件

Redis 7.0+ 采用 Multi-Part AOF(多文件 AOF):

appendonlydir/
├── appendonly.aof.1.base.rdb    ← BASE:重写后的基准(可以是 RDB 格式)
├── appendonly.aof.1.incr.aof    ← INCR:重写之后的增量命令
├── appendonly.aof.2.incr.aof    ← INCR:更多增量
└── appendonly.aof.manifest      ← 清单文件:记录上面文件的顺序

混合持久化(RDB + AOF Preamble)

从源码看到(aof.c:2539):

if (server.aof_use_rdb_preamble) {
  rdbSaveRio(...);   // AOF 文件的前半部分用 RDB 二进制格式
} else {
  rewriteAppendOnlyFileRio(...);  // 纯文本 RESP 格式
}

图示:

┌───────────────────────────────────────────┐
│  appendonly.aof.1.base.rdb
│  ┌─────────────────────────────────────┐  │
│  │  RDB 二进制格式(全量快照)          │  │  ← 恢复快
│  └─────────────────────────────────────┘  │
├───────────────────────────────────────────┤
│  appendonly.aof.1.incr.aof
│  ┌─────────────────────────────────────┐  │
│  │  RESP 文本格式(增量命令)           │  │  ← 不丢数据
│  └─────────────────────────────────────┘  │
└───────────────────────────────────────────┘

这是默认配置(aof-use-rdb-preamble yes),兼顾了 RDB 的恢复速度和 AOF 的数据安全性。

启动时数据恢复

从 server.c:7180 的 loadDataFromDisk():

void loadDataFromDisk(void) {
  if (server.aof_state == AOF_ON) {
      loadAppendOnlyFiles(server.aof_manifest);  // 优先加载 AOF
  } else {
      rdbLoad(server.rdb_filename, ...);          // 否则加载 RDB
  }
}

AOF 优先级更高,因为 AOF 数据通常更完整。

过期策略

Redis 有两种过期删除策略:惰性删除定期删除,二者配合使用。


客户端执行 GET key


 ① 惰性删除(被动)                     ② 定期删除(主动)
 expireIfNeeded()                     activeExpireCycle()
 每次访问 key 时触发                    serverCron() 定时触发
 db.c:2737                            expire.c:287
       │                                    │
       ▼                                    ▼
 发现过期 → 立刻删除                    随机抽样 → 发现过期 → 删除
 只处理当前这一个 key                   批量扫描多个 DB 的 expires 字典

为什么需要两种策略配合?


惰性删除(Lazy Expire)

每次读写 key 之前,先检查它是否过期,过期就删除并返回”不存在”。

客户端发送 GET foo


lookupKey()                     ← db.c:250

    ├── kvstoreDictFind()       ← 从 db->keys 中查找 key

    └── expireIfNeeded()        ← 检查是否过期

            ├── keyIsExpired()              ← 判断是否过期
            │     比较 now > expire_time

            └── deleteExpiredKeyAndPropagate()  ← 过期则删除

                  ├── 从 db->keys 删除
                  ├── 从 db->expires 删除
                  └── 传播 DEL 到 AOF 和从节点

源码:expireIfNeeded()(db.c:2737)

keyStatus expireIfNeeded(redisDb *db, robj *key, kvobj *kv, int flags) {
    // 1. 检查是否过期
    if (!keyIsExpired(db, key->ptr, kv))
        return KEY_VALID;       // 没过期,正常返回

    // 2. 从节点不主动删除,等主节点的 DEL
    if (server.masterhost != NULL && !(flags & EXPIRE_FORCE_DELETE_EXPIRED))
        return KEY_EXPIRED;     // 告诉调用方"当作不存在",但不真的删

    // 3. 主节点:执行删除
    deleteExpiredKeyAndPropagate(db, key);
    return KEY_DELETED;
}

源码:keyIsExpired()(db.c:2679)

int keyIsExpired(redisDb *db, sds key, kvobj *kv) {
    if (server.loading) return 0;     // 正在加载数据时不过期

    mstime_t when = getExpire(db, key, kv);  // 获取过期时间
    if (when < 0) return 0;           // 没有设置过期时间

    const mstime_t now = commandTimeSnapshot();
    return now > when;                // 当前时间 > 过期时间 → 过期了
}

调用时机

expireIfNeeded()lookupKey() 内部调用(db.c:262),而几乎所有读写命令都经过 lookupKey()

// db.c:262  在 lookupKey 中
if (expireIfNeeded(db, key, val, expire_flags) != KEY_VALID) {
    val = NULL;  // key 过期了,当作不存在
}

主从节点的区别

         主节点 (Master)                从节点 (Replica)
              │                              │
    expireIfNeeded()                expireIfNeeded()
              │                              │
         过期了?                         过期了?
           │ 是                            │ 是
           ▼                               ▼
    deleteExpiredKey()             不删除,返回 KEY_EXPIRED
    传播 DEL → AOF                (等主节点发来 DEL 命令)
    传播 DEL → 从节点             (保证主从数据一致性)

从节点不主动删除过期 key 的原因(db.c:2773):

if (server.masterhost != NULL && !(flags & EXPIRE_FORCE_DELETE_EXPIRED))
    return KEY_EXPIRED;  // 从节点:报告过期但不删除

这确保主从删除顺序一致,避免数据不一致。


定期删除(Active Expire)

Redis 定时主动扫描设置了过期时间的 key,随机抽样检查并删除过期的。

两种模式

模式调用位置频率时间上限
SLOWserverCron() (server.c:1218)每秒 server.hz 次(默认10)CPU 时间的 25%
FASTbeforeSleep() (server.c:1859)每次事件循环固定 1ms
// server.c:1216  在 serverCron() 中(慢周期)
if (server.active_expire_enabled) {
    if (iAmMaster()) {
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
    }
}

// server.c:1858  在 beforeSleep() 中(快周期)
if (server.active_expire_enabled && iAmMaster())
    activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);

源码:activeExpireCycle()(expire.c:287)

第一步:计算本次扫描的参数

void activeExpireCycle(int type) {
    // effort 从配置 active-expire-effort 来,默认 1,最大 10
    unsigned long effort = server.active_expire_effort - 1;  // 0~9

    // 每轮扫描的 key 数量:默认 20,effort 越大越多
    config_keys_per_loop = 20 + 20/4 * effort;    // 20 ~ 65

    // 慢周期时间上限:CPU 时间的 25%~43%
    config_cycle_slow_time_perc = 25 + 2 * effort; // 25% ~ 43%

    // 快周期时间上限:1000~3250 微秒
    config_cycle_fast_duration = 1000 + 1000/4 * effort;

    // "可接受的过期 key 比例":低于此比例就停止扫描
    config_cycle_acceptable_stale = 10 - effort;   // 10% ~ 1%

第二步:遍历数据库,随机抽样检查

    // 遍历多个 DB(上次停在哪个 DB,这次接着来)
    for (j = 0; j < server.dbnum; j++) {
        redisDb *db = server.db + (current_db % server.dbnum);
        current_db++;

        do {
            // 从 db->expires 中随机扫描 config_keys_per_loop 个 key
            while (data.sampled < num && checked_buckets < max_buckets) {
                db->expires_cursor = kvstoreScan(
                    db->expires,
                    db->expires_cursor,
                    -1,
                    expireScanCallback,  // ← 对每个 key 调用回调
                    ...
                );
            }

第三步:回调函数检查并删除过期 key

// expire.c:113
void expireScanCallback(void *privdata, const dictEntry *de, ...) {
    kvobj *kv = dictGetKV(de);
    long long ttl = kvobjGetExpire(kv) - data->now;

    if (activeExpireCycleTryExpire(data->db, kv, data->now)) {
        data->expired++;  // 过期了,已删除
    }
    data->sampled++;      // 统计已检查的 key 数
}

// expire.c:40
int activeExpireCycleTryExpire(redisDb *db, kvobj *kv, long long now) {
    if (now < kvobjGetExpire(kv))
        return 0;  // 还没过期

    // 过期了 → 删除 key 并传播 DEL
    sds key = kvobjGetKey(kv);
    robj *keyobj = createStringObject(key, sdslen(key));
    deleteExpiredKeyAndPropagate(db, keyobj);
    decrRefCount(keyobj);
    return 1;
}

第四步:自适应判断——是否继续扫描

            // 计算本轮过期 key 的比例
            // 如果 过期数/采样数 > acceptable_stale(如 10%),继续扫描
            repeat = (data.expired * 100 / data.sampled) > config_cycle_acceptable_stale;

            // 每 16 轮迭代检查一次时间,超时就退出
            if ((iteration & 0xf) == 0) {
                elapsed = ustime() - start;
                if (elapsed > timelimit) {
                    timelimit_exit = 1;  // 标记超时
                    break;
                }
            }
        } while (repeat);  // 过期比例高就继续,低就换下一个 DB
    }
}

核心算法图示

 activeExpireCycle() 的自适应循环:

 ┌────────────────────────────────────────────────┐
 │  从 db->expires 中随机抽样 20 个 key            │
 │                                                │
 │  检查每个 key 是否过期,过期则删除               │
 │                                                │
 │  统计:sampled=20, expired=?                   │
 └───────────────────┬────────────────────────────┘


              expired/sampled > 10% ?
                /           \
              是              否
              │               │
              ▼               ▼
        继续扫描当前 DB    停止,换下一个 DB
        (说明还有大量       (过期 key 已不多,
         过期 key 待清理)    不值得继续花 CPU)


        超过时间限制?
           │ 是

         强制退出
       (下次接着来)

自适应的精髓:过期 key 多就多花时间扫,过期 key 少就早点停。既不浪费 CPU,也不让过期 key 堆积。

FAST 和 SLOW 的区别

// FAST 模式的额外限制(expire.c:318)
if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
    // 上一轮慢周期没超时,且过期比例低 → 不需要跑快周期
    if (!timelimit_exit &&
        server.stat_expired_stale_perc < config_cycle_acceptable_stale)
        return;

    // 两次快周期间隔必须 >= 2 * fast_duration
    if (start < last_fast_cycle + config_cycle_fast_duration * 2)
        return;
}
事件循环时间线:

  ──┬─────────┬─────────┬─────────┬─────────┬──
    │ FAST    │ 处理    │ FAST    │ 处理    │
    │ (1ms)   │ 客户端  │ (1ms)   │ 客户端  │
  ──┴─────────┴─────────┴─────────┴─────────┴──

  ──────────────────┬──────────────────┬─────────
     serverCron     │     serverCron   │
     SLOW (25ms)    │     SLOW (25ms)  │
  ──────────────────┴──────────────────┴─────────
                100ms                 100ms

子 key 过期(Hash Field Expiration)— Redis 8.x 新特性

Redis 8.x 新增了 hash field 级别的过期,由 activeSubexpiresCycle() 处理。

数据结构

redisDb {
    kvstore *keys;          // 键空间
    kvstore *expires;       // key 级过期
    estore  *subexpires;    // ★ sub-key 级过期(hash field)
}

源码(expire.c:228)

static inline void activeSubexpiresCycle(int type) {
    static unsigned int currentDb = 0;

    redisDb *db = server.db + currentDb;

    // 每次最多处理的 field 数
    uint32_t maxToExpire = 10000 / server.hz;  // 默认 1000

    if (activeSubexpires(db, currentSlot, maxToExpire) == maxToExpire) {
        // 还没清完,下次继续
    } else {
        // 当前 DB 清完了,换下一个
        currentDb = (currentDb + 1) % server.dbnum;
    }
}

回调函数(expire.c:169):

static ExpireAction activeSubexpiresCb(eItem item, void *ctx) {
    kvobj *kv = (kvobj *) item;
    assert(kv->type == OBJ_HASH);  // 目前只支持 hash

    // 删除 hash 中过期的 field
    uint64_t nextExpTime = hashTypeActiveExpire(db, kv, &quota, 0);

    if (nextExpTime == INVALID) {
        return ACT_REMOVE_EXP_ITEM;   // hash 没有更多要过期的 field
    } else {
        return ACT_UPDATE_EXP_ITEM;   // 更新下一个 field 的过期时间
    }
}

维度惰性删除定期删除(SLOW)定期删除(FAST)
源码位置db.c:2737expire.c:287expire.c:287
调用者lookupKey()serverCron()beforeSleep()
频率每次读写 key每秒 hz 次(默认10)每次事件循环
时间限制无(O(1)操作)CPU 时间的 25%固定 1ms
扫描范围单个 key多个 DB,每次抽样 20 key同左,但时间更短
自适应过期比例高→继续扫上轮没超时→跳过
主从从节点不删除只在主节点执行只在主节点执行
解决的问题不返回脏数据回收无人访问的过期 key快速响应突发过期

缓冲区

Redis 有 5 种缓冲区,分别服务于不同的场景。

                        Redis Server
┌───────────────────────────────────────────────────────────┐
│                                                           │
│   客户端 A ──→ ① 输入缓冲区 (querybuf)                    │
│              ←─ ② 输出缓冲区 (buf + reply 链表)           │
│                                                           │
│   客户端 B ──→ ① 输入缓冲区                               │
│              ←─ ② 输出缓冲区                               │
│                                                           │
│   ③ AOF 缓冲区 (aof_buf)  ──→  磁盘 AOF 文件              │
│                                                           │
│   ④ 复制积压缓冲区 (repl_backlog)  ──→  从节点增量同步     │
│                                                           │
│   ⑤ 复制缓冲区 (replica output buffer) ──→ 各从节点        │
│                                                           │
└───────────────────────────────────────────────────────────┘

客户端输入缓冲区(querybuf)

客户端发来的命令数据,先读入 querybuf,等攒够一个完整命令后再解析执行。

数据结构(server.h:1377)

typedef struct client {
    sds querybuf;           // ★ 输入缓冲区,动态字符串
    size_t qb_pos;          // 已解析到的位置
    size_t querybuf_peak;   // 近期峰值(用于缩容判断)
    // ...
} client;

内存布局

客户端发送: SET foo bar\r\nGET foo\r\n

querybuf:
┌──────────────────────────────────────────┐
│ *3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\n  │
│ bar\r\n*2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n │
└──────────────────────────────────────────┘
         ↑ qb_pos(已解析到这里)

大小限制

默认上限 1GB(config.c:3264):

// config.c:3264
createSizeTConfig("client-query-buffer-limit", ...,
    server.client_max_querybuf_len,
    1024*1024*1024,   // 默认 1GB
    ...);

打满后的后果(networking.c:3300)

// networking.c:3300
if (!(c->flags & CLIENT_MASTER) &&
    (c->mstate.argv_len_sums + sdslen(c->querybuf) > server.client_max_querybuf_len ||
     (c->mstate.argv_len_sums + sdslen(c->querybuf) > 1024*1024 && authRequired(c))))
{
    c->read_error = CLIENT_READ_REACHED_MAX_QUERYBUF;
    freeClientAsync(c);     // ★ 异步关闭客户端连接
    atomicIncr(server.stat_client_qbuf_limit_disconnections, 1);
}

结果:直接断开客户端连接。 未认证客户端限制更严,只有 1MB。

典型触发场景


客户端输出缓冲区(buf + reply)

作用

命令执行结果先写入输出缓冲区,等网络可写时再发给客户端。

数据结构(server.h:1401、1503)

typedef struct client {
    // 固定缓冲区(小回复走这里,快)
    char *buf;                          // 固定大小的缓冲区
    int bufpos;                         // 已写入位置
    size_t buf_usable_size;             // 可用大小

    // 动态缓冲区(大回复走这里)
    list *reply;                        // clientReplyBlock 链表
    unsigned long long reply_bytes;     // reply 链表总字节数

    time_t obuf_soft_limit_reached_time; // 软限制首次触达时间
} client;

// server.h:1092
typedef struct clientReplyBlock {
    size_t size, used;
    char buf[];             // 柔性数组
} clientReplyBlock;

两级缓冲区设计

命令执行完毕,写回复


   固定缓冲区 buf (16KB)
   ┌────────────────┐
   │ +OK\r\n        │  ← 小回复直接写这里,零分配
   └────────────────┘
         │ 写满了

   动态缓冲区 reply (链表)
   ┌─────────┐   ┌─────────┐   ┌─────────┐
   │ block1  │──→│ block2  │──→│ block3  │
   │ 16KB    │   │ 16KB    │   │ 16KB    │
   └─────────┘   └─────────┘   └─────────┘

   reply_bytes = 所有 block 的总字节数

固定缓冲区大小(server.h:190):

#define PROTO_REPLY_CHUNK_BYTES (16*1024) /* 16KB */

**大小限制 **— 三类客户端不同限制

默认配置(config.c:153):

clientBufferLimitsConfig clientBufferLimitsDefaults[CLIENT_TYPE_OBUF_COUNT] = {
    {0, 0, 0},                         // normal:无限制(默认)
    {256*1024*1024, 64*1024*1024, 60},  // slave:硬256MB,软64MB持续60秒
    {32*1024*1024,  8*1024*1024,  60},  // pubsub:硬32MB,软8MB持续60秒
};
客户端类型硬限制(hard)软限制(soft)软限制时间
normal(普通客户端)0(无限制)0(无限制)0
slave(从节点)256MB64MB 持续 60 秒60s
pubsub(订阅客户端)32MB8MB 持续 60 秒60s

限制检查逻辑(networking.c:4613)

int checkClientOutputBufferLimits(client *c) {
    int soft = 0, hard = 0;
    unsigned long used_mem = getClientOutputBufferMemoryUsage(c);

    int class = getClientType(c);   // normal / slave / pubsub

    // 硬限制:瞬间超过就触发
    if (server.client_obuf_limits[class].hard_limit_bytes &&
        used_mem >= hard_limit_bytes)
        hard = 1;

    // 软限制:持续超过 N 秒才触发
    if (server.client_obuf_limits[class].soft_limit_bytes &&
        used_mem >= soft_limit_bytes)
        soft = 1;

    if (soft) {
        if (c->obuf_soft_limit_reached_time == 0) {
            c->obuf_soft_limit_reached_time = server.unixtime;
            soft = 0;  // 第一次触达,先不算
        } else {
            time_t elapsed = server.unixtime - c->obuf_soft_limit_reached_time;
            if (elapsed <= soft_limit_seconds)
                soft = 0;  // 还没持续够时间
        }
    }

    return soft || hard;
}

打满后的后果(networking.c:4677)

int closeClientOnOutputBufferLimitReached(client *c, int async) {
    if (checkClientOutputBufferLimits(c)) {
        // ★ 设置 CLIENT_CLOSE_ASAP 标志,异步关闭连接
        freeClientAsync(c);
        return 1;
    }
    return 0;
}

结果

触发条件后果
硬限制:用量瞬间 >= hard_limit立刻关闭连接
软限制:用量持续 >= soft_limit 超过 N 秒关闭连接
软限制:用量超过但不够 N 秒暂时容忍,降回来就清零计时

典型触发场景


AOF 缓冲区(aof_buf)

写命令执行后,先追加到内存中的 aof_buf,再在事件循环中统一写入磁盘。

数据结构(server.h:2068)

struct redisServer {
    sds aof_buf;        // ★ AOF 追加缓冲区
    int aof_fd;         // AOF 文件描述符
    int aof_fsync;      // fsync 策略: always / everysec / no
};

工作流程

  SET foo bar


  feedAppendOnlyFile()              ← aof.c:1409

      │  将命令转为 RESP 格式,追加到 aof_buf
      │  server.aof_buf = sdscatlen(server.aof_buf, buf, len)

  flushAppendOnlyFile()             ← aof.c:1147
      │                               在 beforeSleep() 中调用
      │  write(server.aof_fd, server.aof_buf, len)

      ├── always:   立即 fsync()
      ├── everysec: 后台线程每秒 fsync()
      └── no:       不主动 fsync()


  清空或复用 aof_buf

缓冲区复用逻辑(aof.c:1316)

// 写入成功后
if ((sdslen(server.aof_buf) + sdsavail(server.aof_buf)) < 4000) {
    sdsclear(server.aof_buf);   // 小于 4KB:清空内容,复用内存
} else {
    sdsfree(server.aof_buf);    // 大于 4KB:释放旧的,创建新的
    server.aof_buf = sdsempty();
}

aof_buf 本身没有硬性大小限制。它是一个 sds 动态字符串,会随写入量不断增长。

所以 aof_buf 不会被”打满”(没有上限),但如果它增长过大,会导致:

情况后果
everysec 模式下 fsync 太慢(磁盘繁忙)write() 被推迟最多 2 秒,之后强制写入(aof.c:1196)
always 模式下 write() 失败Redis 进程直接 exit(1)(aof.c:1285)
no/everysec 模式下 write() 失败停止接受写命令,等待恢复(aof.c:1291)
内存不足aof_buf 无法分配 → OOM

always 模式最严格(aof.c:1285):

if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
    serverLog(LL_WARNING,
        "Can't recover from AOF write error when the "
        "AOF fsync policy is 'always'. Exiting...");
    exit(1);  // ★ 直接退出进程
}

复制积压缓冲区(repl_backlog)

主节点维护的一个有界缓冲区,记录最近传播给从节点的写命令。当从节点短暂断连后,可以通过积压缓冲区做部分重同步(PSYNC),而不需要全量同步。

数据结构(server.h:1287、1116)

// 积压缓冲区元信息
typedef struct replBacklog {
    listNode *ref_repl_buf_node;  // 指向 repl_buffer_blocks 中的某个节点
    rax *blocks_index;            // 按 offset 索引,快速定位
    long long histlen;            // 当前有效数据长度
    long long offset;             // 第一个字节的复制偏移量
} replBacklog;

// 缓冲区数据块(共享)
typedef struct replBufBlock {
    int refcount;                 // 引用计数(backlog + 各从节点共享)
    long long id;
    long long repl_offset;        // 本块起始偏移量
    size_t size, used;
    char buf[];                   // 实际数据
} replBufBlock;

内存布局

server.repl_buffer_blocks(共享缓冲区链表)

  ┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
  │ block 1  │────→│ block 2  │────→│ block 3  │────→│ block 4  │
  │ ref=1    │     │ ref=0    │     │ ref=1    │     │ ref=2    │
  └──────────┘     └──────────┘     └──────────┘     └──────────┘
       ↑                                 ↑              ↑    ↑
       │                                 │              │    │
   repl_backlog                      Replica_A     Replica_B │
   (从这里开始)                      (读到这里)    (读到这里)  │
                                                     repl_backlog
                                                     (最新写入)

repl_backlog 和各从节点共享同一个 repl_buffer_blocks 链表,通过引用计数管理生命周期。

大小限制

配置项 repl-backlog-size,默认 1MB

// server.h:2161
long long repl_backlog_size;    /* 默认 1MB */

裁剪逻辑(replication.c:321)

void incrementalTrimReplicationBacklog(size_t max_blocks) {
    // 当 histlen > repl_backlog_size 时,从头部裁剪
    while (server.repl_backlog->histlen > server.repl_backlog_size) {
        // 只有引用计数为 1(仅 backlog 自己引用)才能裁剪
        listNode *first = listFirst(server.repl_buffer_blocks);
        replBufBlock *fo = listNodeValue(first);
        if (fo->refcount != 1) break;  // 有从节点还在用,不能裁

        fo->refcount--;
        server.repl_backlog->histlen -= fo->size;
        // 释放该 block...
    }
}

“满了”的后果

积压缓冲区是环形覆盖的——新数据写入时,最老的数据被裁剪掉:

情况后果
从节点断连时间短,数据还在 backlog 中**部分重同步(PSYNC)**成功,增量同步
从节点断连时间长,数据已被覆盖全量重同步,主节点做 BGSAVE 发 RDB
从节点断连前的 offset: 1000
当前 backlog 范围: [5000, 6000]

  1000 < 5000 → 已被覆盖 → 全量重同步

从节点输出缓冲区(Replica Output Buffer)

主节点为每个从节点维护的输出缓冲区。与普通客户端输出缓冲区结构相同(buf + reply 链表),但实际上从节点共享 repl_buffer_blocks,不独立存储数据。

大小限制

走的是客户端输出缓冲区的 slave 类型限制(config.c:155):

// slave 类型默认值
{256*1024*1024, 64*1024*1024, 60}
// 硬限制 256MB,软限制 64MB 持续 60 秒

对应配置:

client-output-buffer-limit slave 256mb 64mb 60

打满后的后果

和普通客户端一样,触发硬/软限制时断开从节点连接。断开后从节点会尝试重连:


完整对比表

缓冲区数据结构所在位置默认大小限制打满后果
客户端输入缓冲区client.querybuf (sds)每个客户端一个1GB断开客户端连接
客户端输出缓冲区client.buf (16KB) + client.reply (链表)每个客户端一个normal: 无限制 / slave: 硬256MB 软64MB / pubsub: 硬32MB 软8MB超硬限制 → 立刻断开;超软限制持续N秒 → 断开
AOF 缓冲区server.aof_buf (sds)全局一个无硬限制always模式写失败 → 进程退出;其他模式 → 停止接受写命令
复制积压缓冲区replBacklog + replBufBlock 链表全局一个1MB (repl-backlog-size)旧数据被覆盖 → 从节点断连后只能全量重同步
从节点输出缓冲区共享 repl_buffer_blocks每个从节点一个引用硬256MB 软64MB/60s断开从节点 → 可能触发全量重同步

缓冲区相关的常见问题及调优

1. 客户端输入缓冲区过大

现象CLIENT LIST 看到某客户端 qbufqbuf-free 很大

原因:发送了超大命令(大 key)或大量 pipeline 命令

调优

# 调整输入缓冲区上限(默认1GB)
client-query-buffer-limit 1073741824

2. 客户端输出缓冲区过大

现象CLIENT LIST 看到 omem 很大

原因:执行了 KEYS *SMEMBERS 大集合等返回海量数据的命令,或者客户端消费太慢

调优

# 分别配置 normal / slave / pubsub 三类客户端
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

3. 复制积压缓冲区太小

现象:从节点频繁全量重同步

原因:积压缓冲区太小,从节点网络抖动后数据已被覆盖

调优

# 建议设为:主节点每秒写入量 × 可容忍的断线时间
# 例如:每秒写 2MB,容忍 60 秒断线 → 至少 120MB
repl-backlog-size 128mb

4. AOF 缓冲区导致内存飙升

现象INFO memoryaof_buffer_length 很大

原因:磁盘 I/O 瓶颈,fsync 跟不上写入速度

调优

配置文件

redis.conf 共约 2400 行,参数按功能分为 16 个区域。下面按分类列出核心参数及其作用,不逐一罗列全部参数。

redis.conf
├── INCLUDES            引入其他配置文件
├── MODULES             启动时加载模块
├── NETWORK             网络与连接
├── TLS/SSL             TLS 加密
├── GENERAL             通用(日志、进程、数据库数量)
├── SNAPSHOTTING        RDB 持久化
├── REPLICATION         主从复制
├── KEYS TRACKING       客户端缓存追踪
├── SECURITY            安全(ACL、密码)
├── CLIENTS             客户端连接限制
├── MEMORY MANAGEMENT   内存管理与淘汰策略
├── LAZY FREEING        惰性删除
├── THREADED I/O        多线程 I/O
├── APPEND ONLY MODE    AOF 持久化
├── CLUSTER             集群
├── SLOWLOG             慢查询日志
├── ADVANCED CONFIG     底层数据结构编码阈值
└── ACTIVE DEFRAG       主动内存碎片整理

一、NETWORK — 网络与连接

控制 Redis 如何监听和管理网络连接。

参数默认值说明
bind127.0.0.1 -::1监听地址,默认仅本机
port6379监听端口
protected-modeyes保护模式,无密码时拒绝外部连接
tcp-backlog511TCP 连接队列长度(accept 前的排队数)
timeout0客户端空闲超时(秒),0 表示不关
tcp-keepalive300TCP keepalive 间隔(秒)

对应源码server.h 中的 server.portserver.tcp_backlogserver.maxidletime 等。


二、GENERAL — 通用配置

控制进程行为、日志、数据库数量。

参数默认值说明
daemonizeno是否后台运行
loglevelnotice日志级别:debug / verbose / notice / warning
logfile""日志文件路径,空则输出到 stdout
databases16数据库数量(db0 ~ db15)
pidfile/var/run/redis_6379.pidPID 文件路径

对应源码server.daemonizeserver.verbosityserver.dbnum


三、SNAPSHOTTING — RDB 持久化

控制 RDB 快照的自动触发条件和行为。

参数默认值说明
save <seconds> <changes>(默认无自动保存规则)自动 BGSAVE 条件,如 save 3600 1
stop-writes-on-bgsave-erroryesBGSAVE 失败后是否停止写入
rdbcompressionyesRDB 文件是否启用 LZF 压缩
rdbchecksumyesRDB 文件是否包含 CRC64 校验
dbfilenamedump.rdbRDB 文件名
dir./RDB 和 AOF 文件的存放目录

对应源码server.saveparams(saveparam 数组)、server.rdb_filenameserver.rdb_compression

触发逻辑(server.c:1580):serverCron() 中遍历 saveparams,检查 dirty >= changes && elapsed > seconds


四、REPLICATION — 主从复制

控制主从同步行为。

参数默认值说明
replicaof <host> <port>(无)设为某主节点的从节点
masterauth(无)连接主节点的密码
replica-serve-stale-datayes同步断开时从节点是否继续响应请求
replica-read-onlyyes从节点是否只读
repl-backlog-size1mb复制积压缓冲区大小
repl-diskless-syncyes全量同步时 RDB 直接通过网络发送,不落盘
replica-priority100从节点优先级,用于 Sentinel 选主

对应源码server.masterhostserver.repl_backlog_sizeserver.repl_diskless_sync


五、SECURITY — 安全

控制访问权限。

参数默认值说明
requirepass(无)设置密码(旧方式)
aclfile(无)ACL 配置文件路径
user <username> ...(无)内联定义 ACL 用户
acllog-max-len128ACL 拒绝日志最大条目数

对应源码server.requirepassserver.acl_filename

Redis 6.0+ 推荐使用 ACL 系统,requirepass 是旧的兼容方式。


六、CLIENTS — 客户端限制

参数默认值说明
maxclients10000最大同时连接客户端数

对应源码server.maxclientsinitServer() 中会据此调整 fd 上限。


七、MEMORY MANAGEMENT — 内存管理

控制 Redis 最大内存以及内存满时的淘汰策略。

参数默认值说明
maxmemory0(无限制)最大内存使用量
maxmemory-policynoeviction内存满时的淘汰策略
maxmemory-samples5LRU/LFU 采样数量
maxmemory-eviction-tenacity10淘汰激进程度

淘汰策略可选值:

noeviction       ← 不淘汰,写入报错(默认)
allkeys-lru      ← 所有 key 中淘汰最近最少使用的
volatile-lru     ← 设了 TTL 的 key 中淘汰 LRU
allkeys-lfu      ← 所有 key 中淘汰最不常用的
volatile-lfu     ← 设了 TTL 的 key 中淘汰 LFU
allkeys-random   ← 所有 key 中随机淘汰
volatile-random  ← 设了 TTL 的 key 中随机淘汰
volatile-ttl     ← 设了 TTL 的 key 中淘汰 TTL 最短的

对应源码server.maxmemoryserver.maxmemory_policyserver.maxmemory_samples


八、LAZY FREEING — 惰性删除

控制 DEL / EXPIRE / 淘汰等操作是否在后台线程异步释放内存。

参数默认值说明
lazyfree-lazy-evictionno内存淘汰时异步释放
lazyfree-lazy-expireno过期删除时异步释放
lazyfree-lazy-server-delno隐式 DEL(如 RENAME 覆盖)时异步释放
lazyfree-lazy-user-delnoDEL 命令是否表现得像 UNLINK
lazyfree-lazy-user-flushnoFLUSHDB/FLUSHALL 是否默认异步

对应源码server.lazyfree_lazy_eviction 等,异步释放由 bio.c 后台线程执行。


九、THREADED I/O — 多线程

Redis 6.0+ 支持多线程处理网络 I/O(命令执行仍单线程)。

参数默认值说明
io-threads1I/O 线程数(1 = 单线程)
io-threads-do-readsno是否用多线程处理读

对应源码server.io_threads_numserver.io_threads_do_reads

建议:CPU 核数较多且网络 I/O 是瓶颈时,设为 2~8。


十、APPEND ONLY MODE — AOF 持久化

控制 AOF 日志的写入策略和重写行为。

参数默认值说明
appendonlyno是否开启 AOF
appendfilename"appendonly.aof"AOF 文件名
appenddirname"appendonlydir"AOF 目录(Redis 7.0+ 多文件 AOF)
appendfsynceverysecfsync 策略:always / everysec / no
no-appendfsync-on-rewriteno重写期间是否跳过 fsync
auto-aof-rewrite-percentage100AOF 增长百分比触发重写
auto-aof-rewrite-min-size64mb触发重写的最小文件大小
aof-use-rdb-preambleyes重写时 BASE 文件用 RDB 格式(混合持久化)

对应源码server.aof_enabledserver.aof_fsyncserver.aof_rewrite_perc


十一、CLUSTER — 集群

参数默认值说明
cluster-enabledno是否启用集群模式
cluster-config-filenodes.conf集群自动生成的配置文件
cluster-node-timeout15000节点超时判定(毫秒)
cluster-require-full-coverageyes有槽位空缺时是否停止服务

对应源码server.cluster_enabledserver.cluster_node_timeout


十二、SLOWLOG — 慢查询日志

参数默认值说明
slowlog-log-slower-than10000(10ms)超过此微秒数记录慢日志
slowlog-max-len128慢日志最大条目数

对应源码server.slowlog_log_slower_thanserver.slowlog_max_len


十三、ADVANCED CONFIG — 数据结构编码阈值

控制各数据类型何时从紧凑编码(省内存)切换到高性能编码。

参数默认值含义
hash-max-listpack-entries512Hash 元素数 ≤ 512 时用 listpack
hash-max-listpack-value64Hash 值长度 ≤ 64 字节时用 listpack
list-max-listpack-size-2quicklistNode 大小限制(-2 = 8KB)
list-compress-depth0quicklist 中间节点 LZF 压缩深度
set-max-intset-entries512Set 元素数 ≤ 512 且全整数时用 intset
set-max-listpack-entries128Set 元素数 ≤ 128 时用 listpack
zset-max-listpack-entries128ZSet 元素数 ≤ 128 时用 listpack
zset-max-listpack-value64ZSet 值长度 ≤ 64 字节时用 listpack
stream-node-max-bytes4096Stream listpack 节点最大字节数
stream-node-max-entries100Stream listpack 节点最大条目数

对应源码server.hash_max_listpack_entriesserver.set_max_intset_entries 等。

与前面讲的数据结构的关系

SADD myset 1 2 3 ... 512 个整数
→ encoding = intset(因为 ≤ set-max-intset-entries)

SADD myset 513
→ encoding 自动转换为 dict(超过阈值)

十四、其他重要参数

参数默认值说明
hz10serverCron 每秒执行次数
dynamic-hzyes是否根据客户端数量动态调整 hz
activerehashingyes允许在 serverCron 中做渐进式 rehash
client-output-buffer-limit normal0 0 0普通客户端输出缓冲区无限制
client-output-buffer-limit replica256mb 64mb 60从节点缓冲区限制
client-output-buffer-limit pubsub32mb 8mb 60Pub/Sub 缓冲区限制
aof-rewrite-incremental-fsyncyesAOF 重写时增量 fsync
rdb-save-incremental-fsyncyesRDB 保存时增量 fsync
jemalloc-bg-threadyesjemalloc 后台线程
active-expire-effort1主动过期扫描强度(1~10)
latency-monitor-threshold0延迟监控阈值(毫秒),0 = 关闭

参数与源码对应关系

所有配置参数最终映射到 struct redisServer 的字段:

redis.conf                          server.h (struct redisServer)
─────────────────────────────────   ──────────────────────────────
port 6379                     →     server.port
maxmemory 4gb                 →     server.maxmemory
maxmemory-policy allkeys-lru  →     server.maxmemory_policy
appendfsync everysec          →     server.aof_fsync
hz 10                         →     server.hz
maxclients 10000              →     server.maxclients
repl-backlog-size 1mb         →     server.repl_backlog_size
cluster-enabled yes           →     server.cluster_enabled

配置加载流程:

main()
  → loadServerConfig(configfile, stdin, options)
      → 逐行解析配置文件
      → 命令行 --key value 覆盖
      → 写入 struct redisServer 对应字段

可通过 CONFIG SET 在线修改大部分参数,通过 CONFIG REWRITE 将当前配置持久化回配置文件。

Redis 标准版 vs 集群版(Cluster)

Redis 的”标准版”和”集群版”其实是同一个二进制程序,通过 cluster-enabled yes/no 开关切换。下面从架构、数据分布、命令限制、故障转移等方面对比两者差异。


架构对比

标准版(Standalone / Sentinel)

                  ┌──────────────┐
  客户端 ────────→│ Redis Master │
                  │ (单节点)     │
                  │ db0 ~ db15   │
                  └──────┬───────┘
                         │ 复制
                ┌────────┼────────┐
                ▼        ▼        ▼
           Replica 1  Replica 2  Replica 3
           (只读)     (只读)     (只读)

           + Sentinel × 3(可选,负责监控和自动故障转移)

集群版(Cluster)

  客户端(任意节点均可接入)


 ┌─────────────────────────────────────────────────┐
 │                Redis Cluster                     │
 │                                                  │
 │  Node A (Master)    Node B (Master)    Node C    │
 │  slots: 0-5460      slots: 5461-10922  slots:    │
 │  db0 only           db0 only           10923-    │
 │       │                  │             16383     │
 │       ▼                  ▼               │       │
 │  Replica A1          Replica B1          ▼       │
 │                                      Replica C1  │
 │                                                  │
 │  节点之间通过 Cluster Bus(gossip)互相通信        │
 └─────────────────────────────────────────────────┘

核心区别

1. 数据分布

标准版:所有数据在一个节点上。

集群版:数据按**哈希槽(hash slot)**分布到不同节点。

源码中的哈希槽计算(cluster.h:65):

// 共 16384 个槽位
#define CLUSTER_SLOTS (1 << 14)   // 16384

static inline unsigned int keyHashSlot(const char *key, int keylen) {
    int s, e;

    // 查找 {,如果有 {xxx} 模式,只对 xxx 部分算哈希
    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    if (s == keylen)
        return crc16(key, keylen) & 0x3FFF;  // 无 {} → 对整个 key 算

    for (e = s+1; e < keylen; e++)
        if (key[e] == '}') break;

    if (e == keylen || e == s+1)
        return crc16(key, keylen) & 0x3FFF;

    // 只对 {} 中间的部分算 CRC16
    return crc16(key+s+1, e-s-1) & 0x3FFF;
}
key = "user:1001"         → slot = crc16("user:1001") % 16384
key = "{user}.name"       → slot = crc16("user") % 16384
key = "{user}.age"        → slot = crc16("user") % 16384   ← 同一个槽!

{...}hash tag,可以强制让相关 key 落在同一个槽上。

2. 数据库数量

标准版:支持 16 个数据库(db0 ~ db15),可以用 SELECT 切换。

集群版:只能用 db0,SELECT 其他数据库会报错。

源码(db.c:1388):

// SELECT 命令实现
if (server.cluster_enabled && id != 0) {
    addReplyError(c, "SELECT is not allowed in cluster mode");
    return;
}

3. 命令重定向

标准版:客户端连上哪个节点就用哪个,不存在重定向。

集群版:如果请求的 key 不在当前节点,返回重定向错误。

源码中的三种重定向(cluster.c:1411):

void clusterRedirectClient(client *c, clusterNode *n, int hashslot, int error_code) {
    if (error_code == CLUSTER_REDIR_CROSS_SLOT) {
        // 多个 key 分布在不同的槽
        addReplyError(c, "-CROSSSLOT Keys in request don't hash to the same slot");
    }
    else if (error_code == CLUSTER_REDIR_MOVED) {
        // 槽位已永久迁移到其他节点
        // -MOVED 3999 127.0.0.1:6381
        addReplyErrorSds(c, sdscatprintf(..., "-MOVED %d %s:%d", ...));
    }
    else if (error_code == CLUSTER_REDIR_ASK) {
        // 槽位正在迁移中,临时重定向
        // -ASK 3999 127.0.0.1:6381
        addReplyErrorSds(c, sdscatprintf(..., "-ASK %d %s:%d", ...));
    }
    else if (error_code == CLUSTER_REDIR_DOWN_STATE) {
        addReplyError(c, "-CLUSTERDOWN The cluster is down");
    }
}
客户端 → Node A: GET user:1001

           slot 不在 Node A


客户端 ← Node A: -MOVED 5555 192.168.1.2:6379


客户端 → Node B: GET user:1001     ← 重定向到正确节点
客户端 ← Node B: "Alice"

4. 多 key 操作限制

标准版:所有多 key 命令正常使用,无任何限制。

集群版:多个 key 必须在同一个槽内,否则报 CROSSSLOT 错误。

命令标准版集群版
MGET key1 key2 key3正常三个 key 必须在同一个 slot,否则 CROSSSLOT
MSET k1 v1 k2 v2正常同上
SUNION set1 set2正常同上
RENAME key1 key2正常同上
RPOPLPUSH src dst正常同上
事务 MULTI/EXEC正常事务内所有 key 必须在同一个 slot
Lua 脚本正常脚本访问的所有 key 必须在同一个 slot

解决方法:使用 hash tag 让相关 key 落到同一个 slot:

MGET {user:1001}.name {user:1001}.age {user:1001}.email
     ───────────────  ───────────────  ─────────────────
     都按 "user:1001" 算 hash → 同一个 slot → 正常执行

5. 集群状态管理

标准版:无集群状态概念。

集群版:每个节点维护一个 clusterState 结构,记录整个集群的全貌。

源码(cluster_legacy.h:334):

struct clusterState {
    clusterNode *myself;                          // 自己是谁
    uint64_t currentEpoch;                        // 当前纪元(用于选举)
    int state;                                    // CLUSTER_OK 或 CLUSTER_FAIL
    int size;                                     // 有槽位的主节点数
    dict *nodes;                                  // 所有节点: name → clusterNode
    clusterNode *slots[CLUSTER_SLOTS];            // ★ 16384 个槽位分别属于哪个节点
    clusterNode *migrating_slots_to[CLUSTER_SLOTS];   // 正在迁出的槽
    clusterNode *importing_slots_from[CLUSTER_SLOTS]; // 正在迁入的槽
    // 故障转移相关...
};

每个节点的信息(cluster_legacy.h:298):

struct _clusterNode {
    char name[40];                               // 节点 ID(40 字符 hex)
    char shard_id[40];                           // 分片 ID
    int flags;                                   // 角色标记(master/slave/fail...)
    unsigned char slots[CLUSTER_SLOTS/8];        // ★ 位图:此节点负责哪些槽
    int numslots;                                // 负责的槽数量
    int numslaves;                               // 从节点数量
    clusterNode **slaves;                        // 从节点列表
    clusterNode *slaveof;                        // 如果是从节点,指向主节点
    clusterLink *link;                           // 与该节点的通信连接
    // ...
};

6. 故障转移

标准版 + Sentinel

Sentinel 1 ──→ 监控 Master
Sentinel 2 ──→ 监控 Master    Master 宕机 → Sentinel 投票
Sentinel 3 ──→ 监控 Master              → 选一个 Replica 提升为 Master
                                         → 通知客户端新地址

集群版(内置故障转移)

Node A 的 Replica 发现 A 宕机


向其他主节点发起投票请求


获得多数主节点投票


Replica 提升为新 Master,接管 A 的槽位


通过 Gossip 协议通知整个集群

7. 扩缩容

标准版

集群版

扩容前:
  Node A: slots 0-5460
  Node B: slots 5461-10922
  Node C: slots 10923-16383

加入 Node D,重新分配:
  Node A: slots 0-4095
  Node B: slots 4096-8191
  Node C: slots 8192-12287
  Node D: slots 12288-16383     ← 新节点接管一部分槽位

三、完整对比表

维度标准版(Standalone/Sentinel)集群版(Cluster)
开关cluster-enabled no(默认)cluster-enabled yes
数据分布全量在一个节点按 16384 个哈希槽分散到多个节点
数据库db0 ~ db15(16个)只有 db0
最大容量单机内存所有主节点内存之和
写入能力单节点瓶颈多节点分担,线性扩展
多 key 命令无限制必须在同一个 slot(否则 CROSSSLOT)
事务无限制所有 key 必须在同一个 slot
Lua 脚本无限制所有 key 必须在同一个 slot
SELECT支持不支持(只能 db0)
故障转移需要额外部署 Sentinel内置,自动完成
扩缩容不支持水平扩展在线迁移槽位,不停服
客户端连一个节点即可需要 cluster-aware 客户端(处理重定向)
通信主从之间只有复制额外的 Cluster Bus(Gossip 协议)
额外开销每个节点多一个 Cluster Bus 端口(port+10000)
适用场景数据量小、业务简单数据量大、需要水平扩展

如何选择

                    数据量 < 单机内存?
                   /                \
                 是                  否
                 │                   │
         需要高可用?           ──→ 集群版
        /          \
      否            是
      │             │
  单机标准版    标准版 + Sentinel
场景推荐方案
开发/测试环境单机标准版
生产环境,数据量不大,需要高可用标准版 + Sentinel(3个)
数据量大(超过单机内存)集群版
写入 QPS 超过单节点瓶颈集群版
大量使用多 key 操作、事务、Lua标准版 + Sentinel(集群版限制太多)