Redis 篇-深入了解 Redis 五种数据类型和底层数据结构(SDS、Intset、Dict、ZipList、SkipList、QuickList)

🔥博客主页: 【小扳_-CSDN博客】**
❤感谢大家点赞👍收藏⭐评论✍**

文章目录

[1.0 Redis 底层数据结构](#1.0 Redis 底层数据结构)

[1.1 Redis 数据结构 - 动态字符串 SDS](#1.1 Redis 数据结构 - 动态字符串 SDS)

[1.2 Redis 数据结构 - Intset](#1.2 Redis 数据结构 - Intset)

[1.3 Redis 数据结构 - Dict](#1.3 Redis 数据结构 - Dict)

[1.3.1 Dict 的渐进式 rehash](#1.3.1 Dict 的渐进式 rehash)

[1.4 Redis 数据结构 - ZipList](#1.4 Redis 数据结构 - ZipList)

[1.5 Redis 数据结构 - QuickList](#1.5 Redis 数据结构 - QuickList)

[1.6 Redis 数据结构 - SkipList](#1.6 Redis 数据结构 - SkipList)

[1.7 Redis 数据结构 - RedisObject](#1.7 Redis 数据结构 - RedisObject)

[2.0 数据类型](#2.0 数据类型)

[2.1 数据类型 - String](#2.1 数据类型 - String)

[2.2 数据类型 - List](#2.2 数据类型 - List)

[2.3 数据类型 - Set](#2.3 数据类型 - Set)

[2.4 数据类型 - ZSet](#2.4 数据类型 - ZSet)

[2.5 数据类型 - Hash](#2.5 数据类型 - Hash)


1.0 Redis 底层数据结构

在 Redis 的底层实现中,除了高层的抽象数据结构(如字符串、哈希、列表、集合等)之外,还有一些更底层的原始数据结构。

1.1 Redis 数据结构 - 动态字符串 SDS

简单动态字符串,简称 SDS 。

Redis 是 c 语言实现的,其中 SDS 是一个结构体,结构如下:

字段解析:

1)uint8_t len:表示 buf 字符数组中已经保存的字符个数,因为一个字符占一个字节,也可以表示已保存的字节数,且不包含结束标识: "\0" 。uint8_t 代表 len 占用了 8 个比特位,也就是一个字节,因此表示 buf 字符数组的最大容量为 2^8 。

2)uint8_t alloc:表示 buf 字符数组申请的总的字节数,同理,且不包含结束标识:"\0" 。

3)unsigned char flags:表示不同 SDS 的类型,用来控制 SDS 的头大小,一共有五种类型。不同的 SDS 类型,可以表示 buf 的容量大小不同。

4)char buf[]:字符数组,存放真实数据的容器。

举个例子:

一个包含字符串 "name" 的 SDS 结构体如下:

SDS 之所以叫做动态字符串,是因为它具备动态扩容的能力:

1)如果新字符串小于 1 M,则新空间为扩展后字符串长度的两倍 + 1 。

2)如果新字符串大于 1 M,则新空间为扩展后字符串长度 + 1M + 1,称为内存预分配。

举个例子:

一个原本的字符串数组为 "hi" 的 SDS 结构为:

接着,假如追加一段字符串 ",Amy",这里首先会申请新的内存空间:

由于 "hi,Amy" 加起来的大小为 6 个字节,小于 1M,因此需要扩展至 13 个字节大小即可。

新的字符数组的 SDS 结构如下:

关于 alloc 为什么不是 13 个字节呢?

这是因为 alloc 表示申请大小不包含结束标识 "\0",实际上,是扩展至 13 个字节大小。

动态字符串 SDS 的优点:

1)获取字符串长度的时间复杂度为 O(1) 。

2)支持动态扩容。

3)由于支持内存预分配机制,因此减少内存分配次数。

4)由于表示了字符数组的总长度,因此明确结束标识的具体位置,所以二进制安全。

1.2 Redis 数据结构 - Intset

IntSet 是 Redis 中 set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。

IntSet 结构如下:

字段解析:

1)uint32_t encoding:编码方式,支持存放 16、32、64 位整数。该字段的目的是规定在整数数组中的每一个数据的范围大小。

2)uint32_t length:元素个数,表示的范围由 encoding 编码方式决定。

3)int8_t cotents[]:具体存放的数据,每一个数据的范围大小都是由 encoding 编码方式来决定的。

为了方便查找,Redis 会将 intset 中所有的整数按照升序依次保存在 contents 数组中,结构如下:

现在数组中每个数字都是在两个字节的范围内,因此采用的编码方式是 INTSET_ENC_INT16,每部分占用的字节大小为:

encoding:固定占用 4 个字节。

length:固定占用 4 个字节。

contents:2 字节 * 3 = 6 个字节。

IntSet 升级:

当要插入的数字大小当前整数所表示的范围时,intset 会自动升级编码方式到合适大小。

现在,假设有一个 intset,元素为:{5,10,20},采用的编码是 INTSET_ENC_INT16,则每个整数占 2 个字节,接着向其中添加一个数字 50000,这个数字超出了 int16_t 的表示范围,intset 会自动升级编码方式到合适的大小,流程如下:

1)升级编码为 INTSET_ENC_INT32,每个整数占 4 个字节,并按照新的的编码方式及元素个数扩容数组。

2)倒叙依次将数组中的元素拷贝到扩容后的正确位置。

