Redis设计与实现读书笔记(一)

2020/01/04

第2章 简单动态字符串

Redis没有使用C语言的char数组的方式来存储字符串,而是自己定义了一个简单动态字符串结构体类型SDS(simple dynamic string),

struct sdshdr {
  int len;//字符串长度
  int free;//剩余使用空间,也就是buf数组中未使用的字节数
  char buf[];//字节数组,用于保存字符串
}  

image.png

在上面例子中,使用SDS保存了字符串Redis,SDS的情况如下: ·free属性的值为0,表示这个SDS没有分配任何未使用空间。 ·len属性的值为5,表示这个SDS保存了一个五字节长的字符串。 ·buf属性是一个char类型的数组,数组的前五个字节分别保存了’R’、’e’、’d’、’i’、’s’五个字符,而最后一个字节则保存了空字符’\0’。”遵从结尾使用空字符这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。

SDS 与 C字符串的区别

1.可以使用常数级别的时间复杂度获取字符串长度 因为SDS结构体使用len变量保存长度,而C语言字符串需要遍历字符数组获取长度 2.防止缓冲区溢出 因为C语言字符串不记录自身长度,拼接时是直接将字符串拼接在字符串后面,所以后面如果有其他字符串的话,会把其他字符串的内容覆盖。如果后面是不可写的内存,则会报错。如果SDS,拼接时会判断长度,长度不够会进行扩容。 3.减少修改字符串带来的内存重分配次数 对于C语言字符串,底层是一个N+1的字符数组,是用多少,分配多少,所以字符串的拼接和截断都会导致内存重分配。

对于SDS,进行扩容时,会多分配空间以减少内存重分配的次数。 当使用长度len<1MB时,分配的总空间为len+len+1,也就是剩余空间为与使用长度相同,再加上额外1字节保存空字符 当使用长度len>1MB时,分配的总空间为len+1MB+1,也就是剩余空间为1MB,再加上额外1字节保存空字符

SDS进行字符串截断时,会延迟字符串剩余空间的释放时机 字符串进行截断后,程序并不立即进行内存重分配来回收多余的字节,而是使用free变量进行记录,将空间闲置,便于以后使用,当然也可以显式调用函数对空间进行释放

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

5.兼容部分C字符串函数 因为遵循C字符串以空字符结尾的惯例,所以可以使用部分C字符串函数

第3章 链表

因为C语言没有提供链表的实现,所以Redis自己实现了链表。 //链表的数据结构

typedef struct list {
  listNode *head;//表头指针
  listNode *tail;//表尾指针
  unsigned long len;//总节点数量
  void *(*dup) (void *ptr);//节点值复制函数,复制节点保存的值
  void (*free)(void *ptr);//节点值释放函数,释放节点保存的值
  int (*match)(void *ptr, void *key);//节点值对比函数,用与比较节点值与输入值
}list  

链表节点的数据结构

typedef struct listNode {
  struct listNode *prev;//指向前一个节点的指针
  struct listNode *next;//指向后一个节点的指针
  void *value;//节点保存的值
}listNode  

这是一个包含三个节点的链表 image.png Redis中的链表具备以下特点: 双端:每个节点保存了指向前后两个节点的指针 无环:表头节点的prev为NULL,表尾节点的next为NULL 有表头指针和表尾指针 记录了链表长度 多态:链表节点listNode使用指针void *保存节点的值,而链表list使用dup、free和match指针来根据链表中存放的对象的不同从而实现不同的方法。

第4章 字典

字典是一种用于保存键值对的抽象数据结构。因为C语言没有提供字典的实现,所以Redis使用哈希表实现了字典这种数据结构。 下图为一个空的哈希表示意图:

image.png

下图是普通状态(非rehash期间)下,保存了两个键值对的字典: image.png

哈希表节点的数据结构如下:

typedef struct dictEntry {
  //键  
  void *key;
  //值,可以是一个指针,也可以是一个uint64_t整数,也可以是int64_t的整数
  union {
    void *val;
    uint64_tu64;
    int64_ts64;
  } v;
  //指向下一个节点的指针
  struct dictEntry *next;
} dictEntry;  

Redis使用的哈希表数据结构如下:

typedef struct dictht {
  //哈希表数组,每个元素存储的是哈希表节点链表
  dictEntry **table;
  //哈希表大小,也就是table数组的大小
  unsigned long size;
  //哈希表大小掩码,总是等于size-1,用于计算哈希表节点在table中存储的位置
  usigned long sizemask;
  //当前存储的节点总数
  unsigned long used;
} dictht;  

Redis字典的数据结构如下:

typedef struct dict {
  //类型特定函数,type是一个指向dictType结构的指针,每个dictType保存了一系列用于操作特定类型键值对的函数,不同的字典会有不同的类型特定函数
  dictType *type;
  //私有数据,privatedata保存了一些需要传给类型特定函数的可选参数
  void *privatedata;
  //哈希表数组,ht是一个数组,保存了两个哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
  dictht ht[2];
  //rehash索引,当不进行rehash时,值为-1
  int rehashindex;
}  

类型特定函数的数据结构

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;  

哈希算法

当要将一个新的键值对添加到字典里面时,Redis先根据键值对的键计算出哈希值,

hash = dict->type->hashFunction(key);  

然后根据哈希值计算得到索引值

index = hash & dict->ht[x].sizemask;  

再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

解决键冲突

Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。

rehash(扩容和收缩)

为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。 扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下: 1.为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值): (1) 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂; (2) 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂; 2.将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。 3.当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

哈希表扩展和收缩的触发条件

