12816 字
35 分钟
blog随心博客
Redis原理的学习

Redis作为高效的NoSQL数据库,在LLM模型也很有用处,现在学习它的原理。

一、背景#

Redis(Remote Dictionary Server)是一个开源的、基于内存的键值存储系统,属于 NoSQL 数据库阵营。它诞生的初衷是解决高并发场景下的性能瓶颈——传统关系型数据库将数据存储在磁盘上,每次读写都涉及磁盘 I/O,而 Redis 将数据直接放在内存中,单机 QPS 可达 10 万级别。

Redis 的核心定位是缓存,但它远不止于此。它支持丰富的数据结构(字符串、列表、集合、有序集合、哈希等),提供持久化(RDB 快照 + AOF 日志)、主从复制、哨兵高可用、集群分片等企业级特性。在现代架构中,Redis 常用于缓存加速、分布式锁、消息队列、排行榜、实时计数器等场景。在 LLM 应用中,它也被广泛用作向量存储、会话缓存和 Prompt 模板的管理层。

Redis 之所以高效,除了内存存储外,还在于它对底层数据结构做了精心设计——本章将从这些基础组件开始,逐一拆解它"快"的秘密。

此次拆解的源码是 Redis 8.6.3 版本。

二、组件#

1. 动态字符串 SDS#

和 C 字符串的区别#

C 语言原生的字符串是以 \0 结尾的 char*,但这种方式存在几个缺陷:

  • 获取长度要 O(n):必须遍历整个字符串才能知道长度。
  • 容易缓冲区溢出:拼接字符串时,如果忘记分配足够内存,就会越界写入。
  • 内存分配频繁:每次修改字符串都要重新分配内存。
  • 二进制不安全:遇到 \0 就认为字符串结束,无法存储图片、序列化对象等二进制数据。

SDS(Simple Dynamic String)就是为解决这些问题而设计的。它本质上是在 C 字符串的基础上包了一层头部信息,用一个 sds 类型(即 char*,指向 buf 起始位置)对外暴露,对外依然兼容 C 字符串的用法。

SDS 结构体源码(带注解)#

// sds.h —— 注意 struct 使用了 __attribute__ ((__packed__)) 取消对齐填充
/* sdshdr5 实际从未使用,仅用于标记布局 */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 低 3 位存类型,高 5 位存长度(最多 31 字节) */
char buf[]; // 柔性数组,存放实际字符串内容
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 当前字符串长度(已用的字节数)
uint8_t alloc; // 已分配的总字节数(不含头、含'\0')
unsigned char flags; // 低 3 位存类型标识,高 5 位未使用
char buf[]; // 柔性数组,存放实际字符串内容
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; // 同上,但宽度为 16 位
uint16_t alloc;
unsigned char flags;
char buf[];
};
// 类似地还有 sdshdr32(32 位)和 sdshdr64(64 位),分别对应不同长度的字符串

flags 的低 3 位表示类型:SDS_TYPE_5(0)、SDS_TYPE_8(1)、SDS_TYPE_16(2)、SDS_TYPE_32(3)、SDS_TYPE_64(4)。Redis 根据字符串长度自动选择最紧凑的头类型。对外暴露的 sds 指针指向 buf 的首地址,而不是结构体开头,因此通过 s[-1] 就能直接读出 flags 字节,进而知道该用哪种结构体去解析。

实例:构建 "name"#

alt text

  • len = 4:当前字符串长度为 4
  • alloc = 4:总共分配了 4 字节可用空间
  • flags 的低 3 位 = SDS_TYPE_8
  • buf 末尾自动追加了 \0,兼容 C 库函数

实例:追加操作("hi" → "hi,Amy")#

假设我们有一个 sds s = "hi",现在要调用 sdscat(s, ",Amy") 追加 4 个字节。

// sds.c —— _sdsMakeRoomFor(),负责扩容的核心逻辑
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) {
size_t avail = sdsavail(s); // 当前剩余空间
size_t len = sdslen(s); // 当前已用长度
size_t newlen, reqlen;
if (avail >= addlen) return s; // 空间足够,直接返回
reqlen = newlen = len + addlen; // 至少需要的长度
if (greedy == 1) {
// 贪婪模式:多分配一些,避免下次再扩容
if (newlen < SDS_MAX_PREALLOC) // SDS_MAX_PREALLOC = 1MB
newlen *= 2; // 小于 1MB 时直接翻倍
else
newlen += SDS_MAX_PREALLOC; // 超过 1MB 后每次多给 1MB
}
// ... 然后根据 newlen 重新选择头类型,realloc 内存 ...
}

追加前 "hi" 的内存布局(假设 alloc = 2):

alt text

追加 ",Amy" 时发现 avail = 0,而 newlen = 2 + 4 = 6。由于 6 < 1MB,触发贪婪分配newlen = 6 * 2 = 12。于是实际分配的空间为 12 字节,alloc 变成 12。

追加后:

alt text

之后如果再追加少量字符(总长不超过 12),就不需要再次分配内存了——这正是内存预分配的意义。

SDS 的优势总结#

  • O(1) 获取长度:直接读 len 字段,无需遍历。
  • 杜绝缓冲区溢出:每次修改前都会检查 avail,不够就 sdsMakeRoomFor
  • 减少内存分配次数:预分配(小于 1MB 翻倍,大于 1MB+1MB)使得频繁追加时很少 realloc。
  • 二进制安全:不再以 \0 作为字符串结束标志,而是以 len 为准,可以存储任意二进制数据。
  • 惰性释放:缩短字符串时并不立即释放多余空间,而是保留在 alloc 中,方便后续再次增长。

2. IntSet#

IntSet 是 Redis 为「只包含整数的小型 Set」设计的底层存储结构。它的本质是一个基于 C 语言整数数组实现的有序、唯一、可变长集合。

结构体定义#

intset.h
typedef struct intset {
uint32_t encoding; // 编码方式:INTSET_ENC_INT16 / INT32 / INT64
uint32_t length; // 当前元素个数
int8_t contents[]; // 柔性数组,实际存储的元素(类型由 encoding 决定)
} intset;
  • encoding 取值:INTSET_ENC_INT16(2 字节)、INTSET_ENC_INT32(4 字节)、INTSET_ENC_INT64(8 字节)。
  • contents 虽然声明为 int8_t[],但实际存储的类型由 encoding 决定。所有元素在数组中保持升序排列且不重复
  • 查找使用二分查找,时间复杂度 O(logN)。插入和删除时需要 memmove 挪动元素,复杂度 O(N)。

编码自动升级#

假设当前 IntSet 中只有 {1, 2, 3} 三个小整数,encoding 为 INTSET_ENC_INT16(每个元素占 2 字节)。现在要插入 50000,这个数超过了 int16 的范围(-32768 ~ 32767),就需要触发编码升级。

升级流程的源码:

// intset.c —— intsetUpgradeAndAdd()
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value); // 确定新编码 → INTSET_ENC_INT32
int length = intrev32ifbe(is->length);
int prepend = value < 0 ? 1 : 0; // 负数插在数组头部,非负数插在尾部
// 1. 先更新 encoding,再 resize 数组(每个元素空间变大)
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is, intrev32ifbe(is->length) + 1);
// 2. 倒序遍历旧元素,逐个搬移到新位置(倒序避免覆盖)
while(length--)
_intsetSet(is, length + prepend,
_intsetGetEncoded(is, length, curenc));
// 3. 将新元素插入头部或尾部
if (prepend)
_intsetSet(is, 0, value);
else
_intsetSet(is, intrev32ifbe(is->length), value);
// 4. 更新 length
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}

以插入 50000(正数,prepend = 0)为例,图解如下:

插入前(INTSET_ENC_INT16):

alt text

升级后(INTSET_ENC_INT32):

alt text alt text

关键细节:如果插入的是负数(如 -5),由于负数小于所有非负数,它会被放在头部(prepend = 1),原有元素整体右移一格。升级是不可逆的——升级后即使删除了导致升级的那个元素,编码也不会降回去,这也能接受,因为一旦存过大值说明这个集合以后也"大概率"还会再存。

IntSet 的特点#

  • 唯一且有序:二分查找保证去重和查找为 O(logN)。
  • 编码升级机制:从 INT16→INT32→INT64 自动扩展,不浪费空间在小数据上。
  • 不适合大量数据:插入/删除需要移动元素(O(N)),当集合较大时性能下降明显,Redis 会将其转为 HashTable 编码。

