Contents

Redis

Contents

[toc]

常见面试题

记录一下常见的面试题,用于自测~~

🚩:代表被问过

Redis 数据类型和结构(必问⭐⭐)
  • Redis常见的数据类型,使用场景?G
  • 各数据类型的底层数据结构是什么?G
  • 数据结构的底层实现,做了哪些优化?🚩G
  • Zset底层用的跳表,为啥跳表这么快?G
  • 为什么用跳表,而不用平衡树?G
Redis 架构(必问⭐⭐⭐)
  • Redis 为啥这么快?🚩🚩G
  • Redis 是单线程还是多线程?哪部分是单线程?哪部分是多线程?G
  • Redis 的线程模型?G
  • Redis 为啥选择单线程呢?G
Redis 集群(必问⭐⭐⭐)
  • Redis 是怎么实现高可用的?G
  • 多个节点间的一致性是咋实现的?主从复制是怎么实现的?🚩G
  • 主节点如何判断一个从节点是不是第一次来同步数据?G
  • 全量同步和增量同步的区别?什么时候执行全量同步?什么时候执行增量同步?G
  • 哨兵模式是怎么实现的?🚩G
  • 分片集群是怎么实现的?G
Redis 持久化(⭐⭐)
  • Redis 如何实现数据持久化存储?🚩G
  • 持久化机制、持久化方式是什么,各有什么优缺点?G
  • 混合持久化是什么?G
  • AOF 日志重写机制?G 后台重写?G
  • 写时复制是什么?有啥用?G
  • RDB 创建快照时会阻塞主进程吗?若不会阻塞,那数据能被修改吗?G
  • BigKey 有啥影响?G
Redis 缓存(⭐⭐)
  • 缓存穿透及解决方案?G
  • 布隆过滤器原理?G
  • 缓存击穿及解决方案?G
  • 缓存雪崩及解决方案?G
  • 如何保证缓存与数据库的一致性?🚩G
  • Redis 内存淘汰策略有哪些?G
  • Redis 怎么判断一个键值对是否过期?G 对于过期 Key 的策略?G
  • LRU 和 LFU 有啥区别?Redis 是咋实现的?G

一、Redis 概念

问:什么是 Redis?有啥用?/为什么要用缓存?

Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景

  • **高性能:**若从数据库读取数据,就是从磁盘读取,速度很慢;而从缓存中读取数据是从内存读取的。若把需要频繁读取的数据放入缓存,那么效率就会大大提高;
  • **高并发:**直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以会提高系统整体的并发。

问:Redis 为啥快?

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

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

  • Redis 内置了多种优化过后的数据结构实现,性能非常高。

问:Redis 和 Memcached 的区别?

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中。
  3. **Redis 有灾难恢复机制。**因为可以把缓存中的数据持久化到磁盘上。
  4. Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  5. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的
  6. Redis 使用单线程的多路 IO 复用模型;Memcached 是多线程,非阻塞 IO 复用的网络模型。
  7. Redis 支持发布-订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  8. Redis 同时使用了惰性删除与定期删除;Memcached 过期数据的删除策略只用了惰性删除。

二、Redis 数据结构

数据类型

问:Redis 常用的数据类型有哪些?使用场景?

  • 5 种基础数据类型:String(字符串)、List(列表,类似双向链表)、Set(集合)、Hash(散列)、Zset(有序集合)。
  • 3 种特殊数据类型:HyperLogLogs(基数统计)、Bitmap(位存储)、Geospatial(地理位置)。

  • String 类型的应用场景:缓存session、token、常规计数、分布式锁、共享 session 信息等。
  • List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算场景,比如点赞、共同关注(交集)、抽奖活动(去重)等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

问:String 还是 Hash 存储对象数据更好?

依据使用场景:

  • String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。比如购物车。
  • String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。如果系统对性能和资源消耗非常敏感的话,String 就非常适合

问:常见的数据类型是怎么实现的?

img

Redis 基本数据类型的底层数据结构实现如下:

String List Hash Set Zset
SDS LinkedList/ZipList/QuickList Hash Table、ZipList ZipList、Intset ZipList、SkipList

数据结构

SDS⭐

问:SDS 的底层实现?⭐

Redis没有直接用C中的char*实现字符串,主要是因为char*有如下缺陷

  • 获取字符串长度的时间复杂度为 O(N);
  • 字符串的结尾是以 \0 字符标识,字符串里面不能包含有 \0 字符,因此不能保存二进制数据;
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;

而Redis中,String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)(类似Go的):

  • SDS 获取字符串长度的时间复杂度是 O(1),因为 SDS 使用 len 属性的值 而不是\0字符来判断字符串是否结束;
  • SDS 不仅可以保存文本数据,还可以保存二进制数据
  • 当判断出缓冲区大小不够用时,SDS 支持动态扩容,避免缓冲区溢出。扩容规则
    • 如果新的 sds 长度(newlen) 小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 * newlen + 1;(+1 是因为结束标识符\0)
    • 如果新的 sds 长度(newlen) 超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB + 1。
    • 分配多余的空间,可以有效的减少内存分配次数

这是SDS的数据结构,是不是贼像Go的:

img
  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,代表了SDS的最大长度,比如2^8^-1、2^16^-1。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

String 类型的应用场景:缓存session、token、常规计数、分布式锁、共享 session 信息等。

intset

问:整数集合(intset) 的底层实现?

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

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

其中,contents[]真正类型取决于 encoding 属性的值

插入元素会按序排列,且占用空间相同,方便用下标寻址

==升级机制:==

主要针对:插入新元素时,若新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级。也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里。

例,向set[1, 2, 3]插入65535:

img

好处就是节省内存。

只有升级操作,没有降级操作

哈希表⭐

问:哈希表(dict) 的底层实现?

哈希表的实现方式之一就是 dict (元素数量 > 500时)。可以以 O(1) 的复杂度快速查询数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct dictht {
    //哈希表数组,桶数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;  
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
} dictht;

哈希表比较熟悉(因为跟Go的好像hhh),这里只记几个关键词:

  • 与运算

  • 拉链法;

  • 渐进式扩容;

  • 负载因子:

    • 当负载因子 >= 1 ,并且Redis没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作;
    • 当负载因子 > 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
    • 当负载因子 < 0.1 时,会做哈希表收缩。

    首个 2^n^ > 新的所需容量,作为扩容/收缩后的容量。

list

问:list 的底层实现?

list的底层实现方式之一就是用List。

其实就是个双向链表。

 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
// 链表
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;

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

一个3节点链表的示意图:

img

优点:

  • 获取链表的表头节点表尾节点的时间复杂度只需O(1)
  • 获取链表中的节点数量的时间复杂度只需O(1)
  • 获取某个节点的前置节点后置节点的时间复杂度只需O(1)

缺点:

  • 保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大
ziplist⭐

