Redis设计与实现--数据结构与对象(一)

简单动态字符串

简介

字符串是我们经常用到的数据结构,在c语言中,字符串是采用N+1长度的字符数组来表示长度为N的字符串,其中,字符数组末尾为’\0’,用来代表字符串的末尾。但是,redis并没有直接采用c语言的实现方式,而是自己构建了一种称为简单动态字符串(simple dynamic string, SDS)的数据结构,并将SDS作为默认的字符串表示。

SDS在redis中的定义为:

struct sdshdr { 
    // buf 中已占用空间的长度
    int len;
    // buf 中剩余可用空间的长度
    int free;
    // 字符数组,用于保存字符串
    char buf[];
};

在redis中,字符串的键和值都是SDS的对象,一个可能的SDS示例如下图所示:
在这里插入图片描述

  • free为0,表示这个SDS未使用空间为0
  • len为5,表示这个SDS保存了5字节长的字符串
  • buf即保存字符串的字符数组,数组长度为6,最后一个字节保存’\0’,和c语言的表示方式一致

与c语言的区别

redis没有采用c语言的表示方式,总归是有原因的,和c语言的实现方式相比,SDS有以下几个优点。

获取字符串长度的复杂度为常数项

c语言获取字符串的复杂度,需要对整个字符串进行遍历计数,复杂度为O(N),但是在redis中,获取字符串长度,只需要返回SDS结构体的len属性即可,复杂度从O(N)降低到O(1)。

防止缓冲区溢出

c语言在对字符串进行操作时,很容易造成字符串溢出,例如采用strcat进行字符串拼接时,内存中有两个相连的字符串s1和s2,其中s1保存了字符串"Hi",s2保存了字符串"Redis",如下图所示。
在这里插入图片描述
如果此时执行了strcat(s1, " Mysql")将s1的内容修改为“Hi Mysql”,但却忘记了为s1分配足够的空间,那么在执行完strcat之后,s1的数据将溢出到s2所在空间中,导致s2被意外修改,如下图所示。
在这里插入图片描述
和c语言不同,SDS空间分配策略完全杜绝了发生缓冲区溢出的可能。当需要对字符串进行修改时,会先检查SDS空间是否满足修改所需的要求,如果不满足的话,则会将SDS的字符数组扩展至所需大小,然后才执行修改操作,避免了我们忘记给字符串分配足够的空间,以至于发生缓冲区溢出的问题,将这一保证交给了SDS来实现。

SDS中拼接字符串的函数是sdscat,假如s初始值为“Hi”,执行sdscat(s, " Redis")之后,SDS数据结构的变化过程如下:
在这里插入图片描述
sdscat不仅进行了字符串拼接操作,还未SDS分配了8个字节的未使用空间,这和SDS的分配策略有关,具体由下一小节说明。

减小修改字符串时带来的内存重分配次数

在c语言中,每次对一个字符串进行修改,都需要进行内存重分配操作:

  • 增长字符串,比如拼接操作,需要先通过内存重分配来扩展底层数组的空间大小,否则会产生缓冲区溢出;
  • 缩短字符串,比如截断操作,需要先通过内存重分配来释放字符串不再使用的那部分空间,否则会产生内存泄漏;

然而,内存重分配操作需要执行内核调用,所以它通常是一个比较耗时的操作,如果内存重分配操作过于频繁,会对程序性能产生过大的影响,针对以上问题,SDS实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

当需要对SDS进行空间扩展时,程序不仅会为SDS分配修改所必需的要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间由以下公式决定:

  • 如果对SDS进行修改后,len值小于1MB,那么程序会分配和len属性同样大小的未使用空间,这时SDS的len和free属性的值相同。
  • 如果对SDS进行修改后,len值不小于1MB,则程序会分配1MB的未使用空间。

通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。

惰性空间释放

当需要对SDS进行空间释放时,程序并不立即使用内存重分配来回收多余的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

与此同时,SDS还提供了相应的API,让我们可以真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

二进制安全

c语言中的字符串必须符合某种编码(比如ASCII),并且除了末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得c字符串只能保存文本数据,而不嗯保存图像、音频等二进制数据。

为了确保redis可以使用于不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,不进行任何过滤、限制,数据写入时是什么样的,它被读取时就是什么样的。

用SDS来保存之前提到的特殊数据格式就没有问题,如下所示:
在这里插入图片描述

链表

简介

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度,在redis中应用很广泛,当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,redis就会使用链表作为底层实现。

链表节点结构体定义为:

typedef struct listNode {
    // 前置节点
    struct listNode *prev;
    // 后置节点
    struct listNode *next;
    // 节点的值
    void *value;
} listNode;

多个listNode可以通过prev和next指针组成双端链表,如下图所示:
在这里插入图片描述
虽然仅仅使用多个listNode结构就可以组成链表,但是使用adlist.h/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;

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

  • dup函数用于复制链表节点所保存的值
  • free函数用于释放链表节点所保存的值
  • match函数用于对比链表节点所保存的值和另一个输入值是否相等

redis中链表的组织结构如下图所示:
在这里插入图片描述

特性

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
  • 无环:表头节点的prev和表尾节点的next都是NULL,对链表的访问以NULL为重点
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表节点的复杂度为O(1)
  • 带链表长度计数器:list结构中的len属性对节点进行计数,获取链表节点数量的复杂度为O(1)
  • 多态:链表节点使用void*指针来保存节点值,并通过dup、free、match三个属性为节点值设置类型特定函数,所以链表可以保存不同类型的值。