扩展操作: 1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。 2.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。 Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。 收缩操作: 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

渐进式rehash

当键值对比较多时,如果要一次性完成rehash那么会对性能产生影响,所以可以分多次,渐进式地完成rehash操作。 哈希表渐进式rehash的详细步骤: 1)为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。 2)在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。 3)在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。 4)随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。 渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

第5章 跳跃表

跳跃表是一种有序数据结构,通过在每个节点中维持多个指向它3前面节点的指针,来达到快速访问节点的目的。Redis在两个地方用到了跳跃表,一个是在实现有序集合键时,当一个有序集合的元素数量比较多或者元素的成员是比较长的字符串时,会采用跳跃表作为有序集合键的底层实现。一个是在集群节点中用作内部数据结构。 image.png 上图是一个跳跃表,最左边的是zskiplist结构,用于保存跳跃表相关的信息,包含以下属性:

typedef struct zskiplist {
    // 表头节点和表尾节点
    structz skiplistNode *header, *tail;
    // 表中节点的数量,头结点不计算在内
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
} zskiplist;
  

位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:

typedef struct zskiplistNode {
    // 层,level是一个数组,在创建跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”
    struct zskiplistLevel {
        // 前进指针:用于访问位于表尾方向的其他节点
        struct zskiplistNode *forward;
        // 跨度:当前节点和前进指针所指向节点的距离
        unsigned int span;
    } level[];
    // 后退指针:指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
    struct zskiplistNode *backward;
    // 分值,跳跃表所有节点都是按照分值从小到大排序的,分值相同时,则按照成员对象在字典序中的大小进行排序
    double score;
    // 成员对象,每个节点所保存的成员对象都是唯一的
    robj *obj;
} zskiplistNode;  

跳跃表遍历过程:

img 1)迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点。 2)在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点。 3)在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点。 4)当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表的表尾,于是结束这次遍历。

第6章 整数集合

当一个集合只包含整数值元素时,Redis就会使用整数集合作为集合键的底层实现。 整数集合的数据结构

typedef struct intset {
  //编码方式,决定contents数组的类型,encoding的值是INTSET_ENC_INT16,那么代表contents数组的类型是int16_t类型
    uint32_t encoding;
//集合元素的个数,也就是contents数组的长度
    uint32_t length;
//contents数组会按照从小到大的顺序来存储集合元素
    int8_t contents[];
}intset;  

下图是一个包含五个int16_t类型整数值的整数集合

img

升级

当我们将一个新元素添加到整数集合里面时,如果新元素的类型比整数集合的contents数组的类型要大时,会对集合进行升级。 升级步骤: 1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。 2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变,升级后的元素在数组中存储的位置也是按照从小到大排序的。 3)将新元素添加到底层数组里面。

升级的好处

1.提升灵活性。 因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。 2.节约内存 要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。

降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

第7章 压缩列表

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,它是列表键和哈希键的底层实现之一。 列表 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。 哈希表 当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。

压缩列表构成

image.png 下图展示了一个包含三个节点的压缩列表 列表zlbytes属性的值为0x50(十进制80),表示压缩列表的总长为80字节。 列表zltail属性的值为0x3c(十进制60),这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址。 列表zllen属性的值为0x3(十进制3),表示压缩列表包含三个节点。 img

压缩表节点构成

每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成,每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种: ·长度<=63(2^6–1)字节的字节数组; ·长度<=16383(2^14–1)字节的字节数组; ·长度<=4294967295(2^32–1)字节的字节数组; 而整数值则可以是以下六种长度的其中一种: ·4位长,介于0至12之间的无符号整数; ·1字节长的有符号整数; ·3字节长的有符号整数; ·int16_t类型整数; ·int32_t类型整数; ·int64_t类型整数。 img

previous_entry_length

因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。 ·如果前一节点的长度<=254字节,那么previous_entry_length属性的长度为1字节:前一节点的长度就保存在这一个字节里面。 ·如果前一节点的长度>=254字节,那么previous_entry_length属性的长度为5字节:其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。 image.png

图7-5展示了一个包含一字节长previous_entry_length属性的压缩列表节点,属性的值为0x05,表示前一节点的长度为5字节。 图7-6展示了一个包含五字节长previous_entry_length属性的压缩节点,属性的值为0xFE00002766,其中值的最高位字节0xFE表示这是一个五字节长的previous_entry_length属性,而之后的四字节0x00002766(十进制值10086)才是前一节点的实际长度。

encoding

节点的encoding属性记录了节点的content属性所保存数据的类型以及长度: ·一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录; ·一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录; 下图中表7-2记录了所有可用的字节数组编码,而表7-3则记录了所有可用的整数编码” image.png

content

节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。 下图中展示了一个保存字节数组的节点示例: ·编码的最高两位00表示节点保存的是一个字节数组; ·编码的后六位001011记录了字节数组的长度11; ·content属性保存着节点的值”hello world”。 img 下图中展示了一个保存整数值的节点示例 image.png

连锁更新

前面说过,每个节点的previous_entry_length属性都记录了前一个节点的长度: ·如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节长的空间来保存这个长度值。 ·如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节长的空间来保存这个长度值。 现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性,换句话说,e1至eN的所有节点的previous_entry_length属性都是1字节长的。 这时,如果我们将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点,因为e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。 现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度有可能变成了介于254字节至257字节之间,这样的话,如果要让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表执行空间重分配操作。“正如扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展……为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。 Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update) 除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。” image.png

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)。 要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的: ·首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见; ·其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的; 因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。

库状态。 5.以上步骤执行完后,开始执行事件循环。

Post Directory