《Redis设计与实现》读书笔记-Redis基本数据类型底层实现原理(SDS, 渐进式rehash,zset跳跃表)

简单动态字符串

Redis没有直接是使用C语言传统的字符串表示,而是自己构建了一种简单动态字符串(simple dynamic string SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。

SDS的定义

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

在这里插入图片描述

SDS与C语言字符串的区别

常数时间复杂度获取字符串的长度

SDS的len属性保存了字符串的长度,而C不保存数组的长度,每一次都需要遍历一遍整个数组。

杜绝缓冲区溢出
  • 假设程序里有两个内存中紧邻的C字符串s1和s2,其中s1保存了字符串"redis",而s2保存了字符串"MongoDB",如果执行strcat(“redis”, “cluster”),但他却忘了在执行strcat之前为s1分配足够的空间,那么在strcat的函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外的修改。
  • SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改的时候,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至修改所需的大小,然后才执行实际的修改操作。
减少修改字符串时带来的内存重分配次数

C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N + 1个字符长的数组。所以每次增长或缩短一个C字符串,都要对这个C字符串的数组进行一次内存重分配的操作
因为内存重分配设计复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较好使的操作:

  • 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次的内存重分配是可以接受的
  • 但是Redis作为数据库,经常被用于速度要求严苛,数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,将会耗费大部分性能。

SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。

  1. 空间预分配
    空间预分配用于优化SDS的字符串增长的操作:当SDS的API对一个SDS进行修改的时候,并且需要对SDS进行空间扩展的时候,程序不仅仅会为SDS分配修改所必须的空间,而且还会为SDS分配额外未使用的空间。
  2. 惰性空间释放
    惰性空间释放用于优化SDS的字符串缩短的操作,当SDS的API需要缩短SDS保存的字符串的时候,程序并不是立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
二进制安全

通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据

兼容部分C字符串函数

总结

C字符串SDS
获取字符串长度的复杂度为O(N)len属性:获取字符串长度的复杂度为O(1)
会造成缓冲区溢出不会造成缓冲区溢出
修改字符串长度N次必然需要N次内存重分配修改字符串长度N次最多需要N次内存重分配
只能保存文本数据可以保存文本或者二进制数据
可以使用所有<string.h>中的函数只能使用部分<string.h>中的函数

链表

Redis的list底层实现就是链表,当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串的时候,Redis就会使用链表作为列表建的底层实现

  1. 双端:链表节点带有prev 和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1);
  2. 无环:表头结点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为中终点
  3. 带表头指针和表尾指针:通过list结构的head指针和tail指针。
  4. 带链表长度的计数器:程序使用list结构的len属性来对list持有的链表节点进行计数。
  5. 多态:通过为链表设置不同的类型特定的函数,可以用于保存各种不同类型的值。
  6. 链表被广泛用于实现Redis的各种功能,比如列表建、发布与订阅、慢查询、监视器等。

Hash

HashMap<String, HashMap<String,value>>,两个hashMap一个正常存放数据,一个备用

Redis的字典使用哈希表(类似于Java的HashMap)作为实现,一个hash表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

哈希表

typedef struct dictht{
dictEntry **table  //哈希表数组
unsigned long size //哈希表大小
unsigned long sizemark; //哈希表掩码大小,用于计算索引值,总是等于size - 1
unsigned long used;//该哈希表已有的节点的数量
}

在这里插入图片描述

哈希表节点

typedef struct dictEntry {
void *key; //键
union {
	void *val;
	uint_tu64;
	int64_ts64;
} v; //值
struct dictEntry *next; //指向下一个哈希表节点,行成链表。
}

在这里插入图片描述

字典

typedef struct dict {
	dictType *type; //类型特定函数
	void *privdata; //私有数据
	dictht ht[2]; //2个哈希表
	in rehashidx;//rehash索引,当rehash不在进行时在,值为-1;

}

ht属性是一个包含两个项的数组,数组中的每个想都是一个dictht哈希表,一般情况下,字典只是用ht[0]哈希表,ht[1]哈希表只会对ht[0]哈希表进行rehash时使用
在这里插入图片描述

哈希冲突

Redis的哈希表使用的是链地址法解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,Redis采用的是头插法

哈希表的扩容与收缩

当满足以下两个条件中的任意一个条件的时候,程序会自动开始对哈希表进行扩展操作。

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令时,并且哈希表的负载因子大于等于1.
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5

其中哈希表的负载因为可以通过公式

负载因子 = 哈希表已经保存的节点数量 / 哈希表大小

根据BGSAVE或者BGREWRITEAOF命令是否正在进行,服务器执行扩展所需的负载因子并不相同,这是因为在执行这些命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy - on - write)计数来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需要的负载因子,从而尽可能的避免在子进程存在期间对哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度节约内存。

当哈希表的负载因子小于0.1的时候,程序自动开始对哈希表进行收缩操作。

渐进式rehash

扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是这个rehash动作不是一次性集中完成的,而是分多次,渐近示完成的。(如果哈希表里面存储的键值对很多,那么全部rehash到1中的话,庞大的计算量可能会导致服务器在一段时间内停止服务
rehash详细步骤

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作的时候,程序除了指定的操作意外,还会顺带将ht[0]哈希表rehashidx索引上的所有键值对rehash到ht[1],让rehash工作完成之后,程序将rehashidx属性的值增加1
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],这是程序将rehashidx属性的值设为-1,表示rehash操作已经完成。

渐近式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的工作量均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash所带来的庞大的计算量。

渐进式rehash执行期间的哈希表操作

  1. 因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除查找操作会在两个哈希表上进行,例如,要在字典里面查找一个键的话,程序会现在ht[0]里面进行查找,如果没有找到的话,就会继续到ht[1]里面进行查找。
  2. 渐进式rehash期间,新添加到字典的键值对一律会保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量之间不增,并随着rehash的操作最终变成空表。

Set

Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。

zset

详细请看:
Redis(2)——跳跃表

跳跃表简介

跳跃表是一种可以与平衡树媲美的层次化链表结构,查找、删除、添加等操作都可以在对数期望时间下完成。

zset类似于是SortedSet 和HashMap的结合体,一方面它是一个set保证了内部value的唯一性。另一方面又可以给每个value赋予一个排序的权重值score,达到排序的步骤。

为什么用跳表而不用红黑树?

首先,因为zset要支持随机的插入和删除,所以它不宜使用数组来实现,关于排序的问题,也可以使用 红黑树/平衡树 这样的树形结构,为什么redis不适用这样的树结构呢?

  1. 性能考虑: redis 普遍适用于高性能的场景,树形结构需要执行一些类似于rebalance这样的可能涉及整棵树的操作(为了维持树的平衡,需要进行调整 LR RR RL LL),相对来说跳跃表的变化只涉及局部。
  2. 实现考虑: 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观。
本质上是为了解决链表的查找问题

对链表按照score进行排序,为了在链表上实现二分查找法,可以给相邻两个节点之间增加一个指针,这样所有的新增的指针连成了一个新的链表,但是它包含的数据只是原来的一半。

通过新增指针的查找,不再需要与链表上的每一个节点逐一的比较,改进之后需要比较的节点数大概只有原来的一半。
在这里插入图片描述
例如 查找13, 那么沿着最上层链表首先比较11,发现11比13小,于是我们就可以跳过11前面的所有的节点,往11后面继续查找

更进一步的跳跃表

跳跃表skipList就是类似这种多层链表,按照生成链表的方式,上一层链表的节点的个数,是下一层的节点的个数的一半,这样的查找过程就非常类似于一个二分查找,使得查找的时间复杂度降低到O(logn)

但是这种方法在插入数据的时候会有很大的问题,新插入一个节点之后,就会打乱上下相邻两个节点之间个数的2 :1的对应关系,如果要维持这种对应的关系,就必须把新插入的节点和后面的所有节点(也包括新插入的节点)重新调整。会让时间复杂度重新退化成O(n).删除数据也有同样的问题。

skipList为了避免这一问题,不要求上下相邻两层链表之间的节点的个数有严格的对应关系,而是为每个节点随机生成一个层数。 这样 新插入一个节点并不会影响到其他节点的层数,因此,插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整。

redis跳跃表的实现

/* ZSETs use a specialized version of Skiplists */
typedefstruct zskiplistNode {
    // value
    sds ele;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsignedlong span;
    } level[];
} zskiplistNode;

typedefstruct zskiplist {
    // 跳跃表头指针
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsignedlong length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;
  • score : 分值
  • sds ele : value
  • zskiplistNode *backward 后退指针
  • struct zskiplistLevel 层
随机层数

对于每一个新插入的节点,都需要调用一个随机的算法给它分配一个合理的层数

直观上期望的目标是50 % 的概率被分到Level1,25 % 的概率被分配到level2,12.5%的概率被分配到 Level3 以此类推 有 2 …… -63 的概率被分配到最顶层,因为这里每一层的晋升率都是50%

Redis跳跃表允许的最大的层数是32

往跳跃表插入节点

主要完成两件事

  1. 找到当前节点需要的插入的位置
  2. 创建新节点,调整前后的指针指向,完成插入。
  1. 声名需要存储的变量
  2. 搜索当前节点的插入的位置
    有一种极端的情况,就是跳跃表中的所有score值都是一样,zset的查找性能会不会退化成O(n)?
    zset排序元素不只是看score的值,也会比较value的值(字符串比较)
  3. 生成插入节点
  4. 重排前向指针
  5. 重拍后向指针并返回
元素排名的实现

跳跃表本身是有序的,Redis在skipList的forward指针上进行了优化,给每一个forward指针都增加了span属性,用来表示从前一个节点沿着当前层的forward指针跳到当前这个节点中间会跳过多少个节点
在Redis插入、删除操作都会小心翼翼的更新span的值大小

所以沿着搜索路径,把所有经过节点的跨度span值进行累加就可以算出当前元素的最终rank值

参考:

  1. 《Redis设计与实现》
  2. Redis(2)——跳跃表
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值