Redis 数据结构

237 阅读15分钟

1、概述

1.1 数据类型与数据结构的关系

1.2 Redis 如何存放键值对

Redis 的键值对数据是通过哈希表来保存的,这使得查找键值对仅需要 O(1) 的时间复杂度。

哈希表就是一个数组,数组中的元素叫做哈希桶,哈希桶存放指向键值对的指针(dictEntry*)。而键值对也是通过 void* key 和 void* value 指针指向实际的 key 和 value。

  • redisDb:表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针。
  • dict:存放了 2 个哈希表,正常情况下都是用哈希表-1,哈希表-2 在 rehash 的时使用。
  • dictht:表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个 dictEntry 的指针。
  • dictEntry:表示哈希表节点的结构,结构里存放了 key 和 value 指针。
  • key:指向 String 对象。
  • value:指向 String、Hash、List、Set、ZSet 等数据结构。

1.3 Redis 对象

Redis 中的每个对象都由 redisObject 表示,而 key、value 都是直接指向 redisObject。redisObject 如下图所示:

  • type:标识对象类型,如:String、Hash、List、...
  • encoding:标识底层数据结构,如:SDS、QuickList、HashTable、...
  • ptr:指向底层数据结构的指针。

2、SDS

SDS:Simple Dynamic String,简单动态字符串,是 Redis 定义的一种可变长字符串。

2.1 C 语言字符串问题

Redis 是使用 C 语言开发的,C 语言中字符串是通过 char* 字符数组表示,那么为什么还要自定义字符串呢?

主要因为 char* 字符串是通过指向字符数组起始位置,结尾是通过 '\0' 标识的,这就会引发一些问题:

  1. 字符串内容不能包含 '\0',也不能保存二进制数据,如图片、音频、视频等。
  2. C 语言规定的字符串操作函数不安全,容易造成缓冲区溢出。
  3. 获取字符串长度需要运算,时间复杂度为 O(n)。

2.2 SDS 结构

Redis 推出 SDS 就是为了解决了这些问题,首先需要了解以下 SDS 的数据结构。

  • len:字符串长度。
  • alloc:分配给字符数组的空间长度。
  • flags:SDS 类型,提供了 5 种类型(sdshdr5、sdshdr8、sdshdr16、sdshdr32 以及 sdshdr64)。
  • buf[]:字符数组,用来保存实际数据。

2.3 SDS 如何解决问题

  1. 字符串长度:SDS 结构加入了 len 成员变量,获取字符串长度只需要返回 len 的值即可,时间复杂度为 O(1)。

  2. 二进制安全:SDS 不再需要 '\0' 来标识字符串结尾,而是通过 len 变量保存字符串长度,使得 SDS 不仅可以保存文本数据,还可以保存任意格式的二进制数据。

  3. 缓冲区溢出:SDS 通过 alloc 和 len 变量,可以通过 alloc - len 获取剩余可用空间大小,因此再进行字符串操作时,可以判断缓冲区是否足够。

2.4 扩容

Redis 允许 SDS 在运行时根据需要自动调整其分配的内存大小。这种机制使得 SDS 能够高效地处理字符串的增长和缩减,同时减少内存的浪费和复制操作。以下是 SDS 动态扩容的工作原理:

  1. 预分配内存:SDS 在初始化或扩容时,会预先分配额外的内存空间。这样可以减少因为字符串增长而导致的内存分配。
  2. 扩容策略:当 SDS 中字符串长度超过了当前申请的字节数时,SDS 会执行扩容操作。假设扩展后的字符串大小为 x,若 x 小于 1M,则申请的空间为 2*x + 1。若 x大于 1M,则申请的空间为 x + 1M + 1
  3. 惰性空间释放:SDS 在缩减操作时,不会立即释放内存。这是为了保留未使用的空间,以便后续字符串增长可以直接使用,避免频繁的内存分配。
  4. 内存碎片管理:由于 SDS 的动态扩缩容,可能会导致内存中出现碎片。为了管理这些碎片,SDS 提供了一些内存碎片管理机制,如在一定条件下将 SDS 的内存空间压缩到实际需要的大小、在 Redis 重新启动时清理无用的内存。
  5. 编码转换:SDS 支持多种编码方式(如 sdshdr5sdshdr8sdshdr16...),可以根据字符串的长度来选择合适的编码方式,可以在不同长度的字符串进行转换,来优化内存的使用。

扩容相关代码:

