Redis数据结构及底层实现

64 阅读14分钟

Redis数据结构及底层原理

Redis快的原因:

  • 内存数据库,所有操作都在内存上进行
  • 各数据类型的实现数据结构,使得进行操作时更快

Redis数据类型对应的实现数据结构

微信图片_20230220194105.png

SDS

Redis是用C语言实现的,但是SDS数据类型没有直接使用C语言的char*字符数组来实现,而是自己封装了一个名为简单动态字符串(simple dynamic string, SDS)的数据结构来表示字符串,Redis既然没有直接使用C语言的char数组实现,肯定是char数组存在一些缺陷,

char* 数组缺陷

  • 获取字符串长度O(N)
  • 数据操作不安全,例如strcat(a, b),需要实现评估复制后字符串长度,并提前分配好空间,不然会导致缓冲区溢出
  • 数组以"\0"结尾,导致不能存储图片、声音等二进制数据

SDS数据结构

企业微信截图_16768924822840.png

其中flags有sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64五种类型

SDS相比于char*优点

  • 获取数组长度为O(1)。SDS有专门记录数组长度的字段len
  • 二进制安全。SDS字节数组不需要用"\0"标识字符串结尾,可以保存文本数据,也可以保存任意格式的二进制数据。
  • 不会发生缓冲区溢出。SDS数据结构中包含了len,alloc变量,在对字符串进行操作的时候,会事先计算alloc-len,可以知道剩余空间的大小,这样对字符串做修改操作的时候,就可以知由程序判断缓冲区是否够用。在缓冲区不够用时,SDS会自动扩大SDS空间大小。在扩展SDS空间的时候,API不仅会为本次操作分配必要的空间,还会给SDS分配额外的【未使用空间】,这样在下次操作的时候API可以直接使用【未使用空间】,有效减少分配内存次数。
  • 节省内存空间。SDS一共有五种类型的结构体,可以根据不同的数据使用不同类型的结构体,并且还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed))(告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐)

链表

Redis的list数据类型底层实现之一就是链表。C语言本身是没有链表数据结构的,所以Redis自己设计了一个链表数据结构。

  • 链表节点数据结构
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;
  • 优点

    • 获取某个节点前驱后继节点时间复杂度O(1)
    • 获取链表头尾节点时间复杂度只需O(1)
    • 因为链表数据结构中提供了保存链表长度的数据结构len,所以获取链表长度节点数据结构为O(1)
    • listNode使用void*指针保存节点值,可以保存各种不同类型的值
  • 缺点

    • 内存不连续,无法充分利用CPU缓存
    • 当链表较长是,链表查询效率可能会很慢

压缩列表

压缩列表是Redis数据类型为list和hash的底层实现之一。

  • 当list只包含少量元素,并且每个元素都是小整数,或者长度比较段的字符串,那么Redis就会使用压缩列表作为list的底层实现。
  • 当一个hash存储只包含少量键值对,并且每个元素键和值都是小整数或者短字符串,那么Redis就会使用压缩列表作为hash键底层实现。

压缩列表数据结构

企业微信截图_16769470411946.png

  • zlbytes:记录整个压缩列表占用内存字节数;
  • zltail:记录尾部节点距离起始地址偏移量;
  • zllen:记录压缩列表包含节点数量;
  • zlend:标记压缩列表尾部节点,固定值OxFF(十进制255)
  • entry:压缩列表节点
    • prevlen:记录前一个节点的长度
    • encoding:记录当前节点数据类型及长度
    • data:记录当前节点存储数据

当我们往压缩列表插入数据时,压缩列表会根据数据是字符还是数字,以及他们的大小在prevlen和encoding这两个元素里保存不同的信息,这种根据数据类型和大小保存数据的思想,正式redis为了节省内存而采用的。

连锁更新

压缩列表节点prevlen字段记录的是上一个节点的长度,

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

假设一个压缩列表里每个节点的prevlen长度都在250-253之间,加入此时将一个长度大于254字节的节点插入到列表头结点,那么造成后面的节点prevlen存不下,从而会造成连锁更新,多米诺效应就此开始。

因此,压缩列表只会用于保存节点数量不多的小的整数或端的字符串的场景,这样即使发生连锁更新也是能接收。

哈希表

哈希表是一种保存键值对(key-value)的数据结构。hash表优点在于,它能以O(1)的复杂度快速查找数据。

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

    //键值对中的值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

