redis核心篇(一)-底层数据结构

1,197 阅读15分钟

1. 前言

在平时的面试中,可能经常会有童鞋被问到,redis的5种数据类型(String、List、Hash、Set、SortedSet)的底层数据结构是怎么实现的,可能有很多童鞋会抱怨,我又不是redis开发者,会在不同的业务场景中使用这五种不同的数据类型就行了,为啥还要去理解它的底层数据结构呀;其实当了解完这些数据结构的底层实现之后,你或许也可以想到redis为啥如此快的原因

2. 五种常用的数据类型

在redis中,5种常用的数据类型以及常用的场景如下:

  • String:缓存,计数器,分布式锁等
  • List:链表,队列,微博关主人时间轴
  • Set:去重,赞,共同好友
  • Hash:用户信息,hash表
  • Zset:排行榜功能,访问量排行榜,点击量排行榜

2.1 与六种底层数据结构的关系

image.png

  • String :存储数字的话,采用int类型编码,非数字,采用rwa编码.
  • List : 当元素字符串的长度小于64字节而且元素个数小于512时,采用zipList;否则采用likedList;该值在redis的配置文件redis.conf的配置为:
    • list-max-ziplist-entries 512
    • list-max-ziplist-value 64
  • hash: 底层数据结构可用使用zipList或者hashTab,当hash对象的键与值的长度都小于64字节时而且键值对的个数小于512个,采用zipList,其它情况,采用hashTa
  • set:Set对象的编码可以是 intset 或 hashta表,当保存的元素都是整形数字,而且元素个数小于配置范围的时候,则使用intset,否则使用hash表。
  • SortSet:底层数据结构可使用zipList或者skiplList,当元素个数小于128而且成员的长度小于64字节的时候,则使用zipList,否则使用skipList。该值在redis.con配置为:
    • zset-max-ziplist-entries 128
    • zset-max-ziplist-value 64

3. 六种底层数据结构的实现

3.1 SDS - 简单动态字符串

redis并没有直接使用C的字符串(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string)的抽象类型SDS用作Redis的默认字符串表示。除了用来保存数据库中的字符串,SDS还用做缓冲区,如AOF缓冲区,客户端输入的缓冲区。 SDS的定义如下:

struct sdshdr{
     // 记录buf 数组已经所使用的字节数量,既SDS的字符长度
     int len;
     // buf未使用字节数量
     int free;
    // 字节数组,保存字符串
     char buf[];
}
  • len表示字符串的长度
  • free表示字符数组,未使用的长度
  • buf 字节数组,保存字符串

3.1.1 空间预分配

空间预分配,用于优化redis对字符串的增长操作,当SDS的API对一个SDS需要修改,并且需要对SDS空间进行扩容时,redis不仅会对SDS分配锁必须的空间,而且还会为SDS分配额外的未使用空间;具体分配规则如下:

  • 当SDS修改之后的长度小于1MB的时候,那么redis将会为SDS分配一个与len一样的未使用空间,既当前free与len大小一样,当前数组buf的长度为:len*2+1;额外的一个字节为保存空字符串。
  • 当SDS修改之后的长度大于等于1MB的时候,那么redis将会为SDS分配1MB的未使用空间,既当然free大小为1MB,当前数组长度为:len+1M+1。

3.1.2 惰性空间释放

当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配

3.1.3 总结

  • 通过使用SDS,reids获取字符串的长度所需的时间复杂度可以从o(n)降到o(1),因为C需要遍历整个字符数组,遇到空格结束才能统计出来字符串的长度。即使我们对非常长的字符串反复使用STRLEN命令,也不会造成系统性能瓶颈,因为STRLEN命令的复杂度为o(1)。
  • 空间预分配, 操作字符串时候,避免了缓冲区溢出,SDS在操作字符串的时候,会预先检查SDS的空间是否足够,如果不够的话,sdscat会先扩展SDS空间,然后再执行拼接操作,避免C在修改字符串长度时,重复执行内存分配。

