Redis面试八股文-Redis为什么快?

传家宝级八股文问题:Redis为什么这么快?

简单回答:
1. 基于内存实现
2. 高效的数据结构(动态字符串(SDS)、跳表(skiplist)、哈希表(hashtable)、整数数组(intset)、快速列表(quicklist)、紧凑列表(listpack)、基数树(stream)等等)
3. 单线程结合多线程(避免频繁的上下文切换和资源竞争)
4. I/O多路复用;非阻塞I/O模型
5. 高性能的自定义通讯协议RESP

接下来展开说说

基于内存实现

低延迟高读取速度:以ddr4内存为例,延迟基本在15纳秒左右,优秀的硬件可以做到更低;读写速度基本在30000MB/s以上,而顶级的PCIE4.0 SSD的读取延迟也在15微妙左右,顺序读取速度在7000MB/s左右,从延迟到速度全方位碾压。
CPU大多数场景下不会成为瓶颈:在Redis诞生之初,在只有基础类型的情况下,由于算法的时间复杂度很低,基本都是O(N)或O(log(N)),所以不太会占用太多的CPU资源,反而内存的随机读写性能会成为瓶颈。但现在也有CPU产生瓶颈的情况,例如GEO计算距离;大量数据排序;大量数据合并等操作。

高效的数据结构

为了追求速度,不同数据类型使用不同的数据结构速度才得以提升,每种数据类型都有一种或者多种数据结构来支撑。

合理的数据编码

Redis 使用对象(redisObject)来表示数据库中的键值,当我们在 Redis 中创建一个键值对时,至少创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。

例如我们执行 SET KeyString ValueString 时,键值对的键是一个包含了字符串“KeyString“的对象,键值对的值对象是包含字符串”ValueString”的对象。

redisObject

1
2
3
4
5
6
7
8
9
struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */

    int refcount;
    void *ptr;
};

这个结构可以表示所有基本的 Redis 数据类型,如字符串、列表、集合、有序集合等。

type 字段记录了数据的类型, 定义如下:

1
2
3
4
5
6
7
8
9
/* The actual Redis Object */
#define OBJ_STRING 0    /* String object. */
#define OBJ_LIST 1      /* List object. */
#define OBJ_SET 2       /* Set object. */
#define OBJ_ZSET 3      /* Sorted set object. */
#define OBJ_HASH 4      /* Hash object. */

#define OBJ_MODULE 5    /* Module object. */
#define OBJ_STREAM 6    /* Stream object. */

refcount 字段记录了一个对象在多个位置引用同一个对象而不需要多次分配。

ptr 字段指向对象的实际表示,即使是相同类型的对象,其表示方式也可能因使用的编码而有所不同。

Redis 对象在 Redis 内部广泛使用,但为避免间接访问的开销,在许多地方最近我们只是使用未包装在 Redis 对象内部的普通动态字符串。

encoding字段决定了数据存储的实际类型,源码中的枚举如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Objects encoding. Some kind of objects like Strings and Hashes can be
 * internally represented in multiple ways. The 'encoding' field of the object
 * is set to one of this fields for this object. */


#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* No longer used: old hash encoding. */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* No longer used: old list/hash/zset encoding. */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of listpacks */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
#define OBJ_ENCODING_LISTPACK 11 /* Encoded as a listpack */

String(OBJ_STRING):
存储整型数字的话,采用 int 类型的编码,如果是非数字的话,采用 raw 编码。

List(OBJ_LIST):
List对象的编码使用quicklist实现;需要注意的是,其他八股文中会说List对象的编码可以是linklist和ziplist,但Redis5.0以后,官方逐步使用listpack完全替代了ziplist,quicklist完全替代了linklist,所以本身没有问题,但知识点已经过时。

Hash(OBJ_HASH):
Hash 对象的编码可以是 listpack 或 hashtable(dict)。这里网上的其他材料也会根据配置文件的key list-max-ziplist-entries 512 觉得还在使用ziplist,可是上面的枚举源码中明确写出了no longer used,出于实事求是的目的翻翻源码,终于在源码中找到了答案,配置key虽然没变,但是读取后实际上已经将值读取到了新的key中

1
2
3
4
5
6
7
8
9
10
11
// config.c line 3193
createSizeTConfig("hash-max-listpack-entries", "hash-max-ziplist-entries", MODIFIABLE_CONFIG, 0, LONG_MAX, server.hash_max_listpack_entries, 512, INTEGER_CONFIG, NULL, NULL),

// 新版本redis.conf
############################### ADVANCED CONFIG ###############################

