mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4449 字
12 分钟
Redis 深入:数据结构、持久化与事件驱动
2024-07-26

当你执行一条 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 会根据数据规模和特征自动选择最优编码:

graph TB subgraph 类型层["数据类型(用户视角)"] STRING["String"] LIST["List"] HASH["Hash"] SET["Set"] ZSET["Sorted Set"] end subgraph 编码层["底层编码(Redis 内部)"] INT["int<br/>整数值"] EMBSTR["embstr<br/>≤44字节短字符串"] RAW["raw<br/>SDS长字符串"] LISTPACK["listpack<br/>压缩列表(Redis 7.0+)"] QUICKLIST["quicklist<br/>压缩列表+双向链表"] HT["hashtable<br/>哈希表"] INTSET["intset<br/>整数集合"] SKIPLIST["skiplist + hashtable<br/>跳表+哈希表"] end STRING --> INT STRING --> EMBSTR STRING --> RAW LIST --> LISTPACK LIST --> QUICKLIST HASH --> LISTPACK HASH --> HT SET --> INTSET SET --> HT ZSET --> LISTPACK ZSET --> SKIPLIST style 类型层 fill:#e8eaf6,stroke:#283593 style 编码层 fill:#e0f2f1,stroke:#00695c

1.2 类型与编码对应关系#

类型编码常量触发条件优势
StringOBJ_ENCODING_INT值为长整型(≤LONG_MAX)零额外内存,直接存在 ptr 指针位置
StringOBJ_ENCODING_EMBSTR字符串长度 ≤ 44 字节一次分配,redisObject 与 SDS 连续内存
StringOBJ_ENCODING_RAW字符串长度 > 44 字节独立 SDS,支持动态扩展
ListOBJ_ENCODING_LISTPACK元素数 ≤ 128 且每个 ≤ 64 字节紧凑存储,省内存
ListOBJ_ENCODING_QUICKLIST超出 listpack 阈值分段压缩,兼顾内存与性能
HashOBJ_ENCODING_LISTPACKfield 数 ≤ 128 且每个值 ≤ 64 字节顺序存储,缓存友好
HashOBJ_ENCODING_HT超出 listpack 阈值O(1) 查找
SetOBJ_ENCODING_INTSET元素全为整数且数量 ≤ 128有序紧凑,省内存
SetOBJ_ENCODING_HT超出 intset 阈值O(1) 查找
Sorted SetOBJ_ENCODING_LISTPACK元素数 ≤ 128 且每个 ≤ 64 字节紧凑存储
Sorted SetOBJ_ENCODING_SKIPLIST超出 listpack 阈值O(log n) 范围查询
Note

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 字段记录前一个节点的长度,用于从尾向头遍历。但正是这个设计引发了连锁更新问题:

flowchart LR A["entry1<br/>prevlen=1byte<br/>len<254"] --> B["entry2<br/>prevlen=1byte<br/>len=250"] B --> C["entry3<br/>prevlen=1byte<br/>len=253"] A2["entry1<br/>prevlen=1byte<br/>len=254+"] -.->|"prevlen 扩为 5byte"| B2["entry2<br/>prevlen=5byte<br/>总长≥254"] B2 -.->|"prevlen 扩为 5byte"| C2["entry3<br/>prevlen=5byte"] style A fill:#c8e6c9,stroke:#2e7d32 style B fill:#c8e6c9,stroke:#2e7d32 style C fill:#c8e6c9,stroke:#2e7d32 style A2 fill:#ffcdd2,stroke:#c62828 style B2 fill:#ffcdd2,stroke:#c62828 style C2 fill:#ffcdd2,stroke:#c62828

当 entry1 从 250 字节扩展到 254+ 字节时,entry2 的 prevlen 需要从 1 字节扩展到 5 字节,导致 entry2 自身也超过 254 字节,进而引发 entry3 的 prevlen 扩展……最坏情况下 N 个节点全部级联重分配,复杂度退化为 O(N²)。

Warning

连锁更新是概率事件——需要连续多个 entry 的长度恰好在 250~253 字节之间。实际生产中极少触发,但理解这一机制才能明白 Redis 7.0 为什么用 listpack 替代 ziplist。listpack 将 prevlen 改为记录自身长度,彻底消除了级联依赖。