3.2 zipList - 压缩列表

zipList是由由连续内存块组成的顺序型数据结构,zipList有多个entry节点,每个entry节点可以存放整数或者字符串,其对应的C语言结构体和图示如下:

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

image.png

3.3 LinkedList-双向列表

链表提供了高效的节点重排能力,以及顺序访问方式,并且可以通过删除节点来灵活调整链表长度;链表和链表节点的实现如下:

image.png

// 链表节点
typedef struct listNode {
    // 前置节点
    struct listNode *pre;
    // 后置节点
    struct listNode *next;
    // 节点值
    void *value;
}
// 链表结构
typedef struct list{
    // 头节点 
    listNode *head;
    // 尾节点
    listNode *tail;
    // 链表所包含的节点数量
    unsigned long len;
     // 节点复制函数,复制链表所保存的值
    void (*dup) (void *ptr);
    // 节点释放函数,ß
    void (*free) (void *ptr);
    // 对比函数
    void (*match) (void *ptr,void *key);
}

3.1 双向列表特性总结

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
  • 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
  • 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。
  • 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。

3.4 skipList 跳跃表

跳跃表是一种有序的数据结构,通过在每个节点维护多个指针,从而达到快速访问的目的,在redis内部,跳跃表只有两种场景用到:

  • 实现有序集合sordSet,当有序集合元素较多或者成员变量是较长的字符串时。
  • 集群内部节点的数据结构。

Redis跳跃由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode为跳跃表节点,zskipList则保存跳跃表相关节点信息,如节点数量,表头表尾指针等。

3.4.1 跳跃表节点

跳跃表节点定义如下:

typedef struct zskiplistNode{
     // 层
    struct zskiplistLevel{
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
        
    }level[];
    // 后退指针
    struct zskiplistNode *backward;
    // 分值
    double score;
    // 成员对象
    robj *robj;
}zskiplistNode;
  • 层:层是redis内部快速访问其它跳跃表节点的一种手段,层的数量越多,访问其它节点的速度就越快;在创建跳跃表的节点时,会根据幂次定律随机生成1-32之间的值作为层数组的大小,这个大小成为层的高度。
  • 前进指针:指向表尾方向的指针,通过遍历前进指针,可以从当前节点访问到表尾。图中虚线表示从表头向表尾方向遍历所有节点路径
  • 跨度:只要用来计算两个节点之间的距离,连个节点的跨度大越大,则表示节点相聚越远。
  • 排位:在查找节点的过程中,将沿途访问过的所有层累积起来,得到的结果。
  • 后退指针:用于从表尾向表头方向访问,与前进指正不同的是,后退指针只有一个。
  • 分值:分值是一个double类型的浮点数,跳跃表中的所有节点都按照分值大小排序。
  • 成员对象:它指向一个字符串对象SDS。

3.4.2 跳跃表

对于跳跃表,redis是通过zskiplist这个数据结构来表示的,其内部不仅持有跳跃表节点,还持有跳跃表节点的表头节点和表尾节点,跳跃表节点的数量。如下,为zskiplist的结构定义:

typedef struct zskiplist{
    // 表头节点和表尾节点
    struct zskiplist *header,*tail;
    // 表节点个数
    unsigned long length;
    // 表节点最大层数
    int level;
}zskiplist;
  • header和tail分表指向跳跃表的头节点和尾节点,通过这两个指针,定位跳跃表的头节点和尾节点的时间复杂度为o(1).
  • length记录跳跃表节点数量,redis可以在o(1)的时间复杂度返回跳跃表长度。

3.4.3 跳跃表的遍历

