阿里二面:Redis身为单线程,它是怎么做到这么快的?

面试时候的常见问题,可以从 Redis 不同数据类型底层的数据结构实现、完全基于内存、IO 多路复用网络模型、线程模型、渐进式 rehash……等等方面回答

1. 基于内存实现

Redis 是基于内存的数据库,跟磁盘数据库相比,完全吊打磁盘的速度。内存直接由 CPU 控制,也就是 CPU 内部集成的内存控制器,所以说内存是直接与 CPU 对接,享受与 CPU 通信的最优带宽。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第1张图片

2. 高效的数据结构

Redis 一共有 5 种数据类型,String、List、Hash、Set、SortedSet。不同的数据类型底层使用了一种或者多种数据结构来支撑,目的就是为了追求更快的速度。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第2张图片

1)SDS简单动态字符串

SDS(simple dynamic string)是redis中String采用的底层数据结构,是一种可以修改的字符串。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第3张图片

SDS 中 len 保存这字符串的长度,O(1) 时间复杂度查询字符串长度信息,传统的C字符串遍历字符串的长度,遇零则止,复杂度为O(n)。
空间预分配:SDS 被修改后,程序不仅会为 SDS 分配所需要的必须空间,还会分配额外的未使用空间。

惰性空间释放:当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配。

二进制安全: SDS本质上就是char *,因为有了表头sdshdr结构的存在,它可以存储任意二进制数据,不像C语言字符串那样以‘\0’来标识字符串结束(遇到’\0’,就认为到达末尾,忽略’\0’以后的所有字符,因此,如果传统字符串保存图片、视频等二进制文件,操作文件时会被被截断)。而SDS表头的buf被定义为字节数组,判断是否到达字符串结尾的依据则是表头的len成员,这意味着它可以存放任何二进制的数据和文本数据,包括’\0’。

补充:

所有的 Redis 对象都有一个 Redis 对象头结构体

struct RedisObject {

    int4 type; // 4bits  类型

    int4 encoding; // 4bits 存储格式

    int24 lru; // 24bits 记录LRU信息

    int32 refcount; // 4bytes

    void *ptr; // 8bytes,64-bit system

} robj;

不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式 encoding(4bit)。

为了记录对象的 LRU 信息,使用了 24 个 bit 的 lru 来记录 LRU 信息。

每个对象都有个引用计数 refcount,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对象内容 (body) 的具体存储位置。

一个 RedisObject 对象头共需要占据 16 字节的存储空间。

Redis 的字符串共有两种存储方式,在长度特别短时,使用 emb 形式存储 (embedded),当长度超过 44 时,使用 raw 形式存储。

embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。而 raw 存储形式不一样,它需要两次 malloc,两个对象头在内存地址上一般是不连续的。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第4张图片

在字符串比较小时,SDS 对象头的大小是capacity+3——SDS结构体的内存大小至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。

如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。而64-19-结尾的\0,所以empstr只能容纳44字节。

2)intset 整数集合

Intset是集合键的底层实现之一,如果一个集合满足只保存整数元素元素数量不多这两个条件,那么 Redis 就会采用 intset 来保存这个数据集。
intset元素类型只能为数字,有三种类型:int16_t、int32_t、int64_t。
元素有序,不可重复。
intset和sds一样,内存连续,就像数组一样,其数据结构如下:

typedef struct intset {
    uint32_t encoding; // 编码模式
    uint32_t length;  // 长度
    int8_t contents[];  // 数据部分
} intset;

其中,encoding 字段表示该整数集合的编码模式,Redis 提供三种模式的宏定义如下:

// 可以看出,虽然contents部分指明的类型是int8_t,但是数据并不以这个类型存放
// 数据以int16_t类型存放,每个占2个字节,能存放-32768~32767范围内的整数
#define INTSET_ENC_INT16 (sizeof(int16_t)) 
// 数据以int32_t类型存放,每个占4个字节,能存放-2^32-1~2^32范围内的整数
#define INTSET_ENC_INT32 (sizeof(int32_t)) 
// 数据以int64_t类型存放,每个占8个字节,能存放-2^64-1~2^64范围内的整数
#define INTSET_ENC_INT64 (sizeof(int64_t)) 
length 字段用来保存集合中元素的个数。

