mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6622 字
18 分钟
Redis 数据结构与持久化原理
2024-07-26

当你执行一条 SET key value,Redis 如何在微秒级完成存储?当你用 ZRANK 查询排行榜,O(log n) 的跳表如何工作?RDB 和 AOF 两种持久化各自怎么把内存数据落到磁盘?Redis 单线程为何能撑起 10 万+ QPS?

Redis 将一切数据放在内存中,用精心设计的数据结构和单线程事件驱动模型换取极致性能。与基于磁盘的 B 树/LSM 树引擎不同,它没有 Buffer Pool 和 WAL 的包袱,所有读写直接发生在内存里。掌握 Redis 的内部实现,才能在缓存穿透、bigkey、主从延迟等真实问题面前做出正确决策。

前置知识#

Important
  • 了解 B 树/LSM 树等磁盘存储引擎的基本模型,便于和 Redis 的内存模型对比

  • 基本的网络编程概念:事件驱动、Reactor 模式、epoll

一、Redis 数据结构全景#

1.1 基本类型与底层编码#

Redis 提供 5 种基本数据类型(外加 Stream 共 6 种),每种类型在底层可能对应多种编码实现。Redis 会根据数据规模和特征自动选择最优编码:

graph TB subgraph type_layer["数据类型(用户视角)"] STRING["String"] LIST["List"] HASH["Hash"] SET["Set"] ZSET["Sorted Set"] STREAM["Stream<br/>(Redis 5.0+)"] end subgraph encoding_layer["底层编码(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/>跳表+哈希表"] RADIX["radix tree + listpack<br/>基数树"] end STRING --> INT STRING --> EMBSTR STRING --> RAW LIST --> LISTPACK LIST --> QUICKLIST HASH --> LISTPACK HASH --> HT SET --> INTSET SET --> HT ZSET --> LISTPACK ZSET --> SKIPLIST STREAM --> RADIX style type_layer fill:#e8eaf6,stroke:#283593 style encoding_layer 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元素全为整数且数量 ≤ 512有序紧凑,省内存
SetOBJ_ENCODING_HT超出 intset 阈值O(1) 查找
Sorted SetOBJ_ENCODING_LISTPACK元素数 ≤ 128 且每个 ≤ 64 字节紧凑存储
Sorted SetOBJ_ENCODING_SKIPLIST超出 listpack 阈值O(log n) 范围查询
StreamOBJ_ENCODING_STREAM始终radix tree + listpack,按 ID 前缀组织消息流
Note

Redis 7.0 用 listpack 替换了 ziplist(压缩列表)。旧版 ziplist 存在”连锁更新”问题,一个节点的扩容可能引发后续所有节点的级联重分配。listpack 通过将节点长度从”前驱记录”改为”自身记录”消除了这一问题。下文仍以 ziplist 讲解原理,因为理解连锁更新是认识 listpack 改进的前提。

表中”128 元素 / 64 字节”是 ziplist 时代的经典默认阈值(如 hash-max-ziplist-entries=128hash-max-ziplist-value=64)。Redis 7 改用 listpack 后,阈值改由 list-max-listpack-sizehash-max-listpack-entrieshash-max-listpack-value 等参数控制。以 List 为例,list-max-listpack-size 默认 -2,即按每个 listpack 节点约 8 KB 的字节数上限切分,没有”128 个元素”的硬上限,200 个短元素仍可能装进同一个 listpack。具体阈值以 CONFIG GET 实际输出为准。

Stream 是 Redis 5.0 引入的第 6 种数据类型,不在传统五种基本类型内,它的 radix tree 编码与消费者组机制见2.7 节

二、底层数据结构#

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)#

压缩列表是为节约内存而设计的顺序数据结构,将所有元素紧凑地排列在一块连续内存中:

压缩列表的内存布局从头部到尾部依次排列:

  1. zlbytes(4 字节):整个 ziplist 占用的字节数
  2. zltail(4 字节):尾节点距离起始地址的偏移量,用于从尾部反向遍历
  3. zllen(2 字节):节点数量
  4. entry1 ... entryN:变长的节点列表
  5. zlend(1 字节):固定 0xFF,标记结束

每个 entry 内部又由三部分组成:prevlen(1 或 5 字节,记录前一个节点的长度)、encoding(变长,记录数据和编码方式)、data(实际数据)。