image.png

  • 头节点:头节点和跳跃表节点结构一样,但是分值和后退指针,成员遍历都不会被用到,所以图中没有画出来
  • 跳跃表节点:在该图最右边四个,跳跃表节点主要有如下几个属性:层(L1,L2,...,Ln)以及层内部的跨度与前进指针,后退指针,分支score,成员变量mem
  • 跳跃表:图中最左边的一个,分表有四个核心组成:头指针,尾指针,跳跃表长度,跳跃表层数
  • 表头到表尾的遍历过程:
    • 迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层(L4)的前进指针移动到表中的第二个节点
    • 在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
    • 在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点
    • 当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,结束这次遍历。

3.5 整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现;其结构如下:

typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;
  • length:记录了整数集合包含的元素数量,也即是contents数组的长度。
  • contents:表示整数集合,集合里的数据按照从小到大排序,并且数组里的数据不包含重复项。
  • encoding : 整形数据的数据编码,也可理解为数组每项所占的字节大小,取值为如下3种:
    • INTSET_ENC_INT16:数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)
    • INTSET_ENC_INT32:数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647
    • INTSET_ENC_INT64:数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)

3.5.1 整数集合升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面,升级过程大致分为三步:

  • 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 将新元素添加到底层数组里面

3.5.1 总结

  • 整数集合是集合键的底层实现之一
  • 整数集合的底层实现是数组,这个数组以有序无重复的方式保存集合元素,同时,在集合添加新元素的时候,会改变数组的数据类型,称之为整数集合的升级
  • 整数集合只支持升级操作,不支持降级操作

3.6 hash表

Redis字典所使用的hash表如下所示:

image.png

hash表有一个table属性数组,数组中的每个元素类型都是一个dictEntry,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面,属性Java的童鞋,可联想对比一下Java中的hashMap,是否也是类似的设计

3.6.1 字典

image.png

字典的核心组成主要有两个:ht数组,rehashIndex,至于其它的核心组成,如类型特定函数这些,不在本文阐述范围,感兴趣的可自行去理解.

  • ht数组,ht是一个固定大小为2的数据,数组的每一个元素分别为一个hash表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
  • rehashidx:它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
3.6.2 字典定位元素过程

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面

  • 计算hash值:根据字典类型,选择不同的hash函数,计算ket的hash值。
  • 定位索引:index= hash & dict->ht[x].sizeMask,ht[x]可以是ht[0]或者ht[1]
  • 解决hash冲突:采用拉链法,注意的是,因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置。
3.6.3 rehash

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  • 为字典中的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操作和当前ht[0]的空间大小
    • 如果要执行扩容操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2^n,既ht[1]分配的小大于等于当前ht[0]当前表节点两倍的最小2次幂
    • 如果要执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n
  • 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
  • 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
3.6.3.1 rehash 触发条件
  • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
  • 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作

注:负责因子计算公式:load_factory = ht[0].userd /ht[0].size

3.6.4 渐进式rehash

渐进式rehash主要是解决rehash过程中,将ht[0]数据拷贝到ht[1]时,如果有上亿数据,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。因此redis为了避免rehash过程对redis服务器性能造成影响,不是一次性将ht[0]数据拷贝到ht[1]中,而是分多次,渐进式的将数据拷贝到ht[1]中。

渐进式rehash步骤如下:

  • 将字典中的变量rehashidx设置为0,表示该字典rehash工作正式开始,此时该字典同时持有ht[0]和ht[1]
  • 当每次对字典进行增,删,改,更新操作时,除了会执行相应的动作之外,同时还会执行rehash过程,将对应ht[0]元素转移到ht[1]中,转移结束之后,将rehashinx值加一
  • 随着操作的不断进行,ht[0]最终持有的元素将为0,此时,渐进式hash结束,将rehashinx的值设置为-1,表示rehash操作完成

注:在渐进式rehash过程中,因为字典持有两个哈希表,所以对字典的操作增,删,改,查都是基于两个hash表进行的,但如果在渐进式hash中,如果有新增操作,会直接放在ht[1]中,这样就能保证ht[0]的元素只减不增,最终会变成一个空的hash表