redis设计与实现
简介
Redis数据库中的每个键值对都是由对象组成的,其中:
- 数据库键总是一个字符串对象
- 而数据库键的值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种对象中的其中一种
简单动态字符串
Redis自己构建了一种名为简单动态字符串(SDS)的抽象类型,并将SDS用作Redis的默认字符串表示
在Redis里面,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方
当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值。
如果客户端执行命令:
redis> SET msg "hello world"
OK
那么Redis将在数据库中创建一个新的键值对,其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串"msg"的SDS
- 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串"hello world"的SDS
redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3
那么Redis将在数据库中创建一个新的键值对,其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串"fruits"的SDS
- 键值对的值是一个列表对象,列表对象包括了三个字符串对象。
除了用来保存数据库中的字符串值以外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态的中的输入缓冲区,都是由SDS实现的
SDS的定义
struct sdsdr {
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
}
SDS遵循C字符串以空字符串结尾的惯例,保存空字符串的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的。
SDS与C字符串的区别
1、常数复杂度获取字符串长度
SDS记录了字符串的长度,获取字符串长度的复杂度为O(1),C语言要获取字符串的长度操作需要O(N)复杂度
2、杜绝缓存区溢出
C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出
3、减少修改字符串时带来的内存重分配次数
C字符串的底层实现总是一个N+1个字符长的数组,因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都要对保存这个C字符串的数组进行一次内存重分配操作:
为了避免C语言的这种缺陷,SDS通过未使用空间接触了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
4、二进制安全
C字符串必须符合某种编码(ASCII),并且除了字符串的末尾以外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何的限制。
5、兼容部分C字符串函数
SDS一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据末尾设置为空字符,并且总在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。
字典
字典的实现
redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对
哈希表
table属性是一个数组,数组中的每一个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2JsxRaXN-1645711487094)(G:\photos\mkd截图\image-20211017133649263.png)]
哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ENMcxrCN-1645711487096)(G:\photos\mkd截图\image-20211017133946663.png)]
key属性保存着所有键值对中的键,而v属性保存键值对中的值,其中键值对的值可以是一个指针,或者是一个unit64_t整数,又或者是一个int64_t整数
next属性是指另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突的问题
字典
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0Hy4v3lc-1645711487097)(G:\photos\mkd截图\image-20211017134341746.png)]
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]进行rehash时使用
rehashidex记录了rehash目前的进度,如果没有进行rehash,那么它的值为-1
哈希算法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9mtlfWjr-1645711487098)(G:\photos\mkd截图\image-20211017133238460.png)]
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少。为了让哈希表的负载因子维持再一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行rehash操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
1、为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量
2、将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
3、当ht[0]包含的所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
渐进式rehash
为了避免rehash对服务器的性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次,渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]
详细步骤:
1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
3、在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作之外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehash属性的值增1
4、随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
跳跃表
跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更简单,所以有不少程序都使用跳跃表来代替平衡树
redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
整数集合
实现
整数集合是redis用于保存整数值的集合抽象数据结构,它可以保存整数值,并且保证集合中不会出现重复元素
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数结合需要先进行升级,然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为3步进行:
1、根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
2、将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层十足的有序性质不变。
3、将新元素添加到底层数组里面
升级的好处
1、提升灵活性
因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误
2、节约内存
如果只有int16_t的值,那么底层实现就一直是int16_t,只有在我们将其他类型添加到集合时,程序才会对数组进行升级
降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态
压缩列表
压缩列表是列表键和哈希键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,那么就是长度比较短的字符串,那么redis就会使用压缩列表来做列表键的底层实现。
压缩列表的构成
压缩列表是redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值
对象
Redis基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
除此之外,redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放。
对象的类型与编码
Redis使用对象来表示数据库重的键和值,每次当我们在redis数据库新创建一个键值对,我们至少会创建两个对象,一个对象用作键值对的键,另一个对象用作键值对的值
类型检查和命令多态
在执行一个类型待定的命令之前,redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。
LLEN命令是多态
redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令
对象共享
除了用于实现引用内存回收,对象的引用计算属性还带有对象共享的作用。
在redis中,让多个键共享同一个值对象需要执行以下两个步骤:
1、为键B新键一个包含整数值100的字符串对象
2、让键A和键B共享同一个字符串对象
给定的命令。
LLEN命令是多态
redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令
对象共享
除了用于实现引用内存回收,对象的引用计算属性还带有对象共享的作用。
在redis中,让多个键共享同一个值对象需要执行以下两个步骤:
1、为键B新键一个包含整数值100的字符串对象
2、让键A和键B共享同一个字符串对象