Redis数据结构:

参考文章:

🌟redis对象与数据结构的关系图:

image-20230925105926664

redisObject结构:

​ redis的每个value底层是由 redisObject指向具体的数据结构

1
2
3
4
5
6
7
8
9
10
#define LRU_BITS 24

typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;

int refcount;
void *ptr;
}robj;

type:是哪种Redlis对象

encoding : 表示用哪种底层编码,用OBJECT ENCODING [key] 可以看到对应的编码方式

Iru:记录对象访问信息,用于内存淘汰。

refcount:引用计数,用来描述有多少个指针,指向该对象

ptr:内容指针,指向实际内容

其中:*ptr指向各种数据结构对象内容

  • 5 种基础数据结构:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)

SDS:

由于在C中:

  1. 计算字符串长度需要遍历字符串数组,直到遇到 ‘ \0 ’ ,因此时间复杂度为O(n);
  2. 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
  3. 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

由于获取字符串长度、修改字符串内容的操作理应频繁,为了提升性能,在redis中,自定义了一个SDS的字符串结构:

1
2
3
4
5
6
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len;
uint8_t alloc;
unsigned char flags;
char buf[];
}

len:字符串长度

alloc:分配给字符数组的空间长度

flags:表示是哪种sdshdr分类,有sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64等多种类型,它们的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//举例🌰
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};


struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};

​ **buf[]**:存数据的char数组,不仅可以保存字符串,也可以保存二进制数据。

这样,就可以通过查询len,从而在O(1)的时间下查询得string的长度,

并且,由于有alloc - len大小的预留空间,可以为后续追加数据留余地。

SDS扩容机制简述:

  • 如果所需的 sds 长度小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的newlen
  • 如果所需的 sds 长度超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB

链表:

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
// redis中,链表的定义
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;


不过,Redis 在 listNode 结构体基础上又封装了 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 函数。

其实就是简单的链表结构:

image-20230920160330614

优点:

​ 略

缺点:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大

压缩列表(早期版本使用):

—-核心思想:根据数据大小和类型进行不同的空间大小分配的设计思想

​ Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。

​ 特点:不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

压缩列表结构设计:

image-20230920170011757

1
2
3
4
5
6
7
struct ziplist<T> {
int32 zlbytes;
int32 zltail_offset;
int16 zllength;
T[] entries;
int8 zlend;
}
  • zlbytes:整个压缩列表占用的字节数,占4Byte
  • zltail_offset:最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个元素,占4Byte
  • zllen:压缩列表的元素个数,占2Byte
  • entries:压缩列表的元素,可以包含多个节点,每个节点可以保存一个字节数组或者一个整数值
  • zlend:压缩列表结束标志,值等于 0xFF,占1Byte

压缩列表的节点结构:

  • prevlen,记录了「前一个节点」的长度目的是为了实现从后向前遍历
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;
prevlen长度:

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 **prevlen 属性的空间大小跟前一个节点长度值有关**,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;
encoding长度:

当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的

  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码,也就是 encoding 长度为 1 字节。通过 encoding 确认了整数类型,就可以确认整数数据的实际大小了,比如如果 encoding 编码确认了数据是 int16 整数,那么 data 的长度就是 int16 的大小。
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。

压缩列表的特性:

​ 在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素

压缩列表的缺点:

连锁更新

​ 假如已知的各个节点的长度都为250~253,然后在表头节点的左边添加一个长度>=254的节点,由于表头节点的prevlen字段的长度默认为1字节,需要扩容到5字节,这就会导致表头节点的长度也 >= 254,导致第二个节点的prevlen也需要扩容……(直到影响了所有的节点)

空间扩展操作需要重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能

所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题

因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。

