Redis 底层数据结构

745 阅读32分钟

我们都知道Redis有五种数据类型,那么,他们的底层是什么呢?为什么要这么设计的,其实,就设计来说,我感觉第一:肯定是节约时间,比如说:空间换时间不管是算法还是Mysql都有大量体现;第二:在保证时间的情况下,最好能减少空间的使用量;像我们的Redis中,有数据结构如下:1.简单动态字符串 2. 跳跃表 3. 压缩列表 4.字典 5. 整数集合 6.quicklist 下面我们来了解一下这些特殊数据结构的设计;

1.简单动态字符串

简单动态字符串是是Redis的基本数据结构之一,用于存储字符串和整型数据。且保证了二进制安全。

什么是二进制安全?

在C语言中,用“\0”表示字符串的结束,如果字符串本身就有“\0”字符,字符串就会被截断,即非二进制安全; 那么,我们如果通过某种机制,当有“\0”时,也不会被截断,那么就是二进制安全。
为了解决这个二进制安全 Redis3.2版本之前,设计了如下结构:

struct sds {
	int len; // buf中已占用字节数
	int free; // buf中剩余可用字节数
	char buf[]; // 数据空间
}

这么看来,由于有len的存在,读写字符串时不依赖"\0"终止符,保证了二进制安全。

但是,该结构是否有改进的空间呢?不同长度的字符串是否有必要占用相同大小的头部(就是 len 和 free 是否需要用int来进行修饰)因为 一个int占4字节,在实际应用中,存放Redis中的字符串往往没有这么长;使用int太浪费空间了。那么,我们考虑三种情况 短字符串,长字符串 更长的字符串 ;所以,对于 短字符串来说, 对于,len 和 free 我们使用1字节来表示就好了 (1 - 2的8次方) 对于 长的字符串, len 和 free 使用 4字节来表示即可; 对于,超长的字符串,len 和 free 就使用8字节来表示即可;

这样确实是好的,但是,又引入了新的问题
问题1:如何区分这3种情况?
问题2:对于短字符串来说,头部还是太长了,以长度为1字节的字符串为例,len 和 free 本身就占用2个字节,成本太大?
对于问题1,我们考虑增加一个字段flags来标识类型;
对于问题2,由于len已经是最小的1字节了,再压缩就只能是考虑用位来存储长度了;

为了解决这两个问题,5种类型(长度1字节、2字节、4字节、8字节、小于1字节)的string,至少需要使用3位来存储类型(2^3 = 8),1个字节8位,剩余5位用来存储长度,可以满足长度小于32的短字符串,在Redis5.0中,我们使用如下结构来存储小于32的短字符串:

sturct sds {
	char flags // 在c语言中char 只占一个字节 在java中,占两个字节
	char buf[] // 存放实际内容
}

结构图如图所示:

image.png 那么,长度大于31的字符串,使用一个flags 是存不下的,那么,剩余的5位,就进行闲置,结构体中,还是需要重新使用len 和 alloc;

sturct sds {
	uint8_t len; // 已使用长度,这里是使用1字节来存储,不同的类型是不一样的,可以改为16 32 64等;
	uint8_t alloc; // 总长度,这里使用1字节来存储
	unsigned char flags; // 低3位存储类型(就是说字符串的什么长度), 后5位预留 不适用
	char buf[]; // 存放实际的内容
}

结构图如下:

image.png 基本操作 数据结构的基本操作不外乎增、删、改、查,由于Redis3.2后 sds涉及多种类型,修改字符串内容带来的变化可能会影响sds的类型而引发扩容。
创建字符串

创建SDS的大致流程:首先计算好不同类型的头部和初始长度,然后动态分配内存。需要注意以下3点:

  1. 创建空字符串是,类型会被强制转换为SDS_TYPE_8(因为,创建空字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为SDS_TYPE_8)

  2. 长度计算时有 “+1”操作,是为了算上结束符“\0".

  3. 返回值是指向sds结构的buf字段的指针。

释放字符串

为了优化性能(减少申请内存的开销),SDS提供了不直接释放内存,而是通过重置统计值达到清空的目的,就是直接将sds的len归零,此处已存在的buf并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存。

拼接字符串:(网易面试的时候,考到)

