Redis底层数据结构

215 阅读16分钟

本文参考小林coding图解Redis 链接

Redis的数据结构

首先,我们可以将整个redis数据库看作一个超大的Map<String, Type>,其中Type就是Redis的数据类型:String、List、Set、Hash和ZSet等,其中每个数据类型内部实际上是基于几种数据结构来进行存储的,比如在Redis的最新版本中,String使用SDS,List使用QuickList,Hash使用ListPack和哈希表,Set使用哈希表和整数集合,Zset使用ListPack和跳表,下图展示的是Redis数据结构的演变过程 image.png 本文将介绍Redis底层用到的所有数据类型SDS、双向链表、压缩列表、哈希表、跳表、整数集合、quicklist、listpack

SDS

SDS相当于是对C语言字符串的改进,C语言中字符串是以char数组的形式进行存储的,字符串指针会指向这个char数组开头的位置,然后以\0这个特殊字符来标识char数组结束的位置,这样做主要有两个缺点:1. 不能存字符\n;2. 查看字符串的长度是O(n)的时间复杂度。如果让我来解决这个问题,我估计会参考java进行如下设计:

final class MySDS{
    char[] arr;
    int len;  // 存储字符串的长度
}

以上的设计会导致一个字符串无法复用,比如我append一些字符之后,我们仍需要重新实例化一个char数组来存储这个append之后的字符,这就会造成额外的性能开销。

SDS 的结构设计

SDS的设计如下:

image.png 从图中可以可以看出,SDS主要包括四个属性:len、alloc、flags和buf

  • len,记录了字符串长度
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容),这样分配会存在一些未使用空间,下次再操作时就可以直接使用,从而减少内存分配次数。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,其中sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,sdshdr32 则都是 uint32_t,为了能灵活保存不同大小的字符串,从而有效节省内存空间__attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐
    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[];
    };
    
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

链表

List之前的实现就是通过双向链表,其定义如下:

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

typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
    //链表节点数量
    unsigned long len;
} list;

image.png

缺点:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存
  • 保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。

ziplist

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

缺点:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。

Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

压缩列表由连续内存块组成的顺序型数据结构,有点类似于数组。

image.png

  • zlbytes,记录整个压缩列表占用对内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

每个节点的结构如下:

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降

哈希表

哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。

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

typedef struct dictEntry {
    //键值对中的键
    void *key;
  
    //键值对中的值 union的大小由最大的成员的大小决定
    // 当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里
    // 无需再用一个指针指向实际的值,从而节省了内存空间
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

![image.png](p3-juejin.byteimg.com/tos-cn-i-k3…

rehash

Redis定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表。 在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。

image.png 随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

  • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

渐进式 rehash

渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;
  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

rehash 触发条件

触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。

image.png

  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

整数集合

整数集合是 Set 对象的底层实现之一。本质上是一块连续内存空间

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

虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如:

  • 如果 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;

整数集合的升级操作

就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。但是不支持降级操作

image.png

跳表

Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。既能进行高效的范围查询,也能进行高效单点查询。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表

image.png

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS
  • 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

跳表的层数设置

跳表的相邻两层的节点数量的比例会影响跳表的查询性能。

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。 Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。

具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数

quicklist

quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

image.png 在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。

通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;      //quicklist的链表头
    //quicklist的链表头
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;

typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;      //quicklist的链表头
    //quicklist的链表头
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;

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

listpack

Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

image.png

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题

ziplist->quicklist->listpack

极客时间 06 | 从ziplist到quicklist,再到listpack的启发 写得特别好!

ziplist

ziplist主要是为了解决双向链表无法充分利用CPU缓存的问题,类似于一个压缩的数组。但是存在两个问题:查找复杂度高、连锁更新。

image.png

  • 查找复杂度高 ziplist可以用来存储只有少量数据的Hash或Zset对象,可以在redis.conf中设置节点数量:hash-max-ziplist-entries 和 zset-max-ziplist-entries。如果需要查找一个元素,就需要从头到尾进行遍历。

  • 连锁更新 当一个元素插入后,会引起当前位置元素新增 prevlensize 的空间。而当前位置元素的空间增加后,又会进一步引起该元素的后续元素,其 prevlensize 所需空间的增加。

    • 插入元素时首先需要计算这个元素所占的空间,然后判断其插在什么位置,如果不是末尾,那么就需要计算当前插入位置A的前一个元素的prvlen和prevlensize(不同大小的prevlen会采用不同的数据类型进行存储,因此长度会不同)。假设插入位置的后一个元素为A,A本身preLen可能只占1字节,但是新插入元素占用了超过一个字节的长度,那就需要申请4个字节的空间来存储这个长度。
    • 此外,如果A的长度本来是一个字节的,但是加了4个字节长度之后无法用一个字节来表示长度了,那么A后面元素的preLen也需要申请额外4个字节的空间。

quicklist

quicklist结合了链表和ziplist的优势,一个quciklist就是一个链表,但是链表中的节点是一个ziplist,相当于限制了ziplist的长度来避免大量的连锁更新问题

image.png

typedef struct quicklist {
    quicklistNode *head;      //quicklist的链表头
    quicklistNode *tail;      //quicklist的链表尾
    unsigned long count;     //所有ziplist中的总元素个数
    unsigned long len;       //quicklistNodes的个数
    ...
} quicklist;


typedef struct quicklistNode {
    struct quicklistNode *prev;     //前一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    unsigned char *zl;              //quicklistNode指向的ziplist
    unsigned int sz;                //ziplist的字节大小
    unsigned int count : 16;        //ziplist中的元素个数 
    ...
} quicklistNode;

插入新元素时,会判断插入位置的ziplist是否在插入新元素之后不超过 8KB,或是插入后ziplist 里的元素个数是否满足要求。如果一个条件满足就会创建新的quicklistNode(对应一个新的ziplist)

listpack

listpack彻底解决了连锁更新的问题,不是在当前元素存储前一个元素的

image.png

image.png

image.png

image.png

image.png

listpack只存储当前的长度,它怎么进行从右向左或从左到右的遍历呢?

  • 从左向右:在从左向右遍历时,首先会根据encoding得到编码类型和数据的长度之和,跳转到len部分(encoding长度+数据长度)读取它本身的数据长度(根据encoding长度+数据长度的和来判断数据长度需要几个字节来存储,然后就可以确定数据长度字段的长度),具体过程如下: image.png
  • 从左向右:关键点在于如何获取到数据长度字段entry-len的长度。entry-len 的编码方式了。entry-len 每个字节的最高位,是用来表示当前字节是否为 entry-len 的最后一个字节。其中entry-len的第一个字节以0开头,剩下的字节都以1开头。

image.png