# Hashes are encoded using a memory efficient data structure when they have a
# small number of entries, and the biggest entry does not exceed a given
# threshold. These thresholds can be configured using the following directives.
hash-max-listpack-entries 512
hash-max-listpack-value 64

rdb.c line 930

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
else if (o->type == OBJ_HASH) {
        /* Save a hash value */
        if (o->encoding == OBJ_ENCODING_LISTPACK) {
            size_t l = lpBytes((unsigned char*)o->ptr);

            if ((n = rdbSaveRawString(rdb,o->ptr,l)) == -1) return -1;
            nwritten += n;
        } else if (o->encoding == OBJ_ENCODING_HT) {
            dictIterator *di = dictGetIterator(o->ptr);
            dictEntry *de;

            if ((n = rdbSaveLen(rdb,dictSize((dict*)o->ptr))) == -1) {
                dictReleaseIterator(di);
                return -1;
            }
            nwritten += n;

            while((de = dictNext(di)) != NULL) {
                sds field = dictGetKey(de);
                sds value = dictGetVal(de);

                if ((n = rdbSaveRawString(rdb,(unsigned char*)field,
                        sdslen(field))) == -1)
                {
                    dictReleaseIterator(di);
                    return -1;
                }
                nwritten += n;
                if ((n = rdbSaveRawString(rdb,(unsigned char*)value,
                        sdslen(value))) == -1)
                {
                    dictReleaseIterator(di);
                    return -1;
                }
                nwritten += n;
            }
            dictReleaseIterator(di);
        } else {
            serverPanic("Unknown hash encoding");
        }
    }

当 Hash 对象同时满足以下两个条件时,Hash 对象采用 listpack 编码:

Hash 对象保存的所有键值对的键和值的字符串长度均小于 64 字节。
Hash 对象保存的键值对数量小于 512 个。
否则就是 hashtable 编码。

Set(OBJ_SET):

Set 对象的编码可以是 intset 或 hashtable 或listpack,intset 编码的对象使用整数集合作为底层实现,把所有元素都保存在一个整数集合里面。

保存元素为整数且元素个数小于一定范围使用 intset 编码,任意条件不满足,则根据实际情况使用 hashtable 或 listpack 编码;

Zset(OBJ_ZSET):
Zset 对象的编码可以是 listpack 或 zskiplist, 当保存的对象少于128个且单个元素的长度小于64字节时回使用listpack,任一条件不满足是会使用zskiplist进行保存。

接下来详细解释下底层数据结构:

SDS 简单动态字符

Redis是用C实现的,那说的这么玄乎的SDS(Simple Dynamic String)是个什么新类型吗?答案是不是,直接看源码中的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
typedef char *sds;

/* 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[];
};

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

从源码可以看出,sds其实就是个指针而已。这里定义了5中不同的数据结构,使用一个字节的char类型作为类型标识,低3位存储类型, 高5位预留。

内存不对齐

仔细看源码会发现结构体在定义的时候加入了__attribute__ ((__packed__))标识,这个标识的作用就是告诉编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。

Redis为什么要这么做呢?
这里有个细节,Redis的sds指向的地址实际是结构体中buf[],而不是结构体开头的len的,所有保证了能够跟一些传统string.h中的定义保持一致。
而且如果这里内存不对齐,那么使用使用指针-1即可访问到flags(因为char在不进行内存对其的情况下,肯定在buf[]的前一个地址),从而通过位运算得到具体的结构体类型,字符串长度,已经分配的内存长度等等,一举两得。

SDS的优势:

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

C 语言字符串布吉路长度信息,需要遍历整个字符串时间复杂度为 O(n),C 字符串遍历时遇到 ‘\0’ 时结束。

SDS 中 len 保存这字符串的长度,用的时候直接读取,O(1) 时间复杂度。

空间预分配

SDS 被修改后,程序不仅会为 SDS 分配所需要的必须空间,还会分配额外的未使用空间。

分配规则如下:如果对 SDS 修改后,len 的长度小于 1M,那么程序将分配和 len 相同长度的未使用空间。举个例子,如果 len=10,重新分配后,buf 的实际长度会变为 10(已使用空间)+10(额外空间)+1(空字符)=21。如果对 SDS 修改后 len 长度大于 1M,那么程序将分配 1M 的未使用空间。

因为 SDS 的空间预分配策略, SDS 字符串在增长过程中不会频繁的进行空间分配。

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

惰性空间释放

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

杜绝缓冲区溢出

字符串的拼接操作是使用十分频繁的,在C语言开发中使用char *strcat(char *dest,const char *src)方法将src字符串中的内容拼接到dest字符串的末尾。由于C字符串不记录自身的长度,所有strcat方法已经认为用户在执行此函数时已经为dest分配了足够多的内存,足以容纳src字符串中的所有内容,而一旦这个条件不成立就会产生缓冲区溢出,会把其他数据覆盖掉,非常危险。
而当SDS API需要对SDS进行修改时,API会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题。

二进制安全

在 Redis 中不仅可以存储 String 类型的数据,也可能存储一些二进制数据。

二进制数据并不是规则的字符串格式,其中会包含一些特殊的字符如 ‘\0’,在 C 中遇到 ‘\0’ 则表示字符串的结束,这限制了C字符串只能保存文本数据,而不能保存二进制数据。
而SDS使用len属性的值判断字符串是否结束,所以不会受’\0’的影响。

哈希表/哈希字典(hashtable)

我们先来看一下源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
typedef struct dictEntry dictEntry; /* opaque */