3)将待添加的元素放入数组末尾。

4)最后,将 intset 的 encoding 属性改为 INTSET_ENC_INT32,将 length 属性改为 4 。

IntSet 可以看做是特殊的整数数组,具备的特点:
1)Redis 会确保 Intset 中的元素唯一、有序。

2)具备类型升级机制,可以节省内存空间。

3)底层采用二分查找方式来查询。

1.3 Redis 数据结构 - Dict

Redis 是一个键值型的数据库,可以根据键实现快速的增删改查。而键与值的映射关系正是通过 Dict 来实现的。

Dict 由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。

哈希表和哈希节点的结构体:

哈希表由数组和链表组成,而链表节点则为哈希点。

字段解析:

1)dictEntry **table:表示指针指向哈希表的地址。

2)unsigned long size:哈希表大小,哈希表的大小总是设置为: 2^n 次方,最小的哈希表为 4 。

3)unsigned long sizemask:哈希表大小的掩码,总等于 size - 1,用来计算哈希点具体存放在数组的哪一个索引下的链表中。

当向 Dict 添加键值对时,Redis 首先根据 key 计算出 hash 值,然后利用 hash 值 & sizemask 来计算元素应该存储数组中的哪个索引位置,最后再进行头插链表的方式存储起来。

4)unsigned long used:哈希节点的个数。

5)void *key:指向 key 的地址。

6)union{} value:联合体,表示 value 的类型。

7)struct dictEntry *next:指向下一个节点。

字典的结构体:

字段解析:

1)dictType *type:dict 类型,内置不同的 hash 函数。不用太在意该字段,了解即可。

2)void *privdata:私有数据,在做特殊 hash 运算时用。不用太在意该字段,了解即可。

3)dictht ht[2]:一个 Dict 包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash 时使用。在扩容或者收缩数组大小会使用该字段。

4)long rehashidx:rehash 的进度,-1 表示未进行。

5)int16_t pauserehash:rehash 是否暂停,1 则暂停,0 则继续。

最终的结构:

1.3.1 Dict 的渐进式 rehash

Dict 中的 HashTable 就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。

负载因子:LoadFactor = used / size ;

Dict 在每次新增键值对时都会检查负载因子,满足以下两种情况时会触发哈希表扩容:

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

2)哈希表的 LoadFactor > 5 。

Dict 除了扩容以外,每次删除元素时,也会对负载因子做检查,当 LoadFactor < 0.1 时,会做哈希表收缩。

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

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

如果是扩容,则新 size 为第一个大于等于 dict.ht[0].used + 1 的 2^n。

如果是收缩,则新 size 为第一个大于等于 dict.ht[1].used 的 2^n(不得小于 4)

2)按照新的 realeSize 申请内存空间,创建 dictht,并赋值给 dict.ht[1]。

3)设置 dict.rehashidx = 0,标示开始 rehash 。

4)每次执行新增、查询、修改、删除操作时,都检查一下 dict.rehashidx 是否大于 -1,如果是则将 dict.ht[0].table[rehashidx] 的 entry 链表 rehash 到 dict.ht[1],并且将 rehashidx++ 。直至 dict.ht[0] 的所有数据都 rehash 到 dict.ht[1] 中。

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

6)将 rehashidx 赋值为 -1,代表 rehash 结束。

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

最终的结构:

1.4 Redis 数据结构 - ZipList

ZipList 是一种特殊的 "双端链表",由一系列特殊的编码连续内存块组成。可以在任意一端进行压入/弹出操作,并且该操作的时间操作复杂度为:O(1) 。

字段解析:

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

2)zltail:4 个字节,记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。

3)zllen:2 个字节,记录了压缩列表包含的节点数量。最大值为 UINT16_MAX(65534),如果超过这个值,此处会记录为 65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。

4)entry:长度不定,压缩列表包含的各个节点,节点的长度由节点保存的内容决定。

5)zlend:1 个字节,特殊值 0xFF(十进制 255),用于标记压缩列表的末端。

ZipList 中的 entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用 16 个字节,浪费内存。而是采用如下结构:

字段解析:

1)previous_entry_length:前一节点的长度,占 1 个或者 5 个字节:

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

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