哈希表节点结构中键值对中的值是一个联合体,值可以是一个指向实际值的指针,有也可以是无符号64位、有符号64位或double类的值,这么做的好处是可以节省内存空间,因为当值是整数或浮点数时,可以内嵌在dicEntry里,无需再用一个指针指向实际值,从而节省内存空间。

哈希冲突

随着哈希表数据不断增多,哈希冲突的可能性也会越高。Redis采用链式哈希,在不扩容哈希表的前提下,将具有相同哈希值的数据连接起来,以便这些数据在表中仍然可以被查到。

  • 链式哈希缺陷:随着链表长度增加,获取冲突位置某一数据时间复杂度接近(n)。要想解决这一问题,需要进行rehash

rehash

Redis会使用了两个全局哈希表进行rehash,正常请求会插入数据到哈希表1,随着数据增多,出发了rehash操作,这个过程可分为三步:

  • 给哈希表2分配空间,一般比哈希表1大2倍
  • 将哈希表1的数据迁移到哈希表2中;
  • 迁移完成后,释放哈希表1的空间,并把哈希表2设置为哈希表1,然后再新创建一个哈希表2,方便下次rehash。

rehash时的结构体:

typedef struct dict {//两个Hash表,交替使用,用于rehash操作
    dictht ht[2]; 
    …
} dict;

上面过程看似简单,实则第二步有很大问题,如果哈希表1的数据非常多,那么在迁移到哈希表2的时候,因为涉及大量的数据拷贝,此时可能会对redis造成阻塞,无法服务其他请求。

渐进式rehash

为了避免数据量大时,rehash在拷贝数据时耗时,影响redis性能,所以redis采用渐进式rehash,也就是rehash不是一次完成的,而是分多次迁移。步骤如下:

  • 给哈希表2分配空间;
  • 在rehash期间,每次哈希表元素进行增、删、改、查操作时,Redis除了会执行对应的操作外,还会顺序将哈希表1中索引位置上的所有key-value迁移到哈希表2上;
  • 随着操作数量增多,最终哈希表1上的值都会迁移到哈希表2上。
  • 在进行rehash时,新增的key-value会被保存到哈希表2上,这样哈希表1的数据只会减少,随着rehash进行,最终哈希表1就会变成空表。

rehash触发条件

rehash的触发条件跟负载因子有关。

负载因子计算公式:

负载因子 = 哈希表已保存节点数量 / 哈希表大小

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

  • 当负载因子大于等于1,并且Redis没有在执行bgsave命令或者bgrewiteaof命令,也就是没有执行RDB快照或没有进行AOF重写的时候,就会进行rehash操作。
  • 当负载因子大于等于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数组真正类型取决于encoding:

  • 如果encoding属性值为int16,那么contents就是一个int16类型的数组
  • 如果encoding属性值为int32,那么contents就是一个int32类型的数值,int64位同理

整数集合升级操作

当保存低位存储的contents数组,例如int16,此时如果插入一个int32位的数字,就会引起扩容,整数集合这种根据数据类型来分片对应数组类型的设计,主要是为了节省内存资源。

跳表

跳表是Zset的底层实现,其优点在于能支持平均O(logN) 复杂度的节点查找。

Zset对象是唯一一个同时使用了两个数据结构来实现的Redis对象,一个是调表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效的单个key查询。其数据结构如下:

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

跳表节点数据结构

链表查找元素的时候需要一一遍历,时间复杂度为O(n),于是就出现了跳表,它是在链表基础上改进过来的,实现了一种多层的有序链表,其好处在于能快速定位数据。

  • 跳表示意图如下:

微信图片_20230222203627.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数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到下一层接着查找。

微信图片_20230222205045.png

查找SDS值为abcd,权重为4的节点过程:

  • 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
  • 但是该层上的下一个节点是空节点,于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
  • 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
  • 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。

跳表节点层数设置

  • 跳表相邻两层的节点数量比例会影响跳表的查询性能。(这就像平衡树一个意思,如果跳表相邻两层节点数的比例严重失衡,查询复杂度就是O(n)了)

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

那么怎样才能维持相邻两层的节点数量的比例为2:1呢?

  • 如果采用新增或删除节点时,来调整跳表节点来维持比例的话,会带来额外开销。

  • Redis采用一种巧妙的方法,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层节点数量比例为2:1的情况。

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

quicklist

quicklist的结构体跟链表的结构体类似,豆瓣了表头和表尾,区别在于quicklist的节点是quicklistNode。

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

quicklist示意图如下:

微信图片_20230222210839.png

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

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

listpack

  • listpack结构设计

微信图片_20230222211251.png

  • listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。

  • listpack节点结构

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

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