每个 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/4)每节点固定 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],删除和修改在对应表中操作。

2.6 quicklist:List 的真实编码#

前面说 List 在小数据量下用 listpack,超出阈值后换成 quicklist。quicklist(快速列表)是 Redis 3.2 引入的 List 专用编码,本质是”用 listpack 节点串成的双向链表”,兼顾了链表的灵活和 listpack 的紧凑。

为什么不再直接用普通双向链表?普通链表每个节点存一个元素,节点间用前后指针连接,指针本身就占 16 字节,元素少时内存浪费严重,而且节点内存不连续,缓存不友好。纯 listpack 又受限于单段连续内存,元素一多扩容和搬迁代价陡增。quicklist 的折中是:把数据按段切进多个 listpack,每段 listpack 作为一个链表节点,节点之间用前后指针连接。

graph LR HEAD["head<br/>quicklistNode"] --> N1["node1<br/>listpack(48元素)"] N1 --> N2["node2<br/>listpack(48元素)<br/>LZF压缩"] N2 --> N3["node3<br/>listpack(48元素)"] N3 --> TAIL["tail<br/>quicklistNode"] HEAD -.->|"prev"| TAIL N1 -.->|"prev"| HEAD style N2 fill:#fff3e0,stroke:#e65100

每个链表节点是一个 quicklistNode,记录前后节点指针、内部 listpack 的指针、元素数量、压缩方式等:

typedef struct quicklistNode {
struct quicklistNode *prev; // 前驱节点
struct quicklistNode *next; // 后继节点
unsigned char *entry; // 指向 listpack 的指针
size_t count; // 本节点元素数
unsigned int container : 2; // 节点类型,2 表示 listpack
unsigned int encoding : 2; // 编码,1 表示 LZF 压缩
// ...
} quicklistNode;

quicklist 还会对中间节点做 LZF 压缩。首尾两端的节点保留不压缩,因为 LPUSH/RPOP 这类高频操作都打在两端,压缩了又要频繁解压反而拖慢。中间节点访问概率低,压缩后能显著省内存。压缩行为由 list-compress-depth 控制,默认值为 0,即不对任何 quicklist 节点做 LZF 压缩;调到 1 时两端各保留 1 个未压缩节点、中间节点压缩,调到 2 则两端各保留 2 个未压缩节点,以此类推。生产中对 List 内存敏感时可按需调大该值。

list-max-ziplist-size 控制每个 listpack 节点的容量上限(正数表示元素数上限,负数表示按字节大小算),它决定了 quicklist 在”每个节点存多少元素”和”链表多长”之间怎么分配,是 List 内存与性能的主要调参点。

2.7 Stream 与 radix tree#

Stream 是 Redis 5.0 引入的数据类型,专门做消息流。它弥补了 List 只能做简单队列、Pub/Sub 不持久化不回溯的短板,支持消费者组、消息确认、历史回溯。Stream 不在传统”五种基本类型”之列,但它是 Redis 里唯一的原生流式数据结构,值得单独看。

Stream 的存储不是链表也不是哈希表,而是 radix tree(基数树)配 listpack 节点。radix tree 是压缩前缀树的一种,按 key 的公共前缀组织,相同前缀的 entry 共用一个树节点,叶子节点指向一个 listpack,listpack 里存实际的消息条目。

graph TD ROOT["radix root"] --> A["前缀 1704..."] ROOT --> B["前缀 1705..."] A --> LP1["listpack 节点<br/>entry1, entry2, entry3"] B --> LP2["listpack 节点<br/>entry4, entry5"] style ROOT fill:#e8eaf6,stroke:#283593 style LP1 fill:#e0f2f1,stroke:#00695c style LP2 fill:#e0f2f1,stroke:#00695c

为什么用 radix tree 而不是跳表或哈希表?Stream 的 entry ID 是单调递增的(形如 1704000000-0),radix tree 能复用 ID 的公共前缀,相邻 ID 共享前缀节点,内存极省。同时按 ID 前缀有序,范围读取(XRANGE)和按 ID 跳转(XSEEK)都能顺着树结构高效定位,不像哈希表要全表扫描。

每条消息在 listpack 里以 field-value 对的形式存。一个 Stream entry 可以带多个 field,等价于一个 Hash,所以 Stream 也能看成”带 ID 的、追加写的、可消费的 Hash 序列”。