问:压缩列表(ziplist) 的底层实现?

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

压缩列表是由连续内存组成的顺序型数据结构,类似数组。

img

压缩列表表头:

  • zlbytes,记录整个压缩列表占用字节数
  • zltail,记录压缩列表「尾部」节点距离起始地址有多少字节,也就是列表尾的偏移量
  • zllen,记录压缩列表包含的节点数量
  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。

==节点 entry==:

  • ==prevlen==,记录了「前一个节点」的长度,目的是为了实现从后向前遍历。(当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,导致连续更新);
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

优点:

  • 压缩列表插入数据时,会根据数据大小类型进行不同的空间大小分配,节省内存;

缺点:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发**==连锁更新==**的问题。

因此,压缩列表只会用于保存的节点数量不多的场景

==连锁更新:==

问题根源是,新插入元素时,后一元素的prevlen占用空间可能会变大,导致自身占用空间变大,然后可能导致后续元素的 prevlen 占用空间都发生变化,发生连锁更新。

quicklist

问:quicklist 的底层实现?

List 的底层对象就是 quicklist。

其实 quicklist 就是**「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表**,而链表中的每个节点又是一个压缩列表

img

quicklist的结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
typedef struct quicklist {
    //quicklist的链表头
    quicklistNode *head;      //quicklist的链表头
    //quicklist的链表尾
    quicklistNode *tail; 
    //所有压缩列表中的总元素个数
    unsigned long count;
    //quicklistNodes的个数
    unsigned long len;       
    ...
} quicklist;

quicklistNode 的结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode *prev;     //前一个quicklistNode
    //下一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    //quicklistNode指向的压缩列表
    unsigned char *zl;              
    //压缩列表的的字节大小
    unsigned int sz;                
    //压缩列表的元素个数
    unsigned int count : 16;        //ziplist中的元素个数 
    ....
} quicklistNode;
  • 添加元素:在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

主要是为了解决 ziplist 的「连锁更新」问题。解决方法是通过控制每个链表节点中的压缩列表的内存大小或者 entry 数量使连锁更新的发生范围变小。其实这并没有完全解决连锁更新的问题,只是把更新问题局限在了单个节点内部

skiplist⭐⭐

问:跳表(skiplist) 的底层实现?⭐⭐

==使用场景==

只有Zset用到了跳表,跳表的优势是支持平均O(logN)复杂度的节点查找

Zset结构体跳表+哈希表组成:

1
2
3
4
typedef struct zset {
    dict *dict;			// 高效的单点查询
    zskiplist *zsl;		// 高效的范围查询
} zset;

Zset 对象在数据插入或是数据更新时,会依次在跳表哈希表插入更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。其中,哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。

:权重,插入数据时,自己给每个元素设置的分值,也就是元素之间的排序依据。


==跳表结构设计==

  • 链表
  • 元素按照升序排列;
  • 节点包含多个(层)指针,指针跨度不同。

链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。

==初始化==

按照规定的最高层数设置头结点的层数,比如 32,score 设置为 0。

==插入节点==

  • 根据传入的 elescore 创建新的节点

  • 查询插入结点的插入位置(查询节点的过程);

  • 插入过程:

    • 随机生成层数
    • 修改插入节点前后的指针

==删除节点==

  • 查询结点的位置(查询节点的过程);
  • 删除过程:
    • 修改插入节点前后的指针

==更新节点==

  • 若新的 score 值并不会带来排序上的变化,那么就不需要调整位置

  • 否则,删除,再重新插入即可。

==查询节点==

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

  • 如果要查找的权重 > 当前节点的权重时,跳表就会访问该层中的下一个节点。
  • 如果要查找的权重 = 当前节点的权重时,并且要查找的数据 > 当前节点的 SDS 类型数据时,就会访问该层中的下一个节点。
  • 如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用当前节点 level 数组里的下一层指针,然后沿着下一层指针继续查找。
image-20230326150516009

