redis数据结构分析(上)

592 阅读12分钟

在《Redis设计与实现》这样描述: Redis 数据库里面的每个键值对(key-value) 都是由对象(object)组成的:

数据库键总是一个字符串对象(string object); 数据库的值则可以是字符串对象、列表对象(list)、哈希对象(hash)、集合对象(set)、有序集合(sort set)对象这五种对象中的其中一种。

Redis基本数据结构:动态字符串,链表,字典,跳表,整数集合。

动态字符串

Redis 是一个开源的使用ANSI C语言编写的key-value 数据库,但是其字符串对象并没有直接使用C语言传统的字符串表示,而是自己构建了一种名为简单动态字符串(simple dynamic string SDS)的抽象类型,并将SDS用作Redis 的默认字符串表示。

注意:结尾的字符\0,不算入字符串的len中,也不作为redis的结束符,只是为了兼容C语言而设计的。且开发者不需要对此进行额外处理,为空字符分配额外的1字节空间以及添加空字符到字符串末尾都是由SDS函数自行处理。

使用示例

插入一个key为msg,value为hello world的键值对。可以查看键和值的底层数据结构,都是SDS。

redis>SET msg "hello world"
OK

内存空间预分配

字符串长度 分配规则 最后实际占用空间大小
len < 1MB free = len sds= free+len+1= len*2+1
len >= 1MB free = 1MB sds= free+ len+1 = 1MB +len +1

字符串长度小于1MB时,分配给字符串2倍的空间;字符串长度大于等于1MB,分配多1MB的内存空间。

分配内存步骤示例

如果字符串a=“aaa1111111”,字符串实际长度为10,按照规则,实际分配的内存空间为21,则free为10,len为10。

现在修改a=”aaa1111111bbb",那么由于之前free=10,内存空间足够,不需要重新分配,可以直接存入。

字符串的内存空间只有在不够用的时候才重新分配,如果足够,则一直都不需要重新分配。

惰性空间释放

假定a="aaa1111111"(实际占用21个字符,len=10,free=10,1个\0字符)

将a中的部分字符移除,修改为a=“aaa”,那么此时a现在的空间是多少呢? 答案还是(len=3,free=17,1个\0字符)。

SDS在减少字符串的时候,并不会重新分配内存,而是将空出来的一起做为备用空间,为下一次增加提供优化。 另外,SDS 提供了相应的API,可以在有需要的时候,自行释放SDS 的空余空间。

SDS vs C语言字符串

  • C语言字符串不记录自身长度,每次查看字符串长度都需要O(n)的时间复杂度,而SDS则为len直接可以看到,所以复杂度为O(1)。
  • C语言字符串在修改字符串时,如果字符串大小超过当前内存空间,需要先进行内存重分配,否则会出现缓冲区溢出; 如果减少字符串比如执行a= a.trim(),那么需要通过内存重新分配释放字符串不再使用的那部分空间,否则会造成内存泄漏。操作相对于SDS会比较复杂。
  • C语言字符串使用空字符作为标志字符串结尾,导致C字符串中只能保存文本数据,不能保存图片,音频,视频等二进制数据,因为实际内容中不能包含空字符。SDS是根据len来确定字符串长度的,因此可以保存二进制数据。

链表

Redis将列表数据结构命名为list而不是array,是因为列表的存储结构用的是链表而不是数组,而且链表还是双向链表。 链表的随机定位性能较弱,首尾插入删除性能较优。如果list的列表长度很长,使用时我们一定要关注链表相关操作的时间复杂度。

使用示例

链表可以从表头和表尾追加和移除元素,结合使用rpush/rpop/lpush/lpop四条指令,可以将链表作为队列或堆栈使用,左向右向进行都可以,因此可以模拟队列和堆栈操作。

> rpush ireader go 
(integer) 1 
> rpush ireader java python 
(integer) 3 
> lpop ireader 
"go"