contents 字段用于保存整数,数组中的元素要求不含有重复的整数且按照从小到大的顺序排列。在读取和写入的时候,均按照指定的 encoding 编码模式读取和写入。

升级
inset中最值得一提的就是升级操作。当intset中添加的整数超过当前编码类型的时候,intset会自定升级到能容纳该整数类型的编码模式,如 1,2,3,4,创建该集合的时候,采用int16_t的类型存储,现在需要像集合中添加一个大整数,超出了当前集合能存放的最大范围,这个时候就需要对该整数集合进行升级操作,将encoding字段改成int32_6类型,并对contents字段内的数据进行重排列。
Redis提供intsetUpgradeAndAdd函数来对整数集合进行升级然后添加数据。其升级过程可以参考如下图示:

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第5张图片

还有一个比较生动的图解:

// 根据集合原来的编码方式,从底层数组中取出集合元素
    // 然后再将元素以新编码的方式添加到集合中
    // 当完成了这个步骤之后,集合中所有原有的元素就完成了从旧编码到新编码的转换
    // 因为新分配的空间都放在数组的后端,所以程序先从后端向前端移动元素
    // 举个例子,假设原来有 curenc 编码的三个元素,它们在数组中排列如下:
    // | x | y | z | 
    // 当程序对数组进行重分配之后,数组就被扩容了(符号 ? 表示未使用的内存):
    // | x | y | z | ? |   ?   |   ?   |
    // 这时程序从数组后端开始,重新插入元素:
    // | x | y | z | ? |   z   |   ?   |
    // | x | y |   y   |   z   |   ?   |
    // |   x   |   y   |   z   |   ?   |
    // 最后,程序可以将新元素添加到最后 ? 号标示的位置中:
    // |   x   |   y   |   z   |  new  |
    // 上面演示的是新元素比原来的所有元素都大的情况,也即是 prepend == 0

    // 当新元素比原来的所有元素都小时(prepend == 1),调整的过程如下:
    // | x | y | z | ? |   ?   |   ?   |
    // | x | y | z | ? |   ?   |   z   |
    // | x | y | z | ? |   y   |   z   |
    // | x | y |   x   |   y   |   z   |
    // 当添加新值时,原本的 | x | y | 的数据将被新值代替
    // |  new  |   x   |   y   |   z   |
————————————————

查找:
查找的逻辑在插入操作时候就已经用到了,实际上是二分查找intsetSearch()
删除:
首先获取元素的encoding,如果不符合条件,success为0表示删除失败。
否则调用intsetSearch()查找到相应的位置
然后将pos+1的元素移动到pos位置上,相当于向前覆盖一个元素。
将元素个数减一,重新分配内存

3)zipList 压缩列表

压缩列表是 List 、hash、 sorted Set 三种数据类型底层实现之一。
当只有少量数据,并且每个列表项要么为小整数值,要么是长度比较短的字符串时, Redis 就会使用压缩列表来做列表键的底层实现
阿里二面:Redis身为单线程,它是怎么做到这么快的?_第6张图片

ziplist本质上就是一个字节数组,是为节约内存而设计的一种线性数据结构,可以包含任意多个元素,每个元素可以是一个字节数组或一个整数。
各字段含义如下:
1、zlbytes:压缩列表的字节长度,占4个字节,因此压缩列表最长(2^32)-1字节;
2、zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量,占4个字节;
3、zllen:压缩列表的元素数目,占两个字节;那么当压缩列表的元素数目超过(2^16)-1怎么处理呢?此时通过zllen字段无法获得压缩列表的元素数目,必须遍历整个压缩列表才能获取到元素数目;
4、entryX:压缩列表存储的若干个元素,可以为字节数组或者整数;entry的编码结构后面详述;
5、zlend:压缩列表的结尾,占一个字节,恒为0xFF。

