Redis数据结构

Redis五种对象以及数据结构

基本结构

redis使用redisObject结构来封装所有五种对象。其中type属性记录了对象类型(五种之一),encoding属性记录了实现该对象的编码类型或数据结构,ptr属性是个指针,指向实现了该对象的数据结构或数值(对于int编码的字符串而言)。除了type, encoding, ptr属性外,redisObject结构还有refcount属性,记录该对象被引用的计数,当变为0时对象被回收。lru属性记录该对象最后一次被使用的时间,因此如果一个对象长时间没有被使用则可能被回收。refcount属性可实现对象共享,redis会默认创建0到9999的字符串对象来被客户端共享。

有序集合对象

  1. 有序集合首先是一个集合,也就是说元素不重复。有序指的是集合中的元素按照其分值从小到大排序,因此当遍历有序集合时能获取到升序排列的元素。有序集合zset有两种实现方式,压缩列表和跳跃表+字典。
  2. 首先介绍如何使用跳跃表+字典实现有序集合,底层数据结构是一个名为zset的结构体,其包含一个跳跃表zskiplist和一个字典dict。跳跃表由跳跃表节点通过指针连接而成,每个跳跃表节点拥有两个基本属性,成员obj和分值score,成员是一个字符串对象,分值是一个double浮点数,每个节点还有一定数量的跳跃指针,称为层数组,每一层都包含一个前进指针直接指向后面的某个节点,而每一层的span属性则记录了这个前进指针的跨度,层数组可以加快访问其它节点的速度,如果没有层数组就只能顺序遍历了,另外层数组也可以用来快速计算某个节点在跳跃表中的排位rank:假设有一条路径从头节点到节点node,那么只需要求路径上所有节点的span属性之和就能得到节点node的rank,跳跃表节点还包含一个后退指针,指向前一个跳跃表节点,因此跳跃表可以类比双向链表,只不过是多了层数组来加快访问速度。
  3. zskiplist结构体包含以下属性:header指向表头节点,但是表头节点是一个空的节点,不保存成员和分值,只有跳跃指针,tail指向表尾节点,level表示层数最大的节点的层数,length表示节点数目(不包括表头节点),节点按照分值升序排列。
  4. 那么字典用在哪里呢?字典用于保存成员和分值的映射关系,因此可以以O(1)的时间复杂度快速查找给定成员的分值,相同的操作如果使用跳跃表则需要O(N)。因此,对于有序集合来说,查找成员的分值由字典完成,而其它范围型操作比如按分值遍历,范围查找等就需要跳跃表来完成。敏锐的coder可能很快意识到,跳跃表和字典共同实现有序集合会不会导致跳跃表和字典内容不一致的情况出现?这里是不会的,因为redis底层由c++实现,因此跳跃表和字典记录的其实都是指针,不会出现数据不一致的情况。
  5. 压缩列表也可以用来实现有序集合,适用于当有序集合元素数目不多或者作为元素的成员的字符串对象都比较短的时候。压缩列表可类比数组,因为其内存空间都是连续的,占用一片连续的空间可以很好的利用CPU缓存访问数据,也能防止出现太多的内存碎片,对于压缩列表来说,增加节点和删除节点都需要重新分配整个列表的内存,这也正是它不适合实现元素数目过多的有序集合的原因。压缩列表的好处顾名思义,“压缩”,节省内存,我们知道数组要求每个元素都是一样的数据类型,尽管某些元素比如整数100,只需要int16就能存储,但由于数组中存在一个超过int16存储范围的元素,所有元素就必须都是int32或更大的整数类型从而实现下标与地址的直接映射,然而却浪费了空间,redis一般用于内存,因此需要有效地利用空间,尽管牺牲一些时间。绕远了,那么压缩空间的方法就在于对于每一个节点来说,节点除了需要保存值本身content之外,还需要记录下节点的数据类型和长度(由encoding记录)和内存占用大小,在压缩列表中,当前节点的内存占用大小由下一个节点的previous_entry_length属性记录,并且该属性也有讲究,也需要控制其大小,如果当前节点的大小小于254字节,那么下一个节点的previous_entry_length属性只需要一个字节,反之则分配5个字节。这就又引入了新的问题:如果在表头插入一个大小超过253字节的节点,那么下一个节点的previous_entry_length属性如果是一个字节便无法记录,则下一个节点需要重新分配内存,分配完之后这个节点的内存大小就会+4,而如果+4后也变成了大于254字节,那么下一个节点的previous_entry_length属性如果也是一个字节那么就又需要更新,最坏情况下整个压缩列表的所有节点都需要重新分配内存,这里所讲的重分配内存不等同于数组的扩容,因为数据扩容只需要将旧数组整体迁移到新数据上,而对于压缩列表的这种情况(称为连锁更新),则每一个压缩表节点都需要重分配内存,这样算来,当我们对某一个节点进行内存重分配的时候已经导致压缩列表的一次扩容了,而对每一个节点都进行内存重分配则需要扩容N次,则整体的时间复杂度为O()。删除节点也可能引发连锁更新。不过引发连锁更新的条件过于苛刻,很少会出现这种情况,因此目前并没有针对这种情况做处理。
  6. encoding属性根据最高两个bit的四种情况记录了该节点的content保存的是什么,是字节数组还是整数,字节数组的长度,整数的类型,然后content属性保存了实际的值。其实redis的双端链表结构允许存储不同的数据类型,所以其实也能压缩空间,那为什么还要压缩列表呢?原因在于链表所使用的前后向指针各需要4个字节,而压缩列表一般用于存储长度小的字节数组,比如长度小于64的字节数组,这时保存长度只需要一个字节就够了(一个字节的encoding,除去前两个bit用于标记数据类型,剩下6个bit可以保存0-63范围内的无符号整数),因此保存长度比保存指针更省内存,所以使用压缩列表要比链表更省内存。
  7. 压缩列表除了保存着连续存储的节点之外,还有一些其它属性,zlbytes记录了整个压缩列表占用的内存字节数,当需要内存重分配时可以用来计算新的压缩列表所需要的内存大小,zltail记录了尾结点与起始地址之间的偏移量,用来快速确定表尾节点的地址,zllen记录节点数目,注意只有2字节长,因此如果节点数目超过该范围则需要遍历整个表才能确定实际的节点数目,不过一般使用压缩列表也不会让其节点数目太多,接下来是连续存储的节点,最后是zlend,特殊值255,标记压缩列表的末端。
  8. 好了介绍完压缩列表,回到如何使用压缩列表实现有序集合的事。灵活的你可能已经想到,既然有序集合需要保存成员和分值,那就将成员和分值依次保存在压缩列表中就行了呗,每次新来一个元素时,先把成员的值添加到压缩列表末尾,再将分值添加在成员的后面,这样压缩节点的总数就等于两倍集合元素的数目。元素需要按照分值从小到大存放。

