Redis数据结构与对象(上)

数据结构与对象

Redis数据库中的键值对都是由对象构成,而对象是由数据结构构成;其中键值对的键可以是个字符串对象;值对象可以五选一(字符串、列表、哈希、集合、有序集合);

这块所说的字符串与平常使用的java中的字符串有些区别,当然主场还是redis本着尊重开发者的原则咱还是说说C字符串比较好;redis中的值对象虽然提供了比较丰富的数据结构,但是学起来也是有一定的难度,尽量先对某一语言的数据结构与算法有一定了解学起来就会比较快,博主之前有些基础,大概一个多星期就学完了;还是推荐先看看java的数据结构,壮哉我大Java红黑树无敌;虽然有序集合这块算是redis里面比较不好理解的但是跟那棵树比基本上就一幼儿园小朋友;

SDS

redis虽然是由C语言实现的但是他只是将C字符串作为字面量使用,这里我们用SDS(Simple Dynamic String 简单动态字符串)来表示字符串。

由sds.h/sdshdr结构表示,共三个属性

free:用于记录分配到的额外的未使用空间(下文中的惰性空间释放策略与空间预分配策略中会用到)

len:记录字符串长度(有的时候知道自己有多长也是有优势的)

buf:一个字节数组,前面存字符串,最后一个字节存/0,代表空白字符(个人觉得是向C语言致敬,后文会提到用处)

SDS有哪些优势

刚才我们介绍了SDS,现在说说有什么优势,其实无论是redis和MC还是rdb和aof,都是要通过比对来判强弱;个人以为还是Java牛逼(我StringBuilder/StringBuffer不服);--(JVM说了 你SDS再牛我字符串常量池也是你爸爸);

这块我们以C字符串来和SDS作对比:

  1. 常数时间复杂度获取字符串长度
    1. C字符串中并未记录字符串长度,获取长度需遍历至字符串结尾,也就是/0,时间复杂度O(n)
    2. SDS提供len属性,O(1)时间复杂度获取(没办法就是这么快)
  2. 杜绝缓冲区溢出
    1. C字符串中并未记录自身长度,增加内容时需先手动扩容后再操作,如果忘记扩容可能会导致溢出到别的紧挨着的字符串中;这样一下子俩字符串都完了
    2. SDS API在扩展之前会先检查自身长度是否满足修改条件,不满足时先扩容再操作
  3. 减少修改字符串长度时内存重分配次数
    1. C字符串并未记录自身长度,所以每次增缩操作时,需要对底层的字节数组进行内存重分配
    2. SDS API提供了两种优化策略
      1. 空间预分配:SDS API会在增长操作时,先检查是否满足条件,不满足便会分配给他足够的内存空间,并分配一块额外的内存空间给他续着
      2. 惰性空间释放:SDS API缩减SDS时,不会一上来就释放内存空间 ,而是由free属性记录要缩减的字节个数,先不删留着以后再用;就是说内存空间不释放只是把一部分字节换成了空白的
  4. 二进制安全
    1. C字符串以/0空白字符结尾就注定与二进制安全无缘,程序在读取C字符串时会认为读到空白字符就读完了;但是二进制数据中有个/0那不是很正常吗
    2. SDS API天然支持二进制安全,程序不会对读入的SDS进行任何限制和过滤,读入时与写入时一致(吃啥拉啥);而且SDS中提供了两个属性
      1. len:可以用来判断是否空串(肯定比/0好使)
      2. buf:被称为二进制字节数组,可以保证安全
  5. 兼容部分C字符串
    1. 因为SDS结尾也是/0,所以可以使用函数库中的一部分函数,向C语言致敬也算是没白敬,挺好的

链表

redis的链表应用还是比较广泛的(列表对象(高级实现)、发布与订阅、慢查询、监视器等)

redis中的链表节点(listNode结构)由一个前驱指针、后继指针以及自身节点值组成,算是个双向链表

list结构:包含一个表头节点指针和一个表尾节点指针(header/tail);可以以O(1)的时间复杂度定位到表头表尾;其中表头节点的前驱指针和表尾节点的后继指针指向Null,也就是说redis中的链表是一个无环的双向链表

redis为不同类型的数据提供了不同类型的特定函数,所以链表可以支持多种丰富的数据类型

字典

Redis的底层由字典实现,对于redis的CRUD就是基于对字典的操作