hash表:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
//两个Hash表,交替使用,用于rehash(扩容)操作
dictht ht[2];
} dictht;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//其中 table 为 dictEntry[] 数组,每个dictEntry的结构如下⬇️:
typedef struct dictEntry {
//键值对中的键
void *key;

//键值对中的值
//键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

结构跟java中的HashMap很像,故不做过多介绍

唯一需要强调的就是:hashmap中的每个dictEntry中,如果存储的是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值,那么就可以将数据直接写在dictEntry中的union结构体中,无需使用其他存储的结构而只是使用一个字段,节省了空间!

image-20230921102950618

hash冲突:

​ 与jdk1.7中hashmap一样,**使用链式hash存储**。

rehash:

​ rehash指:hash表的扩容操作

​ 触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

常规rehash操作:

​ rehash 操作,其过程分为三步:(有点像新生代垃圾回收。。。)

  • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

image-20230921104216278

这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求

渐进式 rehash操作:

​ 为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

​ 渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;

  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上

  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

  • ⚠️在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。

  • ⚠️在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表

整数集合:

​ 整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。

整数集合本质上是一块连续内存空间,它的结构定义如下:

1
2
3
4
5
6
7
8
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;

其中的 contents[]数组 的数据类型根据encoding 属性值,encoding 属性值是什么,content数组类型就是什么。

整数集合的升级操作:

详细定义:

​ 整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

扩容过程:

1. 在原本空间的基础上开辟需要的空间 需要新增的空间 = n * 新的大小 - (n - 1) * 原本大小
2. 从后往前依次将每个元素扩容,如图:
image-20230921110447679

整数集合升级机制的优点是:如果一直添加小元素(如 int16_t),就会使用16位存储一个元素,直到有存储更大的数据类型的需求时才会升级,能够**节省内存资源(按需开辟内存空间)**

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

跳表:

​ Redis 只有 Zset(sorted set) 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

​ zset 结构体里有两个数据结构:一个是跳表(for 范围查询),一个是哈希表(for 单点查询)。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。**Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。**

1
2
3
4
5
6
typedef struct zset {
//hash表
dict *dict;
//跳表
zskiplist *zsl;
} zset;

image-20230921141533670

跳表结构体:

1
2
3
4
5
6
7
8
typedef struct zskiplist {
//跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
struct zskiplistNode *header, *tail;
//跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
unsigned long length;
//跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量;
int level;
} zskiplist;

跳表的节点结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;

//元素权重值
double score;

//后向指针
struct zskiplistNode *backward;

//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
//指向下一个跳表节点的指针
struct zskiplistNode *forward;
//元素之间的跨度
unsigned long span;
} level[];
} zskiplistNode;

其中,span(跨度)用于查找排名为k的元素

跳表节点查询过程

查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重(也就是score)时,跳表就会访问该层上的下一个节点。
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

image-20230921142002848

跳表节点层数设置:

​ 跳表的相邻两层的节点数量的比例会影响跳表的查询性能。

​ **跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)**。

​ 但是2:1的节点数量比值不易维护,如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。

redis使用一种巧妙的方法:

  • 跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
  • 具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数

为什么用跳表而不用平衡树?

  • 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  • 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  • 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

为什么用跳表而不像innodb中使用B+树?

  • 实现简单:跳表是一个相对简单的数据结构,实现起来比B+树要简单。它不需要像B+树那样需要复杂的平衡操作(如旋转、合并等),因此代码实现也相对较简单。
  • 内存占用少:跳表相对于B+树来说,在相同有序集合的情况下,占用的内存更少。跳表不需要存储额外的索引,仅使用一级索引即可,而B+树需要存储多层索引。
  • 查询效率高:虽然B+树在大规模数据上的查询效率更高,但在实际的使用场景中,有序集合的规模通常不会很大,而跳表在小规模数据上的查询效率更高。
    • 并且,虽然跳表的层数通常情况下会大于B+树,但是由于redis数据存储于内存中,不会像mysql一样读取元素时还需要花费IO时间,所以快表又轻又快,当然好啦。
  • 简单的分布算法:在分布式环境下,跳表的划分和排序算法相对来说较为简单,不需要复杂的分割和合并操作,可以更轻松地实现和维护分布式的有序集合。

quicklist:

​ 在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。

简单来说quicklist为 双向链表+压缩列表的组合!

image-20230921151653568

quicklist通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

listpack:

​ 是压缩表的新版本实现方式

​ 因为原来版本的压缩列表中记录了前一个节点长度,即prevlen字段(设置这个字段是为了实现节点从后往前遍历),会因为前一个节点长度 >= 254而产生连锁更新的隐患,在listpack中,取消了这一字段,因此消除了这一隐患。

image-20230921151534614

listpack采用了一种逆序遍历的方式来使节点能够从后往前访问。主要通过逆序遍历listpack,通过listpack的结尾标识来得到前面节点的长度,从而实现逆序遍历:

1
2
3
4
5
6
7
8
在`listpack`中,每个节点的末尾都会有一个字节,用于指示当前节点的类型。如果该字节大于等于240,它将作为一个特殊字节,表示当前节点是列表中的一个稀疏值或哨兵节点。对于正常的列表项节点,该字节的取值范围是0到239。

逆序遍历`listpack`中的节点,可以通过以下步骤进行:

1. 获取`listpack`的总长度 `total_length`。
2. 从 `total_length - 1` 开始,按照字节递减的方式依次处理每个节点。
3. 如果遇到一个特殊字节(大于等于240),则表示当前节点是一个稀疏值或哨兵节点,可以根据情况进行处理。
4. 如果遇到一个正常的列表项节点(字节值在0到239之间),则解析出节点的数据,并按照相应的长度进行处理。

Redis数据各类型底层实现:

未命名

String:

String最大为512MB

String的三种编码方式:

1. int:

​ 当string的value执行自增等数据操作时,会自动转换成int类型。

  • int只能存整数,小数等会以字符串形式保存
2. embstr:

​ 当字符串长度 <= 设定阈值字节时,使用embstr编码

3. raw:

​ 当字符串长度 > 设定阈值字节时,使用raw编码

· 阈值:redis3.2之前为39字节;redis3.2之后为44字节

❓为什么阈值是44B?

1
2
Redis是使用jemalloc内存分配器,Redis以64字节为阈值区分大小字符串。所以EMBSTR的边界数值,其实是受64这个阈值影响。redis对象占用的内存大小由redisObject和sdshdr这两部分组成,redisObject 16字节,sdshdr中已分配、已申请、标记三个字段固定占了3个字节,'\0'占了一个字节,
能存放的数据就是 64- (16+4) = 44 B

string底层构造:

EMBSTR和RAW都是由redisObject和SDS两个结构组成,它们的差异在于:

  • EMBSTR下redisObject 和 SDS是连续的内存,
  • RAW编码下redisObject 和 SDS的内存是分开的。

EMBSTR优点是redisObject和SDS两个结构可以一次性分配空间,缺点在于如果重新分配空间,整体都需要再分配,所以EMBSTR设计为只读,任何写操作之后EMBSTR都会变成RAW,理念是发生过修改的字符串通常会认为是易变的。

随着我们的操作,编码可能会转换:

INT-> RAW: 当存的内容不再是整数,或者大小超过了long的时候;

EMBSTR->RAW:任何写操作之后EMBSTR都会变成RAW;

List:

​ –List 类型的底层数据结构是由双向链表或压缩列表实现的:

  • 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;

但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表

Hash:

​ –Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

Set:

​ –Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Zset:

​ –Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis 存储对象信息是用 Hash 还是 String

String:

由于 Redis 的字符串是动态字符串,可以修改,内部结构类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。

如果此时将此用户信息的某个属性值修改,再存到 Redis 中,Redis 是不需要重新分配空间的

Hash:

Hash 结构也可以存储用户信息,不过是对每个字段单独存储,因此可以在查询时获取部分字段的信息,节省网络流量

but 处理类中属性为别的引用对象的嵌套的情况时比较复杂。

总结:

建议是大部分情况下使用 String 存储就好,毕竟在存储具有多层嵌套的对象时方便很多,占用的空间也比 Hash 小。当我们需要存储一个特别大的对象时,而且在大多数情况中只需要访问该对象少量的字段时,可以考虑使用 Hash。

RDB与AOF

RDB为快照

AOF记录每条指令

  • Redis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。
  • 不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
  • 如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化。

Redis阻塞的原因:

  1. 执行时间复杂度 O(n)以上的命令,如:

    • KEYS :会返回所有符合规则的 key。

    • HGETALL:会返回一个 Hash 中所有的键值对。

    • LRANGE:会返回 List 中指定范围内的元素。

    • SMEMBERS:返回 Set 中的所有元素。

    • SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。

  2. 使用save命令生成RDB快照

    • save : 同步保存操作,会阻塞 Redis 主线程;
    • bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项
  3. AOF阻塞

    1. 日志记录阻塞:

      • 因为AOF 记录日志是在 Redis 主线程中进行
    2. 刷盘阻塞:

      • AOF日志从 AOF缓冲区->内核缓冲区时,需要调用write函数,(主线程调用 write 函数时也会被阻塞)
    3. AOF重写阻塞:

      • fork 出一条子线程来将文件重写,在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。
      • 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。
      • 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

      阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞

  4. 大Key

  • 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  1. 清空数据库

  2. 集群扩容

  3. 内存交换

  4. CPU竞争

  5. 网络延迟

Redis 为什么这么快?

  1. Redis 基于内存,内存的访问速度是磁盘的上千倍;

  2. Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用;

  3. Redis 内置了多种优化过后的数据结构实现(如hash表和跳表),性能非常高。

Redis单线程:

参考文章:

什么是redis单线程?

​ Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发生数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

基于多路复用的高性能 I/O 模型示意图:

image-20230925145105519

​ 在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。

  • 为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。那么,回调机制是怎么工作的呢?其实,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。
    • 如果是 连接事件到来,则会调用 连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket ->调用 epoll_ctr 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数;
    • 如果是 读事件到来,则会调用 读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
    • 如果是 写事件到来,则会调用 写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发生完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 。
  • 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

总结:

​ Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。单线程的 Redis 也能获得高性能,跟多路复用的 IO 模型密切相关,因为这避免了 accept() 和 send()/recv() 潜在的网络 IO 操作阻塞点。

Redis多线程:

参考文章:

redis并非只有一个线程:

​ 对于一些长耗时的操作,为了不影响性能,Redis采用多线程方式处理。

​ Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理

Redis 6.0 新特性:

​ Redis6.0后 **允许**采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度。

需要注意的是,Redis 多 IO 线程模型只用来处理网络读写请求,对于 Redis 的读写命令,依然是单线程处理

image-20230925155107569

主线程与 IO 多线程实现协作示意图:

image-20230925155243715

Redis过期删除策略和内存淘汰策略:

参考文章:

过期删除策略:

​ 为key设置过期时间 (通过expire、pexpire命令设置)

如何判定 key 已过期了?

​ 每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict,实际上是一个hash表)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;
过期删除策略介绍:
  • 定时删除
    • 在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
    • 定时删除策略的优点
      • 可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。
    • 定时删除策略的缺点
      • 在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
  • 惰性删除
    • 不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key
    • 惰性删除策略的优点
      • 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
    • 惰性删除策略的缺点
      • 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。
  • 定期删除
    • 每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
    • 定期删除策略的优点
      • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
    • 定期删除策略的缺点
      • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
      • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
Redis的过期删除策略:

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

惰性删除:

​ Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:

  • 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
  • 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;
定期删除:
  1. 从过期字典中随机抽取 20个 (默认值,可修改) key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