2.3 跳表(skiplist)#

跳表是 Sorted Set 的核心数据结构,以概率平衡替代严格的树平衡,实现 O(log n) 的查找、插入和删除:

graph LR subgraph L3["Level 3"] H3["HEAD"] --> N3_1["1"] N3_1 --> T3["TAIL"] end subgraph L2["Level 2"] H2["HEAD"] --> N2_1["1"] N2_1 --> N2_4["4"] N2_4 --> T2["TAIL"] end subgraph L1["Level 1"] H1["HEAD"] --> N1_1["1"] N1_1 --> N1_3["3"] N1_3 --> N1_4["4"] N1_4 --> N1_7["7"] N1_7 --> T1["TAIL"] end subgraph L0["Level 0(完整链表)"] H0["HEAD"] --> N0_1["1"] N0_1 --> N0_2["2"] N0_2 --> N0_3["3"] N0_3 --> N0_4["4"] N0_4 --> N0_5["5"] N0_5 --> N0_6["6"] N0_6 --> N0_7["7"] N0_7 --> T0["TAIL"] end

查找过程(以查找 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);
}
// ... 正常插入(二分查找 + 移位)
}
Tip

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 将迁移分摊到每次操作中:

flowchart TB A["触发 rehash<br/>负载因子 > 1(或 > 5 强制)"] --> B["分配 ht[1] 空间<br/>rehashidx = 0"] B --> C["每次 CRUD 操作时<br/>迁移 ht[0] 中一个桶的所有节点"] C --> D{"ht[0] 迁移完毕?"} D -->|否| E["rehashidx++<br/>继续渐进迁移"] E --> C D -->|是| F["释放 ht[0]<br/>ht[1] → ht[0]<br/>rehashidx = -1"] style A fill:#fff9c4,stroke:#f9a825 style F fill:#c8e6c9,stroke:#2e7d32
// 渐进式 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;
字段位数作用
type4 bit标识数据类型,TYPE key 命令返回此值
encoding4 bit标识底层编码,OBJECT ENCODING key 可查看
lru24 bitLRU 模式存秒级时间戳;LFU 模式高 16 位存分钟级衰减时间,低 8 位存计数器
refcount32 bit引用计数,为 0 时回收;小整数共享对象 refcount 初始为 OBJ_SHARED_REFCOUNT
ptr64 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; // 无限引用
}
}
Note

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)将某一时刻的全量数据以二进制快照形式写入磁盘:

flowchart TB A["触发 RDB<br/>SAVE / BGSAVE / 自动触发"] --> B{"SAVE 还是 BGSAVE?"} B -->|SAVE| C["主进程执行<br/> 阻塞所有客户端"] B -->|BGSAVE| D["fork() 子进程"] D --> E["子进程利用 COW<br/>遍历内存生成 RDB 文件"] E --> F["写入临时文件<br/>原子替换 dump.rdb"] F --> G["主进程继续服务<br/>几乎零阻塞"] style C fill:#ffcdd2,stroke:#c62828 style G fill:#c8e6c9,stroke:#2e7d32

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)以日志形式记录每一条写命令:

flowchart LR A["客户端写命令"] --> B["追加到 AOF 缓冲区<br/>aof_buf"] B --> C{"根据同步策略<br/>写入 AOF 文件"} C -->|always| D["每条命令 fsync<br/>最安全,最慢"] C -->|everysec| E["每秒 fsync<br/>折中方案(默认)"] C -->|no| F["交由 OS 刷盘<br/>最快,可能丢数据"] style D fill:#c8e6c9,stroke:#2e7d32 style E fill:#fff9c4,stroke:#f9a825 style F fill:#ffcdd2,stroke:#c62828

AOF 同步策略对比

策略fsync 频率数据安全性能影响宕机丢失
always每条命令最高严重下降(~数百 QPS)最多丢 1 条命令
everysec每秒一次较高轻微影响最多丢 1 秒数据
no由 OS 决定最低无影响可能丢数秒数据

4.3 AOF 重写#

AOF 文件会随时间不断膨胀。AOF 重写通过读取当前数据库状态,用最少的命令重建数据:

# 重写前 AOF(6 条命令)
SET counter 1
INCR counter
INCR counter
INCR counter
DEL temp_key
RPUSH mylist a b c
# 重写后 AOF(2 条命令)
SET counter 4
RPUSH 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 的优势:

flowchart TB A["AOF 重写触发"] --> B["fork 子进程"] B --> C["子进程生成<br/>RDB 格式数据(前半段)<br/>+ 增量 AOF 命令(后半段)"] C --> D["写入临时文件"] D --> E["原子替换旧 AOF 文件"] subgraph 混合AOF文件结构 direction LR R["RDB 格式<br/>(紧凑,加载快)"] --> AOF["AOF 格式<br/>(增量命令)"] end style R fill:#bbdefb,stroke:#1565c0 style AOF fill:#c8e6c9,stroke:#2e7d32
持久化方式文件大小恢复速度数据安全适用场景
RDB小(二进制压缩)快(直接加载)可能丢数分钟数据冷备份、容灾
AOF大(文本命令)慢(重放命令)最多丢 1 秒数据安全优先
混合持久化较快最多丢 1 秒生产推荐方案

五、主从复制#

Redis 的主从复制是高可用的基础。关于复制模型的通用讨论参见 数据复制,这里聚焦 Redis 的实现细节。

5.1 全量复制#

当从节点首次连接主节点或断开过久时,执行全量复制:

sequenceDiagram participant M as 主节点 participant S as 从节点 S->>M: PSYNC ? -1(首次连接) M->>S: +FULLRESYNC <runid> <offset> M->>M: 执行 BGSAVE 生成 RDB M->>S: 发送 RDB 文件 M->>S: 发送缓冲区中的增量命令 S->>S: 清空旧数据,加载 RDB S->>S: 执行增量命令,追上进度 S->>M: ACK(复制完成)
# 全量复制的关键参数
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 # 槽 12867
CRC16("{user}:1") % 16384 = 5474 # 槽 5474
CRC16("{user}:2") % 16384 = 5474 # 同一槽,支持多键操作
特性哨兵模式集群模式
数据分片不支持,全量数据支持,16384 槽自动分布
扩展方式只能读扩展(加从节点)读写扩展(加主节点 + 迁移槽)
故障转移哨兵选举 → 切换主节点槽迁移 → 从节点升主
客户端复杂度低(代理或直连主节点)高(需实现 MOVED/ASK 重定向)
运维复杂度中等较高

六、事件驱动模型#

Redis 的核心是一个单线程事件循环——这是理解 Redis 性能特征的关键。与 存储引擎 中讨论的 Buffer Pool 后台线程不同,Redis 的主线程同时处理网络 I/O 和命令执行。

6.1 Reactor 模式#

Redis 采用 Reactor 设计模式——由一个事件循环线程监听多个 I/O 事件,事件就绪时分发到对应的处理器:

flowchart TB subgraph 客户端 C1["Client 1"] C2["Client 2"] C3["Client N"] end subgraph Reactor["Redis 事件循环(单线程)"] MUX["IO 多路复用<br/>epoll_wait()"] --> DEMUX["事件分派器"] DEMUX --> AE_READABLE["可读事件<br/>命令请求"] DEMUX --> AE_WRITABLE["可写事件<br/>命令响应"] DEMUX --> TIME["时间事件<br/>serverCron"] AE_READABLE --> READ["readQueryFromClient<br/>读取→解析→执行"] AE_WRITABLE --> WRITE["sendReplyToClient<br/>发送响应"] TIME --> CRON["serverCron<br/>过期清理/统计/重写"] end C1 --> MUX C2 --> MUX C3 --> MUX style MUX fill:#bbdefb,stroke:#1565c0 style DEMUX fill:#c8e6c9,stroke:#2e7d32

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,但命令执行仍然是单线程——多线程仅用于网络读写:

flowchart TB subgraph 主线程["主线程(命令执行)"] PARSE["解析命令"] EXEC["执行命令<br/>(仍是单线程,保证原子性)"] RESP["构建响应"] end subgraph IO线程组["I/O 线程组"] IO1["I/O Thread 1<br/>读取/发送"] IO2["I/O Thread 2<br/>读取/发送"] ION["I/O Thread N<br/>读取/发送"] end CLIENTS["客户端连接"] --> IO1 CLIENTS --> IO2 CLIENTS --> ION IO1 --> PARSE IO2 --> PARSE ION --> PARSE EXEC --> RESP RESP --> IO1 RESP --> IO2 RESP --> ION IO1 --> CLIENTS IO2 --> CLIENTS ION --> CLIENTS style EXEC fill:#c8e6c9,stroke:#2e7d32 style IO线程组 fill:#bbdefb,stroke:#1565c0
# redis.conf 多线程配置
io-threads 4 # I/O 线程数(建议 ≤ CPU 核心数)
io-threads-do-reads yes # 读操作也使用多线程
Warning

多线程 I/O 的关键约束:主线程在 I/O 线程工作时必须等待(轮询 spin),因为命令执行必须串行化。这意味着多线程 I/O 只解决了网络 I/O 瓶颈,不解决 CPU 密集型命令(如 SORTSUNION)的瓶颈。如果你的瓶颈在命令执行而非网络,多线程 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 分钟计数器减 1
unsigned 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 生产环境最常见的性能杀手之一:

# 检测 bigkey
redis-cli --bigkeys -i 0.1
# 示例输出
-------- summary -------
Biggest string found so far: 'user:session:xxx' with 5.2 MB
Biggest list found so far: 'queue:tasks' with 128456 entries
Biggest set found so far: 'tag:popular' with 89234 members
Biggest hash found so far: 'user:profile:123' with 523 fields
Biggest zset found so far: 'rank:daily' with 234567 members
bigkey 危害原因解决方案
阻塞主线程DEL/EXPIRE 等操作需遍历所有元素UNLINK 异步删除
网络拥塞一次传输数 MB 数据分批获取(HSCAN/SSCAN
内存不均集群模式下槽分配倾斜拆分为多个小 key
持久化阻塞COW 期间大 key 修改导致大量页复制控制单 key 大小 ≤ 10KB
# 异步删除 bigkey
UNLINK 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 e
redis-cli LLEN mylist
# (integer) 5
redis-cli OBJECT ENCODING mylist
# listpack

Redis 7.0+ 用 listpack 替代了 ziplist——解决了连锁更新问题,同时保持紧凑存储。

3. Hash 类型:listpack vs hashtable#

redis-cli HSET myhash field1 value1 field2 value2 field3 value3
redis-cli HGETALL myhash
redis-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 WITHSCORES
redis-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) 96

6. 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) 1778209989

RDB 是 Redis 的快照持久化——save 3600 1 300 100 60 10000 表示:3600 秒内有 1 次写操作、300 秒内有 100 次写操作、60 秒内有 10000 次写操作时自动触发 BGSAVE。

7. 内存信息#

redis-cli INFO memory | head -8
# Memory
used_memory:1357784
used_memory_human:1.29M
used_memory_rss:8388608
used_memory_rss_human:8.00M
used_memory_peak:1357784
used_memory_peak_human:1.29M
used_memory_peak_perc:100.01%
used_memory_overhead:949432

used_memory 是 Redis 分配的内存,used_memory_rss 是操作系统分配给 Redis 的物理内存——RSS 远大于 used_memory 是因为内存碎片和 jemalloc 的分配策略。

八、总结#

核心机制速查#

机制核心设计关键取舍
SDS预分配 + 二进制安全牺牲部分内存换取 O(1) 长度获取和缓冲区安全
压缩列表/listpack连续内存紧凑存储节省内存 vs 连锁更新风险(listpack 已解决)
跳表概率平衡多层链表实现简单 + 范围查询友好 vs 理论常数略大于平衡树
整数集合升级不降级节省内存 vs 不支持降级
渐进式 rehash分摊迁移到每次操作避免阻塞 vs rehash 期间双表内存开销
RDBCOW 快照恢复快 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),参见 存储引擎

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Redis 深入:数据结构、持久化与事件驱动
https://blog.souloss.com/posts/database/redis-deep-dive/
作者
Souloss
发布于
2024-07-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时