3. Dict#

Dict(字典)是 Redis 最核心的数据结构之一,键值对的增删查改、哈希键的底层存储等,背后都是它。整个 Dict 由三个部分构成。

三部分结构#

dict.h
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
struct dictEntry *next; // 链表解决哈希冲突
} dictEntry;
struct dict {
dictType *type; // 类型特定函数(哈希函数、比较、析构等)
void *privdata; // 私有数据
dictEntry **ht_table[2]; // 两个哈希表,ht_table[0] 和 ht_table[1]
unsigned long ht_used[2]; // 每个哈希表里已有的元素数
long ht_size_exp[2]; // 每个哈希表的 size = 2^exp
int16_t rehashidx; // rehash 进度,-1 表示未在 rehash
int16_t pauserehash; // >0 时暂停 rehash
unsigned bucket_size[2]; // 每个 bucket 的实际大小(内存优化)
};
  • dictEntry:键值对节点,用单向链表解决哈希冲突。
  • dictEntry 指针数组 **ht_table[2]:这是真正的「哈希表」,可理解为 dictEntry* bucket数组
  • dict:字典主体,持有两个哈希表、两个 used 计数、rehash 游标等信息。

alt text

索引计算:hash & sizemask#

Redis 的哈希表大小始终是 2 的幂(通过 size = 2^exp 控制)。这样做的好处是可以通过位运算代替取模来定位 bucket:

bucket_index = hash(key) & sizemask

其中 sizemask = size - 1。例如 size = 4,则 sizemask = 3(二进制 011),对任意哈希值取低两位即可找到 bucket 位置。

Redis 使用 dictGenHashFunction()(基于 SipHash,内部用 MurmurHash2),将 key 映射为一个 uint64_t。

双哈希表#

Dict 维护了两个哈希表 ht_table[0]ht_table[1]。正常情况下只使用 ht_table[0]ht_table[1] 是空的。 当需要进行扩容或收缩时,ht_table[1] 被创建出来,用于渐进式 rehash——一边把 ht_table[0] 中的元素迁移到 ht_table[1],一边继续正常服务外部请求。

扩容与收缩的触发条件#

扩容条件dictExpandIfNeeded 中的逻辑):

  • 负载因子 ≥ 1 且没有执行 BGSAVE / BGREWRITEAOF 等后台进程时,Redis 认为现在扩容是安全的,会触发扩容。
  • 负载因子 > 5(即 dict_force_resize_ratio = 5),即使有后台进程也会强制扩容,因为哈希冲突已经太严重,性能下降不可接受。

负载因子 = ht_used / size,即每个 bucket 平均存放的元素个数。

收缩条件

  • 负载因子 < 0.1(即不到 10% 的 bucket 真正有数据),且当前 size 大于初始值 DICT_HT_INITIAL_SIZE(通常为 4),就会触发收缩。

扩容时 size 翻倍,收缩时 size 减半,始终保证 size 是 2 的幂。

Rehash 过程(渐进式)#

无论扩容还是收缩,都需要创建新的哈希表,并将旧表中所有 key 重新计算 bucket 索引后插入新表——这个过程叫 rehash

如果一次性完成,对于一个十几万 key 的字典会造成明显的卡顿。所以 Redis 使用渐进式 rehash(Incremental Rehashing),把迁移任务分摊到多次操作中:

  1. dictExpand 中为 ht_table[1] 分配新数组,并设置 rehashidx = 0,表示 rehash 开始。
  2. 每次对字典执行增删查改操作时,除了完成本次操作,还会顺带把 ht_table[0] 中位于 rehashidx 的那条 bucket 链整条迁移ht_table[1],然后 rehashidx++
  3. 新增操作直接写入 ht_table[1],保证 ht_table[0] 的元素只会减少不会增加。
  4. rehashidx 等于 ht_table[0] 的 size 时,表示迁移完成。释放 ht_table[0],将 ht_table[1] 提升为 ht_table[0],重置 rehashidx = -1
  5. 对于长时间没有请求的字典,每个事件循环也会在 databasesCron 中执行 1ms 的 dictRehash,每次处理 100 个 bucket,逐步消化。

alt text alt text alt text

4. ZipList#

ZipList(压缩列表)是 Redis 为了极致节省内存而设计的一种特殊双向链表。它把所有元素压缩到一整块连续内存中,省去了传统链表每个节点所需的 prev / next 指针开销。

整体结构#

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
字段大小说明
zlbytesuint32_t整个 ziplist 占用的总字节数(含自身 4 字节)
zltailuint32_t最后一个 entry 相对起始位置的偏移量,用于 O(1) 尾部操作
zllenuint16_tentry 数量,超过 65535 时设为 65535,需遍历获取真实数量
entry可变每个元素节点,详见下文
zlenduint8_t固定值 255(0xFF),标识 ziplist 结束

依托 zltail,在尾部进行压入/弹出是 O(1);依托每个 entry 的 previous_entry_length,反向遍历也是 O(1)。但中间插入仍然是 O(N),因为需要移动后续所有元素。

Entry 结构#

每个 entry 由三部分组成:

<prevlen> <encoding> <entry-data>
  • prevlen(previous_entry_length):前一个 entry 的长度。如果前一个 entry 长度 < 254 字节,prevlen1 字节直接记录;如果长度 ≥ 254 字节,prevlen5 字节(首字节固定为 0xFE,后 4 字节存实际长度)。这个字段是反向遍历的基础。

  • encoding:编码字段,同时描述了数据类型和长度。Redis 通过首字节的高 2 位来判断类型:

    • 000110 开头 → 字符串编码:后续位存储字符串长度,长度分三档(≤63 / ≤16383 / ≥16384 字节),编码占 1/2/5 字节,之后紧跟 entry-data 存实际字符串内容。
    • 11 开头 → 整数编码:后 2 位区分具体整数类型(int16/int32/int64/24bit/8bit),编码占 3/5/9/4/2 字节,之后紧跟 entry-data 存整数值。

    一个特殊情形是 |1111xxxx|(xxxx 在 00011101 之间)——4 位立即数编码。它直接把数值 012 压缩在 encoding 字节的低 4 位中,值本身就成了编码的一部分,自然不需要额外的 entry-data。同理,更大的整数类型(如 int16)的数值仍然需要单独的 entry-data 来存放,因为 encoding 字节里只描述了「这是什么类型的整数」,放不下实际数值。

  • entry-data:实际数据内容,在某些整数编码下可能不存在。

存储示例#

存储 "ab""bc" 两个字符串(假设前一个 entry 长度均小于 254):

alt text

存储数字 25(小整数编码,无需 entry-data):

alt text

连锁更新问题#

考虑这样一种情况:ziplist 中有多个长度恰好为 253 字节 的 entry,它们的 prevlen 都只占 1 字节。现在在头部插入一个 254 字节以上 的 entry,导致紧邻它的 entry1 的 prevlen 必须从 1 字节膨胀到 5 字节(多出 4 字节),而 entry1 膨胀后又可能让 entry2 的 prevlen 跟着膨胀……这种级联效应就是连锁更新

连锁更新在最坏情况下需要连续多次内存 realloc,性能下降明显。但这在现实中极难触发——需要大量恰好在 253 字节临界点附近的连续 entry。因此 Redis 并没有在代码层面做特殊防护,只是意识到这个问题存在。

设计取舍:为什么 ZipList 坚持用 prevlen,直到 7.0 才被 Listpack 替代?

prevlen 不是设计缺陷,而是一个明知故留的权衡。prevlen 放在 entry 头部,反向遍历时只需读当前 entry 的第一个字节就能知道前一个 entry 的长度,代码路径极短。Listpack 的 backlen 则需要往回读 1~5 字节(解析 continuation bits),稍复杂一些。antirez 认为这个简洁性值得用连锁更新的理论风险来换——毕竟 Redis 的绝大多数 value 只有几十字节,连续多个"恰好卡在 253"的极端情况几乎不会发生。

那为什么 7.0 最终还是改了?不是因为连锁更新在生产环境爆炸了,而是双通道复制等新特性需要一个对内存块边界更友好的格式,趁着重构顺带清掉了这笔历史债。本质上是「用稍复杂一点的反向遍历,换零连锁更新的完全保证」,在长期维护中后者胜出。

5. QuickList#

QuickList 是 Redis 3.2 之后 List 类型的底层实现,它在「内存紧凑」和「操作效率」之间找到了平衡。

基本结构#

alt text