定期删除的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
do {
//已过期的数量
expired = 0
//随机抽取的数量
num = 20;
while (num--) {
//1. 从过期字典中随机抽取 1 个 key
//2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
}

// 超过时间限制则退出
if (timelimit_exit) return;

/* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4);

内存淘汰策略:

当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。

1、不进行数据淘汰的策略

  • noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。

2、进行数据淘汰的策略

​ 针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值。
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰整个键值中最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

可以使用 config get maxmemory-policy 命令,来查看当前 Redis 的内存淘汰策略

Redis中的LRU:

​ Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

Redis 实现的 LRU 算法的优点:

  • 不用为所有的数据维护一个大链表,节省了空间占用;
  • 不用在每次数据访问时都移动链表项,提升了缓存的性能;

但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

Redis中的LFU:

在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。

img

  • ldt 是用来记录 key 的访问时间戳;
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。

注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的

在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。

对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。

所以,Redis 在访问 key 时,对于 logc 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减;
  2. 然后,再按照一定概率增加 logc 的值

redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:

  • lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;
  • lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。

Redis持久化:

参考文章:

AOF:

​ redis将写操作命令以追加的方式写入AOF文件中,实现数据持久化(存放操作命令)。AOF日志文件中的内容是根据操作命令得出的。

为什么redis先执行写操作,再将命令记录到AOF日志中?

  • 这样可以避免一些语法错误的命令被写入,造成额外的检查开销
  • 并且,如果先执行写操作、再执行日志写入的话,咱们写操作这个流程就不会被写入日志这个操作影响,即不会阻塞当前写操作命令的执行

潜在风险:

  • 主机宕机时,未写入AOF日志文件中的记录信息丢失。
  • 虽然写入日志写操作后执行,但是 写入日志 这个操作会阻塞下一次写操作的执行。

Redis写入AOF日志的过程:

![image-20230929161854043](/Users/donn/Library/Application Support/typora-user-images/image-20230929161854043.png)

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据最终写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。

Redis提供了三种写回硬盘的策略:

  • Always:每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘(从内核缓冲区中写回硬盘);
  • Everysec:每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No:意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

理解起来也和简单:

​ 想要高性能->使用No策略

​ 想要高可靠->使用Always策略

​ 中庸->使用Everysec策略

🌟在OS学习过程中,我们知道:

当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。但是如果我们想要让某个写入硬盘的操作立即执行,我们可以主动调用fsync()函数来立即将数据写入硬盘

因此:

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

AOF重写机制:

​ 随着执行的写操作命令越来越多,AOF文件的大小会越来越大。

当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

这里我们需要解决两个问题:

1.为什么AOF重写能够减小AOF文件的大小?

​ 首先,我们知道AOF文件中都是对Redis数据库的写操作。

​ 其次,Redis中存储的数据值为最后一次写操作赋予的值。

​ 因此,对于AOF缓存中的写命令,我们只需要留下对每个key的最后一次写操作就可以实现数据的持久化了!

2.Redis是怎么实现AOF重写的?

​ Redis的 将命令写入日志 这一操作在Redis主进程完成,这是由于写入的只是几条命令,因此不会对主进程的其他操作造成太大影响。

​ 然而,AOF重写的过程需要将AOF日志中的所有语句都进行判断,写入到新的AOF文件中,这个操作比较耗时,因此将重写AOF的操作放在主进程中执行是不合理的。

Redis的解决方案:

​ Redis 的重写 AOF 过程通过执行redis命令bgrewriteaof 利用创建的子进程来实现,具体实现方式如下:

  • 子进程在重写AOF期间不会阻塞主进程
  • 这里为什么是子**进程而不是子线程**:
    • 因为如果是同一进程中的两个线程的话,线程之间会共享内存,那么在有一方要对共享数据进行修改时,就需要通过加锁的方式保证数据的一致性,降低了性能。
    • 如果使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
方案的具体实现:

​ 主进程通过 fork 系统调用生成子进程,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,然后让子进程读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

​ 现在,父子进程的页表中的内容相同,且父子进程对页表记录的物理内存的权限为「只读」,

​ 当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,会发生「写时复制

image-20231007092622299

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。(即,对于修改了的数据,才开辟新的空间存储,对于没有修改的数据,则指向原来的地址而不去重新复制一份)

1
2
3
某线程修改资源时,写时复制会发生什么:
首先,线程会在内存中为要修改的资源创建一个副本,而不是直接在原始资源上进行修改。这个副本是与原始资源共享内存空间的,并且初始状态与原始资源相同。
接下来,该线程可以在副本上进行修改操作,而无需担心其他线程的影响。这意味着在修改期间,其他线程仍然可以访问和读取原始资源,而不会被阻塞。

有两个阶段会导致阻塞父进程:

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;

重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

image-20231007094049811

也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;

当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
  • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

信号函数执行完后,主进程就可以继续像往常一样处理命令了。

在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。

RDB:

RDB 快照记录某一个瞬间的内存数据,记录的是实际数据。

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。

所以可以认为,执行快照是一个比较重的操作,如果频率太高,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

执行快照时,redis中的数据也能够被修改,靠的依然是写时复制技术(Copy-On-Write, COW)

父子进程共享页表,父进程若要修改,则复制一个副本后对副本进行修改。这样的话,RDB快照中的数据只能是开始执行复制时的Redis中原本的数据,中途修改的数据不会被记录,而是需要下一次保存RDB快照来记录。

在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。

所以,针对写操作多的场景,我们要留意下快照过程中内存的变化,防止内存被占满了。

RDB和AOF混合:

​ 尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

在 Redis 4.0 提出一种方法:混合使用 AOF 日志和内存快照,也叫混合持久化。

​ 当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

​ 使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

​ 这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

Redis高可用:

参考文章:

主从模式:

​ 与MySQL中的方案类似,主数据库可以读写、从数据库只读「读写分离」。

image-20231008145357993

主从复制运行原理:

​ 在从服务器上执行一下命令:replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>

​ 这样,该服务器的redis服务就成为服务器A的从服务器

image-20231008140330120

具体流程:

  • 从数据库启动成功后,连接主数据库,发送 SYNC 命令;
  • 主数据库接收到 SYNC 命令后,开始执行 BGSAVE 命令生成 RDB 文件使用缓冲区记录此后执行的所有写命令
  • 主数据库 BGSAVE 执行完后,向所有从数据库发送快照文件,并在发送期间继续记录被执行的写命令
  • 从数据库收到快照文件后丢弃所有旧数据,载入收到的快照;
  • 主数据库快照发送完毕后开始向从数据库发送缓冲区中的写命令
  • 从数据库完成对快照的载入,开始接收命令请求,并执行来自主数据库缓冲区的写命令;(从数据库初始化完成
  • 主数据库每执行一个写命令就会向从数据库发送相同的写命令,从数据库接收并执行收到的写命令(从数据库初始化完成后的操作
  • 由于主服务器与从服务器之间完成全量复制后,会维持一个TCP连接用于实现后续的增量复制,如果TCP连接由于网络波动出现断开重连的情况,Redis2.8之后的版本会将断线期间的命令传给从数据库,即仍使用增量复制来进行同步。
    • 这种网络断开的情况下,其实也是有增量复制、全量复制两种情况的:
      • redis对于写命令会有一个环形缓冲区,主、从服务器各有一个offset指针指向缓冲区中的某条指令,表示执行到了哪条,如果从服务器网络恢复时,其offset指针指向的位置在缓冲区中不存在(说明掉线太久了,环形缓冲区已经被覆盖一圈了,不能够再通过这个缓冲区来同步进度了,这时候会采用全量复制来同步数据;否则,会使用增量复制来同步数据。)
  • 主从数据库刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。Redis 的策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。

问题:从服务器数量较多:

如果从服务器较多,而且都与主服务器进行全量同步的话,就会带来两个问题:

  • 由于是通过 bgsave 命令来生成 RDB 文件的,那么主服务器就会忙于使用 fork() 创建子进程,如果主服务器的内存数据非大,在执行 fork() 函数时是会阻塞主线程的,从而使得 Redis 无法正常处理请求;
  • 传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。

我们可以构建树状结构的主从关系,让主服务器管理个别从服务器,再让这几个从服务器管理其他的从服务器,以达到分散主服务器压力的目的。

image-20231008150658511

主从复制优点:

  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离
  • 为了分散 Master 的读操作压力,Slave 服务器可以为客户端提供只读操作的服务,写服务仍然必须由Master来完成;
  • Slave 同样可以接受其它 Slaves 的连接和同步请求,这样可以有效的分散 Master 的同步压力;
  • Master Server 是以非阻塞的方式为 Slaves 提供服务。所以在 Master-Slave 同步期间,客户端仍然可以提交查询或修改请求
  • Slave Server 同样是以非阻塞的方式完成数据同步。在同步期间,如果有客户端提交查询请求,Redis则返回同步之前的数据;

主从复制缺点:

  • Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP(手动重新设置主数据库)才能恢复(也就是要人工介入);
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性;
  • 如果多个 Slave 断线了,需要重启的时候,尽量不要在同一时间段进行重启。因为只要 Slave 启动,就会发送sync 请求和主机全量同步,当多个 Slave 重启的时候,可能会导致 Master IO 剧增从而宕机。
  • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂;

哨兵模式(Sentinel 模式):

​ Redis 提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个 Redis 实例

​ 简单哨兵模式:

image-20231008141843134

​ 多哨兵模式:

image-20231008141935130

哨兵模式的工作方式:

​ 三步骤:监控、选主、通知

  • 每个Sentinel(哨兵)进程以每秒钟一次的频率向整个集群中的 Master 主服务器、Slave 从服务器以及其他Sentinel(哨兵)进程发送一个 PING 命令。
  • 如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel(哨兵)进程标记为主观下线(SDOWN)
  • 如果一个 Master 主服务器被标记为主观下线(SDOWN),则正在监视这个 Master 主服务器的所有 Sentinel(哨兵)进程要以每秒一次的频率确认 Master 主服务器的确进入了主观下线状态
  • 当有足够数量的 Sentinel(哨兵)进程(大于等于配置文件指定的值)在指定的时间范围内确认 Master 主服务器进入了主观下线状态(SDOWN), 则 Master 主服务器会被标记为客观下线(ODOWN)
    • 🌟
    • 标记为客观下线后,哨兵们发起投票,先投票选出一个Leader哨兵来处理本次故障转移的流程!
    • 选出Leader哨兵后,实行主从故障转移:
      • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。
      • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
      • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
      • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;
  • 在一般情况下, 每个 Sentinel(哨兵)进程会以每 10 秒一次的频率向集群中的所有 Master 主服务器、Slave 从服务器发送 INFO 命令。
  • 当 Master 主服务器被 Sentinel(哨兵)进程标记为客观下线(ODOWN)时,Sentinel(哨兵)进程向下线的 Master 主服务器的所有 Slave 从服务器发送 INFO 命令的频率会从 10 秒一次改为每秒一次。
  • 若没有足够数量的 Sentinel(哨兵)进程同意 Master主服务器下线, Master 主服务器的客观下线状态就会被移除。若 Master 主服务器重新向 Sentinel(哨兵)进程发送 PING 命令返回有效回复,Master主服务器的主观下线状态就会被移除。

哨兵模式可以看作自动版的主从复制,但是仍较难支持在线扩容。

总结:

1、第一轮投票:判断主节点下线

​ 当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。

当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。

2、第二轮投票:选出哨兵leader

​ 某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件:

  • 第一,拿到半数以上的赞成票;
  • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。
3、由哨兵 leader 进行主从故障转移

​ 选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤:

  • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则:
    • 过滤掉已经离线的从节点;
    • 过滤掉历史网络连接状态不好的从节点;
    • 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。
  • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
  • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
  • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

集群模式(Cluster 模式):

​ Cluster 集群模式,实现了 Redis 的分布式存储,每台 Redis 节点上存储不同的内容

image-20231008143654026

​ 在这个图中,每一个蓝色的圈都代表着一个 redis 的服务器节点。它们任何两个节点之间都是相互连通的。客户端可以与任何一个节点相连接,然后就可以访问集群中的任何一个节点。对其进行存取和其他操作。

集群的数据分片:

Redis 集群没有使用一致性 hash,而是引入了哈希槽【hash slot】的概念。

Redis 集群有16384 个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽。集群的每个节点负责一部分hash槽,举个例子,比如当前集群有3个节点,那么:

  • 节点 A 包含 0 到 5460 号哈希槽
  • 节点 B 包含 5461 到 10922 号哈希槽
  • 节点 C 包含 10923 到 16383 号哈希槽

这种结构很容易添加或者删除节点。比如如果我想新添加个节点 D , 我需要从节点 A, B, C 中得部分槽到 D 上。如果我想移除节点 A ,需要将 A 中的槽移到 B 和 C 节点上,然后将没有任何槽的 A 节点从集群中移除即可。由于从一个节点将哈希槽移动到另一个节点并不会停止服务,所以无论添加删除或者改变某个节点的哈希槽的数量都不会造成集群不可用的状态。

在 Redis 的每一个节点上,都有这么两个东西,一个是插槽(slot),它的的取值范围是:0-16383。还有一个就是 cluster,可以理解为是一个集群管理的插件。当我们的存取的 Key到达的时候,Redis 会根据 CRC16 的算法得出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点,然后直接自动跳转到这个对应的节点上进行存取操作。

Redis 集群 + 主从复制模型:

​ 注意:这里的主从模式并不完全是之前说的主从模式,而是单纯的将从节点作为主节点的备份,主节点如果没挂掉,就用不到从节点。

​ 为了保证高可用,redis-cluster集群引入了主从复制模型,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点。当其它主节点 ping 一个主节点 A 时,如果半数以上的主节点与 A 通信超时,那么认为主节点 A 宕机了。如果主节点 A 和它的从节点 A1 都宕机了,那么该集群就才被认为无法再提供服务。

故障检测:

一个节点认为某个节点宕机不能说明这个节点真的挂起了,无法提供服务了。只有占据多数的实例节点都认为某个节点挂起了,这时候cluster才进行下线和主从切换的工作。
Redis 集群的节点采用 Gossip 协议来广播信息,每个节点都会定期向其他节点发送ping命令,如果接受ping消息的节点在指定时间内没有回复pong,则会认为该节点失联了(PFail),则发送ping的节点就把接受ping的节点标记为主观下线。
如果集群半数以上的主节点都将主节点 xxx 标记为主观下线,则节点 xxx 将被标记为客观下线,然后向整个集群广播,让其它节点也知道该节点已经下线,并立即对下线的节点进行主从切换。

主从故障转移:

当一个从节点发现自己正在复制的主节点进入了已下线,则开始对下线主节点进行故障转移,故障转移的步骤如下:

  • 如果只有一个slave节点,则从节点会执行SLAVEOF no one命令,成为新的主节点。
  • 如果是多个slave节点,则采用选举模式进行,竞选出新的Master
    • 集群中设立一个自增计数器,初始值为 0 ,每次执行故障转移选举,计数就会+1。
    • 检测到主节点下线的从节点向集群所有master广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,所有收到消息、并具备投票权的主节点都向这个从节点投票。
    • 如果收到消息、并具备投票权的主节点未投票给其他从节点(只能投一票哦,所以投过了不行),则返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示支持。
    • 参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,如果收集到的选票 大于等于 (n/2) + 1 支持,n代表所有具备选举权的master,那么这个从节点就被选举为新主节点。
    • 如果这一轮从节点都没能争取到足够多的票数,则发起再一轮选举(自增计数器+1),直至选出新的master。
  • 新的主节点会撤销所有对已下线主节点的slots指派,并将这些slots全部指派给自己。
  • 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

集群的特点

  • 所有的 redis 节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。
  • 节点的 fail 是通过集群中超过半数的节点检测失效时才生效。
  • 客户端与 Redis 节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。

Redis缓存: