redis中基础数据结构

redis 是大家比较常用的一款内存数据库,其高并发能力和简洁的使用受产业界频繁采用。本篇根据资料概括redis 的数据结构和实现逻辑。部分内容需要进一步阐释将在其他地方展开。

用户视角的数据结构主要有 字符串,列表,字典哈希,集合,有序集合,其它数据结构不在本文讨论范畴以内。在redis的以内这些统统称为redis对象。那么redis为了高效,自己对这些数据结构有着高效而独到的实现。

我们知道redis是c语言写的,c的语言库匮乏,许多结构不具备,不符合成熟数据结构的要求,一方面有着诸多边界条件抛给编程者处理,例如字符串的动态管理,穿件与销毁空间控制等等;另一方面对于特定使用场景,效率上也受限。
redis 首先定义一些最基础的编程结构,例如字符串,链表,字典,跳表,整数集合,压缩列表,与用户视角不同的是,这些是内部的具体实现。
以下内容主要来自《Redis设计与实现》的总结。

动态字符串(SDS)

数据结构的定义示例:

struct sdshdr {
	//记录buf数组中已经存在的字符的数量
	int len;
	//记录buf数组中未使用的字符的数量
	int free; 
	// 字节数组,用于保存字符串
	char buf[];
}

需要指出的是,这个定义是redis低版本中的形式,后来redis对其进行了进一步优化,按照len,和 free两个变量分类的不同,sds的结构体也被定义了不同的形式,例如对于长度小于 256 的字符来讲,int_8 足以存储 len的长度。redis 对性能追求近乎变态,大家还是自行翻看redis的相关源码吧。

有了结构体不难发现,字符串长度询问是常数级别。

字符串结构不得不应对修改操作,这期间我们必须保证缓冲区不能溢出,按照节省的原则,增加操作要更多空间,减少操作需要压缩空间,但是操作系统申请和释放空间的操作是耗时的,我们必须谨慎的选择空间分配和释放的时机。
当每次需要增加空间时,我们会预分配更多的空间,预期将来还会有更多的更改,但是这个多分配多少合适呢,按照《Redis设计与实现》的说法,当修改SDS串之后,空间占用小于1M,程序会分配多一倍的空间。这源于均摊时间复杂度的分析,很多的实现都采用这种方法,例如c++ stl容器中的vector。redis这里不同的是,当SDS修改后长度大于等于1M,那么将预先多分配1M的空间,多余空间大小用free来标示,大体策略如此。
那么当字符串buf空当太多如何释放呢,答案是不立即释放,在超出一些配置项设置值后统一触发释放。即惰性释放。
最后要指出的是,SDS虽然叫字符串,实际可以存储任意值,没有什么格式上的限制,图片,二进制都可以,也称 SDS是二进制安全的。

链表

老生常谈,链表虽然容易理解,但越是简单的数据结构,越是常用。

redis中的list特别长的时候,底层就是链表实现的,为什么强调长,因为短的时候还有一些其他的实现方式待选,请往后阅读。
除此之外,redis发布与订阅,慢查询,监视器等功能也用到了链表。
有时候我们也认为链表是图或者树的退化版本。
不仅仅redis,mysql底层也大量使用了链表,有时间开个专题讲一下。
多于实现比较简单不细说。我们只需要知道,redis中的链表是:

  1. 双端链表
  2. 无环(无聊的面试题经常让你判断链表是否有环,我打算也写篇文章)
  3. 有表头和表尾
  4. 带长度计数器
  5. 多态(void指针存储节点值,理论上节点可以存储任意值)

字典

字典,hash表,大家都熟悉,不多介绍了哈。
问题在于redis是怎么实现呢。先看下数据结构,我们思考它是如何应对各种操作的。

typedef struct dict {
	// 类型特定函数
	dicType *type 
    
	// 私有数据
	void privateData 
	
	// 哈希表
	dict ht[2]	

    // rehash 索引
	int trehashidx	
}

先搞明白每个字段干嘛的,
type 里面存储的是一簇用于操作特定类型值键值对的函数,例如计算hash值的函数,复制键和值的函数, 比较函数,销毁函数,而 privateData里保存传递这些函数的可选参数。
ht属性比较特殊,它包含了两个hash表,多说一句,redis的hash表解决冲突的方法是开链法。这两个hash表是rehash时用的。
rehash用来调整hash表的负载因子,将其维持在一个合理的范围内。 rehash可以使表扩展或者收缩。步骤是这样的。
1.首先根据原始hash表的大小分配空间ht[1]。
2. 将 ht[0] 的所有键值rehash到 ht[1]
3. 释放 ht[0], ht[1] 设置到 ht[0] ,并在 ht[1] 重建一个空表,为下次rehash做准备。

rehash操作的时机并不是一成不变的,当系统在执行 BGSAVE 或者 BGWRITEAOF操作时候,系统应尽量避免执行rehash,因此负载因子的计算会释放放宽一些,防止rehash过于频繁导致系统超载。

rehash 并不是在一个时间节点把所有工作做完,而是采用渐进rehash的方式,每当执添加,删除,查找,更新操作时,顺带执行一些rehash的操作,hash表中采用一个 rehashidx的变量来记录rehash的进度。

当rehash正在进行时,增加操作会直接rehash到ht[1], 而查找操作会先查ht[0], 再查ht[1].rehash期间,ht[0] 不再增加元素。

跳跃表

跳跃表是一种性能接近平衡树,但实现和维护要简单的多的存储有序元素的数据结构。它是redis有序集合实现的基础数据结构之一。

关于跳表的原理和详细实现已经写过相关文章,这里跳表实现。本部分主要介绍redis跳表的实现以及一些概念。

redis的跳跃表结构首先存储了head和tail,以及跳跃表的最大层级level,跳跃表的长度length。每个跳跃表的节点除了存储跳表每层的跳跃指针外,还存储了后退节点,即节点前一个直接节点,分值,以及节点对象. 跳跃表中每个节点是唯一的。当分值相同时,按照对象的大小进行排序。

整数集合

整数集合是集合底层实现之一,当一个集合只包含整数值元素,并且这个集合元素数量不多的时候,Redis就会用整数集合作为集合的底层实现。
整数集合中不会出现重复元素。整数集合中各个项按照从小到大排列。

那么整数集合是如何节省内存的呢。

整数集合中的元素存储的类型是由占用字节数目最大的那个元素的类型决定的,例如所有的元素都是16位整数的话,那么所有的类型都以 int_16 存储,如果有一个元素是int_64, 那么所有的元素都要升级为 int_64. 升级操作需要对所有元素重新分配空间后填充。频繁的升级也会损害效率。
升级后整数集合不支持降级操作。

压缩列表

当一个列表键中要么是小整数值,要么是长度较短的字符串时,就会用压缩列表作为底层的实现。
我的建议,你不需要清清楚楚看明白这个数据结构,记住我说的思路就可以了,其他你是记不住的。用操作去反推实现。
首先压缩列表是一块连续的内存区域,存储一个任意元素的列表。那么问一些问题。

  1. 存了多少? 前四个字节
  2. 怎么遍历? 再四个字节存储尾指针。
  3. 尾指针如何 往前走? 每个元素存储上个元素的大小,当前可以直接反推上个元素,直到所有节点。
  4. 存了多少节点?第9-10字节存储。

每个节点存储了节点信息,采用了一些优化的存储编码策略,不细说,也记不住,用到时候查资料吧。
其实我们只需要记住怎么遍历就行。就是因为节点有个 previous_entry_length.
这个length有些讲究,为了节省空间,当节点长度小于 254 个字节,previous_entry_length 只用一个字节存储,当超过254字节时,要用5字节空间保存值。
那么问题来了,添加删除元素怎么办,都会改变元素的相邻关系,这个previous_entry_length的字节长度要时刻变化了,最坏可能导致连锁更新!不过好在这种出现的概率不是很大,大多数情况都是很高效的。