步骤如下:

  1. 若sds中剩余空闲长度大于新增内容的长度,那么,直接在buf末尾追加即可,无须扩容;

  2. 若sds中剩余长度小于或等于新增内容的长度,则需要分情况扩容,如果,新增后总长度len + addlen < 1MB,按新长度的2倍扩容,如果,新增总长度len + addlen > 1MB, 那么就按照新长度 + 1MB来进行扩容;

  3. 最后,根据新长度来重新选取存储类型(因为存储类型可能会发生了改变),并分配空间。

  • 此处若无须更改类型,那么,直接扩大柔性数组即可;

  • 此处若需要更改类型,那么,就需要重新开辟内存,并将原字符串的buf内容移动到新位置。

小结: 针对动态字符串来说,感觉主要考虑的是空间的利用率;为了节约内存而设计的;

2.跳跃表

背景: 有序集合在生活中较常见,如根据成绩对学生进行排名、根据得分对游戏玩家进行排名等。对于有序集合的底层实现,我们可以使用数组、链表、平衡树等结构。数组不便于元素的插入和删除;链表的查询效率低,需要遍历所有元素;平衡树或者红黑树结构虽然效率高但实现复杂。Redis采用了一种新型的数据结构--跳跃表。跳跃表的效率堪比红黑树,然而实现却远比红黑树简单。
有序链表和跳跃表的区别

image.png

image.png 根据有序链表,如果要查询值为51,需要找6次;
根据跳跃表的话,如果要查询值为51,只需要查询5次即可;
所以,如果,采用的是跳跃表的话,会少查询两次,如果,数据量大的话,优势会更明显;

跳跃表节点与结构 跳跃表节点

  • ele: 用于存储字符串类型的数据(member)

  • score:用于存储排序的分值

  • backward: 后退指针,只能指向当前节点最底层的前一个节点,头节点和第一个节点 -- backward 指向NULL,从后向前遍历跳跃表时使用

  • level: 为柔性数组,每个节点的数组长度是不一样的,在生成跳跃表节点时,随机生成一个1~64的值,值越大出现的概率越低。
    level 数组的每项内容包含以下两个元素。 forward: 指向本层下一个节点,如果是尾节点就指向NULL。
    span: forward指向的节点与本节点之间的元素个数。span值越大,跳过的节点个数

    跳跃表是Redis有序集合的底层实现方式之一,所以每个节点的ele存储有序集合的成员member值,score存储成员score值。所有节点的分值都是按从小到大的方式排序的,当有序集合的成员分值相同时,节点会按照member的字典序来进行排序; 跳跃表结构体 ​ 除了跳跃表节点外,还需要一个跳跃表结构来进行管理节点(像一些公共信息是需要管理在该结构上的,比如说:所有的节点中,最高层级是多少,系统,不可能从64层级开始找,而是,通过最高层级来经常查找)

  • header :指向跳跃表头节点

  • tail: 指向跳跃表尾节点

  • length: 跳跃表长度

  • level : 跳跃表高度

    通过跳跃表结构体的相关属性,程序可以在O(1)的时间复杂度下,快速获取到跳跃表的头节点、尾节点、长度和高度。 在Reids中,跳跃表主要应用于有序集合(zset) 的底层实现,有序集合的另外一种实现方式为压缩列表。 Redis的配置文件中关于有序集合的底层实现有两个配置。

  1. zset-max-ziplist-entries 128: zset 采用压缩列表时,元素个数最大值。默认为128
  2. zset-max-ziplist-value 64: zset采用压缩列表时,每个元素的字符串长度的最大值,默认值为64。
    因为,元素的字符串长度也不会太长,所以在创建 有序集合时,默认使用压缩列表的底层实现。zset新插入元素时,会判断一下两种条件:
    第一:元素的个数是否大于128个
    第二:插入的元素字符串长度是否大于64
    如果,满足任一条件时,Redis 便会将zset的底层实现有压缩列表转换为跳跃表。注意:转换为跳跃链表以后,就不会重新转换为压缩列表。
    跳跃表小结:跳跃表是通过空间换时间的方式来提高数据的查询效率。支持Redis的快的特性;

3.压缩列表

压缩列表ziplist 本质上就是一个字节数组,是Redis为了节约内存而设计的一种线性数据结构,可以包含多个元素,每个元素都可以是一个字节数组或一个整数。
Redis的有序集合、散列和列表都直接或者间接使用了压缩列表。当有序集合或散列表的元素比较少,且元素都是短字符串时,Redis便使用压缩列表作为其底层数据存储结构。列表使用快速链表数据结构存储,而快速链表就是双向列表与压缩列表的组合。
例:使用如下命令创建一个hash,并查看其编码。

image.png
可以查看hash使用的就是压缩列表;
ziplist结构