简单来说:QuickList 是一个双向链表,每个节点是一个 ZipList(新版使用 Listpack)。外层链表用于快速定位到某个节点,内层的 ziplist/listpack 以紧凑的内存存储实际数据。

关键配置项#

  • list-max-listpack-size(旧称 list-max-ziplist-size):控制每个内部节点最大占用字节数。默认 -2(8KB)。可设为正数(精确字节限制)或负数(不同优化级别,如 -1 = 4KB,-2 = 8KB,-3 = 16KB,-4 = 32KB,-5 = 64KB)。
  • list-compress-depth:控制 QuickList 的压缩深度。由于链表两端的节点访问频率最高,中间节点可以被 LZF 压缩以节省内存。设为 0 表示不压缩;设为 1 表示首尾各 1 个节点不压缩、中间全压缩;设为 2 表示首尾各 2 个不压缩,以此类推。
list-compress-depth = 1:
[node0 不压缩] → [node1 压缩] → [node2 压缩] → ... → [nodeN 不压缩]
头 尾

6. SkipList#

SkipList(跳表)是 Redis 中 ZSet(有序集合)的底层实现之一(另一个是 Listpack,用于数据量较小时)。它的本质是一个在有序链表上加了多层索引的数据结构

与普通链表的区别#

  • 元素按 score 升序排列,score 相同时按 ele(成员字符串)字典序排列。
  • 每个节点可能包含多个层级指针(称为 level),每个 level 的跨度(span)不同,高层指针用来"跳过多余元素"以加速查找。
  • 层数随机生成(最高 32 层),跳表的期望查询复杂度为 O(logN)。

结构体定义#

server.h
typedef struct zskiplistNode {
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;
typedef struct zset {
dict *dict; // 字典:key→score,用于 O(1) 按成员查分值
zskiplist *zsl; // 跳表:按 score 排序,用于范围查询
} zset;

ZSet 同时使用 dict 和 skiplist:dict 提供 O(1) 的成员分值查询,skiplist 提供 O(logN) 的范围查询和排序能力。两者存的是同一份数据(指针引用),内存不会翻倍。

查询流程示意#

alt text

  • 查找时从最高层开始,如果前进指针指向的节点的 score 小于目标值,就沿着该层前进;如果大于目标值,就下降一层继续查找。
  • span 记录了两个节点之间在 level 0 上跳过了多少个元素,用于计算排位(rank)。

与红黑树的对比#

跳表和红黑树的增删查改复杂度都是 O(logN),但跳表的实现更简单:不需要复杂的旋转和变色逻辑,没有红黑树的多种情况分支。此外,跳表天然支持范围查询(直接沿 level 0 遍历),而且可以方便地获取元素的排位(通过 span 累加)。这些特性恰好契合 ZSet 的需求。

7. Listpack#

Listpack 是 ZipList 的继任者,Redis 7.0 起在所有场景中替代了 ZipList。它的定位完全一致——用一块连续内存紧凑存储多个元素,支持双向遍历、两端 O(1) 压入/弹出。区别在于它通过改变 entry 结构,从根本上消除了 ZipList 的连锁更新问题。

整体结构#

<total_bytes> <num_elements> <entry> <entry> ... <entry> <0xFF>
字段大小说明
total_bytesuint32_tlistpack 占用的总字节数(含自身 4 字节)
num_elementsuint16_tentry 数量,超过 65535 时取值为 65535,需遍历获取真实值
entry可变每个元素节点
0xFFuint8_t结束标记

和 ZipList 的关键差异是:Listpack 不再存 zltail,尾部定位改为从最后一条 entry 的 backlen 反向推算,少了一个 4 字节的全局字段。

Entry 结构 —— 化解连锁更新的核心#

<encoding> <data> <backlen>
  • encoding:编码字节。高 2 位区分类型:10 开头为短字符串(6 位长度,≤63 字节);1110 开头为中字符串(12 位长度,≤4095 字节);0 开头为小整数(7 位无符号,0~127);110 开头为 13 位整数;随后还有 16/24/32/64 位整数编码。与 ZipList 的 encoding 逻辑类似,但编码号不同。
  • data:实际数据内容。对于小整数(7 位编码),值直接嵌在 encoding 中,此部分不存在。
  • backlen当前 entry 的总长度(encoding + data + backlen 本身)。占 1~5 字节,通过每个字节的最高位(continuation bit)表示是否读下一字节。这是替代 ZipList prevlen 的关键设计。

为什么 Listpack 没有连锁更新#

ZipList 的连锁更新根源在于 prevlen:entry1 膨胀导致 entry2 的 prevlen 也必须膨胀,级联扩散。Listpack 把视角翻了过来——每个 entry 记录的是自己的长度(backlen),而不是前一个 entry 的长度。

反向遍历时,从某个 entry 的起始位置往前回退一个 backlen 就是前一个 entry 的起点。一个 entry 的 backlen 只取决于自身的总大小,与前后 entry 无关,因此任何 entry 的修改都不会触发邻居的连锁连带更新。

从 entry3 反向遍历到 entry2:
entry2_start = entry3_start - entry2_backlen
从 entry2 反向遍历到 entry1:
entry1_start = entry2_start - entry1_backlen

与 ZipList 的对比#

维度ZipListListpack
连续内存
反向遍历prevlen(记录前一个 entry 长度)backlen(记录当前 entry 长度)
连锁更新存在(prevlen 级联膨胀)不存在
头部字段zlbytes / zltail / zllen(10 字节)total_bytes / num_elements(6 字节)
首次引入远古版本Redis 7.0,7.2 起全面替代

本文后续提到"小对象优化用 ZipList 做紧凑存储"的地方,在 Redis 7.0+ 中实际运行时都是 Listpack,但编码思想和应用场景完全一致。


小结:七种组件分工#

组件角色
SDS动态字符串,承载所有文本/二进制值的存储
IntSet小整数集合,有序、二分查找
Dict哈希表,O(1) 增删查改,支持渐进式 rehash
ZipList / Listpack紧凑连续内存块,小数据量下的省内存方案
QuickList双向链表 + Listpack 节点,List 的默认实现
SkipList多层有序跳表,O(logN) 范围查询和排名

这七种组件是 Redis 的全部"积木"。下一章会看到它们如何被组装成对外的五种数据结构。

三、RedisObject —— 类型系统的桥接层#

第二章讲的是七种存储实现,第四章要讲五种对外数据类型。那么问题来了:Redis 怎么知道一个 String 该用 INT、EMBSTR 还是 RAW?一个 Set 什么时候用 IntSet、什么时候用 Dict?

答案就是 RedisObject。它不是存储实现,而是类型分发器——用两个 4 位的字段 typeencoding,把「数据类型」映射到「底层实现」。

结构体定义#

object.h
struct redisObject {
unsigned type:4; // 4 位:数据类型(OBJ_STRING/OBJ_LIST/...)
unsigned encoding:4; // 4 位:底层编码方式(七种组件选其一)
unsigned refcount : OBJ_REFCOUNT_BITS; // 引用计数(23 位)
unsigned iskvobj : 1; // 是否为 kvobj(键值一体对象)
unsigned metabits : 8; // 附加元数据位图(仅在 iskvobj=1 时有效)
unsigned lru : LRU_BITS; // 24 位:LRU 时钟或 LFU 计数器
void *ptr; // 8 字节:指向实际数据的指针
};

总计 16 字节。type 决定「是什么」,encoding 决定「怎么存」,ptr 指向真正的存储实现。

编码方式一览#

Redis 的编码常量定义在 object.h 中:

编号常量对应组件目前状态
0OBJ_ENCODING_RAWSDS使用中
1OBJ_ENCODING_INT无(ptr 直接存值)使用中
2OBJ_ENCODING_HTDict使用中
3OBJ_ENCODING_ZIPMAP已废弃
4OBJ_ENCODING_LINKEDLIST已废弃
5OBJ_ENCODING_ZIPLISTZipList已废弃
6OBJ_ENCODING_INTSETIntSet使用中
7OBJ_ENCODING_SKIPLISTDict + SkipList使用中
8OBJ_ENCODING_EMBSTRSDS(嵌入式)使用中
9OBJ_ENCODING_QUICKLISTQuickList使用中
10OBJ_ENCODING_STREAMRadix Tree + Listpack使用中
11OBJ_ENCODING_LISTPACKListpack使用中
12OBJ_ENCODING_LISTPACK_EXListpack(扩展)使用中

lru 字段 —— 内存淘汰的近似 LRU#

redisObject 中的 lru(24 位)字段看起来和类型分发无关,它的职责是内存淘汰——当 Redis 使用内存超过 maxmemory 上限时,决定哪些 key 优先被清理。

和常见八股题(HashMap + 双向链表实现精确 LRU)不同,Redis 用的是近似 LRU。如果给几百万个 key 维护一个全局双向链表,每次访问都要加锁移动节点,内存和 CPU 开销根本扛不住。

实现代码在 evict.c 中,思路很简单:

1. 记录时间戳而非链表指针。 lru 存的是一个 24 位的秒级时钟值(mstime() / 1000,约 194 天转一圈)。每次访问一个 key 时,lookupKey() 顺手把 lru 更新为当前时钟——只是一个赋值,O(1)。

2. 淘汰时采样,不全局排序。 需要释放内存时,随机取 N 个 key(maxmemory-samples,默认 5),计算它们的空闲时间(当前时钟 - key.lru),把最久没访问的那些塞进一个大小为 16 的候选池(evictionPool),从中挑最老的那个淘汰。没释放够就继续采样、继续淘汰。

换句话说,它淘汰的不是「全 Redis 最久未访问的 key」,而是「几次随机采样中看起来最旧的 key」。牺牲一点精确度,换来零额外内存和无需全局锁。

淘汰策略由 maxmemory-policy 配置,共 10 种:noeviction(不淘汰,写操作直接报错)、volatile-lru / allkeys-lru(近似 LRU,按范围看是否只看有过期时间的 key)、volatile-lfu / allkeys-lfu(近似 LFU,lru 字段改为存访问频率计数器)、volatile-ttl(最接近过期的先淘汰)、volatile-random / allkeys-random(随机淘汰)、以及 volatile-lrm / allkeys-lrm(LRU 采样后二次筛选的变体)。

LFU 模式下 lru 字段的语义变了:高 16 位存上次衰减时间,低 8 位存对数计数器的访问频率。核心仍然是「用一个 int 字段替代全局链表」。

分发逻辑#

高层数据类型(type 字段) 底层实现(encoding 字段)
───────────────────────── ───────────────────────────
String ────┬──────────────→ INT / EMBSTR / RAW
List ────┼──────────────→ QUICKLIST
Set ────┼──────────────→ INTSET ──→ HT
ZSet ────┼──────────────→ LISTPACK ──→ SKIPLIST
Hash ────┼──────────────→ LISTPACK ──→ HT
Stream ────┘──────────────→ STREAM
判断逻辑由 RedisObject 的 encoding 字段驱动,
每种数据结构的 *.c 文件中各自维护了切换阈值。

四、五种数据结构#

前两章分别拆解了存储组件和类型系统,这一章看最上层——五种对用户暴露的数据结构。它们不是独立的数据结构,而是底层组件经过 RedisObject 的 encoding 分发后,呈现给用户的"最终形态"。理解每种数据结构在什么条件下选用哪种底层编码,是读懂 Redis 性能模型的关键。

1. String#

String 是 Redis 中最基础的数据类型,一个键对应一个字符串值,值可以是普通文本、整数、二进制数据,最大 512MB。

三种内部编码#

String 并非总是用 SDS 来存储。根据值的内容和长度,Redis 会选择三种编码之一:

编码常量触发条件内部存储方式
INTOBJ_ENCODING_INT (1)值可以解析为整数,且范围在 LONG_MIN ~ LONG_MAX指针直接存数值,不额外分配内存
EMBSTROBJ_ENCODING_EMBSTR (8)字符串长度 ≤ 44 字节RedisObject 和 SDS 在同一块连续内存中,一次分配
RAWOBJ_ENCODING_RAW (0)字符串长度 > 44 字节RedisObject 和 SDS 分别两次分配内存

编码选择流程(对应源码)#

// object.c —— createStringObject()
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
if (len <= 44) return createEmbeddedStringObject(ptr, len); // → EMBSTR
else return createRawStringObject(ptr, len); // → RAW
}
// object.c —— createStringObjectFromLongLongWithOptions()
robj *createStringObjectFromLongLongWithOptions(long long value, ...) {
// 小整数(0~9999)优先使用共享对象,避免重复分配
if (value >= 0 && value < 10000)
return shared.integers[value];
// 范围内整数直接存 ptr 中
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void*)((long)value); // ptr 不指向内存,直接存数值
}