typedef struct dict dict;

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(dict *d, const void *key);
    void *(*valDup)(dict *d, const void *obj);
    int (*keyCompare)(dict *d, const void *key1, const void *key2);
    void (*keyDestructor)(dict *d, void *key);
    void (*valDestructor)(dict *d, void *obj);
    int (*expandAllowed)(size_t moreMem, double usedRatio);
    /* Flags */
    /* The 'no_value' flag, if set, indicates that values are not used, i.e. the
     * dict is a set. When this flag is set, it's not possible to access the
     * value of a dictEntry and it's also impossible to use dictSetKey(). Entry
     * metadata can also not be used. */

    unsigned int no_value:1;
    /* If no_value = 1 and all keys are odd (LSB=1), setting keys_are_odd = 1
     * enables one more optimization: to store a key without an allocated
     * dictEntry. */

    unsigned int keys_are_odd:1;
    /* TODO: Add a 'keys_are_even' flag and use a similar optimization if that
     * flag is set. */


    /* Allow each dict and dictEntry to carry extra caller-defined metadata. The
     * extra memory is initialized to 0 when allocated. */

    size_t (*dictEntryMetadataBytes)(dict *d);
    size_t (*dictMetadataBytes)(void);
    /* Optional callback called after an entry has been reallocated (due to
     * active defrag). Only called if the entry has metadata. */

    void (*afterReplaceEntry)(dict *d, dictEntry *entry);
} dictType;

struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;     /* Next entry in the same hash bucket. */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */

};

typedef struct {
    void *key;
    dictEntry *next;
} dictEntryNoValue;

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

整个数据库就是一个全局哈希表,而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的 entry 找到对应数据,这个也是 Redis 快的原因之一。

那 Hash 冲突怎么办?

当写入 Redis 的数据越来越多的时候,哈希冲突不可避免,会出现不同的 key 计算出一样的哈希值。

Redis 通过链式哈希解决冲突:也就是同一个桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差。
所以 Redis 为了追求快,使用了两个全局哈希表,用于 rehash 操作,增加现有的哈希桶数量,减少哈希冲突。

开始默认使用 hash 表 1 保存键值对数据,哈希表 2 此刻没有分配空间。当数据越来多触发 rehash 操作,则执行以下操作:

给 hash 表 2 分配更大的空间;
将 hash 表 1 的数据重新映射拷贝到 hash 表 2 中;
释放 hash 表 1 的空间。
值得注意的是,将 hash 表 1 的数据重新映射到 hash 表 2 的过程中并不是一次性的,这样会造成 Redis 阻塞,无法提供服务。

而是采用了渐进式 rehash,每次处理客户端请求的时候,先从 hash 表 1 中第一个索引开始,将这个位置的 所有数据拷贝到 hash 表 2 中,就这样将 rehash 分散到多次请求过程中,避免耗时阻塞。

整数数组(intset)

当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。结构如下:

1
2
3
4
5
typedef struct intset {
    uint32_t encoding; // 根据整数值的大小决定编码方式,编码方式优先级:Int64 > Int32 > Int16
    uint32_t length; // 集合中元素个数
    int8_t contents[]; // 保存元素的数组,这里保存为字节数组,根据编码方式进行顺序存取
} intset;

contents 数组是整数集合的底层实现:需要注意的是这里contents的元素个数不等于length(因为当encoding == 64时,一个元素就会占用8个字节,以此类推),contents中的元素按照从小到大的顺序排列,不包含任何重复元素。

跳表(skiplist)

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。
跳表指的就是除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。
这就使得我们在查找数据的时候能够先在高层的链表中进行查找,然后逐层降低,最终降到第1层链表来精确地确定数据位置。
在这个过程中,我们跳过了一些节点,从而也就加快了查找速度。
跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

skiplist在Redis中的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

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

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

从源码中可知:
zskiplistNode backward字段是指向链表前一个节点的指针(前向指针)。节点只有1个前向指针,所以只有第1层链表是一个双向链表。
跳表也是Redis zset的一种底层数据结构。

快速列表(quicklist)

为什么要设计 quicklist?

原有的ziplist存在两个问题,
1. 不能保存过多的元素,否则访问性能会下降
2. 不能保存过大的元素,否则容易导致内存重新分配,甚至引起连锁更新

特点
quicklist 的设计,其实是结合了链表和 listpack 各自的优势。简单来说,一个 quicklist 就是一个链表,而链表中的每个节点又是一个entry,每个entry中包含了对应对的listpack。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *entry;
    size_t sz;             /* entry size in bytes */
    unsigned int count : 16;     /* count of items in listpack */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* PLAIN==1 or PACKED==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int dont_compress : 1; /* prevent compression of entry that will be used later */
    unsigned int extra : 9; /* more bits to steal for future usage */
} quicklistNode;

typedef struct quicklistLZF {
    size_t sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

typedef struct quicklistBookmark {
    quicklistNode *node;
    char *name;
} quicklistBookmark;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all listpacks */
    unsigned long len;          /* number of quicklistNodes */
    signed int fill : QL_FILL_BITS;       /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

typedef struct quicklistIter {
    quicklist *quicklist;
    quicklistNode *current;
    unsigned char *zi; /* points to the current element */
    long offset; /* offset in current listpack */
    int direction;
} quicklistIter;

typedef struct quicklistEntry {
    const quicklist *quicklist;
    quicklistNode *node;
    unsigned char *zi;
    unsigned char *value;
    long long longval;
    size_t sz;
    int offset;
} quicklistEntry;

紧凑列表(listpack)

紧凑列表,用一块连续的内存空间来紧凑保存数据,同时使用多种编码方式,表示不同长度的数据(字符串、整数)。
在Redis中主要用于替代原本的linklist和ziplist。

ziplist和listpack结构对比:

从以上结构中可以看到,listpack移除了prevlen,在data后新增了backlen,两者有着本质区别:

● 在ziplist中,prevlen代表前一个entry节点长度的偏移量;
● 在listpack中,backlen代表的是本entry节点的回朔起始地址长度的偏移量

Entry这样设计具有以下一些优势:

● 在遍历最后一个entry时可以通过lp+totallen快速定位到lp尾地址,然后使用backlen快速定位到last entry的起始地址;***并且可以将head中的last offset字段节省出来;
● 由于backlen仅代表了回朔本entry起始地址长度的偏移量,所以在增/删/改时,无需再关心前一个节点的长度,仅需要整体移动entry即可,所以不会涉及到内存的连锁更新;

基数树(stream)

stream 数据结构采用的是基数树包装的listpack(Encoded as a radix tree of listpacks),保证了消息队列数据存储的稳定和和可靠性,这里先不详细说明,后续写消息队列再详细讨论。

单线程结合多线程

单线程结合多线程?

不是大家都说Redis是单线程的吗?怎么你这里还结合上多线程了?

首先,Redis的命令执行,数据操作确实是在同一个线程串行执行的,这也就是传统意义上的Redis是单线程的说法由来。
但是,对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行;在Redis6.0以后,官方提供了多线程处理网络IO的选项,需要注意的是,Redis 多线程IO模型只用来处理网络读写请求,对于 Redis 的读写命令,依然是单线程处理。

为什么Redis不充分利用多核呢?
官方的说法是Redis在大多数时候性能瓶颈都不在CPU上,单核心就已经足够了;但随着数据结构的越来越复杂多样,官方也在努力推进Redis多线程化,比如6.0以后网络io的多线程化处理。

单线程的优势

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

多线程的优势

1. 可以充分利用物理服务器多核心的优势,所以在Redis6.0以后充分利用了这一点,将网络io放在多线程进行处理。
2. io线程数量可配置,但一般不超过8个,根据官方说法,超过8个线程以后就没有什么太多的实际意义了。

I/O多路复用;非阻塞I/O模型

非阻塞I/O模型

在Linux中,默认情况下所有socket都是阻塞的,每一次读取调用都需要内核先准备好数据报文=>拷贝数据报文=>执行拷贝操作=>返回执行完成这样串行操作并返回结果。
对于网络IO来说,很多时候数据在一开始还没到达时(比如还没有收到一个完整的TCP包),系统内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。
当系统内核一直等到数据准备好了,它就会将数据从系统内核中拷贝到用户内存中,然后系统内核返回结果,用户进程才解除阻塞的状态,重新运行起来。所以,阻塞IO模型的特点就是在IO执行的两个阶段(等待数据和拷贝数据)都被阻塞了。

但是,我们可以通过设置socket使IO变为非阻塞状态,如果内核中的数据还没有准备好,那么它不会阻塞用户进程,而是立刻返回一个错误。
接下来就是用户进程循环执行相关操作,直到得到正确的状态码,所以,在非阻塞式IO中,用户进程其实需要不断地主动询问kernel数据是否准备好。非阻塞的接口相比阻塞型接口的显著差异在于被调用之后立即返回。
所以,在非阻塞式IO中,用户进程其实需要不断地主动询问kernel数据是否准备好。非阻塞的接口相比阻塞型接口的显著差异在于被调用之后立即返回。

I/O多路复用

多路IO复用,有时也称为事件驱动IO(Reactor设计模式)。它的基本原理就是有个函数会不断地轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

当用户进程调用了select,那么整个进程会被阻塞,而同时,内核会”监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。
这个模型和阻塞IO的模型其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接。所以,如果系统的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程的阻塞IO的web server性能更好,可能延迟还更大;select/epoll的优势并不是对单个连接能处理得更快,而是在于能处理更多的连接。

IO多路复用是最常使用的IO模型,但是其异步程度还不够“彻底”,因为它使用了会阻塞线程的select系统调用。因此IO多路复用只能称为异步阻塞IO,而非真正的异步IO。

Redis的 I/O 多路复用程序的所有功能都是通过包装常见的select、epoll、evport、kqueue这些多路复用函数库来实现的。
因为Redis 为每个 I/O 多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现是可以互换的。
Redis 在 I/O 多路复用程序的实现源码中用 #include 宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的 I/O 多路复用函数库来作为 Redis 的 I/O 多路复用程序的底层实现

I/O 多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。然后这些套接字的处理采用顺序队列来执行,保证事件不会错乱。

高性能的自定义通讯协议RESP

Redis通信协议RESP(REdis Serialization Protocol)是一种简单、快速、可读性强的协议,由Redis自身的需求所驱动,具有以下几个优点:

简单:RESP是一种基于文本的协议,易于实现和调试。它的语法简单明了,只包含一些基本的命令和数据类型,没有复杂的数据结构或语法。

快速:RESP使用简单的文本格式和紧凑的编码方式,数据传输量小,速度快。RESP还支持多条命令的批量执行,可以显著提高Redis的性能。

可读性强:RESP使用文本格式存储数据,使得数据易于读取和理解。RESP还支持一些特殊的数据类型,如Null、错误消息等,使得客户端能够更好地处理异常情况。

易于扩展:RESP支持多种数据类型,如字符串、整数、数组、错误消息等,这使得它易于扩展和适应不同的需求。

跨语言支持:RESP是一种独立于编程语言的协议,支持多种语言的实现,如Python、Java、PHP等,这使得Redis可以方便地与其他编程语言进行通信。

综上所述,Redis通信协议RESP具有简单、快速、可读性强、易于扩展和跨语言支持等优点,使得Redis能够更加高效、灵活地进行数据存储和通信。

总结

Redis为什么这么快的原理总结:

1. 基于内存实现,基本都是在内存中进行高速存取操作,所以对CPU的消耗不大,速度极快;
2. 高效的数据结构(动态字符串(SDS)、跳表(skiplist)、哈希表(hashtable)、整数数组(intset)、快速列表(quicklist)、紧凑列表(listpack)、基数树(stream)等等);而且会根据不同的数据进行不同的数据编码选择;高效的数据结构和编码选择保证了数据的存取速度
3. 单线程结合多线程,保证了每个操作的原子性,也减少了线程的上下文切换和竞争;多线程解决网络io先关的瓶颈问题;
4. I/O多路复用;非阻塞I/O模型;用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,Redis 采用自己实现的事件分离器,效率比较高;
5. 高性能的自定义通讯协议RESP;简单、快速、可读性强、易于扩展和跨语言支持等优点,使得Redis能够更加高效、灵活地进行数据存储和通信;
6. 整个 Redis 就是一个全局 哈希表,他的时间复杂度是 O(1),而且为了防止哈希冲突导致链表过长,Redis 会执行 rehash 操作,扩充 哈希桶数量,减少哈希冲突。并且防止一次性 重新映射数据过大导致线程阻塞,采用 渐进式 rehash。巧妙的将一次性拷贝分摊到多次请求过程后总,避免阻塞。

程序猿老龚(龚杰洪)原创,版权所有,转载请注明出处.

One thought on “Redis面试八股文-Redis为什么快?

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注