image.png 下面,我们来解释一下每一部分存储的内容:

  • zlbytes: 压缩列表的字节长度,存储的是包括它自己在内的整个ziplist所占用的字节数

  • zltail: 存储的是最后一个entry的偏移量,用来快速定位最后一个元素(便于从后往前找)

  • zllen: 16位无符号整数,用于存储entry的数量,当元素数量大于 2的16次方时 - 2时,这个值就会被设置为2的16次方 - 1,这个时候,我们想知道元素的数量就需要遍历整个列表了。

  • entry: 表示存储的元素

  • zlend: 8位无符号整数,用于标记整个ziplist的结尾。它的值是255。 了解了ziplist的大概结构,我们就需要了解更深一层的entry的结构
    对于每个entry都有两个前缀:

  • prevlen 代表的是前一个字节的长度,占1字节或者5字节,当前一个元素长度小于254字节时,用1个字节来表示,否则用5个字节来表示。假设已知当前元素的首地址为p,那么,prevlen就代表前一个元素的首地址。(因为,它们是连在一起的,比如说 我当前是 1000 ,而prevlen = 100 那么,前一个元素的首地址为900就可以推出来了)所以,从后到前的遍历就比较简单了。

  • encoding 字段表示当前元素的编码, 因为,数据是存在content中的内容。这个时候,为了节约内存,encoding字段长度可变(有点类似于string了) 而且,根据它的前两位,就可以判断出content字段存储的是整数或者字节数组 如果,是数字的话,其实存储占用的空间会小一点(注意:如果是string类型,为了二进制安全,它是不会变成数字的)

  • entry-data(content): 元素的数据,它并不一是一定存在的。
    插入元素 由于新插入了元素,压缩列表所需要的空间增大(因为压缩列表是连在一起的,所以,肯定需要重新分配存储空间)那么,空间大小等于多少呢?空间大小 不一定等于添加前压缩列表长度与新添加元素长度之和。因为有prevlen 的存在;
    举个例子:

image.png entryX 元素长度为128字节,那么entryX + 1元素的prevlen的元素的长度就只要1个字节就可以了,这个时候,在entryX 和 entryX+1之间加了一个entryNew元素,这个元素长度是1024字节,那么,这个时候entryX+1中的prevlen就需要变成5个字节了。注意:后面的元素可能也要进行更新。
当删除entryX的时候,entryX+1的prevlen 就变成了5字节,那么entryX+1的长度就变成了257字节;又导致后面进行更新,这种叫做连锁更新由于,每次扩展都将重新分配内存,导致效率很低,当更新到一次不变时,那么就不向后检查了,比如说后面有一个entryX+3 为100字节,那么就不会进行检查了。

3.字典

字典又称散列表,是用来存储键值对的一种数据结构;但是,C语言中是没有这种数据结构的,Redis是k-v型数据库,整个数据库都是用字典来存储的,对Redis数据库进行增删改查操作,实际上就是对字典中的数据进行增删改查操作;
特点:

  1. 可以存储海量数据,键值对是映射关系,可以先找到键 然后以O(1)的时间复杂度取出或插入关联值。
  2. 键值对中的可以是字符串、整型、浮点型等,且键是唯一的;
  3. 键值对中的的类型可以为String、Hash、List、Set、SortedSet;
    那么,为了满足这些特征,Redis是怎么设计的呢?
  4. 为了满足海量数据存储,这个时候,需要添加一个data字段,用于指向数据存储的内存地址。
  5. “找到key以后需要以O(1)的时间复杂度去取值”这个时候,就应该选择数组来进行实现;
    因为,C数组通过下标就可以快速定位到元素,所以只要内存够用,就可以存储海量的数据; 但是,数组下标是整数,而我们的key可以是字符串 浮点型等,所以,为了解决这个问题,需要对key进行特殊处理,这个过程称为Hash;

在应用上,通常使用现成的开源Hash算法 比如说:Redis自带的客户端就是使用“times 33” ,Redis 服务端的Hash函数使用的是siphash算法(随机性更好,但是,算法较为复杂);times 33 是针对字符串已知的最好的散列(Hash)算法之一,因为其计算速度快,而且分布的也比较好,像Java中的hashCode也是使用这个算法;

    public int hashCode() {
        int h = hash; // 默认为0
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i]; // 这里为什么需要选择31呢 其实,对于奇数来说,他们的效果是差不多的, 但是31可以看成32左移5位 然后-1 就是把乘法变成位运算 和加法运算,提高效率,所以,其实Java8的源码的hashCode()函数,是可以再进行优化的;
            }
            hash = h;
        }
        return h;
    }

