面试之Redis

序言

经过上次两篇的面试文章,这一次我打算总结一下redis,经过友友的意见,觉得文字太多图片太少,这一点是因为,我太懒了,很多场景我在自己脑子里面过了一遍,但是没有画出来,就造成了只有我知道。刚开始写文章其实我只是总结我所了解的重点,给自己做一个笔记,但是看到好多伙伴们都有看,加上我自己也经常找资料,很难找一个通俗易懂而且齐全的。所以后面我会改进尽量多添加图片,用简单的方式去写,方便理解和记忆!
ps:博主自己也是一个小白,是大自然拼拼凑凑的搬运工~

一、什么是Redis

Redis是一种基于内存的数据库,对数据的读写操作都是在内存中完成的,因此读写速度非常快,常用于缓存,消息队列,分布式锁等场景。
Redis提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。

二、为什么用Redis作为Mysql的缓存?

因为Redis具备高性能高并发两种特性

  • 高性能
    当我们第一次访问mysql中的数据时,会非常慢,因为是从硬盘上面读取的。将用户访问的数据缓存在Redis中,这样下一次再访问这些数据的时候就可以从缓存中获取了,操作Redis缓存就是直接操作内存,所以速度非常快。
  • 高并发
    单台设备的Redis的Qps是mysql的10倍,可以轻松破10w。所以Redis能够承受的请求远大于mysql,可以把部分数据转移redis,让用户直接去缓存,而不用去mysql。
    【redis高性能是因为redis是单线程和基于内存,redis高并发是因为redis使用了多路复用技术】

三、Redis数据库

Redis是一个键值对类型的数据库,其中的key是字符串对象,而value可以是字符串对象,也可以是集合数据类型的对象(list,hash等)。

Redis是怎么实现键值对数据库的呢?

Redis是使用了字典的思想,主要使用到哈希表保存所有键值对,哈希表最大好处就是让我们可以用O(1)的时间复杂度来快速的查找到键值对。

比如我们输入以下命令

set name zjx

redis解析完这个命令之后,定位但那么这个key对应的字段空间的字典,找到当前正在使用的哈希表,按照如下步骤完成键值对存储:

  1. 计算那么的hash值(假设为2)
  2. 按照算出来的值去寻找对应的位置(类似一个数组,数组中的元素叫做哈希桶)
  3. 查看对应的这个位置是否有元素,如果没有直接添加,反之追加到之前马哥元素的后面(也就是发生hash冲突时的连地址法)

Redis是怎么存储键值对的呢?

在这里插入图片描述
redisDB表示数据库结构,结构体里面指向了dict结构指针
dict结构体,存放量2个哈希表,平时只使用一张hash表,当发生rehash的时候会使用到两张表。(在后面会提到)
dictht结构体,hash表结构体,里面存放的是hash数组,数组中每一个元素是一个dictEntry结构体的指针。
dictEntry结构体,里面存放key和value的指针 都是redisObject,key只能指向string类型,value可以指向所有。
在redisobject中,type执行他们的数据类型,encoding,标识该对象使用了哪种底层的数据结构,ptr,指向底层数据结构的指针。
dictEntry结构体通常定义如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

key 是一个指向键的指针,这个键是一个 RedisObject。
v 是一个联合体,用于存储不同类型的值。在 Redis 的上下文中,v.val 通常用作指向值的指针,这个值也是一个 RedisObject。
next 是一个指向下一个 dictEntry 的指针,用于处理哈希冲突。
RedisObject 是 Redis 中用于表示所有数据的通用结构体。无论是字符串、列表、集合、有序集合还是哈希表,它们在内存中都是以 RedisObject 的形式存在的。

四、Redis数据类型的底层结构

在这里插入图片描述

string

根据上面的图片,我们可以知道string的低层是SDS。redis的底层是用C语言实现的,但是它没有直接使用C语言的*char字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS)的数据结构来表示字符串。

为什么Redis要自己封装SDS?

C语言的字符串数组

我们先看一下C语言字符串是什么样的。
C语言的字符串其实就是一个字符数组,即数组中每个元素是字符串的一个字符。
我们看一下c语言中的字符串的实现方式。

#include<stdio.h>
#include<string.h>
int main(){
	char a[] = "hello";
	int i = 0;
	while(a[i] != '\0'){
		printf("%c",a[i]);
		i++;
	}
	printf("\n");
	i=0;
	char b[]={'h','e','\0','l','l','o'};
	while(b[i] != '\0'){
		printf("%c",b[i]);
		i++;
	}
	return 0;
} 

从图中可以看到,我定义了两个字符数组a和b,但是在b中的一个字符为\0。这是为什么呢?
我们先来看一下这段代码的输出:
在这里插入图片描述
可以看到字符数组b遇到\0之后就停止输出了,并且也没有输出\0。
这是因为在c语言的字符数组中是以\0为结束的标识符的,尽管我们的字符数组a并没有手动添加\0,但是其实C语言在a字符串的结尾处默认添加了一个\0,为结束的标识符。也就是说在c语言中获取字符串时,如果遇到\0后就会停止操作,代表字符串结束。
获取字符串长度时,是通过指针的方式从第一个字符一个一个往后移动,遇到\0之后结束计算,也就是说获取字符串长度的时间复杂度为O(n)。
同时这样的规则也就代表了我们的字符串里面不能带有\0这个字符,否则字符串会提前结束,也就代表了不能保存图片,音频,视频这样二进制数据。
另外,C语言中字符串的操作函数不安全,容易造成缓冲区溢出、
这是什么意思呢? 下面举个例子
在这里插入图片描述
c语言的字符串是不会记录自身的缓冲区大小的,所以strcat函数假定程序员在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串的所有内容,而如果这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止。
并且,在strcat函数的内部,时间复杂度也非常高,需要先遍历字符串才能得到目标字符串的末尾。

经过上面的理解,我们可以得知c语言的字符串有以下几个缺点:

  • 不可以有‘\0’这样的字符出现,也就是不能存储图片,视频,音频等二进制数据。
  • 获取字符串长度的时间复杂度为O(n).
  • 容易造成数据溢出,导致程序终止。
redis的SDS。

在这里插入图片描述len:记录了字符串的长度。则获取字符串长度的时候只需要O(1)
alloc:分配给字符数组的空间长度。这样可以判断空间,减少时间复杂度,自动扩容,解决溢出问题。
flags:表示sds用的是那种类型。sds有不同类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。

可以节省空间,这5种类型的区别就在于,他们在数据结构中的len和alloc成员变量的数据类型不同,
之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。

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

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 attribute ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。

buf[]:字符数组,用来保存实际数据。这样解决了不可以保存二进制数据的问题。

List

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。
List 类型的底层数据结构是由双向链表或压缩列表实现的

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

链表节点结构设计

typedef struct listNode {
    //指向前置节点
    struct listNode *prev;
    //指向后置节点
    struct listNode *next;
    //节点的值
    void *value;
} listNode;

从结构体代码可以看出,这是一个双向链表
在这里插入图片描述

链表结构设计

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;

在这里插入图片描述

链表的优点和缺点

优点
  • 获取某个节点的前后节点都是O(1),且因为前后都可以指向null所以链表是无环的。
  • 获取头结点和尾结点都是O(1)。
  • 获取链表的节点数量也是O(1)。
  • 链表节点使用void*保存节点,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值;
缺点
  • 链表的每个节点之间的内存是不连续的,也就意味着不能利用cpu缓存,而数组可以。
  • 保存链表的每个节点都不仅只保存一个值,所以内存开销较大
    因此,在list对象在数据量较少的情况下,会采用[压缩列表]作为底层数据结构的实现,节省内存空间,并且是内存紧凑型的数据结构。

压缩列表

压缩列表最大的特点,就是它被设计成一种内存紧凑的数据结构,占用一块连续的内存空间,这样不仅可以利用cpu的缓存,而且会针对不同长度的数据,进行相应编码,这样就可以有效节省内存开销。