根据上述结构,可以很容易获得ziplist的字节长度,元素数目等,那么如何遍历所有元素呢?我们已经知道对于每一个entry元素,存储的可能是字节数组或整数值;那么对任一个元素,我们如何判断存储的是什么类型?对于字节数组,我们又如何获取字节数组的长度?
回答这些问题之前,需要先看看压缩列表元素的编码结构,如图所示:

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第7张图片

遍历
previous_entry_length字段表示前一个元素的字节长度,占1个或者5个字节;当前一个元素的长度小于254字节时,previous_entry_length字段用一个字节表示;当前一个元素的长度大于等于254字节时,previous_entry_length字段用5个字节来表示;而这时候previous_entry_length的第一个字节是固定的标志0xFE,后面4个字节才真正表示前一个元素的长度;

因为每个元素的previous_entry_length字段存储的是前一个元素的长度,因此压缩列表的**前向遍历**相对简单,表达式(p-previous_entry_length)即可获取前一个元素的首地址,这里不做详述。

后向遍历时,需要解码当前元素,计算当前元素长度,才能获取后一个元素首地址
  encoding字段表示当前元素的编码,即content字段存储的数据类型(整数或者字节数组),数据内容存储在content字段;为了节约内存,encoding字段同样是可变长度。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第8张图片

可以看出,根据encoding字段第一个字节的前2个比特,可以判断content字段存储的是整数,或者字节数组(以及字节数组最大长度);当content存储的是字节数组时,后续字节标识字节数组的实际长度;当content存储的是整数时,根据第3、4比特才能判断整数的具体类型;而当encoding字段标识当前元素存储的是0~12的立即数时,数据直接存储在encoding字段的最后4个比特,此时没有content字段。

entry 结构体

对于任意的压缩列表元素,获取前一个元素的长度,判断存储的数据类型,获取数据内容,都需要经过复杂的解码运算才行,那么解码后的结果应该被缓存起来,为此定义了结构体zlentry,用于表示解码后的压缩列表元素

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第9张图片

回顾压缩列表元素的编码结构,可变因素实际上不止三个;previous_entry_length字段的长度(字段prevrawlensize表示)、previous_entry_length字段存储的内容(字段prevrawlen表示)、encoding字段的长度(字段lensize表示)、encoding字段的内容(字段len表示数据内容长度,字段encoding表示数据类型)、和当前元素首地址(字段p表示)。而headersize字段则表示当前元素的首部长度,即previous_entry_length字段长度与encoding字段长度之和。
函数zipEntry用来解码压缩列表的元素,存储于zlentry结构体:
阿里二面:Redis身为单线程,它是怎么做到这么快的?_第10张图片

ziplist的级联更新

entry中的prevlen字段表示前一个entry的长度,有两种取值,1byte或者5byte.
当一个entry前边的entry的长度发生变化时,会导致需要增大entry 的prevlen字段的size 来存储前一个entry的长度,如果有连续多个entry的容量接近254时,就会发生多个entry的prevlen的size需要扩容,这时就发生所谓的级联更新。
这种更新本质是prevlen size的变化,它以下有两种情形,
一种是扩大(1byte —> 5bytes),
一种是收缩(5bytes—>1byte),ziplist中不处理收缩情形,因为可以使用5bytes冗余的表示1byte的情形

在插入或者删除元素时都可能发生级联更新

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第11张图片

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第12张图片

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第13张图片

连锁更新会导致多次重新分配内存以及数据拷贝,效率是很低下的。但是出现这种情况的概率是很低的,因此对于删除元素与插入元素的操作,redis并没有为了避免连锁更新而采取措施。redis只是在删除元素与插入元素操作的末尾,检查是否需要更新后续元素的previous_entry_length字段

4)linkedlist 双向链表

因为C语言没有内置链表这种数据结构,所以Redis构建了自己的链表实现。列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。

typedef struct listNode {

    struct listNode *prev;//前驱指针

    struct listNode *next;//后继指针

    void *value; //节点的值

} listNode;

typedef struct listIter {//链表迭代器

    listNode *next;

    int direction;//遍历方向

} listIter;

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函数则用于对比链表结点所保存的值和另一个输入值是否相等;

特点

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

5) quicklist

虽然 ziplist 节省了内存开销,可它也存在两个设计代价:一是不能保存过多的元素,否则访问性能会降低;二是不能保存过大的元素,否则容易导致内存重新分配,引发连锁更新的问题。
因此,针对 ziplist 在设计上的不足,Redis 代码在开发演进的过程中,新增设计了两种数据结构:quicklist 和 listpack。这两种数据结构的设计目标,就是尽可能地保持 ziplist 节省内存的优势,同时避免 ziplist 潜在的性能下降问题。在Redis 3.2版本之后,为了进一步提升Redis的性能,统一采用quicklist来存储列表对象
quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第14张图片

uicklist宏观上是一个双向链表,因此,它具有一个双向链表的有点,进行插入或删除操作时非常方便,虽然复杂度为O(n),但是不需要内存的复制,提高了效率,而且访问两端元素复杂度为O(1)。
quicklist微观上是一片片entry节点,每一片entry节点内存连续且顺序存储,可以通过二分查找以 log2(n)log2(n) 的复杂度进行定位

quicklist 大致结构如图:

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第15张图片

6)dict 字典

dict (dictionary 字典),是Hash键的底层实现结构之一(数据量少时为ziplist)。Redis本身也叫REmote DIctionary Server (远程字典服务器),其实就是一个非常大的字典,它的key通常来说是String类型的,但是Value可以是
String、Set、ZSet、Hash、List等不同的类型。dict的数据结构定义:

哈希表节点dictEntry定义如下:

typedef struct dictEntry {
    void *key;                //key void*表示任意类型指针

    union {                   //联合体中对于数字类型提供了专门的类型优化
       void      *val;
       uint64_t  u64;
       int64_t   s64;
    } v;

    struct dictEntry *next;   //next指针

} dictEntry;

Redis的字典是用哈希表实现的,一个哈希表里面有多个哈希表节点,每个节点表示字典的一个键值对

typedef struct dictht {
    dictEntry **table;        //数组指针,每个元素都是一个指向dictEntry的指针

    unsigned long size;       //表示这个dictht已经分配空间的大小,大小总是2^n

    unsigned long sizemask;   //sizemask = size - 1; 是用来求hash值的掩码,为2^n-1

    unsigned long used;       //目前已有的元素数量
} dictht;

完整的字典dict实现是由2个哈希表dictht加上几个变量构成的,具体如下:

typedef struct dict {
    dictType *type;     //type中定义了对于Hash表的操作函数,比如Hash函数,key比较函数等

    void *privdata;      //privdata是可以传递给dict的私有数据         

    dictht ht[2];       //每一个dict都包含两个dictht,一个用于rehash

    int rehashidx;      //表示此时是否在进行rehash操作

    int iterators;      //迭代器
} dict;

通过上面三个数据结构,可以大概看出dict的组成,数据(Key-Value)存储在每一个dictEntry节点;然后一条Hash表就是一个dictht结构,里面标明了Hash表的size,used等信息;最后每一个Redis的dict结构都会默认包含两个dictht,如果有一个Hash表满足特定条件需要扩容,则会申请另一个Hash表,然后把元素ReHash过来,ReHash的意思就是重新计算每个Key的Hash值,然后把它存放在第二个Hash表合适的位置,但是这个操作在Redis中并不是集中式一次完成的,而是在后续的增删改查过程中逐步完成的,这个叫渐进式ReHash.

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第16张图片

字典的插入过程

  1. 先用哈希函数计算键key的哈希值(Redis使用的是MurMurHash2算法来计算哈希值)
    hash = dict->type->hashFunction(key)

  2. 借助sizemask和哈希值,计算出索引值(下面的x可以是0或者1)
    index = hash & dict->ht[x].sizemask

  3. 上面计算出来index的值其实就是对应dictEntry*数组的下标,如果对应下标没有存放任何键值对,则直接存放,否则借助开链法,从链表头插入新的键值对(因为链表没有记录指向链表尾部的指针,所以从链表头插入效率更高,可以达到O(1))