消费者组(Consumer Group)是 Stream 区别于 List 队列的关键。它让多个消费者分摊同一批消息,靠两个结构支撑:每个消费者组维护一个”最后投递 ID”(last-delivered-id),投递新消息时只取比它大的;同时维护 PEL(Pending Entries List)记录已投递但未确认的消息。消费者 XREADGROUP 取走消息后记进 PEL,XACK 确认后才从 PEL 删除,没确认的消息可以用 XPENDING/XCLAIM 重新投给别的消费者,这就实现了至少一次(at-least-once)投递和故障转移。

Note

Stream 和 List 队列(LPUSH + BRPOP)都能做消息传递,但语义不同:List 是”读即删”的简单队列,消费完就没了;Stream 是日志型结构,消息写进 Stream 后所有消费者组都能各自消费到自己的进度,还能回溯历史。做简单任务分发用 List 够了,要做可靠投递、多消费组、消息回溯就用 Stream。

三、对象系统#

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 字节)typeencodinglrurefcount
后半段sdshdr8 + buf + \0lenallocflagsdata

整块内存只需一次 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,Redis 7 默认)
save 3600 1 # 3600 秒内有至少 1 次修改
save 300 100 # 300 秒内有至少 100 次修改
save 60 10000 # 60 秒内有至少 10000 次修改
Note

Redis 6.x 及更早版本的默认 save 配置是 900 1 300 10 60 10000,即 900 秒内 1 次、300 秒内 10 次、60 秒内 10000 次。Redis 7 起默认值改为 3600 1 300 100 60 10000,拉长了低频触发的间隔(900→3600)、调高了中频触发的阈值(10→100),减少小写入频繁触发 BGSAVE 的 fork 开销。具体阈值以 CONFIG GET save 实际输出为准。

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 的主从复制、哨兵故障转移、Cluster 分片与 Gossip、脑裂防护等高可用机制,是独立的专题,在 redis-主从复制与高可用 中完整讨论。本篇聚焦单机内部实现,复制机制不在此展开。

六、事件驱动模型#

Redis 的核心是一个单线程事件循环,这是理解 Redis 性能特征的关键。与磁盘数据库的 Buffer Pool 后台线程模型不同,Redis 的主线程同时处理网络 I/O 和命令执行。

6.1 Reactor 模式#

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