压缩列表结构设计

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。
在这里插入图片描述

  • zibytes:记录整个压缩列表占用内存的字节数;
  • zltail:记录压缩列表尾部节点距离起始地址有多少字节;
  • zllen:记录压缩列表包含的节点数量;
  • zlend:叶索列表的结束点,固定值0xFF(十进制255)。
    在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。
    压缩列表是按照每个元素的大小分配的内存,每个元素的大小可能是不一样的,而数组在一开始就规定好了类型,所以每个元素大小是一样的,这也就是为什么数组查询时O(1),而压缩列表不是的原因
    压缩列表节点entry结构是这样的:
    在这里插入图片描述
  • prevlen,记录了「前一个节点」的长度;
  • encoding,记录了当前节点实际数据的类型以及长度;
  • data,记录了当前节点的实际数据;
    当我们往压缩列表中插入数据时,压缩列表就会根据数据是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
    压缩列表里的每个节点中的prevlen属性都记录了[前一个节点的长度],而且prevlen属性的空间大小和前一个节点的长度值相关:
  • 如果前一个节点的长度小于254字节,那prevlen属性需要1字节的空间来保存这个长度值。
  • 如果前一个节点的长度大于等于254字节,那么prevlen属性需要5字节的空间来保存这个长度值。
    encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关:
  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码。
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码。
压缩列表的缺点

压缩列表不能存入太多太长的数据,因为他的查找复杂度比较高。除此之外还有一个问题。
压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起**「连锁更新」**问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
什么是连锁更新?
前面提到,压缩列表节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;
    现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。
    在这里插入图片描述
    这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。
    在这里插入图片描述
    e1 原本的长度在 250~253 之间,因为刚才的扩展空间,此时 e1 的长度就大于等于 254 了,因此原本 e2 保存 e1 的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。
    正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展… 一直持续到结尾。
    在这里插入图片描述

这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下…,
空间扩展操作也就是重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。
所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有连锁更新的问题。
因此,压缩列表只会用于保存的节点数量不多的场景,因为只要节点数量足够小,就算发生连锁更新,也可以接受。
Redis对压缩列表设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(redis3.2引入)和listpack(redis 5.0引入)。这两种数据结构的设计目的,就是尽可能的保存压缩列表节省内存的优势,同时解决压缩列表的连锁更新的问题
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

Hash

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

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构,最新 Redis 代码已将压缩列表替换成 listpack;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
    压缩列表在上面我们已经说过了,下面说一下hash表。

哈希表

哈希表是一种保存键值对(k-v)的数据结构。
哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。
哈希表的有点在于,它能以O(1)的复杂度快速查询数据。怎么做到的呢?
将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么哈希冲突的可能性也会越高。
解决哈希冲突的方式,有很多种。
Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。

哈希表结构设计

Redis哈希表结构

typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;  
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
} dictht;

在这里插入图片描述
哈希表节点结构:

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

dictEntry结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。

哈希冲突

哈希表实际上是一个数组,数组里多一个元素就是一个哈希桶。
当一个键值对的键经过 Hash 函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。
什么是哈希冲突呢?
当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。
为了解决哈希冲突,Redis使用的是链式哈希方法
链式哈希是怎么实现的?
实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。
不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。
要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。
接下来,看看 Redis 是如何实现的 rehash 的。

rehash

我们说Redis的哈希表结构设计时,Redis使用的是dictht 结构体来表示哈希表。在实际使用hash表时(还有刚开始我们在redis怎么存储键值对中提到的dict结构体),都是定义了一个dict结构体,在这个结构体里定义了两个哈希表ht[2]。
之所以定义了2个哈希表,是因为进行rehash的时候,需要用2个哈希表进行操作。
正常情况下我们都只使用哈希表1,而哈希表2并没有被分配空间。
当数据逐步增加,触发了rehash操作时,会发生下面过程:
给哈希表2分配空间,一般是哈希表1的两倍。
将哈希表1的数据迁移到哈希表2中。
迁移完成之后,哈希表1的空间会被释放,并把哈希表2设为哈希表1,新创建哈希表2,为下一次的扩展和收缩做准备。
但在迁移的过程中,存在一个很大的安全隐患,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
为了解决这个问题,Redis采用了渐进式rehash

渐进式rehash

渐进式rehash是为了解决大量数据拷贝,可能会造成redis阻塞,影响redis性能的情况。
渐进式rehash的含义也就是分多次迁移,不采用一次性迁移的操作。
渐进式rehash步骤如下:

  • 给哈希表2分配空间。
  • 在rehash进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间嗲呢,会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。具体的实现与对象中的rehashindex属性相关,「若是rehashindex 表示为-1表示没有rehash操作」。
    当rehash操作开始时会将该值改成0,在渐进式rehash的过程「更新、删除、查询会在ht[0]和ht[1]中都进行」,比如更新一个值先更新ht[0],然后再更新ht[1]。查找一个 key 的值的话,先会在ht[0]里面进行查找,如果没找到,就会继续到ht[1]里面进行找到。(ht[0]是哈希表1,ht[1]是哈希表2)
    而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证「ht[0]只减不增,直到最后的某一个时刻变成空表」,这样rehash操作完成。
rehash触发条件
  1. 负载因子超过阈值
  • Redis 中的负载因子(load factor)用于衡量哈希表的使用程度。负载因子的计算公式为:负载因子 = 哈希表已存储的节点数量 / 哈希表的大小 。
  • 当负载因子超过一定的阈值(默认情况下,Redis 中哈希表的负载因子大于 1 时),就会触发 rehash 操作来扩展哈希表的大小,以提高性能和减少冲突。
  1. 哈希表收缩
  • 当哈希表中的元素数量大量减少,并且负载因子低于某个较小的阈值(默认 0.1)时,Redis 会触发 rehash 操作来收缩哈希表,释放多余的内存空间。

例如,如果一个哈希表最初被创建时大小合适,但随着数据的删除,元素数量变得很少,此时负载因子就会降低。当低于收缩的阈值时,Redis 就会执行 rehash 来减小哈希表的大小,避免浪费内存。

Set

Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
Set 类型和 List 类型的区别如下:

  • List 可以存储重复元素,Set 只能存储非重复元素;
  • List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。
    Set 类型的底层数据结构是由哈希表或整数集合实现的:
  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
    哈希表在上面已经说过了,下面我们来看一下整数集合。

整数集合

整数集合本质上是一块连续的内存空间。

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如:

  • 如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
  • 如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
  • 如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;
    不同类型的 contents 数组,意味着数组的大小也会不同。
整数集合的升级

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级;就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。具体的升级过程如下:

  • 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  • 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  • 整数集合升级后就不会再降级,编码会一直保持升级后的状态。
    整数升级的好处
    整数升级的好处就是节省内存资源。
    如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。整数升级就可以避免这种情况。

Zset

Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
Zset 类型的底层数据结构是由压缩列表或跳表实现的(在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。):

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

跳表

skiplist也叫做跳跃表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
下面是zset的结构体

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

可以看到zset里面不仅使用了跳表,还使用了dict字典,也就是说,他同时使用了跳表和哈希表。

  • 哈希表:Zset 中的每个元素都存储在一个哈希表中,键是元素的 member,值是元素的 score。因此,给定一个 member,可以通过哈希表 O(1) 时间复杂度直接查找它的分值。

当你执行 ZSCORE key member 命令时,Redis 按如下步骤操作:
通过哈希表直接查找:Redis 首先会使用哈希表通过 member 快速查找对应的 score。这个查找操作的时间复杂度是 O(1)。
跳表不参与此操作:由于 ZSCORE 命令只是查询 member 的分值,不涉及按 score 查找或排序操作,因此跳表在这一步不参与查找。跳表主要用于处理按 score 范围查询、排序和排名等操作。

  • 跳表:跳表用于按分值排序,并允许快速范围查找、范围删除等操作。跳表中的每个节点保存着 member 和 score,并且节点按照 score 递增的顺序链接起来。
为什么这样设计?

Redis 的这种设计结合了哈希表和跳表的优点:
哈希表可以在 O(1) 时间内通过 member 查找到对应的 score,这是在 ZSCORE、ZADD 等命令中非常高效的操作。
跳表则可以在 O(logN) 时间内按 score 进行范围查找、范围删除等操作,这是在 ZRANGE、ZRANK、ZREM 等命令中非常有用的。

