- 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
- 📕系列专栏:Spring源码、JUC源码、Kafka原理、分布式技术原理、数据库技术
- 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
- 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
- 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀
动态字符串SDS
SDS 简介
无论是 Redis 的 Key 还是 Value,其基础数据类型都是字符串。例如,Hash 型 Value 的field 与 value 的类型、List 型、Set 型、ZSet 型 Value 的元素的类型等都是字符串。虽然 Redis是使用标准 C 语言开发的,但并没有直接使用 C 语言中传统的字符串表示,而是自定义了一种字符串。这种字符串本身的结构比较简单,但功能却非常强大,称为简单动态字符串,Simple Dynamic String,简称 SDS。
注意,Redis 中的所有字符串并不都是 SDS,也会出现 C 字符串。C 字符串只会出现在字符串"字面常量"中,并且该字符串不可能发生变更。
SDS 结构
SDS 不同于 C 字符串。C 字符串本身是一个以双引号括起来,以空字符'\0'结尾的字符序列。但 SDS 是一个结构体,定义在 Redis 安装目录下的 src/sds.h 中:
c
struct sdshdr {
// 字节数组,用于保存字符串
char buf[];
// buf[]中已使用字节数量,称为 SDS 的长度
int len;
// buf[]中尚未使用的字节数量
int free;
}
例如执行 SET country "China"命令时,键 country 与值"China"都是 SDS 类型的,只不过一个是 SDS 的变量,一个是 SDS 的字面常量。"China"在内存中的结构如下:
通过以上结构可以看出,SDS 的 buf 值实际是一个 C 字符串,包含空字符'\0'共占 6 个字节。但 SDS 的 len 是不包含空字符'\0'的。
该结构与前面不同的是,这里有 3 字节未使用空间。
SDS 的优势
C 字符串使用 Len+1 长度的字符数组来表示实际长度为 Len 的字符串,字符数组最后以空字符'\0'结尾,表示字符串结束。这种结构简单,但不能满足 Redis 对字符串功能性、安全性及高效性等的要求。
防止"字符串长度获取"性能瓶颈
对于 C 字符串,若要获取其长度,则必须要通过遍历整个字符串才可获取到的。对于超长字符串的遍历,会成为系统的性能瓶颈。
但,由于 SDS 结构体中直接就存放着字符串的长度数据,所以对于获取字符串长度需要消耗的系统性能,与字符串本身长度是无关的,不会成为 Redis 的性能瓶颈。
保障二进制安全
C 字符串中只能包含符合某种编码格式的字符,例如 ASCII、UTF-8 等,并且除了字符串末尾外,其它位置是不能包含空字符'\0'的,否则该字符串就会被程序误解为提前结束。而在图片、音频、视频、压缩文件、office 文件等二进制数据中以空字符'\0'作为分隔符的情况是很常见的。故而在 C 字符串中是不能保存像图片、音频、视频、压缩文件、office 文件等二进制数据的。
但 SDS 不是以空字符'\0'作为字符串结束标志的,其是通过 len 属性来判断字符串是否结束的。所以,对于程序处理 SDS 中的字符串数据,无需对数据做任何限制、过滤、假设,只需读取即可。数据写入的是什么,读到的就是什么。
减少内存再分配次数
SDS 采用了空间预分配策略 与惰性空间释放策略来避免内存再分配问题。
空间预分配策略是指,每次 SDS 进行空间扩展时,程序不但为其分配所需的空间,还会为其分配额外的未使用空间,以减少内存再分配次数。而额外分配的未使用空间大小取决于空间扩展后 SDS 的 len 属性值。
- 如果 len 属性值小于 1M,那么分配的未使用空间 free 的大小与 len 属性值相同。
- 如果 len 属性值大于等于 1M ,那么分配的未使用空间 free 的大小固定是 1M。
SDS 对于空间释放采用的是惰性空间释放策略。该策略是指,SDS 字符串长度如果缩短,那么多出的未使用空间将暂时不释放,而是增加到 free 中。以使后期扩展 SDS 时减少内存再分配次数。
兼容 C 函数
Redis 中提供了很多的 SDS 的 API,以方便用户对 Redis 进行二次开发。为了能够兼容 C函数,SDS 的底层数组 buf[]中的字符串仍以空字符'\0'结尾。 现在要比较的双方,一个是 SDS,一个是 C 字符串,此时可以通过 C 语言函数strcmp(sds_str->buf,c_str)
新版本的SDS
SDS结构大致上可以分为2大部分:header部分和buff部分,并且header部分和buff部分在内存中是连续的。
header部分
header部分保存的是SDS一些源数据信息。
其中:
len
表示: 字符串的长度;alloc
表示:字符串的长度 + 额外预留空间的长度。alloc
代表可用来存储字符空间的总大小,但是不包括null-term
,所以是比实际分配出来的buff长度减1;
除了我们上面提到过的len和alloc,这里还多了一个flag字段。flag字段决定len和alloc的变量类型。具体什么意思呢?因为字符串大小不固定有长有短,比如我们要保存"hello"这个字符串,那么对于len和alloc变量来说,最合适的变量类型应该是uint8,用一个字节来存储就够了,如果用uint16、uint32或uint64都显得有点太浪费。基于此SDS根据需要保存的字符串长度设计了如下5种flag类型,flag本身占用1个字节,如下表所示:
flag | flag_value | 字符串长度(len)字节 | (len、alloc) type |
---|---|---|---|
SDS_TYPE_5 | 0 | 特殊 | 特殊 |
SDS_TYPE_8 | 1 | len < (1 << 8) 256 | uint8 |
SDS_TYPE_16 | 2 | len < (1 << 16) 65536 | uint16 |
SDS_TYPE_32 | 3 | len < (1 << 32) | uint32 |
SDS_TYPE_64 | 4 | len >= (1 << 32) | uint64 |
假设我们要保存的字符串长度为len:
- SDS_TYPE_5:这个比较特殊,注释上说未被使用,但是也不完全准确,无论如何这个类型都不是我们讨论的重点。
- SDS_TYPE_8: 当len 小于 1 << 8,也就是小于256时,变量len和alloc用uint8类型。
- SDS_TYPE_16: 当len 小于 1 << 16,也就是小于65536时,变量len和alloc用uint16类型。
- SDS_TYPE_32: 当len 小于 1 << 32,也就是小于4294967296时,变量len和alloc用uint32类型。
- SDS_TYPE_64: 当len 大于等于 1 << 32,也就是大于等于4294967296时,变量len和alloc用uint64类型。
我们来举个简单例子,假设我们要用SDS("hello")保存"hello"这个字符串。"hello"长度为5个字节,len为5,是小于 1 << 8。因此flag为SDS_TYPE_8
,len
、alloc
变量类型为uint8各自占用一个字节,flag始终占用1个字节,如下图所示:
len值为5:表示hello字符串长度为5个字节;
alloc值为12: 表示一共分配出来可用空间12字节。buff总长度为13字节,由于null-term要额外占用1字节不能计算在内,因此需要减1;
flag值为1: 表示的是SDS_TYPE_8类型;
buff部分
Buff比较简单主要分为2个部分: 第一个部分是原生的C字符串,以null-term
结尾,第二个部分是还未使用的额外空间,如下图所示。
前面说过SDS可以在一定程度上兼容C字符串,只要C-String部分是binary-safe
,用户可以拿到SDS的buff起始地址(图中return to user),在只读场景当作原生C字符串使用。
还是以前面的"hello"字符串为例:
buff部分索引0~5存储"hello"的C字符串,索引6到索引12是预留空间还未使用。如果后续需要追加字符串并且在8个字节以内,即可直接修改,无需重新分配内存空间。
SDS扩容规则
当我们要往原SDS追加字符串时会触发SDS的扩容,为了阐述方便,我们定义如下变量:
len
= 原SDS长度;
avail
= 原SDS剩余预留空间长度;
addlen
= 追加字符串的长度;
newlen
= len + addlen 追加后字符串的总长度;
SDS的扩容规则如下:
-
如果原SDS预留空间空间avail大于等于追加字符串的长度addlen,不会触发扩容。
-
如果追加后的字符串总长度newlen小于1MB(1024*1024),将newlen扩容为2倍再加1(null-term),也就是newlen = newlen * 2 + 1。
-
如果追加后的字符串总长度newlen大于等于1MB(1024*1024),将newlen额外扩容1MB再加1(null-term),也就是newlen = newlen + 1MB + 1。
需要注意的是,如果newlen大于flag
所指定的范围,flag
的类型也需要随之变大。
我们来举几个例子,依次看一下这2个扩容规则:
规则1:假设有如下图的SDS("hello"),我们要追加字符串",world"。
变量初始化如下:
len = 5;
avail = alloc - len = 12 - 5 = 7;
addlen = ",world"的长度 = 6;
newlen = 5 + 6 = 11;
按照扩容规则,avail > addlen,不会触发扩容,因此可以原地追加,追加后的SDS如下图所示:
规则2:上面的SDS("hello,world")基础上,我们继续追加字符串";nihao"。
变量初始化如下:
len = 11;
avail = alloc - len = 12 - 11 = 1;
addlen = ";nihao"的长度 = 6;
newlen = 11 + 6 = 17;
按照扩容规则,avail < addlen,不满足规则1,newlen < 1MB,满足扩容规则2,因此扩容后的newlen为:
newlen = 17 * 2 + 1 = 35;
如下图所示:
总结
SDS巧妙的利用空间换时间
的思想,虽然额外牺牲了一些空间,但是换来的是在高频场景下更佳优异的性能表现以及更好的兼容性。
集合的底层实现原理
Redis 中对于 Set 类型的底层实现,直接采用了 hashTable。但对于 Hash、ZSet、List 集合的底层实现进行了特殊的设计,使其保证了 Redis 的高性能。
两种实现的选择
对于Hash与ZSet集合,其底层的实现实际有两种:压缩列表zipList,与跳跃列表skipList。这两种实现对于用户来说是透明的,但用户写入不同的数据,系统会自动使用不同的实现。只有同时满足以配置文件 redis.conf 中相关集合元素数量阈值与元素大小阈值两个条件,使用的就是压缩列表 zipList,只要有一个条件不满足使用的就是跳跃列表 skipList。例如,对于ZSet 集合中这两个条件如下:
- 集合元素个数小于 redis.conf 中 zset-max-ziplist-entries 属性的值,其默认值为 128
- 每个集合元素大小都小于 redis.conf 中 zset-max-ziplist-value 属性的值,其默认值为 64字节
zipList
什么是 zipList
zipList,通常称为压缩列表,是一个经过特殊编码的用于存储字符串或整数的双向链表。其底层数据结构由三部分构成:head、entries 与 end。这三部分在内存上是连续存放的。
head
head 又由三部分构成:
- zlbytes:占 4 个字节,用于存放 zipList 列表整体数据结构所占的字节数,包括 zlbytes本身的长度。
- zltail:占 4 个字节,用于存放 zipList 中最后一个 entry 在整个数据结构中的偏移量(字节)。该数据的存在可以快速定位列表的尾 entry 位置,以方便操作。
- zllen:占 2 字节,用于存放列表包含的 entry 个数。由于其只有 16 位,所以 zipList 最多可以含有的 entry 个数为 2^16-1 = 65535 个。
entries
entries 是真正的列表,由很多的列表元素 entry 构成。由于不同的元素类型、数值的不同,从而导致每个 entry 的长度不同。
每个 entry 由三部分构成:
- prevlength:该部分用于记录上一个 entry 的长度,以实现逆序遍历。默认长度为 1 字节,只要上一个 entry 的长度<254 字节,prevlength 就占 1 字节,否则其会自动扩展为 3 字节长度。
- encoding:该部分用于标志后面的 data 的具体类型。如果 data 为整数类型,encoding固定长度为 1 字节。如果 data 为字符串类型,则 encoding 长度可能会是 1 字节、2 字节或 5 字节。data 字符串不同的长度,对应着不同的 encoding 长度。
- data:真正存储的数据。数据类型只能是整数类型或字符串类型。不同的数据占用的字节长度不同。
end
end 只包含一部分,称为 zlend。占 1 个字节,值固定为 255,即二进制位为全 1,表示一个 zipList 列表的结束。
listPack
对于 ziplist,实现复杂,为了逆序遍历,每个 entry 中包含前一个 entry 的长度,这样会导致在 ziplist 中间修改或者插入 entry 时需要进行级联更新。在高并发的写操作场景下会极度降低 Redis 的性能。为了实现更紧凑、更快的解析,更简单的实现,重写实现了 ziplist,并命名为 listPack。
在 Redis 7.0 中,已经将 zipList 全部替换为了 listPack,但为了兼容性,在配置中也保留了 zipList 的相关属性。
什么是 listPack
listPack 也是一个经过特殊编码的用于存储字符串或整数的双向链表。其底层数据结构也由三部分构成:head、entries 与 end,且这三部分在内存上也是连续存放的。
listPack与zipList的重大区别在head与每个entry的结构上,表示列表结束的end与zipList的 zlend 是相同的,占一个字节,且 8 位全为 1。
head
head 由两部分构成:
- totalBytes:占 4 个字节,用于存放 listPack 列表整体数据结构所占的字节数,包括totalBytes 本身的长度。
- elemNum:占 2 字节,用于存放列表包含的 entry 个数。其意义与 zipList 中 zllen 的相同。
与 zipList 的 head 相比,没有了记录最后一个 entry 偏移量的 zltail。
entries
entries 也是 listPack 中真正的列表,由很多的列表元素 entry 构成。由于不同的元素类型、数值的不同,从而导致每个 entry 的长度不同。但与 zipList 的 entry 结构相比,listPack的 entry 结构发生了较大变化。
其中最大的变化就是没有了记录前一个 entry 长度的 prevlength,而增加了记录当前entry 长度的 element-total-len。而这个改变仍然可以实现逆序遍历,但却避免了由于在列表中间修改或插入 entry 时引发的级联更新。
每个 entry 仍由三部分构成:
- encoding:该部分用于标志后面的 data 的具体类型。如果 data 为整数类型,encoding长度可能会是 1、2、3、4、5 或 9 字节。不同的字节长度,其标识位不同。如果 data为字符串类型,则 encoding 长度可能会是 1、2 或 5 字节。data 字符串不同的长度,对应着不同的 encoding 长度。
- data:真正存储的数据。数据类型只能是整数类型或字符串类型。不同的数据占用的字节长度不同。
- element-total-len:该部分用于记录当前 entry 的长度,用于实现逆序遍历。由于其特殊的记录方式,使其本身占有的字节数据可能会是 1、2、3、4 或 5 字节。
逆序遍历
在 Redis 中,ziplist 是一种紧凑的、压缩的、连续的内存数据结构,用于存储列表、哈希和有序集合等数据类型。listPack 是 ziplist 的一种变体,用于存储列表数据。
要实现逆序遍历 ziplist 或 listPack,可以使用以下步骤:
首先,通过指针定位到整个 ziplist 或 listPack 的末尾(即最后一个节点)。
然后,从末尾节点开始按照逆序遍历的顺序向前遍历。
每次迭代时,可以使用 prevlength 字段(对于 ziplist)或 element-total-len 字段(对于 listPack)来获取当前节点的长度。
通过减去当前节点的长度,可以得到上一个节点在内存中的位置,然后将指针移动到上一个节点。
重复上述步骤,直到遍历完所有节点。
需要注意的是,ziplist 和 listPack 的数据结构比较复杂,包含了多个字段和指针。在实际操作中,需要仔细处理指针的移动和字段的解析,以确保正确地实现逆序遍历。
skipList
什么是 skipList
skipList,跳跃列表,简称跳表,是一种随机化的数据结构,基于并联的链表,实现简单,查找效率较高。简单来说跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能。也正是这个跳跃功能,使得在查找元素时,能够提供较高的效率。
skipList 原理
假设有一个带头尾结点的有序链表。
在该链表中,如果要查找某个数据,需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点,或者找到最后尾结点,后两种都属于没有找到的情况。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。
为了提升查找效率,在偶数结点上增加一个指针,让其指向下一个偶数结点。
这样所有偶数结点就连成了一个新的链表(简称高层链表),当然,高层链表包含的节点个数只是原来链表的一半。此时再想查找某个数据时,先沿着高层链表进行查找。当遇到第一个比待查数据大的节点时,立即从该大节点的前一个节点回到原链表中进行查找。例如,若想插入一个数据 20,则先在(8,19,31,42)的链表中查找,找到第一个比 20 大的节点 31,然后再在高层链表中找到 31 节点的前一个节点 19,然后再在原链表中获取到其下一个节点值为 23。比 20 大,则将 20 插入到 19 节点与 23 节点之间。若插入的是 25,比节点23 大,则插入到 23 节点与 31 节点之间。
该方式明显可以减少比较次数,提高查找效率。如果链表元素较多,为了进一步提升查找效率,可以将原链表构建为三层链表,或再高层级链表。
层级越高,查找效率就会越高。
存在的问题
这种对链表分层级的方式从原理上看确实提升了查找效率,但在实际操作时就出现了问题:由于固定序号的元素拥有固定层级,所以列表元素出现增加或删除的情况下,会导致列表整体元素层级大调整,但这样势必会大大降低系统性能。
例如,对于划分两级的链表,可以规定奇数结点为高层级链表,偶数结点为低层级链表。对于划分三级的链表,可以按照节点序号与 3 取模结果进行划分。但如果插入了新的节点,或删除的原来的某些节点,那么定会按照原来的层级划分规则进行重新层级划分,那么势必会大大降低系统性能
算法优化
为了避免前面的问题,skipList 采用了随机分配层级方式。即在确定了总层级后,每添加一个新的元素时会自动为其随机分配一个层级。这种随机性就解决了节点序号与层级间的固定关系问题。
上图演示了列表在生成过程中为每个元素随机分配层级的过程。从这个 skiplist 的创建和插入过程可以看出,每一个节点的层级数都是随机分配的,而且新插入一个节点不会影响到其它节点的层级数。只需要修改插入节点前后的指针,而不需对很多节点都进行调整。这就降低了插入操作的复杂度。
skipList 指的就是除了最下面第 1 层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针跳过了一些节点,并且越高层级的链表跳过的节点越多。在查找数据的时先在高层级链表中进行查找,然后逐层降低,最终可能会降到第 1 层链表来精确地确定数据位置。在这个过程中由于跳过了一些节点,从而加快了查找速度。
quickList
什么是 quickList
quickList,快速列表,quickList 本身是一个双向无循环链表,它的每一个节点都是一个zipList。从Redis3.2版本开始,对于List的底层实现,使用quickList替代了zipList 和 linkedList。
zipList 与 linkedList 都存在有明显不足,而 quickList 则对它们进行了改进:吸取了 zipList 和 linkedList 的优点,避开了它们的不足。
quickList 本质上是 zipList 和 linkedList 的混合体。其将 linkedList 按段切分,每一段使用 zipList 来紧凑存储若干真正的数据元素,多个 zipList 之间使用双向指针串接起来。当然,对于每个 zipList 中最多可存放多大容量的数据元素,在配置文件中通过 list-max-ziplist-size属性可以指定。
检索操作
为了更深入的理解 quickList 的工作原理,通过对检索、插入、删除等操作的实现分析来加深理解。
对于 List 元素的检索,都是以其索引 index 为依据的。quickList 由一个个的 zipList 构成,每个 zipList 的 zllen 中记录的就是当前 zipList 中包含的 entry 的个数,即包含的真正数据元素的个数。根据要检索元素的 index,从 quickList 的头节点开始,逐个对 zipList 的 zllen 做 sum求和,直到找到第一个求和后 sum 大于 index 的 zipList,那么要检索的这个元素就在这个zipList 中。
插入操作
由于 zipList 是有大小限制的,所以在 quickList 中插入一个元素在逻辑上相对就比较复杂一些。假设要插入的元素的大小为 insertBytes,而查找到的插入位置所在的 zipList 当前的大小为 zlBytes,那么具体可分为下面几种情况:
- 情况一:当 insertBytes + zlBytes <= list-max-ziplist-size 时,直接插入到 zipList 中相应位置即可
- 情况二:当 insertBytes + zlBytes > list-max-ziplist-size,且插入的位置位于该 zipList 的首部位置,此时需要查看该 zipList 的前一个 zipList 的大小 prev_zlBytes。
若 insertBytes + prev_zlBytes<= list-max-ziplist-size 时,直接将元素插入到前一个zipList 的尾部位置即可
若 insertBytes + prev_zlBytes> list-max-ziplist-size 时,直接将元素自己构建为一个新的 zipList,并连入 quickList 中
- 情况三:当 insertBytes + zlBytes > list-max-ziplist-size,且插入的位置位于该 zipList 的尾部位置,此时需要查看该 zipList 的后一个 zipList 的大小 next_zlBytes。
若 insertBytes + next_zlBytes<= list-max-ziplist-size 时,直接将元素插入到后一个zipList 的头部位置即可
若 insertBytes + next_zlBytes> list-max-ziplist-size 时,直接将元素自己构建为一个新的 zipList,并连入 quickList 中
- 情况四:当 insertBytes + zlBytes > list-max-ziplist-size,且插入的位置位于该 zipList 的中间位置,则将当前 zipList 分割为两个 zipList 连接入 quickList 中,然后将元素插入到分割后的前面 zipList 的尾部位置
删除操作
对于删除操作,只需要注意一点,在相应的 zipList 中删除元素后,该 zipList 中是否还有元素。如果没有其它元素了,则将该 zipList 删除,将其前后两个 zipList 相连接。