源码篇--Redis 底层数据结构

文章目录

  • 前言
  • 一、字符串数据结构:
    • 1.1 字符串:
    • 1.2 SDS 动态字符串由来:
    • 1.3SDS 动态字符串结构:
    • 1.4 为什么sds 是动态字符:
    • 1.5 sds 动态字符串的优点:
  • 二、 intset数据结构:
    • 2.1 intset:int 类型的 集合:
    • 2.2 IntSet 结构:
    • 2.3 存储结构示意:
    • 2.4 IntSet 扩容:
    • 2.5 IntSet 的特点:
  • 三、 Dict 键值对
    • 3.1 Dict:键值对存储:
    • 3.2 Dict 结构:
    • 3.3 Dict 存入元素下标的计算:
    • 3.4 字典(Dict)的结构:
    • 3.5 Dict 的扩容&缩容(rehash):
      • 3.5.1 扩容和缩容:
      • 3.5.2 渐进式迁移:
    • 3.6 Dict 的结构特点:
  • 四、 ZipList 压缩链表:
    • 4.1 ZipList 结构:
    • 4.2 ZipList entry 的编码:
      • 4.2.1 字符串编码:
      • 4.2.2 整数的编码:
    • 4.3 ZipList entry 的更新:
      • 4.3.1 更新问题产生原因:
    • 4.3 ZipList 的特点:
  • 五、 QuickList 快速链表:
    • 5.1 QuickList 的结构:
    • 5.2 QuickList 的大小限制:
      • 5.2.1 控制每个 ziplist 的大小:
      • 5.2.2 控制对QuickList 中的ziplist 进行压缩::
    • 5.3 QuickList 的源码实现:
    • 5.4 QuickList 的特点:
  • 六、 SkipList:跳表:
    • 6.1 SkipList 数据结构:
    • 6.2 SkipList 源码实现:
    • 6.3 SkipList 层级指针结构展示:
    • 6.4 SkipList 特点:
  • 七、 RedisObject :redis 对象
    • 7.1 RedisObject 结构:
    • 7.2 encoding 的编码:
  • 总结


前言

在项目中我们经常使用redis 作为数据库的前置层,从而加快数据的检索速度,而我们经常使用的五种数据接口它们底层是怎么实现的,本篇记录redis 中常见的几种数据结构。


一、字符串数据结构:

1.1 字符串:

在rendis 中我们经常使用的可能就是字符串了,不管是key 还是value 都可以是字符串,甚至我们存储的对象也是序列化成字符串存入的,接下来看下 redis 底层对应字符串是如何存储的;

1.2 SDS 动态字符串由来:

redis 虽然使用c 语言编写,但是其底层并没有直接使用 c 语言里的字符串 ,因为 c 语言中的字符串有以下缺点:

  • 不能直接获取到字符串的长度(需要遍历获取字符串的长度);
  • 字符串实际已字符数组存储并以 \0 结尾,声明的字符串中如果有 \0 则会出现问题
  • 声明的字符是直接方法内存中,无法直接进行 修改;

所以redis 构建了动态字符串SDS

1.3SDS 动态字符串结构:

源码篇--Redis 底层数据结构_第1张图片

  • len : buf【】 中的字符个数,uint8: u 无符号 8个字节整形 最大可以标识2 ^8-1=255 所以使用sdshdr8 编码,最大保存255 个字符;
  • alloc : 申请的内存空间大小,可以和len 不相等,第一次申请 len 和alloc 是相等的;
  • flags : 不同长度数据类型区分
  • buf[] 内容字节数组;

因为sdshdr8 最大只能支持255 字节,超过这个字节怎么办?,所以在redis 中字符串 可以根据其长度使用不同的编码结构体 进行构建,除了 sdshdr8 还有存储超过255 字节的结构体,来满足字符串长度的问题:
源码篇--Redis 底层数据结构_第2张图片
为什么不直接使用 sdshr32 ,没有必要大多数的key 并不会那么大,字符串越大所需要的内存空间也越大;

“name” 字符串结构示意:
在这里插入图片描述

  • 读取时 不以\0 作为读取完毕标识,从开始处读取len 个字符;

1.4 为什么sds 是动态字符:

它可以进行动态扩容;字符串的内容在进行追加时 ,申请的空间也会随之加大 ;它的扩容规则为:

  • 如果拼接后的字符串小于 1m ,则新申请的空间为 拼接后字符串长度的2倍 +1;
  • 如果拼接后的字符串大于1m,则新申请的空间为拼接后的字符串长度+1m+1;

动态字符串的内存申请采用的是 空间预分配,因为申请空间需要从用户空间到内核空间,需要消耗资源,避免频繁去进行内存分配,所以每次扩容都要预留一部分内存,+1 是保留了\0 的空间;

1.5 sds 动态字符串的优点:

  • 获取字符串的长度时间复杂度为o(1),因为结构体本身记录字符串的长度;
  • 支持动态扩容,延迟缩容(当字符串长度减少时并不会立刻进行缩容,倾向于保持由于扩容所获得的空间);
  • 内存预分配减少内存分配的次数;
  • 二进制安全的 支持 \0 的数据;
  • 可以存储任意的字节,包括视频,音频,图片 因为其可以转换为字节数组然后进行存储;

二、 intset数据结构:

2.1 intset:int 类型的 集合:

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

2.2 IntSet 结构:

源码篇--Redis 底层数据结构_第3张图片

  • contents : 保存的内容:
    int8_t: int 类型数据 最大支持 8个字节,也就是说 contents 数组中的每个元素最大只能是 -128到 127 之间;不是这样的 数组中每个元素的取值范围 实际时由 encoding 决定的 有三种模式,contents 只是充做数组中第一个元素的起始位置,contents 中的元素会按照升序进行排列 ;
  • length : 整数数组中元素的个数;
  • encoding 有3中格式,以此来支持存储不同大小的元素数据;
    源码篇--Redis 底层数据结构_第4张图片

2.3 存储结构示意:

源码篇--Redis 底层数据结构_第5张图片
虽然元素数值较小 但是每个也都要占用2/4/8 字节数,这里强调编码,是为了方便对于元素的读取,contents 只是指向数组中第一个元素的内存地址;找下一个元素的地址 则从起始位置 + 2/4/8 字节数 得到下个元素地址
在这里插入图片描述

  • sizeof(int16) 长度为2 sizeof(int32) 长度为 4, sizeof(int64) 长度为8;

2.4 IntSet 扩容:

源码篇--Redis 底层数据结构_第6张图片
倒叙的原因是:如果按照正序 第一个元素在迁移时就会抢占第2个元素的位置;如果倒序则不会出现这个情况;然后将新加入的元素放到数组末尾;
在这里插入图片描述

然后修改length 的属性:
在这里插入图片描述

2.5 IntSet 的特点:

  • 元素是是有序(默认升序)且不重复的;
  • 只支持整形的数字;
  • 支持输的扩容;
  • 适应于数据量不是很大的场景,如果数据量很大因为底层是数组,需要申请连续的内存空间;

三、 Dict 键值对

3.1 Dict:键值对存储:

Dict 结构用来存储key-value 结构的数据

3.2 Dict 结构:

Dict 由三部分组成,分别是 哈希表(DictHashTable),哈希节点(DictEntry),字典(Dict),先看下哈希表(DictHashTable),哈希节点(DictEntry)的结构
源码篇--Redis 底层数据结构_第7张图片
DictHashTable:

  • ** table : 指向hash 数组的指针
  • size: 哈希数组的格子大小
  • used: 哈希表中的 entry 键值对的个数,因为随着数据的增加,key 的hash 值可能重复,这样其在哈希数组中的下标位置就是相同的,此时在这个格子里会形成链表结果,所以一般情况下used 是要大于size 的;

DictEntry数组中的元素 entry 键值对:

    • key : 字符串 指针,对应key的存储
  • union: 联合体,对应是value 的存储 ,可以是一个指针/无符号整形/有符号整形/double
  • *next : 哈希冲突时形成连接结构;

3.3 Dict 存入元素下标的计算:

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用h & sizemask来计算元素应该存储到数组中的哪个索引位置;key 的哈希值与 sizemask 做与 运算,相当月对 key 的哈希值对 哈希数组大小 做 余数运算;得到0到size-1 的下标位置;

假如 我们存储k1=V1,假设k1的哈希值h =1,则183 =1,因此k1=v1要存储到数组角标1位置:
源码篇--Redis 底层数据结构_第8张图片
存储 k2 : v2 k2 的哈希也是1 ,则将新元素放入链表的首部:
源码篇--Redis 底层数据结构_第9张图片
为什么不放到链表的尾部? 因为放到尾部还要对链表从头去遍历;

3.4 字典(Dict)的结构:

源码篇--Redis 底层数据结构_第10张图片

  • *type 和 * privdata 来适用于不同场景下对key 使用不同的hash 函数来获取哈希值
  • dicth2 ht[2] 两个hash 表,一般只用ht[0],ht[1] 做哈希表扩容rehash 使用 对已经存在的key 重新计算hash 然后计算下标使用;
  • rehasidx 是否正在进行扩容和缩容标识;

可见 字典(Dict) ht[2]中包含了 哈希表(DictHashTable)而DictHashTable 中的每个哈希节点(DictEntry)又是一个链表的结构,所以它们整体的结构为:

源码篇--Redis 底层数据结构_第11张图片

3.5 Dict 的扩容&缩容(rehash):

3.5.1 扩容和缩容:

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor= used/size),满足以下两种情况时会触发哈希表扩容

  • 哈希表的 LoadFactor>=1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程
  • 哈希表的 LoadFactor > 5;
    源码篇--Redis 底层数据结构_第12张图片
    如果是>=1 如果没有bgsave 或者bgrewiteaof 后端进程 则进行扩容,否则先不进行扩容;如果负载因子已经大于5 此时 出现大量的hash 冲突则直接进行扩容;

有扩容就有缩容,每次删除元素时,也会对负载因子做检查,当LoadFactor<0.1 时,会做哈希表收缩:

源码篇--Redis 底层数据结构_第13张图片

3.5.2 渐进式迁移:

主线程在进行增加和删除时,如果元素很多,则进行动态扩容缩容时 ,主线程 会被阻塞一直到rehash 完成;
渐进式迁移,避免主线程一直阻塞,则每次rehash 时 则只 进行一个hash 数组中某个下标位置的元素;这样避免一次性迁移过的数据;如果此时我们去查找数据时,则需要在ht[0] 和ht[1] 都要去寻找,直到找到为止;在rehash 的过程中新增的数据 会全部被放到ht[1] 中,因为本来就是要将ht[0] 迁移到ht[1] 还不如直接就放到ht[1];

渐进式rehash。流程如下:

  • 计算新hash表的size,值取决于当前要做的是扩容还是收缩:
    如果是扩容,则新size为第一个大于等于dict.htlol.used+1的2n;
    如果是收缩,则新size为第一个大于等于dict.ht[0l.used的2n (不得小于4)
  • 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1];
  • 设置dict.rehashidx =0,标示开始rehash;
  • 每次执行新增、查询、修改、删除操作时,都检查一下dict,rehashidx是否大于-1,如果是则将dict,htl0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.hti0l的所有数据都rehash到dict.ht[1]
  • 将dictht[1]赋值给dict,ht[0],给dict,ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存;
  • 将rehashidx赋值为-1,代表rehash结束;
  • 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash进程,最终dict.ht0]清空;

3.6 Dict 的结构特点:

Dict的结构:

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突;
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash;
  • 使用了大量的指针,内存可以是不连续的,就会产生内存的碎片,造成内存浪费;

Dict的伸缩:

  • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容;
  • 当LoadFactor小于0.1时,Dict收缩;
  • 扩容大小为第一个大于等于used +1的2n;
  • 收缩大小为第一个大于等于used 的2n;
  • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
  • rehash时ht[0】只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表;

四、 ZipList 压缩链表:

特殊的双端链表,没有存储指针,但是可以保证任意一端进行压入/弹出 操作,是有连续内存块组成,从任意一端入/弹出 时间复杂度为o(1);

4.1 ZipList 结构:

源码篇--Redis 底层数据结构_第14张图片
因为 ZipList 中没有指针,怎么取遍历entry 呢? 先开看下 各个字段占用的字节长度:

源码篇--Redis 底层数据结构_第15张图片
从上图可以看到其他的字段 长度都是固定的 但是entry 中因为存储的元素内容不同 ,所以其长度也是非固定的;先来看下entry 内部结构:
ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
在这里插入图片描述

  • previous entry length: 前一节点的长度,占1个或5个字节
    如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
  • encoding: 编码属性,记录cntent的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节ziplist 只能用来存储字符串/整数;
  • contents:负责保存节点的数据,可以是字符串或整数
  • 每个entry 都记录前一个节点 占用的内存长度;这用就可以通过 zltail 从尾部 对每个entry 进行计算遍历 从当前entry 减去当前entry 的长度 得到想要的entry ;也可以从当前的位置 依次向后去加上每个entry 的长度也可以得到 entry 的位置,这样就可以前后遍历;

4.2 ZipList entry 的编码:

因为ZipList 可以用来存储字符串/整数,而字符串和整数的大小不同会采取不同的编码;

4.2.1 字符串编码:

根据字符串的长度来进行不同的编码:
源码篇--Redis 底层数据结构_第16张图片

  • 以00 对应1个字节 占用8位 2个位来存放00 剩下6位放入content 大小最大能支持 2^6-1 = 63 ,
  • 01 对应2个字节 占用16位 ,2个位来存放01 标识 剩下14位放入content 大小最大能支持 2^14-1 = 16383
  • 10 对应5个字节 占用40位,第一个字节8个位来存放10 标识 剩下32位放入content 大小最大能支持 2^32-1 = 4294967295
  • encoding 编码 取决于content 的长度;

4.2.2 整数的编码:

源码篇--Redis 底层数据结构_第17张图片

encoding 以 11 代表content 的类型是整数: 然后剩下的6位用来标记不同的整数类型;此时进行遍历 计算每个entry 的长度时 content 的长度 就按照 encoding 的类型 计算 它是占用 2个字节还是4个还是8个等等;

4.3 ZipList entry 的更新:

因为每个entry 内部 previous_ent_lenth : 记录前一个节点的占用空间的大小;所以当前一个节点内容发生变化,则有可能影响其后每个节点的长度,会造成链式更新:

4.3.1 更新问题产生原因:

ZipList的每个Entry都包含previous entry length来记录上一个节点的大小,长度是1个或5个字节:

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

现在,假设我们有N个连续的、长度为250~253字节之间的entrv,因此entrv的previous entry lenath属性用1个字节即可表示,如图所示:此时如果在插入一个entry 的长度为 254 字节,则后续的每个entry 对样进行修改,ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。
在这里插入图片描述
怎么处理:
目前redis 没有进行处理,因为概率比较低,必须要是连续n 个 使用相同编码存储数据的情况;

4.3 ZipList 的特点:

  • 压缩列表的可以看做一种连续内存空间的"双向链表列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低;
  • 如果列表数据过多,导致链表过长,可能影响查询性能增或删较大数据时有可能发生连续更新问题;
  • -zipList 的不同编码本质上还是为了节省内存,zipList 进行遍历时只能从前向后,或者从后向前,如果存储的数据量较多 entry 的链就会很长,遍历会有性能;

五、 QuickList 快速链表:

zipList 特殊的双向链表,虽然内存是连续的,读取的效率比较公安,但是如果存储的数据很多,需要去申请很大的内存,通常用来存储小数据量;数据量较多可以使用QuickList;

5.1 QuickList 的结构:

在redis3.2 版本中引入QuickList 它是一个双端连接,链表中的每个节点都是一个zipList ,每个zipList 都可以申请自己的内存;以此来解决单个zipList 的数据量上限;为了避免QuickList 占用过大问题,还需要对其进行限制:

源码篇--Redis 底层数据结构_第18张图片

5.2 QuickList 的大小限制:

5.2.1 控制每个 ziplist 的大小:

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

  • -1:每个ZipList的内存占用不能超过4kb
  • -2:每个zipList的内存占用不能超过8kb
  • -3:每个ZipList的内存占用不能超过16kb
  • -4:每个ZipList的内存用不能超过32kb
  • -5:每个zipList的内存占用不能超过64kb

其默认值为-2:

在这里插入图片描述

5.2.2 控制对QuickList 中的ziplist 进行压缩::

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

  • 0:特殊值,代表不压缩
  • 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩
  • 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩以此类推

默认值: 0 不压缩

源码篇--Redis 底层数据结构_第19张图片

5.3 QuickList 的源码实现:

源码篇--Redis 底层数据结构_第20张图片
quicklist,保存了前后指针 可以进行遍历每个 指针的数据结构是一个quickListNode 节点;每个节点中记录了当前zipList 的大小以及是否压缩标记;

5.4 QuickList 的特点:

  • QuickList 是一个节点为ZipList的双端链表;
  • 节点采用zipList,解决了传统链表的内存占用问题;
  • 控制了zipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

六、 SkipList:跳表:

QuickList ,虽然比较节省内存,只有从首尾进行遍历读取方便,从中间读取随机读取不方便;
链表中的元素进行升序排序,每个节点可以包含多个指针,指针的跨度不同,跨度最大支持32级指针,查询从最高层级依次向下查询;

6.1 SkipList 数据结构:

源码篇--Redis 底层数据结构_第21张图片
当进行元素查找是可以从高层次指针依次 向下查找,类似于2分查找法,提高检索的速度;

6.2 SkipList 源码实现:

源码篇--Redis 底层数据结构_第22张图片

  • skiplist 是一个双向链表 记录链表中节点数量,记录索引的层级;
  • zskiplistNode:ele 存储节点的值,通过score 达到升序排序 ,记录前置节点,支持从后向前遍历;
  • 使用level[] 数组 记录下一个节点,支持索引层级查询,从前向后可以进行索引层级查

6.3 SkipList 层级指针结构展示:

源码篇--Redis 底层数据结构_第23张图片
每个node 都有层级指针方便进行查找;

6.4 SkipList 特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值;
  • 节点按照score值排序,score值一样则按照ele字典排序;
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数;
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大;
  • 增删改查效率与红黑树基本一致,实现却更简单;

七、 RedisObject :redis 对象

redis 的存储的数据的key 和value 值最终都会封装成为 redisObject ;

7.1 RedisObject 结构:

源码篇--Redis 底层数据结构_第24张图片

  • type 来标识对象类型
  • encoding 指定具体对象类型数据使用的编码
  • lru 最近一次被访问的时间;
  • refcount : 被引用的次数
  • *ptr 指向具体对象 数据的 内存指针;

大量的字符串进行存储,如果存储字符串,每个字符串其实就是一个redisObject ,不如使用list 或者set 存储以此节省不必要的 type encoding 等的空间;

7.2 encoding 的编码:

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:
源码篇--Redis 底层数据结构_第25张图片

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

源码篇--Redis 底层数据结构_第26张图片


总结

本文总结了 redis 底层使用的 7中数据结构,不同的数据结构的特点,来更好的支持数据的存入和读取。

你可能感兴趣的:(源码解析篇,db数据库,java工具篇,redis,数据结构,数据库)