hisds hi_sdsMakeRoomFor(hisds s, size_t addlen) {
    // ...
    // 1. 判断剩余空间是否足够,若足够,则无需扩展
    if (avail >= addlen)
        return s;
    // 2. 获取长度
    len = hi_sdslen(s);
    sh = (char *)s - hi_sdsHdrSize(oldtype);
    // 3. 计算扩展后长度
    newlen = (len + addlen);
    // 3.1 若新长度 < 1M,则分配 2*newlen
    if (newlen < HI_SDS_MAX_PREALLOC)
        newlen *= 2;
    // 3.2 若新长度 >= 1M,则分配 newlen + 1M
    else
        newlen += HI_SDS_MAX_PREALLOC;
    // ...
}

2.5 SDS 类型

SDS 为了灵活保存不同大小的字符串,从而节省内存空间,提供了 5 种类型,分别是:

SDS 类型处理字符串最大长度len 类型alloc 类型
sdshdr5<= 32 Byte
sdshdr833 Byte ~ 64 Byteuint8_tuint8_t
sdshdr1665 Byte ~ 16383 Byteuint16_tuint16_t
sdshdr3216384 Byte ~ 4194303 Byteuint32_tuint32_t
sdshdr64>= 4194304 Byteuint64_tuint64_t

示例:sdshdr16

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc; 
    unsigned char flags;
    char buf[];
}

优化对齐:是一种编译器优化技术,用于改善数据结构的内存对齐情况,从而提高访问数据的效率。

内存对齐:数据在内存中按照特定的边界(通常是 4 字节或 8 字节)对齐存储,这样处理器就可以更快地访问这些数据。

为了节省内存空间,还使用了编译优化来节省内存空间,即在 struct 声明 __attribute__ ((__packed__)),告诉编译器取消在编译过程中的优化对齐,按照实际占用字节数进行对齐。

3、ZipList

ZipList:是一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码。

3.1 ZipList 结构

  • zlbytes:整个 ZipList 占用对内存字节数。
  • zltail:尾节点距离起始地址由多少字节。
  • zllen:包含的节点数量。
  • zlend:结束点,固定值 0xFF(十进制255)。

由于 ZipList 独特的结构,对于第一个元素和最后一个元素查找的复杂度为 O(1),其余元素均需要顺序查找,复杂度为 O(n)。

3.2 Entry 结构

1)prevlen:前一个 Entry 长度,可以根据 prevlen 进行从后向前遍历。

prevlen 的大小与前一个节点长度有关:

  • 前一个节点长度 < 254 Byte,prevlen = 1 Byte
  • 前一个节点长度 >= 254 Byte,prevlen = 5 Byte

2)encoding:当前 Entry 实际数据的类型和长度。

encoding 的取值:

  • 当前节点的数据是整数:encoding 会使用 1 Byte 进行编码。
  • 当前节点的数据是字符串,encoding 会使用 1 Byte/ 2 Byte/ 5 Byte 进行编码。
encoding 编码encoding 长度data 类型
00 xxxxxx1 Byte最大长度为 63 的字节数组
01 xxxxxx xxxxxxxx2 Byte最大长度为 2^14-1 的字节数组
10 xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx5 Byte最大长度为 2^32-1 的字节数组
1100 00001 Byteint16 整数
1101 00001 Byteint32 整数
1110 00001 Byteint64 整数
1111 00001 Byte24 位整数
1111 11101 Byte8 位整数
1111 xxxx1 Byte-

3)data:当前 Entry 的实际数据。

3.3 连锁更新

连锁更新:在新增/修改元素时,引发的多个 Entry 的 prevlen 同时更新的情况。

示例:ZipList 中存在多个 prevlen 在 250 ~ 253 之间的 Entry,在这些 Entry 前添加一个 prevlen 大于 254 的 Entry。

  1. 新增:在头部新增一个 Entry,由于下一个 Entry1 原本在头部,其 prevlen=0,新增后,prevlen = 5。
  2. 更新:Entry1 长度在增加 5 后,超过 254,Entry2 的 prevlen 从 1 -> 5,也超过了 254,继续更新。
  3. 结束:直到 Entry 更新完成,结束新增操作。

连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这会导致 ZipList 性能降低。因此 ZipList 只适用于节点数量较少的场景。

4、HashTable

HashTable:是 Redis 中保存键值对(key-value)的数据结构。