例,要查找「元素:abcd,权重:4」的节点:

  • 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
  • 但是该层的下一个节点是空节点(leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
  • 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
  • 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。

==查询示例==

image-20230412205422874
  • 若在链表中查找节点16这个元素,就需要遍历嘛,也就是查找 16 次

  • 若在跳表中查找,可以 1 -> 10 -> 15 -> 16,也就是查找 4 次

当数据量很大时,跳表的查找复杂度就是 O(logN)。可以思考一下,其实跳表的查找过程类似于二分查找

==如何实现多层级呢?==

跳表是一个带有层级关系的链表,每一层级可以包含多个节点,每一个节点通过指针连接起来。

跳表

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

跳表节点

1
2
3
4
5
6
7
8
9
typedef struct zskiplistNode {
    sds ele;		 					// Zset 对象的元素值
    double score;    					// 元素权重值,用于排序、查找
    struct zskiplistNode *backward;     // 前向指针
    struct zskiplistLevel {				// 节点的level数组,保存每层上的前向指针和跨度
        struct zskiplistNode *forward;	// 后向指针
        unsigned long span;				// 跨度
    } level[];							// level数组
} zskiplistNode;
  • 每个节点要同时保存elescore,先按照score排序,score一样时按照ele排序。
  • backward指向前一个节点,便于从后往前遍历。
  • level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。
  • 跨度用来记录两个节点之间的距离跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

==跳表节点层数设置==

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

例如,以下跳表在查找6时,就退化成链表:

image-20230326151009265

改进后的跳表:

img

问:怎么维持跳表相邻两层的节点数量大概为 2:1 ?

Redis 采用一种巧妙的方法:跳表在创建节点的时候,随机生成每个节点的层数

  • 在[0, 1]中不断产生随机数,随机数<0.25,该节点所在层数+1;
  • 随机数>0.25,该节点结束,当前层数即该节点的层数。

相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

问:为什么用跳表而不用平衡树?(如 AVL树、红黑树等)⭐(感觉写的不好)

  1. 实现简单

跳表的实现相对平衡树来说更加简单,且不需要进行旋转操作,因此在实现上更加容易。

  1. 查询效率高

平衡树的查询时间复杂度为O(log n),而跳表的查询时间复杂度为O(log n),虽然两者的时间复杂度相同,但实际上跳表的查询效率更高,因为跳表可以通过增加层数来减少查询的次数,而平衡树则需要进行复杂的旋转操作。

  1. 空间利用率高

跳表相对于平衡树来说,可以在保证查询效率的前提下,使用更少的空间。因为平衡树需要维护左右子树的平衡,需要在每个节点中维护额外的平衡因子,而跳表只需要在每个节点中维护一个指向下一个节点的指针,因此可以减少空间的使用。

  1. 算法复杂度低

跳表的插入和删除操作相对于平衡树来说更加简单,只需要进行简单的指针操作即可。而平衡树的插入和删除操作需要进行复杂的旋转操作,算法复杂度较高。

综上所述,Redis选择使用跳表而不是平衡树,是因为跳表相对于平衡树来说具有更简单的实现、更高的查询效率、更少的空间使用和更低的算法复杂度等优点。在Redis中,跳表被广泛应用于有序集合和各种其他数据结构中,以实现高效的数据存储和查询。

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

问:listpack 的底层实现?

listpack用来代替压缩列表,主要就是把prelen改成了len只记录本节点的长度,就不会导致连续更新了。

listpack的结构:

img

listpack 头包含两个属性,分别记录了 listpack 总字节数元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。

节点的结构:

img

主要包含三个方面内容:

  • **encoding:**定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • **data:**实际存放的数据;
  • **len:**encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题

那它是如何实现双向遍历的呢?

  • 从前往后遍历的话,只需要根据当前entrylen字段就可以找到下一个entry的起始地址;

  • 从后往前遍历时,可以直接跳到结尾标识部分,每个entrylen都保存在末尾,如果能得到len的长度,就可以推算出len的起始地址,拿到len就可以推算出该entry的起始地址。那么怎么拿到len的长度呢?

    • len 每个字节的最高位,是用来表示当前字节是否为 len 的最后一个字节,这里存在两种情况,分别是:

      • 最高位为 1,表示 len 还没有结束,当前字节的左边字节仍然表示 len 的内容;
      • 最高位为 0,表示当前字节已经是 len 最后一个字节了。
      image-20230418172555050

应用场景

问:购物车信息用 String 还是 Hash ?

由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:

  • 用户 id 为 key
  • 商品 id 为 field,商品数量为 value
image-20230302205453980

那用户购物车信息的维护具体应该怎么操作呢?

  • 用户添加商品就是往 Hash 里面增加新的 field 与 value;
  • 查询购物车信息就是遍历对应的 Hash;
  • 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
  • 删除商品就是删除 Hash 中对应的 field;
  • 清空购物车直接删除对应的 key 即可。

问:使用 Redis 做一个排行榜怎么做?

sorted set 经常被用在各种排行榜的场景。

相关的一些 Redis 命令: ZRANGE(从小到大排序) 、ZREVRANGE(从大到小排序)、ZREVRANK(指定元素排名)。

问:使用 Set 实现抽奖系统需要用到什么命令?

  • SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。

  • SRANDMEMBER key count:随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

三、Redis 线程模型

问:Redis 到底是单线程还是多线程?

  • 核心业务部分由单个线程(主线程)来完成:「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」

  • 但整个 Redis 是多线程的是会启动后台线程(BIO)的:

    • 关闭文件;

    • AOF 刷盘;

    • 释放内存。

一共三个后台线程,用于处理这些比较耗时的任务。

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

image-20230302210728774

问:Redis 的单线程模型是什么样的?

首先创建一个epoll对象,然后创建一个server socket并开始监听它的 FD,初始化完后,主线程就进入到一个监听事件循环函数,主要会做以下事情:

  • 首先,先调用处理发送队列函数,检查发送队列里是否有任务,如果有发送任务,就会注册写事件(到这里就形成闭环了:连接->读->写->返回),等待 epoll_wait 处理。
  • 接着,才会调用 epoll_wait 函数等待事件就绪:
    • 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册**「读事件」**处理函数;
    • 如果是读事件就绪,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据并写到客户端对象的接收缓冲区 -> 解析命令 -> 处理命令 -> 将执行结果写到客户端对象的发送缓存区等待发送 -> 将客户端对象添加到发送队列
    • 如果是写事件就绪,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理。
image-20230302211309335

多线程的部分主要是在 网络I/O 部分:

  • 读事件到来,要读取请求数据,可以在这里开启多线程;
  • 写事件,要把数据写到网络,在这里可以开启多线程。

其余执行操作时,均为单线程

image-20230414175621928

可以看到 socket对象的发送缓冲区和接收缓冲区:

image-20230414193806007

当 socket 状态处于 Established时:

  • Recv-Q 表示 socket 缓冲区中还没有被应用程序读取的字节数;
  • Send-Q 表示 socket 缓冲区中还没有被远端主机确认的字节数;

而当 socket 状态处于 Listen 时:

  • Recv-Q 表示全连接队列的长度;
  • Send-Q 表示全连接队列的最大长度;

黑马Redis的视频:P171 原理篇-27

问:为啥 Redis 的单线程还这么快?

  • 大部分操作在内存中完成,并且采用高效的数据结构;
  • 单线程可以避免多线程切换的开销
  • 采用了I/O多路复用处理客户端请求。

注:I/O 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

问:Redis 6.0 之前为啥单线程,6.0 之后为啥多线程?⭐

==为啥要选择单线程?==

  • Redis 是在内存操作(最主要原因⭐),执行速度非常快,它的性能瓶颈是 网络I/O,而不是执行速度,因此多线程并不会带来巨大的性能提升;
  • 引入多线程的话,会面临线程安全问题,增加了系统复杂性,同时可能引发线程切换加锁解锁等带来的性能损耗。

==为啥又选择多线程?==

6.0 之后采用多个 I/O 线程来处理网络请求这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。所以为了提高网络 I/O 的并行度,Redis 6.0 **==仅对于网络 I/O 采用多线程==**来处理。但是对于命令的执行,Redis 仍然使用单线程来处理

四、Redis 持久化

问:Redis 如何实现数据不丢失?

为了保证内存中的数据不丢失,Redis 实现了数据持久化的机制,会把数据存储到磁盘

共有三种数据持久化的方式:

  • AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
  • RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘
  • 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点。

问:什么是 AOF 日志?

Redis 在执行完一条操作命令,会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

img

问:AOF 中,为什么先执行写操作命令,再写入日志?

  • **避免额外的检查开销:**先执行可以检查命令是否正确,要是先写入日志又发现命令有错误,就不好办了。
  • 不会阻塞当前写操作。

也会带来风险:

  • **数据丢失:**刚执行完,还没来得及写入日志,Redis 就寄了。
  • **可能阻塞后一个指令:**AOF 操作也是由主线程完成,所以有可能会阻塞下一个操作无法执行。

问:AOF 写回策略有哪几种?

主进程执行写入,写入 AOF 日志的过程:

img

3 种写回策略的优缺点:

img

问:AOF 日志过大,会触发什么机制?

AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。

所以,Redis 为了避免 AOF 文件越写越大,提供了 ==AOF 重写机制==。当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

问:AOF 重写机制的过程?

当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件(相当于压缩版 AOF),这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。

  • 在 AOF 重写期间,Redis 服务器会维护一个 AOF 重写缓冲区,并创建一个子进程执行重写操作;
  • 子进程会将生成的新 AOF 文件保存在一个临时文件中,同时主进程会继续将新的写命令追加到旧的 AOF 文件和一个重写缓冲区中(此处为什么还要追加到旧的 AOF?是因为怕宕机导致丢失数据,如果不追加到旧的 AOF,可能会发生宕机导致重写缓冲区没了,然后这部分写操作也没加到旧的 AOF,就造成丢失了。);
  • 当子进程完成重写操作后,它会向主进程发送一个信号,主进程会将重写缓冲区的内容追加到新 AOF 文件中,然后用新 AOF 文件替换旧 AOF 文件

也就是说,主进程主要完成三个工作:

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

注意:

重写操作:扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志

**为啥能减少命令数目呢?**就比如:

https://chuyu-typora.oss-cn-hangzhou.aliyuncs.com/image/723d6c580c05400b3841bc69566dd61b.png

问:AOF 后台重写?

普通的写入 AOF 日志的操作是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。但 AOF 重写时,一般 AOF 文件都很大,所以不应该放到主进程里完成。

Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,优点:子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程。


思考:为什么是子进程而不是子线程

因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生**「写时复制」**,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

问:写时复制是什么?有啥用?

在子进程执行 AOF 重写 或 执行 RDB 的过程中,如果主进程又修改了数据,会导致数据不一致,这怎么办?

实际上,当主进程修改了数据,就会发生写时复制子进程就会拥有原始未更改的数据副本了。

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存。也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。这样可以共享物理内存资源,节省内存。同时,页表对应的页表项的属性会标记该物理内存的权限为只读

image-20230418192816833

当父进程或者子进程在修改共享内存中的某条数据时,CPU 就会触发写保护中断,执行写时复制。也就是说,在发生写操作时,操作系统才去复制物理内存。这个过程还会阻塞主进程。之后,主进程就可以对数据副本进行操作,子进程就可以对原数据进行 AOF 重写

image-20230418193035954

综上,共有两处阻塞主进程的地方:

  • 创建子进程时,需要复制页表等,会阻塞主进程;
  • 发生写时复制时,需要复制数据副本,会阻塞主进程。

问:什么是 RDB 快照?

RDB 是 Redis 默认采用的持久化方式,它会将某一时刻的内存数据文件的形式保存到磁盘中,记录的是实际的数据。

作用:

  • 可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能);
  • 还可以将快照留在原地以便重启服务器的时候使用。

Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。显然,这是一个比较重的操作。