字典的底层是由哈希表构成 ,哈希表又由多个哈希表节点构成每个哈希表节点都存了一个字典的键值对

哈希表

哈希表由dict.h/dictht结构表示 有4个属性

table:是一个数组,数组中的每个数组项都是一个指向一个dictEntry结构的指针,每个dictEntry结构都保存着一个键值对

size:记录哈希表的长度即数组长度

used:记录哈希表中哈希表节点的个数(有多少个键值对)

sizemask:总是size-1,与索引值共同确定一个键应该被放到table数组的哪个索引上

哈希表节点

哈希表节点由dict.h/dictEntry结构表示 有3个属性

key:键值对的键

v:键值对的值(值可以是个指针,也可以是uint64_t/int64_t整数)

next:指向下一个节点的指针

字典

字典由dict.h/dict结构来表示 有几个属性

type和privdata属性是为了不同类型的键值对,为实现多态字典而设置的属性

type:是一个指向一个dictType结构的指针,每个dictType结构都包含一组用于处理多种不同类型键值对的函数;Redis为多态字典设置提供了不同类型的特定函数

privdata:用于存放传给那些不同类型特定函数的可选参数

ht[]:字典中最重要的属性还是得提到ht,ht是一个数组项但是只有两个项(两个哈希表),ht[0],ht[1];正常情况下字典只会对ht[0]进行CRUD操作,ht[1]只会在对ht[0]rehash时使用;

rehashidx:该属性也是与rehash有关,可以说是rehash的进度条;俗称索引计数器变量;非rehash时值为-1(下文还有)

rehash(重新散列)

负载因子:used/size  哈希表中元素个数/哈希表的长度

作用:对哈希表进行扩容缩容

为了维持负载因子在一个比较正常的范围对哈希表进行扩容缩容操作,也就是说在哈希表中的哈希表节点个数过多过少时进行rehash,当然不止这一个因素,下文会具体讲解

rehash一步肯定完不了大概需要三步:

  1. 为ht[1]分配内存空间,分配多大取决于具体操作(扩还是缩)以及ht[0]的键值对个数(used属性)
    1. 扩容:ht[1]size大于等于第一个ht[0]used*2的2^n
    2. 缩容:ht[1]size大于等于第一个ht[0]used的2^n
  2. 将ht[0]中的所有键值对rehash到ht[1]上,rehash:重新计算哈希值与索引值并将键值对放到table数组指定的位置上
  3. 将ht[0]中的所有键值对rehash到ht[1]之后,将ht[1]设置为ht[0],并在ht[1]位置上重新创建一个哈希表为下一次rehash做准备

渐进式rehash

分多次的渐进的将ht[0]中的所有键值对rehash到ht[1]中

为什么要有渐进式rehash呢,因为在哈希表中键值对数量极大时比如几亿个,一次性rehash到ht[1],会造成庞大的计算量,可能会导致服务一段时间内停止

渐进式rehash运行时四个步骤:

  1. 为ht[1]分配内存空间,字典同时持有ht[1]ht[0]两张哈希表
  2. 设置字典的rehashidx索引计数器变量值,将该值设置为0,证明rehash正式开始
  3. 每次对字典进行CRUD操作时,除了处理正常操作之外,还需要将rehashidx索引上的所有键值对rehash到ht[1]中,本次阶段性rehash成功后,将rehashidx的值+1
  4. 随着操作不断的进行,最终必定会在某一时间点将ht[0]中的所有键值对rehash到ht[1],此时再将rehashidx的值设置为-1,证明rehash结束

优势:与一次性rehash相比,将rehash分配在各个CRUD操作中,可以分多次rehash,避免对服务器造成大量计算负担

渐进式rehash时对哈希表的操作:

新增操作和别的不太一样:只会在ht[1]中新增元素,保证ht[0]中的used值有减不增

查询等操作都是先去ht[0]中查找,要是没有就去ht[1]中操作

何时开始rehash

满足以下条件时,对哈希表进行扩容操作

  1. 当服务器未执行BGSAVE和BGREWITEAOF命令时,并且负载因子大于等于1;
  2. 当服务器执行BGSAVE和BGREWITEAOF命令时,并且负载因子大于等于5;

为什么这两种情况下的负载因子的要求不一样呢,因为服务器进程在执行BGSAVE和BGREWITEAOF命令时,会创建一条子进程来执行创建RDB文件/AOF后台重写,会执行大量对磁盘的写入操作,将负载因子上限调大也是为了避免此时发生扩容给服务器带来更大的压力,所以服务器希望在未执行该命令时完成扩容

