Redis 源码
- 编程
- 数据库
- 源码
目录
- Redis 源码架构总览
- 入口
- 基础环境初始化
- spt_init
- getRandomBytes
- 配置加载
- multi-call binary
- 命令行解析
- 系统检查 & 守护进程化
- 核心服务初始化
- 进入事件循环
- 源码数据结构
- redisServer
- 进程基本信息
- 数据库核心 — redisDb
- 命令表
- 事件循环
- 网络与客户端
- 客户端管理
- 多线程 I/O
- RDB 持久化
- AOF 持久化
- 主从复制
- 内存管理与淘汰
- 集群
- 统计信息 (stat_*)
- 存储数据的数据结构
- SDS(Simple Dynamic String)
- dict(哈希表)
- listpack(紧凑列表)
- quicklist(List 的底层)
- intset(整数集合)
- skiplist(跳表)— ZSet 专用
- rax(基数树)— Stream 底层
- 持久化策略
- RDB(Redis Database)— 快照
- 自动触发
- 手动触发
- bgsave
- save
- AOF(Append Only File)— 追加日志
- 写入流程
- 重写
- 混合持久化(RDB + AOF Preamble)
- 启动时数据恢复
- 过期策略
- 惰性删除(Lazy Expire)
- 定期删除(Active Expire)
- 子 key 过期(Hash Field Expiration)— Redis 8.x 新特性
- 缓冲区
- 客户端输入缓冲区(querybuf)
- 客户端输出缓冲区(buf + reply)
- AOF 缓冲区(aof_buf)
- 复制积压缓冲区(repl_backlog)
- 从节点输出缓冲区(Replica Output Buffer)
- 完整对比表
- 缓冲区相关的常见问题及调优
- 1. 客户端输入缓冲区过大
- 2. 客户端输出缓冲区过大
- 3. 复制积压缓冲区太小
- 4. AOF 缓冲区导致内存飙升
- 配置文件
- 一、NETWORK — 网络与连接
- 二、GENERAL — 通用配置
- 三、SNAPSHOTTING — RDB 持久化
- 四、REPLICATION — 主从复制
- 五、SECURITY — 安全
- 六、CLIENTS — 客户端限制
- 七、MEMORY MANAGEMENT — 内存管理
- 八、LAZY FREEING — 惰性删除
- 九、THREADED I/O — 多线程
- 十、APPEND ONLY MODE — AOF 持久化
- 十一、CLUSTER — 集群
- 十二、SLOWLOG — 慢查询日志
- 十三、ADVANCED CONFIG — 数据结构编码阈值
- 十四、其他重要参数
- 参数与源码对应关系
- Redis 标准版 vs 集群版(Cluster)
- 架构对比
- 标准版(Standalone / Sentinel)
- 集群版(Cluster)
- 核心区别
- 1. 数据分布
- 2. 数据库数量
- 3. 命令重定向
- 4. 多 key 操作限制
- 5. 集群状态管理
- 6. 故障转移
- 7. 扩缩容
- 三、完整对比表
- 如何选择
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) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
server.c:包含main()函数,所有核心初始化和事件循环server.h:核心数据结构定义,如redisServer、client、redisCommand
入口
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 个阶段:
- 基础环境初始化(随机种子、内存、哈希种子)
- 配置加载
- multi-call binary
- 命令行解析
- 系统检查 & 守护进程化
- 核心服务初始化(数据库、网络、集群、数据恢复)
- 进入事件循环 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 的核心,它不断地:
- beforeSleep → 刷写客户端缓冲区、AOF fsync、处理异步任务
- epoll_wait / kqueue → 等待网络事件(新连接、客户端请求、可写事件)
- afterSleep → 处理被唤醒后的逻辑
- 处理时间事件 → 定时执行 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 字符串的区别:
- O(1) 获取长度(不用 strlen 遍历)
- 二进制安全(可以存 \0,靠 len 判断结束)
- 预分配 + 惰性释放(减少 realloc 次数)
- 5 种 header 按长度选择,短字符串只用 1 字节 header
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 ──→ 不压缩
- 每个 node 内部是一个 listpack(紧凑的连续内存)
- 中间的 node 可以用 LZF 压缩,两端的不压缩(因为常访问)
- 兼顾了链表的快速增删 + 连续内存的缓存友好性
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 ]
←── 有序排列,二分查找 ──→
特点:
- 自动升级:插入一个 int64 时,整个数组从 int16 升级到 int64
- 不支持降级(删除大值后不会缩回去)
- 有序 + 二分查找,O(log n) 查询
- Set 只包含整数且元素少时使用,极致省内存
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 ──→ 90 → NULL
↑ ↑ ↑ ↑ ↑ ↑
backward 链(第1层双向)
ZSet 的完整结构:
typedef struct zset {
dict *dict; // member → score 的映射(O(1) 按成员查分数)
zskiplist *zsl; // 按 score 排序(O(log n) 范围查询)
} zset;
为什么用跳表不用红黑树:
- 范围查询(ZRANGEBYSCORE)跳表直接链表遍历,红黑树要中序遍历,跳表更简单
- 实现更简单,代码量少很多
- 插入删除只需修改局部指针
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 存具体消息内容。
总结
| 底层结构 | 源文件 | 时间复杂度 | 谁在用 | 核心特点 |
|---|---|---|---|---|
| SDS | sds.h | O(1)取长度 | 所有字符串 | 二进制安全,5种header按长度选 |
| dict | dict.h | O(1)增删查 | Hash大、Set大、键空间 | 渐进式rehash,两张表交替 |
| quicklist | quicklist.h | O(1)头尾操作 | List | 双向链表 + listpack + LZF压缩 |
| listpack | listpack.c | O(n)遍历 | 小Hash/小Set/小ZSet/quicklist内部 | 连续内存,无指针,极致省内存 |
| intset | intset.h | O(log n)查找 | 纯整数小Set | 有序数组 + 自动升级编码 |
| skiplist | server.h | O(log n)增删查 | ZSet大 | 多层链表,范围查询高效 |
| rax | rax.h | O(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;
}
}
配置文件中的规则:
- save 3600 1 # 3600秒内有1次修改
- save 300 100 # 300秒内有100次修改
- save 60 10000 # 60秒内有10000次修改
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 字典
为什么需要两种策略配合?
- 只有惰性删除:如果一个 key 过期后再也没人访问,它就永远占着内存(内存泄漏)
- 只有定期删除:两次定期扫描之间,过期 key 被访问时会返回脏数据
- 两者配合:定期删除批量清理 + 惰性删除兜底,既保证内存回收,又保证数据正确
惰性删除(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,随机抽样检查并删除过期的。
两种模式
| 模式 | 调用位置 | 频率 | 时间上限 |
|---|---|---|---|
| SLOW | serverCron() (server.c:1218) | 每秒 server.hz 次(默认10) | CPU 时间的 25% |
| FAST | beforeSleep() (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, "a, 0);
if (nextExpTime == INVALID) {
return ACT_REMOVE_EXP_ITEM; // hash 没有更多要过期的 field
} else {
return ACT_UPDATE_EXP_ITEM; // 更新下一个 field 的过期时间
}
}
| 维度 | 惰性删除 | 定期删除(SLOW) | 定期删除(FAST) |
|---|---|---|---|
| 源码位置 | db.c:2737 | expire.c:287 | expire.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。
典型触发场景
- 客户端发送超大 key/value(如一个 512MB 的字符串)
- 客户端使用 pipeline 一次灌入大量命令,服务端处理不及
客户端输出缓冲区(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(从节点) | 256MB | 64MB 持续 60 秒 | 60s |
| pubsub(订阅客户端) | 32MB | 8MB 持续 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 秒 | 暂时容忍,降回来就清零计时 |
典型触发场景
- 普通客户端:执行
KEYS *等返回海量数据的命令,客户端读取速度跟不上 - 从节点:网络抖动导致主节点积压大量写命令,从节点消费不及
- Pub/Sub 客户端:订阅了高频频道,消费速度跟不上发布速度
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 看到某客户端 qbuf 或 qbuf-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 memory 中 aof_buffer_length 很大
原因:磁盘 I/O 瓶颈,fsync 跟不上写入速度
调优:
- 使用更快的磁盘(SSD)
everysec模式下 fsync 延迟超过 2 秒会强制写入并打日志- 检查是否有 BGSAVE/BGREWRITEAOF 子进程在竞争磁盘 I/O
配置文件
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 如何监听和管理网络连接。
| 参数 | 默认值 | 说明 |
|---|---|---|
bind | 127.0.0.1 -::1 | 监听地址,默认仅本机 |
port | 6379 | 监听端口 |
protected-mode | yes | 保护模式,无密码时拒绝外部连接 |
tcp-backlog | 511 | TCP 连接队列长度(accept 前的排队数) |
timeout | 0 | 客户端空闲超时(秒),0 表示不关 |
tcp-keepalive | 300 | TCP keepalive 间隔(秒) |
对应源码:server.h 中的 server.port、server.tcp_backlog、server.maxidletime 等。
二、GENERAL — 通用配置
控制进程行为、日志、数据库数量。
| 参数 | 默认值 | 说明 |
|---|---|---|
daemonize | no | 是否后台运行 |
loglevel | notice | 日志级别:debug / verbose / notice / warning |
logfile | "" | 日志文件路径,空则输出到 stdout |
databases | 16 | 数据库数量(db0 ~ db15) |
pidfile | /var/run/redis_6379.pid | PID 文件路径 |
对应源码:server.daemonize、server.verbosity、server.dbnum。
三、SNAPSHOTTING — RDB 持久化
控制 RDB 快照的自动触发条件和行为。
| 参数 | 默认值 | 说明 |
|---|---|---|
save <seconds> <changes> | (默认无自动保存规则) | 自动 BGSAVE 条件,如 save 3600 1 |
stop-writes-on-bgsave-error | yes | BGSAVE 失败后是否停止写入 |
rdbcompression | yes | RDB 文件是否启用 LZF 压缩 |
rdbchecksum | yes | RDB 文件是否包含 CRC64 校验 |
dbfilename | dump.rdb | RDB 文件名 |
dir | ./ | RDB 和 AOF 文件的存放目录 |
对应源码:server.saveparams(saveparam 数组)、server.rdb_filename、server.rdb_compression。
触发逻辑(server.c:1580):serverCron() 中遍历 saveparams,检查 dirty >= changes && elapsed > seconds。
四、REPLICATION — 主从复制
控制主从同步行为。
| 参数 | 默认值 | 说明 |
|---|---|---|
replicaof <host> <port> | (无) | 设为某主节点的从节点 |
masterauth | (无) | 连接主节点的密码 |
replica-serve-stale-data | yes | 同步断开时从节点是否继续响应请求 |
replica-read-only | yes | 从节点是否只读 |
repl-backlog-size | 1mb | 复制积压缓冲区大小 |
repl-diskless-sync | yes | 全量同步时 RDB 直接通过网络发送,不落盘 |
replica-priority | 100 | 从节点优先级,用于 Sentinel 选主 |
对应源码:server.masterhost、server.repl_backlog_size、server.repl_diskless_sync。
五、SECURITY — 安全
控制访问权限。
| 参数 | 默认值 | 说明 |
|---|---|---|
requirepass | (无) | 设置密码(旧方式) |
aclfile | (无) | ACL 配置文件路径 |
user <username> ... | (无) | 内联定义 ACL 用户 |
acllog-max-len | 128 | ACL 拒绝日志最大条目数 |
对应源码:server.requirepass、server.acl_filename。
Redis 6.0+ 推荐使用 ACL 系统,requirepass 是旧的兼容方式。
六、CLIENTS — 客户端限制
| 参数 | 默认值 | 说明 |
|---|---|---|
maxclients | 10000 | 最大同时连接客户端数 |
对应源码:server.maxclients,initServer() 中会据此调整 fd 上限。
七、MEMORY MANAGEMENT — 内存管理
控制 Redis 最大内存以及内存满时的淘汰策略。
| 参数 | 默认值 | 说明 |
|---|---|---|
maxmemory | 0(无限制) | 最大内存使用量 |
maxmemory-policy | noeviction | 内存满时的淘汰策略 |
maxmemory-samples | 5 | LRU/LFU 采样数量 |
maxmemory-eviction-tenacity | 10 | 淘汰激进程度 |
淘汰策略可选值:
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.maxmemory、server.maxmemory_policy、server.maxmemory_samples。
八、LAZY FREEING — 惰性删除
控制 DEL / EXPIRE / 淘汰等操作是否在后台线程异步释放内存。
| 参数 | 默认值 | 说明 |
|---|---|---|
lazyfree-lazy-eviction | no | 内存淘汰时异步释放 |
lazyfree-lazy-expire | no | 过期删除时异步释放 |
lazyfree-lazy-server-del | no | 隐式 DEL(如 RENAME 覆盖)时异步释放 |
lazyfree-lazy-user-del | no | DEL 命令是否表现得像 UNLINK |
lazyfree-lazy-user-flush | no | FLUSHDB/FLUSHALL 是否默认异步 |
对应源码:server.lazyfree_lazy_eviction 等,异步释放由 bio.c 后台线程执行。
九、THREADED I/O — 多线程
Redis 6.0+ 支持多线程处理网络 I/O(命令执行仍单线程)。
| 参数 | 默认值 | 说明 |
|---|---|---|
io-threads | 1 | I/O 线程数(1 = 单线程) |
io-threads-do-reads | no | 是否用多线程处理读 |
对应源码:server.io_threads_num、server.io_threads_do_reads。
建议:CPU 核数较多且网络 I/O 是瓶颈时,设为 2~8。
十、APPEND ONLY MODE — AOF 持久化
控制 AOF 日志的写入策略和重写行为。
| 参数 | 默认值 | 说明 |
|---|---|---|
appendonly | no | 是否开启 AOF |
appendfilename | "appendonly.aof" | AOF 文件名 |
appenddirname | "appendonlydir" | AOF 目录(Redis 7.0+ 多文件 AOF) |
appendfsync | everysec | fsync 策略:always / everysec / no |
no-appendfsync-on-rewrite | no | 重写期间是否跳过 fsync |
auto-aof-rewrite-percentage | 100 | AOF 增长百分比触发重写 |
auto-aof-rewrite-min-size | 64mb | 触发重写的最小文件大小 |
aof-use-rdb-preamble | yes | 重写时 BASE 文件用 RDB 格式(混合持久化) |
对应源码:server.aof_enabled、server.aof_fsync、server.aof_rewrite_perc。
十一、CLUSTER — 集群
| 参数 | 默认值 | 说明 |
|---|---|---|
cluster-enabled | no | 是否启用集群模式 |
cluster-config-file | nodes.conf | 集群自动生成的配置文件 |
cluster-node-timeout | 15000 | 节点超时判定(毫秒) |
cluster-require-full-coverage | yes | 有槽位空缺时是否停止服务 |
对应源码:server.cluster_enabled、server.cluster_node_timeout。
十二、SLOWLOG — 慢查询日志
| 参数 | 默认值 | 说明 |
|---|---|---|
slowlog-log-slower-than | 10000(10ms) | 超过此微秒数记录慢日志 |
slowlog-max-len | 128 | 慢日志最大条目数 |
对应源码:server.slowlog_log_slower_than、server.slowlog_max_len。
十三、ADVANCED CONFIG — 数据结构编码阈值
控制各数据类型何时从紧凑编码(省内存)切换到高性能编码。
| 参数 | 默认值 | 含义 |
|---|---|---|
hash-max-listpack-entries | 512 | Hash 元素数 ≤ 512 时用 listpack |
hash-max-listpack-value | 64 | Hash 值长度 ≤ 64 字节时用 listpack |
list-max-listpack-size | -2 | quicklistNode 大小限制(-2 = 8KB) |
list-compress-depth | 0 | quicklist 中间节点 LZF 压缩深度 |
set-max-intset-entries | 512 | Set 元素数 ≤ 512 且全整数时用 intset |
set-max-listpack-entries | 128 | Set 元素数 ≤ 128 时用 listpack |
zset-max-listpack-entries | 128 | ZSet 元素数 ≤ 128 时用 listpack |
zset-max-listpack-value | 64 | ZSet 值长度 ≤ 64 字节时用 listpack |
stream-node-max-bytes | 4096 | Stream listpack 节点最大字节数 |
stream-node-max-entries | 100 | Stream listpack 节点最大条目数 |
对应源码:server.hash_max_listpack_entries、server.set_max_intset_entries 等。
与前面讲的数据结构的关系:
SADD myset 1 2 3 ... 512 个整数
→ encoding = intset(因为 ≤ set-max-intset-entries)
SADD myset 513
→ encoding 自动转换为 dict(超过阈值)
十四、其他重要参数
| 参数 | 默认值 | 说明 |
|---|---|---|
hz | 10 | serverCron 每秒执行次数 |
dynamic-hz | yes | 是否根据客户端数量动态调整 hz |
activerehashing | yes | 允许在 serverCron 中做渐进式 rehash |
client-output-buffer-limit normal | 0 0 0 | 普通客户端输出缓冲区无限制 |
client-output-buffer-limit replica | 256mb 64mb 60 | 从节点缓冲区限制 |
client-output-buffer-limit pubsub | 32mb 8mb 60 | Pub/Sub 缓冲区限制 |
aof-rewrite-incremental-fsync | yes | AOF 重写时增量 fsync |
rdb-save-incremental-fsync | yes | RDB 保存时增量 fsync |
jemalloc-bg-thread | yes | jemalloc 后台线程 |
active-expire-effort | 1 | 主动过期扫描强度(1~10) |
latency-monitor-threshold | 0 | 延迟监控阈值(毫秒),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(可选,负责监控和自动故障转移)
- 一个主节点承载所有数据
- 从节点只做读分离和容灾
- 可选搭配 Sentinel 做自动故障转移
- 数据容量受限于单机内存
集群版(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)互相通信 │
└─────────────────────────────────────────────────┘
- 多个主节点分担数据,每个节点只负责一部分槽位
- 每个主节点可以有自己的从节点
- 去中心化,不依赖 Sentinel
- 数据容量 = 所有主节点内存之和
核心区别
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
→ 通知客户端新地址
- 需要额外部署 Sentinel 进程(至少 3 个)
- Sentinel 通过 Pub/Sub 通知客户端主从切换
- 故障转移时间一般 10~30 秒
集群版(内置故障转移):
Node A 的 Replica 发现 A 宕机
│
▼
向其他主节点发起投票请求
│
▼
获得多数主节点投票
│
▼
Replica 提升为新 Master,接管 A 的槽位
│
▼
通过 Gossip 协议通知整个集群
- 不需要额外进程,集群自身完成故障检测和转移
- 基于 Gossip 协议 + Raft-like 投票
- 故障转移时间一般 1~5 秒(更快)
7. 扩缩容
标准版:
- 无法水平扩展写入能力
- 只能加从节点分担读压力
- 数据量受限于单机内存
集群版:
- 扩容:新增节点 → 从现有节点迁移部分槽位到新节点
- 缩容:将节点的槽位迁移到其他节点 → 移除节点
- 迁移过程中服务不中断(通过 ASK 重定向处理迁移中的请求)
扩容前:
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(集群版限制太多) |