EMBSTR 的 44 字节限制是精心计算的:redisObject 占 16 字节,sdshdr8 头占 3 字节,加上 44 字节数据和一个 \0 共 64 字节,正好对齐 jemalloc 的 64 字节 arena,一次 malloc 即可,分配和释放效率极高。

编码的内存布局对比#

alt text

编码转换#

  • INT → RAW:对 INT 编码的对象执行 append 等字符串操作时,会自动转为 RAW。因为 INT 编码下 ptr 存的是数字而非 SDS 指针,无法进行字符串拼接。
  • EMBSTR → RAW:EMBSTR 分配的内存是只读连续的,任何修改操作(如 append)都会触发重新分配,转为 RAW 编码。这是 EMBSTR 的唯一代价——它只适用于不变的短字符串。
  • INT 优化:当对 String 执行 incrdecr 等数值操作时,Redis 会尝试将 RAW/EMBSTR 转为 INT。tryObjectEncoding() 函数负责这一优化。

2. List#

List 是一个有序的字符串列表,支持从两端压入/弹出,典型的使用场景是消息队列、最新动态列表等。

编码演进#

Redis 3.2 是一个分水岭:

版本编码方式
3.2 之前小数据用 ZipList(连续内存块),大数据用 LinkedList(真正的双向链表)
3.2 及之后统一切换为 QuickList(双向链表 + 每个节点的 Listpack/ZipList)

旧方案中 ZipList 省内存但中间插入慢,LinkedList 插入快但每个节点都有 prev/next 指针开销,内存碎片严重。QuickList 折中了二者——外层链表控制粒度,内层 listpack 保持内存紧凑。目前 List 只使用 QuickList 这一种编码,不再需要在小数据量和大数据量之间切换。

QuickList 的配置回顾#

list-max-listpack-size -2 # 每个内部节点最大 8KB(负数按 4K/8K/16K/32K/64K 分级)
list-compress-depth 0 # 中间节点 LZF 压缩深度,0=不压缩

这两个参数在第二章第 5 节已有详细说明,这里不再展开。

典型操作#

LPUSH mylist "world" → ["world"]
LPUSH mylist "hello" → ["hello", "world"]
RPUSH mylist "!" → ["hello", "world", "!"]
LRANGE mylist 0 -1 → ["hello", "world", "!"]
LPOP mylist → "hello"

底层对 QuickList 头尾节点的 push/pop 都是 O(1),而 LRANGE 等范围查询需要遍历节点内的 listpack 元素。

alt text

3. Set#

Set 是一个无序、唯一的字符串集合,支持交集、并集、差集等集合运算,常用于标签系统、共同好友、随机抽奖等场景。

编码方式#

Set 有两种底层编码,会根据数据特征自动选择:

1. INTSET(OBJ_ENCODING_INTSET)

当集合同时满足两个条件时使用:

  • 所有元素都是整数(或能解析为整数)
  • 元素数量 ≤ set-max-intset-entries(默认 512)

此时 Set 就是一个 IntSet(见第二章第 2 节),元素有序存储、二分查找去重,非常省内存。

2. HT(OBJ_ENCODING_HT)

一旦不满足上述条件(比如加入了非整数元素,或数量超阈值),Set 会转为 HashTable 编码。这里直接复用了 Dict 结构,具体做法是:

  • Dict 的 key 存集合元素
  • Dict 的 value 统一设为 NULL

也就是说,Set 本质上是一个「只有键没有值」的哈希表。这样做的好处是代码复用度极高——增删查改直接走 Dict 的 dictAdd/dictDelete/dictFind,不需要专门为 Set 写一套哈希表。

编码转换触发#

// t_set.c —— intset 元素数量超限时转换
static void maybeConvertIntset(robj *subject) {
if (intsetLen(subject->ptr) > intsetMaxEntries()) // 默认 > 512
setTypeConvert(subject, OBJ_ENCODING_HT); // 转为哈希表
}
// t_set.c —— 新元素不是整数时直接转 HT
if (!isSdsRepresentableAsLongLong(value, NULL)) {
setTypeConvertAndExpand(set, OBJ_ENCODING_HT, ...);
}

