0%

redis数据结构核心实现原理

[toc]


redisObject对象#

redis每一个key(键) ,本质上都对应了一个redisObject对象。
但是键对应的数据结构有很多,做的操作也不同,所以要先以redisObject作为入口和关联点,再去做操作
3f2f395c3f0f7e0adc26c461f1b47846b61abf52


a374de100b37f8fd036ff7dfae7da6a4ff2e5fc7
对象类型、编码类型的对应关系在前面那张图里有解释。


Q: redis从命令调用到触发执行,经历了什么?#

A:
803dc234f7de4819af505a6a705429dd34e8da3a
以lpop为例
3c79d88d49559edc82bd3abe1d2d65bacdfb6c3f


Q: redis的共享机制有了解吗?#

A:
有一些类似常量、或者通用的值,会放到共享对象中。
比如OK、ERROR、 状态码等,即每次不会重新生成这些对象。
只支持字符串对象(整数也属于字符串)


Q: 为什么共享对象不支持字符串以外的那些列表、哈希对象等?#

A:
对复杂度较高的对象创建共享对象, 需要消耗很多的CPU
而为了验证内容是否改变或者匹配, 消耗的量也很大,不如就不共享。


Q: redis对象什么时候被回收?#

A:
redisObject有个引用计数。 但删除了某个key, 则这个key对应redis对象的引用计数-1。
当通过删除操作减为0的时候, 释放这个键的内存空间。


SDS 动态字符串(simple dynmic string)#

实际结构如下
fd702c8bb3c049afdacc62293d6f49921a486bec
注意,len并不是包含头部+buf+\0的。
他只是记录用了多少字符。
而实际上的buf可能比你用的要多。


Q:经典,SDS相比直接用C语言的字符串char*有什么优势?#

A:

  • 可以根据len马上获取字符串长度。而C语言需要遍历直到遇到\0才能得到长度
  • 杜绝缓冲区溢出。当涉及字符串拼接式,可以提前计算长度是否够用,再扩容
  • 可以进行预分配,当字符串要扩容时,提前分配一倍空间,避免频繁扩容
  • 二进制安全,即不用担心字符串中间自带的\0问题(图片流、序列化流之类的可能会有)

Q: 既然有长度len了,为什么末尾还要保留\0?#

A:
这样可以直接重用C语言库<string.h>中的一些函数


Q: 怎么计算未使用的空间?#

A:
alloc - len, 就是未使用的buf空间


Q: 什么时候, buf空间会比实际用的多?#

A:
只有当字符串真正需要增长的时候,且空间不足时,才会增加一倍。
这个就是空间预分配。
注意刚开始建立一个字符串时,是不会腾多余空间的。


Q:这个增长后多出来的预分配空间后面会被优化掉?#

A:
不会被释放,除非:

  1. 键被删除
  2. redis重启,通过持久化载入字符串不会做预分配。

如果真的频繁增长,则需要修改redis服务代码,增加定时释放机制。


Q: 字符串如果做了缩短,多出来的空间也不会释放吗?#

A:
会释放,但是需要执行一些api例如sdsfree进行手动释放。
适用于你这个redis会频繁做字符串缩短的场景。


ziplist 压缩列表#

  • 一句话解释: ziplist这个列表中,每个元素的大小不是固定的, 每个元素都有个头部,确认这个元素的大小,因此这个ziplist非常紧致。

    存储结构如下:

    3c72452741a16801ceb312d2e8ff95fd14d3e462


Q: ziplist的最后有个zlend终止符0xff, entry中的数据有没有可能是0xff,导致冲突?#

A:
ziplist保证任何情况下, 一个entry的首字节(entry头字节)都不会是255。

存储int时:
|11000000| encoding为3个字节,后2个字节表示一个int16;
|11010000| encoding为5个字节,后4个字节表示一个int32;
|11100000| encoding 为9个字节,后8字节表示一个int64;
|11110000| encoding为4个字节,后3个字节表示一个有符号整型;
|11111110| encoding为2字节,后1个字节表示一个有符号整型;
|1111xxxx| encoding长度就只有1个字节,xxxx表示一个0 - 12的整数值;
|11111111| 还记得zlend么?


Q: 为什么ziplist这样设计很省内存?#

A:
用了encoding来细化存储大小, 这样可以避免很多的冗余空间。


Q: ziplist做新增时,会像arryalist那样预留空间吗?#

A:
为了保持紧致,不会预留,每次写都是做分配内存的操作
删除的话也是立刻缩容。


Q: 什么是ziplist的链式反应?#

A:
头部加了一个超级大的节点
后面节点的prelen字段变长, 于是整个entry变长
于是再导致后面节点的prelen变长,以此类推。
影响就是时间会比较慢,总是要依次分配。
但是触发概率比较低, 不会那么巧全部都在prelen增长的边缘


quicklist快表#

也就是 很多个ziplist组成的list
fbc43b472ab1370f35f023cce85dba8e9a6c1490
原先ziplist里,每个entry只存存数。
而现在的entry变成了一个个可以动态扩展的ziplist。