注意:虽然key 类型分为 整形 字符串 浮点型 这三种类型,但是,对于Redis服务器来说,收到客户端发送过来的键实际都为字符串; 每一个位置都是使用char[] 来进行保存的;
这时候,解决了hash的问题,将字符串变成hash值,然后,通过hash值来判断应该存储在哪个位置上; 但是,又出现了一个新的问题了,hash值是很大的,数组长度是没这么大的,这个时候,就需要对数组的长度进行取模,取模后的结果为键在该字典中的索引值,来确定应该存在数组的那个位置上,即“索引值==数组下标值”;这个时候,在这个data字段上,就需要保存1. 数组的长度 2. 数组已经存了多少数据量了; 如下图所示:

image.png Hash 冲突
hash + 取模 虽然解决了 把字符串变整数 把整数取模变成数组下标,但是,又产生了一个新的问题,就是hash冲突,两个不同的键会关联上同一个数组下标,这种叫做键的冲突;为了解决hash冲突:有四种方法1:开放定址法(比如说:hash冲突了,以他为基础,再产生一个hash,如果,这个hash地址不冲突,就把该元素存入其中。线性探测就是往后找;以线性探测为例,那么,数组就是一个环形数组,插入数据,如果冲突了就一直往后插入,查找的时候,如果发现不是也一直往后找,找到为空,才算找不到) 2:再哈希法(有多个不同的Hash函数,当发生冲突的时候,使用第二个,第三个,...等哈希函数) 3: 链地址法 4: 建立公共溢出法(将哈希表分为基本表和移除表,凡是和基本表发生冲突的元素,一律填入溢出表,在查找时,先与基础表相应位置对比,如果成功,这查找相等,则到溢出表进行顺序查找。如果,不相等,则到溢出表进行顺序查找。如果相对于基本表而言,在有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的)

为了解决Hash冲突,设计了链表,就是数组中的元素除了应该把键值对中的“key”存储起来,还应该存储一个next指针,和value所对应的指针;具体如下图所示:

image.png
综上,根据键来查找值时,分为如下几步:
第1步: 键通过Hash、取余等操作得到索引值,根据索引值找到对应的元素
第2步: 判断元素中键与查找的键是否相同,相等则读取元素中的值返回,否则判断next指针是否有值,如存在值,则读取next指向元素,回到第2步继续执行,如不存在值,这代表该key在字典中不存在,返回NULL;
设计到这里,第二个特征的前半部分也就能实现了,还有一个特征是“键是唯一的”,所以,在每次键值对插入字典前都要执行一遍查找操作,如果键已经存在,则修改即可,否则执行插入操作;
第三个特征的实现,即“键值对中值的类型可为 String、Hash、List、Set、SortedSet”,可以将数组元素中的val字段设置成指针,通过指针指向值所在的任意内存。


Redis字典的实现

Redis的字典也是通过Hash函数来实现的,因为Redis是基于工程应用的,需要考虑的因素会多一点;先来个结构示意图:

image.png 1.dictht称为Hash表 hash表结构有以下四个部分: size:尺寸   table: 指向数组   sizemask: = size-1 用来找索引的,将hash % size -> hash & sizemask 把取模操作变成位运算,提高效率;   used : 已经使用的元素;
2.dictEntry称为Hash表节点 对于,数组中元素,是用dictEntry 结构体来表示的;dictEntry 主要是用来存储键值对,具体结构如下:

typedef struct dictEntry {
	void *key; //存储key
	union {
		void *val; // db.dict 的val*
		int64_t s64 // 存储过期时间
	}
	v // 值 存指针
	struct dictEntry *next; // 当hash冲突时,指向冲突的元素,形成单链表
} dictEntry;

v字段是一个联合体,存储的是键值对中的值,在不同的场景中,使用不同的字段;
3.字典
Redis 字典实现除了包含前面两个结构体Hash表以及Hash表节点外,还在最外层封装了一个叫字典的数据结构;主要作用:对hash表进行再一次封装,当字典需要进行一些特殊操作时,要用到里面的辅助字段。
先来一个完整的Redis字典的数据结构图:

image.png ht:是个大小为2的数组,该数组存储的元素类型为dictht(Hash表),虽然有两个元素,但一般情况下只会使用ht[0],当该字典扩容、缩容需要进行rehash时,才会用到ht[1];
rehashidx :用来标记该字典是否在进行rehash,没进行rehash时,值为-1;否则,该值用来表示Hash表 ht[0]执行rehash到了哪个元素,并记录该元素的数组下标值;
iterators: 用来记录当前运行的安全迭代器数,当有安全迭代器绑定在该字典时,会暂停rehash操作;例如:执行keys命令会创建一个安全迭代器,此时iterators会加1,命令执行完毕则减1,而执行sort命令时会创建普通迭代器,该字段不会改变;
字典扩容

