Redis 内置了多种数据结构,这些数据结构用于构建我们使用的数据对象,同时它们也在 Redis 内部使用。本文介绍简要介绍这些数据结构的实现原理。

字符串

C 字符串是不可修改的,而且获取字符串长度需要遍历整个字符串,只能用于一些简单的场景,如打印日志。Redis 使用简单的动态字符串 (simple dynamic string,SDS) 作为默认字符串的数据结构。一个 SDS 的结构如下:

struct sdshdr {
    // buf 数组中已使用的字节数
    int len;
    // buf 数组中空闲的字节数
    int free;
    // 保存字符串值的数组
    char buf[];
}

buf 以字节进行存储,因此除了存储文本数据外,还可以用于存储图像等二进制数据。为了兼容 C 中的字符串 API,buf 遵循了以 \0 结尾。每次修改 buf 数组时都会记录最新的 len 属性,因此能以 O(1) 复杂度获取字符串长度,而不用遍历整个字符串。

SDS 的动态性体现在 buf 的动态伸缩上。在进行字符串增长操作时,预留的 free 字节数可能不够用,要重新进行内存分配,并且分配的空间不是刚好够用就行,而要比实际需要的大。在进行字符串缩短操作时,不会真正进行内存空间释放,而只更改 len 和 free 进行记录。buf 数组的这种伸缩策略,可以避免频繁内存分配造成的开销,提升修改字符串长度操作的性能。

用途

  1. 保存数据库的字符串值,可能是键或者值
  2. 用作内部的缓冲区,如 AOF 持久化缓冲区,客户端输入缓冲区

链表

链表(list)是一种基本的数据结构,它的每个节点存储了相邻节点的链接,因此可以进行高效的节点增删操作。据不同的实现,链表可能是单向的、双向的或者环形的等。Redis 中链表节点、链表的结构如下:

typedef struct listNode {
    // 前置节点
    struct listNode * prev;
    // 后置节点
    struct listNode * next;
    // 节点的值
    void * value;
} listNode;

用途

  • 实现列表对象
  • 用于发布与订阅、慢查询、监视器等功能
  • 保存客户端状态信息,客户端输出缓冲区

字典

字典(dict)又称为映射,通常的实现方式是使用树或散列表(哈希表)。这两种实现方式也是数据库索引中常讨论的话题,如时间复杂度、范围查询等方面的比较。Redis 使用散列表来实现字典。

程序设计实现一书中认为,程序员经常重要的数据结构包括:数组、表(链表)、树和散列表,其中散列表是一项伟大的发明,它综合了数组、链表和一些数学方法。数组是指散列表数组,它在字典的结构中定义:

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

散列表节点的结构如下。节点中有键、值和 next 指针。

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

节点的位置由键的散裂值决定,散列值通过散列函数计算得到。当不同的键获得了同样的散列值,便发生了散列冲突。为了减少冲突,好的散列函数应尽可能满足随机的特点,而无视输入的规律。Redis 中采用的是 MurmurHash 算法。

然而想要完全避免散列冲突是不能的,在发生冲突时需要予以处理。常见的处理方法有链接法和开址法。链接法中,将同一位置的节点连接成一个链表;开址法中会不断探查散列表,直到找到空槽。两种处理方式的特点如下:

链接法

  • 编写简单
  • 负载因子可大于 1
  • 需要空间存储指针
  • 链表的查询性能不好,但好的散列算法可使每个链表的长度相对均匀

开址法

  • 探查方法多,有线性探查、二次探查和双重散列等
  • 负载因子不会大于 1
  • 删除元素困难,会导致无法查询
  • 探查会出现聚集问题
  • 不用存储指针节约了空间,潜在地降低了冲突

Redis 中采用拉链法来处理散列冲突。链表中新加入的节点位于旧节点的前面,这样可以保证性能,也很容易编写。当负载因子足够大时触发扩展,即 rehash。一次完成 rehash 开销很大,所以这个操作是渐进式的。由于同时存在两个散列表,设计查询、删除和更新的操作在这两张表上进行,新增操作在新表上进行。

用途

  • 作为 k-v 数据库的底层
  • 实现哈希对象和集合对象

跳跃表

跳跃表(skip list)对链表进行扩展,每个节点维护多个通往后面节点的指针,节点按编号大小有序排列。

跳跃表是一种概率性的数据结构。每一层的元素以一定的概率出现在上一层中,因此顶层的链表最稀疏,底层的链表包含每一个元素。容易理解,查询元素时从顶部开始,向下逐层缩短查询范围,到达底部时再进行少量的顺序查询。

跳跃表

用途

  • 实现有序集合对象
  • 集群中的内部数据结构

整数集合

整数集合(intset)是用来保存整数的集合,支持多种宽度的整数类型,包括 int16_t, int32_t 和 int64_t。底层实现基于一个数组,在该数组中,整数有序排列且不重复。

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

注意到数组 contents 的类型为 int8_t,多个 int8_t 元素即可构成我们想要的 encoding 编码,如 int16_t 由两个相邻的 int8_t 构成。编码方式由在最大宽度的元素决定,当新插入的元素的宽度大于当前的编码方式时,整数集合会升级编码:将所有元素按照新的编码方式保存。数组的插入操作比较低效,只有在元素数量不太多时才会使用这种数据结构。

保存集合元素的数组具有如下特性:

  • 动态扩展。
  • 多种编码。根据元素大小,自动升级编码,支持多种整数类型。
  • 有序。查找元素时使用二分法。

用途

  • 实现集合对象

压缩列表

压缩列表(ziplist)是一种连续内存的数据结构,包含任意数量的节点,每个节点存储一个整数或一个字节数组。在前文中,整数集合的每个元素有统一的编码,这使得设计十分简单,但元素数量很多时可能会造成内存的浪费。压缩列表可以节约内存,这也意味着该数据结构的设计会更加复杂。

每个压缩列表节点由三部分组成:

  • previous_entry_length 前一个节点的长度。视前一个节点长度的大小,该属性可能为 1 字节或多个字节。通过 previous_entry_length 可以反向遍历压缩列表。
  • encoding 数据类型与长度。标识位记录编码,其余位记录 content 字节长度。可能为 1 字节或多字节长。
  • content 节点值。类型与长度由 encoding 决定。

previous_entry_length 所占的字节数与前一个节点的长度有关。删除或新增节点可能造成下一个节点的 previous_entry_length 属性的长度发生变化,需要重新对其分配内存,继而可能形成多米诺骨牌效应,随后的多个节点都进行空间扩展,即发生连锁更新。连锁更新性能低下,但发生的条件苛刻:连续多个节点的长度处于即将引发 previous_entry_length 变化的范围内。

用途

  • 实现列表对象、有序集合地点对象和哈希对象

参考

  1. Redis 设计与实现
  2. Redis内部数据结构详解
  3. 算法导论