Redis设计与实现-数据结构

177 阅读16分钟

第一部分数据结构和对象

一、简单动态字符串

数据结构

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字符串函数库里面的函数。

image.png

总结C字符串与SDS的区别:

比起C字符串, SDS具有以下优点

  1. 常数复杂度获取字符串长度。

  2. 杜绝缓冲区溢出。 如:C中的字符串相邻,而又忘记给前面的字符串分配足够的空间,则扩展的时候溢出会覆盖另外的空间的内容。而当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作

  3. 减少修改字符串长度时所需的内存重分配次数。 对于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,在需要时,可以释放未使用空间。

  1. 二进制安全。 C语言字符串除了末尾是空字符外,其他地方不能是空字符,否则出错。 将导致C字符串只能保存文本数据,不能保存成图片,视频,压缩文件等二进制数据。

SDS的buf属性成为字节数组的原因是: Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

SDS使用len属性的值而不是空字符来判断字符串是否结束!

  1. 兼容部分C字符串函数。

SDS简单动态字符串,比起C字符串的优势:

  1. 获取字符串长度,时间复杂度为O(1)

  2. 增加、减少 字符串长度时,操作步骤小于等于C字符串

  3. API安全,防止了缓冲区溢出

  4. 内容上,可以保存二进制数据,因为C字符串遇到空字符就会认为到末端了,SDS不会这样认为,会依据len属性判断是否到末端

  5. 由于最后按照C字符串的格式,存储了一个空字符,所以可以使用部分现成的C函数。

image.png

二、链表

介绍

  1. C言并没有内置链表数据结构,所以Redis构建了自己的链表实现

  2. 链表结构,除了用于链表键之外, 发布与订阅、 慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息, 以及使用链表来构建客户端输出缓冲区

  3. 链表节点结构图

image.png

  1. list结构为链表提供了表头指针head、表尾指针tail, 以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数。

image.png

  1. 例如下面展示的是一个list结构和三个listNode 归结构组成的链表

image.png

链表结构优势:

  1. 双端,查看当前节点的下一个节点、上一个节点,时间复杂度是O(1)

  2. 无环,头节点和尾节点的指针都指向null

  3. 带表头指针和表尾指针,可直接获取头节点和尾节点,时间复杂度为O(1)

  4. 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节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,添加到链表的表头位置

字典的扩展

  1. 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中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

写时复制

  1. 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 已停止。

  1. 渐进式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 进程。

  1. rehash的额外措施 在哈希表进行 rehash 时,字典还会采取一些特别的措施,确保 rehash 顺利、正确地进行:
  • 因为在 rehash 时,字典会同时使用两个哈希表,所以在这期间的所有查找、删除等操作,除了在 ht[0] 上进行,还需要在 ht[1] 上进行;

  • 在执行添加操作时,新的节点会直接添加到 ht[1] 而不是 ht[0],这样保证 ht[0] 的节点数量在整个 rehash 过程中都只减不增。

  1. 字典的收缩 如果哈希表的负载因子小于0.1,那么也可以通过对哈希表进行 rehash 来收缩(shrink)字典。收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,字典收缩和字典扩展的区别是:
  • 字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);

  • 字典的收缩操作则是由程序手动执行。

重点回顾

  1. 字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。

  2. Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,另一个仅在rehash时使用。

  3. 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用Murmur Hash2算法来计算键的哈希值。

  4. 在对哈希表进行扩展或者收缩时,程序将现有哈希表包含的所有键值对rehash到新哈希表里,并且这个rehash不是一次性完成的,而是渐进式地完成的。

四、跳跃表

跳跃表的定义

跳跃表(skiplist)是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。

image.png

跳跃表主要由以下部分构成:

  • 表头(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中的跳跃表

image.png 为了满足自身的功能需要,Redis 基于原始的跳跃表进行了以下修改:

  • 允许重复的 score 值:多个不同的 member 的 score 值可以相同;

  • 进行对比操作时,不仅要检查 score值,还要检查 member:当 score 值可以重复时,单靠 score 值无法判断一个元素的身份,所以需要连 member 域都一并检查才行;分数相同的节点按照成员对象在字典序中的大小来进行排序,

  • 每个节点都带有一个高度为 1 层的后退指针,用于从表尾方向向表头方向迭代:当执行 ZREVRANGE 或 ZREVRANGEBYSCORE 这类以逆序处理有序集的命令时,就会用到这个属性。

总结:

  • 跳跃表是有序集合的底层实现之一。

  • Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。

  • 每个跳跃表节点的层高都是1至32之间的随机数。

  • 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。

  • 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。

  • 和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途