《Redis设计与实现》笔记——数据结构与对象

第一部分 数据结构与对象

Redis之关于SDS
  • 当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值。
  • 首先,SDS的结构:len表示字符串的长度(不包括空字符’\0’),free表示未使用的字节的数量,buf表示一个char类型的数组。
  • 优势:
    1. SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度为常量级
    2. 杜绝缓冲区溢出,比如说,在拼接字符串之前会先根据free确定是否有足够的空间。
    3. 二进制安全,C字符串中不能包含空字符,比如hello world,读取到中间空格的时候会认为结束了,sds就不会出现这个问题。sds会以处理二进制的方式来处理buf数组里的数据,程序不对数据做任何操作,数据在写入时什么样,读取出的就是什么样。
    4. 减少修改字符串带来的内存重分配次数
      • 增长字符串(拼接):空间预分配。比如说如果拼接之后SDS的len值小于1MB,那么分配给free属性的值会跟len属性的相同。例如,修改后sds的len变成13,那么free也会分配到13,buf的实际长度就会变成13+13+1=27。如果修改后len大于1MB,那么程序会分配1MB的free,buf的实际长度就是30MB+1MB+1byte。扩展空间之前,SDS API会先检查未使用的空间是否足够,够的话就直接用,无需执行内存重分配。
      • 缩短字符串(截断):惰性空间释放。程序并不立即使用内存重分配来回收,而是使用free将字节的数量记录起来,并等待将来使用。
Redis之关于链表
  • 链表的能力:高效的节点重派、顺序性的节点访问方式。可用于列表键、发布与订阅、慢查询、监视器等。
  • 链表节点的结构:前置节点指针prev、后置节点指针next以及节点的值value。多个链表节点组成双端链表。为了方便持有链表节点组成的链表,用另一个链表list来管理。这个list的结构为:head指向表头节点,tail指向表尾节点,len表示节点数量,dup函数用于复制链表节点所保存的值,free用于释放链表节点所保存的值,match用于对比链表节点所保存的值和另一个输入值是否相等。
  • 实现的特性:
    1. 双端:prev和next指针,获取前置后置节点的复杂度都是O(1)
    2. 无环:表头的prev和表尾的next都指向null
    3. 获取链表表头节点和表尾节点复杂度为O(1):list的head和tail
    4. 带链表长度计数器:list中的len,O(1)
    5. 多态:节点用指针来保存值,并且dup、free、match属性可为节点值设置类型特定函数,所以链表可用于保存各种不同类型的值。
Redis之关于字典
  • 字典使用哈希表作为底层实现,一个哈希表中有多个哈希表节点,每个哈希表节点保存了字典中的一个键值对。哈希表的结构:

    • 哈希表数组table,table是一个数组,数组中的每个元素表示一个哈希表节点,都是一个指向dictEntry结构的指针,每个dictEntry结构保存一个键值对。dictEntry中有key,v,next。key表示键,v表示键值对中的值,值可以为一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。next指向另一个哈希表节点的指针,这个是拉链法的指针,连接的是多个哈希值相同的键值对。
    • 哈希表大小(即数组大小)size,
    • 计数器sizemask(为size-1),
    • 已有节点的数量used。
  • 字典的结构为:

    • type:是个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
    • privdata:私有数据,保存需要传给那些类型特性函数的可选参数
    • ht[2]:哈希表,包含两个项的数组,每个项都是一个dictht哈希表,一般只使用ht[0]哈希表,ht[1]只在对ht[0]哈希表进行rehash时使用
    • rehashidx:rehash索引,记录了rehash目前的进度,如果没有在rehash,那它的值为-1
      在这里插入图片描述
  • 哈希算法:先用哈希函数对key进行哈希操作得出key的哈希值,然后再将哈希值与上sizemask(size-1),得出的就是键的索引。

  • Redis使用MurmurHash2算法来计算键的哈希值。优点:即使输入的键很有规律,算法依然能给出很好的随机分布性,且算法速度很快。

  • 解决键冲突:redis的哈希表用链地址法,多个值相同的哈希节点就用next指针构成单向链表。因为dictEntry节点组成的链表没有直接指向链表表尾的指针,为了速度考虑,程序总是将新节点添加到链表的表头位置。

    在这里插入图片描述

  • rehash操作:为了尽可能避免冲突,希望哈希表的负载因子(load factor),维持在一个合理的范围之内,就需要对哈希表进行扩展或收缩(rehash操作)。步骤如下

    1. 先判断是扩展还是收缩列表
      1. 扩展:ht[1] 的大小为第一个大于等于ht[0].used*2的2^n。比如used为4, 4 * 2 = 8(2^3),8恰好是大于等于4的2的n次方。
      2. 收缩:ht[1] 的大小为第一个大于等于ht[0].used的2^n
    2. 重新计算ht[0]上的键的哈希值和索引值,然后移到ht[1]上
    3. 释放ht[0],将ht[1]设置为 ht[0],并在 ht[1]新创建一个空白哈希表,为下次rehash做准备
  • 哈希表的扩展与收缩:

    • 自动扩展要满足的条件:
      1. 服务器目前没有bgsave/bgrewriteaop,且负载因子大于等于1
      2. 服务器正在bgsave/bgrewriteaop,且负载因子大于等于5。主要因为这两个命令采用写时复制,所以子进程存在期间,服务器会提高负载因子,尽可能避免在子进程存在期间进行哈希表的扩展操作,这样可以避免不必要的内存写入操作,最大限度地节约内存。
      3. 负载因子:used/size
    • 当负载因子小于0.1,程序自动开始收缩。
  • 渐进式rehash:将所有键值对从ht[0]到ht[1],不是一次性的迁移,而是分多次渐进式完成的。一次性的话如果数据过多,会导致服务器在一段时间内停止服务。步骤如下:

    1. 为ht[1]分配空间
    2. 在字典中维持索引计数器rehashidx=0,表示rehash工作正式开始
    3. 未完成
  • 渐进式rehash执行期间,字典会同时找ht[0] 、ht[1]两个哈希表,所以rehash期间,字典的删除查找更新会在两个哈希表上进行。例如,查找一个键,先在ht[0]里进行查找,没有的话,继续到ht[1]里查找。

  • 同时,rehash期间,新添加到字典的键值对一律会被保存到ht[1]里,这就保证了ht[0]里面的键值对数量只减不增。

