Redis设计与实现(第一部分):数据结构与对象
一、数据结构与对象
1. 简单动态字符串
Redis的底层没有直接使用C语言的字符串表示方法,而是自己构建了一种名为**SDS(简单动态字符串)**的抽象数据类型,用作Redis的默认字符串表示,此外,SDS还被用作缓冲区。
1.1 SDS的定义
如图,一个sdshdr结构表示一个SDS值 ,free表示分配的未使用空间,len表示字符串的长度,buf是一个char类型的数组,保存了’R’、‘e’、‘d’、‘i’、‘s’五个字符,最后一个字节保存了空字符’\0’,遵循C字符串以空字符结尾的惯例。
1.2 SDS与C字符串的区别
1. 常数复杂度获取字符串长度
C语言获取字符串长度时需要遍历整个数组,复杂度为O(N),SDS在len属性中记录了SDS本身的长度,获取只需要O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。
2. 杜绝缓冲区溢出
C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出,而 SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
3. 减少修改字符串时带来的内存重分配次数
C语言每次增长或者缩短一个字符串,总要对保存这个字符串的数组进行一次内存重分配操作,为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在SDS中,buf数组的长度不一定就是字符数量加一,还可能包含包含未使用的字节free。
通过未使用空间,SDS实现了 空间预分配和惰性空间释放 两种优化策略。
■ 1.空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS进行修改并空间扩展时,程序不但会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
修改之后,如果SDS长度小于1MB,分配和长度一样大小的未使用空间.
修改之后,如果SDS长度大于1MB,分配1MB的未使用空间.
■ 2.惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:缩短SDS字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来备用。
4. 二进制安全
C字符串中的字符必须符合某种编码,并且字符串里面不能包含空字符,所以它只能保存文本,不能保存图片音频等,而 SDS的API都是二进制安全的 ,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,不对数据格式做任何限制。
5. 兼容部分C字符串函数
虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例,这使得SDS可以在有需要时重用<string.h>函数库,从而避免了不必要的代码重复。
6. 总结
2. 链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。
2.1 链表和链表节点的实现
每个链表节点使用一个listNode表示,多个listNode可以通过prev和next指针组成双端链表,如上图所示。
虽然使用多个listNode结构就可以组成链表,但使用list来持有链表的话,操作起来会更方便:
/*
* 双端链表结构
*/
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数,对比链表节点所保存的值和另一个输入值是否相等。
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
Redis链表实现总结
■ 双端:带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
■ 无环:表头节点的prev指针和表尾节点的next指针都指向NULL。
■ 获取链表的表头节点和表尾节点的复杂度为O(1)。
■有长度计数器。
■链表可以用于保存各种不同类型的值。
3. 字典
字典,又称为符号表、关联数组或映射,是一种用于保存键值对的抽象数据结构。
3.1 字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
1.哈希表
2.哈希表节点
3.字典
3.2 哈希算法
将一个新的键值对添加到字典里面时,先根据键值对的键计算出哈希值和索引值,然后将哈希表节点放到哈希表数组的指定索引上面
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
3.3 解决键冲突
Redis的哈希表使用 链地址法 来解决键冲突
每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来(头插法),这就解决了键冲突的问题。
3.4 rehash
当哈希表保存的键值对数量太多或者太少时,为了让哈希表的负载因子维持在一个合理的范围之内,需要对哈希表的大小进行相应的扩展或者收缩,可以通过执行rehash(重新散列)操作来完成:
■为字典的ht[1]哈希表分配空间
■将保存在ht[0]中的所有键值对rehash到ht[1]上面(重新计算键的哈希值和索引值)
■迁移完成后,释放ht[0]
■将ht[1]设置为ht[0],并为ht[1]新创建一个空白哈希表,为下一次rehash做准备。
3.5 渐进式rehash
上边我们介绍了rehash,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。因为如果哈希表里边保存的键值对过多,一次性rehash的庞大计算可能导致服务器停止一段时间。渐进式rehash就是为了避免rehash对服务器性能造成影响:
■为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
■在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
■在rehash进行期间,每次对字典执行操作后,顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],完成后rehashidx属性的值增一
■最后当ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
4. 跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
4.1 跳跃表的实现
Redis的跳跃表由 zskiplistNode和zskiplist 两个结构定义,zskiplistNode结构表示跳跃表节点,zskiplist结构保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等,源码如下:
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
上图是一个跳跃表的大体结构,接下来对 zskiplistNode和zskiplist 详细分析:
4.2 跳跃表节点
1. 层
跳跃表节点的level数组可以包含多个元素,每个元素表示一个指向其他节点的指针,层数越多,访问其他节点越快。
每次创建一个新跳跃表节点时,程序会根据幂次定律(越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
2. 前进指针
每个层都有一个指向表尾方向的前进指针,遍历整个跳跃表如下:
3. 跨度
用于记录两个节点之间的距离,跨度越大,相距越远,指向null的指针跨度为0
跨度是用来计算 排位 的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
例子 : 下图,寻找分值2.0,成员对象o2的节点,箭头为路径
4. 后退指针
每个节点只有一个后退指针,所以每次只能后退至前一个节点,如果要倒序遍历的话,可以让头指针先访问表尾节点,然后通过后退指针逐个往前倒。
5. 分值和成员
节点的分值是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
在一个跳跃表中,每个节点的成员对象必须是唯一的,但分值不一定唯一,分值相同的节点将按照成员对象
在字典序中的大小来进行排序,成员对象较小的节点会排在前面。
4.3 跳跃表
仅靠多个跳跃表节点就可以组成一个跳跃表,但通过使用一个zskiplist结构来持有这些节点更方便,跳跃表结构在前边图中已给出,这里不在赘述。
5. 整数集合
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
5.1 整数集合的实现
如上边源码所示,encoding表示编码方式,length属性表示集合的元素数量,contents数组用于保存元素,它是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按从小到大有序地排列,且不重复。
5.2 类型升级
当将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,步骤如下:
1. 根据新元素类型扩展集合底层数组,并为新元素分配空间。
2.将所以现有元素转成新元素类型,并放到正确的位置,期间保持有序性不变。
3.添加新元素。
升级的好处:提升灵活性,节约内存。
注意:整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
6. 压缩列表
压缩列表是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
6.1 压缩列表的构成
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值,详细结构如下:
6.2 压缩列表节点的构成
1. previous_entry_length
记录了压缩列表中前一个节点的长度,前一节点长度小于 254字节,previous_entry_length = 1,大于等于254字节,previous_entry_length = 5 。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以可以根据当前节点的起始地址来计算出前一个节点的起始地址,这也是压缩列表从尾到头遍历的原理。
2.encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度
2.content
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
7. 对象
前边已经学习了Redis的主要数据结构,但是Redis并没有直接使用这些数据结构来实现键值对数据库,而是基
于这些数据结构创建了一个对象系统,这个系统包含 字符串对象、列表对象、哈希对象、集合对象和有序集合对象 这五种类型的对象,接下里逐一学习以上五种对象。
7.1 对象的类型与编码
每当在Redis中创建一个键值对时,至少会创建两个对象,一个键对象,一个值对象。
Redis中的每个对象都由一个redisObject结构表示,如下:
type属性记录了对象的类型 ,有:字符串对象、列表对象、哈希对象、集合对象和有序集合对象。
ptr指针指向对象的底层实现数据结构 ,而这些数据结构由对象的encoding属性决定。
encoding属性记录了对象所使用的编码 ,也即是说这个对象使用了什么数据结构作为对象的底层实现。
7.2 字符串对象
字符串对象的编码可以是int、raw或者embstr 。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。
如果这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。
embstr和raw的区别
embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
释放embstr编码的字符串对象只需要调用一次内存释放函数,而raw需要两次。
embstr编码的数据保存在一块连续的内存里。
embstr是只读的,没有任何修改命令,修改它时会先将对象的编码从embstr转换成raw
注意:浮点数在Redis中也是作为字符串值来保存的。
7.3 列表对象
列表对象的编码可以是 ziplist 或者 linkedlist 。
ziplist编码的列表对象使用 压缩列表 作为底层实现,linkedlist编码的列表对象使用 双端链表 作为底层实现。
列表对象保存的所有字符串元素的长度都小于64字节,并且元素数量小于512个,使用ziplist编码,否则使用linkedlist。
7.4 哈希对象
哈希对象的编码可以是ziplist或者hashtable 。
ziplist编码的哈希对象使用压缩列表作为底层实现,加入新键值对时,先将保存了键的节点推入压缩列表表尾,再将保存了值的压缩列表节点推入到压缩列表表尾,所以,同一键值对两个节点总是相连,键节点在前,值节点在后 。
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存。
所有键值对的键和值的字符串长度都小于64字节,且键值对数量小于512个使用ziplist编码,否则使用hashtable编码。
7.5 集合对象
集合对象的编码可以是intset或者hashtable 。
intset编码的集合对象使用 整数集合 作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
hashtable编码的集合对象使用 字典 作为底层实现,字典的每个键都是一个 字符串对象 ,每个字符串对象包含了一个集合元素,而字典的 值则全部被设置为NULL 。
集合对象保存的所有元素都是整数值,且元素数量不超过512个使用intset编码,否则使用hashtable编码。
7.6 有序集合对象
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序
skiplist编码的有序集合对象使用 zset结构 作为底层实现,一个zset结构 同时包含一个字典和一个跳跃表,zset结构中的 zsl 跳跃表 按分值从小到大 保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。
zset结构中的dict字典为有序集合创建了一个 从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。
有序集合每个元素的成员都是一个字符串对象,而每个元素的 分值都是一个double类型的浮点数。值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会 通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
有序集合保存的所有元素成员的长度都小于64字节,且元素数量小于128个,使用ziplist编码,否则使用skiplist编码。
7.7 内存回收
Redis中利用 引用计数 实现内存回收机制,通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
创建一个新对象,refcount = 1;
对象被新程序使用, refcount + 1;
对象不再被新程序使用, refcount - 1;
refcount = 0 时, 对象所占用的内存会被释放。
7.8 对象共享
对象的引用计数属性还有 对象共享 的作用
比如现在有一个键 A,保存了一个整数值 100 的对象,这时键 B 也想创建一个这样的对象,我们就可以让让键A和键B共享同一个字符串对象,这样更加节省内存,这一操作需要两步:
1)将数据库键的值指针指向一个现有的值对象;
2)将被共享的值对象的引用计数增一。
注意:Redis只对包含整数值的字符串对象进行共享,因为虽然共享对象可以节约更多的内存,但是在验证在共享对象和目标对象是否完全相同时,越复杂的对象消耗的CPU时间也会越多。
7.9 对象的空转时长
redisObject结构还有最后一个属性 lru ,该属性记录了对象最后一次被命令程序访问的时间 ,这个时间可以用于计算对象的空转时间,可以用于垃圾回收或者缓存。
到这里,Redis的第一部分:数据结构与对象就学习完了,下一章:单机数据库的实现!