Redis高级(十一)、彻底掌握Redis基本数据类型及底层实现【中篇】ZipList、Dict、QuickList

80 阅读13分钟

觉得对你有益的小伙伴记得点个赞+关注

后续完整内容持续更新中

希望一起交流的欢迎发邮件至javalyhn@163.com

基于前文,我们详细讲解了SDS,这篇,我们抓紧学完ZipList、QuickList

1. Hash数据结构介绍

1.1 两个重要配置

hash-max-ziplist-entries:使用压缩列表保存时哈希集合中的最大元素个数。
hash-max-ziplist-value:使用压缩列表保存时哈希集合中单个元素的最大长度。

1.2 hash编码格式一定是ziplist吗

hash的两种编码格式为 ziplist与hashtable

在不修改上述两个配置的情况下,我们只有满足下面两个条件才使用ziplist,否则使用hashtable

  1. Hash类型键的字段个数 小于 hash-max-ziplist-entries (默认512个)
  2. 每个字段名和字段值的长度 小于 hash-max-ziplist-value 时(默认64byte,一个英文字母一个字节), Redis才会使用 OBJ_ENCODING_ZIPLIST来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT的编码方式

ziplist升级到hashtable可以,反过来降级不可以

一旦从压缩列表转为了哈希表,Hash类型就会一直用哈希表进行保存而不会再转回压缩列表了。

在节省内存空间方面哈希表就没有压缩列表高效了。

1.3 案例演示

我们缩小默认配置来演示

image.png

image.png

1.4 ziplist源码分析

Ziplist 压缩列表是一种紧凑编码格式,总体思想是多花时间来换取节约空间,即以部分读写性能为代价,来换取极高的内存空间利用率,因此只会用于 字段个数少,且字段值也较小 的场景。压缩列表内存利用率极高的原因与其连续内存的特性是分不开的。

image.png

在jvm知识里,有GC垃圾回收机制:标记--压缩算法,大家可以类比该算法来理解ziplist

当一个hash对象只包含少量键值对并且每一个键值对的键和值要么就是小的整数要么就是长度较短的字符串,那么它用ziplist作为底层实现

ziplist是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,节约内存,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面

image.png

image.png

image.png

image.png

1.5 ziplist各个组成单元是什么意思

image.png

1.6 明明有链表了,为什么出来一个压缩链表

  1. 普通的双向链表会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,得不偿失。ziplist 是一个特殊的双向链表没有维护双向指针:prev next;而是存储上一个 entry的长度和 当前entry的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度更费内存。这是典型的“时间换空间”

  2. 链表在内存中一般是不连续的,遍历相对比较慢,而ziplist可以很好的解决这个问题,普通数组的遍历是根据数组里存储的数据类型找到下一个元素的(例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行),但是ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),所以ziplist只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。

  3. 头节点里有头节点里同时还有一个参数 len,和string类型提到的 SDS 类似,这里是用来记录链表长度的。因此获取链表长度时不用再遍历整个链表,直接拿到len值就可以了,这个时间复杂度是 O(1)

image.png

1.7 压缩列表节点的构成

压缩列表是 Redis 为节约空间而实现的一系列特殊编码的连续内存块组成的顺序型数据结构,本质上是字节数组

在模型上将这些连续的数组分为3大部分,分别是header+entry集合+end

其中header由zlbytes+zltail+zllen组成,

entry是节点,

zlend是一个单字节255(1111 1111),用做ZipList的结尾标识符。

见下: 压缩列表结构:由zlbytes、zltail、zllen、entry、zlend这五部分组成

image.png

zlbytes 4字节,记录整个压缩列表占用的内存字节数。

zltail 4字节,记录压缩列表表尾节点的位置。

zllen 2字节,记录压缩列表节点个数。

zlentry 列表节点,长度不定,由内容决定。

zlend 1字节,0xFF 标记压缩的结束。

image.png

1.8 zlentry实体结构解析

image.png

压缩列表zlentry节点结构:每个zlentry由前一个节点的长度、encoding和entry-data三部分组成

image.png

前节点:(前节点占用的内存字节数)表示前1个zlentry的长度,prev_len有两种取值情况:1字节或5字节。取值1字节时,表示上一个entry的长度小于254字节。虽然1字节的值能表示的数值范围是0到255,但是压缩列表中zlend的取值默认是255,因此,就默认用255表示整个压缩列表的结束,其他表示长度的地方就不能再用255这个值了。所以,当上一个entry长度小于254字节时,prev_len取值为1字节,否则,就取值为5字节。