Redis之跳跃表:
  • 跳跃表:有序,能够快速访问节点,是因为每个节点中维持多个指向其他节点的指针。可以用来实现有序集合(ZSet),如果Zset包含的元素数量比较多,或者zset中元素的成员是比较长的字符串时底层就用跳跃表。

  • 跳跃表在链表的基础上增加了多级索引来提高查找效率。但是空间换时间,会带来一个问题,索引占内存。但是如果原链表存储的是很大的对象,索引节点只需要存储关键值和几个指针的情况下,缺点可以忽略。

  • 跳跃表由跳跃表节点和一些附加属性组合而成。

  • 跳跃表结构

    • *head:指向跳跃表的表头节点。核心,因为后面挂了一串跳跃表节点
    • *tail:指向跳跃表的表尾
    • length:跳跃表长度(表头节点不计算在内)
    • int level:跳跃表内层数最大的那个节点的层数,O(1)获取最高节点的层数。可理解为深度。
  • 跳跃表节点

    • obj:成员对象,是个指针,指向字符串对象,而字符串对象则保存着一个SDS值。
    • score:分值,double,跳跃表中节点按所保存的分值从小到大排列,有序。
    • backword:后退指针,从表尾向表头遍历时使用,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
    • level数组:层,L1代表第一层,L2代表第二层。层的数量越多,访问其他节点的速度就越快
      • forward:前进指针,用于访问位于表尾方向的其他节点。
      • span:跨度,记录下一节点与当前的距离。
  • 跳跃表是如何迭代寻找分值对象呢?使用前进指针就能实现

    • 1)迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点
    • 2)在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。
    • 3)在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。
    • 4)当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qVvDLWHu-1648295922225)(Redis之万能魔法女巫加油.assets/2020121118381640.png)]

  • 如何计算目标节点在跳跃表中的排位?

    • 在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是,实际上就是节点的顺序值。
    • 需要注意:在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
Redis之整数集合
  • 整数集合:当一个集合只包含整数值元素,且集合的元素数量不多,Redis就用整数集合作为集合键的底层实现。唯一不重复。

  • 整数集合结构:

    • encoding:contents数组中元素的真实类型。如果encoding值为INTSET_ENC_INT16INTSET_ENC_INT32INTSET_ENC_INT64),contents类型则为int16_t(int32_t, int64_t),数组里的每个项都是int16_t(int32_t, int64_t)类型的整数值。
    • length:元素数量,contents数组的长度
    • contents[]:保存元素的数组,各个元素在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复元素
  • 整数集合升级:往数组中添加新元素,且新元素的类型比集合类型长时,要进行升级。步骤如下:

    1. 根据新元素,扩展数组的空间大小,并为新元素分配空间
    2. 将底层数组的所有元素都转换成与新元素相同的类型,并将转换后的元素放到正确的位置,这个过程要保证有序性
    3. 将新元素添加到数组中
    • 注意,如果新元素的长度比现有元素的大,那么,这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素【正整数和负整数】
      • 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0)
      • 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)
  • 整数集合升级的好处:

    1. 提升灵活性:可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误
    2. 尽可能的节约内存:如果一开始就用int64_t的话,可能会出现用64的空间去存储16或者32的值,这样很浪费。而整数集合现在的做法既可以让集合同时存三种不同类型的值,又可以确保升级操作只在有需要的时候进行。这样能尽量节省内存,即”动态变化“。
  • 整数集合不支持降级。