zset并没有直接在结构体中体现压缩列表,压缩列表是在Redis内部根据条件进行选择和管理的

跳表的结构设计
typedef struct zskiplist{
	// 表头结点和尾节点
	struct zskiplistNode *header, *tail;
	
	// 表中节点的数量
	unsigned int length;
	
	// 表中层数最大的节点的层数
	int level;
} zskiplist;

在这里插入图片描述
header:指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂度就为O(1)。
tail:指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就为O(1)。
level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内),通过这个属性可以再O(1)的时间复杂度内获取层高最好的节点的层数。
length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内),通过这个属性,程序可以再O(1)的时间复杂度内返回跳跃表的长度。
头结点为L0~L2三个指针,分别指向了不同层次的节点,每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点1、2、3、4、5;
  • L1 层级共有 3 个节点,分别是节点 2、3、5;
  • L2 层级只有 1 个节点,也就是节点 3 。
    举例:
    假设我们要查找节点4这个元素:
    在链表中,我们必须从头结点开始遍历,需要查找4次。
    在跳表中,我们可以从L2层级开始跳到3然后从3向后移动1位就到4,只需要查找2次。
    跳表有点类似二分查找,当数据量很大的时候,跳表的查找复杂度就是O(logN)。
    跳表节点结构体:
typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

在这里插入图片描述
Zset同时保存元素和元素的权值,在跳表节点结构体里就是ele和score变量。每个节点都有一个后向指针backward,指向前一个节点,目的是为了方便从跳表的尾结点开始访问节点,这样倒序查找时就会很方便。
在这里插入图片描述
查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。
    如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。
    举个例子
    根据上面的图,我们来查找元素d分值7的位置:
  1. 先从头结点最高层L2开始,L2指向的元素为c分值为6,分值6比7小,所以我们可以访问这个L2层的这个结点。
  2. 该结点的下一个结点是空节点,所以我们会往下一层到达L1层,L1的下一个结点分值是7,跳到这个结点,并且此时也正是我们的元素,所以查找结束。
跳表节点层数的设置

如果我们的元素所在层级全部都在level0或者第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。这种情况,我们查找的复杂度也达到了O(N),为了降低查询复杂度,我们就需要维持相邻结点数间的关系。
跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
在这里插入图片描述那怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢?
如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。
Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

quicklist(快速列表) – List底层数据结构(Redis3.2)

在Redis3.2之后List的底层数据结构就从双向链表+压缩列表的组合改为quicklist了。
在前面我们有说过压缩列表会产生连锁更新的风险,使得redis的性能下降,但是如果ziplist里面的元素够少的情况下,连锁更新的负担其实也能接受。所以有了下面quicklist的结构 。

quicklist的结构设计

// quicklist结构体
 typedef struct quicklist {
     //头节点指针
     quicklistNode *head;
     //尾节点指针
     quicklistNode *tail;
     //所有ziplist的entry的数量
     unsigned long count;
     // ziplists总数量
     unsigned long len;
     //ziplist的entry上限,默认值-2
     int fill:QL_FILL_BITS;
     //首尾不压缩的节点数量
     unsigned int compress:QL_COMP_BITS;
     //内存重分配时的书签数量及数组,一般用不到
     unsigned int bookmark_count:QL_BM_BITS;
     quicklistBookmark bookmarks[];
 } quicklist;

quicklistNode的结构设计

// quicklistNode结构体
 typedef struct quicklistNode {
     //前一个节点指针
     struct quicklistNode *prev;
     //下一个节点指针
     struct quicklistNode *next;
     //当前节点的ZipList指针
     unsigned char *zl;
     //当前节点的ZipList的字节大小
     unsigned int sz;
     //当前节点的ZipList的entry个数
     unsigned int count:16;
     //编码方式:1,ZipList; 2,zf压缩模式
     unsigned int encoding:2;
     //数据容器类型(预留):1,其它;2,ZipList
     unsigned int container:2;
     //是否被解压缩。1∶则说明被解压了,将来要重新压缩
     unsigned int recompress:1;
     unsigned int attempted_compress:1;      //测试用
     unsigned int extra:10;                  /*预留字段*/
 } quicklistNode;

在这里插入图片描述
从上面可以看出quicklist(快速列表)的每一个节点都是一个压缩列表,每个压缩列表都比较短,这样就一定程度上的缓解率连锁更新的问题。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

listpack – 代替压缩列表(Redis5.0)

在hash、Zset里面我们都使用了压缩列表作为数据结构,但在redis6.2版本之后,就粗了listpack来代替压缩列表,并且解决了连锁更新的问题。那么listpack是怎么做的呢?

listpack的结构设计

在这里插入图片描述
tot-bytes:也就是 total bytes,占用 4 字节,记录 listpack 占用的总字节数。
num-elements:占用 2 字节,记录 listpack elements 元素个数。
elements:listpack 元素,保存数据的部分。
listpack-end-byte:结束标志,占用 1 字节,值固定为 255。
encoding-type:元素的编码类型,会不同长度的整数和字符串编码。
element-data:实际存放的数据。
element-tot-len:encoding-type + element-data 的总长度,不包含自己的长度。
每个 element 只记录自己的长度,不像 ziplist 的 entry,记录上一项的长度。当修改或者新增元素的时候,不会影响后续 element 的长度变化,解决了连锁更新的问题。

五、Redis为什么那么快?

  1. 基于内存
  2. 使用高效的数据结构
  3. 处理客户端的请求(即执行命令)时,是单线程去执行的。
  4. 多路复用

六、Redis是单线程吗?

Redis是单线程还是多线程这个问题是面试的常考题啦,很多时候我们会认为redis是单线程的。但其实不是。
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
Redis程序并不是单线程的,Redis在启动的时候,是会启动后台线程(BIO)的:

  • Redis2.6版本,会启动2个后台线程,分别处理关闭文件,AOF刷盘这两个任务。
  • Redis在4.0版本之后,新增了一个后台线程,用来异步释放Redis的内存,也就是lazyfree线程。当我们执行unlink key(异步删除一个键)、flushdb async(异步清空当前数据库的所有键)、flushall async(异步清空所有数据库的所有键)等命令,会把这些操作拿给后台线程去执行,就不会导致Redis主线程卡顿。因此,当我们删除一个大key的时候,不要使用del命令删除,del是在主线程处理的,会导致Redis主线程卡顿,我们要使用unlink命令来异步删除大key。
    之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
    后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
    在这里插入图片描述关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
  • BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
  • BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
    Redis 采用单线程为什么还这么快?
    官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:
    之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
  1. Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  2. Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  3. Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。

  • Redis 6.0 之后为什么引入了多线程?
    虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
    所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
    Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
    Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
io-threads-do-reads yes 

同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。

// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4 

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):
Redis-server : Redis的主线程,主要负责执行命令;
bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

七、Redis持久化

Redis的读写操作都是在内存中,所以Redis性能才会高,但是当Redis重启后,内存中的数据就会丢失,为了保证内存中的数据不会丢失,Redis就实现了数据持久化的机制,这个机制会把Redis在内存中的数据存储到磁盘,这样Redis重启就能够从磁盘中恢复原有的数据。
Redis共有三张数据持久化方式:
AOF日志: 每执行一条写操作命令,就把该命令以追加的方式写入一个文件里。
RDB快照: 将某一时刻的内存数据,以二进制的方式全部写入磁盘。
混合持久化方式 Redis4.0新增的方式,集成了AOF和RDB的优点。
Redis默认开启的是RDB快照。

AOF日志是怎么实现的?

当Redis执行一条命令之后,就会把该命令以追加的方式写入一个文件里,然后Redis重启的时候,就会读取该文件的命令,然后逐一执行文件的命令来进行数据恢复。
注意只会记录写操作命令,读操作是不会被记录的,因为没意义。
在Redis中AOF持久化功能默认是不开启的,如果需要开启,可以修改redis.conf配置文件中的