哈希表中的 key 是唯一的,可以根据 key 找到与之对应的 value,并能够以 O(1) 的时间复杂度查询数据。

哈希表通过对 key 进行 hash() 运算,得到在表中对应的索引,从而根据索引快速查找数据。但随着数据的增多,可能会出现哈希冲突。

4.1 哈希冲突

哈希冲突:不同 key 经过 hash() 得到了相同的哈希值。

解决哈希冲突问题通常有以下方法:

  1. 链地址法:在哈希表的每个槽(slot)维护一个链表,将所有映射到该槽的键值对都添加到链表末尾。
  2. 开放寻址法:当发生冲突时,算法会寻找哈希表中的下一个空闲位置来存储该键值对。
  3. 再哈希法:使用另一个哈希函数重新计算哈希值,如果再次发生冲突,则继续使用不同的哈希函数,直到找到一个空闲槽。

Redis 的哈希表解决哈希冲突应用了链地址法,被分配到同一个哈希桶上的多个节点可以用这个单向链表连接起来。

4.2 哈希表设计

dict 源码:

typedef struct dict {
    // ...
    dictht ht[2];  // 两个哈希表交替使用
    // ...
}

dictht 的源码:

typedef struct dictht {
    dictEntry **table;  // 哈希表数组
    unsigned long size;   // 哈希表大小
    unsigned long sizemask;  // 哈希表大小掩码,用于计算索引值
    unsigned long used;  // 哈希表已有的节点数量
}

dictEntry 的源码:

typedef struct dictEntry {
    void *key;  // 键
    union {
        void *val; // 指向实际数据的指针
        uint64_t u64; // 64 位无符号整数
        int64_t s64; // 64 位有符号整数
        double d; // 浮点数
    } v;  // 值
    struct dictEntry *next; // 指向下一个 dictEntry 指针
}

4.3 refresh

refresh:当已存储元素的数量与哈希表大小的比例达到一定阈值时,为了保持操作的效率,哈希表会进行扩容,即创建一个更大的新哈希表,并将所有元素重新映射(rehash)到新的哈希表中。

在正常服务阶段,插入的数据都会写入到 ht[0],而 ht[1] 并没有被分配内存。

随着数据的增多,就会触发 refresh 操作,以下是 refresh 的流程:

  1. 分配内存:为 ht[1] 分配空间,一般是 ht[0] 的 2 倍。

  2. 数据迁移:将 ht[0] 的数据迁移到 ht[1] 中。

  3. 结束处理:释放 ht[0],将 ht[0] 设置为 ht[1],为 ht[1] 创建一个空白的哈希表。

若 ht[0] 的数据量很大,在数据迁移时,会存在许多数据拷贝,在此期间可能会造成 Redis 阻塞,无法处理命令。

4.4 渐进式 refresh

渐进式 refresh:为了避免 refresh 期间造成长时间阻塞,Redis 会将数据迁移的工作分多次完成。

渐进式 refresh 流程:

  1. 分配空间:为 ht[1] 分配空间,一般是 ht[0] 的 2 倍。
  2. 数据迁移:在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将 ht[0] 中索引位置上的所有 key-value 迁移到 ht[1] 上。
  3. 结束处理:最终在某个时间点会把 ht[0] 的所有 key-value 迁移到 ht[1] 中,释放 ht[0],将 ht[0] 设置为 ht[1],为 ht[1] 创建一个空白的哈希表。

4.5 refresh 触发条件

refresh 的触发条件与 loadfactor 有关。

loadfactor = 哈希表已保存节点数量 / 哈希表大小

refresh 的触发条件:

  1. loadfactory >= 1,并且 Redis 没有执行 bgsave 或 bgrewriteaof,就会执行 refresh。
  2. loadfactory >= 5,不管 Redis 是否执行 bgsave 或 bgrewriteaof,都会执行 refresh。

5、IntSet

IntSet:是 Redis 中用于存储整数集合的数据结构。

5.1 IntSet 结构

IntSet 源码:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
}

1)encoding:编码方式,决定了 contents 的数据类型。

  • encoding = INTSET_ENC_INT16:contents 是一个 int16_t 类型的数组,数组中每一个元素都是 int16_t。
  • encoding = INTSET_ENC_INT32:contents 是一个 int32_t 类型的数组,数组中每一个元素都是 int32_t。
  • encoding = INTSET_ENC_INT64:contents 是一个 int64_t 类型的数组,数组中每一个元素都是 int64_t。

