Redis设计与实现——第一部分 数据结构与对象

笑着哭i 提交于 2020-02-03 22:46:28

第一部分 数据结构与对象

第2章 简单动态字符串

Redis自己创建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。在Redis里面,C字符串只会作为字符串字面量用在一些无须对字符串值进行修改的地方,比如打印日志什么。但是如果用在一个字符串变量,Redis就会使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS来实现的。

127.0.0.1:6379> set k1 hello
OK
127.0.0.1:6379> get k1
"hello"

那么Redis将在数据库中创建一个新的键值对,其中键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串"k1"的SDS。键值对的值也是一个字符串对象,对象的底层实现是一个保持着字符串"hello"的SDS。

如果客户端执行命令

127.0.0.1:6379> RPUSH fruits apple banana cherry
(integer) 3

那么Redis将在数据库中创建一个新的键值对,其中键值对的键是一个字符串对象,对象底层实现是一个保存了字符串"fruits"的SDS。键值对的值是一个列表对象,列表对象包含三个字符串对象,这三个字符串对象分别由三个SDS实现。

除了用来保存数据库中的字符串值外,SDS还被用作缓冲区(buffer):AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。

2.1 SDS的定义

在sds.h中定义了sdshdr结构

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

在这五个结构体中,len表示字符串的长度,alloc表示buf指针分配空间的大小(不包括头和空终止符),flags表示该字符串的类型(sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64),是由flags的左侧三位表示的。

2.2 SDS与C字符串的区别

根据传统,C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符’\0’。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GM5zFoga-1580714111331)(C:\Users\李志超\AppData\Roaming\Typora\typora-user-images\1579958361150.png)]

2.2.1 常数复杂度获取字符串长度

因为C字符并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的时间复杂度为O(N)。但SDS与C语言中的字符串不同,因为SDS中的len属性记录SDS本身的长度,所以获取一个SDS长度的复杂度为O(1),通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。

2.2.2 杜绝缓冲区溢出

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。比如C语言中<string.h>/strcat操作可以将src字符串中的内容拼接到dest字符串的末尾。

char* strcat(char* dest, const char *src)

如果用户在执行这个函数的时候为dest分配了足够多的内存,则可以容纳src字符串中的所有内容,而一旦这个假设不成立,就会产生缓冲区溢出。

而与C字符串不同,当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需要的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作。

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

正如前面提到了,因为C字符并不记录自身的长度,所以对一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作。

比如我们持有一个值为"Redis"的C字符串s,那么为了将s的值改为"Redis Cluster",在执行:strcat(s," Cluster");之前,我们需要使用内存重分配操作,扩展s的空间。而因为内存重分配设计复杂的算法,并且可能需要执行系统调用,所以它是一个比较耗时间的操作,而且Redis作为数据库,平凡的修改会对性能造成影响,所以为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度包括未使用的字节。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。

**空间预分配:**空间预分配用于优化SDS的字符串增长操作,当SDS的API对一个SDS进行修改,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:

如果对SDS进行修改之后,SDS的长度(也就是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,比如如果进行修改之后SDS的len将要变成13字节,那么程序也会分配13字节的未使用空间。

如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。

通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。

**惰性空间释放:**惰性空间释放用于优化SDS的字符串缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序并不是立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。sdstrim函数接受一个SDS和一个C字符串作为参数,移除SDS中所有在C字符串中出现过的字符。

2.2.4 二进制安全

C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
在这里插入图片描述

C字符串所用的函数只会识别其中的"Redis",而忽略之后的"Cluster"。为了确保Redis可以适用各种不同的使用场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组中的数据,这也是我们将SDS中的buf属性称为字节数组的原因,Redis用这个数组来保存一系列二进制数据。所以使用SDS来保存之前提到的特殊格式就没有任何问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束。

2.2.5 兼容部分C字符串函数

虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数,从而避免了不必要的代码重复。

2.3 SDS API

下面列出SDS的主要操作API。

函数 作用 时间复杂度
sds sdsnew(const char *init); 创建一个包含给定C字符串的SDS O(N),N是给定C字符串的长度
sds sdsempty(void); 创建一个不包含任何内容的空SDS O(1)
void sdsfree(sds s); 释放给定的SDS O(N),N为释放SDS的长度
size_t sdslen(const sds s); 返回SDS的已经使用字节数 O(1)
size_t sdsavail(const sds s) 返回SDS的未使用空间字节数 O(1)
sds sdsdup(const sds s); 创建一个给定SDS的副本 O(N)
void sdsclear(sds s); 清空SDS保存的字符串内容 因为惰性空间释放策略,复杂度为O(1)
sds sdscat(sds s, const char *t); 将给定C字符串拼接到SDS字符串的末尾 O(N),N为被拼接C字符串的长度
sds sdscatsds(sds s, const sds t); 将给定SDS字符串拼接到另一个SDS字符串的末尾 O(N),N为被拼接SDS字符串的长度
sds sdscpy(sds s, const char *t); 将给定的C字符串复制到SDS里面,覆盖SDS原来的字符串 O(N)
sds sdsgrowzero(sds s, size_t len); 用空字符将SDS扩展至给定长度 O(N)
void sdsrange(sds s, ssize_t start, ssize_t end); 保留SDS给定区间内的数据,不在区间内的数据将被覆盖或清除 O(N)
sds sdstrim(sds s, const char *cset); 接受一个SDS和一个C字符串作为参数,从SDS中移除所有在C字符串中出现过的字符 O(N2)
int sdscmp(const sds s1, const sds s2); 对比两个SDS字符串是否相等 O(N)

第3章 链表

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

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

3.1 链表和链表节点的实现

每个链表节点使用一个adlist.h/listNode结构表示:

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

我们可以看到listNode是用一个双端链表实现的。多个listNode可以通过prev和next指针组成双端链表,如图所示
在这里插入图片描述
此外,Redis中使用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;

在这里插入图片描述
Redis的链表实现可以总结如下:

  • 双端:链表节点带有prev和next指针
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL
  • 带表头指针和表尾指针
  • 带链表长度计数器
  • 多态,链表节点使用void*指针来保存节点值,所以链表可以用于保存各种不同类型的值

3.2 链表和链表节点的API

函数 作用 时间复杂度
listSetDupMethod(l,m) ((l)->dup = (m)) 将给定的函数设置成链表的节点值复制函数 直接调用链表的dup属性,O(1)
listGetDupMethod(l) ((l)->dup) 返回链表当前正在使用的节点复制函数 O(1)
listSetFreeMethod(l,m) ((l)->free = (m)) 设置链表的节点值释放函数 O(1)
listGetFree(l) ((l)->free) 返回链表当前正在使用的节点值释放函数 O(1)
listSetMatchMethod(l,m) ((l)->match = (m)) 设置链表的节点值对比函数 O(1)
listGetMatchMethod(l) ((l)->match) 返回链表使用的节点值对比函数 O(1)
list
listLength(l) ((l)->len) 长度 O(1)
listFirst(l) ((l)->head) 链表的表头节点 O(1)
listLast(l) ((l)->tail) 链表的表尾节点 O(1)
listPrevNode(n) ((n)->prev) 返回给定节点的前置节点 O(1)
listNode *listSearchKey(list *list, void *key); 查找并返回链表中给定值的节点 O(N)
listNode *listIndex(list *list, long index); 返回链表在给定索引上的节点 O(N)
void listRotate(list *list); 将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头,成为新的表头节点 O(1)
list *listDup(list *orig); 复制一个给定链表的副本 O(1)
void listRelease(list *list); 释放给定链表,以及链表中的所有节点 O(N)

第4章 字典

字典,又称符号表、关联数组或映射,是一种保存键值对的抽象数据结构。在字典中,一个键可以和一个值进行关联;字典中每个键都是独一无二的。

字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对于数据库的增、删、查、改操作也是构建对字典的操作之上的。除了表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。

4.1 字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

4.1.1 哈希表

typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,值总是等于size-1。这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面
    unsigned long sizemask;
    //哈希表已有节点的数量
    unsigned long used;
} dictht;

在这里插入图片描述

4.1.2 哈希表节点

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

在这里插入图片描述

4.1.3 字典

typedef struct dict {
    dictType *type;	//类型特定函数
    void *privdata;	//私有数据
    dictht ht[2];	//哈希表
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

type属性和private属性是针对不同类型的键值对,为创建多态字典而设置的:

  • type类型是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • 而private属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {
    uint64_t (*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时使用。除了ht[1]之外,另外一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有进行rehash,那么它的值为-1。
在这里插入图片描述

4.2 哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis计算哈希值和索引值的方法如下:

#使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);

#使用哈希表的sizemask属性和哈希值,计算出索引值
#根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

4.3 解决键冲突

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!