AIGC标识 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 结尾)有四个致命缺陷:

  1. 获取长度 O(N)strlen 要扫描整个字符串;
  2. 非二进制安全(Binary Safe):内容中出现 \0 就会被截断,无法存图片、序列化后的对象等;
  3. 缓冲区溢出风险strcat 前需要调用者自己保证空间够用;
  4. 频繁修改导致频繁内存重分配:每次增长/缩短都要 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);
}

规则总结:

  1. 触发时机:增删改查都会先调用 _dictRehashStep,顺路搬一个桶;
  2. 查找路径:rehash 期间先查 ht_table[0],没找到再查 ht_table[1]
  3. 新元素一律插入 ht_table[1]:保证 0 号表只减不增,最终必然搬完;
  4. 定时任务兜底:serverCron 中若发现 rehash 进行中且超时(dictRehashMilliseconds(d, 1)),会主动多搬一些,保证大表也能收敛;
  5. 迭代器约束:有安全迭代器存在时暂停渐进搬迁,保证迭代语义稳定。

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 的核心改进:

  1. 移除 prevlen:ziplist 的每个元素都存前一个元素的长度(用于倒序遍历),导致连锁更新问题
  2. 前向遍历:listpack 通过在每个元素末尾存储 backlen(前一个元素的总长度),实现倒序遍历
  3. 无连锁更新:修改一个元素只影响相邻元素,不会级联更新

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

  1. 创建时选最紧凑的编码(compact encoding):tryObjectEncoding() 会依次尝试 int → embstr → raw;
  2. 写入时检查阈值:元素个数或单个元素大小超过 *-max-listpack-entries / *-max-listpack-value 等配置即触发转换;
  3. 只升不降:一旦转为 dict/skiplist,即使后来删得只剩一个元素也不会降级回去(避免来回抖动,也简化实现);
  4. 转换是原子的一次性操作,期间该 key 的其他命令照常执行(单线程模型保证了这一点)。

这套机制的本质:用紧凑结构吃满 CPU 缓存与小内存红利,用标准结构保住大数据量的渐近复杂度,边界由配置驱动、运行时自动切换。


8. 本篇小结

回顾一下本篇的核心结论:

  1. SDS:指针前藏头部、低 3 位存类型、O(1) 长度、二进制安全;扩容"小于 1MB 翻倍、大于 1MB 加 1MB"。
  2. dict:双表 + rehashidx 实现渐进式 rehash,把 O(N) 搬迁摊薄到每一次操作;负载因子 1 扩 / 0.1 缩;SipHash 抗碰撞;指针低位藏元数据。
  3. zset:dict 管 O(1) 点查,skiplist 管范围与排名;span 字段免费送出 RANK 能力;1/4 概率升层。
  4. quicklist:外层双向链表 + 内层 listpack,两端 O(1)、中部可压缩,是"链表与数组折中"的典范。
  5. rax:路径压缩基数树 + listpack 存消息体,ID 有序即数据有序。
  6. redisObject:4bit type + 4bit encoding 的多态对象系统,配合阈值驱动的编码自动升级,构成 Redis 性能与内存双赢的总开关。

贯穿始终的三条设计哲学:

  • 省内存:位域、packed 结构、按需选型、指针打标;
  • 抗抖动:渐进式 rehash、惰性释放、只升不降;
  • 缓存友好:listpack/embstr 的连续内存布局,把小数据彻底喂饱 CPU Cache。

思考题

  1. sdsMakeRoomFor 在扩容后如果类型发生变化(如 sdshdr8 → sdshdr16),返回的指针和原来的 sds 指针是什么关系?调用方为什么必须使用它的返回值?
  2. 渐进式 rehash 期间,如果客户端一直没有任何请求(也没有 serverCron 触发搬迁),两张表的状态会如何?什么机制最终保证搬迁完成?
  3. zskiplist 的 backward 指针为什么只有一层,而不像 forward 那样每层都有?如果补齐会发生什么?
  4. 为什么 zset 要同时维护 dict 和 skiplist,而不是只用一个?如果让你砍掉一个,哪些 Redis 命令会受影响?
  5. quicklist 的 fill 设为负数(如 -2,表示 4KB/节点)和设为正数(如 128 个元素/节点)分别适合什么业务场景?
  6. Stream 的 Entry ID 若不要求单调递增,rax 方案还成立吗?需要怎么改造?
  7. embstr 编码的字符串执行 APPEND 后会变成什么编码?为什么说 embstr 是"只读"的?
  8. 试着估算: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步骤

  1. 触发条件load_factor >= 1BGSAVE 未执行,或 load_factor >= 5
  2. 分配新表ht[1] = malloc(sizeof(dictEntry*) * newsize)
  3. 设置rehashidxrehashidx = 0
  4. 渐进迁移:每次操作字典时,迁移 ht[0]rehashidx 位置的bucket
  5. 完成迁移:当 ht[0] 的所有bucket都迁移完,释放 ht[0]

迁移过程中的查询

  • 先查 ht[0],再查 ht[1]
  • 插入操作只插入 ht[1]
  • 删除操作在两个表中都查找

3. zskiplist的backward指针为什么只有一层?

原因

  1. 节省空间:backward指针用于倒序遍历,只需要在最底层(第1层)维护即可
  2. 实现简单:倒序遍历不需要跨层,只需要在底层链表上移动
  3. 性能考虑:倒序遍历的场景较少(如ZRANGE),不需要多层backward

forward指针在每层的原因

  1. 快速跳转:forward指针用于快速定位,多层结构允许跳过中间节点
  2. 范围查询:ZRANGEBYSCORE等操作需要快速定位范围起点
  3. 性能保证:O(logN)的查找性能依赖多层forward指针

4. 为什么zset同时维护dict和skiplist?

dict的作用

  • O(1)的成员查找:ZSCOREZSCORE等命令
  • 存储成员到分值的映射

skiplist的作用

  • O(logN)的范围查询:ZRANGEZRANGEBYSCORE等命令
  • 支持有序遍历
  • 支持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:时间戳(毫秒)
  • 序列号:同一毫秒内的序号

单调递增的保证

  1. 时间戳递增:使用系统时间,保证主ID递增
  2. 序列号递增:同一毫秒内的消息使用递增序列号
  3. 冲突处理:如果新消息的时间戳小于等于最后一条消息,序列号+1

代码实现

streamNextID(&stream->last_id, &id);
// 如果新ID <= last_id,自动+1

7. embstr字符串执行APPEND会怎样?

embstr的特性

  • embstr字符串是只读的
  • 执行APPEND等修改操作会触发编码转换

转换过程

  1. 检测到是embstr类型
  2. 分配新的raw类型内存
  3. 复制数据
  4. 释放旧的embstr内存
  5. 返回新的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的整数对象
posted @ 2026-09-04 17:26  IcarusLee  阅读(4)  评论(0)    收藏  举报