接着之前的SDS结构,这篇我们开始介绍Hash结构( ̄∇ ̄)/🎉
Hash数据结构
看过前面的介绍,大家应该知道 Redis 的 Hash 结构的底层实现在 6 和 7 是不同的,Redis 6 是 ziplist 和 hashtable,Redis 7 是 listpack 和 hashtable
Redis 6.0.5 及其以前的Hash是由哈希表+压缩列表zipList组成
2021.11.29以后,Redis 7 以后Hash是由哈希表+紧凑列表listpack组成
我们先使用config get hash*看下 Redis 6 和 Redis 7 的 Hash 结构配置情况(在Redis客户端的命令行界面中使用INFO server可以查看包括版本号等各个信息)
通过Docker拉取一个Redis7的镜像,同样的命令查看相关信息
可以看到Redis7多了hash-max-listpack-entries和hash-max-listpack-value两项,我们先介绍下这四个参数的含义:
- hash-max-ziplist-entries:使用压缩列表保存数据时,哈希集合中最大的元素个数
- hash-max-ziplist-value:使用压缩列表保存数据时,哈希集合中单个元素的最大长度
-
- 单位byte:一个英文字母一个byte
- hash-max-listpack-entries:使用紧凑列表保存数据时,哈希集合中最大的元素个数
- hash-max-listpack-value:使用紧凑列表保存数据时,哈希集合中单个元素的最大长度
上面两张图中的参数都是默认情况,为了方便测试效果我们把这两个值都改小些(如下图)
依次存储不同长度的值,并查看编码方式
有上面两张图可以看到,当同时满足字段个数小于hash-max-ziplist-entries并且字段值都小于hash-max-ziplist-value时,才会使用OBJ_ENCODING_ZIPLIST的编码方式进行存储,二者不满足任意一个就会转换为OBJ_ENCODING_HT的编码方式进行存储,并且不可逆(OBJ_ENCODING_ZIPLIST变成OBJ_ENCODING_HT可以,但是即便存储的数据又满足了上面提到的两个要求,OBJ_ENCODING_HT也不会再变回OBJ_ENCODING_ZIPLIST(反反复复浪费性能),相比较OBJ_ENCODING_HT,OBJ_ENCODING_ZIPLIST会更节省内存空间)。
逻辑流程图
源码分析
t_hash.c
hashtable在Redis中被称为"字典",他是一个数组+链表的结构
OBJ_ENCODING_HT这种编码方式内部才是真正的哈希表结构,或称为字典结构,有一个dictEntry,详见下图源代码:
OBJ_ENCODING_HT这种编码方式内部才是真正的哈希结构(或称为字典结构),其可以实现O(1)的时间复杂度的读写操作,因此效率很高,在Redis内部,从OBJ_ENCODING_HT类型到底层真正的散列表数据结构是一层一层嵌套起来的,组织关系如下图:
从宏观到微观:
从微观到宏观:
当执行hset命令时,底层实际操作如下(下图为Redis 6的源代码):
我们进入方法
Check the length of a number of objects to see if we need to convert a
- ziplist to a real hash. Note that we only check string encoded objects
- as their string length can be queried in constant time.
我们翻一下方法上面的注释:
检查对象们的长度,看看是否需要将 ziplist 转换为实际的哈希(这里以Redis 6的源代码为示例,如果是Redis 7及之后则使用 listpack 替换了ziplist,具体原因会在后面详细分析),其实我们只检查字符串编码的对象 ,因为它们的字符串长度可以在恒定时间内查询。 <img src<img src="=""" alt="" width="50%" /> alt="" width="50%" />
ziplist.c
Ziplist压缩列表是一种紧凑编码格式,总体思想是多花时间来换取节约空间,即以牺牲部分读写性能为代价,来换取极高的内存空间利用率,因此只会用于字段个数少,且字段值也较小的场景。压缩列表内存利用率极高的原因与其连续内存的特性是分不开的。
当一个hash对象只包含少量的键值对且每个键值对的键和值要么就是小整数要么就是长度上比较短的字符串,那么它用ziplist作为底层实现。
ziplist是为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组,但实际上它是一种特殊的双向链表(不存储指向前一个节点和后一个节点的指针,而存的是上一个节点长度 和当前节点的长度),通过牺牲部分读写性能,来换取空间利用率(节约内存,空间换时间),因此只用在字段个数并且字段值较小的场景。
ziplist的组成
我们来看看ziplist到底长什么样儿~
暂时无法在飞书文档外展示此内容
我们分别解释下上面👆各组成单元的含义:
- zlbytes:记录整个压缩列表占用的内存字节数
-
- 在对压缩列表进行内存重分配或者计算zlend的位置时使用
- zltail:记录压缩列表尾节点距离起始地址的偏移量(有多少个字节)
-
- 通过这个偏移量可以快速确定该压缩列表尾节点的地址(无需遍历整个列表)
- zllen:记录整个压缩列表的节点数量
-
- 前提是节点数<65535(UINT16_MAX),如果zllen=65535,那么该压缩列表的节点数还是要通过遍历来获得
- entryN:各个节点
- zlend:结尾标识符0xFF(只有1字节),用于标记列表末端
详解节点(zlentry)的构成
压缩列表ziplist的节点是zlentry,这个zlentry节点可以对比Map.Entry理解,每个zlentry节点主要由前一个节点的长度+encoding+data三部分组成,我们先看下他的源码结构:
分别解释下
arduino
typedef struct zlentry {
unsigned int prevrawlensize; /* 前一个节点的长度的字节数 */
unsigned int prevrawlen; /* 存储"前一个节点的长度"这个值所用的字节数 */
unsigned int lensize; /* 存储"当前链表节点占用的长度"这个值所用的字节数*/
unsigned int len; /* 当前链表节点占用的长度 */
unsigned int headersize; /* 当前链表头部大小(prevrawlensize + lensize),就是非数据域大小. */
unsigned char encoding; /* 编码方式 */
unsigned char *p; /* 当前节点的指针,压缩链表以字符串的形式保存指针指向当前节点起始位置 */
} zlentry;
其中prevlen记录了前一个节点的长度,encoding记录了当前节点实际数据的类型以及长度,data则记录了当前节点的实际数据。
值得注意的是,关于前节点占用的内存字节数(prevlen)表示的是前一个zlentry的长度,有两个取值情况:1字节和5字节
- 1字节:表示上一个entry节点的长度小于254字节
- 5字节:表示上一个entry节点的长度大于254字节
虽然1字节的值能表示的数值范围是0-255,但是压缩列表中zlend的取值默认是255,所以默认使用255表示整个压缩列表的结束(其他地方就不使用255这个值了),因此当上一个entry长度小于254字节时,prevlen的值就取1字节,否则就取5字节,这样记录长度更省内存。
链表在内存中,一般是不连续的,因此遍历的速度比较慢,ziplist就很好的优化了这一点(由于ziplist的entry是连续的,即知道了起始位置就可以根据记录的每个节点的长度获取的每个entry的地址)
产生的原因
都已经有了链表了,为什么还要搞一个压缩链表出来呢?
- 我们了解到的是普通的双向链表会有两个指针(一个指向前一个节点,一个指向后一个节点),但是当存储的数据很小时,可能存储的实际数据的大小可能还没有指针占用的空间大。ziplist的特点就是没有维护这两个指针,而是存储上一个entry的长度和当前entry的长度,由于ziplist的entry是连续的,即知道了起始位置就可以根据记录的每个节点的长度获取的每个entry的地址。只其实是一种牺牲读取性能,以获得高效的存储空间的策略,是典型的"时间换空间"。
- 此外,链表在内存中,一般是不连续的,因此遍历的速度比较慢,ziplist就很好的优化了这一点,即可以像访问普通数组一样(例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)),但是ziplist的每个节点的长度是可以不一样,而我们面对不同长度的节点又不可能直接sizeof(entry),所以就将一些必要的偏移量信息记录在了每一个节点里,使之能通过简单的计算获取的每个entry的地址。
- 同时头节点里有一个参数len,这个参数跟上一篇介绍的SDS类似,用来记录链表长度,以便可以直接获取到链表长度而不用再遍历整个链表(时间复杂度是O(1))
总结
在Redis 6及其以前的版本中,ziplist 为了节省内存而采用了紧凑的连续存储方式 ,ziplist是一个双向链表,可以在时间复杂度O(1)下从头部 或者尾部 进行插入/删除操作,但是不能保存过多的元素,否则查询效率会降低(每个节点要计算),这也是为什么redis当同时满足字段个数小于hash-max-ziplist-entries并且字段值都小于hash-max-ziplist-value时,才会使用OBJ_ENCODING_ZIPLIST的编码方式进行存储,二者不满足任意一个就会转换为OBJ_ENCODING_HT的编码方式进行存储。此外还有一个很严重的问题,就是ziplist在新增/更新元素有可能会出现连锁更新的问题,因此在Redis 7及以后被listpsck所取代。
listpack.c
首先啰嗦下,listpack是用来代替ziplist的,因此总的来说,他们是两个入口,一个落地(Redis 6是ziplist,Redis 7是listpack),并且紧凑链表listpack的修改会相互影响的,我们来做个小实验
先通过命令config get hash*看下默认设置:
- hash-max-listpack-entries表示使用listpack保存时哈希表集合中最大的元素个数
- hash-max-listpack-value表示使用listpack保存时哈希表中单个元素的最长长度
修改下hash-max-listpack-entries和hash-max-listpack-value的值
再次查看可以发现listpack和ziplist都被修改了
同样的,我们修改了hash-max-ziplist-entries和hash-max-ziplist-value的值,hash-max-listpack-entries和hash-max-listpack-value也会被修改
其实listpack和ziplist的规则基本一致:当哈希对象保存的键值对数量小于512个 并且所有的键值对的健和值的字符串长度都小于等于64byte(1个英文字母就是1个字节)时用listpack,反之用hashtable,并且listpack升级到hashtable后,不会再变回listpack(就和ziplist一毛一样。。。)。
我们先来对比下Redis 6和Redis 7对于HashObject的实现上有什么不同:
接下来看下方法lpNew()
实现:
大致解释下这个方法里都干了些啥
先创建一个空的listpack并分配LP_HDR_SIZE+1大小的字节空间,LP_HDR_SIZE的值为6(字节),其中4字节记录listpack的总字节数,2个字节记录listpack的元素数
最后那个字节用来标识listpack的结束,默认为宏定义LP_EOF(255,和ziplist一致)
随后就进入到了方法creatObject()
产生的原因
既然明明有了ziplist,为什么还要多出一个listpack呢?
这主要是由于ziplist的连锁更新的问题,由于压缩列表中每个节点中的prevlen属性都记录了前一个节点的长度,而且prevlen属性的空间大小跟前一个节点长度值有关,我们来看下如果前一个节点的长度<254字节,那么当前节点的prevlen就会用1字节的空间来保存这个长度值,如果前一个节点的长度>=254字节,那么当前节点的prevlen就会用5字节的空间来保存这个长度值。
这样当压缩列表需要进行新增/修改时,如果空间不够,压缩列表就要重新分配内存空间,而当插入的数据较大时,就可能会导致后面元素的prevlen都要发生变化,这就是"连锁更新"问题,这种情况每个元素的空间都要重新分配,造成访问压缩列表性能下降。
我们看个案例:
有一个压缩列表,它有多个连续的长度在250-253之间的节点,由于这些节点的长度都<254,因此它们的prevlen属性都需要用1字节来保存这个长度值,现在,我们有一个长度>=254的节点要加入到列表头(成为节点1的前置节点),这个时候,节点1的prevlen属性就需要从1字节变为5字节,于是节点2的prevlen属性也需要从1字节变为5字节,......以此类推,就出现了传说中的连续更新的问题,这个问题会造成访问压缩列表性能下降,也就有了本章节介绍的listpack。
详解listpack的结构
参考地址
这里先提到了ziplist的结构
然后给出了listpack的结构
从上图可以看出,listpack由4部分构成
- :整个listpack的空间大小(占4字节),每个listpack最多42949672953bytes
- :listpack的元素个数(占2字节)
- :具体的元素
- :结束标志(占1字节,0xFF)
而每一个都是一个listpackEntry,由三个部分构成:
- :当前元素的编码类型
- :元素数据
- 编码类型和元素数据这两部分的长度
来画张图~
结束^ ^撒花🎉~~~