当负载因子小于0.1时,对哈希表进行缩容操作

跳表

跳表在redis中引用的并不算十分广泛,只用在有序集合的实现中作为组件出现;再有就是集群中子节点的数据结构;

跳表又称跳跃表;它是从单链表中抽出索引层,索引层越多效率越高,也就是具备了多级索引层的链表,与实现二分查找算法的数组较为相似;

跳表支持最好情况下O(logN) 最坏情况下O(n) 的查找时间复杂度

跳表节点由redis.h/zskiplistNode结构定义 有以下属性

level(层):每个层都是一个数组,包含多个元素比如(前进指针和跨度值),层越多,跳表查询的速度越快

forword:前进指针,从表头方向指向表尾方向的一个指针,可以用于表头向表尾方向的访问

span:跨度值,用于记录前进指针所指向的节点与当前节点的距离

  1. 跨度值越大,距离越远
  2. 前进指针为NUll时,跨度值为0 ,因为没有指向任何节点

backword :后退指针,从表尾方向指向表头的指针,与前进指针不同的是,后退指针每次只能退一个节点,因为每层只有一个后退指针,却可能包含多个前进指针

score:分值;一个double类型的浮点数值。用于记录元素的分值,跳表中按照分值大小从小到大排序

obj:成员对象;一个指向了sds字符串的指针

在跳表中,每个节点的obj必须是唯一的,但是节点的分值可以相同,分值相同时,按照成员对象在字典序中的大小排列,小的在前(接近表头方向),大的靠后(靠近表尾方向)

跳表由redis.h/zskiplist结构定义

其实由多个跳表节点也可以组成跳表,但是比不上zskiplist结构的跳表;

zskiplist中提供了几个属性:

header/tail:表头节点指针与表尾节点指针,支持O(1)的时间复杂度查找定位表头表尾节点

length:跳表中节点个数

level:跳表中具备最大层级的节点(O(1)时间复杂度定位)

整数集合

整数集合可以作为集合对象的底层实现;有所限制集合中元素只能是整数值,并且不能有任何重复元素存在

由intset.h/intset结构定义:

contents:是一个保存元素的数组,集合中的每个元素都是contents数组的一个数组项(item),各个项在数组中按值从小到大有序的排列,并且数组中不包含任何重复项

length:记录了集合的元素数量(contents数组的长度)

encoding:

  • 编码方式,用于决定contents数组的类型
    • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组(2个字节16位)
    • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组(4个字节32位)
    • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组(8个字节64位)

升级:

    1. 何时升级:当新元素加入整数集合并且该元素比现有所有元素的类型都要长时,进行升级,之后添加新元素
    2. 三步骤:
      • 根据新元素的类型,扩展contents数组大小,并为其分配空间
      • 将contents数组中现有元素都转换为新元素的类型,并按照值的大小从小到大排列于数组中
      • 将新元素添加到contents数组
    3. 升级的好处:
      • 提升灵活性
        1. 整数集合可以自动升级底层数组来适应新元素,所以不用担心出现类型错误
      • 节约内存
        1. 底层数组一直都是保持一种类型,只有新元素的类型比现有元素类型长时才会升级
    4. 整数集合不支持降级操作

压缩列表

压缩列表可以作为列表对象、哈希对象、有序集合对象的底层实现,他是redis为了节省内存而实现的,由一系列特殊编码组成的内存块构成的顺序型数据结构(可以当做是个数组)

压缩列表 ziplist

zlbytes:记录压缩列表占用的内存字节数

zllen:记录压缩列表包含的节点数量

entryX:记录压缩列表中所有的节点

zltail:记录表尾节点到起始地址有多少字节

zlend:用于标记压缩列表的末端

压缩列表节点 ziplistNode

previous_entry_length属性:以字节为单位,记录了压缩列表中前一个节点的长度

  1. 如果前一节点的长度<254字节,长度为1字节
  2. 如果前一节点的长度>=254字节,长度为5字节

encoding属性:记录了节点的类型以及长度

content属性:负责保存节点的值,节点的值可以是个字节数组(字符串)或者整数

连锁更新:添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高

下一篇再见!感谢阅读者阅读并提出建议!骂都可以就是别带脏字!下一篇咱们接着说对象,今儿就先歇了

 

 

 

你可能感兴趣的:(redis,redis)