appendonly yes                 //是否开启aof 默认是no
appendfilname "appendonly.aof" //aof持久化名称

在这里插入图片描述

在AOF日志中
*3代表这个命令一共有三部分
$3代表这个不分的长度为3字节
为什么先执行命令,再把数据写入日志呢?
优点:

  1. 避免额外的错误,如果先写入AOF日志,再执行命令的话,假设当前的命令有问题,就会没有进行语法检测然后直接记录在日志中,在Redis使用日志恢复数据的时候就可能会出错。

  2. 不会阻塞写操作,因为是先写操作执行成功之后,才会将命令记录到AOF日志。
    缺点:

  3. 数据可能会丢失:如果在写操作完成之后,redis宕机,还没有写入aof日志就会丢失这条数据。

  4. 可能阻塞其他操作:写操作命令执行成功和记录日志是两个过程,所以不会阻塞写命令,但是aof日志也是在主线程中执行的,所以当redis把日志写入磁盘的时候,还是会阻塞后续的操作无法执行。[给下一个命令带来阻塞风险]
    认真分析一下,其实这两个风险都有一个共性,都跟「 AOF 日志写回硬盘的时机」有关。
    Redis写入AOF的过程
    在这里插入图片描述

内核缓冲区的数据什么时候写入硬盘,由内核决定
AOF的刷盘机制(内核写回硬盘的策略):
在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  1. Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  2. Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  3. No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
    在这里插入图片描述如果要高性能,就选择 No 策略;
    如果要高可靠,就选择 Always 策略;
    如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。

AOF重写机制

AOF日志是一个文件,随着写操作越来越多,我们的文件也会越来越大。重启Redis时,需要读取AOF文件的内容,当AOF文件过大,整个恢复过程就会变慢。
所以,为了避免这种情况,提供了AOF重写机制,当AOF的文件大小超过所设定的阈值之后,Redis就会启用该机制,来压缩AOF文件。
AOF重写机制在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到【心得AOF文件】,等到全部记录完后,就将心得AOF文件替换掉现有的AOF文件。
举个例子:
在没有使用重写机制前,假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令的话,就会将这两个命令记录到 AOF 文件。
在这里插入图片描述
但是在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。
重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。
之后,在通过AOF日志恢复数据时,只用执行这条命令,就可以直接完成这个键值对的写入了。
重写机制的妙处就在于,不管一个键值被修改多少次,最终也只需要根据最新的状态,用一条命令去记录,代替之前的一堆命令,从而减小AOF文件的体积。
为什么重写AOF时不复用当前的AOF文件,而是用新的AOF文件?
因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。
所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。

AOF后台重写

写入AOF日志的操作是在主线程中完成的,因为它写入的内容不多,所以一般不太影响命令的操作。
但是AOF的重写不一样,当AOF文件大于64M时,就会对AOF文件进行重写,这是就需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后写入新的AOF文件,写完之后,替换掉当前的AOF文件。这一个过程可以看出是非常耗时的。所以重写的操作不能放在主进程里。
Redis的重写AOF过程是由子进程bgrewriteaof来完成的
使用子进程的好处:

  • 子进程进行AOF重写期间,子进程可以继续处理命令请求,从而避免阻塞主进程;
  • 子进程带有主进程的数据副本(数据副本是什么,怎么产生的后面会说),这里使用的是子进程而不是线程,因为如果是线程的话,多线程之间会共享内存,如果修改共享内存数据的时候,需要通过加锁来保证数据的安全,这样就会降低性能。而使用子进程,创建子进程的时候,父进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生【写时复制】,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
    子进程是怎么拥有主进程一样的数据副本的呢?
    在linux中,所有的进程都是没有办法直接操作我们的物理内存(真正的存储位置)的,而是有操作系统给每一个进程分发一个虚拟进程,进程只能操作虚拟进程,操作系统会维护一个物理内存和虚拟内存的一个映射表,这个表称之为页表。
    (ps:下面两张图的aof和rdb是一样的过程)
    在这里插入图片描述
    在子进程进行读取物理内存的数据时,是不影响主进程处理用户请求的,但是这时会出现一个问题,就是在子进程读取数据的同时,主进程操作了数据,会不会造成读脏数据呢?
    为了解决这样的情况
    fork采用的是copy-on-write(写时复制)技术:
  • 当主进程执行读操作时,访问共享内存;
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。
    -
    触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。
    但是子进程重写过程中,主进程依然可以正常处理命令。
    如果此时主进程修改了已经存在的k-v,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改的物理内存还是子进程共享的。
    所以如果这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。
    还有个问题,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?
    为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
    在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
    在这里插入图片描述
    也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;
    当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
    主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
  • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
    信号函数执行完后,主进程就可以继续像往常一样处理命令了。
    在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。
    https://cloud.tencent.com/developer/article/1843141

RDB快照

Redis默认开启的持久化机制就是RDB快照,所谓的快照就是记录某一个瞬间的东西,RDB快照也就是记录某一瞬间的内存数据,记录的是实际数据,而AOF文件记录的是命令操作日志,而不是实际数据。
因此,Redis恢复数据时,RDB的回复数据的效率会比AOF高,因为RDB文件可以直接读入内存,AOF还需要额外的操作命令步骤才能恢复数据。
快照怎么使用?
Redis里面提供了两个命令来生成RDB文件,分别是save和bgsave,他们的区别就在于是否在主进程里面执行:

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

RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。
Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsava 命令,默认会提供以下配置:

save 900 1
save 300 10
save 60 10000

别看选项名叫 sava,实际上执行的是 bgsava 命令,也就是会创建子进程来生成 RDB 快照文件。
只要满足上面条件的任意一个,就会执行 bgsava,它们的意思分别是:
900 秒之内,对数据库进行了至少 1 次修改;
300 秒之内,对数据库进行了至少 10 次修改;
60 秒之内,对数据库进行了至少 10000 次修改。
值的注意的是,Redis的快照是全量快照,也就是说每次执行快照,都是把内存中的【所有数据】都记录到磁盘中。
也就是说,执行快照是一个比较重的操作,如果操作太频繁,会对Redis的性能参赛影响,但是如果频率太低,如果Redis服务发生故障就会丢失大量数据。
通常可能会设置5分钟才保存一次快照,这时如果Redis出现宕机情况,就意味着最多丢失5分钟的数据。
这是RDB快照的缺点,在服务器发送故障时,丢失的数据会比AOF持久化丢失的更多,因为AOF可以以秒级的方式记录命令,所以相对于来说丢失的数据会更少。
执行快照时,数据能被修改吗?
这就提到了我们上面在AOF重写中说到的写时复制了
但是和AOF有一点不一样的地方是在于,在子进程bgsave时,主进程修改的那条数据该怎么办?会像AOF一样有一个AOF重写缓冲区吗?
bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。
所以 Redis 在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程的内存数据和子线程的内存数据已经分离了,子线程写入到 RDB 文件的内存数据只能是原本的内存数据。
如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
写时复制出现的极端
假设我们的子进程在重新写RDB文件时特别慢,慢到我们的主进程把原来的数据全部都修改了一次,那也就是说,此时我们的数据占据内存的空间就翻倍了,如果我们占据内存16g,那翻倍就是32g,所以我们在分配内存给redis时,不能把服务器的内存全部分给redis,要不然做RDB时,可能会溢出。

混合持久化方式

经过了解了上面的AOF和RDB,我们可以知道AOF和RDB都有自己的缺点与优点:
RDB快照:数据恢复快,但是每次快照是整个redis的内存的记录,如果太过于频繁,会影响Redis性能;按时如果频繁太低,两次快照之间如果发生宕机,就会丢失太多的数据。
AOF日志:数据恢复较慢,因为需要执行每一行的命令,且日志太大需要重写,但是每秒执行一次数据丢失较少。
混合持久化方式就是将RDB和AOF结合使用,充分利用他们的优势。
开启混合持久化功能

aof-use-rdb-preamble yes