集合对象

  1. 集合set有两种实现方式,整数集合和字典。首先看字典,其实从有序集合的介绍中可以看出,如果没有对分数有相关需求的功能,那么字典其实就能实现集合了。类比JAVA的集合实现,也是通过哈希表来实现的。我们知道,集合其实不存在键值对概念,但是我们可以通过将值固定为某个值来将元素存储在键中,JAVA中的集合和redis的集合都利用了这个概念,其中redis集合将值固定为null,即空指针。通过这种方式,集合中的元素就保存在了字典的键中,对集合元素的增删改查就通过对字典(哈希表)的增删改查来实现。redis所说的字典其实是对哈希表的封装(注意,这里哈希表不等同于后面所说的哈希对象,哈希表指的是传统意义上的哈希表,而redis的哈希对象指的是存储键值对的数据结构,不仅限于哈希表结构)。
  2. redis的字典dict包含了两个哈希表dichht ht[2],第一个哈希表用来存储键值对,第二个哈希表用于渐进式重哈希,此外属性rehashidx记录了重哈希的进度,-1表示当前没有在rehash。介绍一下渐进式重哈希。如果哈希表包含的键值对数目非常多,那么重哈希将占用非常多的时间,而这段时间内字典将不能对外提供服务,因此需要采用渐进式重哈希,由rehashidx属性记录下一个需要重哈希的哈希下标,这样字典就能同时进行重哈希和对外提供服务,与平时不同的是,新增键值对只会插入到ht[1],查找键值对如果ht[0]找不到就需要到ht[1]找,重哈希结束后rehashidx设为-1,释放ht[0],将ht[0]指向ht[1],ht[1]创建新的空的哈希表。
  3. 接着介绍哈希表dictht结构,该结构类似java1.7中hashmap的实现,即数组挂链表实现哈希表,除了数组外,还记录了数组的长度size,哈希掩码sizemask==size-1,哈希表中节点数目used。同样也是通过load_factor来判断需不需要扩容,与java不同的是,redis由于需要控制内存占用率,因此还有哈希表收缩操作,当load_factor小于0.1时收缩,而判断扩容的条件则不同,如果服务器没有在执行BGSAVE或BGREWRITEAOF命令,则扩容条件为load_factor大于等于1,反之则需要大于等于5。当执行以上两个命令时,需要fork出服务器进程的子进程,父子进程共享数据,而如果父线程或子线程对数据进行了写操作,那么父子进程就不能再共享数据,需要为子进程拷贝出一份写操作之前的数据,这样就提高了内存占用率,因此需要提高哈希表的扩容标准,避免父进程的写操作,从而避免需要为子进程拷贝数据,占用更多内存。
  4. 接下来看整数集合。和压缩列表一样,整数集合也是redis用来压缩内存的一种数据结构。整数集合intset基本属性是一个整数数组contents,数组长度为length,整数集合可以用来存储int16, int32, int64三种不同范围的整数,不过不是像压缩列表那样可以同时存几种,而是从int16数组开始,如果往intset中添加了超过int16范围的整数,数组就会升级到int32,依次类推,最终可以升级到int64数组,但是无法降级。那么,intset中的第三个属性encoding则记录了当前数组的数据类型。intset的contents长度只有length,因此每次添加元素都得扩容。虽然intset用于实现集合,但是contents数组中的元素会按照从小到大排列,这使得查找一个元素只需要O(logN)的复杂度。既然压缩列表能实现有序集合,那感觉压缩列表也能实现集合呀,不知道为啥不用。