问:RDB 创建快照时会阻塞主进程吗?若不会阻塞,那数据能被修改吗?

提供两种方式:

  • save : 主进程执行,会阻塞主进程;
  • bgsave : 子进程执行,不会阻塞主进程,默认选项

若通过bgsave的方式,执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的。

主要是通过 写时复制 来实现的。详细见 AOF 。

问:如何选择 RDB 还是 AOF?

  1. RDB 更好的地方:
  • RDB 文件存储的内容是经过压缩的二进制数据, 保存着某个时间点的数据集,文件很小,适合做数据的备份,灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多

  • 使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢

  1. AOF 更好的地方:
  • AOF 可以近实时的持久化数据,保证数据不丢失;
  • AOF 记录了所有的命令操作,可以用于审计和故障排查,安全性更好。

综合来说:

  • 如果需要高性能快速恢复,并且可以容忍一定程度的数据丢失,可以选择 RDB;
  • 如果需要高可靠性完整性,并且可以容忍一定程度的性能损耗和恢复延迟,可以选择 AOF;
  • 如果需要同时满足两者的需求,并且有足够的磁盘空间和内存资源,可以选择混合持久化(RDB + AOF)。

问:AOF 会丢失数据吗?

  • 如果命令执行成功,写入日志的时候宕机了,命令没有写入到日志中,这时候就有丢失数据的风险了。
  • 如果 AOF 文件损坏了,可能会导致数据恢复失败或者部分丢失。

问:为啥要混合持久化?

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

AOF 优点是丢失数据少,但是数据恢复不快。

Redis 4.0 提出了混合使用 AOF 日志和 RDB 内存快照,也叫混合持久化。既保证了 Redis 重启速度,又降低数据丢失风险。


==什么是混合持久化:==

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

混合持久化的原理是,在 AOF 重写的过程中,把 RDB 格式的全量数据写到 AOF 文件的开头,然后再追加 AOF 格式的增量数据。这样,当 Redis 启动时,只需要加载一个文件就可以恢复所有数据,而不需要先加载 RDB 文件再加载 AOF 文件。也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

img

==混合持久化优点:==

  • 开头为 RDB 的格式,使得 Redis 可以更快的启动
  • 同时结合 AOF 的优点,有减低数据丢失的风险。

==混合持久化缺点:==

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

五、Redis 事务

问:如何使用事务?

Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(transaction)功能。

  • MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。

  • 通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

  • 通过 WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。

问:Redis 支持原子性吗?

Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的(而且不满足持久性)。

**原因:**Redis 开发者们觉得没必要支持回滚,命令执行错误应该在开发过程中就被发现而不是生产过程中。

可以将 Redis 中的事务就理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

实际中,不建议使用 Redis 的事务。

六、Redis 性能优化

6.1 键值设计

Key 的最佳实践

  • 固定格式:[业务名]:[数据名]:[id]
  • 足够简短,不超过44字节;
  • 不包含特殊字符。

Value的最佳实践

  • 合理拆分数据,拒绝bigkey;
  • 选择合适的数据结构;
  • Hash 结构的 entry 数量不超过 1000;
  • 设置合理的超时时间。

6.2 bigkey

6.2.1 bigkey 发现与删除

问:什么是 bigkey?

简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。

一般有以下两种情况:

  • String 类型的值大于 10 KB;
  • Hash、List、Set、ZSet 类型的元素的个数超过 5000 个;

问:bigkey 会造成什么影响?

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时(慢查询),那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应;
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大;
  • 持久化时,若对 bigkey 进行修改,会触发写时复制,由于是 bigkey,可能会阻塞主进程较长的时间;
  • 内存分布不均。有大 key 的 Redis 节点占用内存多,出现数据和查询倾斜情况。

问:如何发现 bigkey?

  • redis-cli --bigkeys

使用 Redis 自带的 --bigkeys 参数来查找,可以遍历分析所有的 key,并返回 key 的整体统计信息与每个数据的 Top1 的 bigkey。

  • 分析 RDB 文件

网上有现成的代码/工具RDB-Tools,可以进行离线的分析 RDB 文件。

  • 网络监控

自定义工具,监控进出 Redis 的网络数据,超出预警值时主动告警。

问:如何删除 bigkey?

bigkey 内存占用较多,导致即使是删除 bigkey,也贼慢。。

  • unlink -keyname。redis 4.0 后提供的异步删除方式。

  • 若是集合类型,可以遍历bigkey的元素,先逐个删除子元素,再删除该 bigkey。

6.2.2 bigkey 避免