扩容的主要流程为: 1. 申请一块新内存,初次申请时默认容量大小为4个dictEntry;非初次申请时,申请内存的大小为当前Hash表容量的一倍;2.把新申请的内存地址赋值给ht[1],并把字典的rehashidx 标识由-1改为0,表示之后需要进行rehash操作;示意图如下所示:

image.png 扩容后,字典容量及掩码值会发生改变,同一个键与掩码经过位运算后得到的索引值就会发生改变,从而导致键查找不到值的情况,
解决这个方法是,新扩容的内存放到一个全新的Hash表中(ht[1]),并给字典打上在进行rehash操作中的标识(即 rehashidx = -1)。此后,新添加的键值对都往新的Hash表中存储;而修改、删除、查找操作需要在ht[0]、ht[1]中进行检查,然后再决定去对那个Hash表操作。除此之外,还需要把老Hash表(ht[0])中的数据重新索引值后全部迁移插入新的Hash表(ht[1])中,此迁移的过程称作rehash; 渐进式rehash
rehash 除了扩容时会触发,缩容时也会触发。Redis整个rehash的实现分为如下几步:

  1. 给Hash表ht[1] 申请足够的空间,不管是扩容还是缩容;如果,扩容的话,空间大小设为当前容量*2;如果缩容的话,空间大小为恰好包含使用结点的2^N次方幂整数,并把字典中字段rehashidx标识为0;
  2. 进行rehash操作调用的是dictRehash函数,重新计算ht[0]中的每个键的Hash值与索引值,依次添加到新的Hash表ht[1],并把老Hash表中该键值对删除。把字典中字段rehashidx字段修改为Hash表ht[0]中正在进行rehash操作节点的索引值。
  3. rehash 操作后,清空ht[0], 然后对调一下ht[1] 与 ht[0]的值,并把字典中rehashidx字段标为-1;

   我们知道,Redis可以提供高性能的线上服务,而且是单进程模式,当数据库中键值对数量达到了百万、千万、亿级别时,整个rehash过程将变得非常缓慢,如果不优化rehash过程,可能会造成很严重的服务不可用现象。Redis优化采用了分而治之的思想进行rehash操作,大致步骤如下:
执行插入、删除、查找、修改等操作前,都先判断当前字典rehash操作是否在进行中,进行中则每次只对1个节点(我认为是dictEntry-Hash 表节点)进行rehash操作,共执行一次。
当服务空闲时,如果当前字典也需要进行rehash操作,则进行批量rehash (每次对100个节点进行rehash操作,共执行1毫秒)。在经历N次rehash操作后,整个ht[0]的数据会迁移到ht[1]中;
这里也体现了Redis速度第一的特点,空闲的时候rehash 忙的时候,就不rehash,以空间换时间;
结构总结 : key-value的存储分为三部分 分别是 1.字典 2.hash表 3.hash表结点
查找元素

  1. 根据键调用函数取得其hash值
  2. 根据hash值取到索引值
  3. 如果 不是rehash状态 那么,就遍历一个Hash表。如果 是rehash状态 那么,就遍历两个Hash表
  4. 遍历该元素单链表,如果找到了与自身键匹配的键,则返回该元素
  5. 找不到则返回NULL;
    修改元素
  6. 先进行查找,看键是否存在
  7. 如果不存在,就中断执行
  8. 如果存在 就修改键值对中的值为新值(其实,就是一个新的指针)
  9. 释放旧值内存 删除元素
  10. 查找改键是否存在于该字典中;
  11. 存在则把该节点从单链表中删除;
  12. 释放该节点对应键占用的内存、值占用内存,以及本身占用的内存;
  13. 给对应的Hash表的used字典减1操作;
  14. 当字典中数据经过一系列操作后,使用量不到总空间<10%时,就会进行缩容操作,将Redis数据库占用内存保持在合理的范围内,不浪费内存。

字典的遍历

字典遍历的原则: 1. 不重复出现的数据 2. 不遗漏任何数据
字典遍历的方式: 1.全遍历 2. 间断遍历 迭代器遍历

​ 迭代器 分为 安全迭代器 和 普通迭代器;

普通迭代器:普通迭代器在迭代的过程中,不能进行增删改查,迭代器中又一个fingerprint 字段来做严格的校验,确保读取到的数据不出现重复;如果,在迭代的过程中,出现了增删改查操作,那么,就会报异常;

