Redis底层数据结构详解!

SDS动态字符串详解

        我们知道在redis中字符串是使用非常广泛的,并且redis是基于C语言编写的,但是C语言中的字符串,是通过‘\0’来区分结束的位置的。这样并不安全,所以在redis中使用结构体定义了SDS动态字符串

        其中 len 代表了字符串的长度,这样就不会读取字符串结束标识,而是直接读取指定长度的字符串,从而保证二进制安全 

        之所以叫做动态字符串就是因为,这个结构具备扩容的能力,假设一个内容为'hi’的字符串,我想要在其后面加上‘,Amy’这四个字节的数据,那么如何实现的呢?

        这里首先会申请新内存空间:

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

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

        很显然这里没有超过1M,所以数组长度会扩容至新空间的两倍+1 也就是 2 * 6 + 1 = 13

        最后总结一下:

intset详解

         intset是redis中set集合的一种实现,我们知道set集合是有序去并且没有重复元素的,所以和上面说到的sds类似,redis底层也是基于C语言中的结构体来实现的,源码如下

        其中编码方式直接决定了数组中每个元素的大小,如果是16位,那么数组中每个元素的大小就是两个字节。那么这个时候就有一个问题了:为什么要要采用统一的编码方式呢?其实这主要是是为了方便确定元素在数组中给的位置,这样就可以很容易找到元素的起始下标。

         可能还会有细心的人注意到为什么整数的数组只有1个字节的大小?那是因为这个数组保存的并不是真正的数据,而是指向这些数据的指针,所以是一个保存指针的数组

        这里也衍生了一个问题,那就是如果保存的数字大于encoding规定的长度那又该如何呢?这里intset会对整个数组进行一个整体的扩容,具体的过程如下:

        1、将编码进行调整,例如如果存储一个数据50000,那么16位就得扩容为32位

        2、将数据倒序进行拷贝,如果正序拷贝会将原有的数据覆盖,所以采用倒序拷贝

        3、将待添加的数据放到数组的末尾

        4、将encoding和len的值进行重新计算

        源码参考如下:

         值得注意的是如果插入的元素是最小的,那么就会将其插入到intset中的首位,如果是最大的那么就会插入到尾部,但是如果是基于二者之间的呢?这时候我们注意到有一个函数是intsetSearch,这个函数就是用来确定位置的,是基于二分查找来确定在数组中的位置。

        总结一下:

        Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序

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

  • 底层采用二分查找方式来查询

        DICT详解

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

        下面是对应的源码

 

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

        

         dict的数据结构可也参考下面的源码

        具体对应的数据结构如图所示

        其中ht[1]表示rehash使用的哈希表,其实状态的rehashidx为-1表示未进行,当需要对哈希表进行扩容的时候就会使用rehash重新计算数据在哈希表中的下标。

        Dict扩容和收缩

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

        Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容: 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程; 哈希表的 LoadFactor > 5 ;

        Dict在删除元素的时候也会去检验检查负载因子,当负载因子小于0.1的时候就会触发哈希表的收缩机制

        

        

        不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的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最终为空

        扩容后的结构如图所示

         总结一下:

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]执行,其它操作在两个哈希表

        ZipList

        ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。实际上并不是双端链表,但是ZipList的结构能实现双向链表的功能,如图所示

        

         每个属性的用途如下:

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。
entry列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

        最关键的就是其中的列表节点十分巧妙, 可以实现双向链表的功能,结构如下图:

   

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

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

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

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

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

        如果是第一个元素那么previous_entry_length就是0,encoding值就保存content的数据类型,这样就可以计算这个entry的值,就可以实现双向链表的正序遍历和倒序遍历,但是值得一提的是:ZipList无法实现随机遍历,只能从头开始或者从后开始进行遍历。

        

        QuickList

        思考一下这样的一个问题,使用ZipList一般存储的数据不宜过大,防止bigkey的问题出现,但是那些占用很大的数据应该如何去存储呢?

        那我用多个ZipList来存储不就好了!没错,QuickList就是这样的一个数据结构,采用双向链表的方式存储,其中每个节点都是一个ZipList。结构如图所示

        

        为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。 如果值为正,则代表ZipList的允许的entry个数的最大值 如果值为负,则代表ZipList的最大内存大小,分5种情况,其默认值为 -2:

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

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

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

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

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

         以下是QuickList的和QuickListNode的结构源码:

        我们可以使用一段流程图来描述一下这个结构

        总结一下:

QuickList的特点:

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

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

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

  • 中间节点可以压缩,进一步节省了内存

        SkipList

        SkipList(跳表)首先是链表,但与传统链表相比有几点差异: 元素按照升序排列存储 节点可能包含多个指针,指针跨度不同。结构图如图所示:

         然后来看一下源码中是如何定义这样的一个结构的

        可以看到skiplist这个结构就是一个头尾指针然后节点数量和最大索引的层级,也不算复杂。

        对应存储节点的指针就稍微复杂一点了,主要包括这些部分:

        1、存储节点的值

        2、用作排序的分数

        3、指向下一个节点和前一个节点的指针

        4、多级索引数组。

        这里为什么要用一个数组去存储多级指针呢?还是 上面的结构图,我们可以看到节点1有四级指针,但是节点3只有二级指针,所以不同的节点的指针的数量是不同的,所以用一个数组来存储,并且每个节点起码都有一个指向下一个节点的指针,所有数组中至少有一个元素。注意一下,指针最多有32级,不能超过这个数量了。

        总结一下

SkipList的特点:

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

  • 节点按照score值排序,score值一样则按照ele字典排序

  • 每个节点都可以包含多层指针,层数是1到32之间的随机数

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

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

        RedisObject

        上面这些所有的数据类型在redis中都会封装成一个RedisObject,也就是redis对象,例如一个10个sds字符串,那么在存储的时候就是10个RedisObject,但是如果我们使用一个ZipList进行存储的话,那么就是一个RedisObject,这样就节省了很多空间。

        下面是对应的源码:

        Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:

编号编码方式说明
0OBJ_ENCODING_RAWraw编码动态字符串
1OBJ_ENCODING_INTlong类型的整数的字符串
2OBJ_ENCODING_HThash表(字典dict)
3OBJ_ENCODING_ZIPMAP已废弃
4OBJ_ENCODING_LINKEDLIST双端链表
5OBJ_ENCODING_ZIPLIST压缩列表
6OBJ_ENCODING_INTSET整数集合
7OBJ_ENCODING_SKIPLIST跳表
8OBJ_ENCODING_EMBSTRembstr的动态字符串
9OBJ_ENCODING_QUICKLIST快速列表
10OBJ_ENCODING_STREAMStream流

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

数据类型编码方式
OBJ_STRINGint、embstr、raw
OBJ_LISTLinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SETintset、HT
OBJ_ZSETZipList、HT、SkipList
OBJ_HASHZipList、HT

         有了这些前置的知识,下篇文章就来解析一下redis中常见的数据类型String,list,set,zset和hash

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值