字典

简介

字典,又称符号表、关联数组或映射,是用来保存键值对的数据结构。redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个节点,每个节点保存了一个键值对。

redis字典所使用的哈希表由dict.h/dictht结构定义:

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

其中,size记录了哈希表的大小,即table数组的大小,used记录了哈希表当前已有节点的数量,sizemask总是等于size-1,这个属性和哈希值一起决定一个键被放到table数组的哪个索引上。

table属性是一个数组,数组中的每一个元素都是指向dict.h/dictEntry结构的指针,dictEntry就是哈希表节点,其定义如下:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

dictEntry通过next节点来解决键冲突的问题,一个可能的哈希表结构如下:
在这里插入图片描述
其中,k1和k2的索引值相同。

redis中的字典由dict.h/dict结构表示:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */
} dict;

type和private是为了实现多态字典而设置的,type是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数;private保存了需要传递给函数的可选参数。

typedef struct dictType {
    // 计算哈希值的函数
    unsigned int (*hashFunction)(const void *key);
    // 复制键的函数
    void *(*keyDup)(void *privdata, const void *key);
    // 复制值的函数
    void *(*valDup)(void *privdata, const void *obj);
    // 对比键的函数
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    // 销毁键的函数
    void (*keyDestructor)(void *privdata, void *key);
    // 销毁值的函数
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

ht属性是一个包含两个dictht元素的数组,一般只用ht[0],ht[1]只会在对ht[0]进行rehash的时候使用,rehashindex记录了rehash的进度,若没有进行rehash,则其值为-1。

一个普通状态的字典结构如下:
在这里插入图片描述

redis计算哈希值和索引值的方法如下:

// 计算key的哈希值
hash = dict->type->hashFunction(key);
// 计算索引值
index = hash & dict->ht[x].sizemask;

redis使用了MurmurHash2算法计算哈希值。

扩容与收缩

rehash

哈希表初始分配的size是有限的,当哈希表中保存的元素数目过多或过少时,需要对哈希表进行相应的扩容或者收缩,这些过程统一通过rehash来完成,rehash的操作过程如下:

  1. 为ht[1]分配空间:如果执行扩容操作,则ht[1]的大小为第一个大于等于ht[0].used*2的2**n(2的n次方);如果执行收缩操作,则ht[1]的大小为第一个大于等于ht[0].used的2**n。
  2. 将ht[0]上的元素rehash到ht[1]上,即重新计算哈希值,将元素保存到ht[1]中。
  3. 当元素全部迁移完成后,释放ht[0],将ht[1]设置为ht[0],并为ht[1]新创建一个空白的哈希表。

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩容操作:

  • 服务器当前未执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
  • 服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5

负载因子的计算公式为:

hf[0].used / ht[0].size

因为服务器在执行BGSAVE或者BGREWRITEAOF命令过程中,需要创建当前进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩容操作的负载因子,从而避免在子进程存在期间进行哈希表的扩展操作,从而避免不必要的内存写入操作,最大限度节约内存。

另一方面,当负载因子小于0.01时,程序会自动执行收缩操作。

渐进式rehash

redis为了避免rehash对服务器性能造成影响,rehash过程不是一次性完成的,而是分多次、渐进式完成,渐进式rehash过程如下:

  1. 为ht[1]分配空间
  2. 设置rehashindex值为0,表示rehash开始
  3. 在rehash期间,没进行一次添加、删除、查找、更新操作,程序除了完成制定操作外,还会顺带将ht[0]哈希表在rehashindex索引上的所有键值对rehash到ht[1],当rehash完成后,将rehashindex加1
  4. 随着字典操作的进行,最终所有元素都被rehash到ht[1],这时,将rehashindex设置为-1,表示rehash操作完成

在渐进式rehash期间,若要进行查找操作,程序会先在ht[0]里面查找,没找到才会在ht[1]里面查找。同时,在此期间,新加入的元素一律会被保存到ht[1]里面,保证了ht[0]数量的只增不减。

跳跃表

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点,跳跃表是redis中有序集合的底层实现。

redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode表示跳跃表中的节点,而zskiplist用于保存条跳跃表节点的相关信息。

zskiplistNode定义如下:

typedef struct zskiplistNode {
    // 成员对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];
} zskiplistNode;

跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度越快。

每创建一个节点时,程序都会根据幂次定律(越大的数出现的概率越小)随机生成一个介于1-32之间的值作为level数组的大小。

每个层都有一个指向表尾方向的前进指针(level[i].forward),用于从表头向表尾方向访问节点。

层的跨度用来记录两个节点之间的距离(level[i].span):两个节点的跨度越大,它们的距离就越远;指向NULL的所有前进指针的跨度都为0。

跨度可以用来计算排位:在查找某个节点的过程中,将沿途访问过的所有层的跨度累加起来,就是目标节点在跳跃表中的排位。

节点的分值(score)是一个double类型的浮点数,跳跃表中的所有节点都按照分值从小到大排序;节点的成员对象(obj)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却是可以相同的,此时,节点将按照成员对象在字典序中的大小进行排序,较小的节点会排在前面。

zskiplist定义如下:

typedef struct zskiplist {
    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;

header和tail分别指向跳跃表的表头和表尾节点,通过length记录节点的数量,level则记录了跳跃表中层高最大的那个节点的层数量(不包括表头结点)。

一个可能的跳跃表结构如下:
在这里插入图片描述

参考资料

黄健宏《Redis设计与实现》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值