Redis之压缩列表:
  • 压缩列表:列表键和哈希键的底层实现之一。Redis为了节约内存而开发的。连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点保存一个字节数组或者一个整数值。

  • 压缩列表结构:压缩列表节点+一系列属性

    在这里插入图片描述

    • zlbytes:4字节,记录压缩列表占用的内存字节数
    • zltail:4字节,记录压缩列表表尾节点距离压缩列表起始地址有多少字节。通过这个值,压缩列表无需偏移量就能快速确定表尾节点地址。
    • zllen:2字节,当属性值小于65535时,记录了压缩列表包含的节点数量,当属性值等于65535时,节点数量需要遍历获取。
    • entryN:压缩列表节点
    • zlend:1字节,特殊值0xFF,用于标记压缩列表的末端。

  • 压缩列表节点:

    • previous_entry_length:上一节点长度,以字节为单位。长度为一字节或者五字节。
      • 如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。
      • 如果前一节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。
    • encoding:content的编码,记录content所存数据的类型以及长度。一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码
    • content:实际内容
      • 一个字节数组:长度可以是三选一:26-1、214-1、2^32-1
      • 或者是一个整数值:六选一:4位、1字节、3字节、int16_t、int32_t、int64_t
  • 举个栗子:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UxOaxj9h-1648295922225)(Redis之万能魔法女巫加油.assets/20201211210049536.png)]

    • 列表zlbytes属性的值为0xd2(十进制210),表示压缩列表的总长为210字节,O(1)
    • 列表zltail属性的值为0xb3(十进制179),这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量179,就可以计算出表尾节点entry5的地址,O(1)
    • 列表zllen属性的值为0x5(十进制5),表示压缩列表包含五个节点,O(1)
  • 压缩列表的特性:

    • 倒序回溯:previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址,压缩列表的从表尾向表头遍历操作就是使用这一原理实现的。
    • 连锁更新:例如,压缩列表中,有多个连续的、长度250 ~ 253之间的节点e1至en,因为e1至en的所有节点的长度都小于254字节,所以记录这些节点的长度只要1字节长的previous_entry_length,即,e1到en的所有节点的previous_entry_length都是1字节长的。这时候我如果将一个长度大于等于254字节的新节点设置为压缩列表的表头节点,那么,因为e1的previous_entry_length为1字节,没办法保存新节点的长度,这时就要对e1的previous_entry_length从1字节进行扩展至5字节。那么问题来了,原来是250 ~ 253,现在增加了4字节,变成了254 ~ 257,后一个节点的previous_entry_length也容纳不下了。以此类推,后面的节点都要进行扩展。与添加新节点相似,删除节点也可能会引发连锁更新。
    • 连锁更新在最坏情况下的复杂度为O(N*N),但真正造成性能问题的几率是很低的。首先压缩列表中要恰好有多个连续的,长度正好介于250~253字节之间的节点,连锁更新才有可能被引发,其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。
Redis之对象
  • 对象的类型与编码:Redis中每个对象都由一个redisObject结构表示,其中有三个属性与保存数据有关:

    • type:类型,在redis中,键总是一个字符串对象,而值可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种。所以字符串键的意思是,这个数据库键所对应的值是字符串对象。
    • encoding:编码,决定ptr指向对象的底层实现。例如,set msg "nihao", OBJECT ENCODING msg 就可以查看数据库的键的值对象的编码。
    • *ptr:指向底层实现数据结构的指针
  • 字符串对象:编码可以是int、raw的sds实现、embstr的sds实现。

  • 内存回收:因为Redis是基于C语言的嘛,而C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)【和Java的引用计数机制是一样的】技术实现内存回收机制。通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录。

    • 在创建一个新对象时,引用计数的值会被初始化为1
    • 当对象被一个新程序使用时,它的引用计数值会被增一
    • 当对象不再被一个程序使用时,它的引用计数值会被减一
    • 当对象的引用计数值变为0时,对象所占用的内存会被释放

    由于Redis是个内存级的数据库,所以可想而知其瓶颈就在内存上,内存回收策略很重要,而且Java其实也是基于C实现的。

  • 对象共享:除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。假设键A创建了一个包含整数值100的字符串对象作为值对象,键B也要创建一个同样保存了整数值100的字符串对象作为值对象,此时B发现A已经创建了,则无需再创建而是直接指向A的值对象即可。

    在这里插入图片描述

  • 共享对象池 : Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建。这一万个字符串对象也叫共享对象池。创建共享字符串对象的数量可以通过修改配置来调整。

    • 这些共享对象的引用计数开始都是1,被服务器引用,之后如果有键A或B之类的指向它,refcount就累加即可,但不会被释放,除非服务器宕机,重新初始化。
    • 这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象

    共享对象池对于节约内存还是很重要的

  • 那么问题来了,为什么Redis不共享包含字符串的对象?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值