2)encoding:编码属性,记录 content 的数据类型(字符串还是整数)以及长度,占用 1 个、2 个或者 5 个字节。

编码分为字符串和整数两种:

字符串:如果 encoding 是以 "00"、"01" 或者 "10" 开头,则证明 content 是字符串;

举个例子:

整数:如果 encoding 是以 "11" 开始,则证明 content 是整数,且 encoding 固定只占用 1 个字节。

举个例子:

3)contents:负责保存节点的数据,可以是字符串或者整数。

ZipList 特征:

1)压缩列表的可以看做一种连续内存空间的"双向链表"。

2)列表的节点之间不是通过指针连接,而是记录上一个节点和本节点长度来寻址,内存占用较低。

3)如果列表数据过多,导致链表过长,可能影响查询性能。

4)增或删较大数据时有可能发生连续更行问题。

1.5 Redis 数据结构 - QuickList

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

这就不得不引入新的数据结构 QuickList,它是一个双端链表,只不过链表中的每个节点都是一个 ZipList,结构如下:

1)为了避免 QuickList 中的每个 ZipList 中 entry 过多,Redis 提供了一个配置项:list-max-ziplist-size 来限制。

如果值为正,则代表 ZipList 的允许的 entry 个数的最大值。

如果值为负,则代表 ZipList 的最大内存大小,分五种情况:

-1:每个 ZipList 的内存占用不能超过 4 kb;

-2:每个 ZipList 的内存占用不能超过 8 kb;

-3:每个 ZipList 的内存占用不能超过 16 kb;

-4:每个 ZipList 的内存占用不能超过 32 kb;

-5:每个 ZipList 的内存占用不能超过 64 kb;

其默认值为:-2

2)除了控制 ZipList 的大小,QuickList 还可以对节点的 ZipList 做压缩。通过配置项 list-compress-depth 来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。

0:特殊值,代表不压缩。

1:标示 QuickList 的首尾各有 1 个节点不压缩,中间节点压缩。

2:标示 QuickList 的首尾各有 2 个节点不压缩,中间节点压缩。

以此类推......

默认值是 0 ,不压缩节点。

QuickList 与 QuickListNode 的结构源码:

结构如下:

QuickList 的特点:

1)是一个节点为 ZipList 的双端链表

2)节点采用 ZipList,解决了传统链表的内存占用问题。

3)控制了 ZipList 大小,解决连续内存空间申请效率问题。

4)中间节点可以压缩,进一步节省内存。

1.6 Redis 数据结构 - SkipList

首先是链表,但与传统链表相比有以下差异:

1)元素按照升序排序存储。

2)节点可能包含多个指针,指针跨度不同。

在跳表中查找目标数据的步骤:

1)初始化指针:从跳表的最高层的头节点开始,初始化一个指针(通常称为 current)。

2)向右移动:在当前层中,检查 current 节点的下一个节点的值。如果这个值小于目标值,则将 current 指针移动到下一个节点。

3)找到合适的位置:如果下一个节点的值大于或等于目标值,或者已经到达当前层的末尾,则停止此层的移动。

4)向下移动:如果当前层已经达到目标值或找到了更大的值,改变层级,将 current 指针向下移动到下一层。如果目标值小于当前层的下一个节点的值, 则在这一层的当前节点停止查找,进入下一层按步骤 2 进行查找。

5)重复步骤:重复步骤 2 到步骤 4,直到找到目标值或到达底层。如果找到了目标值,则查找成功;

如果到达底层且没有找到目标值:

在底层,检查当前节点的值。

如果当前节点的值等于目标值,则查找成功。

如果当前节点的值小于目标值,继续移动到下一个节点。

如果当前节点的值大于目标值,停止查找。

SkipList 结构源码:

SkipList 结构:

SkipList 的特点:

1)跳跃表是一个双向链表,每一个节点都包含 score 和 ele 值。

2)节点按照 score 值排序,score 值一样则按照 ele 字典排序。

3)每一个节点都快包含多层指针,层数是 1 到 32 之间的随机数。

4)不同层指针到下一个节点的跨度不同,层级越高,跨度越大。

5)增删改查效率与红黑树基本一致,实现却更简单。

1.7 Redis 数据结构 - RedisObject

Redis 中的任意数据类型的键和值都会被封装为一个 RedisObject,也叫做 Redis 对象,源码如下:

字段解析:

1)unsigned type:对象类型,可以是 string、hash、list、set、zset,占用 4 个 bit 位。

2)unsigned encoding:底层编码方式,共有 11 种,占 4 个 bit 位。

3)int refcount:对象引用计数器,计数器为 0 则说明对象无人引用,可以被回收。

4)void *ptr:指针,指向存放实际数据的空间。

5)unsigned lru:lru 表示该对象最后一次被访问的时间,其占用 24 个 bit 位。便于判断空闲时间太久的 key 。