注意:INTSET → HT 是单向的,转过去后即使把所有非整数元素删掉,也不会降级回 INTSET。这是因为「曾有过非整数,以后大概率还会有」。

内存布局示意#

alt text

4. ZSet#

ZSet(Sorted Set,有序集合)在 Set 的基础上为每个元素绑定了一个 score(分值),元素按 score 升序排列,score 相同时按元素字符串字典序排列。典型应用是排行榜、延迟队列、带权重的标签。

双结构混合编码(SKIPLIST)#

当数据量较大时,ZSet 使用 两种结构配合

typedef struct zset {
dict *dict; // 字典:element → score,O(1) 按元素查分值
zskiplist *zsl; // 跳表:按 score 排序,O(logN) 范围查询 + 排名
} zset;
操作用哪个结构复杂度
查某个元素的 scoredictO(1)
按 score 范围查元素(ZRANGEBYSCORE)skiplistO(logN + M)
查某个元素的排名(ZRANK)skiplist(累加 span)O(logN)
添加/更新元素dict + skiplist 同步操作O(logN)

为什么不用单一结构? 仅用 dict 拿不到排序和排名,仅用 skiplist 按元素查分值要从最高层一路找(O(logN)),不如 O(1) 的 dict 快。两者存同一份数据的指针引用,不会造成内存翻倍——dict 的 key 和 skiplist 的 ele 指向同一个 SDS 对象。

小对象优化:LISTPACK 编码#

当数据量较小时,跳表的层指针和 dict 的 bucket 数组的开销就显得不划算。此时 Redis 切换为紧凑的 LISTPACK(旧版叫 ZipList)编码:

触发条件(两个都满足才使用 LISTPACK):
1. 元素数量 ≤ zset-max-listpack-entries(默认 128)
2. 每个元素的字符串长度 ≤ zset-max-listpack-value(默认 64 字节)

Listpack 本身没有排序和键值对概念,Redis 通过编码约定来解决:

Listpack 中 ZSet 元素的排列方式:
[ele1, score1, ele2, score2, ele3, score3, ...]
即相邻两个 entry 构成一对 (element, score),按 score 从小到大排列。

当插入新元素时,Redis 会遍历 listpack 找到正确的插入位置(保持 score 有序),然后用 memmove 挪出空间。这也是为什么 listpack/zset 只在数据量小时使用——数据量大后 memmove 的开销不可接受。

有序是谁的责任? 一个容易混淆的点:SkipList 的有序是数据结构自带的——节点按 score 链式排列,插入时走高层指针定位,O(logN)。Listpack 本身只是一个紧凑数组,没有排序能力——它的有序是 t_zset.c 的插入代码「逐对遍历、比大小、找位置、memmove」强行维系的。两者都服务于 ZSet 的排序需求,只是实现路径不同:前者靠结构,后者靠操作。

编码切换#

LISTPACK → SKIPLIST:
当插入后元素数量 > 128 或新 ele 长度 > 64 字节时触发升级
SKIPLIST → LISTPACK:
当删除后剩余元素 ≤ 128 且最大 ele ≤ 64 字节时,会降级回 LISTPACK
(与 Set 的 INTSET 不同,这里是双向的,通过 zsetConvertToListpackIfNeeded 实现)

结构示意图#

alt text

5. Hash#

Hash 是一个字段-值的映射(field → value),适合存储对象(如用户信息、商品属性)。它与 ZSet 极其相似,本质上是「去掉了排序需求的 ZSet」。

与 ZSet 的相似与区别#

维度ZSetHash
元素结构(element, score)(field, value)
排序按 score 排序无序
查单个O(1) 或 O(logN)O(1)
大对象编码SKIPLIST(dict + skiplist)HT(仅 dict)
小对象编码LISTPACKLISTPACK
小对象内部排列[ele, score, ele, score, ...][field, value, field, value, ...]

关键差异一句话:Hash 不需要排序,所以把 ZSet 大对象编码中的 skiplist 去掉,只保留 dict,就是 Hash 的大对象编码。

编码方式#

1. LISTPACK(小数据)

触发条件(两个都满足):

  • 元素数量 ≤ hash-max-listpack-entries(默认 512)
  • 每个 field 和 value 的长度 ≤ hash-max-listpack-value(默认 64 字节)

Listpack 中相邻两个 entry 为一对 [field, value]

Listpack 中 Hash 元素的排列:
[field1, value1, field2, value2, field3, value3, ...]

查找时遍历 listpack,逐个匹配 field,找到后返回紧随其后的 value。因为没有排序需求,插入时直接追加到末尾(或覆盖已有 field 的 value),不需要像 ZSet 那样查找排序位置。

2. HT(大数据)

一旦超出小对象阈值,Hash 转为纯粹的 Dict 编码(OBJ_ENCODING_HT)。Dict 的 key 存 field,value 存实际值。这是 Hash 最通用的形态——O(1) 查找、O(1) 平均插入。

编码转换#

Hash 的编码切换逻辑与 ZSet 基本一致,摘出核心代码(t_hash.c):

// t_hash.c —— hashTypeSet()
if (sdslen(field) > server.hash_max_listpack_value ||
sdslen(value) > server.hash_max_listpack_value)
hashTypeConvert(db, o, OBJ_ENCODING_HT); // 单条数据过大 → 转 HT
if (hashTypeLength(o, 0) > server.hash_max_listpack_entries)
hashTypeConvert(db, o, OBJ_ENCODING_HT); // 元素总数超限 → 转 HT

与 ZSet 不同的是,Hash 从 HT 回退到 LISTPACK 需要显式触发(如 HSCAN 扫描检测),不会像 ZSet 那样在每次删除时主动检查降级。

五种数据结构总结#

String ── INT / EMBSTR / RAW
List ── QUICKLIST
Set ── INTSET ──────────→ HT (dict, value=NULL)
ZSet ── LISTPACK ────────→ SKIPLIST (dict + skiplist)
Hash ── LISTPACK ────────→ HT (dict, field→value)
← 小数据,内存紧凑 → ← 大数据,查询高效 →

这五种数据结构的设计思想一以贯之:「小对象用连续内存(INT/EMBSTR/LISTPACK/INTSET),大对象用索引结构(RAW/HT/SKIPLIST)」。Redis 用极少的代码量实现了一套自适应的存储引擎,这也是它能在各种场景下同时兼顾内存和性能的原因。

一个容易混淆的词:「有序」

Redis 的 List 和 ZSet 都被描述为"有序",但含义完全不同:

  • List 的「有序」= 按插入顺序。你从左边推就在左边,右边推就在右边,listpack 中 [ele1, ele2, ele3] 就是先后插入的次序。两端 push/pop 直接操作头尾,不需要比较内容。
  • ZSet 的「有序」= 按 score 数值排序。每个元素带一个 score(double 类型),元素之间比大小,按升序排列。插入时要找到正确的 score 位置塞进去。

前者是"先后有序",后者是"大小有序"。解决的是两类完全不同的问题。

五、Redis网络模型#

1. Linux五种阻塞模型#

(1) 阻塞IO#

用户进程调用 recvfrom 后,内核等待数据到达,数据到达后内核将数据从内核空间拷贝到用户空间,整个过程用户线程一直处于阻塞状态(让出 CPU,被内核挂起),两个阶段全部阻塞。

alt text

(2) 非阻塞IO#

用户进程反复调用 recvfrom,如果没有数据,内核立即返回错误码(EAGAIN),不会挂起线程。反复轮询虽然让第一阶段不再阻塞,但如果写成忙等会严重浪费 CPU。数据到达后,第二阶段拷贝数据时线程依然是阻塞的。忙等空转就是它不如 IO 多路复用的根本原因。

alt text

(3) IO多路复用#

FD(File Descriptor,文件描述符)是 Linux 中一切 I/O 操作的句柄——每个 socket 连接对应一个 fd。IO 多路复用的核心思想是:单个线程同时监听多个 fd,哪个先就绪就先处理哪个,避免对每个 fd 的无意义等待。 线程阻塞在 select/poll/epoll 上,一旦有 fd 可读或可写,内核就唤醒线程,线程再去对就绪的 fd 执行读写。

alt text

监听 fd 的方式经历了三代演进:

select:将用户态的 fd_set(bitmap,默认上限 1024)拷贝进内核,内核遍历全部 fd 检查就绪状态,再拷回用户态,用户再遍历一遍才能找到就绪的 fd。两次拷贝 + 两次 O(N) 遍历,连接数一多性能急剧下降。