什么是混合持久化?

  • 混合持久化就是在AOF持久化的基础上,定期进行RDB持久化,以保证数据的快速恢复。
  • 混合持久化的实现方式就是在AOF重写时,将RDB文件以二进制格式写入到AOF文件的开头,之后再以AOF格式追加到文件的末尾。
  • 混合持久化的优点:
  1. 可以减少AOF文件的大小,节省磁盘空间
  2. 就快数据的恢复,避免执行大量的AOF命令
  3. 减少数据丢失
    混合持久化中AOF日志重写过程
    当开启混合持久化之后,在AOF重写日志的时候,fork出来的重写子进程会笑将主线程共享的内存数据以RDB方式写入AOF文件中,然后主线程处理的操作命令记录会被记录在重写缓冲区,重写缓冲区里的命令会以AOF方式写入到AOF文件中,写入完成之后通知主进程将心得含有RDB格式和AOF格式的AOF文件替换旧的AOF文件。
    前半部分是RDB格式的全量数据,后半部分是AOF格式的增量数据。
    在这里插入图片描述
    使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
    这样的好处就在于,重启Redis加载数据的时候,前半部分是RDB内容,这样加载的速度会很快,加载完RDB的内容后,才会加载后半部分的AOF内容,这里的内容是Redis后台子进程重写AOF期间,主线程处理的操作命令,可以使得数据更少的丢失。

八、Redis过期删除与内存淘汰

Redis的过期删除策略是什么?

我们知道Redis是可以对key设置过期时间的,因此需要有相对应的机制来将已过期的键值对删除掉,而做这个工作的就是过期键值删除策略。
每当我们对一个key设置了过期时间,Redis会把该key带上过期时间存储到一个过期字典中,也就是说【过期字典】中保存了数据库中有过期时间的key和该key的过期时间。
当我们查询一个key的时候:
Redis首先会检查该key是否存在于过期字典中

  1. 如果不在,则正常读取键值。
  2. 如果存在,则会获取该key的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判断该key已过期。
    Redis使用的过期策略是【惰性删除+定期删除】这两种策略和使用。

什么是惰性删除策略?

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问key时,都检测key是否过期,如果过期则删除该key。
在这里插入图片描述
惰性删除策略的优缺点:
优点:

  • 每次访问时,才会检查key是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对cpu时间最友好。
    缺点:
  • 如果一个key已经过期,而这个key又仍然保留在数据库中,那么只要这个过期的key一直没有被访问,那他就一直占着内存不会释放,当大量的过期key都没有被访问就会导致redis的内存空间大量浪费,甚至内存溢出。为了避免这种情况,Redis还采用了定期删除策略。

定期删除策略

定期删除策略的做法是,每隔一段时间【随机】从数据库中取出一定数量的key进行检查,并删除其中的过期key。
Redis定期删除的流程:

  1. 从过期字典中随机取20个key;
  2. 检查这20个key是否过期,并删除已过期的key;
  3. 如果本轮检查的已过期的key的数量超过5个(1/4),也就是【已过期key的数量】占比【随机抽取key的数量】大于25%,则继续重复步骤1;如果已过期的key比例小于25%,则停止继续删除过期key,然后等待下一轮再检查。
  4. 可以看到,定期删除是一个循环的流程。那Redis为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增强了定期删除循环流程的时间上线,默认不会超过25ms。
    在这里插入图片描述
    定期删除策略的优缺点:
    优点:
  • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
    缺点:
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

Redis的内存淘汰策略

上面说的过期删除策略,针对的是删除过期的key,而当Redis的运行内存已经超过Redis设置的最大内存之后,则会使用内存淘汰策略删除符合条件的key,以此来保障Redis高效的运行。
Redis的终于打运行内存可以在redis.conf中通过maxmemory 来设定。
Redis内存淘汰策略有哪些?
Redis内存淘汰策略共有八种,这八种策略大体分为【不进行数据淘汰】和【进行数据淘汰】两种策略。

  1. 不进行数据淘汰策略
    noeviction(Redis3.0之后,默认的内存淘汰策略):它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
  2. ** 进行数据淘汰的策略**
    在进行数据淘汰的策略里面,又分为了在设置了过期时间的数据中进行淘汰在所有数据范围进行淘汰这两类策略。
    在设置了过期时间的数据中进行淘汰:
  • volatile-random:随机淘汰设置了过期值的任意键值。
  • volatile-ttl:优先淘汰更早设置过期时间的键值。
  • volatile-lru:在设置过期时间的键中,淘汰最久没有使用的键值。(最近没有使用的)
  • volatile-lfu:在设置过期时间的键中,淘汰最少使用的键值。(使用频率最小的)
    在所有数据范围进行淘汰:
    allkeys-random:随机淘汰任意键值。
    allkeys-lru:淘汰最久没有使用的键值。
    allkeys-lfu:淘汰最少使用的键值。

LRU和LFU的区别

什么是LRU?

LRU(Least Recently Used )翻译为最近最少使用过的,也就是会选择最长时间没有被使用过的键值进行淘汰。
传统的LRU算法的视线是基于链表结构,链表中的元素是按照操作顺序从前往后排的,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。
Redis并没有使用这样的方式去实现LRU,因为传统的LRU算法存在两个问题:

  1. 需要用链表管理所有的缓存数据,这样会带来额外的空间开销。
  2. 当数据被访问时,需要在链表上把该数据移到头端,如果有大量的数据被访问,就会带来很多链表的移动操作,产生很多耗时,进而降低Redis的缓存性能。
Redis是如何实现LRU算法的?**

Redis实现的是一种近似LRU的算法,目的是为了更好的节约内存,它的实现方式是在Redis的对象结构体中添加一个额外的字段,用来记录该数据最近一次被访问的时间。
当Redis进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是在LRU维护的一个候选池中随机取几个值(这个值可以配置),然后淘汰最久没有使用的那个。
Redis实现的LRU算法的优点:

  1. 不用为所有数据维护一个大链表,节约了空间占用。
  2. 不用在每次数据访问时都移动链表项,提升了缓存的性能。
    但是LRU算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在Redis中很长一段时间,造成缓存污染。
    因此,在Redis4.0之后引入了LFU算法来解决这个问题。

长时间留存:由于 LRU 算法的特性是优先淘汰最近最少使用的数据,而在这种情况下,这些只被读取一次的数据在被读取后成为了 “最近使用” 的数据。在后续没有再次被访问的情况下,它们会在缓存中停留很长一段时间,因为只有随着时间推移,有更多新的数据被访问,它们才可能逐渐在 LRU 算法的作用下被视为相对不常使用的数据而被淘汰。
缓存污染:这些只使用一次的数据长时间占据缓存空间,使得真正可能被频繁使用的数据无法进入缓存或者被过早淘汰,从而降低了缓存的效率和有效性,这就造成了缓存污染。

什么是LFU?

LFU(Least Frequently Used)为最近不常使用的,也就是使用频率最少的。是根据数据访问次数来淘汰数据的,它的核心思想就是,如果数据过去被访问多次,那么将来被访问的频率也会很高。
所以LFU算法会记录每个数据的访问次数,放一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
Redis是如何实现LFU算法的?
Redis是在对象结构中,多记录了【数据的访问频次】的信息

typedef struct redisObject {
    ...
      
    // 24 bits,用于记录对象的访问信息
    unsigned lru:24;  
    ...
} robj;

Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。
在这里插入图片描述

  • ldt 是用来记录 key 的访问时间戳;
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。
    注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的。
    在每次key被访问的时候,会先对logc嘴一个衰减操作,衰减操作的值和前后访问时间差有关,如果两次访问之间时间差大,那衰减的值就越来,也就是说LFU算法是根据访问频率来淘汰的,并不是根据访问次数,需要考虑被访问的时间差。做完衰减,就会根据访问次所来进行增加,也就是说,Redis在访问key时,logc的变化如下:
  1. 先按照上次访问距离当前的时长,来对logc进行衰减;
  2. 再按照一定概率增加logc的值
  3. 这两个值都能自己配置
LRU和LFU策略选择的方式