安全迭代器: 当Redis执行部分命令时,会使用安全迭代器迭代字典数据,例如keys 命令。keys 命令主要作用是通过模式匹配,返回给定模式的所有key列表,遇到过期的key则会进行删除操作。Redis数据键值对,都存储在字典中,因此,keys命令会通过安全迭代器来遍历整个字典。安全迭代器和普通迭代器一样,也是通过循环调用dictNext函数依次遍历字典中Hash表的节点。安全迭代器确保读取数据的准确性,是通过限制rehash的进行来确保数据的准确性,为什么限制rehash就可以保证数据的准确性? 比如,rehash和遍历同时进行,现在,已经遍历过index=3的元素了,然后,因为发生了rehash,就会导致它跑到index=33的位置上,这就会导致重复遍历;keys算法是遍历算法,复杂度是O(n),如果,实例中有千万级以上的key,这个指令就会导致Redis服务卡顿。其他指令都会被延后甚至会超时报错;所以,我们一般使用Redis过程中,切勿执行需要长时间运行的指令(keys、smembers、hgetall、zrange),这样可能导致Redis阻塞,影响执行其他指令;如果,需要执行,就应该使用相应的scan命令渐进式遍历,可以有效防止阻塞时机;
间断遍历 当我们的Redis中有海量的数据库时,执行keys命令进行一次数据库全遍历,花费的时间就太长了。为了解决这个问题,redis 2.8新增了“间断遍历” 主要是在迭代字典中数据时使用,例如 hscan 命令迭代整个数据库中的key, 以及zscan命令迭代有序集合所有成员与值,都是通过dictScan函数来实现的字典遍历。而且,它是支持rehash操作的。

dictScan 函数间断遍历字典过程中会遇到如下3种情况。

  1. 从迭代开始到结束,散列表没有进行rehash操作

  2. 从迭代开始到结束,散列表进行了扩容或缩容操作,且恰好两次迭代间隔期完成了rehash操作

  3. 从迭代开始到结束,某次或某几次迭代时散列表正在进行rehash操作

针对情况1 和 情况2 在遍历过程中始终没有遇到rehash操作

针对情况1,只要依次按照顺序遍历Hash表ht[0]中节点即可。

针对情况2 因为在遍历的整个过程中,期间字典可能发生了扩容或缩容操作,如果依次按照顺序遍历,则可能会出现数据重复读取的现象。根据扩容原理 下标为0的键值对在扩容一次后,可能分布在下标为0或者4的节点中。倘若第一次遍历的是0 到3,第二次在遍历的时候,可能进行了扩容,那么原来下标0的节点可能重复出现。Redis为了做到不漏数据且尽量不重复数据,统一采用了一种叫做reverse binary iteration的方法来进行间断数据迭代,其源码实现如下:

它的原理是这样的,比如说 4个数 按照 0 2 1 3 二进制表示法为 00 10 01 11(高位进位法 为了解决遍历重复和遗漏)的顺序来进行遍历 假设,已经遍历了0 2;这个时候,系统进行了扩容变成8;然后,再进行间断遍历,那么 它剩下遍历的 就是 1 5 3 7 因为 0 2 已经遍历过了;所以,4 6 也不用遍历了 扩容链接 
如果是 原来的Hash表大小为8,迭代进行到第5次时 (已经遍历到了 0 4 2 6),Hash表缩容到了4 那么 这个时候,遍历就只需要遍历1 3 即可;

所以,只要在遍历的时候没有遇到rehash正好在进行中,通过上述游标变更算法,不管中间是否经历了缩容/扩容,都可以遍历完整个Hash表,(除了中间缩容两次,一般概率不大,雪崩可能会小概率发生? 大小表都使用高位序访问)
针对情况3 在遍历过程中遇到rehash操作

​ 从迭代开始到结束,某次或某几次迭代时 散列表正在进行rehash操作,rehash操作中会同时并存两个Hash表:一张表为扩容或缩容后的表ht[1],一张为老表ht[0],ht[0]的数据通过渐进式rehash会逐步迁移到ht[1]中,最终完成整个迁移过程。

​ 因为大小两表并存,所以需要从ht[0]和ht[1]中都取出数据,整个遍历过程为:先找到两个散列表中更小的表,先对小的Hash表遍历,然后对大的Hash表遍历,将结果融合后发给客户端;注意:scan的结果可能是有重复的
scan 的理解:
sql示例 首先向redis中 加入一些key

scan 0 match k* count 5 