当哈希表的冲突率过高时链表会很长,这时查询效率就会变低,所以有必要进行哈希表扩展,而如果哈希表存放的键值对很少的时候把size设的很大,又会浪费内存,这时就有必要进行哈希表收缩。这里扩展和收缩的过程,其实就是rehash的过程。

rehash的步骤如下:

  1. 为dict的哈希表ht[1]分配空间,分配的空间大小取决于操作类型和当前键值对数量ht[0].used
    (1)如果是扩展操作,ht[1]的大小为第一个大于等于ht[0].used22^n的整数
    (2)如果是收缩操作,ht[1]的大小为第一个大于等于ht[0].used*2^n的整数

  2. 重新计算ht[0]中所有键的哈希值和索引值,将相应的键值对迁移到ht[1]的指定位置中去,需要注意的是,这个过程是渐进式完成的,不然如果字典很大的话全部迁移完需要一定的时间,这段时间内Redis服务器就不可用了哟

  3. 当ht[0]的所有键值对都迁移到ht[1]中去后(此时ht[0]会变成空表),把ht[1]设置为ht[0],并重新在ht[1]上新建一个空表,为下次rehash做准备

渐进式rehash:
对于Redis来说,如果Hash表的key太多,这样可能导致ReHash操作需要长时间进行,阻塞服务器,所以Redis本身将ReHash操作分散在了后续的每次增删改查中。
ReHash期间访问策略:
Redis中默认有关Hash表的访问操作都会先去 0 号哈希表查找,然后根据是否正在ReHash决定是否需要去 1 号Hash表中查找,关键代码如下

渐进的过程具体如下:
i. 为ht[1]分配空间,此时字典同时持有ht[0]和ht[1]
ii. 将rehashidx设为0,表示rehash正式开始
iii. 在rehash期间,每次对字典执行任意操作时,程序除了执行对应操作之外,还会顺带将ht[0]在rehashidx索引上的所有键值对rehash到ht[1],操作完后将rehashidx的值加一
iv. 在rehash期间,对字典进行ht[0].size次操作之后,rehashidx的值会增加到ht[0].size,此时ht[0]的所有键值对都已经迁移到ht[1]了,程序会将rehashidx重新置为-1,以此表示rehash完成

这里需要注意的是,在rehash的过程中,ht[0]和ht[1]可能同时存在键值对,因此在执行查询操作的时候两个哈希表都得查,而如果是执行插入键值对操作,则直接在ht[1]上操作就行。

最后说下Redis在什么条件下会对哈希表进行扩展或收缩:

  1. 服务器当前没有在执行BGSAVE或BGREWRITEAOF命令且哈希表的负载因子大于等于1时进行扩展操作

  2. 服务器正在执行BGSAVE或BGREWRITEAOF命令且哈希表的负载因子大于等于5时进行扩展操作(这里负载因子的计算公式为:负载因子=哈希表当前保存节点数/哈希表大小,而之所以在服务器进行BGSAVE或BGREWRITEAOF的时候负载因子比较大才进行扩展操作是因为此时Redis会创建子进程,而大多数操作系统采取了写时复制的技术来优化子进程使用效率,不适合在这种时候会做大规模的数据迁移活动,说白了就是为了节约内存和提升效率)

  3. 当前负载因子小于0.1时进行收缩操作

7)skipList 跳跃表

sorted set 类型的排序功能便是通过「跳跃列表」skiplist数据结构来实现。
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第17张图片

跳表为多个链表的集合,是一种概率型数据结构,用来替代平衡树的数据结构。准确来说,是用来替代自平衡二叉查找树(self-balancing BST)的结构。(普通BST插入元素越有序效率越低,最坏情况会退化回链表。自平衡BST在任何情况下的增删查操作都保持O(logn)的时间复杂度如AVL树、Splay树、2-3树及其衍生出的红黑树。但是树的自平衡过程比较复杂,实现起来麻烦,在高并发的情况下,加锁也会带来可观的overhead。)
跳表就是一种设计简单但与自平衡BST效率相近的一种数据结构。

跳表具有如下的性质:

i.由多层组成,最底层为第1层,次底层为第2层,以此类推。层数不会超过一个固定的最大值Lmax。
ii.每层都是一个有头节点的有序链表,第1层的链表包含跳表中的所有元素
iii.如果某个元素在第k层出现,那么在第1~k-1层也必定都会出现,但会按一定的概率p在第k+1层出现。

很显然这是一种空间换时间的思路,与索引异曲同工。第k层可以视为第k-1级索引,用来加速查找。为了避免占用空间过多,第1层之上都不存储实际数据,只有指针(包含指向同层下一个元素的指针与同一个元素下层的指针)。

当查找元素时,会从最顶层链表的头节点开始遍历。以升序跳表为例,如果当前节点的下一个节点包含的值比目标元素值小,则继续向右查找。如果下一个节点的值比目标值大,就转到当前层的下一层去查找。重复向右和向下的操作,直到找到与目标值相等的元素为止。下图中的蓝色箭头标记出了查找元素21的步骤。

在这里插入图片描述

插入元素的概率性
前文已经说过,跳表第k层的元素会按一定的概率p在第k+1层出现,这种概率性就是在插入过程中实现的。
当按照上述查找流程找到新元素的插入位置之后,首先将其插入第1层。至于是否要插入第2,3,4…层,就需要用随机数等方法来确定.

在redis中的实现
跳表在Redis中称为zskiplist,是其提供的有序集合(sorted set/zset)类型底层的数据结构之一。除了zskiplist之外,zset还使用了KV哈希表dict。Redis中有序集合的默认实现其实是更为普遍的ziplist(压缩双链表),但在redis.conf中有两个参数可以控制它转为zset实现:

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

当有序集合中的元素数超过zset-max-ziplist-entries时,或其中任意一个元素的数据长度超过zset-max-ziplist-value时,就由ziplist自动转化为zset

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

zskiplist的节点定义是结构体zskiplistNode,其中有以下字段。

obj:存放该节点的数据。
score:数据对应的分数值,zset通过分数对数据升序排列。
backward:指向链表上一个节点的指针,即后向指针。
level[]:结构体zskiplistLevel的数组,表示跳表中的一层。每层又存放有两个字段:
forward是指向链表下一个节点的指针,即前向指针。
span表示这个前向指针跳跃过了多少个节点(不包括当前节点)。

zskiplist就是跳表本身,其中有以下字段。
header、tail:头指针和尾指针。
length:跳表的长度,不包括头指针。
level:跳表的层数。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第18张图片

可见,zskiplist的第1层是个双向链表,其他层仍然是单向链表,这样做是为了方便可能的逆向获取数据的需求。

另外,节点中还会保存前向指针跳过的节点数span,这是因为zset本身支持基于排名的操作,如zrevrank指令(由数据查询排名)、zrevrange指令(由排名范围查询数据)等。如果有span值的话,就可以方便地在查找过程中累积出排名了。

以上是zskiplist相对于前述的传统跳表的两点不同,并且都给我们带来了便利。下面我们来继续读代码,看看它的部分具体操作。

3. 单线程模型

千万别说 Redis 就只有一个线程。
单线程指的是 Redis 键值对读写指令的执行是单线程

单线程有什么好处?

1.不会因为线程创建导致的性能消耗;
2.避免上下文切换引起的 CPU 消耗,没有多线程切换的开销;
3.避免了线程之间的竞争问题,比如添加锁、释放锁、死锁等,不需要考虑各种锁问题。
4.代码更清晰,处理逻辑简单。

(官方答案:“因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是机器内存的大小或者网络带宽。” 既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了)

4. I/O 多路复用模型

为什么Redis中要使用 I/O 多路复用这种技术呢?因为Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入 或 输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导,致整个进程无法对其它客户提供服务。而 I/O 多路复用就是为了解决这个问题而出现的。为了让单线程(进程)的服务端应用同时处理多个客户端的事件,Redis采用了IO多路复用机制(关于IO复用https://www.cnblogs.com/reecelin/p/13537734.html)。

redis的I/O 多路复用其实是使用一个线程来检查多个Socket的就绪状态,在单个线程中通过记录跟踪每一个socket(I/O流)的状态来管理处理多个I/O流。如下图是Redis的I/O多路复用模型:
阿里二面:Redis身为单线程,它是怎么做到这么快的?_第19张图片

如上图对Redis的I/O多路复用模型进行一下描述说明:
(1)一个socket客户端与服务端连接时,会生成对应一个套接字描述符(套接字描述符是文件描述符的一种),每一个socket网络连接其实都对应一个文件描述符。
(2)多个客户端与服务端连接时,Redis使用 I/O多路复用程序 将客户端socket对应的FD注册到监听列表(一个队列)中,并同时监控多个文件描述符(fd)的读写情况。当客服端执行accept、read、write、close等操作命令时,I/O多路复用程序会将命令封装成一个事件,并绑定到对应的FD上。
(3)当socket有文件事件产生时,I/O 多路复用模块就会将那些产生了事件的套接字fd传送给文件事件分派器。
(4)文件事件分派器接收到I/O多路复用程序传来的套接字fd后,并根据套接字产生的事件类型,将套接字派发给相应的事件处理器来进行处理相关命令操作。
(5)整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,当其中一个client端达到写或读的状态,文件事件处理器就马上执行,从而就不会出现I/O堵塞的问题,提高了网络通信的性能。
(6)如上图,Redis的I/O多路复用模式使用的是 Reactor设计模式的方式来实现。

关于reactor模式 :https://www.jianshu.com/p/188ef8462100
NIO实现多reactor模式:https://blog.csdn.net/qq_32445015/article/details/104584433

Redis基于Reactor模式开发了自己的网络事件处理器,称之为文件事件处理器(File Event Hanlder)。文件事件处理器由Socket、IO多路复用程序、文件事件分派器(dispather),事件处理器(handler)四部分组成。

IO多路复用程序会同时监听多个socket,当被监听的socket准备好执行accept、read、write、close等操作时,相对应的文件事件就会产生。IO多路复用程序会把所有产生事件的socket压入一个队列中,然后有序地每次仅一个socket的方式传送给文件事件分派器,文件事件分派器接收到socket之后会根据socket产生的事件类型调用对应的事件处理器进行处理。

文件事件处理器分为几种:
连接应答处理器:用于处理客户端的连接请求;
命令请求处理器:用于执行客户端传递过来的命令,比如常见的set、lpush等;
命令回复处理器:用于返回客户端命令的执行结果,比如set、get等命令的结果;

事件种类:
AE_READABLE:与两个事件处理器结合使用。
当客户端连接服务器端时,服务器端会将连接应答处理器与socket的AE_READABLE事件关联起来;
当客户端向服务端发送命令的时候,服务器端将命令请求处理器与AE_READABLE事件关联起来;
AE_WRITABLE:当服务端有数据需要回传给客户端时,服务端将命令回复处理器与socket的AE_WRITABLE事件关联起来。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第20张图片

4. Redis 全局 hash 字典

前文提到过Redis 整体就是一个哈希表,用来保存所有的键值对,无论数据类型是 5 种的任意一种。哈希表,本质就是一个数组,每个元素被叫做哈希桶,,每个桶里面的 entry 保存着实际具体值的指针。

阿里二面:Redis身为单线程,它是怎么做到这么快的?_第21张图片

在SDS中提到,所有的 Redis 对象都有一个 Redis 对象头结构体。Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。当我们在 Redis 中创建一个键值对时,至少创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。

也就是说在全局哈希表中,每个 entry 保存着 「键值对」的 redisObject 对象,通过 redisObject 的指针找到对应数据。

Redis 通过链式哈希解决冲突:也就是同一个 桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis 使用了两个全局哈希表用于 rehash 操作,增加现有的哈希桶数量,减少哈希冲突,并且将 rehash 分散到多次请求过程中,避免耗时阻塞。

你可能感兴趣的:(后端,程序员,java,java,后端,架构)