LRU 策略的选择方式
LRU 策略在实现近似算法时,会维护一个候选池,当需要进行内存淘汰时,从候选池中随机选取一些键进行比较,选出最近最少使用的键进行淘汰。
LFU 策略的选择方式
LFU 策略会为每个键维护一个访问频率计数器,当键被访问时,频率计数器会根据一定的规则增加。在进行内存淘汰时,Redis 会综合考虑键的访问频率以及时间因素(为了避免旧的低频数据一直留在内存中)来选择访问频率最低的键进行淘汰。它不是通过随机选取几个键来比较的方式,而是基于更精确的频率统计和算法规则来确定要淘汰的键。
Redis 中的 LFU 策略不是像 LRU 一样随机获取几个再删除。

九、Redis缓存设计

通常我们为了保证缓存中的数据与数据库中的数据一致性,会给Redis里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务就需要去数据库里面获取数据,并将数据重新更新到Redis里面,这样后续请求都可以直接命中缓存。
在这样的业务过程中,常常会出现一些问题,比如说缓存穿透,击穿,雪崩。下面我们就分别看一下这个几个问题。

缓存穿透

缓存穿透就是,一个请求请求的key在缓存里面没有并且数据库也没有,也就是访问一个不存在的key。当大量的请求请求这个不存在的key时,就会全部打进数据库,从而造成数据库的压力,这就叫缓存穿透。
发送缓存穿透的情况

  • 业务上面的操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据。
  • 被恶意攻击,故意大量访问某些读取不存在数据的业务。
    怎么解决缓存穿透?
  • 限制非法的请求,也就是判断请求的参数是否合理,不合理就直接打回,避免访问缓存和数据库
  • 设置空值或者默认值,也就是说,当第一个请求发送缓存和数据库都不存在的key值时,设置一个默认值或者空值在缓存,之后的请求在访问时,就不会进入数据库。
  • 使用布隆过滤器来快速判断数据是否存在【判断不存在就一定不存在,判断存在不一定存在】。

我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

什么是布隆过滤器?布隆过滤器时怎么工作的呢?
布隆过滤器是由【初始值都为0的位图数组】和【N个哈希函数】两部分组成。
布隆过滤器呢采用了二进制的格式,有点像签到表,使用一个bitmap的格式,每位的值就只有0和1,0代表不存在,1代表存在,这样的数据结构,也就奠定了使用布隆过滤器,只占用很少的内存空间,布隆过滤器的核心采用的是hash算法,利用hash函数来计算,有几个hash函数,就计算几次。
比如说,我们现在布隆过滤器里面有三个hash函数,现在查找baidu这个字符串存不存在
布隆过滤器是由一个固定大小的二进制向量或者位图(bitmap)和一系列映射函数组成的。
对于长度为 m 的位数组,在初始状态时,它所有位置都被置为0,如下图所示:
在这里插入图片描述1. 现在有一个元素进入布隆过滤器,先利用过滤器里面的三个hash函数,分别对元素进行计算,得到相对应的hash值
在这里插入图片描述
2. 接着进入了一个新元素到布隆过滤器

此时我们可以看到,下标为5的数组位置被重复标记了,由此我们可以知道
布隆过滤器不支持计数,同一个元素可以多次插入,但效果和插入一次相同
也可以猜想到,当数组被填写的越来越满之后,布隆过滤器是会存在判断失误的,也就是说,布隆过滤器判断出元素存在的情况时元素并不一定存在,但是当布隆过滤器判断元素不存在时,元素一定不存在
在这里插入图片描述

3.查询元素

对给定元素再次进行相同的哈希计算
得到哈希值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值存在布隆过滤器当中,如果存在一个值不为 1,说明该元素不在布隆过滤器中

在这里插入图片描述
例如我们查询 “cunzai” 这个值是否存在,哈希函数返回了 1、5、8三个值
结果得到三个 1 ,说明 “cunzai” 是有可能存在的。
为什么说是可能存在,而不是一定存在呢?主要分为以下几种情况:
因为映射函数本身就是散列函数,散列函数是会有碰撞的情况发生。
情况1:一个字符串可能是 “chongtu” 经过相同的三个映射函数运算得到的三个点跟 “xinlang” 是一样的,这种情况下我们就说出现了误判
情况2: “chongtu” 经过运算得到三个点位上的 1 是两个不同的变量经过运算后得到的,这也不能证明字符串 “chongtu” 是一定存在的
在这里插入图片描述
鉴于上面的情况,不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整哈希函数。
布隆过滤器判定某个元素存在,小概率会误判;布隆过滤器判定某个元素不在,则这个元素一定不在。

缓存击穿

缓存中的某个热点数据过期了(比如秒杀场景),这时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
怎么解决缓存击穿?

  • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能后驱互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值和默认值。
  • 不给热点数据设置过期时间,设置一个逻辑时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存雪崩

缓存雪崩就是大量数据在同一时间过期或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
怎么解决缓存雪崩?

  1. 大量数据同时过期:
    设置不同的过期时间,添加随机值,避免造成同一时间一起过期
  2. Redis故障宕机:
  • 服务熔断机制

暂停业务应用对缓存服务的服务,直接返回错误,不再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常访问,等到Redis恢复正常之后,在允许业务应用访问缓存服务。

【服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作】

  • 请求限流机制

为了减少对业务的影响,我们可以使用请求限流,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等Redis恢复正常并把缓存预热之后,再解除请求限流机制。

  • 构建Redis高可用集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

如何设计一个缓存策略,可以动态缓存热点数据呢?

由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。
热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。
以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:
先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;
同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;
这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回(hash)。
在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

具体实现方案:

  1. 使用有序集合存储商品的访问时间
    在Redis中,我们可以使用zset数据结构来存储商品的访问时间。每次用户访问某个商品时,我们将该商品的ID和当前时间戳存入zset中,并根据时间戳来对商品进行排序
  2. 获取Top 1000 热点商品
    我们可以通过Redis的zrange方法获取访问时间排名前1000的商品。这些商品可以视为当前的热点商品。
  3. 过滤并替换队列中的数据
    系统会定期(例如每天)过滤掉zset中排名最后的200个商品,然后从数据库中随机读取200个商品并将其加入到zset中。这样可以保证zset中总是存储了最近访问频率最高的商品,同时也有一些随机加入的商品,避免数据局部化问题。
  4. 读取缓存中的商品详情
    当用户请求商品详情时,系统首先检查该商品是否在热点商品列表(zset的前1000名)中。如果在,则从另一个缓存数据结构(如hash)中读取商品详情;如果不在,则直接从数据库读取,并可能将该商品重新加入到zset中。
    通过上述步骤,我们设计了一个动态缓存策略,能够根据商品的访问频率实时调整缓存内容,确保系统只缓存最热门的Top 1000商品。同时,通过定期更新和随机替换机制,避免了缓存数据的局部化和陈旧问题。这种策略可以有效提高缓存命中率,减少数据库的访问压力,提升系统性能。

缓存一致性问题

先更新数据库,再更新缓存

当请求A和请求B同时更新一条数据时(并发),可能会出现这样的顺序。
也就会造成Redis和数据库数据不一致的问题。
先更新数据库,再更新Redis

先更新缓存,再更新数据库

依然还是存在并发问题,分析的思路也是一样的,
当请求A和请求B同时更新一条数据时(并发),可能会出现这样的顺序。
也就会造成Redis和数据库数据不一致的问题。
在这里插入图片描述
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
我们可以尝试,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
删除一个数据,相比更新一个数据更加轻量级,出问题的概率也就更小。

先删除缓存,再更新数据库

请求A要把值从1更新为2,在更新的同时,请求B来获取这个值,可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。
在这里插入图片描述

先更新数据库,再删除缓存

可以看出在这种情况下,也会出现数据不一致的问题
在这里插入图片描述

先删缓存,再写数据库,再删缓存(缓存双删)

解决了上面的先删除redis再写入数据库的数据不一致问题
在这里插入图片描述
怎样才能保证请求A是在最后删缓存的呢?
1.让请求A多等待一会在执行 这个方法我觉得不太好
2.我觉得利用消息队列,一定时间以后回去查看一下,重试

先写数据库,再异步更新Redis

