当你执行一条 SET key value,Redis 如何在微秒级完成存储?当你用 ZRANK 查询排行榜,O(log n) 的跳表如何工作?当主节点宕机,哨兵如何在秒级完成故障转移?Redis 单线程为何能撑起 10 万+ QPS?
上一章我们深入了 PostgreSQL 的 MVCC 与 VACUUM 机制,本章转向另一个极端——纯内存数据库 Redis。与基于磁盘的 B 树/LSM 树引擎(参见 存储引擎)不同,Redis 将一切数据放在内存中,用精心设计的数据结构和单线程事件驱动模型换取极致性能。理解 Redis 的内部实现,你才能在缓存穿透、bigkey、主从延迟等真实问题面前做出正确决策。
前置知识
- Ch02 存储引擎:理解 B 树/LSM 树的磁盘模型,对比 Redis 的内存模型
- 基本的网络编程概念:事件驱动、Reactor 模式、epoll
一、Redis 数据结构全景
1.1 五种基本类型与底层编码
Redis 提供 5 种基本数据类型,但每种类型在底层可能对应多种编码实现。Redis 会根据数据规模和特征自动选择最优编码:
1.2 类型与编码对应关系
| 类型 | 编码常量 | 触发条件 | 优势 |
|---|---|---|---|
| String | OBJ_ENCODING_INT | 值为长整型(≤LONG_MAX) | 零额外内存,直接存在 ptr 指针位置 |
| String | OBJ_ENCODING_EMBSTR | 字符串长度 ≤ 44 字节 | 一次分配,redisObject 与 SDS 连续内存 |
| String | OBJ_ENCODING_RAW | 字符串长度 > 44 字节 | 独立 SDS,支持动态扩展 |
| List | OBJ_ENCODING_LISTPACK | 元素数 ≤ 128 且每个 ≤ 64 字节 | 紧凑存储,省内存 |
| List | OBJ_ENCODING_QUICKLIST | 超出 listpack 阈值 | 分段压缩,兼顾内存与性能 |
| Hash | OBJ_ENCODING_LISTPACK | field 数 ≤ 128 且每个值 ≤ 64 字节 | 顺序存储,缓存友好 |
| Hash | OBJ_ENCODING_HT | 超出 listpack 阈值 | O(1) 查找 |
| Set | OBJ_ENCODING_INTSET | 元素全为整数且数量 ≤ 128 | 有序紧凑,省内存 |
| Set | OBJ_ENCODING_HT | 超出 intset 阈值 | O(1) 查找 |
| Sorted Set | OBJ_ENCODING_LISTPACK | 元素数 ≤ 128 且每个 ≤ 64 字节 | 紧凑存储 |
| Sorted Set | OBJ_ENCODING_SKIPLIST | 超出 listpack 阈值 | O(log n) 范围查询 |
Redis 7.0 用 listpack 替换了 ziplist(压缩列表)。旧版 ziplist 存在”连锁更新”问题——一个节点的扩容可能引发后续所有节点的级联重分配。listpack 通过将节点长度从”前驱记录”改为”自身记录”消除了这一问题。下文仍以 ziplist 讲解原理,因为理解连锁更新对认识 listpack 的改进至关重要。
二、底层数据结构
2.1 SDS:简单动态字符串
Redis 没有使用 C 语言原生字符串,而是自己实现了 SDS(Simple Dynamic String)。为什么?
// C 原生字符串char *str = "hello"; // 长度需 O(n) 遍历,不防溢出
// SDS 结构(Redis 5.0 sdshdr16 为例)struct __attribute__((__packed__)) sdshdr16 { uint16_t len; // 已使用长度:O(1) 获取 uint16_t alloc; // 总分配容量(不含 header 和 \0) unsigned char flags; // 类型标识:sdshdr5/8/16/32/64 char buf[]; // 实际数据,以 \0 结尾};SDS 相对 C 字符串有三大关键改进:
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) 读 len 字段 |
| 缓冲区溢出 | strcat 可能越界写 | sdscat 检查容量,自动扩容 |
| 二进制安全 | 遇 \0 截断 | 以 len 判断结束,可存任意二进制数据 |
空间预分配策略:SDS 扩容时不仅分配所需空间,还额外预分配:
// SDS 扩容逻辑(简化)sds sdsMakeRoomFor(sds s, size_t addlen) { size_t len = sdslen(s); size_t newlen = len + addlen;
if (newlen < SDS_MAX_PREALLOC) // 1MB newlen *= 2; // 翻倍分配 else newlen += SDS_MAX_PREALLOC; // 线性增长 1MB
// 分配新空间...}这种策略使得 N 次追加操作的均摊复杂度为 O(1),避免了频繁 realloc。
2.2 压缩列表(ziplist)
压缩列表是为节约内存而设计的顺序数据结构,将所有元素紧凑地排列在一块连续内存中:
┌──────────┬───────────┬───────────┬─────────┬─────────┬─────────┐│ zlbytes │ zltail │ zllen │ entry1 │ entry2 │ zlend ││ 4 bytes │ 4 bytes │ 2 bytes │ 变长 │ 变长 │ 1 byte │└──────────┴───────────┴───────────┴─────────┴─────────┴─────────┘ │ ▼ ┌─────────────────────┐ │ prevlen │ encoding │ data │ │ 1/5 bytes│ 变长 │ 变长 │ └─────────────────────┘每个 entry 的 prevlen 字段记录前一个节点的长度,用于从尾向头遍历。但正是这个设计引发了连锁更新问题:
当 entry1 从 250 字节扩展到 254+ 字节时,entry2 的 prevlen 需要从 1 字节扩展到 5 字节,导致 entry2 自身也超过 254 字节,进而引发 entry3 的 prevlen 扩展……最坏情况下 N 个节点全部级联重分配,复杂度退化为 O(N²)。
连锁更新是概率事件——需要连续多个 entry 的长度恰好在 250~253 字节之间。实际生产中极少触发,但理解这一机制才能明白 Redis 7.0 为什么用 listpack 替代 ziplist。listpack 将 prevlen 改为记录自身长度,彻底消除了级联依赖。
2.3 跳表(skiplist)
跳表是 Sorted Set 的核心数据结构,以概率平衡替代严格的树平衡,实现 O(log n) 的查找、插入和删除:
查找过程(以查找 score=5 为例):从最高层出发,向右比较,若目标大于当前节点则右移,否则下降一层。最终在 Level 0 找到目标。
// Redis 跳表节点typedef struct zskiplistNode { sds ele; // 成员对象 double score; // 分值 struct zskiplistNode *backward; // 后退指针(Level 0) struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度(用于排名计算) } level[]; // 层(柔性数组)} zskiplistNode;
// 跳表typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; // 节点数量 int level; // 最大层数} zskiplist;为什么 Redis 选跳表而不是红黑树?
| 对比维度 | 跳表 | 红黑树 |
|---|---|---|
| 实现复杂度 | 简单,约 200 行代码 | 复杂,旋转/着色逻辑多 |
| 范围查询 | 天然支持,沿 Level 0 遍历 | 需中序遍历,不直观 |
| 内存开销 | 每节点平均 1.33 个指针(幂次概率 1/2) | 每节点固定 3 个指针 |
| 并发友好 | 局部修改,锁粒度小 | 旋转涉及多节点,锁粒度大 |
| 排名计算 | span 字段直接支持 ZRANK | 需额外维护子树大小 |
2.4 整数集合(intset)
当 Set 的元素全是整数且数量较少时,Redis 使用 intset 存储:
typedef struct intset { uint32_t encoding; // 编码方式:INT16/INT32/INT64 uint32_t length; // 元素数量 int8_t contents[]; // 实际数据(按编码决定元素大小)} intset;升级机制:当插入一个比当前编码更大的整数时,intset 会整体升级:
// intset 升级示例:从 INT16 升级到 INT32// 升级前:[1, 2, 3] 每个占 2 字节,共 6 字节// 插入 65535(超出 INT16 范围)// 升级后:[1, 2, 3, 65535] 每个占 4 字节,共 16 字节
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) { uint8_t valenc = _intsetValueEncoding(value); uint32_t pos;
if (valenc > intrev32ifbe(is->encoding)) { // 需要升级——重新分配内存,从后向前搬运元素 return intsetUpgradeAndAdd(is, value); } // ... 正常插入(二分查找 + 移位)}intset 只升级不降级——即使后来删除了触发升级的大整数,编码也不会回退。这是因为降级需要遍历所有元素确认没有大整数,开销不划算。intset 中的元素始终有序排列,支持二分查找,查找复杂度 O(log n)。
2.5 哈希表与渐进式 rehash
Redis 的字典底层是哈希表,使用链地址法解决冲突:
// 哈希表typedef struct dictht { dictEntry **table; // 哈希桶数组 unsigned long size; // 哈希表大小(2 的幂) unsigned long sizemask; // 哈希表大小掩码(size - 1) unsigned long used; // 已有节点数量} dictht;
// 字典typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[2]; // 两个哈希表(rehash 时使用) long rehashidx; // rehash 进度(-1 表示未进行) int16_t pauserehash;// 暂停 rehash 的安全标记} dict;渐进式 rehash 是 Redis 哈希表的核心设计。当负载因子(used / size)超过阈值时,需要扩容。但一次性迁移所有键值对会阻塞服务,因此 Redis 将迁移分摊到每次操作中:
// 渐进式 rehash 核心逻辑(简化)static void _dictRehashStep(dict *d) { // 每次迁移一个桶 dictRehash(d, 1);}
int dictRehash(dict *d, int n) { int empty_visits = n * 10; // 最多访问 10n 个空桶 while (n-- && d->ht[0].used != 0) { dictEntry *de, *nextde; // 跳过空桶 while (d->ht[0].table[d->rehashidx] == NULL) { d->rehashidx++; if (--empty_visits == 0) return 1; } de = d->ht[0].table[d->rehashidx]; // 迁移该桶所有节点到 ht[1] while (de) { uint64_t h; nextde = de->next; h = dictHashKey(d, de->key) & d->ht[1].sizemask; de->next = d->ht[1].table[h]; d->ht[1].table[h] = de; d->ht[0].used--; d->ht[1].used++; de = nextde; } d->ht[0].table[d->rehashidx] = NULL; d->rehashidx++; } // 检查是否迁移完毕 if (d->ht[0].used == 0) { zfree(d->ht[0].table); d->ht[0] = d->ht[1]; _dictReset(d->ht + 1); d->rehashidx = -1; return 0; // rehash 完成 } return 1; // rehash 进行中}rehash 期间,所有 CRUD 操作同时在两个哈希表上进行:查找先查 ht[0] 再查 ht[1],新增只写 ht[1],删除和修改在对应表中操作。
三、对象系统
3.1 redisObject 结构
Redis 的每种数据类型都通过 redisObject 封装,它是连接”类型”与”编码”的桥梁:
typedef struct redisObject { unsigned type:4; // 类型:STRING/LIST/HASH/SET/ZSET unsigned encoding:4; // 编码:int/embstr/raw/listpack/quicklist/... unsigned lru:24; // LRU 时间戳或 LFU 计数器 int refcount; // 引用计数 void *ptr; // 指向底层数据结构的指针} robj;| 字段 | 位数 | 作用 |
|---|---|---|
type | 4 bit | 标识数据类型,TYPE key 命令返回此值 |
encoding | 4 bit | 标识底层编码,OBJECT ENCODING key 可查看 |
lru | 24 bit | LRU 模式存秒级时间戳;LFU 模式高 16 位存分钟级衰减时间,低 8 位存计数器 |
refcount | 32 bit | 引用计数,为 0 时回收;小整数共享对象 refcount 初始为 OBJ_SHARED_REFCOUNT |
ptr | 64 bit | 指向实际数据结构(SDS/listpack/skiplist 等) |
3.2 内存优化策略
embstr 编码:当字符串 ≤ 44 字节时,redisObject 和 SDS 头分配在同一块连续内存中,只需一次 malloc/free,且缓存友好:
┌──────────────────────────────────────────────────┐│ redisObject (16 bytes) │ sdshdr8 + buf + \0 ││ type|encoding|lru|ref │ len|alloc|flags|data │└──────────────────────────────────────────────────┘ ← 一次 malloc 分配,一次 free 释放 →整数共享对象:Redis 启动时预创建 0~9999 的整数对象,所有用到这些值的键共享同一对象:
// server.c 初始化void createSharedObjects(void) { for (int j = 0; j < OBJ_SHARED_INTEGERS; j++) { shared.integers[j] = makeObjectShared( createObject(OBJ_STRING, sdsfromlonglong(j)) ); shared.integers[j]->refcount = OBJ_SHARED_REFCOUNT; // 无限引用 }}当 maxmemory 配置了 LRU/LFU 淘汰策略时,Redis 会禁用共享整数——因为共享对象的 lru 字段无法为每个键独立维护访问信息。这是内存节省与淘汰精度之间的取舍。
编码转换:Redis 在操作时自动检测并转换编码,对用户完全透明:
# Hash 编码自动转换示例HSET user:1 name "Tom" # listpack 编码(field 数 ≤ 128)# ... 继续添加 129 个 field ...HSET user:1 field129 "value" # 自动转换为 hashtable 编码OBJECT ENCODING user:1 # "hashtable"四、持久化
Redis 是内存数据库,进程退出数据即丢失。持久化机制是 Redis 走向生产的关键。与 存储引擎 中讨论的 WAL + Buffer Pool 模式不同,Redis 的持久化设计围绕”内存快照”和”命令日志”两条路线展开。
4.1 RDB 持久化
RDB(Redis Database)将某一时刻的全量数据以二进制快照形式写入磁盘:
COW(Copy-On-Write)机制:fork() 创建子进程时,父子进程共享同一块物理内存。只有当父进程修改某页时,内核才复制该页——这就是 COW。对于读多写少的缓存场景,COW 的额外内存开销极小。
# RDB 自动触发配置(redis.conf)save 900 1 # 900秒内有至少1次修改save 300 10 # 300秒内有至少10次修改save 60 10000 # 60秒内有至少10000次修改4.2 AOF 持久化
AOF(Append Only File)以日志形式记录每一条写命令:
AOF 同步策略对比:
| 策略 | fsync 频率 | 数据安全 | 性能影响 | 宕机丢失 |
|---|---|---|---|---|
always | 每条命令 | 最高 | 严重下降(~数百 QPS) | 最多丢 1 条命令 |
everysec | 每秒一次 | 较高 | 轻微影响 | 最多丢 1 秒数据 |
no | 由 OS 决定 | 最低 | 无影响 | 可能丢数秒数据 |
4.3 AOF 重写
AOF 文件会随时间不断膨胀。AOF 重写通过读取当前数据库状态,用最少的命令重建数据:
# 重写前 AOF(6 条命令)SET counter 1INCR counterINCR counterINCR counterDEL temp_keyRPUSH mylist a b c
# 重写后 AOF(2 条命令)SET counter 4RPUSH mylist a b c// AOF 重写流程(简化)int rewriteAppendOnlyFileBackground(void) { pid_t childpid;
if ((childpid = redisFork()) == 0) { // 子进程:遍历数据库,生成最简命令 char tmpfile[256]; snprintf(tmpfile, sizeof(tmpfile), "temp-rewriteaof-bg-%d.aof", getpid()); if (rewriteAppendOnlyFile(tmpfile) == C_OK) { exitFromChild(0); } else { exitFromChild(1); } } else { // 父进程:记录子进程 PID,继续服务 server.aof_child_pid = childpid; // 同时将重写期间的新命令写入 AOF 重写缓冲区 server.aof_rewrite_buf = sdsempty(); }}4.4 混合持久化
Redis 4.0 引入混合持久化,结合 RDB 和 AOF 的优势:
| 持久化方式 | 文件大小 | 恢复速度 | 数据安全 | 适用场景 |
|---|---|---|---|---|
| RDB | 小(二进制压缩) | 快(直接加载) | 可能丢数分钟数据 | 冷备份、容灾 |
| AOF | 大(文本命令) | 慢(重放命令) | 最多丢 1 秒 | 数据安全优先 |
| 混合持久化 | 中 | 较快 | 最多丢 1 秒 | 生产推荐方案 |
五、主从复制
Redis 的主从复制是高可用的基础。关于复制模型的通用讨论参见 数据复制,这里聚焦 Redis 的实现细节。
5.1 全量复制
当从节点首次连接主节点或断开过久时,执行全量复制:
# 全量复制的关键参数repl-timeout 60 # 复制超时(秒)repl-diskless-sync no # 是否无盘复制(直接通过网络发送 RDB)client-output-buffer-limit replica 256mb 64mb 60 # 从节点输出缓冲区限制5.2 增量复制
短时间断连后,从节点可通过 repl_backlog 环形缓冲区进行增量复制,避免全量同步的开销:
// 增量复制核心逻辑int masterTryPartialResynchronization(client *c, long long psync_offset) { long long psync_len;
// 检查主节点 runid 是否匹配 if (strcasecmp(c->argv[1]->ptr, server.replid)) { goto need_full_sync; // runid 不匹配,需全量 }
// 检查偏移量是否在 backlog 范围内 if (psync_offset < server.repl_backlog_off || psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen)) { goto need_full_sync; // 偏移量已过期,需全量 }
// 增量复制:发送 backlog 中从 psync_offset 开始的数据 psync_len = server.repl_backlog_off + server.repl_backlog_histlen - psync_offset; // ... 发送 psync_len 字节数据 ... return C_OK;}| 复制类型 | 触发条件 | 开销 | 数据量 |
|---|---|---|---|
| 全量复制 | 首次连接 / runid 变化 / 偏移量过期 | 高(BGSAVE + 传输 RDB) | 全量数据 |
| 增量复制 | 短暂断连 / 偏移量在 backlog 内 | 低(仅传输差异数据) | 部分数据 |
5.3 哨兵(Sentinel)
哨兵负责监控主节点健康状态,在故障时自动执行故障转移:
# 哨兵选举流程(Raft 协议简化版)1. 哨兵 A 发现主节点主观下线(SDOWN)2. 哨兵 A 询问其他哨兵:是否同意主节点下线?3. 超过 quorum 数量的哨兵同意 → 客观下线(ODOWN)4. 哨兵之间选举 Leader(Raft 协议)5. Leader 哨兵执行故障转移: a. 选择新主节点(优先级 > 复制偏移量 > runid 最小) b. 对新主节点执行 SLAVEOF NO ONE c. 通知其他从节点复制新主节点 d. 更新 sentinel 配置5.4 Redis 集群
Redis Cluster 采用去中心化的哈希槽分区,将 16384 个槽分配到不同节点:
# 集群键路由算法def slot(key): # 哈希标签:{user}:1 和 {user}:2 映射到同一槽 if key contains "{...}": key = extract_hash_tag(key) return CRC16(key) % 16384
# 示例CRC16("mykey") % 16384 = 12867 # 槽 12867CRC16("{user}:1") % 16384 = 5474 # 槽 5474CRC16("{user}:2") % 16384 = 5474 # 同一槽,支持多键操作| 特性 | 哨兵模式 | 集群模式 |
|---|---|---|
| 数据分片 | 不支持,全量数据 | 支持,16384 槽自动分布 |
| 扩展方式 | 只能读扩展(加从节点) | 读写扩展(加主节点 + 迁移槽) |
| 故障转移 | 哨兵选举 → 切换主节点 | 槽迁移 → 从节点升主 |
| 客户端复杂度 | 低(代理或直连主节点) | 高(需实现 MOVED/ASK 重定向) |
| 运维复杂度 | 中等 | 较高 |
六、事件驱动模型
Redis 的核心是一个单线程事件循环——这是理解 Redis 性能特征的关键。与 存储引擎 中讨论的 Buffer Pool 后台线程不同,Redis 的主线程同时处理网络 I/O 和命令执行。
6.1 Reactor 模式
Redis 采用 Reactor 设计模式——由一个事件循环线程监听多个 I/O 事件,事件就绪时分发到对应的处理器:
6.2 IO 多路复用
Redis 根据平台选择最优的 IO 多路复用实现:
// ae.c — 根据平台选择实现#ifdef HAVE_EVPORT#include "ae_evport.c" // Solaris event ports#else #ifdef HAVE_EPOLL #include "ae_epoll.c" // Linux epoll 最常用 #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" // macOS/FreeBSD kqueue #else #include "ae_select.c" // POSIX select(兜底) #endif #endif#endif// ae_epoll.c — epoll 封装(简化)typedef struct aeApiState { int epfd; // epoll 实例 struct epoll_event *events; // 事件数组} aeApiState;
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { aeApiState *state = eventLoop->apidata; int retval, numevents = 0;
// 等待事件就绪,tvp 为超时时间 retval = epoll_wait(state->epfd, state->events, eventLoop->setsize, tvp ? (tvp->tv_sec * 1000 + tvp->tv_usec / 1000) : -1);
if (retval > 0) { numevents = retval; for (int j = 0; j < numevents; j++) { int mask = 0; struct epoll_event *e = state->events + j; if (e->events & EPOLLIN) mask |= AE_READABLE; if (e->events & EPOLLOUT) mask |= AE_WRITABLE; eventLoop->fired[j].fd = e->data.fd; eventLoop->fired[j].mask = mask; } } return numevents;}6.3 文件事件与时间事件
Redis 事件循环处理两类事件:
| 事件类型 | 触发条件 | 处理函数 | 特征 |
|---|---|---|---|
| 文件事件 | socket 可读/可写 | readQueryFromClient / sendReplyToClient | 即时响应 |
| 时间事件 | 定时器到期 | serverCron | 周期执行(默认 100ms) |
// 事件循环主函数void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); // 循环前钩子 aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_AFTER_SLEEP); // 处理所有事件 }}
// aeProcessEvents 核心逻辑(简化)int aeProcessEvents(aeEventLoop *eventLoop, int flags) { // 1. 查找最近的时间事件,计算阻塞时间 if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT)) shortest = aeSearchNearestTimer(eventLoop);
// 2. 调用 epoll_wait 等待文件事件 numevents = aeApiPoll(eventLoop, &tvp);
// 3. 处理文件事件(优先) for (j = 0; j < numevents; j++) { fe = &eventLoop->events[eventLoop->fired[j].fd]; if (fe->mask & mask & AE_READABLE) fe->rfileProc(eventLoop, fd, fe->clientData, mask); if (fe->mask & mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, fe->clientData, mask); }
// 4. 处理时间事件 if (flags & AE_TIME_EVENTS) processTimeEvents(eventLoop);}6.4 Redis 6.x 多线程 I/O
Redis 6.0 引入了多线程 I/O,但命令执行仍然是单线程——多线程仅用于网络读写:
# redis.conf 多线程配置io-threads 4 # I/O 线程数(建议 ≤ CPU 核心数)io-threads-do-reads yes # 读操作也使用多线程多线程 I/O 的关键约束:主线程在 I/O 线程工作时必须等待(轮询 spin),因为命令执行必须串行化。这意味着多线程 I/O 只解决了网络 I/O 瓶颈,不解决 CPU 密集型命令(如 SORT、SUNION)的瓶颈。如果你的瓶颈在命令执行而非网络,多线程 I/O 帮助有限。
七、过期与淘汰
7.1 过期删除策略
Redis 使用惰性删除 + 定期删除的组合策略:
// 惰性删除:访问时检查是否过期robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) { robj *val; if (expireIfNeeded(db, key) == 0) { // 键已过期,返回 NULL return NULL; } val = lookupKey(db, key, flags); return val;}
// 定期删除:周期性随机抽样删除过期键void activeExpireCycle(int type) { // 每次循环抽样 20 个键 // 如果过期键占比 > 25%,继续抽样 // 每轮最多执行 25ms(自适应频率) for (int j = 0; j < dbs_per_call; j++) { int expired = 0; do { num = dictGetSomeKeys(db->expires, samples, ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP); for (i = 0; i < num; i++) { if (keyIsExpired(db, samples[i])) { deleteKey(db, samples[i]); expired++; } } } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP / 4); }}| 策略 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 | 访问键时 | 零 CPU 开销 | 过期键长期不访问则内存泄漏 |
| 定期删除 | 周期性抽样 | 平衡 CPU 与内存 | 可能遗漏少量过期键 |
| 定时删除 | 过期时立即删 | 内存最友好 | CPU 开销不可控,Redis 未采用 |
7.2 内存淘汰策略
当内存使用超过 maxmemory 限制时,Redis 根据配置的策略淘汰键:
| 策略 | 淘汰范围 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,写入报错 | 数据不可丢失 |
allkeys-lru | 所有键中淘汰最久未使用 | 缓存场景(最常用) |
allkeys-lfu | 所有键中淘汰最少使用 | 访问频率差异大的缓存 |
volatile-lru | 设了过期的键中淘汰最久未使用 | 混合持久化+缓存 |
volatile-lfu | 设了过期的键中淘汰最少使用 | 同上 |
volatile-ttl | 设了过期的键中淘汰 TTL 最短 | 业务明确 TTL 优先级 |
allkeys-random | 所有键中随机淘汰 | 无访问热点 |
volatile-random | 设了过期的键中随机淘汰 | 较少使用 |
LRU vs LFU 实现:Redis 的 LRU 并非精确 LRU(维护全局链表开销太大),而是基于 lru 字段的近似算法——随机抽样 N 个键,淘汰其中最久未访问的。LFU 则在 lru 字段低 8 位维护对数计数器,随时间衰减:
// LFU 计数器更新(简化)uint8_t lfuIncr(uint8_t counter) { if (counter < 255) { double r = (double)rand() / RAND_MAX; double baseval = counter - LFU_INIT_VAL; if (baseval < 0) baseval = 0; // 概率递增:计数器越大,递增概率越低 double p = 1.0 / (baseval * lfu_log_factor + 1); if (r < p) counter++; } return counter;}
// LFU 衰减:每 lfu_decay_time 分钟计数器减 1unsigned long LFUDecrAndReturn(robj *o) { unsigned long ldt = o->lru >> 8; // 高 16 位:衰减时间 unsigned long counter = o->lru & 255; // 低 8 位:计数器 unsigned long num_periods = server.unixtime - ldt; if (num_periods > lfu_decay_time) { counter = (num_periods / lfu_decay_time) > counter ? 0 : counter - (num_periods / lfu_decay_time); } return counter;}7.3 bigkey 问题
bigkey 是 Redis 生产环境最常见的性能杀手之一:
# 检测 bigkeyredis-cli --bigkeys -i 0.1
# 示例输出-------- summary -------Biggest string found so far: 'user:session:xxx' with 5.2 MBBiggest list found so far: 'queue:tasks' with 128456 entriesBiggest set found so far: 'tag:popular' with 89234 membersBiggest hash found so far: 'user:profile:123' with 523 fieldsBiggest zset found so far: 'rank:daily' with 234567 members| bigkey 危害 | 原因 | 解决方案 |
|---|---|---|
| 阻塞主线程 | DEL/EXPIRE 等操作需遍历所有元素 | UNLINK 异步删除 |
| 网络拥塞 | 一次传输数 MB 数据 | 分批获取(HSCAN/SSCAN) |
| 内存不均 | 集群模式下槽分配倾斜 | 拆分为多个小 key |
| 持久化阻塞 | COW 期间大 key 修改导致大量页复制 | 控制单 key 大小 ≤ 10KB |
# 异步删除 bigkeyUNLINK rank:daily # 非阻塞删除,后台线程回收内存
# 拆分 bigkey 示例# 原:HSET user:123 field1 v1 field2 v2 ... field500 v500# 拆:HSET user:123:1 field1 v1 field2 v2# HSET user:123:2 field3 v3 field4 v4·附、实践:Redis 数据结构与内存分析
本节用 Redis CLI 观察不同数据类型的底层编码和内存使用。需要 Redis 环境。
1. String 类型:embstr vs raw
redis-cli SET mykey "hello"redis-cli GET mykey# "hello"
redis-cli TYPE mykey# string
redis-cli OBJECT ENCODING mykey# embstr短字符串(≤44 字节)使用 embstr 编码——一次内存分配,SDS 头和字符串数据连续存储,缓存友好。
2. List 类型:listpack
redis-cli LPUSH mylist a b c d eredis-cli LLEN mylist# (integer) 5
redis-cli OBJECT ENCODING mylist# listpackRedis 7.0+ 用 listpack 替代了 ziplist——解决了连锁更新问题,同时保持紧凑存储。
3. Hash 类型:listpack vs hashtable
redis-cli HSET myhash field1 value1 field2 value2 field3 value3redis-cli HGETALL myhashredis-cli OBJECT ENCODING myhash# listpack小 Hash(字段数少、值短)使用 listpack 紧凑存储;当字段数或值长度超过阈值时,自动转换为 hashtable。
4. Sorted Set 类型:listpack vs skiplist
redis-cli ZADD myzset 1 "one" 2 "two" 3 "three" 4 "four" 5 "five"redis-cli ZRANGE myzset 0 -1 WITHSCORESredis-cli OBJECT ENCODING myzset# listpack小 Sorted Set 使用 listpack;元素超过阈值后转换为 skiplist + hashtable 双结构。
5. 内存使用分析
redis-cli MEMORY USAGE mykey# (integer) 64
redis-cli MEMORY USAGE mylist# (integer) 72
redis-cli MEMORY USAGE myhash# (integer) 104
redis-cli MEMORY USAGE myzset# (integer) 966. RDB 持久化
redis-cli CONFIG GET save# 1) "save"# 2) "3600 1 300 100 60 10000"
redis-cli BGSAVE# Background saving started
redis-cli LASTSAVE# (integer) 1778209989RDB 是 Redis 的快照持久化——save 3600 1 300 100 60 10000 表示:3600 秒内有 1 次写操作、300 秒内有 100 次写操作、60 秒内有 10000 次写操作时自动触发 BGSAVE。
7. 内存信息
redis-cli INFO memory | head -8# Memoryused_memory:1357784used_memory_human:1.29Mused_memory_rss:8388608used_memory_rss_human:8.00Mused_memory_peak:1357784used_memory_peak_human:1.29Mused_memory_peak_perc:100.01%used_memory_overhead:949432used_memory 是 Redis 分配的内存,used_memory_rss 是操作系统分配给 Redis 的物理内存——RSS 远大于 used_memory 是因为内存碎片和 jemalloc 的分配策略。
八、总结
核心机制速查
| 机制 | 核心设计 | 关键取舍 |
|---|---|---|
| SDS | 预分配 + 二进制安全 | 牺牲部分内存换取 O(1) 长度获取和缓冲区安全 |
| 压缩列表/listpack | 连续内存紧凑存储 | 节省内存 vs 连锁更新风险(listpack 已解决) |
| 跳表 | 概率平衡多层链表 | 实现简单 + 范围查询友好 vs 理论常数略大于平衡树 |
| 整数集合 | 升级不降级 | 节省内存 vs 不支持降级 |
| 渐进式 rehash | 分摊迁移到每次操作 | 避免阻塞 vs rehash 期间双表内存开销 |
| RDB | COW 快照 | 恢复快 vs 可能丢数据 |
| AOF | 追加日志 + 重写 | 数据安全 vs 文件大/恢复慢 |
| 混合持久化 | RDB + AOF | 兼顾恢复速度与数据安全 |
| Reactor | 单线程事件循环 | 简单无锁 vs CPU 密集命令阻塞 |
| 多线程 I/O | 网络 I/O 多线程 | 解决网络瓶颈 vs 命令执行仍单线程 |
Redis 设计哲学
Redis 的所有设计决策都围绕一个核心原则:在内存中,简单即是快。
- 单线程避免了锁竞争和上下文切换的开销
- 紧凑编码(listpack/intset/embstr)用 CPU 时间换内存空间
- 渐进式操作(rehash/过期删除)将大任务分摊到小步骤
- 概率算法(跳表/近似 LRU)用理论最优的微小偏差换取实现的极大简化
理解了这些设计取舍,你就能在面对缓存穿透、bigkey 阻塞、主从延迟等问题时,从原理层面做出正确决策——而不是盲目调参。
延伸阅读:本章聚焦 Redis 单机内部实现。关于主从复制的通用理论(单主/多主/无主复制、读写一致性),参见 数据复制。关于存储引擎的磁盘模型(B 树/LSM 树/WAL),参见 存储引擎。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