flowchart TB subgraph clients["客户端"] 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 main_thread["主线程(命令执行)"] PARSE["解析命令"] EXEC["执行命令<br/>(仍是单线程,保证原子性)"] RESP["构建响应"] end subgraph io_threads["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_threads 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 生产环境最常见的性能杀手之一。现象是某次 DELHGETALLRDB 触发后,主线程突然阻塞几十毫秒到几秒。根因在于 Redis 命令执行是单线程的,处理一个包含十几万元素的 bigkey 时,遍历和释放操作会占满事件循环,其他客户端命令只能排队等待。

# 检测 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

7.4 持久化 fork 阻塞#

BGSAVEBGREWRITEAOF 都依赖 fork() 创建子进程。fork 本身是轻量操作,但 Redis 实例占用几 GB 甚至几十 GB 内存时,fork 需要遍历并复制页表,这段页表拷贝期间主线程被阻塞。

阻塞时长与实例内存大小正相关。内存越大,页表越大,fork 耗时越长。在这期间所有客户端命令都在排队等待,表现为突发的延迟尖峰。

根因有两层:

  1. 页表拷贝fork 需要复制父进程的页表项,10 GB 内存大约对应数百万个页表项,拷贝本身就要消耗几十到几百毫秒。
  2. COW 期间的页复制fork 之后父进程继续处理写请求,每修改一页内核就要复制该页。如果大 key 被频繁修改,COW 会把内存翻倍,同时复制操作发生在主线程的写路径上。
# 查看 fork 耗时
redis-cli INFO stats | grep latest_fork_usec
# latest_fork_usec:82300 # 单位微秒,82ms
# 延迟监控(需开启 latency monitor)
redis-cli CONFIG SET latency-monitor-threshold 100
redis-cli LATENCY HISTORY fork
排查维度方法
fork 耗时INFO statslatest_fork_usec,超过 100ms 就需要警惕
延迟事件LATENCY HISTORY fork,周期性出现的尖峰对应 BGSAVE/BGREWRITEAOF
内存翻倍INFO memory 观察 used_memory_rss 在 fork 期间是否接近翻倍

控制方案:

  • 控制单实例内存,大实例拆分到多个小实例,单实例建议不超过 10 GB
  • 调低 save 触发频率或关闭自动 RDB,改为低峰期手动 BGSAVE
  • 开启 activedefrag yes 主动碎片整理,降低 fork 时的实际页数
  • AOF 重写同理,调大 auto-aof-rewrite-percentage 避免频繁触发
Warning

如果 Redis 运行在虚拟化或容器环境(如 KVM、Xen),fork 可能更慢,因为 hypervisor 对 fork 的页表操作有额外开销。生产环境中 Redis 延迟突然飙升,先查 latest_fork_usec

Note

上述 fork 阻塞的诊断流程和阈值参考 Redis 官方文档和社区经验,具体的阻塞时长和内存翻倍幅度取决于实例规模、写入模式和宿主环境,待补充真实案例数据。

附录: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 的分配策略。

八、部署运维要点#

8.1 持久化参数#

RDB 关键参数:

参数默认值说明
save3600 1 300 100 60 10000自动触发 BGSAVE 的条件,格式为「秒数 修改次数」,可多条
stop-writes-on-bgsave-erroryesBGSAVE 失败时拒绝写入,避免静默丢数据
rdbcompressionyesRDB 文件 LZF 压缩,CPU 换磁盘空间
rdbchecksumyesRDB 文件尾部 CRC64 校验,加载时验证完整性
dbfilenamedump.rdbRDB 文件名
dir./RDB 和 AOF 文件目录

AOF 关键参数:

参数默认值说明
appendfsynceverysecAOF 刷盘策略,always 每条命令刷盘、everysec 每秒、no 交给 OS
auto-aof-rewrite-percentage100AOF 文件比上次重写后大 100% 触发重写
auto-aof-rewrite-min-size64mb触发重写的最小文件大小,避免小文件频繁重写
aof-use-rdb-preambleyesAOF 重写时前半段用 RDB 格式(即混合持久化)
aof-load-truncatedyesAOF 文件末尾损坏时截断后继续加载

8.2 内存参数#

参数说明
maxmemory实例内存上限,超过后按 maxmemory-policy 淘汰,生产环境必须设置
maxmemory-policy淘汰策略,缓存场景常用 allkeys-lruallkeys-lfu,持久化场景用 noeviction
maxmemory-samples近似 LRU/LFU 的随机抽样数,默认 5,调大更精确但更慢
activedefragyes 开启主动碎片整理,后台线程合并内存碎片
active-defrag-ignore-bytes触发碎片整理的最小碎片字节数,默认 100 MB
active-defrag-threshold-lower碎片率超 10% 开始整理(frag / used 比值)
active-defrag-cycle-min碎片整理最小 CPU 占用百分比,默认 1
active-defrag-cycle-max碎片整理最大 CPU 占用百分比,默认 25

8.3 监控指标#

生产环境需要持续关注几类指标:

指标获取方式关注点
used_memory_rss / used_memoryINFO memory碎片率,比值大于 1.5 说明碎片严重
latencyLATENCY HISTORY延迟尖峰事件,fork、bigkey、AOF fsync 都会记录
latest_fork_usecINFO stats上次 fork 耗时,超过 100ms 需要警惕
connected_clientsINFO clients客户端连接数,突增可能是连接泄漏
rejected_connectionsINFO stats被拒绝的连接数,非 0 说明 maxclients 不够
expired_keys / evicted_keysINFO stats过期和淘汰的键数量,辅助判断内存压力
master_repl_offsetINFO replication主从复制偏移量,和从节点的差值反映复制延迟
# 一次性查看关键指标
redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|mem_fragmentation_ratio:"
redis-cli INFO stats | grep -E "latest_fork_usec|expired_keys|evicted_keys"
redis-cli INFO clients | grep connected_clients

8.4 核心机制速查#

机制核心设计关键取舍
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 数据结构与持久化原理
https://blog.souloss.cn/posts/middleware/cache/redis-data-structure-and-persistence/
作者
Souloss
发布于
2024-07-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时