可以使用正下标自然数0,1,2,....n-1和负下标-1,-2,...-n来表示链表位置。-1表示「倒数第一」,-2表示「倒数第二」,那么-n就表示第一个元素,对应的下标为0。

> lrange ireader 0 2
1) "go"
2) "java"
3) "python"
> lrange ireader 0 -1  # -1表示倒数第一
1) "go"
2) "java"
3) "python"

链表的底层存储结构

Redis 3.2版本之前,当列表对象中元素的长度比较小或者数量比较少的时候,采用ziplist来存储,当列表对象中元素的长度比较大或者数量比较多的时候,则会转而使用双向列表linkedlist来存储。 在版本3.2之后,引入了一个 quicklist 的数据结构,列表的底层都由quicklist实现,quicklist是一个以ziplist为节点的双向链表。

linkedlist和ziplist单独使用的缺点

  • 双向链表linkedlist便于在表的两端进行push和pop操作,在插入节点上复杂度很低,但是内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
  • ziplist存储在一段连续的内存上,存储效率很高。但是,它不利于修改操作,插入和删除操作需要频繁的申请和释放内存。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝。

ziplist(压缩列表)

参考: redisbook.readthedocs.io/en/latest/c…

因为 ziplist 节约内存的性质, 哈希键、列表键和有序集合键初始化的底层实现皆采用 ziplist。

ziplist的结构

长度/类型 域的值
zlbytes uint32_t 整个 ziplist 占用的内存字节数,对 ziplist 进行内存重分配,或者计算末端时使用。
zltail uint32_t 到达 ziplist 表尾节点的偏移量。 通过这个偏移量,可以在不遍历整个 ziplist 的前提下,弹出表尾节点。
zllen uint16_t ziplist 中节点的数量。 当这个值小于 UINT16_MAX (65535)时,这个值就是 ziplist 中节点的数量; 当这个值等于 UINT16_MAX 时,节点的数量需要遍历整个 ziplist 才能计算得出。
entryX ? ziplist 所保存的节点,各个节点的长度根据内容而定。
zlend uint8_t 255 的二进制值 1111 1111 (UINT8_MAX) ,用于标记 ziplist 的末端。

ziplist中entry节点的结构

pre_entry_length记录了前一个节点的长度,通过这个值,可以进行指针计算,从而跳转到上一个节点:用指向当前节点的指针e,减去pre_entry_length的值(假定为0000 0101 的十进制值,5),得出的结果就是指向前一个节点的地址p。

根据编码方式的不同, pre_entry_length 域可能占用 1 字节或者 5 字节:

  • 1 字节:如果前一节点的长度小于 254 字节,便使用一个字节保存它的值。
  • 5 字节:如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。 encoding和length两部分一起决定了content. 部分所保存的数据的类型(以及长度)。其中,encoding域的长度为两个bit,它的值可以是00、01、10和11。00、01和10表示 content中保存着字符数组;11 表示content中保存着整数。
entry示例

ziplist连锁更新

压缩列表是连续的一块内存,假设现在已经存入了entry0,entry1。如果此时有一个元素要压入列表的头部。而这个头节点超过了254个字节的长度,那么entry0的pre_entry_length占用的空间就要由原本的一个字节变为5个字节。因此entry0整体大小也会变为比之前多4个字节。

  • 如果变化之后的entry0整体没有超过254个字节,则不需要再更新entry1。

  • 如果变化导致entry0整体超过254个字节,则需要更新entry1的pre_entry_length从占用1个字节变为占用5个字节。同样的如果entry1更新的长度也超过了254个字节,则需要继续向后更新,这就是连锁更新。

只有在新添加节点的后面有连续多个长度接近 254 的节点时, 这种连锁更新才会发生。但是这种几率特别小,使用者在使用过程中不必太多担心连锁反应。

ziplist的遍历

可以对 ziplist 进行从前向后的遍历,或者从后先前的遍历。

从前向后遍历

程序从指向节点e1的指针p开始,计算节点e1的长度(e1-size),然后将p加上e1-size ,就将指针后移到了下一个节点e2。。。如此反覆,直到p遇到ZIPLIST_ENTRY_END为止,这样整个ziplist就遍历完了。