监听mysql,当对mysql进行操作之后,就会通过异步更新redis
在这里插入图片描述
这和先写数据库,再写redis的区别是什么呢?
因为消息队列会重试,所以会保证最终的redis和数据库的数据一致,但是中途请求b去获取redis也可能会读取脏数据
结论:保证最终一致,但是不能保证实时性

十、Redis高可用

在之前的AOF和RDB,这两个持久化技术保证了即使服务器重启的情况下也不会丢失数据(或者少量损失)。
不过,由于数据都是存储在一台服务器上,
如果服务器发送宕机(也就是我们在缓存雪崩那块有提到),由于数据恢复是需要点时间的,那么这个期间是无法服务新的请求的;
如果这台服务器的硬盘出现了故障,可能数据就都丢失了。
对于这些单点故障,我们想要解决它,实现高可用,就可以采用集群的思想。

Redis主从复制

搭建主从集群,主要是提高了我们Redis的并发能力,实现读写分离。

什么是读写分离呢?

读写分离的思想就是使用主节点来进行写操作,从节点来进行读操作。

  • 如果是写操作,应该访问master节点,master会自动将数据同步给两个slave节点
  • 如果是读操作,建议访问各个slave节点,从而分担并发压力
    在这里插入图片描述
    也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从的数据是一致的。

主节点的数据怎么同步给从节点呢?

多台服务器之间要通过什么方式来确定谁是主服务器,或者谁是从服务器呢?
我们可以使用replicaof(Redis5.0之前使用salveof)命令形成主服务器和从服务器的关系。
比如,现在有服务器A和服务器B,我们在服务器B上面执行这条命令:

replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>

接着,服务器B就会变成服务器A的从服务器,然后与主服务器进行第一次同步
全量同步
主从服务器间的第一次同步过程可分为三个阶段:
1.第一阶段是建立连接,协商同步;
2.第二阶段是主服务器同步数据给从服务器;
3.第三阶段是主服务器发送新写操作命令给从服务器。
在这里插入图片描述
当建立了一个从节点,第一次的数据同步,我们称为全量同步。
1.第一阶段是建立连接,协商同步;

执行replicaof命令之后,从服务就会给主服务发生psync命令,表示要进行数据同步。
psync命令里包含两个参数,分别是主服务器的runID和复制进度偏移量offset。

  • runID,每个Redis服务器在启动时都会自动生成一个随机唯一ID来标识自己。当从服务器和主服务器要第一次同步时,并不知道主服务器的runID,所以设置为了“?”。
  • offset,表示复制的进度(标志着复制在哪个位置了),第一同步时,值为-1。

当主服务器收到psync命令后,会用FULLRESYNC作为响应命令返回给对方。(FULLRESYNC的意思为全量复制,也就是把主服务器的所有数据全部同步给从服务器)
第一阶段结束,第一阶段就是为了全量复制做准备。

2.第二阶段是主服务器同步数据给从服务器;

接下来,主服务器会执行bgsave命令来执行RDB快照文件,把文件发送给从服务器。
从服务器收到RDB文件之后,会清空当前自己的全部数据,然后加载RDB文件。
在上面RDB中我们有说过bgsave是一个子进程,不会阻塞我们主进程,在这个时候如果修改了数据,就会导致主从主服务器之间的数据是不一致的。
在上面我们说RDB快照时,当数据被修改的情况,只能等待下一次RDB,而在主从复制中也是这样吗?
答案:不是,因为在主从复制中为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中收到的写操作命令,会写入在repl_backlog里面:

  • 主服务生成RDB文件期间;
  • 主服务器发送RDB文件给从服务器期间;
  • 从服务器加载RDB文件期间;

3.第三阶段是主服务器发送新写操作命令给从服务器。

在主服务器生成的RDB文件发送完,从服务器收到RDB文件后,丢弃所有旧数据,将RDB数据载入但内存完成之后,会回复一个确认的消息给主服务器。
接着,主服务器将repl_backlog里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器repl_backlog发来的命令,这个时候数据就一致了。
主从服务器的第一次同步工作就完成了

命令传播
主从服务器在完成第一次同步后,双方之间就会维护一个TCP链接。
在这里插入图片描述
后续主服务器就可以通过这个链接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。
而且这个链接是长连接,目的是避免频繁的TCP连接和断开带来的性能消耗。
分摊主服务器的压力
在前面的分析中,我们知到主从服务器在第一次数据同步过程中,主服务器会做两件耗时的操作:生成RDB文件和传输RDB文件。
主服务器时可以有多个从服务器的,如果从服务器数量非常多,而且都与主服务器进行全量同步的话,就会带来两个问题:

  • 由于是通过bgsave命令来生产RDB文件的,那么主服务器就会忙于使用fork()创建子进程,如果主服务器的内存数据非常大,在执行fork()时也是会阻塞主进程的,从而使得Redis无法正常处理请求;
  • 传输RDB文件会占用主服务器的网络宽带,会对主服务器响应命令请求产生影响。
    这种情况就好像,刚创业的公司,由于人不多,所以员工都归老板一个人管,但是随着公司的发展,人员的扩充,老板慢慢就无法承担全部员工的管理工作了。
    要解决这个问题,老板就需要设立经理职位,由经理管理多名普通员工,然后老板只需要管理经理就好。
    Redis 也是一样的,从服务器可以有自己的从服务器,我们可以把拥有从服务器的从服务器当作经理角色,它不仅可以接收主服务器的同步数据,自己也可以同时作为主服务器的形式将数据同步给从服务器。

在这里插入图片描述
通过这种方式,主服务器生成 RDB 和传输 RDB 的压力可以分摊到充当经理角色的从服务器。
那具体怎么做到的呢?
其实很简单,我们在「从服务器」上执行下面这条命令,使其作为目标服务器的从服务器:

replicaof <目标服务器的IP> 6379

此时如果目标服务器本身也是「从服务器」,那么该目标服务器就会成为「经理」的角色,不仅可以接受主服务器同步的数据,也会把数据同步给自己旗下的从服务器,从而减轻主服务器的负担。

增量同步
主从服务在完成第一次同步后,就会基于长连接进行命令传播。
但是,网络有时候会延迟,会断开。
如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从从服务器读到旧的数据。
那么问题来了,如果此时断开的网络,又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?
如果主服务器在命令同步时出现了网络断开又恢复的情况,从服务器和主服务器重新进行一次全量复制,很明显这样的开销就太大了,所以,主服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。
在这里插入图片描述
同步给从服务器的步骤主要有3个:

  • 从服务器在恢复网络后,会发生psync命令给主服务器,此时的psync命令里面的offset参数不为-1,而是偏移量,runID也是主服务器的ID;

主节点和从节点分别维护一个复制偏移量(offset),代表的是主节点向从节点传递的字节数;主节点每次向从节点传播 N 个字节数据时,主节点的 offset 增加 N;从节点每次收到主节点传来的 N 个字节数据时,从节点的 offset 增加 N。
offset 用于判断主从节点的数据库状态是否一致:如果二者 offset 相同,则一致;如果 offset 不同,则不一致,此时可以根据两个 offset 找出从节点缺少的那部分数据。例如,如果主节点的 offset 是1000,而从节点的 offset 是500,那么部分复制就需要将 offset 为501-1000 的数据传递给从节点。
因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据。
因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。
master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。
master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了(以后就只有看offset做增量同步了)。
因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致。

  • 主服务器收到命令之后,会查看runID,如果runID一致就代表是增量同步,回复contine。
  • 去repl_baklog中获取offset之后的数据,发送给从节点,实现数据一致.

repl_baklog大小是有上限的,写满后会覆盖最早的数据,如果slave断开时间过久,导致尚未备份的数据被覆盖,就无法基于log做增量同步,只能再次全量同步。
reple_baklog文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset:
在这里插入图片描述
slave与master的offset之间的差异(红色区域),就是salve需要增量拷贝的数据了。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:
在这里插入图片描述
直到数组被填满:
在这里插入图片描述
此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分。
但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset:
在这里插入图片描述如果master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:
在这里插入图片描述
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了。只能做全量同步。