select模式

poll 对 select 做了最直接的改进:用 struct pollfd 数组替代固定大小的 bitmap,破除了 1024 限制。但内核和用户态依然要做 O(N) 遍历。

epoll 在 Linux 2.6 引入,三个函数分工:

  • epoll_create:创建 epoll 实例,内核分配 eventpoll 对象
  • epoll_ctl(ADD/MOD/DEL):将 fd 注册进内核的红黑树,O(logN)
  • epoll_wait:阻塞等待,直接从就绪链表上取就绪的 fd,O(1),只返回有事件的 fd,无需遍历全量

epoll模式

epoll 的 LT 和 ET 模式

  • LT(Level Triggered,水平触发,默认):只要 fd 缓冲区还有数据,下次 epoll_wait 仍会通知,编程简单不易丢事件。
  • ET(Edge Triggered,边缘触发):只在 fd 状态从"无数据"变"有数据"的瞬间通知一次,必须配合非阻塞 IO 一次性读完(循环 read 直到返回 EAGAIN),否则剩余数据可能永远丢失。优点是减少重复通知,高并发下性能更好。

基于 epoll 模式的 web 服务的基本流程

基于epoll模式的web服务的基本流程

socket() → bind() → listen() // 创建监听 socket
epoll_create() // 创建 epoll 实例
epoll_ctl(ADD, listen_fd) // 注册监听 fd
while (1) {
n = epoll_wait(); // 阻塞等待事件
for (i = 0; i < n; i++) {
if (fd == listen_fd) {
conn_fd = accept(); // 新连接
set_nonblocking(conn_fd); // 设为非阻塞
epoll_ctl(ADD, conn_fd); // 注册新 fd
} else {
read(fd); // 非阻塞读
process();
write(fd); // 写回响应
}
}
}

(4) 信号驱动IO#

预先注册 SIGIO 信号处理函数后立即返回,进程不阻塞。数据就绪时内核发送 SIGIO 信号,进程在信号处理函数中调用 recvfrom 完成拷贝(第二阶段仍阻塞)。编程复杂度高,多连接下信号排队和竞态难以处理,Redis 未采用。

alt text

(5) 异步IO#

调用 aio_read 后立即返回,两个阶段均由内核完成——数据直接拷贝到用户指定的 buffer,完成后内核通知用户。全程不阻塞。但 Linux 原生 AIO 对 socket 支持有限(主要面向文件 IO),实际应用极少。

alt text

2. Redis网络模型#

单线程还是多线程?#

分两个层面回答:

  • 核心命令处理:始终单线程。从 readQueryFromClient 读命令 → processCommand 解析执行 → addReply 写回复,整条链路在主线程的事件循环中串行完成。这意味着不存在两个命令同时修改同一个 key 的竞争问题,所有数据结构无需加锁。
  • 整个 Redis 进程:6.0 起引入了 IO 线程(io-threads 配置项),专门把网络读写的 CPU 密集部分分摊到多个线程,但命令执行永远在主线程。

为什么选择单线程?#

  1. CPU 不是瓶颈。Redis 纯内存操作,单个命令执行时间通常是微秒级。瓶颈在内存带宽和网络 I/O,不是 CPU。
  2. 避免锁开销。如果多线程并发执行命令,Dict、SkipList 等核心结构都需要加锁,锁竞争的开销可能超过并行收益——毕竟每个命令本身就几微秒,加锁解锁可能也几微秒。
  3. 代码简单可靠。单线程意味着没有竞态条件,没有死锁。早期可能只是 antirez 的个人偏好,但事实上这个选择让 Redis 的核心代码保持了极高的可维护性。

事件循环的核心源码#

Redis 的网络模型源码分三层:ae 抽象层 → epoll 实现层 → 上层网络处理。

// ae.c —— 主循环
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS |
AE_CALL_BEFORE_SLEEP |
AE_CALL_AFTER_SLEEP);
}
}
// ae.c —— 单次循环
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
// 1. beforeSleep: 过期键清理、AOF刷盘、回复写入、IO线程任务分发
if (eventLoop->beforesleep && (flags & AE_CALL_BEFORE_SLEEP))
eventLoop->beforesleep(eventLoop);
// 2. 阻塞等待 fd 就绪(Linux 下 → epoll_wait)
numevents = aeApiPoll(eventLoop, tvp);
// 3. afterSleep
if (eventLoop->aftersleep && flags & AE_CALL_AFTER_SLEEP)
eventLoop->aftersleep(eventLoop);
// 4. 逐个处理就绪 fd 的读写回调(→ readQueryFromClient / sendReplyToClient)
for (j = 0; j < numevents; j++) {
if (mask & AE_READABLE) fe->rfileProc(eventLoop, fd, ...);
if (mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, ...);
}
// 5. 处理到期的定时事件(serverCron 等)
processTimeEvents(eventLoop);
}

一次事件循环的完整时序:

┌──────────────────────────────────────────────────────────┐
│ aeProcessEvents 单次迭代 │
│ │
│ beforeSleep() epoll_wait() 处理就绪fd+定时器 │
│ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ 过期键清理 │ │ 阻塞等待 │ │ 读客户端命令 │ │
│ │ AOF 刷盘 │──→│ fd就绪或 │───→│ 执行命令 │ │
│ │ 回复写入 │ │ 超时 │ │ 写回复 │ │
│ │ IO线程分发 │ │ │ │ serverCron │ │
│ └──────────────┘ └──────────┘ └──────────────┘ │
│ 不阻塞 阻塞 不阻塞 │
└──────────────────────────────────────────────────────────┘

Redis 启动完毕后在 main() 最后一行执行 aeMain(server.el),进入上述死循环直到进程退出。

一条请求的完整生命周期#

1. client 连接 → TCP 三次握手
2. epoll_wait 检测到 listen_fd 可读
3. acceptTcpHandler → accept() → createClient()
- 将 conn_fd 设为 O_NONBLOCK
- 注册 AE_READABLE 回调 → readQueryFromClient
4. 客户端发送 "GET mykey\r\n"
5. epoll_wait 检测到 conn_fd 可读
6. readQueryFromClient():
- read(fd, buf, 16KB) ← 非阻塞读
- 解析 RESP 协议
7. processCommand():
- lookupKey() 查 kvstore 找 redisObject
- 检查过期、权限
- call() 执行命令处理函数
8. addReply() → 回复数据写入 client 的 reply buffer
9. beforeSleep() 中 handleClientsWithPendingWrites():
- writeToClient() 把 reply buffer 数据写回 socket
- 如果 socket 缓冲区满没写完,注册 AE_WRITABLE 回调等下次继续写

Redis 6.0+ 的多线程网络模型#

IO 线程只做网络读写和协议解析,不执行命令。实现代码在 iothread.c

// iothread.c —— 每个 IO 线程内部也是独立的 ae 事件循环
void *IOThreadMain(void *ptr) {
IOThread *t = ptr;
aeSetBeforeSleepProc(t->el, IOThreadBeforeSleep);
aeMain(t->el);
return NULL;
}
// iothread.c —— 初始化 IO 线程池
void initThreadedIO(void) {
if (server.io_threads_num <= 1) return;
server.io_threads_active = 1;
for (int i = 1; i < server.io_threads_num; i++) {
IOThread *t = &IOThreads[i];
t->el = aeCreateEventLoop(server.maxclients + CONFIG_FDSET_INCR);
// 主线程与 IO 线程通过 eventfd 通信
aeCreateFileEvent(t->el, getReadEventFd(t->pending_clients_notifier),
AE_READABLE, handleClientsFromMainThread, t);
pthread_create(&t->tid, NULL, IOThreadMain, (void *)t);
}
}

架构示意:

alt text

主线程通过 assignClientToIOThread(c) 把客户端分配给某个 IO 线程来处理读写。IO 线程完成读写和协议解析后,通过 eventfd 通知主线程,主线程在 beforeSleep 中调用 handleClientsFromIOThread 取回结果并执行命令。一句话:命令执行永不并发,网络读写可以并行。

六、Redis通信协议#

1. RESP协议#

Redis 的客户端和服务端之间使用 RESP(REdis Serialization Protocol)协议通信。它用纯文本形式传输,人类可读,解析简单,但同时足够紧凑。

五种基本数据类型(RESP2)#

RESP 通过每行第一个字节区分数据类型:

首字节类型格式示例
+Simple String+内容\r\n+OK\r\n
-Error-错误信息\r\n-ERR unknown command\r\n
:Integer:数字\r\n:1000\r\n
$Bulk String$字节数\r\n内容\r\n$5\r\nhello\r\n
*Array*元素个数\r\n各元素...*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n

RESP3(Redis 6 引入)扩展了 ~(Set)、%(Map)、#(Bool)、,(Double)、((Big Number)、=(Verbatim String)、|(Attribute)、_(Null)等类型,但核心思想不变:首字节决定类型,\r\n 分隔

请求与响应示例#

客户端发送一个 SET 命令:

*3\r\n ← 数组,共 3 个元素
$3\r\n ← 第 1 个元素,3 字节
SET\r\n ← "SET"
$5\r\n ← 第 2 个元素,5 字节
mykey\r\n ← "mykey"
$5\r\n ← 第 3 个元素,5 字节
Hello\r\n ← "Hello"

服务端返回:

+OK\r\n ← 简单字符串,表示成功

一个 GET 命令:

客户端:*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n
服务端:$5\r\nHello\r\n ← 批量字符串,5 字节 "Hello"

RESP 在源码中的处理#

resp_parser.h 中定义了完整的解析器回调。每种数据类型对应一个回调函数:simple_str_callback+)、error_callback-)、long_callback:)、bulk_string_callback$)、array_callback*),解析完成后由上层 processCommand 取出 argc/argv 执行对应命令。

七、Redis内存策略#

Redis 的内存管理涉及两个层面:键的过期删除(主动的还是人为设的 TTL)和内存满时的淘汰驱逐(不要和过期混淆)。两者分别回答"怎么删到期 key"和"满了怎么办"。

1. 过期策略#

Redis 的键可以设置 TTL(Time To Live),到期后需要被删除。如果只用一个单一的删除机制,要么太慢(累积太多过期 key),要么太耗 CPU(不断扫描)。所以 Redis 采用了惰性删除 + 定期删除的组合策略。

惰性删除(Lazy Expiration)#

每次访问一个 key 时,先检查它是否过期。如果已过期,当场删除并返回空。核心函数是 expireIfNeeded(),在 lookupKey() 中被调用。

优点是不浪费额外 CPU,缺点是如果某个过期 key 再也没被访问,它就永远占着内存。

定期删除(Active Expiration)#

为了解决惰性删除的"垃圾堆积"问题,Redis 周期性主动扫描过期键,分两种模式:

SLOW 模式(慢速周期):

  • serverCron 周期性触发(hz 频率,默认每秒 10 次)
  • 每次从若干个数据库中随机采样,检查并删除过期 key
  • 单次执行时间有上限:ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC(默认 25%)的 CPU 时间
  • 如果一轮没扫完所有数据库,下次继续从未完成的数据库开始

FAST 模式(快速周期):

  • beforeSleep 触发,在事件循环的每次迭代中执行(频率远高于 SLOW)
  • 单次执行时间上限仅 1ms(ACTIVE_EXPIRE_CYCLE_FAST_DURATION
  • 但如果上次 SLOW 周期没有因为超时而退出(说明过期 key 不多),或者过期比例低于阈值,FAST 模式会直接跳过

两种模式协作的逻辑(expire.c):

FAST mode(高频短跑) SLOW mode(低频长跑)
┌────────────┐ ┌────────────────────┐
│ 每次事件循环 │ │ 每次 serverCron │
│ 最多耗时 1ms │ │ 最多占 25% CPU 时间 │
│ 过期少时跳过 │ │ 持续扫描所有数据库 │
└────────────┘ └────────────────────┘
↑ ↑
└──── 共同保证过期 key 不会堆积 ────┘

惰性删除保证「访问时一定删」,定期删除保证「不访问也最终会删」。两者合力,在 CPU 开销和内存回收之间取得平衡。

2. 淘汰策略#

淘汰(Eviction)和过期是两回事:过期是 key 到了 TTL 被删,淘汰是内存满了不得不删

当 Redis 使用的内存达到 maxmemory 上限时,触发淘汰。策略由 maxmemory-policy 配置,共 10 种:

分类策略选择范围淘汰标准
不淘汰noeviction写操作直接报错
LRUvolatile-lru仅有过期时间的 key近似 LRU
LRUallkeys-lru所有 key近似 LRU
LFUvolatile-lfu仅有过期时间的 key近似 LFU
LFUallkeys-lfu所有 key近似 LFU
随机volatile-random仅有过期时间的 key随机
随机allkeys-random所有 key随机
TTLvolatile-ttl仅有过期时间的 key最接近过期
LRMvolatile-lrm仅有过期时间的 keyLRU + 采样后二次筛选
LRMallkeys-lrm所有 keyLRU + 采样后二次筛选

淘汰策略

近似 LRU/LFU 的具体实现在 redisObjectlru 字段(24 位时钟/计数器)和 evict.c 的采样淘汰机制中已有详细说明,见第三章「lru 字段 —— 内存淘汰的近似 LRU」一节。

八、持久化#

Redis 是内存数据库,数据在断电后会丢失。持久化就是把内存中的数据保存到磁盘上,重启时再加载回来。Redis 提供了两种机制:RDB(快照)和 AOF(日志),二者可单独使用也可组合使用。

1. RDB(Redis Database)#

RDB 是全量快照式持久化。在某个时间点,把内存中所有数据序列化成一个二进制文件(默认 dump.rdb),恢复时直接读取这个文件重建整个数据集。

触发方式#

手动触发

  • SAVE:由主线程执行保存,保存期间 Redis 不能处理任何请求,适合停机维护场景。
  • BGSAVEfork() 出一个子进程,子进程负责写入 RDB 文件,主进程继续处理请求,不阻塞服务。

自动触发(通过配置 save 参数):

save 900 1 ← 900 秒内至少 1 次修改
save 300 10 ← 300 秒内至少 10 次修改
save 60 10000 ← 60 秒内至少 10000 次修改

满足任一条件即自动触发 BGSAVE

BGSAVE 的 fork 机制#

主进程 子进程
┌────────┐ ┌──────────┐
│ 处理请求 │ ──fork()──→ │ 遍历内存 │
│ 继续干活 │ │ 写入RDB文件│
└────────┘ └──────────┘
共享同一份内存页(Copy-on-Write)
主进程写某个页时,内核才复制那一页

得益于 Linux 的 Copy-on-Write,fork 时并不立即复制全部内存,主进程和子进程共享同一份物理内存页。只有当主进程修改了某个页时,内核才会把该页复制给子进程。因此内存占用峰值 ≈ 原始数据 + 写入期间的增量修改。

RDB 的优缺点#

优点缺点
文件紧凑,适合灾备和迁移两次快照之间的数据可能丢失
恢复速度快,直接加载二进制大数据量下 fork 耗时长,可能造成短暂卡顿
fork 子进程写入,主进程不阻塞频繁 fork 会消耗 CPU

2. AOF(Append Only File)#

AOF 是增量日志式持久化。把每一条修改命令以 RESP 协议格式追加写入日志文件,重启时逐条回放命令来还原数据。

刷盘策略#

核心配置是 appendfsync,控制 write() 之后何时执行 fsync()(真正落盘):

行为安全性性能
always每执行一条修改命令立即 fsync最高,最多丢一条最慢
everysec每秒 fsync 一次(默认)最多丢 1 秒数据折中,生产环境首选
no不主动 fsync,交给 OS 决定可能丢几秒数据最快,但不推荐

源码 flushAppendOnlyFile() 中做了区分:AOF_FSYNC_ALWAYS 每次都同步,AOF_FSYNC_EVERYSEC 靠后台 bio 线程每秒刷盘。

AOF 重写(Rewrite)#

AOF 文件会随运行时间不断膨胀。例如一个计数器被 INCR 了 100 次,AOF 中有 100 条记录,但最终值其实只需要一条 SET counter 100。重写就是创建一个新的 AOF 文件,用最少命令集描述当前数据库状态。

触发条件:

auto-aof-rewrite-percentage 100 ← 文件大小比上次重写后增长 100%
auto-aof-rewrite-min-size 64mb ← 文件至少 64MB 才考虑重写

重写同样通过 fork 子进程执行(rewriteAppendOnlyFileBackground()),不影响主进程对外服务。重写期间新产生的修改命令同时写入旧的 AOF 文件和 AOF 重写缓冲区,等子进程完成后,再把缓冲区内容追加到新文件末尾,原子性地切换(rename)过去。

AOF 的优缺点#

优点缺点
最多丢失 1 秒数据文件体积比 RDB 大
文件是 RESP 文本,可读可编辑恢复速度比 RDB 慢(逐条回放)
rewrite 机制控制文件膨胀always 策略下性能开销大

3. RDB + AOF 混合使用#

Redis 4.0 引入了混合模式(aof-use-rdb-preamble yes)。AOF 重写时,子进程先把当前数据以 RDB 格式写入 AOF 文件头,再把后续的增量命令以 AOF 格式追加。结果是:前半段的 RDB 部分加载快,后半段的 AOF 部分保证数据完整性。生产环境推荐开启。

混合 AOF 文件结构:
┌──────────────┬────────────────────────┐
│ RDB 格式快照 │ AOF 增量命令(RESP文本) │
│ (二进制) │ 重写之后的修改命令 │
└──────────────┴────────────────────────┘

九、事务#

1. 事务的实现#

Redis 的事务和关系型数据库的事务完全不同——没有隔离级别,没有回滚,不做行锁。它的语义很简单:把一组命令打包,按顺序串行执行,执行期间不插入其他客户端的命令

三个核心命令:

  • MULTI:开启事务,标记客户端进入事务状态(CLIENT_MULTI 标志位)
  • EXEC:执行队列中所有命令
  • DISCARD:放弃事务,清空队列

源码流程(multi.c):

MULTI → 客户端设置 CLIENT_MULTI 标志
之后的每个命令 → 不执行,而是加入 c->mstate.commands 队列
每次收到命令 → addReply(c, shared.queued) 告诉客户端"已排队"
EXEC → 遍历队列,逐个 call() 执行
DISCARD → 清空队列,取消 CLIENT_MULTI 标志
// multi.c —— 事务中的命令不执行,只排队
void queueMultiCommand(client *c, uint64_t cmd_flags) {
multiCmd *mc;
// ...
mc = &c->mstate.commands[c->mstate.count];
mc->cmd = c->cmd;
mc->argv_len = c->argv_len;
// 复制参数、记录命令 —— 但不执行
c->mstate.count++;
}
// multi.c —— EXEC 时批量执行
void execCommand(client *c) {
// 检查是否被 WATCH 破坏
if (c->flags & (CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC)) {
discardTransaction(c);
return; // 事务中止
}
// 逐条执行队列中的命令
for (j = 0; j < c->mstate.count; j++) {
call(c, c->mstate.commands[j].cmd);
}
}

2. 原子性的真实含义#

Redis 事务的原子性,指的是执行期间不被打断——MULTIEXEC 之间排队的命令会一口气全部执行完,不会插入其他客户端的命令。但它不保证事务中某条命令失败后回滚前面的成功命令

举例:

MULTI
SET key1 "a"
INCR key1 ← 对字符串执行 INCR,会失败(类型错误)
SET key2 "b"
EXEC

结果:key1 被设为 "a"INCR key1 报错,但 key2 仍然被设为 "b"。前面的 SET key1 "a" 不会因为后面 INCR 失败而回滚。Redis 的设计哲学是:编程错误应该在开发阶段暴露,不应该依赖生产环境的运行时回滚。

3. WATCH —— 乐观锁#

WATCH key 可以监视一个 key。如果在 WATCH 之后、EXEC 之前,被监视的 key 被其他客户端修改了,整个事务会被中止(返回 nil)。

这是 Redis 的乐观锁机制:不阻止别人写,但在提交时检查版本是否变了。常用于实现原子性的"检查后操作"(比如余额扣减前检查余额是否足够)。

WATCH balance
GET balance ← 假设返回 100
MULTI
DECRBY balance 50
EXEC ← 如果 balance 在 WATCH 后被别人改了,这里返回 nil

源码中通过 c->watched_keys 列表跟踪,被监视的 key 被修改时会设置 CLIENT_DIRTY_CAS 标志位,EXEC 检测到该标志位后中止执行。

十、主从复制#

1. 复制的意义#

单机 Redis 有 QPS 上限,且存在单点故障风险。主从复制的核心目标:

  • 读写分离:主节点处理写请求,多个从节点处理读请求,分摊读压力
  • 数据冗余:从节点持有完整数据副本,主节点宕机后可接管
  • 高可用基础:配合哨兵(Sentinel),实现自动故障转移

2. 复制流程#

Redis 的复制分为全量同步部分同步两个阶段。

全量同步(Full Resynchronization)#

初次连接或复制信息丢失时触发,流程如下:

Slave Master
│ │
│──── PSYNC ? -1 ──────────────→│ (首次连接,不知道任何 offset)
│ │
│←── FULLRESYNC <replid> <offset>│ 告诉 Slave "我们要全量同步"
│ │
│ │ Master 执行 BGSAVE,生成 RDB
│ │ ︙
│←── 发送 RDB 文件 ─────────────→│
│ │
│ 加载 RDB,重建数据 │
│ │
│←── 发送 RDB 期间的增量命令 ─────│ (replication buffer)
│ │
│ 执行增量命令,追上最新状态 │
  1. Slave 发送 PSYNC ? -1? 表示未知 master,-1 表示没有 offset)
  2. Master 返回 FULLRESYNC 和自己的 replid + offset
  3. Master 执行 BGSAVE 生成 RDB,发送给 Slave
  4. RDB 生成期间的写操作暂存在 replication buffer 中,RDB 发完后一并发送
  5. Slave 先加载 RDB,再执行增量命令,之后进入命令传播阶段