问:假如有hash类型的key,其中有100万对field和value,field是自增id,这个key存在什么问题?如何优化?

  • 方案一
image-20230501223334082
  • 方案二

每个 key 存储的时候,还会存储它的元信息,应尽量减少 key 的数量。

image-20230501223308777
  • 方案三
image-20230501223641723

6.3 批处理优化

6.3.1 什么是批处理

  1. 普通执行 N 条命令:将 N 条命令依次发送和执行。

$$ N 次命令的响应时间 = N 次往返的网络传输耗时 + N 次Redis执行命令耗时 $$

其中,命令耗时 是非常短的也就是大部分时间都浪费在了 网络传输

image-20230502161131704
  1. 批量执行:将 N 条命令批量发送和执行。

$$ N 次命令的响应时间 = 1 次往返的网络传输耗时 + N 次Redis执行命令耗时 $$

image-20230502161535306

那么如何实现上述的批量操作呢?

6.3.2 批处理方案

6.3.2.1 Mset

Redis 提供了很多 Mxxx 这样的命令,可以实现批量插入数据,例如:

  • mset
  • hmset

但 mset 只能操作 string 类型,因此具有局限性。

6.3.2.2 Pipeline⭐

mset 虽然能批处理,但是却只能操作部分数据类型,因此如果有对复杂数据类型的批处理需要,建议使用 Pipeline

注意事项

  • 批处理时要避免一次性发送过多命令,导致管道内的命令太多,进而发生网络阻塞;
  • Pipeline 的多个命令之间不具备原子性,而 mset 具备原子性。即 Pipeline 多个命令间可能有别的命令插队。
6.3.2.3 并行slot

集群中,批处理操作通常不会成功,因为不同的 key-val 分布在不同的 slot 中。这时,可以采用并行 slot 操作(spring 已经实现了)。

实现思路:在客户端计算每个 key 的 slot,将 slot 一致的分成一组,每组都利用 Pipeline 批处理,并行执行各组命令。 $$ N 次命令的响应时间 = 1 次往返的网络传输耗时 + N 次Redis执行命令耗时 $$

6.4 服务端优化

6.4.1 慢查询

慢查询:在 Redis 执行时耗时超过某个阈值的命令,称为慢查询。

image-20230502165347748

通常,bigkey 会导致慢查询,可以通过慢查询日志发现哪些命令触发了慢查询,进而优化。

通常,慢查询的原因主要有:

  • 使用复杂度过高的命令
  • bigkey问题
  • 集中过期

6.4.2 内存配置

当 Redis 内存不足时,可能导致 Key 频繁被删除、响应时间变长、QPS 不稳定等问题。当内存使用率达到 90% 以上时就需要警惕,并快速定位到内存占用的原因。

image-20230502165834405

6.5 集群优化

6.5.1 可用性

在 Redis 的默认配置中,如果发现任意一个 slot 不可用,则整个集群都会停止对外服务。

为了保证高可用性,可以将 cluster-require-full-coverage = false。这样,只有 down 的 slot 不可用。

6.5.2 集群带宽问题

集群节点间会不断的互相 ping 来确定集群中其他节点状态。每次 ping 携带的信息至少包括:

  • slot 信息;
  • 集群状态信息。

集群中节点越多,集群状态信息量也越大,此时每次集群互通所需带宽就会非常高。

解决途径:

  • 避免大集群,集群节点数不要太多,最好 < 1000,如果业务庞大,则建立多个集群;
  • 避免在单个物理机上运行太多 Redis 实例;
  • 配置合适的 cluster-node-timeout 值。

七、Redis 缓存设计

7.1 缓存穿透

Cache Penetration

问:什么是缓存穿透?

简单来说就是,大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中。导致这些请求直接砸到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

问:有哪些解决方法?

  • 缓存无效 key。就是把缓存找不到的、数据库也查不到的 key,写入到缓存中,并设置过期时间。但无法根本上解决,缓存一堆没用的数据照样能给你干宕机。
  • 布隆过滤器。可以帮助快速判断一个给定数据是否存在于海量数据中。具体是这样做的:所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走之后的流程(缓存、数据库)。

问:布隆过滤器原理?缺陷及解决?

  1. 元素加入布隆过滤器的过程:
  • 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。

  • 根据得到的哈希值,在多个位数组中把对应下标的值置为 1。

  1. 判断一个元素是否存在于布隆过滤器的过程:
  • 对给定元素再次进行相同的哈希计算;

  • 得到值之后判断位数组中对应下标元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

image-20230303195554173

布隆过滤器的缺点

  • 还是有几率缓存穿透布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在解决方法:可以增多哈希函数,计算出多个哈希值,只有所有哈希值对应的数组中的值都为 1 时,才会认为这个元素在集合中。
  • 不支持删除元素,这也和哈希冲突有关。解决方法:让数组中不再只有 0 和 1 两个值,而是存储一个计数。比如如果 A 和 B 同时命中了一个数组的索引,那么这个位置的值就是 2,如果 A 被删除了就把这个值从 2 改为 1。但这会增加空间的消耗。所以,要依据业务场景来选择使用布隆过滤器

7.2 缓存击穿

Hotspot Invalid

问:什么是缓存击穿?

缓存击穿中,请求的 key 对应的是热点数据,该数据存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

**比如:**秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。

问:有啥解决方法?

  • 设置热点数据永不过期或者过期时间比较长
  • 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
  • 分布式锁。控制在某一个热点缓存项失效之后启动一个后台线程获得锁,穿透到数据库,将数据加载到缓存中,在缓存未加载之前,所有访问这个缓存的请求都阻塞或直接返回;
  • 逻辑过期,当发现数据过期,并且尝试获取互斥锁失败时,会返回旧数据,因为此时已经有其他线程去更新数据了。这主要保证数据可用性。

问:缓存穿透和缓存击穿的区别?

  • 缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。

  • 缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)

7.3 缓存雪崩

问:什么是缓存雪崩?

缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。

**比如:**数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。

问:有啥解决方法?

==针对 Redis 宕机的情况:==

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。

  2. 服务降级限流

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务降级限流机制,暂停业务应用对缓存服务的访问,返回默认值或友好提示,不用再继续访问数据库,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

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

  1. 请求限流

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

==大量数据同时过期的情况:==

  1. **均匀(随机)**设置过期时间;

  2. 互斥锁。如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存

问:缓存雪崩和缓存击穿有啥区别?

  • 缓存雪崩是缓存中的大量或所有数据失效;
  • 缓存击穿某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)。

7.4 缓存一致性⭐

缓存一致性的核心问题是:在更新数据库数据时,如何保证缓存数据也同步更新或失效,避免读取到过期或错误的数据。

常见的缓存更新策略有以下几种:

  • Cache Aside⭐:旁路缓存策略
  • Read/Write Through:读穿/写穿策略;
  • Write Back:写回策略。