从后往前遍历

程序从指向节点 eN 的指针p出发,取出eN的 pre_entry_length值,然后用p减去pre_entry_length ,这就将指针移动到了前一个节点eN-1。。。如此反覆,直到p遇到ZIPLIST_ENTRY_HEAD为止, 这样整个ziplist就遍历完了。

quicklist

参考: cs-cjl.com/2019/04_18_… juejin.cn/post/684490…

quicklist配置项

(1) ziplist压缩配置:list-compress-depth 0

表示一个quicklist两端不被压缩的节点个数。这里的节点是指quicklist双向链表的节点,而不是指ziplist里面的数据项个数。一个quicklist节点上的ziplist,如果被压缩,就是整体被压缩的。

参数list-compress-depth的取值含义如下:
0: 是个特殊值,表示都不压缩。这是Redis的默认值。
1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。
2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。
3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩。

依此类推…

Redis对于quicklist内部节点的压缩算法,采用的LZF——一种无损压缩算法。

注意:

  • quicklist 不会对长度小于MIN_COMPRESS_BYTES的 ziplist 进行压缩。
  • quicklist 在对 ziplist 进行压缩后,会对比压缩后的长度和未压缩的长度,若压缩后的长度 - 未压缩的长度 < 8(MIN_COMPRESS_IMPROVE),则使用未压缩的数据。
  • quicklist 的头节点和尾节点在任何时候都不会被压缩,因此可以保证将数据插入列表的头或者尾是高效的。
  • 插入一个数据到压缩的节点时,需要先对节点的 ziplist 整个进行解压,插入后再次进行压缩。
(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。

quicklist结构

完全无压缩时:

在使用压缩配置list-compress-depth为1时。在不使用压缩的时候,quicklistNode中的数据指针zl指向的是一个ziplist结构;在使用压缩时quicklistNode中的数据指针zl指向的是一个quicklistLZF结构。

quicklistNode的分裂

当插入数据到 quicklist 中间某个节点的时候,若果此节点的 ziplist 已经达到list-max-ziplist-size配置限值,则会发生 quicklistNode 的分裂,分裂点为要插入的位置。

假设我们有如下 quicklist,插入位置为 p 指向的位置。

假设我们要插入的 ziplist 已经到达限值,节点需要分裂。 分裂是以插入点进行分割的,p指向的entry会成为到分裂完成的后一个节点中ziplist的第一个entry。

我们要插入的数据会插入分裂后的前一个节点中ziplist的最后一个entry。

quicklistNode的合并

完成插入后,为了避免由于节点分裂导致quicklist的节点碎片化,会调用 _quicklistMergeNodes(...) 对节点进行合并。(这里应该是redis自己会调用)

以分裂后的前一个节点作为中心节点(下面简称为 center)尝试进行如下合并:
(center->prev->prev, center->prev)
(center->next, center->next->next)
(center->prev, center)
(center, center->next)

在节点的两两合并前会先检查合并后的节点是否符合 quicklist的list-max-ziplist-size配置限制,若符合则进行合并,否则不合并。 假设只有 (center->next, center->next->next) 这一步是符合合并条件的。 合并后的 quicklist 如下所示:

如果某个quicklistNode节点(node)指向的ziplist的数据项个数已经达到了list-max-ziplist-size配置限制,此时对这个节点执行插入操作会导致该节点的 ziplist在插入点进行分裂,此时这个ziplist会变成两个ziplist(新建一个quicklistNode保存多出来的ziplist),然后将数据插入node指向的 ziplist的末尾,并对node进行_quicklistMergeNodes操作,完成节点合并。

参考

(ziplist)redisbook.readthedocs.io/en/latest/c…

(quicklist)cs-cjl.com/2019/04_18_…

(quicklist)juejin.cn/post/684490…

www.baowenwei.com/post/fen-bu…

juejin.cn/post/684490…