2)length:集合包含的元素数量。

3)contents:保存元素的数组,虽然是 int8_t 类型数组,但真正数据取决于 encoding。

5.2 IntSet 升级

IntSet 升级:新增元素时,若 new_type 比当前集合数据 type 长,按 new_type 扩展扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合中。

示例:向已存在 5 个元素 int16_t 的 IntSet 添加 65535。

  1. 65535 需要用 int32_t 保存,比 int16_t 更大,需要进行扩容。

  1. 计算需要的内存空间,32 * 6 - 16 * 5 = 112,申请内存。

  1. 将 IntSet 中原数据扩展为 int32_t,并放到正确位置。

注意:IntSet 虽然支持升级操作,但不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。

6、SkipList

SkipList:是 Redis 中多层级的有序链表数据结构。

6.1 SkipList 结构

SkipList 源码:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头指针、尾指针
    unsigned long length; // 跳表节点的数量
    int level; // 跳表的最大层数
}
typedef struct zskiplistNode {
    sds ele; // Zset对象的元素值
    double score; //元素权重值
    struct zskiplistNode *backward; // 后向指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 每层上的前向指针
        unsigned long span; // 跨度
    } level[]; // level[] 中的每一个元素代表跳表的一层
} zskiplistNode;

6.2 查询

查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 ele 和 score 来进行判断:

  • 如果当前节点的 score < 要查找的 score 时,跳表就会访问该层上的下一个节点。
  • 如果当前节点的 score = 要查找的 score 时,且当前节点的 ele < 要查找节点的 ele 时,会访问该层上的下一个节点。

如果都不满足或下一个节点为空,跳表会进入 level 数组里的下一层指针,沿着下一层指针继续查找。

6.3 层数设置

跳表相邻两层节点数量最理想比例为 2:1,这样查询复杂度就可以降到 O(logN)。

Redis 在创建节点时,随机生成每个节点的层数,并没有严格维持相邻两层节点数量为 2:1。

具体做法:创建节点时生成 [0~1] 的随机数。

  • 若随机数 < 0.25,则层数 + 1,继续生成下一个随机数。
  • 若随机数 > 0.25,结束,在计算得出的层数创建节点。

6.4 为什么使用跳表不用平衡树?

1)内存占用:

  • 平衡树:每个节点包含左子树、右子树 2 个指针。
  • 跳表:每个节点包含 1/(1-p) 个指针,在 Redis 中 p = 0.25,即平均 1.33 个指针。 跳表比平衡树内存占用更少。

2)范围查找:

  • 平衡树:找到指定范围的小值后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。
  • 跳表:找到指定范围小值后,对第 1 层链表进行若干步的遍历就可以实现。 跳表比平衡树范围查找更简单。

3)实现难度:

  • 平衡树:插入和删除操作可能引发子树的调整。
  • 跳表:插入和删除只需要修改相邻节点的指针。

跳表比平衡树操作实现更简单。

7、QuickList

QuickList:是 Redis 中双向链表 + 压缩列表的组合数据结构。

QuickList 中每个 quicklistNode 都存在一个 ZipList,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。

7.1 QuickList 结构

QuickList 源码:

typedef struct quicklist {
    quicklistNode *head; // 链表头
    quicklistNode *tail; // 链表尾
    unsigned long count; // ZipList 中的总元素个数
    unsigned long len;  // quicklistNodes 的个数
}
typedef struct quicklistNode {
    struct quicklistNode *prev; // 上一个 quicklistNode
    struct quicklistNode *next; // 下一个quicklistNode
    unsigned char *zl; // 指向的压缩列表
    unsigned int sz; // ziplist 的字节大小
    unsigned int count : 16; // ziplist中的元素个数
}

7.2 插入元素

向 quicklist 添加一个元素的时候,不会直接新建一个链表节点。而是检查插入位置的压缩列表是否能容纳该元素。

  • 能容纳,就直接保存到 quicklistNode 结构里的压缩列表。
  • 不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

8、ListPack

ListPack:是 Redis 推出用来替代 ZipList 的数据结构。

8.1 ListPack 结构

  • total_tytes:存储 ListPack 占据的字节大小。
  • num_elements:存储 ListPack 节点个数。
  • end:结束表示,值为 0xFF。

8.2 Entry 结构

  • encoding:元素的编码类型。
  • data:实际存放的数据。
  • len:encoding + data 的长度。