以上策略都不能完全保证强一致性(即任何时刻都能读取到最新的数据),只能保证最终一致性(即经过一段时间后能够达到一致状态)。要实现强一致性,则需要牺牲系统的可用性或分区容忍性(根据CAP理论)。

这里主要介绍了旁路缓存策略,有空再看其他两种策略

问:如何保证缓存与数据库的一致性?⭐⭐

实际开发中,Redis 和 MySQL 的更新策略用的是旁路缓存策略(Cache Aside)。策略可以细分为「读策略」和「写策略」。

img
  • 写策略:
    • 采用先更新数据库再删除缓存⭐,主要是为了解决线程安全问题;
    • 确保数据库与缓存操作的原子性。单体系统天生就能原子性,分布式系统的话就用分布式事务。
  • 读策略:
    • 缓存命中则直接返回;
    • 缓存未命中则查询数据库,并写入缓存,设定超时时间(相当于兜底方案,避免写方案更新缓存失效)。

问:写策略中,能否先删除缓存,后更新数据库呢?⭐

不能滴~~

  • 先删除缓存再更新数据库:这种方式可以保证不会读取到过期的缓存数据,但是存在并发问题。如果在删除缓存后、更新数据库前,有其他线程读取了该数据,并将旧数据写入了缓存,那么就会导致缓存和数据库不一致
image-20230504204654371
  • 先更新数据库再删除缓存⭐:这种方式其实理论上也有问题:
    • 存在延迟问题(会读到旧数据)。如果在更新数据库后、删除缓存前,有其他线程读取了该数据,就会读取到过期的旧数据;
    • 理论上也可能出现缓存和数据库的不一致,不过出现的机率远小于第一种原因是缓存的写入通常远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。
image-20230504204953955

简单总结,足以适应绝大部分的互联网开发场景的决策:

  • 针对大部分读多写少场景,建议选择更新数据库后删除缓存的策略。
  • 针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略。

问:Cache Aside 有啥问题吗?

Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

  1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  2. 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。

参考文章:

7.5 过期、淘汰

问:Redis 怎么判断一个键值对是否过期?

可以通过expire设置过期时间ttl。利用两个 Dict 分别记录key-valuekey-ttl

1
2
3
4
5
typedef struct redisDb {
    dict *dict;    /* 数据库键空间,存放着所有的键值对 */
    dict *expires; /* 键的过期时间 */
    ....
} redisDb;

问:Redis 对于过期 Key 的策略?

对于过期 key,Redis 采用的是 定期删除+惰性删除 策略。

  • 惰性删除:在访问一个key时先检查它是否过期,如果过期就删除它。

    • 优点:在访问时才会检查,只会使用很少的系统资源,对 CPU 友好;
    • 缺点:若 key 过期,之后一直没被访问到,就会一直占用内存。
  • 定期删除:周期性的取出部分 key 进行检查,然后删除其中过期的。

    • 优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能规避惰性删除的问题;
    • 缺点:执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢

问:大量 key 集中过期的情况怎么办?

常采用以下两种解决方法:

  • 给 key 设置随机过期时间;
  • 开启惰性删除,让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

问:Redis 都有啥内存淘汰策略?

Redis 内存淘汰策略共有 8 种,这 8 种策略大体分为**「不进行数据淘汰」「进行数据淘汰」**两类策略:

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

  • noeviction(Redis3.0之后,默认的内存淘汰策略):它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误

2、进行数据淘汰的策略

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

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

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

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

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

问:LRU 和 LFU 有啥区别?Redis 是咋实现的?

LRU:最近最少使用,会淘汰最久未使用的键值对;

LFU:最近最不常使用,会淘汰最少使用的键值对。

1
2
3
4
5
typedef struct redisObject { 
    // 24 bits,用于记录对象的访问信息
    unsigned lru:24;  
    ...
} robj;
  1. LRU 的实现

传统 LRU 是通过 链表+哈希表 实现,但是会有如下缺点:

  • 需要引入链表哈希表,带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,带来额外的时间开销。

Redis 实现的是近似 LRU:在 Redis 的对象结构体中添加一个额外的字段 lru,用于记录此数据的最后一次访问时间。当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

优点就是节省了内存,并且处理也更快。缺点就是:无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

  1. LFU 的实现

LFU 根据数据访问次数来淘汰数据,核心思想是**“如果数据过去被访问多次,那么将来被访问的频率也更高”**。

Redis 的 LFU 也是通过 lru 字段实现的,24 bits 的 lru 字段被分成两段来存储:高 16bit 存储访问时间戳,低 8bit 存储访问次数访问次数会随着时间衰减

image-20230415162105594

八、Redis 集群

8.1 主从复制

问:Redis 如何实现服务高可用?

**数据持久化:**这是基础,保证系统在发生宕机或者重启之后数据不会丢失。

==三种模式:==

  1. **主从复制:**将数据存储至多台服务器,当主节点挂了,保证能够很快的切换;
  2. 哨兵:监控主节点的状态,若主节点挂了,自动选举新的主节点,并完成故障转移
  3. 集群:提供了多主多从的 Redis 分布式集群环境,实现分布式存储,每个节点存储不同的内容,节省了内存也提高了可用性。
问:如何保证多个 Redis 节点间的数据一致性呢?

通过主从复制读写分离

问:主从复制是如何实现的?⭐🚩

主从复制是 Redis 高可用服务的最基础的保证。实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是**「读写分离」**的方式。

==读写分离:==

读写分离主服务器可以进行读和写操作,当发生写操作时自动将写操作同步给从服务器;而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。

img

该方式,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

==主从复制:==

全量同步(从节点是新加入集群的):

  1. 从节点向主节点发送 slaveof 命令,包括(replid=?, offset=-1),建立复制关系,并发送请求同步命令。

  2. 主节点收到命令后,判断要进行哪种同步,若是全量同步,会创建一个后台子线程,负责将自己的数据**快照(RDB文件)**发送给从节点。

  3. 从节点收到数据快照后,会先保存在本地,然后清空自己的数据,并载入收到的数据快照

  4. 主节点在等待从节点同步数据的同时,还会将自己新执行的所有写命令缓存在 replication buffer 中。

  5. 当从节点完成数据快照的载入后,会向主节点发送一个消息,请求接收写命令

  6. 主节点收到消息后,会将 replication buffer 中的写命令依次发送给从节点,从而使得从节点与主节点保持一致。

  7. 此后,主节点和从节点保持一个长连接。每执行一个写命令,就会将该命令同步给所有的从节点。

image-20230421214614374

增量同步(从节点重启后同步):

  1. 若网络不好,长连接断了。恢复后,主从服务器会采用增量同步的方式继续同步。
  2. 从节点向主节点发送同步命令(replid, offset);
  3. 主节点判断 replid 是否一致,若一致且从节点的 offset 跟主节点的 offset 差距没有过大(未同步的数据没被覆盖),就会增量备份,主节点会从repl_baklog获取从节点offset之后的增量数据,将增量数据写入到 replication buffer 缓冲区,然后发送给从节点。
