redis-源码带读-03-核心数据结构实现
Redis 源码带读 第3篇:核心数据结构实现
系列:Redis 源码带读(基于 Redis 7.x)
本篇是整个系列最重要的一篇。Redis 之所以快、之所以"省",秘密全部藏在它自己动手实现的那几个数据结构里。读完本篇,你会对"工程化的数据结构"有一个全新的认识。
1. 本篇目标
前面两篇我们搞清楚了 Redis 的启动流程和事件循环,从本篇开始进入真正的核心地带:
- 深挖 Redis 最优雅的几个数据结构实现(Data Structure Implementation):
- SDS(Simple Dynamic String,简单动态字符串)
- dict(Dictionary,字典/哈希表,含渐进式 rehash)
- skiplist(跳表,有序集合的底层骨架)
- quicklist + listpack(列表的新老组合拳)
- rax(Radix Tree,基数树,Stream 的索引骨架)
- redisObject 与编码(Encoding)自动升级机制
- 理解每个结构 为什么这么设计——每一个字段、每一段位运算背后都是内存与 CPU 的权衡。
- 建立一条主线:同一个对外类型(type),多种底层编码(encoding),按数据规模自动升降级。
涉及的源文件:
src/sds.c src/sds.h src/dict.c src/dict.h
src/t_zset.c src/t_hash.c src/t_list.c src/t_string.c
src/t_stream.c src/rax.c src/object.c src/listpack.c
src/quicklist.c
2. SDS —— 一切字符串的根基(sds.c + sds.h)
2.1 为什么不用 C 字符串?
C 语言原生字符串(char*,以 \0 结尾)有四个致命缺陷:
- 获取长度 O(N):
strlen要扫描整个字符串; - 非二进制安全(Binary Safe):内容中出现
\0就会被截断,无法存图片、序列化后的对象等; - 缓冲区溢出风险:
strcat前需要调用者自己保证空间够用; - 频繁修改导致频繁内存重分配:每次增长/缩短都要
malloc/realloc,而字符串操作在数据库里是最高频的操作之一。
SDS 针对以上每一点都给出了答案。
2.2 五种头部结构:sdshdr5 / 8 / 16 / 32 / 64
/* sds.h */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 低 3 位存类型,高 5 位存长度 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 已使用长度 */
uint8_t alloc; /* 分配的总长度(不含头部和结尾 \0) */
unsigned char flags; /* 低 3 位存类型 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len;
uint64_t alloc;
unsigned char flags;
char buf[];
};
设计要点:
__attribute__ ((__packed__)):取消字节对齐(Alignment Padding),让结构体严格紧凑排列,节省内存。- 按字符串长度选择头部:短字符串用
sdshdr8(头部仅 3 字节),长字符串才用 32/64 位头部。这是典型的空间分级思想。 sdshdr5最特殊:没有独立的 len/alloc 字段,5 位长度直接塞进flags的高位,只能存 31 字节以内的字符串,且不支持扩容(实际很少用于可变场景)。
2.3 sds 就是一个 char*,但头在指针前面
typedef char *sds;
注意这个反直觉的设计:SDS 并不是指向结构体的指针,而是直接指向 buf[] 数据区。头部信息在指针地址的"前面"(负偏移处):
内存布局:
+--------+-------+---------+---------------------+----+
| len | alloc | flags | b u f f e r . . . | \0 |
+--------+-------+---------+---------------------+----+
^
|
sds 指针指到这里
要取回头部?只需看指针前一个字节的 flags:
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
#define SDS_TYPE_MASK 7 /* 0b111 */
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1]; /* 头部最后一个字节就是 flags */
switch(flags & SDS_TYPE_MASK) { /* 低 3 位 => 类型 */
case SDS_TYPE_5: return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8: return ((struct sdshdr8 *)s - 1)->len;
...
}
}
- 低 3 位存类型(
flags & SDS_TYPE_MASK,掩码为 7),高位留给sdshdr5存长度。一个字节掰成两半用。 - 这种"元数据藏在数据指针前面"的技巧,和后面 dict 的指针打标(Pointer Tagging)一脉相承:能不额外分配内存就不分配。
好处:sds 可以直接传给 printf("%s")、strcmp 等所有 C 函数(兼容性),同时又能 O(1) 拿到长度(性能)。
2.4 创建:sdsnewlen()
/* sds.c */
sds sdsnewlen(const void *init, size_t initlen) {
...
/* 根据初始长度挑选最小够用的头部类型 */
int type = sdsReqType(initlen);
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer */
/* 多分配 1 字节给结尾 '\0';sdsmalloc 里还会多留 hdrlen 用于回填 */
sh = s_malloc(hdrlen + initlen + 1);
...
s = (char *)sh + hdrlen; /* 返回值指向 buf */
fp = ((unsigned char*)s) - 1; /* 回填 flags */
*fp = type;
...
}
细节:空字符串不会用 sdshdr5(因为后续大概率要追加,而 5 型不可扩容),强制升为 sdshdr8。
2.5 扩容策略:sdsMakeRoomFor() 与 SDS_MAX_PREALLOC
#define SDS_MAX_PREALLOC (1024*1024) /* 1MB */
sds sdsMakeRoomFor(sds s, size_t addlen) {
...
if (avail >= addlen) return s; /* 空间够,直接返回 */
reqlen = sdslen(s) + addlen;
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2; /* 小于 1MB:翻倍 */
else
newlen += SDS_MAX_PREALLOC; /* 超过 1MB:每次只多给 1MB */
...
}
这是教科书级的惰性预分配(Preallocation)策略:
| 场景 | 策略 | 动机 |
|---|---|---|
| 新长度 < 1MB | 空间翻倍 | 摊还 O(1) 追加,减少 realloc 次数 |
| 新长度 ≥ 1MB | 每次 +1MB | 大字符串翻倍太浪费,防止内存爆炸 |
配套还有两个"省钱"函数:
sdsIncrLen():追加后原地更新 len,避免重复遍历;sdsRemoveFreeSpace():字符串确定不再增长时,把多余的空闲空间释放掉(缩容)。
2.6 二进制安全与 O(1) 长度
- 长度记录在 len 字段 →
sdslen是纯算术运算,O(1); - 判断结束靠 len 而不是
\0→ 内容可以包含任意字节(Binary Safe); \0依然会分配(+1),纯粹是为了复用 C 标准库函数,属于"白送的兼容性"。
3. dict —— 渐进式 rehash 的哈希表(dict.c)
3.1 核心结构体
/* dict.h(Redis 7.x) */
struct dict {
dictEntry **ht_table[2]; /* 两张哈希表!正常只用 ht_table[0] */
unsigned long ht_used[2]; /* 每张表已有元素个数 */
unsigned long ht_size_exp[2]; /* 表大小的 2 的幂指数(size = 1<<exp) */
long rehashidx; /* rehash 进度:-1 表示未在进行 rehash */
/* 迭代器相关 */
unsigned long iterators;
...
};
要点:
- 双表设计(ht_table[2]) 是渐进式 rehash 的前提。平时只有 0 号表在工作,rehash 期间两张表同时有数据。
- 表大小恒为 2 的幂(Power of Two),所以取模可以用位运算:
h & (size - 1)。 dictEntry就是经典的拉链法节点:void *key; union v value; struct dictEntry *next;。
3.2 渐进式 rehash(Incremental Rehash):一次搬一个桶
传统哈希表扩容时一次性搬迁所有元素(O(N)),对单线程的 Redis 来说会造成长尾卡顿。Redis 的做法是把这次搬迁摊到后续每一次操作中:
static void _dictRehashStep(dict *d) {
/* 每次只搬 1 个桶;但最多访问 10 个空桶就停(避免长时间找不到非空桶) */
unsigned long empty_visits = 10;
dictRehash(d, 1);
}
规则总结:
- 触发时机:增删改查都会先调用
_dictRehashStep,顺路搬一个桶; - 查找路径:rehash 期间先查
ht_table[0],没找到再查ht_table[1]; - 新元素一律插入
ht_table[1]:保证 0 号表只减不增,最终必然搬完; - 定时任务兜底:serverCron 中若发现 rehash 进行中且超时(
dictRehashMilliseconds(d, 1)),会主动多搬一些,保证大表也能收敛; - 迭代器约束:有安全迭代器存在时暂停渐进搬迁,保证迭代语义稳定。
3.3 扩容与缩容的负载因子阈值
/* 扩容判定(dictExpandIfNeeded) */
if (d->ht_used[0] >= d->ht_size_exp[0] && /* load factor >= 1 */
(dict_can_resize || d->ht_used[0] / DICTHT_SIZE(d->ht_size_exp[0]) > dict_force_resize_ratio))
return dictExpand(d, d->ht_used[0]*2);
/* 缩容由 serverCron 触发 */
if (used / size < 0.1) shrink /* load factor < 0.1 */
- 负载因子(Load Factor)= used / size:
>= 1且允许 resize 时 → 扩容到used*2;- 正在执行 BGSAVE/BGREWRITEAOF(fork 子进程,Copy-On-Write 敏感)时默认禁止扩容,除非负载因子超过
dict_force_resize_ratio(5),宁可冒 COW 风险也不能让哈希表退化成链表; < 0.1→ 缩容(Shrink),及时归还内存。
3.4 哈希函数:SipHash
uint64_t dictGenHashFunction(const void *key, Py_ssize_t len) {
return siphash(key, len, siphash_seed); /* 全局随机种子 */
}
- 从 Redis 5.0 起,键的哈希采用 SipHash(一种带密钥的 PRF),种子(Seed)在启动时随机生成;
- 目的是防御哈希碰撞攻击(Hash-Flooding DoS):攻击者无法离线构造出大量同桶的 key;
- 整数键走专门的
dictGenCaseHashFunction/ 直接用整数本身做散列。
3.5 指针打标(Pointer Tagging):低比特位藏元数据
dict 内部有一类特殊需求:某些"条目"其实不需要真的分配 dictEntry(比如 SET 类型中值就是键)。为了区分"指向 dictEntry 的指针"和"直接当键用的指针",Redis 把元数据塞进指针的低比特位:
/* dict.h */
#define ENTRY_PTR_NO_ENTRY 1 /* 最低位为 1 => 这不是 dictEntry*,而是直接的 key */
#define ENTRY_PTR_EXPIRE 2 /* 第二低位 => 附带 TTL 元数据 */
/* 取真实指针时清掉低两位 */
dictEntry *entryFromEntryPtr(void *entry_ptr) {
return (dictEntry *)(~3 & (unsigned long)entry_ptr);
}
这与 SDS 用 flags 低 3 位存类型的思路完全一致——利用 64 位指针天然对齐产生的空闲低位,零成本携带元数据。这类技术在工程上统称为 Tagged Pointer。
3.6 dictType:面向回调的泛型化
dict 本身不关心键值的类型,一切差异通过 dictType 结构的回调注入:
typedef struct dictType {
uint64_t (*hashFunction)(const void *key); /* 计算哈希 */
int (*keyCompare)(dict *d, const void *key1, const void *key2);
void (*keyDestructor)(dict *d, void *key); /* 键析构 */
void (*valDestructor)(dict *d, void *obj); /* 值析构 */
int (*expandAllowed)(size_t maxUsedEntries, double expandFactor, ...);
...
} dictType;
db->dict(键空间)、SET、HASH、ZSET 内部的 member 表等,都各自注册了一套 dictType。这是 C 语言里非常干净的泛型容器(Generic Container)写法。
3.7 dict 的核心操作
dictExpand() —— 扩容
int dictExpand(dict *d, unsigned long size) {
// 1. 计算新的2的幂
unsigned long realsize = dictNextPower(size);
// 2. 分配新的哈希表
newtable = zcalloc(sizeof(dictEntry*) * realsize);
// 3. 初始化ht_table[1]
d->ht_table[1] = newtable;
d->ht_size_exp[1] = realsize_exp;
d->ht_used[1] = 0;
// 4. 设置rehashidx = 0,开始渐进式rehash
d->rehashidx = 0;
return DICT_OK;
}
dictFind() —— 查找
dictEntry *dictFind(dict *d, const void *key) {
// 1. rehash期间:先查ht[0],再查ht[1]
for (table = 0; table <= 1; table++) {
idx = dictHashKey(d, key) & (DICTHT_SIZE(d->ht_size_exp[table]) - 1);
he = d->ht_table[table][idx];
while (he) {
if (dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) break; // 非rehash时只查ht[0]
}
return NULL;
}
dictAdd() —— 插入
int dictAdd(dict *d, void *key, void *item) {
// 1. rehash时顺路搬一个桶
if (dictIsRehashing(d)) _dictRehashStep(d);
// 2. 查找是否已存在
if ((he = dictFind(d, key)) != NULL) {
return DICT_ERR; // 已存在
}
// 3. 新元素插入ht[1](rehash期间)或ht[0]
int htidx;
if (dictIsRehashing(d))
htidx = 1; // rehash期间插入到ht[1]
else
htidx = 0;
// 4. 插入到链表头部
entry->next = d->ht_table[htidx][idx];
d->ht_table[htidx][idx] = entry;
d->ht_used[htidx]++;
return DICT_OK;
}
4. t_zset.c —— 有序集合:skiplist + dict 双引擎
4.1 跳表是什么:多级链表,O(logN)
跳表(Skip List)是一条带索引层的多层链表:底层包含所有元素,越往上元素越稀疏,查询时自顶层向下逐层"跳着走",把平均复杂度降到 O(logN):
L3: head ------------------------------> 50 ------------> NULL
L2: head ---------> 20 --------------------------------> 50 ----------> NULL
L1: head -> 10 ---> 20 ---------> 40 -----------------> 50 ---> 70 --> NULL
L0: head -> 10 --> 20 --> 30 --> 40 --> 45 --> 50 --> 60 --> 70 --> NULL
相比平衡树(AVL/红黑树)的优势:实现简单得多,且范围查询天然高效(找到起点后沿底层链表顺序输出即可);并发场景下也更容易做无锁优化(虽然 Redis 单线程用不上这点)。
4.2 zskiplistNode 与 zskiplist
/* server.h */
typedef struct zskiplistNode {
sds ele; /* 成员(SDS 字符串) */
double score; /* 排序分值 */
struct zskiplistNode *backward; /* 后退指针,仅第一层有,用于反向遍历 */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 本层的前进指针 */
unsigned long span; /* 跨度:到 forward 节点跨过了几个元素 */
} level[]; /* 柔性数组,层数动态 */
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length; /* 元素总数 */
int level; /* 当前最大层高 */
} zskiplist;
- span(跨度) 是个精妙设计:把沿途各层的 span 相加,就能 O(logN) 算出某元素的排名(Rank),这正是
ZRANK命令的实现基础; backward只存在于第 1 层,服务ZREVRANGE类反向遍历。
4.3 层高的随机生成:概率 1/4
int zslRandomLevel(void) {
static const int threshold = ZSKIPLIST_P*RAND_MAX; /* P = 0.25 */
int level = 1;
while (random() < threshold)
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
- 每插入一个节点,以 1/4 概率再升高一层,循环直到失败或到达上限
ZSKIPLIST_MAXLEVEL = 32; - 数学期望下第 i 层的节点数约为 N·(1/4)^i,因此查找路径长度期望为 O(log₁/₄N),即 O(logN);
- 选 1/4 而不是经典论文的 1/2,是为了减少上层指针数量、节省内存(代价是常数因子略大,实测更划算)。
4.4 zset = dict + skiplist,为什么要有两套结构?
typedef struct zset {
dict *dict; /* member -> score,O(1) 点查 */
zskiplist *zsl; /* 按 (score, ele) 排序,支持范围/排名操作 */
} zset;
两个结构共享同一批 sds 和分数(dict 的 value 直接指向 score 所在的 double,member 是同一个 SDS 指针),并不浪费多少内存,却换来两种能力:
| 操作 | 用哪个结构 | 复杂度 |
|---|---|---|
ZSCORE key member |
dict | O(1) |
ZRANGE / ZRANGEBYSCORE |
skiplist | O(logN + M) |
ZRANK / ZCOUNT |
skiplist(span 求和) | O(logN) |
ZADD 更新已存在的分值 |
dict 定位 + skiplist 重插 | O(logN) |
排序规则:先按 score 升序,score 相同时按 ele 的字典序(sdscmp),保证全序唯一。
4.5 跳表的核心操作
zslInsert() —— 插入节点
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
// 1. 记录每层的插入位置
zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
unsigned long rank[ZSKIPLIST_MAXLEVEL];
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
// 在当前层向右走,直到找到比(score,ele)大的节点
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
// 2. 生成随机层高
level = zslRandomLevel();
// 3. 在每层插入新节点
for (i = 0; i < level; i++) {
x->level[i].forward = x;
// 更新span
}
// 4. 如果新节点层高超过当前最大层,更新zsl->level
if (level > zsl->level) zsl->level = level;
zsl->length++;
return newnode;
}
zslDelete() —— 删除节点
int zslDelete(zskiplist *zsl, double score, sds ele, nodefp) {
// 1. 查找每层的前驱节点
zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele, ele) < 0))) {
x = x->level[i].forward;
}
update[i] = x;
}
// 2. 找到目标节点
x = x->level[0].forward;
if (x && score == x->score && sdscmp(x->ele, ele) == 0) {
// 3. 在每层删除节点
for (i = 0; i < zsl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
}
}
// 4. 释放节点
zslFreeNode(x);
zsl->length--;
return 1;
}
return 0;
}
5. t_list.c —— 列表:quicklist 双向链表套 listpack
5.1 演进史:adlist → ziplist → quicklist(+listpack)
- 早期版本:普通双向链表 adlist。问题:每个节点一次 malloc、prev/next 两个指针,小数据量下开销巨大且碎片化严重;
- Redis 3.2 引入 quicklist(快速列表):双向链表的每个节点不再放单个元素,而是放一小段压缩数据(当时是 ziplist);
- Redis 7.0 起,ziplist 被 listpack(紧凑列表) 全面取代(ziplist 有致命的连锁更新 Cascade Update 问题,listpack 彻底移除了 prevlen 字段)。
5.2 quicklist 的结构
typedef struct quicklist {
quicklistNode *head; /* 头节点 */
quicklistNode *tail; /* 尾节点 */
unsigned long count; /* 所有 listpack 中元素总数 */
unsigned long len; /* quicklistNode 个数 */
signed int fill : QL_FILL_BITS; /* 单个节点 listpack 的填充限制 */
unsigned int compress : QL_COMPRESS_BITS; /* 两端不压缩的节点深度 LZF */
...
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *entry; /* 指向 listpack(或 LZF 压缩块) */
size_t sz; /* entry 占用的字节数 */
...
} quicklistNode;
设计权衡一目了然:
- 链表在外层:头尾插入仍是 O(1),且中间可以分段管理;
- listpack 在内层:一段连续内存装多个元素,CPU 缓存友好、无逐元素指针开销;
fill参数控制每个 listpack 最多装多少元素/多大字节(对应配置list-max-listpack-size,正数为个数,负数 -1~-4 对应 4KB~8KB 等);compress支持两端之外的中间节点做 LZF 压缩(list-compress-depth),典型的 LRU 友好设计:热数据在两端,冷数据压在中间。
一句话:quicklist = 双向链表(Doubly Linked List)+ listpack 分段存储,兼顾两端 O(1) 操作与内存紧凑性。
5.3 listpack 的内部结构
// listpack.c
typedef struct listpack {
uint32_t total_bytes; /* listpack 总字节数(不含头尾) */
uint16_t numele; /* 元素个数 */
unsigned char data[]; /* 元素数据 */
} listpack;
// 每个元素的编码格式:
// [entry-header][data][backlen]
// entry-header: 1字节,编码类型+数据长度
listpack 的核心改进:
- 移除 prevlen:ziplist 的每个元素都存前一个元素的长度(用于倒序遍历),导致连锁更新问题
- 前向遍历:listpack 通过在每个元素末尾存储
backlen(前一个元素的总长度),实现倒序遍历 - 无连锁更新:修改一个元素只影响相邻元素,不会级联更新
5.4 quicklist 的核心操作
quicklistPushHead() —— 头部插入
int quicklistPushHead(quicklist *quicklist, void *data, size_t sz) {
quicklistNode *node = quicklist->head;
// 1. 如果头部节点有空间,直接插入
if (node && node->fill >= lb_size) {
quicklistPackEntry(node, data, sz);
quicklist->head->count++;
quicklist->count++;
return 1;
}
// 2. 创建新的quicklistNode
_quicklistInsertNode(quicklist, quicklist->head, new_node, 0);
// 3. 将数据插入新节点
quicklistPackEntry(new_node, data, sz);
return 1;
}
quicklistIndex() —— 按索引查找
int quicklistIndex(quicklist *quicklist, long long idx, listpackEntry *entry) {
quicklistNode *node;
unsigned int relidx = 0;
// 1. 确定从头还是尾开始遍历
if (idx >= 0) {
node = quicklist->head;
} else {
node = quicklist->tail;
idx = -idx - 1;
}
// 2. 在节点间移动
while (node) {
if ((idx - relidx) < node->count) {
// 找到目标节点
break;
}
relidx += node->count;
node = (idx >= 0) ? node->next : node->prev;
}
// 3. 在listpack中查找元素
if (node) {
listpackIndex(node->entry, idx - relidx, entry);
return 1;
}
return 0;
}
6. t_stream.c —— Stream:rax 基数树 + listpack
Stream 是 Redis 5.0 加入的消息队列原语,底层是本篇最"炫"的组合:Radix Tree(基数树)做 ID 索引,listpack 做实体存储。
6.1 rax:压缩前缀树
typedef struct rax {
raxNode *head;
raxNode *tail;
uint64_t numele; /* 树中的 key 总数 */
uint64_t numnodes; /* 节点总数 */
raxStack stack; /* 迭代器用的栈 */
} rax;
- 普通 Radix Tree 每个字符一个节点,浪费严重;rax 做路径压缩(Path Compression / Compressed Trie):只有一个后继的连续字符合并进同一节点,比如
["stream", "structure"]共享"str"; - 节点内部用
[header][字符段][子指针][value 指针]的变长布局,同样追求零浪费; - 典型用途:Stream 的 Entry ID、集群节点名映射、
MEMORY STATS的统计树等。
6.2 Stream 的组织方式
stream 对象:
rax 树
└─ key = "1626847600000-0" (EntryID 的二进制编码, 128bit)
└─ value = raxNode 内嵌的 listpack
└─ [field1, value1, field2, value2, ...]
加上:
consumer groups(消费者组):每个组也是一个 rax,
组内 pending 列表(PEL)还是 rax
要点:
- Entry ID(毫秒时间戳-序号)被序列化为定长 17 字节的二进制 key 放入 rax,ID 天然有序 ⇒ 树的中序即消息的时间序,
XRANGE范围读取极其自然; - 每个 rax 叶子节点的 value 是一个 listpack,打包存储若干条消息(宏
STREAM_NODE_MIN_BYTES控制 ~4KB 一个节点),又是"树索引 + 紧凑块存储"的分层思想; - 消费者组(Consumer Group)、待确认表(Pending Entries List, PEL)同样用 rax 组织,删除旧消息用
XTRIM直接摘除树的前缀节点即可。
6.3 rax 的核心操作
raxInsert() —— 插入
int raxInsert(rax *rax, unsigned char *s, size_t len, void *data, void **old) {
// 1. 查找插入位置
raxNode *parent, *child;
_raxHelperFind(rax, s, len, &parent, &child);
// 2. 检查是否需要路径压缩
if (child && child->iscompr) {
// 分裂压缩节点
raxNode *split = raxCompressNode(child, ...);
}
// 3. 插入新节点
raxNode *new = raxNewNode(children, 0);
memcpy(new->data, s, len);
// 4. 更新父节点指针
parent->children[idx] = new;
rax->numele++;
return 1;
}
raxRemove() —— 删除
int raxRemove(rax *rax, unsigned char *s, size_t len, void **old) {
// 1. 查找目标节点
raxNode *parent, *child;
_raxHelperFind(rax, s, len, &parent, &child);
// 2. 删除节点
raxFree(child);
// 3. 合并(如果父节点只有一个子节点)
if (parent->numchildren == 1) {
raxMergeChild(parent, ...); // 路径压缩合并
}
rax->numele--;
return 1;
}
7. object.c —— redisObject 与编码自动升级
7.1 robj:一个 16 字节的对象头
/* server.h */
typedef struct redisObject {
unsigned type:4; /* 对象类型:string/list/hash/zset/set/stream... */
unsigned encoding:4; /* 底层编码:int/embstr/raw/listpack/... */
unsigned lru:LRU_BITS; /* 24bit:LRU 时钟 或 LFU 计数 */
int refcount; /* 引用计数 */
void *ptr; /* 指向底层数据结构 */
} robj;
- type 和 encoding 都是 4 bit 位域(Bit Field),加上 24 bit lru 和 refcount,整个头部恰好 16 字节;
- type 决定命令语义,encoding 决定底层实现——这就是 Redis "多态"的实现方式;
refcount配合incrRefCount/decrRefCount实现引用计数式共享,数字 0~9999 的字符串还能通过共享对象池复用。
7.2 同一类型,多种编码
| type | 可能的 encoding | 说明 |
|---|---|---|
| string | int |
值能转成 long 且在长整范围内,ptr 直接存整数(免了解引用!) |
| string | embstr |
≤ 44 字节:robj 头 + SDS 一次 malloc 连续分配,只读优化 |
| string | raw |
一般 SDS |
| list | listpack |
元素少且短时的紧凑编码 |
| list | quicklist |
默认形态 |
| hash | listpack |
field/value 都少且短 |
| hash | hashtable(dict) |
超过阈值后转换 |
| set | intset |
全是整数时 |
| set | listpack |
少且短的成员 |
| set | hashtable(dict) |
默认大集合形态 |
| zset | listpack |
member/score 交替存放的小 zset |
| zset | skiplist |
dict + skiplist |
embstr 的 44 字节怎么来的:robj 头 16 字节 + sdshdr8 头 3 字节 + 结尾 \0 1 字节 = 20,jemalloc 的 64 字节分配单元减去 20 正好剩 44。连内存分配器的粒度都算计到了。
7.3 自动升级(Auto-Upgrade):小而美 → 大而强
以 Hash 为例(t_hash.c):
/* 写入前检查是否该升级编码 */
if (hashTypeLength(o) >= server.hash_max_listpack_entries || /* 默认 128 */
sdsEncodedObject(val) && sdslen(...) > server.hash_max_listpack_value) /* 默认 64 字节 */
{
hashTypeConvert(o, OBJ_ENCODING_HT); /* listpack -> dict,一次性整体转换 */
}
通用规律(object.c / 各 t_*.c):
- 创建时选最紧凑的编码(compact encoding):
tryObjectEncoding()会依次尝试 int → embstr → raw; - 写入时检查阈值:元素个数或单个元素大小超过
*-max-listpack-entries/*-max-listpack-value等配置即触发转换; - 只升不降:一旦转为 dict/skiplist,即使后来删得只剩一个元素也不会降级回去(避免来回抖动,也简化实现);
- 转换是原子的一次性操作,期间该 key 的其他命令照常执行(单线程模型保证了这一点)。
这套机制的本质:用紧凑结构吃满 CPU 缓存与小内存红利,用标准结构保住大数据量的渐近复杂度,边界由配置驱动、运行时自动切换。
8. 本篇小结
回顾一下本篇的核心结论:
- SDS:指针前藏头部、低 3 位存类型、O(1) 长度、二进制安全;扩容"小于 1MB 翻倍、大于 1MB 加 1MB"。
- dict:双表 +
rehashidx实现渐进式 rehash,把 O(N) 搬迁摊薄到每一次操作;负载因子 1 扩 / 0.1 缩;SipHash 抗碰撞;指针低位藏元数据。 - zset:dict 管 O(1) 点查,skiplist 管范围与排名;span 字段免费送出 RANK 能力;1/4 概率升层。
- quicklist:外层双向链表 + 内层 listpack,两端 O(1)、中部可压缩,是"链表与数组折中"的典范。
- rax:路径压缩基数树 + listpack 存消息体,ID 有序即数据有序。
- redisObject:4bit type + 4bit encoding 的多态对象系统,配合阈值驱动的编码自动升级,构成 Redis 性能与内存双赢的总开关。
贯穿始终的三条设计哲学:
- 省内存:位域、packed 结构、按需选型、指针打标;
- 抗抖动:渐进式 rehash、惰性释放、只升不降;
- 缓存友好:listpack/embstr 的连续内存布局,把小数据彻底喂饱 CPU Cache。
思考题
sdsMakeRoomFor在扩容后如果类型发生变化(如 sdshdr8 → sdshdr16),返回的指针和原来的 sds 指针是什么关系?调用方为什么必须使用它的返回值?- 渐进式 rehash 期间,如果客户端一直没有任何请求(也没有 serverCron 触发搬迁),两张表的状态会如何?什么机制最终保证搬迁完成?
- zskiplist 的
backward指针为什么只有一层,而不像 forward 那样每层都有?如果补齐会发生什么? - 为什么 zset 要同时维护 dict 和 skiplist,而不是只用一个?如果让你砍掉一个,哪些 Redis 命令会受影响?
- quicklist 的
fill设为负数(如 -2,表示 4KB/节点)和设为正数(如 128 个元素/节点)分别适合什么业务场景? - Stream 的 Entry ID 若不要求单调递增,rax 方案还成立吗?需要怎么改造?
- embstr 编码的字符串执行 APPEND 后会变成什么编码?为什么说 embstr 是"只读"的?
- 试着估算:100 万个平均 30 字节、值为纯数字的 string key,分别用 int 编码和 raw 编码存储,内存差距有多大?(提示:别忘了 robj 头部和 SDS 头部)
下一篇预告:《Redis 源码带读 第4篇:持久化——RDB 快照与 AOF 日志》,我们将进入 fork() 与写时复制(Copy-On-Write)的世界。
思考题解答
1. sdsMakeRoomFor扩容后头信息变化(sdshdr8到sdshdr16),指针关系?
扩容机制:
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
size_t reqlen = sdslen(s) + addlen;
// 检查是否需要扩容
if (reqlen < sdsalloc(s)) return s;
// 根据新长度选择头类型
if (reqlen < SDS_MAX_SIZE) {
type = sdsReqType(reqlen); // SDS_TYPE_8/16/32/64
}
// 分配新内存
newsh = zmalloc(sizeof(newsh) + reqlen + 1);
// 复制数据
memcpy(newsh->buf, sh->buf, sdslen(s));
// 更新头信息
newsh->len = reqlen;
newsh->alloc = reqlen;
// 返回新的sds指针(指向buf)
return (char*)newsh->buf;
}
指针关系:
- sds指针指向
buf,不是头结构的开始 - 头结构在
buf之前(负偏移) - 这允许从sds指针反向访问头信息
2. 渐进式rehash迁移过程?
渐进式rehash步骤:
- 触发条件:
load_factor >= 1且BGSAVE未执行,或load_factor >= 5 - 分配新表:
ht[1] = malloc(sizeof(dictEntry*) * newsize) - 设置rehashidx:
rehashidx = 0 - 渐进迁移:每次操作字典时,迁移
ht[0]的rehashidx位置的bucket - 完成迁移:当
ht[0]的所有bucket都迁移完,释放ht[0]
迁移过程中的查询:
- 先查
ht[0],再查ht[1] - 插入操作只插入
ht[1] - 删除操作在两个表中都查找
3. zskiplist的backward指针为什么只有一层?
原因:
- 节省空间:backward指针用于倒序遍历,只需要在最底层(第1层)维护即可
- 实现简单:倒序遍历不需要跨层,只需要在底层链表上移动
- 性能考虑:倒序遍历的场景较少(如ZRANGE),不需要多层backward
forward指针在每层的原因:
- 快速跳转:forward指针用于快速定位,多层结构允许跳过中间节点
- 范围查询:ZRANGEBYSCORE等操作需要快速定位范围起点
- 性能保证:O(logN)的查找性能依赖多层forward指针
4. 为什么zset同时维护dict和skiplist?
dict的作用:
- O(1)的成员查找:
ZSCORE、ZSCORE等命令 - 存储成员到分值的映射
skiplist的作用:
- O(logN)的范围查询:
ZRANGE、ZRANGEBYSCORE等命令 - 支持有序遍历
- 支持rank计算
只维护一个的后果:
- 只有dict:范围查询需要O(N)扫描,性能差
- 只有skiplist:成员查找需要O(logN),不如dict的O(1)
内存开销:虽然增加了内存开销,但换来了更好的查询性能。
5. quicklist的fill参数含义?
fill参数的含义:
- 正数:每个quicklist节点最多存储
fill个元素 - 负数:限制节点大小为
-fill * 1024字节
示例:
fill = -2:每个节点最大2KB(4KB/节点)fill = 128:每个节点最多128个元素
适用场景:
- 小数据量(如配置项):
fill = -2(2KB),适合小数据 - 大数据量(如消息队列):
fill = 128,适合大批量操作
6. Stream的Entry ID单调递增?
Entry ID的结构:
- 主ID:时间戳(毫秒)
- 序列号:同一毫秒内的序号
单调递增的保证:
- 时间戳递增:使用系统时间,保证主ID递增
- 序列号递增:同一毫秒内的消息使用递增序列号
- 冲突处理:如果新消息的时间戳小于等于最后一条消息,序列号+1
代码实现:
streamNextID(&stream->last_id, &id);
// 如果新ID <= last_id,自动+1
7. embstr字符串执行APPEND会怎样?
embstr的特性:
- embstr字符串是只读的
- 执行APPEND等修改操作会触发编码转换
转换过程:
- 检测到是embstr类型
- 分配新的raw类型内存
- 复制数据
- 释放旧的embstr内存
- 返回新的raw类型对象
为什么说embstr是"只读"的:
- embstr设计为不可修改,修改会触发编码转换
- 这简化了内存分配(一次性分配)
- 适合存储短且不变的字符串
8. 100万30字节string key的内存占用?
计算过程:
对象头(robj):
type:1字节encoding:1字节lru:4字节refcount:4字节*ptr:8字节- 总计:18字节(对齐后24字节)
SDS头(sdshdr8):
len:1字节alloc:1字节flags:1字节buf[]:30字节 + 1字节(结尾空字符)= 31字节- 总计:34字节
单个key内存:
- robj:24字节
- sdshdr8:34字节
- 总计:58字节(对齐后64字节)
总内存:
- 100万 × 64字节 = 64MB
- 加上dict开销(哈希表、entry指针):约80-100MB
优化建议:
- 使用整数编码:如果值是整数,可以用embint(16字节/对象)
- 使用共享对象:Redis预分配了0-9999的整数对象

浙公网安备 33010602011771号