第一部分数据结构和对象
一、简单动态字符串
数据结构
1、简单动态字符串 SDS
①在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的。
②redis> RPUSH fruits "apple”“banana”"cherry"
键值对的键是一个字符串对象,对象的底层实现是一个保存了字符串 ” fruits” 的 SDS。
键值对的值是一个列表对象,列表对象包含了三个字符串对象,这三个字符串对象分别由三个 SDS 实现:第一个SDS保存着字符串 ” apple”,第二个SDS保存着字符串 "banana ”,第三个SDS保存着字符串 " cherry” 。
③除了用来保存数据库中的字符串值之外,sds 还被用作缓冲区(buffer ) : AOF模块中的AOF缓冲区, 以及客户端状态中的输人缓冲区,都是由SDS实现的。
④ free属性的值为0, 表示这个SDS没有分配任何未使用空间。 len属性的值为5, 表示这个SDS保存了一个五字节长的字符串。
buf属性是一个char类型的数组, 数组的前五个字节分别保存了 'R'、'e'、'd'、'i'、's'五个字符,最后添加了一个空字符'\0'
⑤保存空字符的1字节空间不计算在SDS的 len属性里面,好处是SDS可以直接重用一部分C字符串函数库里面的函数。
总结C字符串与SDS的区别:
比起C字符串, SDS具有以下优点
-
常数复杂度获取字符串长度。
-
杜绝缓冲区溢出。 如:C中的字符串相邻,而又忘记给前面的字符串分配足够的空间,则扩展的时候溢出会覆盖另外的空间的内容。而当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作
-
减少修改字符串长度时所需的内存重分配次数。 对于C字符串来说,如果进行拼接操作,内存需要重分配来扩展底层数组的空间大小,忘了缓冲区溢出
对于C字符串来说,如果进行缩短操作,内存需要重分配来释放不再使用的那部分,忘了内存泄露
SDS的空间扩展策略,减少修改字符串时带来的内存重分配次数 (SDS实现了空间预分配和惰性空间释放两种优化策略)
(1)空间预分配----用于优化字符串增长的操作 -在对SDS修改是,不仅对SDS分配修改所必须要的空间,还会增加额外未使用空间 当长度(len)小于1MB时,len长度和free长度是相同的 (buf实际长度 = len + free + 1(\0) len == free) 当长度(len)大于等于1MB是,程序会分配1MB的未使用空间 (buf实际长度 = 30MB + 1MB + 1byte(\0))
(2)惰性空间释放----用于优化SDS的字符串缩短操作 当SDS缩短SDS保存的字符串时,程序不会立即重分配来回收缩短后多出来的字节,而是使用free属性将这些数量记录起来,并等待将来的使用。与此同时,SDS也提供了相应的API,在需要时,可以释放未使用空间。
- 二进制安全。 C语言字符串除了末尾是空字符外,其他地方不能是空字符,否则出错。 将导致C字符串只能保存文本数据,不能保存成图片,视频,压缩文件等二进制数据。
SDS的buf属性成为字节数组的原因是: Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
SDS使用len属性的值而不是空字符来判断字符串是否结束!
- 兼容部分C字符串函数。
SDS简单动态字符串,比起C字符串的优势:
-
获取字符串长度,时间复杂度为O(1)
-
增加、减少 字符串长度时,操作步骤小于等于C字符串
-
API安全,防止了缓冲区溢出
-
内容上,可以保存二进制数据,因为C字符串遇到空字符就会认为到末端了,SDS不会这样认为,会依据len属性判断是否到末端
-
由于最后按照C字符串的格式,存储了一个空字符,所以可以使用部分现成的C函数。
二、链表
介绍
-
C言并没有内置链表数据结构,所以Redis构建了自己的链表实现
-
链表结构,除了用于链表键之外, 发布与订阅、 慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息, 以及使用链表来构建客户端输出缓冲区
-
链表节点结构图
- list结构为链表提供了表头指针head、表尾指针tail, 以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数。
- 例如下面展示的是一个list结构和三个listNode 归结构组成的链表
链表结构优势:
-
双端,查看当前节点的下一个节点、上一个节点,时间复杂度是O(1)
-
无环,头节点和尾节点的指针都指向null
-
带表头指针和表尾指针,可直接获取头节点和尾节点,时间复杂度为O(1)
-
len属性可以直接获取链表长度,时间复杂度为O(1)
5.多态:通过为链表设置不同的类型特定函数 如dup/free/match,Redis的链表可以用于保存各种不同类型的值。如:listSetDupMethod 将给定的函数设置为链表的节点值复制函数
dup函数用于复制链表节点所保存的值
free函数用于释放链表节点所保存的值
match用于对比链表节点所保存的值和另一个输入值是否相等
三、字典
字典的定义
字典(dictionary),又名映射(map)或关联数组(associative array),是一种抽象数据结构,由一集键值对(key-value pairs)组成,各个键值对的键各不相同,程序可以添加新的键值对到字典中,或者基于键进行查找、更新或删除等操作。
Redis 选择了高效、实现简单的哈希表,作为字典的底层实现。
Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查操作也是构建在对字典的操作之上的
字典的定义:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表(2个)
dictht ht[2];
// 记录rehsh进度的标志,当rehash未进行时,值为-1
int rehashidx;
} dict;
哈希表的定义:
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组大小
unsigned long size;
// 指针数组大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表节点的定义:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向后继节点
struct dictEntry *next;
} dictEntry;
字典的用途
字典的主要用途有以下两个:
-
实现数据库键空间(key space);
-
用作 Hash 类型键的底层实现之一。
Redis 是一个键值对数据库,数据库中的键值对由字典保存:每个数据库都有一个对应的字典,这个字典被称之为键空间(key space)。
哈希算法
使用dict->type->hashFunction(key)计算哈希值
使用哈希表的sizemask属性和哈希值,计算出索引值
使用的是Murmurhash2哈希算法,算法能给出很好的随机性分布,并且计算速度也快
字典的插入
字典虽然创建了两个哈希表,但正在使用的只有 0 号哈希表。
ht[0]->table 的空间分配将在第一次往字典添加键值对时进行;
ht[1]->table 的空间分配将在 rehash 开始时进行。
给定的键值对添加到字典流程
如果字典为未初始化(即字典的 0 号哈希表的 table 属性为空),则程序需要对 0 号哈希表进行初始化;
如果在插入时发生了键碰撞,则程序需要处理碰撞;
如果插入新元素,使得字典满足了 rehash 条件,则需要启动相应的 rehash 程序;
当程序处理完以上三种情况之后,新的键值对才会被真正地添加到字典上;
此外 dictht 使用链地址法(又称拉链法)来处理键碰撞:当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来,因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,添加到链表的表头位置
字典的扩展
- rehash时机 指针数组大小(size 属性)与保存节点数量(used 属性)之间的比率ratio = used / size 满足以下任何一个条件的话,rehash 过程就会被激活:
自然 rehash : 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子ratio >= 1
强制 rehash : 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子ratio >= 5
负载因子 = 哈希表已保存的节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
BGSAVE表示后台异步保存数据到磁盘
根据BGSAVE或BGREWRITEAOF命令是否正在进行,服务器执行拓展操作所需的负载因子并不相同。
在执行BGSAVE或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,大多数操作系统都采用写时复制技术优化子进程的使用效率。
故在子进程存在期间,服务器会提高执行扩展操作所需要的负载因子,从而尽可能避免在子进程存在期间进行哈希表扩展操作。
这样可以避免不必要的内存写入操作,最大限度地节约内存。
写时复制:
在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考
虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。
- rehash流程 通过rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash步骤:
-
为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(即ht[0].used的值)
-
拓展:ht[1]大小为第一个大于等于ht[0].used*2的2的n次方幂。
-
收缩:ht[1]大小为第一个大于等于ht[0].used的2的n次方幂。
-
-
将保存在ht[0]的键值对rehash到ht[1]上面。rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]表的指定位置上。
-
当ht[0]上的所有键值对转移到ht[1]表上(ht[0]为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]上新建一个空白哈希表,为下一次rehash做准备
在 rehash 的最后阶段,程序会执行以下工作:
-
释放 ht[0] 的空间;
-
用 ht[1] 来代替 ht[0],使原来的 ht[1] 成为新的 ht[0];
-
创建一个新的空哈希表,并将它设置为 ht[1];
-
将字典的 rehashidx 属性设置为 -1,标识 rehash 已停止。
- 渐进式rehash rehash 程序并不是在激活之后,就马上执行直到完成的,而是分多次、渐进式地完成的。因为要求服务器必须阻塞直到 rehash 完成,这对于 Redis 服务器本身是不能接受的。
渐进式 rehash 主要由 _dictRehashStep 和 dictRehashMiliseconds 两个函数进行:
_dictRehashStep 用于对数据库字典、以及哈希键的字典进行被动 rehash。
-
每次执行 _dictRehashStep,ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table。
-
在 rehash 开始进行之后(d->rehashidx不为 -1),每次执行一次添加、查找、删除操作,_dictRehashStep都会被执行一次;
dictRehashMiliseconds 则由 Redis 服务器常规任务程序(自认为不一定是增删改查)(server cron job)执行,用于对数据库字典进行主动 rehash。
-
dictRehashMiliseconds 可以在指定的毫秒数内,对字典进行 rehash。
-
当 Redis 的服务器常规任务执行时。dictRehashMiliseconds 会被执行,在规定的时间内,尽可能地对数据库字典中那些需要 rehash 的字典进行 rehash ,从而加速数据库字典的 rehash 进程。
- rehash的额外措施 在哈希表进行 rehash 时,字典还会采取一些特别的措施,确保 rehash 顺利、正确地进行:
-
因为在 rehash 时,字典会同时使用两个哈希表,所以在这期间的所有查找、删除等操作,除了在 ht[0] 上进行,还需要在 ht[1] 上进行;
-
在执行添加操作时,新的节点会直接添加到 ht[1] 而不是 ht[0],这样保证 ht[0] 的节点数量在整个 rehash 过程中都只减不增。
- 字典的收缩 如果哈希表的负载因子小于0.1,那么也可以通过对哈希表进行 rehash 来收缩(shrink)字典。收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,字典收缩和字典扩展的区别是:
-
字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);
-
字典的收缩操作则是由程序手动执行。
重点回顾
-
字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
-
Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,另一个仅在rehash时使用。
-
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用Murmur Hash2算法来计算键的哈希值。
-
在对哈希表进行扩展或者收缩时,程序将现有哈希表包含的所有键值对rehash到新哈希表里,并且这个rehash不是一次性完成的,而是渐进式地完成的。
四、跳跃表
跳跃表的定义
跳跃表(skiplist)是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。
跳跃表主要由以下部分构成:
-
表头(head):负责维护跳跃表的节点指针;
-
跳跃表节点:保存着元素值,以及多个层;
-
层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访 问,然后随着元素值范围的缩小,慢慢降低层次;
-
表尾:全部由 NULL组成,表示跳跃表的末尾。
跳跃表的定义
typedef struct zskiplist{
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数,表头节点的层数不计算在内
int level;
} zskiplist;
1
2
3
4
5
6
7
8
跳跃表节点的定义
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值,按照从小到大排序
double score;
// 成员对象
robj *obj; //是一个指针,指向一个字符串对象,而字符串对象则保存着一个SDS值
// 层,每个层所带有的属性
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
属性解析
层:每次创建一个新跳跃节点的时候,程序依据幂次定律(越大的数出现的概率越小)随机生成1-32之间的值作为level[]数组的大小
跨度:和遍历操作无关,是用来计算排位的,查找节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
后退节点:每次只能后退到前一个节点
分值和成员:分值是一个double类型的浮点数,按照从小到大排序 成员对象obj属性是一个指针,指向一个字符串对象,字符串对象则保存着一个SDS值,如果分数相同,按照字典序排
zskiplist有指向表头节点和表尾节点
redis中的跳跃表
为了满足自身的功能需要,Redis 基于原始的跳跃表进行了以下修改:
-
允许重复的 score 值:多个不同的 member 的 score 值可以相同;
-
进行对比操作时,不仅要检查 score值,还要检查 member:当 score 值可以重复时,单靠 score 值无法判断一个元素的身份,所以需要连 member 域都一并检查才行;分数相同的节点按照成员对象在字典序中的大小来进行排序,
-
每个节点都带有一个高度为 1 层的后退指针,用于从表尾方向向表头方向迭代:当执行 ZREVRANGE 或 ZREVRANGEBYSCORE 这类以逆序处理有序集的命令时,就会用到这个属性。
总结:
-
跳跃表是有序集合的底层实现之一。
-
Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。
-
每个跳跃表节点的层高都是1至32之间的随机数。
-
在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
-
跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。
-
和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途