image-20230421215231403 image-20230511204945114

问:主节点如何判断从节点是不是第一次来同步数据?

两个很重要的概念:

  • **Replication Id:**简称replid,是数据集的标记,id一致说明是同一数据集。每一个主节点都有唯一的replid从节点会继承主节点的replid
  • Offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。从节点完成同步时也会记录当前同步的offset如果从节点的offset小于主节点的offset,说明从节点数据落后于主节点,需要更新。

从节点同步数据时,必须向主节点声明自己的replidoffset,然后由主节点判断需要同步哪些数据:

  • replid为 “?” 或 不一样,就是第一次,将进行全量同步
  • replid一样,就通过offset进行增量同步

==注意:==难道从节点断联特别久,再连接上也是增量同步吗?

当然不是!这就跟repl_baklog有关了~

repl_baklog相当于一个环形缓冲区,写满之后就会覆盖最早的数据。如果从节点断开过久,导致尚未备份的数据已经被覆盖了,就无法基于repl_baklog做增量同步了,只能触发全量同步

问:全量同步和增量同步的区别?什么时候执行全量同步?什么时候执行增量同步?

区别:

  • **全量同步:**主节点将内存数据生成RDB,发送RDB文件给从节点。后续命令记录在baklog中,逐个发送给从节点;
  • **增量同步:**从节点提交自己的offset到主节点,主节点获取baklog中的offset之后的命令给从节点。

什么时候全量同步:

  • 从节点第一次连接到主节点时;
  • 从节点离开太久,未同步的baklog已经被覆盖;

什么时候增量同步:

  • 从节点断开又恢复,并且在baklog中能找到offset时。

问:如何优化主从数据同步?

优化全量同步

  • 在网络条件很好的时候,在 master 启用无磁盘复制,也就是不把 RDB 文件写入到磁盘,而是直接写入网络IO,减少磁盘的复制;

减少全量同步次数

  • 适当提高repl_backlog
  • 发现从节点宕机时,尽快实现故障恢复,尽可能避免全量同步;
  • 限制一个主节点上的从节点数量,如果实在太多从节点,则可以采用 主-从-从 的结构,减少主节点压力。
image-20230421220203635

问:Redis主从节点时长连接还是短连接?

长连接

问:怎么判断 Redis 某个节点是否正常工作?

Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心跳检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。

Redis 主从节点发送的心跳间隔是不一样的,而且作用也有一点区别

  • Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数repl-ping-slave-period控制发送频率。
  • Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了:
    • 实时监测主从节点网络状态;
    • 上报自身复制偏移量,检查复制数据是否丢失,如果从节点数据丢失,再从主节点的复制缓冲区中拉取丢失数据。

问:主从复制架构中,过期key如何处理?

主节点处理了一个key或者通过淘汰算法淘汰了一个key时,主节点需要模拟一条del命令发送给从节点,从节点收到该命令后,就进行删除key的操作。

问:Redis 是同步复制还是异步复制?

Redis 主节点每次收到写命令之后,先写到内部的缓冲区,然后异步发送给从节点。

问:主从复制中两个 Buffer(replication buffer 、repl backlog buffer)有什么区别?

replication buffer 、repl backlog buffer 区别如下:

  • 出现的阶段不一样:
    • repl backlog buffer 是在增量复制阶段出现,一个主节点只分配一个 repl backlog buffer
    • replication buffer 是在全量复制阶段和增量复制阶段都会出现,主节点会给每个新连接的从节点,分配一个 replication buffer
  • 这两个 Buffer 都有大小限制的,当缓冲区满了之后,发生的事情不一样:
    • 当 repl backlog buffer 满了,因为是环形结构,会直接覆盖起始位置数据;
    • 当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,重新开始全量复制

问:如何应对主从数据不一致?

之所以会出现主从数据不一致的现象,是因为主从节点间的命令复制是异步进行的,所以无法实现强一致性保证(主从数据时时刻刻保持一致)。

有两种方法

  • 第一种方法,尽量保证主从节点间的网络连接状况良好,避免主从节点在不同的机房。

  • 第二种方法,可以开发一个外部程序来监控主从节点间的复制进度。具体做法:

    1. Redis 的 INFO replication 命令可以查看主节点接收写命令的进度信息(master_repl_offset)和从节点复制写命令的进度信息(slave_repl_offset),所以,我们就可以开发一个监控程序,先用 INFO replication 命令查到主、从节点的进度,然后,我们用 master_repl_offset 减去 slave_repl_offset,这样就能得到从节点和主节点间的复制进度差值了。
    2. 如果某个从节点的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从节点连接进行数据读取,这样就可以减少读到不一致数据的情况。不过,为了避免出现客户端和所有从节点都不能连接的情况,我们需要把复制进度差值的阈值设置得大一些。

问:主从切换如何减少数据丢失?

主从切换过程中,产生数据丢失的情况有两种:

  • 异步复制同步丢失
  • 集群产生脑裂数据丢失

不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。

异步复制同步丢失

对于 Redis 主节点与从节点之间的数据复制,是异步复制的,当客户端发送写请求给主节点的时候,会返回 ok,接着主节点将写请求异步同步给各个从节点,但是如果此时主节点还没来得及同步给从节点时发生了断电,那么主节点内存中的数据会丢失。

减少异步复制的数据丢失的方案

Redis 配置里有一个参数 min-slaves-max-lag,表示一旦所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值,那么主节点就会拒绝接收任何请求。

假设将 min-slaves-max-lag 配置为 10s 后,根据目前 master->slave 的复制速度,如果数据同步完成所需要时间超过10s,就会认为 master 未来宕机后损失的数据会很多,master 就拒绝写入新请求。这样就能将 master 和 slave 数据差控制在10s内,即使 master 宕机也只是这未复制的 10s 数据。

那么对于客户端,当客户端发现 master 不可写后,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间(等 master 恢复正常)后重新写入 master 来保证数据不丢失,也可以将数据写入 kafka 消息队列,等 master 恢复正常,再隔一段时间去消费 kafka 中的数据,让将数据重新写入 master 。

集群产生脑裂数据丢失

由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

减少脑裂的数据丢的方案

当主节点发现「从节点下线的数量太多」,或者「网络延迟太大」的时候,那么主节点会禁止写操作,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果主从同步的延迟超过 x 秒,主节点会禁止写数据。

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。

这两个配置项组合后的要求是,主节点连接的从节点中至少有 N 个从节点,「并且」主节点进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主节点就不会再接收客户端的写请求了。

即使原主节点是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从节点进行同步,自然也就无法和从节点进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主节点就会被限制接收客户端写请求,客户端也就不能在原主节点中写入新数据了