对象

redis 最终提供给用户的是对象系统,是基于前面介绍的数据结构封装实现的,这个系统包含 字符串对象,列表对象,hash对象,集合对象,有序集合对象五种,每一种都至少用到前面介绍的数据结构, 具体是实现数据结构我们称之为编码。所以我们也说一种对象对应至少一种编码。

编码除了我们前面提到的,增加介绍两种,一种是 long 类型的整数,另一种是 embstr编码的动态字符串.

字符串对象

字符串对象可以是 int raw 或 embstr,
如果字符串保存的是一个字符串值,且字符串的值的长度小于39字节,那么字符串值对象使用embstr的编码方式,一般情况下,字符串对象在结构体中又存储了一级指针,数据是二级寻址,embstr将元数据和编码类型放到一块连续的内存区域,提高了内存分配和释放的效率,更好的利用了缓存。
如果保存的字符串值大于39字节,字符串对象编码将使用SDS来保存字符串值。
如果一个字符串对象保存的是整数值,且这个整数值可以用long类型保存,那么字符串对象会保存为long类型。
浮点数也是用字符串类型保存的,必要时会进行转换(执行命令计算等)。
在对字符串进行修改操作时,对于int或者embstr编码来说是只读的,在其间会首先对其转换为raw后再进行操作。

列表对象

列表对象可以是ziplist压缩列表或链表linklist。
不管哪种,节点元素可以是字符串对象。
当满足以下条件时候列表编码使用 ziplist

  1. 每个元素小于64字节
  2. 元素数量小于512
    以上值都可以定制化。
哈希对象

哈希对象可以是 ziplist或者 hashtable
当使用压缩列表的编码时,会将键和值一次推到压缩列表的表尾,二者紧挨着。
同样的,当

  1. 当hash表所有键值对长度小于64字节
  2. 键值对数量小于512个。
    使用ziplist进行编码。使用字典编码。以上也可以定制化。
    否则就用字典编码。
    对着时间推移,列表会被更新,当任意一个条件已经不满足,编码会发生相应改变,这种改变又是可逆的。
集合对象

集合对象可以是intset或者hashtable
当集合元素都是整数且集合元素不超过512个,对象使用intset编码。以上阈值也是可以修改的,不能满足的使用hashtable编码。

有序集合

这是一个很重要的数据结构,有时候被封装为全局的优先队列。
有序集合的编码可以是ziplist或者 skiplist。
有序集合每个元素存储了分值,对象按照分值进行排序。
有序集合比较特别的是,还创建了一个每个成员到分值的映射字典,这保证查询每个对象的分值复杂度是O(1)级别,同事也保证了有序性。两种数据结构为了节省空间,相同的节点内存上是共享的。
当有序集合对象同时满足

  1. 有序集合元素小于128个
  2. 每个有序集合元素都小于64字节
    使用压缩列表进行编码。
    否则使用跳表。
关于应用在对象上的命令

有些命令可以作用于任意对象例如 DEL,有些命令作用于相同对象任意编码例如 LLEN。
这些都可以根据相应存储的字段判断,我们说命令是多态的,有些对于类型多态,有些对于编码多态。
redis为这些对象设计了内存回收的机制,每个对象都有引用计数,在引用计数为0的时候会在合适的时候被垃圾回收。
处于内存节省的考虑,redis初始化服务器时候,会创建一万个字符串,这些包含了0-9999的所有整数值,当服务器用到这些的时候会与这些共享内存。
在redis对象的内存储了一个 lru的字段,记录了对象最后一次被命令程序访问的时间。当开启最大内存参数时,空转时长较高的部分会优先被服务器释放。

参考文献
[1]https://juejin.cn/post/7036614335157764133
[2] 《Redis的设计与实现》黄健宏

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值