哈希对象

  1. 哈希对象肯定可以用字典实现啦,通过对集合的介绍也已经了解了。
  2. 哈希对象也可以用压缩列表实现,同样只适用于元素数目不多或键和值的长度都比较小的时候。灵活的你可能已经想到,既然哈希对象需要保存键值对,那就将键和值依次保存在压缩列表中就行了呗,每次新来一个键值对,先把键添加到压缩列表末尾,再将值添加在键的后面,这样压缩节点的总数就等于两倍键值对的数目。

列表对象

Redis3.2之前的版本使用压缩列表和双向链表两种数据结构来实现列表对象,3.2之后使用quicklist实现,其实就是压缩列表的双向链表,链表节点是压缩列表。我们知道,压缩列表的好处在于内存占用小且连续,但是坏处在于插入和删除操作都需要O(N)复杂度来重新分配内存,而链表的好处在于插入和删除简单,只需要修改指针,不需要对整个链表重新分配内存,尤其因为链表结构记录了表头节点和表尾节点,因此对于插入删除表尾或表头操作来说只需要O(1)的复杂度,redis中对于列表最常用的命令就是LPUSH,RPUSH,LPOP,RPOP,都是对于表头和表尾的操作,因此使用链表的话,这些操作就非常简单了,但链表的坏处在于内存不连续,且存储指针比较耗内存,因此quicklist基本上就是结合了这两种数据结构,综合了它们的优势,互补,链表结构保证LPUSH等命令的简单性,压缩列表节点减小了内存的使用。

字符串对象

  1. 值得说明的是,以上四种对象的数据结构其实在保存元素的时候,保存的都是字符串对象,比如有序集合元素的跳跃表节点的成员对象、集合元素中的字典的键、哈希元素中的字典的键和值,注意压缩列表则不使用字符串对象,不然就又浪费内存了。
  2. 字符串对象有三种编码,int,raw和embstr,也就是说字符串对象可以直接保存整数值(long),而如果是浮点数或字符串就需要使用后两种编码。
  3. raw编码指的是使用简单动态字符串sdshdr,其实是对char数组的封装,除了char数组外还保存了len属性记录char数组已使用的元素个数,free属性记录了还剩下多少未使用的元素,因此char数组的大小就等于len+free+1(最后一个字节为’\0’标记结尾)。len属性使得我们可以通过O(1)的复杂度直接获取字符串的长度,free属性有两个用处:空间预分配,字符串增长时可以直接使用free部分,而不用每次都扩容;惰性释放,字符串缩短时不释放空间,而是将缩短那部分归入free。sdshdr是二进制安全的,也就是说可以保存二进制数据,尽管二进制数据里面包含了’\0’也不会把它判断为字符串的结尾,而是通过len属性来判断的。至于char数组末尾的’\0’则是为了兼容C语言,当保存文本数据时可以直接使用C的函数。因此,raw编码的字符串对象的ptr指针指向sdshdr结构。
  4. embstr编码的字符串对象则直接将sdshdr结构放在redisObject后面形成连续内存,能利用缓存优势也能减少内存分配和释放次数,但embstr编码的字符串对象不可变,一旦进行修改就会改为使用raw编码。
  5. int编码下,ptr指针直接保存整数,如果对该字符串对象进行APPEND使其不再是整数,那么也会转为raw编码。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值