Q: 快表和压缩表的区别?#

A:

  • 压缩链表不适合中间写, 因此每次都需要重新分配内存。而快表宏观上是一个双向链表,进行插入时很方便。
  • 快表可以用二分查找定位数据

quicklist源码解读


字典/哈希表 - Dict#

就是简单的哈希表 + 链表解决冲突的实现结构。
f09e457c6a9d4ccab072c4958e0b6aff88b55004
扩容是就是rehash重新哈希进行扩展或者收缩。
扩容和收缩:当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:
如果执行扩展操作,会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。
相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。


Q: 如果哈希表的数据量很大, 扩容时会怎么做?#

A:
使用渐进rehash的方式。
扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的

  • 字典的删除查找更新等操作可能会在两个哈希表上进行

  • 第一个哈希表没有找到,就会去第二个哈希表上进行查找。

  • 但是进行 增加操作,一定是在新的哈希表上进行的

    目的是防止扩容过程阻塞哈希的响应,因此扩容是异步完成的机制

intSet整数集合#

intSet是set的底层实现。
当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。使用遍历实现集合的操作。
实现很简单
编码(决定整数是16/32/64位)——个数——存放整数
659d4ccfbc6169c3d352904f4fe911deda8dfd03
为了方便二分查找,各个项在数组中的值得大小从小到大有序排,且不包含任何重复项


Q: 原先是16位的编码, 如果此时插入了1个64位的整数怎么办?#

A:

  1. 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上
  3. 在放置元素的过程中, 需要继续维持底层数组的有序性质不变
  4. 最后改变encoding的值,length+1。

ZSikpList跳表#

skiplist实现原理

  • 插入时,随机一个层数,然后找到那一层的对应位置插入,并更新前后节点

  • get时,先从最高层找,比要找的大时,再去下一层。

  • 随机时不是完全随机,会根据当前分布情况,修改概率p,并设置某一层的最大限制,防止比别的层过多。

    b148147baa7475ed77e0525aa6fb624c7f822861


Q: 跳表和平衡树的比较?#

A:

  • 范围查找时,跳表比平衡树简单,找到最小点时直接遍历即可找到最大点的位置即可。 平衡树可能还要重新搜。
  • 增删节点时, 平衡树还要调整树,但是跳表只需要修改前后两个节点。
  • 跳表平均每个节点存1.33个指针, 而平衡树要一定存2个指针。
  • 时间复杂度, 跳表和平衡树没有区别,都是O(logn).

底层结构和redis数据结构对象关系#

字符串string底层结构#

f1bc579ea85ccb7493f162e9acc242906b3d551e
可以看到字符串有三种编码:int、 embstr(小于44的短字符串)、raw(大于44的长字符串)


Q: embstr和raw的底层区别是什么?#

A:

  • embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。
  • 因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。
  • embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间
  • 因此redis中的embstr实现为只读。

Q: 如果int的大小溢出long了,怎么办?#

A:
会自动转换编码为raw。


列表list底层结构#

7f13eca2de5a7046c82c9c25b53235fec4da5d3f
可以看到新版本的redis, 列表只有一种实现方式——quicklist。
以前有OBJ_ENCODING_ZIPMAP与OBJ_ENCODING_LINKEDLIST两种编码,现在都被废弃了。


哈希表hash底层结构#

164eb020bd8c70f6cb695f479067260c951ee4c2
可以hash表的底层是 ziplist或者dict
dict好理解, 为什么ziplist可以作为haxi表呢?
因为ziplist被hash表限制在了数量比较少的场景, 这种场景下直接遍历是不会耗时很多的。
aeabbe3b03d258faa3891cd1519b890e831fbbd8
使用ziplist作为底层hash结构的条件:

  1. 列表保存元素个数小于512个
  2. 每个元素长度小于64字节

Q: ziplist怎么解决哈希冲突?#

A: 元素太少了, 不用解决, 没有哈希过程, 直接遍历。 如果有相同的key,直接替换他的下一个entry即可。

集合对象底层结构#

ef7b67ef44114b3ca380b467840de99a21fce23f
集合是无序且不支持重复的。
使用dict和intset两种编码。
使用dict时, 只有dict的键部分, 值部分的链表不会允许展开,因为不允许重复。

intset的条件:#

  1. 都是整数
  2. 数量小于512

有序集合 sortedSet底层结构#

056c5fd86e054f81735172f916350716fe684dc9
使用ziplist或者(skiplist+dict)实现。
ziplist仍然是数量很少的时候使用,时间消耗没那么高
skip+dict比较复杂,如下图所示:
4e33d050961d676cb2fdb6ea2a2bea5a9cf8eac5


Q: 为什么不能单独使用skiplist或者单独使用dict实现?#

A:

  • 假如我们单独使用 字典,虽然能以 O(1) 的时间复杂度查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;
  • 假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作有 O(1)的复杂度变为了O(logN)。因此Redis使用了两种数据结构来共同实现有序集合。