enncoding:记录节点的content保存数据的类型和长度

content:保存实际数据内容

image.png

typedef struct zlentry {    // 压缩列表节点
    // prevrawlen是前一个节点的长度
   unsigned int prevrawlensize, prevrawlen;  
   //prevrawlensize是指prevrawlen的大小,有1字节和5字节两种
    unsigned int prevrawlensize, prevrawlen;    
    // len为当前节点长度 lensize为编码len所需的字节大小
    unsigned int lensize, len;  
     // 当前节点的header大小
    unsigned int headersize;   
    // 节点的编码方式
    unsigned char encoding; 
    // 指向节点的指针
    unsigned char *p;   
} zlentry;

1.9 压缩列表的遍历

通过指向表尾节点的位置指针p1, 减去节点的previous_entry_length,得到前一个节点起始地址的指针。如此循环,从表尾遍历到表头节点。从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

1.10 ziplist存储情况

image.png

image.png

每个键值对都会有一个dictEntry

1.11 ziplist连锁更新

ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:

如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值;

如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据

现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

image.png

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。

1.12 ziplist小总结

ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的"双向链表"
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

2. Dict数据结构介绍

2.1 介绍

我们知道Redis是一个键值型(Key-Value Pair)的数据库,在Redis中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。

我们可以根据实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。 Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

在 Redis内部,从 OBJ_ENCODING_HT类型到底层真正的散列表数据结构是一层层嵌套下去的,组织关系见面图:

image.png

image.png

image.png

image.png

image.png

2.2 存储数据时Dict的过程

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask(哈希表大小的掩码,总等于size - 1(哈希表大小))来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。

image.png

image.png

image.png

image.png

2.3 Dict的扩容

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。 Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:

  1. 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
  2. 哈希表的 LoadFactor > 5 ;

image.png

2.4 Dict的rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:

  • 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:

    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
  • 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]

  • 设置dict.rehashidx = 0,标示开始rehash

  • 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]

  • 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

  • 将rehashidx赋值为-1,代表rehash结束

  • 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

image.png

2.5 Dict总结

Dict的结构:

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

Dict的伸缩:

  • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
  • 当LoadFactor小于0.1时,Dict收缩
  • 扩容大小为第一个大于等于used + 1的2^n
  • 收缩大小为第一个大于等于used 的2^n
  • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
  • rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表

3. List数据结构介绍

3.1 两个重要配置

list-max-ziplist-size
list-compress-depth

image.png

(1) ziplist压缩配置:

list-compress-depth 0 表示一个quicklist两端不被压缩的节点个数。这里的节点是指quicklist双向链表的节点,而不是指ziplist里面的数据项个数

参数list-compress-depth的取值含义如下:

0: 是个特殊值,表示都不压缩。这是Redis的默认值。

1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。

2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。

3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩。

依此类推…

(2) ziplist中entry配置:

list-max-ziplist-size -2 当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。

比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项。

当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。这时,它只能取-1到-5这五个值, 每个值含义如下:

-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。(注:1kb => 1024 bytes)

-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。

-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。

-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(-2是Redis给出的默认值)

-1: 每个quicklist节点上的ziplist大小不能超过4 Kb。

3.2 quicklist的引入

问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。

问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?

答:我们可以创建多个ZipList来分片存储数据。

问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?

答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。

在低版本的Redis中,list采用的底层数据结构是ziplist+linkedList;

高版本的Redis中底层数据结构是quicklist(它替换了ziplist+linkedList),而quicklist也用到了ziplist

image.png

image.png

3.3 quicklist源码解析

quicklist 实际上是 zipList 和 linkedList 的混合体,它将 linkedList按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。

image.png

image.png

image.png

我们接下来用一段流程图来描述当前的这个结构

image.png

3.4 quicklist总结

quicklistNode中的*zl指向一个ziplist,一个ziplist可以存放多个元素

QuickList的特点:

  • 是一个节点为ZipList的双端链表
  • 节点采用ZipList,解决了传统链表的内存占用问题
  • 控制了ZipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

好了小伙伴们,中篇就到此结束,还有两个skiplist,intset希望小伙伴们与我继续进步下去!!