第一个 参数0为从 数组的那个位子开始找

第二个 是key的正则模式

第三个 是limit 槽位数 注意有些槽位是空的,有些槽位是挂了很多链表

image.png

像这个结果 就找匹配k*的key,从第0个槽开始找,找5个槽的数量,这边返回槽的索引是26 说明,从在0 - 26之间有21个槽是空的;在符合条件的槽位中 有三个符合的key;

总结 :在字典中,Redis为了提高速度,设计如下

  1. 字典这种数据结构设计查找就已经很快了;
  2. 在扩容或者缩容的过程中,为了防止rehash太慢,产生了渐进式rehash;
  3. 在遍历数据的时候,为了防止全遍历太慢,也提供了间断遍历。

4.整数集合

首先:整数集合提出是为了解决什么问题? 答:为了充分利用内存;前提 是集合 set 就是在set下的一种优化;

​ 整数集合是一个有序的、存储整型数据的结构。Redis是存在内存中的,所以,我们必须考虑如何能够高效地利用内存。当Redis集合类型的元素都是整数并且都处在64位有符号整数范围之内时,使用该结构体存储; 例子:

image.png

第一个 testSet 存储类型是整型 第二个 test2存储类型是hashtable; 在整数集合中,数据保存是按照从小到大进行保存的,使之可以通过二分查找来进行查找;

其结构如下:

image.png

encoding: 代表编码类型,决定每个元素占用几个字节。

判断一个值需要用什么类型编码格式,只需要查看该值所处的范围即可。

int64 (2147483647, 9223372036854775808) 或 [-9223372036854775808, -2147483647, 9223372036854775808)

int32 (32767, 2147483647] 或 [-2147483647, -32768)

int16 [-32768, 32767]

刚刚存的数据是这样的,返回所有数据的时候,也是按照从小到大进行返回

image.png

这个时候,假设我需要添加一个5 , 那么,就先需要判断是否能找到该值,如果,能找到的话,就直接返回(集合中不能有重复的值);如果找不到的话,就重新分配一块内存,然后,把数据迁移过去;

如果,这个时候我添加的是一个50000,那么,这个时候,编码类型就需要进行升级了,而且,这个新加的数肯定是最小的数或者最大的数;这里是最大的数,

然后,重新分配一块内存,移动原来的元素,在移动的时候,按照从后往前的顺序依次移动,这样可以避免数据被覆盖;

总结:整数集合是对set的一种优化,如果,存的都是整数,使用的是整数集合,那么,在set进行查找的时候效率就会提升;当然,也会节约内存;

5.quicklist的实现

quicklist是Redis底层最重要的数据结构之一,它是Redis对外提供的6种基本数据结构中List的底层实现;在redis3.2版本中引入。在引入quicklist之前,Redis采用压缩链表(ziplist) 以及 双向链表(adlist)作为List的底层实现。当元素个数比较少并且元素长度比较小时,Redis采用压缩链表 作为底层存储;当任意一个条件不满足时,就变成双向列表;这么做的原因,当元素长度比较小时,采用ziplist可以有效节省存储空间,但是,当元素长度变多时,修改元素时,必须重新分配存储空间,这无疑会影响Redis的执行效率,会采用双向链表来进行存储。

​ 那么,在Redis3.2以后,采用了quicklist, quicklist是综合考虑了时间效率与空间效率引入了新型数据结构;

​ 众所周知,list查找的时间复杂度为O(n),不适用于快速查找的场合;而quicklist是Redis3.2中新引入的数据结构。quicklist可以看成是用双向链表将若干小型的ziplist连接到一起组成的一种数据结构。

极端情况:

​ 情况一: 当ziplist节点过多的时候,quicklist就会退化为双向链表。效率较差;效率最差是,一个ziplist中只包含一个entry,即只有一个元素的双向链表。(增加了查询的时间复杂度)

​ 情况二:当ziplist元素个数过少时,quicklist就会退化成为ziplist,最极端的时候,就是quicklist中只有一个ziplist节点。(增加了增加数据时,ziplist需要重新分配空间)

​ 所以说:quicklist其实就是综合考虑了时间和空间效率引入的新型数据结构。(使用ziplist 能提高空间的使用率,使用双向链表能够降低插入元素时的时间);

数据存储

quicklist是一个由ziplist充当节点的双向链表。quicklist的存储结构如下所示:

image.png
quicklist有如下几种核心结构:

typedef struct quicklist {
	quicklistNode *head; 
	quicklistNode *tail;
	unsigned long count; // 为quicklist中元素
	unsigned long len;	// quicklist 节点的个数
	int fill : 16; // 用来指明每个quicklistNode 中ziplist长度,当fill为正数时,表明每个ziplist最多含有的数据项数,当为负数的话,就代表ziplist节点最大存储的量;
	unsigned int compress : 16;
}  quicklist;

由于,针对list 我们访问的是两端的数据,为了进一步节省空间,Redis允许对中间的quicklistNode节点进行压缩,通过修改参数list-compress-depth进行配置,即设置compress参数,该项的具体含义是两端各有compress个节点不压缩, 当compress为1时,quicklistNode个数为3时,其结构图如上图所示:很显然,第一个第三个没有压缩,第二个压缩了。

**注意:**quicklist的节点是 quicklistNode 其结构如下:

typedef struct quicklistNode {
	struct quicklistNode *prev; // 指向前节点
	struct quicklistNode *next; // 指向后节点
	unsiged char *z1; // 存ziplist 压缩链表
	unsiged int sz;	  // 代表整个ziplist结构的大小
	unsiged int encoding : 2; // 1:代表原生 2.代表使用LZF进行压缩
	unsiged int recompress : 1; // 代表这个节点是否是压缩节点,如果是的话,则在使用前先进行解压缩,使用后需要重新进行压缩
}quicklistNode;

数据压缩

​ quicklist 每个节点的实际数据存储结构为ziplist,这种结构的主要优势在于节约存储空间。为了进一步降低ziplist所占用的空间,Redis允许对ziplist进一步压缩,Redis采用的压缩算法是LZF。压缩后的数据可以分为多个片段,每个片段有2部分:一部分是解释字段,另一部分是存放具体的数据字段;

​ 那么,压缩怎么就可以节约存储空间了呢?LZF数据压缩的基本思想是:数据与前面重复的,记录重复位置以及重复长度,否则直接记录原始数据内容。

添加元素

​ quicklist 提供了push操作,可以在头部或者尾部进程插入:如果,头部或者尾部不能插入的话,就需要新建quicklistNode ,然后再把quicklistNode 插入到quicklist结构体中;

​ 除了push,quicklist还提供了在任意位置插入的方法。这时候,会有继续插入和不能继续插入两种情况;

​ 1)当前插入位置所在的quicklistNode 仍然可以继续插入,此时可以直接插入;

​ 2) 当前插入位置所在的quicklistNode不能继续插入,此时可以分为如下几种情况。

  1. 需要向当前quicklistNode 第一个元素前插入元素,并且,当前ziplist所在的quicklistNode的前一个quicklistNode可以插入,则将数据插入到前一个quicklistNode。如果前一个quicklistNode 不能插入,则新建一个quicklistNode,插入到当前quicklistNode前面。
  2. 需要向当前quicklistNode的最后一个元素后面插入元素,当前ziplist所在的quicklistNode的后一个quicklistNode 可以插入,则直接将数据插入到后一个quicklistNode。如果后一个quicklistNode 不能插入,则新建一个quicklistNode插入到当前quicklistNode的后面。
  3. 不满足前面2个条件的所有其他种情况,将当前所在的quicklistNode以当前待插入位置为基准,拆分成左右两个quicklistNode, 之后将需要插入的数据插入到其中一个拆分出来的quicklistNode中。

删除元素

​ 有删除一个元素 和删除区间元素,反正就是先找到指定元素在进行删除;

更改元素

​ 由于,quicklist其内部是ziplist结构,ziplist在内存中是连续存储的,当改变一个元素的时,可能会影响后续元素。故,quicklist 采用的是先删除后插入的方案;(通过索引来设置新的值会用到,位置还是不变)

查找元素

​ quicklist查找元素主要是针对index,即通过元素在链表中的下标查找对应元素。基本思路是,首先找到index对应的数据所在的quicklistNode节点,之后调用ziplist的接口函数ziplistGet得到index对应的数据。
总结: quicklist 更多的是考虑节约内存,采用压缩列表来节约内存;但是,如果元素变多时,修改元素时,必须重新分配存储空间,也是会比较占用时间,所以,为了解决这个问题,引用了双向链表,能减少修改元素时,所花的时间;(如果,列表中有序插入的话,应该重新分配存储空间概率不大;但是,要是向中间位置修改(增加/删除)一个元素,就需要重新分配存储空间了,可能还会有连锁更新)

总结

以上大部分知识来源于Redis5设计与实现,加入自己一些简单的思考,做个笔记;如果,大家有不理解的可以去看一下这本书,感觉写的很好,也欢迎大家多多指教;