部分同步(Partial Resynchronization)#

在连接短暂断开后重连时,如果条件满足,可以避免全量同步:

Slave Master
│ │
│── PSYNC <replid> <offset> ──→│
│ │
│ 检查 repl_backlog 中是否还有 offset 位置的数据
│ │
│←── CONTINUE ────────────────│ (在 backlog 中找到了,只补发差额)
│ │
│←── 发送 offset 之后的增量 ────│

部分同步依赖 Master 的 replication backlog(默认 1MB 的环形缓冲区)。Master 会将每个写操作同时写入 backlog。如果 Slave 携带的 offset 仍然在 backlog 范围内,就可以只补发差额;如果已经超出范围(Slave 断开太久,backlog 中对应位置的数据被覆盖了),则降级为全量同步。

// replication.c —— backlog 定义
server.repl_backlog->offset = server.master_repl_offset + 1;
server.repl_backlog->histlen = 0; // 当前 backlog 存储的数据长度
// 当 histlen > repl_backlog_size 时,旧数据被覆盖

命令传播#

全量或部分同步完成后,Master 和 Slave 进入稳定状态。Master 每执行一个写命令,都会把命令广播给所有 Slave。Slave 接收后执行,保持数据一致。这是一个异步过程——Master 不等待 Slave 确认就返回客户端,因此主从之间可能存在毫秒级的复制延迟。

3. 主从拓扑#

┌─────────┐
│ Master │ ← 写请求
└────┬────┘
┌───────┼───────┐
↓ ↓ ↓
┌──────┐ ┌──────┐ ┌──────┐
│Slave1│ │Slave2│ │Slave3│ ← 读请求
└──────┘ └──────┘ └──────┘

Slave 也可以有自己的 Slave,形成级联复制,减轻 Master 的复制压力。


全文总结#

十个章节串起 Redis 从数据到网络、从存储到容灾的完整知识体系:

一、背景 —— Redis 是什么、为什么快
二、组件 —— SDS / IntSet / Dict / ZipList / QuickList / SkipList / Listpack
七种积木块,解决「数据怎么存」
三、RedisObject —— 类型系统,encoding 字段把五种类型映射到七种组件
四、五种数据结构 —— String / List / Set / ZSet / Hash,对外暴露的最终形态
五、网络模型 —— epoll + 事件循环 + IO 线程,解决「请求怎么来、回复怎么回」
六、通信协议 —— RESP 协议,客户端和服务端的通用语言
七、内存策略 —— 过期删除 + 淘汰驱逐,解决「内存不够怎么办」
八、持久化 —— RDB 快照 + AOF 日志,解决「数据丢了怎么办」
九、事务 —— MULTI/EXEC/WATCH,一组命令原子执行,不被打断
十、主从复制 —— 全量同步 + 部分同步 + 命令传播,解决「单机不够怎么办」

专题阅读

blog

这篇文章属于同一条阅读链。你可以直接在这里切换,不用再回到列表页重新找。

当前进度3 / 3

阅读导航

文章目录

当前阅读位置将在这里显示

0 节