Redis 中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:

2.0 数据类型

常见的五种数据类型:String、List、set、zset、hash,以下介绍这五种的数据类型,底层用到的数据结构:

2.1 数据类型 - String

String 是 Redis 中最常见的数据存储类型:

1)其基本编码方式是 RAW,基于简单动态字符串(SDS)实现,存储上限为 512 mb 。

结构如下:

2)如果存储的 SDS 长度小于 44 字节,则会采用 EMBSTR 编码,此时 object head 与 SDS 是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。

结构如下:

3)如果存储的字符串是整数值,并且大小在 LONG_MAX 范围内,则会采用 INT 编码:直接将数据保存在 RedisObject 的 ptr 指针位置(刚刚好 8 字节),不再需要 SDS 了。

结构如下:

2.2 数据类型 - List

Redis 的 List 结构类似一个双端链表,可以从首尾操作列表中的元素:

在 3.2 版本之前,Redis 采用 ZipList 和 LinkedList 来实现 List,当元素数量小于 512 并且元素大小小于 64 字节时采用 ZipList 编码,超过则采用 LinkedList 编码。

在 3.2 版本之后,Redis 统一采用 QuickList 来实现 List:

整体结构:

2.3 数据类型 - Set

Set 是 Redis 中的单列集合,满足下列特点:

1)不保证有序性

2)保证元素唯一(可以判断元素是否存在)

3)求交集、并集、差集

因此:

如果为了查询效率和唯一性,set 采用 HT 编码(Dict)。Dict 中的 key 用来存储元素,value 统一为 null 。

结构如下:

如果存储的所有数据都是整数,并且元素数量不超过 set-max-intset-entries 时,Set 会采用 IntSet 编码,以节省内存。

结构如下:

相关的源码:

2.4 数据类型 - ZSet

ZSet 也就是 SortedSet,其中每一个元素都需要指定一个 score 值和 member 值。

其特点:可以根据 score 值排序、member 必须唯一、可以根据 member 查询分数。

因此:

zset 底层数据结构必须满足键值存储、键必须唯一,可排序:

1)SkipList:可以排序,并且同时存储 score 和 ele 值(member)

2)HT(Dict):可以键值存储,并且可以根据 key 找 value

源码如下:

整体结构:

当元素数量不多时,HT 和 SkipList 的优势不明显,而且更耗内存。因此 zset 还会采用 ZipList 结构来节省内存,不过需要同时满足两个条件:

1)元素数量小于 zset_max_ziplist_entries,默认值 128

2)每个元素都小于 zset_max_ziplist_value 字节,默认值 64

源码如下:

思考:ziplist 本身没有排序功能,而且没有键值对的概念,因此需要有 zset 通过编码实现:

1)ZipList 是连续内存,因此 score 和 element 是紧挨在一起的两个 entry,element 在前,score 在后。

2)score 越小越接近队首,score 越大越接近队尾,按照 score 值升序排序。

整体结构如下:

2.5 数据类型 - Hash

Hash 结构与 Redis 中的 Zset 非常类似:都是键值存储、都要求根据键获取值、键必须唯一。

因此:

Hash 结构默认采用 ZipList 编码,用以节省内存。ZipList 中相邻的两个 entry 分别保存 field 和 value 。

结构如下:

当数据量较大,Hash 结构会转为 HT 编码,也就是 Dict,触发条件有两个:

1)ZipList 中的元素数量超过了 hash-max-ziplist-entries(默认 512)

2)ZipList 的任意 entry 大小超过了 hash-max-ziplist-value(默认 64 字节)

结构如下:

相关推荐
好看资源平台7 分钟前
Spring 全家桶使用教程 —— 后端开发从入门到精通
java·数据库·spring
Satan71211 分钟前
【Spring】Spring Aop基础入门
java·开发语言·jvm
这河里吗l19 分钟前
Java每日面试题(JVM)(day15)
java·开发语言·jvm·笔记·后端
white__ice22 分钟前
2024.9.27
java
小柯J桑_26 分钟前
史上最牛排序集合,带你认清所有排序算法!(必看系列)~
数据结构·算法·排序算法
Y_3_729 分钟前
Redis 中 String 字符串类型详解
linux·数据库·redis·缓存·bootstrap
robot_大菜鸟36 分钟前
Powerbi -l9-异常数据的处理
android·java·powerbi
AutoAutoJack37 分钟前
C#的结构体(Struct)应用示例
开发语言·数据结构·人工智能·c#
江南一点雨38 分钟前
又一家培训机构即将倒闭!打工人讨薪无果,想报名的小伙伴擦亮眼睛~
java·程序员
NineData1 小时前
如何利用 Kafka,实时挖掘企业数据的价值?
分布式·mysql·kafka·同步·数据管理·ninedata·迁移