等到新主节点上线时,就只有新主节点能接收和处理客户端请求,此时,新写的数据会被直接写到新主节点中。而原主节点会被哨兵降为从节点,即使它的数据被清空了,也不会有新数据丢失。我再来给你举个例子。

假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主节点因为某些原因卡住了 15s,导致哨兵判断主节点客观下线,开始进行主从切换。同时,因为原主节点卡住了 15s,没有一个从节点能和原主节点在 12s 内进行数据复制,原主节点也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主节点能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。

8.2 哨兵机制

哨兵模式是在主从复制的基础上增加了一组哨兵(sentinel)节点来监控主从节点的状态,实现主从节点自动故障转移。哨兵的结构和作用如下:

  • 监控:Sentinel 会不断检查 Master 和 Slave 的状态
  • 自动故障恢复:如果 Master 故障,Sentinel 会自动将一个 Slave 提升为 Master。当故障节点恢复后也会以新的 Master 为主节点
  • 通知:Sentinel 充当 Redis 客户端的服务发现源,当集群发生故障转移时,会将最新信息自动推送到 Redis 客户端
image-20230421221236704

问:哨兵机制是如何实现的?

如果发现主节点失效,哨兵会自动选举出一个领导者(leader),然后由领导者从剩余的从节点中选出一个新的主节点,并通知其他哨兵和客户端。

==具体工作过程:==

  1. 哨兵会定期(每隔1s)向所有的主节点和从节点发送心跳包,检测它们的运行情况;
  2. 如果一个哨兵发现某个主节点没有响应心跳包,就会将其标记为主观下线,然后请求其他哨兵节点进行投票
  3. 如果有超过指定值的哨兵(配置文件中指定的 quorum 值)都将某个主节点标记为主观下线(也就是赞成),那么该主节点就被认为是客观下线,并开始进行故障转移
  4. 故障转移过程前,哨兵会从该主节点的所有候选者中选出一个合适的候选者,将其升级为哨兵中的 Leader,负责进行故障转移;

候选者:哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点以及投赞成票的哨兵节点就都是候选者;

  1. Leader 会从所有的从节点中选择一个合适的从节点作为新的主节点
  2. 故障转移完成后,原来的主节点如果恢复了正常,就会变成新主节点的从节点。
img

问:哨兵 如何成为 哨兵Leader?

候选者:哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点以及投赞成票的哨兵节点就都是候选者。

  • 候选者会向其他哨兵发送投票请求;

  • 每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己;

  • 要成为哨兵 Leader,需满足以下条件:

    • 第一,拿到半数以上的赞成票;
    • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

问:哨兵Leader如何选择新的主节点?⭐

哨兵 Leader 通过如下规则选择新的主节点:

  • 选择 Slave 中优先级最高的。优先级是在 Slave 配置中设置的;
  • ⭐哨兵 Leader 会优先选择 offset 最大的 Slave,因为它表示数据最完整。
  • 如果有多个从节点复制偏移量相同,ID 号小的从节点胜出。

问:哨兵Leader节点进行 主从故障转移操作 的过程?⭐

  • 挑选出 Master
  • **广播通知所有 Slave **与新的 Master 进行同步;
  • 将新的 Master 通过「发布者/订阅者机制」通知给客户端;
  • 继续监视旧的 Master ,当它重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新的 Master 的 Slave。

问:哨兵集群是怎么组成的?

哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的

在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。

img

问:集群脑裂?

  1. 什么是脑裂?

由于网络问题,哨兵和从节点都与主节点失联了,但主节点依然正常运行,此时由于哨兵以为主节点挂了,所以就会从从节点选出新的主节点,此时就有两个主节点 - ==脑裂现象==

由于此时旧主节点依然和客户端能正常通信,客户端向旧主节点写入数据,然后网络又好了!此时旧主节点发现已经有新的主节点,就会变为从节点,清楚自身所有数据,然后进行全量同步,显然这样会导致丢失部分数据。

**总结一句话就是:**由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

  1. 咋解决呢?

当主节点发现 $从节点总数量 - 从节点下线或者通信超时的数量 < 阈值$ 时,那么禁止主节点进行写数据操作,直接把错误返回给客户端。

等到新主节点上线时,就只有新主节点能接收和处理客户端请求,此时,新写的数据会被直接写到新主节点中。而原主节点会被哨兵降为从节点,即使它的数据被清空了,也不会有新数据丢失。

8.3 分片集群

问:分片集群是如何实现的?

集群模式是将多个 Redis 节点组成一个分布式系统,每个节点负责一部分数据,并通过**槽(slot)**来映射数据分片。集群模式支持多个主节点和多个从节点,每个主节点可以有多个从节点作为备份。集群模式可以实现数据的分布式存储、读写分离、负载均衡、故障转移等功能,提高了服务可扩展性和稳定性,但是也增加了系统复杂度和维护成本。

==散列插槽==

Redis 共有 16384 个哈希槽(hash slot),将每个 key 通过哈希函数 crc16() 将 key 转化成一个长整型数字,再对 16384 取余,最终决定这个 key 存储的哈希槽。也就是会把每个 key 映射到0-16383个插槽上,每个主节点负责一部分插槽(即一部分键值对)。

==实现方式==

  • 客户端分片

客户端自己计算key需要映射到哪一个主节点。

优点:降低了集群的复杂度

缺点:当新增Redis实例时需要支持动态分片

  • 服务端分片

客户端访问某个key时,服务器计算key应该映射到哪个节点,若不是当前节点,就会重定向到指定节点。

  • 代理分片

客户端将请求发送到代理,代理计算得到需要映射的节点信息,然后将客户端的请求转发到对应节点上,然后返回响应给客户端。

==分片机制的缺点==

  • 分片是由多台Redis实例共同运转,所以如果其中一个Redis实例宕机,则整个分片都将无法使用,所以分片机制无法实现高可用
  • 如果有不同的key映射到不同的Redis实例,这时候不能对这两个key做交集或者使用事务。
  • 使用分片机制因为涉及多实例,数据处理比较复杂。
  • 分片中对于实例的添加或删除会很复杂,不过可以使用预分片技术进行改善。

问:自动故障转移和手动故障转移?

自动故障转移:

  • 首先是 Master 与其他节点失去连接;
  • 其他节点标记 Master 为疑似宕机;
  • Master 确定宕机,自动提升一个 Slave 为 Master。

手动故障转移:利用 cluster failover 命令可以手动让集群中某个 Master 宕机,切换到执行 cluster failover 命令的这个 Slave 节点,实现无感知的数据迁移

image-20230422110706241

九、多级缓存(未)

多级缓存就是利用请求处理的每个环节,分别添加缓存,减轻 TomCat 的压力。

image-20230422111545683

十、Redis 实战(未)

10.1 如何为秒杀系统设计缓存体系