Redis哨兵模式

在主从复制中我们实现了读写分离的方式,让我们的Redis更加高可用,使用master传数据给从节点的方式,让数据保持一致。但是如果master宕机了,用户就无法进行写操作了。面对master这样的情况,我们可以使用Redis哨兵机制来监控我们主节点的情况,从来动态的更换主节点,来防止不能进行写操作的情况。

什么是哨兵机制

哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵的作用和原理

在这里插入图片描述

哨兵的作用如下:

  • 监控:Sentinel 会不断检查您的master和slave是否按预期工作
  • 自动故障恢复(选主):如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
  • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
哨兵如何判断主节点真的故障了?

Sentinel基于心跳机制检测服务状态,每隔1秒向集群的每个实例发送评命令:

  • 主观下线: Sentinel集群的每一个Sentinel节点会定时对redis集群的所有节点发心跳包检测节点是否正常。如果一个节点在down-after-milliseconds时间内没有回复Sentinel节点的心跳包,则该redis节点被该Sentinel节点主观下线。
  • 客观下线:当节点被一个Sentinel节点记为主观下线时,并不意味着该节点肯定故障了,还需要Sentinel集群的其他Sentinel节点共同判断为主观下线才行。
    该Sentinel节点会询问其他Sentinel节点,如果Sentinel集群中超过quorum数量的Sentinel节点认为该redis节点主观下线,则该redis客观下线。
    quorum值最好超过Sentinel实例数量的一半。
    如果客观下线的redis节点是从节点或者是Sentinel节点,则操作到此为止,没有后续的操作了;如果客观下线的redis节点为主节点,则开始故障转移,从从节点中选举一个节点升级为主节点。
由哪个哨兵进行主从故障转移

为了更加客观的判断主节点是否故障,一般不会自由单个哨兵检测结果来判断,而是多个哨兵一起判断,这样可以减少误判概率,所以哨兵是以哨兵集群的方式存在的。
那哨兵集群会选择哪个从节点成为新的主节点呢?
每一个Sentinel节点都可以成为Leader,当一个Sentinel节点确认redis集群的主节点主观下线后,会请求其他Sentinel节点要求将自己选举为Leader。被请求的Sentinel节点如果没有同意过其他Sentinel节点的选举请求,则同意该请求(选举票数+1),否则不同意。(也就是说每个Sentinel只有一票给别人,给了就没有了)
如果一个Sentinel节点获得的选举票数达到Leader最低票数(quorum和Sentinel节点数/2+1的最大值),则该Sentinel节点选举为Leader;否则重新进行选举。
在这里插入图片描述

Sentinel Leader决定新主节点

当Sentinel集群选举出Sentinel Leader之后,由Sentinel Leader从redis节点中选择一个节点作为主节点:

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
  • 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
  • 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
  • 最后是判断slave节点的运行id大小,越小优先级越高。
选出一个新的Master之后,该怎么切换呢?
  • sentinel给备选的salve节点发送slaveof no one 命令,让该结点成为master
  • sentinel给所有其他salve发送slaveof 主节点IP 命令,让这些slave成为新的master的从节点,开始从新的master上同步数据。
  • 最后,sentinel将故障结点标记为slave,当故障结点恢复后会自动成为新的master的slave节点

Redis分片集群

当Redis缓存数量大到一台服务器无法缓存时,就需要使用Redis切片集群方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高Redis服务的读写性能。
在这里插入图片描述
分片集群特征:

  • 集群中有多个mster,每个master保存不同数据
  • 每个master都可以多个slave节点
  • master之间通过心跳机制检测彼此健康状态
  • 客户端请求可以访问集群任意结点,最终都会被转发到正确节点

散列插槽

Redis分片集群方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系,在Redis分布汲取方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据他的key’,被映射到一个哈希槽中,具体执行过程分为两大步:

  1. 根据键值对的key,按照CRC16算法计算一个16bit的值。
  2. 再用16bit值对16384取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
    这些哈希槽是怎么被映射到具体的Redis节点上的呢?
    平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
    手动分配:可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
    在这里插入图片描述
    上图中的切片集群一共有 2 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。

十一、BigKey

什么是Redis大key?

我记得我第一次了解bigkey的时候一直以为是键值很大的称之为大key,但是其实bigkey并不是值key的值很大,而是key对于的value很大。
满足下面条件之一的就称之为bigkey。
根据<Alibaba标准手册写的>

  1. String类型的值大于10KB;
  2. Hash,List,Set,ZSet类型的元素的个数超过5000个;

大key会带来的影响

  1. 客户端超时阻塞。

Redis的执行命令都是单线程的,在操作bigkey时会耗时比较久,就会柱塞Redis,从客户端的角度来看,就是很久没有响应。

  1. 引发网络阻塞。

每次获取bigkey时产生的网络流量比较大。 可能会导致网络宽带被占满,从而导致Redis变得缓慢,也可能会影响其他物理机。

  1. 使用del删除bigkey时会阻塞主进程

在删除bigkey时我们不要使用del,而是使用unlink,因为unlink是异步的子进程。

  1. 内存分布不均匀

在分片集群的情况下,会出现数据和查询倾斜的情况,也就是又bigkey的Redis实例内存使用率远超于其他实例,无法使数据分片的内存资源达到平衡。

  1. 序列化和反序列化

bigkey的数据序列化和反序列化时会导致cpu的使用率飙升,影响Redis实例和本机其他应用。

如何发现bigkey

  1. redis-cli --bigkeys 查找大key
    可以通过redis-cli --bigkeys命令查找大key:
redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys

使用这个命令的时候最好在从节点上执行,在主节点上会柱塞主节点影响主节点的其他请求。
没有从节点也可以在Redis低峰阶段查询,或者使用-i 参数控制扫描间隔,避免长时间扫描降低Redis实例的性能。
这个命令返回的是每种类型中最大的那个Key,并不一定就是bigkey
在这里插入图片描述
2. 使用scan命令查找大key
自己编程,利用scan扫描Redis中的所有key,利用strlen、hlen等命令判断key的长度(此处不建议使用MEMORY USAGE)
需要知道写入集合的元素大小,在业务上获取或者可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。
3. 使用第三点工具,利用第三方工具,如 Redis-Rdb-Tools 分析RDB快照文件,全面分析内存使用情况

如何删除bigkey

删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

  1. 分批次删除,对于集合类型,可以分批删除,也可以先遍历删除不是bigkey的最后再删bigkey
  2. 异步删除 用unlink代替del命令

Redis大key对持久化有什么影响?

BigKey对AOF日志的影响

在上面我们有了解过AOF日志写入硬盘的策略有三种,分别是:
Always,它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
Everysec,它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
这三种策略只是在控制fsync()函数的调用时机。
当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。
在这里插入图片描述
如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
No 策略就是永不执行 fsync() 函数;
在使用Always策略的时候,主线程在执行完命令之后,会吧数据写入到AOF日志文件,然后会调用fsync()函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
然后在Always策略下,如果写入的是一个大key,主线程在执行fsync()函数的时候,阻塞的时间会比较久,写入磁盘本身就比较慢,数据量大就更慢了。
当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。
当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。

BigKeyAOF重写和对RDB的影响

当AOF写入了很多bigkey,AOF的日志就会很大,那就会出发AOF重写机制。
在AOF重写机制和RDB快照(bgsave)的过程中,都会fork()一个子进程来处理任务。
在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
随着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。
在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象。
而且,fork 函数是由 Redis 主线程调用的,如果 fork 函数发生阻塞,那么意味着就会阻塞 Redis 主线程。由于 Redis
执行命令是在主线程处理的,所以当 Redis 主线程发生阻塞,就无法处理后续客户端发来的命令。
同时我们知道在子进程进行重写和快照的过程中,会发生写时复制的情况。
写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
如果创建完子进程后,父进程对共享内存中的大 Key 进行了修改,那么内核就会发生写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么在复制物理内存这一过程中,也是比较耗时的,于是父进程(主线程)就会发生阻塞。
所以,有两个阶段会导致阻塞父进程:

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值