知识体系之Redis

目录

1.Redis数据结构

2.底层数据结构

2.1.简单动态字符串SDS

2.2.哈希表

2.3.压缩列表ziplist

2.4.跳跃表skipList

3.过期删除与内存淘汰

3.1.Redis 使用的过期删除策略是什么?

3.2.Redis 主从模式中,对过期键会如何处理?

3.3.Redis 内存满了,会发生什么?

3.4.Redis 内存淘汰策略有哪些?

4.持久化机制RDB/AOF

4.1.Redis 如何实现数据不丢失?

4.1.RDB

4.1.1.概念

4.1.2.RDB的三种触发机制

4.1.3.优缺点

4.1.4.rdb备份会阻塞主线程么?

4.2.AOF

4.2.1.AOF 日志是如何实现的?

4.2.2.AOF的3种写回策略/刷盘策略

4.2.3.优缺点

4.2.4.rewrite机制

4.3.混合持久化: RDB+AOF

4.4.Redis大Key对持久化的影响?

4.4.1.大Key对AOF日志的影响

4.4.2.大 Key 对 「AOF 重写」和「RDB快照」的影响

5.Redis高可用

5.1.主从模式

5.1.1.作用

5.1.2.主从数据同步的过程

5.1.3.增量同步

5.1.4.面试题

5.2.哨兵

5.2.1.作用

5.2.2.原理

5.2.3.缺点

5.2.4.哨兵集群

5.3.切片集群模式 Cluster

5.3.1.原理

5.3.2.在集群中执行命令

5.3.3.重新分片

5.3.4.故障

5.3.5.Redis分区/分片(sharding)

5.3.6.Redis Cluster的Gossip通信机制

5.3.7.数据量分配倾斜

5.3.8.数据访问倾斜

6.Redis扩展特性

6.1.发布订阅模式

6.2.事务

6.2.1.SQL和Redis的事务有本质的区别

6.2.2.Redis事务原理

6.2.3.Redis 事务支持回滚吗?为什么Redis 不支持事务回滚?

6.3.Redis单线程?多线程?

6.3.1.Redis单线程?多线程?

6.3.2.为什么redis单线程模型处理速度如此之快?

6.3.3.Redis6.0之前为什么之前一直使用单线程?

6.3.4.Redis6.0之后为什么引入了多线程?

6.3.5.Redis6.0默认开启多线程么?如何开启和设置线程数?

7.实战篇

7.1.Redis实现延迟队列

7.2.Redis实现消息队列 / 异步队列

7.2.1.基于List的消息队列解决方案

7.2.2.基于Streamer的消息队列解决方案:redis专门为消息队列设计的数据类型

7.3.Redis变慢CheckList \ 如何排查Redis性能问题

7.4.缓存三大问题

7.4.1.缓存雪崩

7.4.2.缓存击穿

7.4.3.缓存穿透

7.5.缓存一致性问题

7.6.缓存更新策略

7.6.1.Cache Aside(旁路缓存)策略

7.6.2.Read/Write Through(读穿 / 写穿)策略

7.6.3.Write Through 策略

7.7.分布式锁

7.8.Redis的大Key如何处理?

7.8.1.什么是 Redis 大 key?

7.8.2.大 key 会造成什么问题?

7.8.3.如何找到大 key ?

7.8.4.如何删除大 key?

7.9.Redis管道Pipeline

7.9.1.Redis cluster模式下如何查询多个key,具体的查询过程是怎样的?

7.10.生产环境中Redis是怎么部署的?

7.11.场景:有台 8 核机器,只部署 Redis,有什么办法可以尽可能提高 Redis 性能


1.Redis数据结构

 Redis 五种数据类型的应用场景:

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

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

2.底层数据结构

2.1.简单动态字符串SDS

struct sds{
	int len;     // buf数组已经使用字符串的长度
	int free;    // buf数组未使用的长度
	char buf[];  // 保存字符串
};

Q1:为什么要使用SDS,而不使用C字符串?

A1:

背景:redis作为缓存数据库,数据经常被修改,造成内存重分配,影响性能

原因详述

  1. SDS有len成员变量,可以在O(1)时间复杂度获得长度信息
  2. 更安全,杜绝缓冲区溢出(当SDS执行strcat/strcpy等函数时,会先检查free是否够用,不够用时,就扩增)
  3. 减少重分配次数:①扩增:SDS会多分配free的空间,当需要扩容时,若free空间足够,直接改变len/free的值就可以 ②缩短:缩短时,直接修改free的值,不需要释放旧空间,申请小的新空间存放新字符串

2.2.哈希表

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

struct dictht {
	dictEntry_t **table;  // 数组,每个元素都是dictEntry_t*,它是一个链表头,所有冲突的key挂在相同链表上
	unsigned long size;   // 哈希表容量大小
	unsigned long used;   // 当前已经使用大小
}

// 字典
struct dict {
	dictht ht[2];   // 2个哈希表: ht[0]正常情况下使用, ht[1]在rehash时使用
    int rehashidx;  // rehash索引 (没进行rehash时,该值为-1)
}
```

Q1:为什么要进行rehash

A1:哈希表中键值对的增加/减少,都可能导致rehash(为了使哈希表的 负载因子 维持在合理的范围内):一般进行2倍扩充(算法导论中的平摊分析)

Q2:rehash过程

A2:

  1. ht[1]分配空间,新建一个空的哈希表
  2. rehash索引计数器(rehash_index),由-1变为0,表示rehash正式开始
  3. 将ht[0]中的元素,rehash重新散列到ht[1]上
  4. 每次一个(key,value)键值对rehash成功后,rehash索引计数器都+1
  5. 当所有的ht[0]都rehash到ht[1]中后,ht[0]被清空,此时将ht[0],ht[1]交换,rehash结束,最后将rehash索引设为-1

在rehash过程中,新增加的(key,value)键值对,怎么处理?

  • 会直接rehash到ht[1]上,这样做,会保证ht[0]只减不增:rehash过程,是在增删改查时,一点点的将hash[0]上的数据,rehash到hash[1]上,将rehash的过程平摊到各个crud过程中,不会对redis造成阻塞

2.3.压缩列表ziplist

  1. 使用场景
    1. 列表键、哈希键: 含有少数的键,且键是“短整型”、“短字符串”
  2. 优点
    1. 节省内存,实现简单,是连续内存块的顺序存储(有点像变长数组,它通过长度划分每个节点)
  3. 每个`压缩列表节点`构成
    1. 前一个节点的长度pre_len 当前节点的长度、类型
    2. 当前节点的数据内容
  4. 连锁更新:当插入和删除元素时,可能会导致连锁更新。
    1. (big1、small、big2):当删除small时,将会引起big2后面的节点连锁更新
    2. (全small)原ziplist节点都是长度小于256:当在idx插入大于256的节点时,idx+1后面的节点e1的成员pre_len无法保存前一个节点的长度,因此,要重分配内存。这样e1内存就扩增了,因为是顺序存储,所以e2、e3后面的元素都要向后移动(更新)

2.4.跳跃表skipList

  1. 结构:是一个**多层次**的链表,每层节点的**next跨度**大小都不同,从上到下依次减小
  2. 时间复杂度
    1. 性能可以和AVL树媲美,且实现简单
    2. 最好O(lgN)
    3. 最差O(N)
  3. 使用场景:zset有序集合键
  4. Redis为什么使用skiplist,不使用红黑树

    1. 范围查找的时候,平衡树比skiplist操作要复杂

      1. 在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现

      2. 而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现

    2. 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。

    3. 从内存占用上来说,skiplist比平衡树更灵活一些

      1. 一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

    4. 查找单个key,skiplist和平衡树的时间复杂度都为O(logn),大体相当

    5. 算法实现难度上来比较,skiplist比平衡树要简单得多

3.过期删除与内存淘汰

3.1.Redis 使用的过期删除策略是什么?

Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

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

Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。

  1. 定时删除机制
    1. 原理:开启定时器,扫描删除(不是全量扫描,而是批量扫描)
    2. 优缺点:删除及时;消耗CPU
  2. 惰性删除机制
    1. 原理:一个数据过期后,并不会立即删除,而是等到再有请求来读取这个key时,对数据进行检查,如果发现数据已经过期了,再删除这个数据
    2. 优缺点:不消耗CPU;对于用不到的数据,不尽快删除,会占据内存资源
  3. redis4.0新特性:Lazy Free
    1. 出现原因:从根本上上解决大key(元素较多集合类型)删除的风险
    2. 定义:定义:当删除key时,redis提供异步延时释放key内存的功能,把key释放操作放在bio(background I/O)单独的子线程处理,减少删除大key对redis主线程的阻塞
    3. 为什么需要lazy-free
      1. redis是单线程程序,当运行一个消耗较大的请求(大key删除),会导致所有请求排队等待

      2. 在redis4.0之前,没有lazy-free,DBA只能通过取巧的方法,类似scan big key,每次删除100个元素;但是面对“被动删除键”的场景,这种取巧的删除就无能为力了

    4. lazy-free的使用分为2类

      1. 主动删除:unlink

      2. 被动删除:与下面4个配置有关

        1. lazyfree-lazy-eviction:redis内存达到maxmeory && 设置有淘汰策略

        2. lazyfree-lazy-expire:设有TTL的key,达到过期后

        3. slave-lazy-flush:salve全量数据同步时,slave在加载master的rdb文件前,会运行flushall清理自己的数据

        4. lazyfree-lazy-server-del:隐式del操作,如rename

3.2.Redis 主从模式中,对过期键会如何处理?

当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。

从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key

3.3.Redis 内存满了,会发生什么?

在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory

3.4.Redis 内存淘汰策略有哪些?

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 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值

4.持久化机制RDB/AOF

4.1.Redis 如何实现数据不丢失?

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。

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

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

数据持久化:redis是内存数据库,把数据保存在磁盘中,就是数据持久化

4.1.RDB

4.1.1.概念

rdb备份,是每次将数据库中的所有键值对,保存到文件中

在指定时间间隔内,将内存中的数据和操作,通过【快照】的方式保存到RDB文件

因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西。(所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据)

4.1.2.RDB的三种触发机制

  1. SAVE:save会阻塞redis服务器进程,redis不能处理客户端的其他命令请求,直到rdb文件创建完毕为止
  2. 手动触发:BGSAVE会创建一个子进程,更新rdb文件,父进程可以继续处理请求
  3. 自动触发:通过redis配置文件来完成

Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:

save 900 1     # 900 秒之内,对数据库进行了至少 1 次修改;
save 300 10    # 300 秒之内,对数据库进行了至少 10 次修改;
save 60 10000  # 60 秒之内,对数据库进行了至少 10000 次修改

4.1.3.优缺点

优点

  1. RDB文件紧凑,全量备份(保存的是数据),非常适用于备份和灾难恢复
  2. RDB恢复速度比AOF快(AOF要进行重放)

缺点

  1. 全量部分:快照方式备份,耗用时间多
  2. 不安全:会丢失时间间隔内的数据
  3. fork子进程开销:每次保存rdb文件时,都要fork一个子进程持久化,性能开销较大

4.1.4.rdb备份会阻塞主线程么?

SAVE:在主线程执行,阻塞主线程

BGSAVE:创建子进程,专门用于写入rdb文件,主线程不会阻塞

RDB 在执行快照的时候,数据能修改吗?

可以的,执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。

执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

4.2.AOF

4.2.1.AOF 日志是如何实现的?

        Redis将每次更新写操作命令(这里不是数据,而是命令),都以追加方式写入AOF文件。重启时,只需要从头到尾执行一次AOF中的指令,可以恢复数据。

为什么先执行命令,再把数据写入日志呢?(为什么不使用WAL)

Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。

  • 避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

当然,这样做也会带来风险:

  • 数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
  • 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。

4.2.2.AOF的3种写回策略/刷盘策略

先来看看,Redis 写入 AOF 日志的过程,如下图:

具体说说:

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

        Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;

  1. 可以做到基本不丢数据,但是因为在执行写命令后有一个同步刷盘操作,不可避免的会影响主线程性能
  2. 最多可能丢失1条数据(因为AOF采用的不是WAL)

Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;

No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

4.2.3.优缺点

优点

  1. 备份机制更稳健,丢失数据概率更低
  2. 可读的日志文本,通过操作AOF稳健,可以处理误操作

缺点

  1. 恢复备份速度要慢:发生宕机,aof中记录的命令要被一个个重新执行,整个恢复过程十分缓慢
  2. 比起RDB占用更多的磁盘空间:每条命令写入aof文件,aof文件会变的很大,占据磁盘空间+写入效率低下
  3. 每次读写都同步的话,有一定的性能压力

4.2.4.rewrite机制

1. 为什么要引入rewrite机制

        AOF采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制。

        AOF重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,全盘读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。

2. 什么情况下触发rewrite

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size   64mb
  • 系统载入或者上次重写完毕时,Redis会记录此时AOF大小,设为base_size。如果Redis的AOF文件当前大小>= base_size+base_size*100% && 当前大小>=64mb的情况下,Redis会对AOF进行重写。

3. AOF如何实现重写

AOF文件持续增长而过大时,会触发rewrite

  • fork出一条新进程,遍历新进程的内存中数据,每条记录有一条的Set语句

4. 如何保证在rewrite时,主进程能继续提供服务?

触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

但是重写过程中,主进程依然可以正常处理命令,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,那么会发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

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

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

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

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

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

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

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

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

4.3.混合持久化: RDB+AOF

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

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

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

假设,RDB备份时间为N,执行过程如下

  1. 时间到达时,执行第一次RDB全量备份,得到checkpoint1
  2. 在时间区间(N,2N)时,产生的增量数据,记录到AOF文件中
  3. 当时间达到2N时,执行第2次RDB全量备份的过程为:在checkpoint1的基础上,重新执行AOF日志记录的操作,生成checkpoint2,清空AOF

4.4.Redis大Key对持久化的影响?

字节一二面时,被问到:Redis 的大 Key 对持久化有什么影响?

4.4.1.大Key对AOF日志的影响

分别说说AOF 3种写回磁盘策略,在持久化大 Key 的时候,会影响什么?

当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的

当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。

当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程

4.4.2.大 Key 对 「AOF 重写」和「RDB快照」的影响

AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。

5.Redis高可用

​ Redis实现高可用,在于提供多个节点,通常有三种部署模式:主从模式哨兵模式集群模式

5.1.主从模式

5.1.1.作用

  1. 正常情况下,主节点提供写服务,并将数据同步到备份机器;当主节点宕机后,子节点立即开始服务
  2. master宕机后,通过选举投票方式选出新的master,继续提供服务
  3. 实现读写分离,提高并发性

5.1.2.主从数据同步的过程

主从库数据第一次同步的三个阶段

  1. 第一阶段:建立连接,协商同步
    1. 从库告诉主库建立连接,并且发送PSYNC命令,告诉主库即将进行数据同步。PSYNC命令包括了主库ID、复制offset两个参数
    2. 主库收到PSYNC命令后,会使用FULL RESYNC(第一阶段,采用RDB方式,进行全量复制)响应命令带上两个参数:主库ID、主库目前的复制offset
  2. 第二阶段:主库将所有数据同步给从库,从库接收到数据后,在本地完成数据加载
    1. 主库执行BGSAVE,生成RDB文件,将RDB文件发送给从库
    2. 从库接收到RDB文件后,先清空当前数据库,然后加载RDB文件
  3. 第三阶段:主库会把第二阶段中执行的新的写入命令,重新发送给从库
    1. 当主库完成RDB文件发送后,会将“复制积压缓冲区”中修改的操作发送给从库
    2. 从库再重新执行这些操作,这样一来,主从库就实现了数据一致

5.1.3.增量同步

旧版本:复制功能的缺陷(断线后,重新复制):网络断线后,会重新全量复制

新版本解决了断线重复复制问题:复制积压缓冲区,主服务器和从服务器都维护了自己的复制偏移量

  1. 当主服务器向从服务器传播N个字节的数据后,会把自己的复制偏移量+N
  2. 从服务器收到主服务器传来的N个字节且更新成功后,也会将自己的复制偏移量+N

因此,通过主从服务器的复制偏移量,就能知道二者是否处于一致性状态!

  1. 主服务器,还维护了一个定长的复制积压缓冲区,每次向从服务器发送更新数据时,同时会向复制积压缓冲区写入数据
  2. 当连接断开后,从服务器判断自己的复制偏移量offset是否在复制积压缓冲区中
    1. 如果在,执行部分复制
    2. 如果不在,执行全量复制

5.1.4.面试题

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

A1:长连接

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

A2:通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接

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

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

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

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

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

A5:replication buffer 、repl backlog buffer 区别如下:

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

5.2.哨兵

5.2.1.作用

在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。

为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

5.2.2.原理

哨兵将会监控集群中所有的节点:一般为了防止单哨兵节点故障,将配置多个哨兵协同合作

切换过程

  1. 主观下线:哨兵A检测到主节点下线后,将不会立即切换主节点,而是认为它客观下线

  2. 询问:哨兵A会询问监听该主节点的其他哨兵,收集汇总信息,当有足够多的主观下限信息时,判断是否为客观下线

  3. 选取新领头哨兵:当有一个哨兵判断为客观下限后,将会选举出领头哨兵,由它进行切换主节点操作

主从复制面临的3个问题

  1. 主库真的挂了么?
  2. 该选择那个从库作为主库?
  3. 怎么把新主库的相关信息通知给从库和客户端呢?

哨兵机制的基本流程 ==> 哨兵机制是实现主从库自动切换的关键机制,它有效解决了主从复制模式下故障转移的3个问题。哨兵主要负责的就是3个任务,如下:

  1. 监控:哨兵在运行时,周期性的给所有主从库发送ping命令
    1. 主观下线:某一个哨兵判断主库处于主观下线状态
    2. 客观下线:询问哨兵集群中的其他哨兵,主库是否下线,当>=(n/2+1)个哨兵下线后,认为客观下线
  2. 选主:“筛选+打分”,按照一定的规则,给从库打分,从得分最高的从库中选择一个作为新的主
    1. 筛选+打分
      1. 从库在线
      2. 之前的网络连接状态
      3. 从库优先级(salve-proxy配置项)、从库复制进度、从库ID号码
    2. 选主:投票机制,quorum
  3. 通知
    1. 将新主库的连接信息发送给其他从库,让从库执行replicaof命令,和新主库建立连接,执行数据复制
    2. 将新主库的链接信息发送给客户端,让客户端的请求发到新主库

5.2.3.缺点

  1. 运维复杂
  2. 哨兵选主期间,不能对外提供服务(因为如果master宕机后,redis不可用,要等到重新选出主后才能对外提供服务)

5.2.4.哨兵集群

引入:哨兵集群的配置,只需要设置主库的IP和端口,并没有配置其他哨兵的连接信息

问题:这些哨兵不知道彼此的地址,又是怎么组成集群的呢?==>哨兵集群的组成和运行机制

哨兵集群的组成和运行机制:基于发布订阅的哨兵集群组成

  1. 哨兵实例之间可以相互发现,归功于redis的发布订阅机制
  2. 哨兵只要和主库建立起连接,就可以在主库上发布消息了,比如:发布它自己的连接信息(IP+PORT),同时,它也可以在从库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布订阅操作后,它们之间就能知道彼此的IP+port

5.3.切片集群模式 Cluster

5.3.1.原理

一个集群通常有多个服务器节点组成

  1. 最开始时,各个服务器节点是相互独立的
  2. 之后,将各个节点连接起来

槽指派

  1. 一个redis集群中共有16384个槽(这些哈希槽类似于数据分区),每个键值对都根据它的key,映射到一个哈希槽中,哈希槽的计算公式=CRC16(key)%16384
  2. 所有槽全部分配到redis实例后,redis集群才能真正地对外提供服务。只要有任何一个槽发生问题,整个集群就不可用

将槽分配到集群中某个节点的算法

  1. 取模算法
  2. 一致性哈希算法

5.3.2.在集群中执行命令

        当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽

  1. case1: 如果键所在的槽正好就指派给当前节点,那么当前节点直接执行该命令
  2. case2: 如果键所在的槽没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令

5.3.3.重新分片

  1. 定义:将任意数量已经指派给某个节点的槽,改为指派给另一个节点,并且相关槽所属的键值对也会从源节点移动到目标节点。
  2. 重新分片可以「在线进行」,即:在重新分片过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求

各个分片组成主从复制:上面已经知道,集群中某个节点维护了某个区域的槽

  1. 为了防止该节点挂掉,一般对该节点配置主从关系,形成主从复制结构
  2. 从节点会复制主节点的槽,当某个主节点故障了,因为从节点已经复制了它的槽,所以该从节点将会升级为主节点,继续服务。(与哨兵相比,在此期间,不会停止服务)

5.3.4.故障

故障检测

故障检测:每个节点会定时向集群中其他节点发送ping,检测对方是否在线

  1. 集群中各个节点会互相发送消息,交换集群中各个节点的状态信息
  2. 当某个主节点x疑似下线的数过半时,将会被标记为已下线,之后,会广播给集群中所有节点,告诉它们节点x已经下线

故障转移/raft选主算法

  1. 通过raft选主算法,会从下线的主节点的从节点中选取一个,让它成为新的主节点
  2. 新节点将会广播一条pong消息,通知其他节点自己已经变成了主节点
  3. 新主节点将接管原来已经下线的主节点,继续提供服务

raft共识算法就是三个主要模块:leader选举,数据同步和分区共识

leader选举

  • 所有节点都有一个定时器(每个节点的定时器都是随机值)。该定时器表示收到Leader的心跳包。

  • 如果在定时器内没有收到Leader的心跳包,则自己变成Candidate状态,参与竞选Leader。(选举超时,自己当成候选者)

  • 然后所有任参与选票,候选者的投票只会投给自己,其他人则投给某个候选者(可能出现多个候选者情况)

  • 如果自己的票数过半,则成为新的Leader。

  • 如果此时有人跟自己一样同时竞选Leader,那么这两个人都会在起一个定时器(随机值),进行在一轮的选票

数据同步

  • 当leader接收到请求后,将该请求写到自己的日志中

  • leader将该请求发送给其他节点,要求其他节点也写到日志中

  • 其他节点将请求写到日志后,回一个ack给leader

  • leader统计有过半节点回复了ack后,将请求数据更新

  • leader发送请求给其他节点,要求其他节点将数据更新

分区共识

  • 集群5个节点,一开始1个leader和4个Follower

  • 然后网络出现问题,把1个leader和1个Follower划分为一个区A。而其他三个节点为另外一个区B

  • 另外三个节点为一个区A后,进行重新选举leader

  • 有个clientA向分区A发送请求,但是这个时候分区只有两个节点,没有超过半数所以该请求无效

  • 有个clientB向分区B发送器请求,有过半节点同意该请求,所以请求有效,更新数据

  • 当网络恢复后,出现了两个leaderA和leaderB。因为leaderB是新一代的leader,所以leaderA放弃做leader。并且将分区B的数据更新到分区A的每个节点上

  • 问题:一致性并不一定代表完全正确性!三个可能结果:成功,失败,unknown

5.3.5.Redis分区/分片(sharding)

  1. 定义:分区是分割数据到多个Redis实例的处理过程,因此每个实例只保存key的一个子集(一个分区,就是一个redis实例)
    1. 例如:可以在同一个服务器部署多个Redis的实例,并把他们当作不同的服务器来使用,在某些时候,无论如何一个服务器是不够的, 所以,如果你想使用多个 CPU,你可以考虑一下分片(shard)。
  2. 分区的优势
    1. 通过利用多台计算机内存的和值,允许我们构造更大的数据库
    2. 通过多核和多台计算机,允许我们扩展计算能力
    3. 通过多台计算机和网络适配器,允许我们扩展网络带宽
  3. 分区的不足
    1. 涉及多个key的操作通常是不被支持的。举例来说,当两个set映射到不同的redis实例上时,你就不能对这两个set执行交集操作。
    2. 涉及多个key的redis事务不能使用
    3. 当使用分区时,数据处理较为复杂,比如你需要处理多个rdb/aof文件,并且从多个实例和主机备份持久化文件。
    4. 增加或删除容量也比较复杂。redis集群大多数支持在运行时增加、删除节点的透明数据平衡的能力,但是类似于客户端分区、代理等其他系统则不支持这项特性。然而,一种叫做presharding的技术对此是有帮助的

5.3.6.Redis Cluster的Gossip通信机制

Gossip:流言蜚语

  • Gossip 协议(gossip protocol)又称 epidemic 协议(epidemic protocol),是基于流行病传播方式的节点或者进程之间信息交换的协议,在分布式系统中被广泛使用。利用一种随机的方式将信息传播到整个网络中,并在一定时间内使得系统内的所有节点数据一致。
  • Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

缺陷

  • 消息的延迟:由于 Gossip 协议中,节点只会随机向少数几个节点发送消息,消息最终是通过多个轮次的散播而到达全网的,因此使用 Gossip 协议会造成不可避免的消息延迟。不适合用在对实时性要求较高的场景下。
  • 消息冗余:Gossip 协议规定,因此就不可避免的存在消息重复发送给同一节点的情况,造成了消息的冗余,同时也增加了收到消息的节点的处理压力。而且,由于是定期发送,因此,即使收到了消息的节点还会反复收到重复消息,加重了消息的冗余。

为了让让集群中的每个实例都知道其他所有实例的状态信息,Redis 集群规定各个实例之间按照 Gossip 协议来通信传递信息。

上图展示了主从架构的 Redis Cluster 示意图,其中实线表示节点间的主从复制关系,而虚线表示各个节点之间的 Gossip 通信。

Redis Cluster 中的每个节点都维护一份自己视角下的当前整个集群的状态,主要包括:

  1. 当前集群状态
  2. 集群中各节点所负责的 slots信息,及其migrate状态
  3. 集群中各节点的master-slave状态
  4. 集群中各节点的存活状态及怀疑Fail状态

也就是说上面的信息,就是集群中Node相互八卦传播流言蜚语的内容主题,而且比较全面,既有自己的更有别人的,这么一来大家都相互传,最终信息就全面而且一致了。

Redis Cluster 的节点之间会相互发送多种消息,较为重要的如下所示:

  • MEET:通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群,然后新节点就会开始与其他节点进行通信;
  • PING:节点按照配置的时间间隔向集群中其他节点发送 ping 消息,消息中带有自己的状态,还有自己维护的集群元数据,和部分其他节点的元数据;
  • PONG: 节点用于回应 PING 和 MEET 的消息,结构和 PING 消息类似,也包含自己的状态和其他信息,也可以用于信息广播和更新;
  • FAIL: 节点 PING 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。

通过上述这些消息,集群中的每一个实例都能获得其它所有实例的状态信息。这样一来,即使有新节点加入、节点故障、Slot 变更等事件发生,实例间也可以通过 PING、PONG 消息的传递,完成集群状态在每个实例上的同步。下面,我们依次来看看几种常见的场景。

5.3.7.数据量分配倾斜

  1. 现象:redis实例上的数据分布不均匀,某些实例上的数据特别多,某些实例上数据特别少
  2. 原因:
    1. 大Key倾斜导致
    2. 运维人员slot分配不均衡导致
    3. hash tag导致倾斜
      1. hash tag:指在键值对对key中的一堆花括号{}。这对括号会把key的一部分括起来,客户端在计算key的crc16值时,只会对Hash Tag花括号中的key内容进行计算 ==> 导致大量的key会集中到某个实例中
      2. Hash Tag使用场景:将多个key,hash到同一个redis cluster分片上,使得可以使用LUA脚本、MGET等批量命令,对多个key执行操作
  3. 解决方案
    1. 在业务侧保证生成数据时,避免过多的数据保存在一个key中
    2. 一个key,拆分成多个key,分散存在不同的redis实例上

5.3.8.数据访问倾斜

  1. 只读热点数据
    1. 因为热点数据以服务读操作为主,所以采用「热点数据多副本」的方法来应对
    2. 具体做法:将热点数据复制多份,在每个数据副本的key中增加一个随机后缀,让它和其他副本数据不会被映射到同一个slot中(这样一来,热点数据既有多个副本同时提供服务,同时,这些副本数据的key又不一样,会被映射到不通的slot中)
  2. 有读有写热点数据
    1. 热点数据多副本方案只能针对只读热点数据,如果热点数据有读有写,为了保证多副本之间的一致性,就会带来额外的开销
    2. 具体做法:对于有读有写的热点数据,就要加redis实例

6.Redis扩展特性

6.1.发布订阅模式

客户端订阅服务端的频道

当服务器向该频道发送消息时,频道中所有的客户端收到该消息,执行响应的动作

6.2.事务

6.2.1.SQL和Redis的事务有本质的区别

  1. MySQL 在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。
  2. Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。

6.2.2.Redis事务原理

  1. 使用 「乐观锁」,只负责监听key有没有被改动
  2. 采用watch监听某个key
  3. 在执行命令时,检查该被监视的key是否已经被修改
  4. 如果该key时被改动,那么事务将会被打断

6.2.3.Redis 事务支持回滚吗?为什么Redis 不支持事务回滚?

大概的意思是,作者不支持事务回滚的原因有以下两个:

  • 他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;
  • 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。

这里不支持事务回滚,指的是不支持事务运行时错误的事务回滚

Q:Redis multi/EXEC命令、lua脚本可否保证原子性操作

A:Redis中的事务是不满足原子性的,详细分析见下:

  1. 存在「语法错误」的情况下,所有命令都不会执行

  2. 存在「运行错误」的情况下,除执行中出现错误的命令外,其他命令都能正常执行

那么为什么Redis不支持回滚呢,官方文档给出了说明,大意如下:

  1. Redis命令失败只会发生在语法错误或数据类型错误的情况,这一结果都是由编程过程中的错误导致,这种情况应该在开发环境中检测出来,而不是生产环境

  2. 不使用回滚,能使Redis内部设计更简单,速度更快

  3. 回滚不能避免编程逻辑中的错误,如果想要将一个键的值增加2却只增加了1,这种情况即使提供回滚也无法提供帮助

6.3.Redis单线程?多线程?

6.3.1.Redis单线程?多线程?

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

但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。

redis单线程模型是怎样的?

图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:

  • 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket
  • 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;
  • 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。

初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:

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

6.3.2.为什么redis单线程模型处理速度如此之快?

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的「内存」或者「网络带宽」,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

6.3.3.Redis6.0之前为什么之前一直使用单线程?

我们都知道单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?我们不妨先看一下Redis官方给出的答案。

核心意思是:CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到「内存大小」和「网络I/O」的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。

除了上面的官方回答,选择单线程的原因也有下面的考虑。

使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗

6.3.4.Redis6.0之后为什么引入了多线程?

  • 网络I/O采用多线程,命令的执行仍然是单线程

虽然 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多线程
io-threads-do-reads yes 
// 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 的压力

6.3.5.Redis6.0默认开启多线程么?如何开启和设置线程数?

  1. 默认多线程模式是关闭的
  2. 官方建议
    1. io-threads 4
    2. 4核机器建议设置为2或3,8核建议设置为6.(线程数一定要小于机器核数)
    3. 还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了
  3. 开启多线程后,是否会存在线程并发安全问题?
    1. 只是用来处理网络数据的读写和协议解析
    2. 执行命令仍然是主线程顺序执行
    3. ==> 所以,不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题

7.实战篇

7.1.Redis实现延迟队列

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

  • 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
  • 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;
  • 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间

使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。

 

时间序列数据的特点

  1. 因为时间是不回退的,所以只是插入操作
  2. 记录过的数据,无需更新

查询特点

  1. 点查询:查询某个时间点
  2. 范围查询:查询一段时间内
  3. 聚合计算:如何对时间序列做聚合计算,如,每3min算一次最大值

实现方案:基于zset保存时间序列数据

# 创建延迟任务 ZADD key score member [[score member] [score member] …]
Zadd DelayQueueKey <deal time> <task msg>
# 删除延迟任务 
Zrem DelayQueueKey <task msg>
# ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] 返回有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。有序集成员按 score 值递增(从小到大)次序排列
# 获取某个延迟任务:从zset拿出score在[0, nowTime]的元素
zrangebyscore DelayQueueKey 0 now_time limit 0 1
  1. key:delay queue的名字
  2. score:任务处理时间
  3. member:每一个任务实体

7.2.Redis实现消息队列 / 异步队列

消息:有序、重复幂等处理、消息可靠性

7.2.1.基于List的消息队列解决方案

  1. 有序性:List本身按照FIFO顺序对数据进行存储(LPUSH+RPOP)
  2. 重复幂等处理
  3. 消息可靠性:当消费者程序执行RPOP取出了数据,若执行程序失败or消费程序宕机了,该消息就不存在了
    1. Redis提供了BrpopLpush命令,作用是
      1. 让消费程序从一个List读取消息,同事插入到备份List
      2. 这样当消费者读取消息没正常处理时,等重启后,能从备份List重新读取消息

7.2.2.基于Streamer的消息队列解决方案:redis专门为消息队列设计的数据类型

  1. XAdd:插入消息,保证有序,可以自动生成全局唯一ID
  2. XRead:读取消息
  3. XReadGroup:按消费组形式读取消息
  4. XPending:查询每个消费组内所有消费者已经读取但未确认的消息
  5. XAck:向消息队列确认消息处理已完成(类似于手动提交)

7.3.Redis变慢CheckList \ 如何排查Redis性能问题

当遇到redis变慢时,按照以下checklist依次排查

  1. 获取redis实例在当前环境下的基线性能

  2. redis实例运行机器内存不足,导致swap发生

  3. 是否用了慢查询命令:如果是的话,使用其他命令代替慢查询命令

    1. 将聚合计算命令放在客户端做

    2. keys

  4. 大量key集中过期

    1. Redis 的过期数据采用被动过期 + 主动过期两种策略:

      1. 被动过期:只有当访问某个 key 时,才判断这个 key 是否已过期,如果已过期,则从实例中删除

      2. 主动过期:Redis 内部维护了一个定时任务,默认每隔 100 毫秒(1秒10次)就会从全局的过期哈希表中随机取出 20 个 key,然后删除其中过期的 key,如果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降到 25% 以下,或者这次任务的执行耗时超过了 25 毫秒,才会退出循环注意,这个主动过期 key 的定时任务,是在 Redis 主线程中执行的)==> 也就是说,如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可以服务这个客户端请求 ==> 此时就会出现,应用访问 Redis 延时变大

    2. 解决方案

      1. 在设置 key 的过期时间时,增加一个随机时间(不会因为集中删除过多的 key 导致压力过大,从而避免阻塞主线程)

      2. Redis 4.0 以上版本,开启 lazy-free 机制(释放过期 key 的内存,放到后台线程执行)

  5. 操作大key

  6. 大key删除

    1. 使用Scan命令迭代删除

    2. 对于大key的集合查询和聚合操作,可以使用Scan命令在客户端完成

  7. fork耗时严重:

    1. 为了保证 Redis 数据的安全性,我们可能会开启后台定时 RDB 和 AOF rewrite 功能。当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出一个子进程进行数据的持久化。

    2. 主进程创建子进程,会调用操作系统提供的 fork 函数。(而 fork 在执行过程中,主进程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也会比较耗时),而且这个 fork 过程会消耗大量的 CPU 资源,在完成 fork 之前,整个 Redis 实例会被阻塞住,无法处理任何客户端请求。

7.4.缓存三大问题

7.4.1.缓存雪崩

  1. 原因
    1. 缓存宕机,缓存数据大面积同时失效
    2. 缓存同时到期,缓存数据大面积同时失效
  2. 应对方案
    1. 一般采用“搭建高可用集群”,防止“Redis服务器宕机”导致缓存雪崩
    2. 采用“设置不同的过期时间”:防止同一时间大量数据过期现象发生
    3. 已经出现缓存雪崩:服务限流、降级、熔断

7.4.2.缓存击穿

  1. 原因
    1. 指`缓存中没有,但数据库中有的数据(一般是缓存时间到期)`,这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
  2. 应对方案
    1. 不给热点数据设置过期时间,一直保留
    2. 分布式锁

7.4.3.缓存穿透

  1. 原因
    1. 访问缓存和数据库中都没有的数据
  2. 应对方案
    1. 布隆过滤器

在使用缓存时,一定要考虑好下面两个问题:① 如何使用缓存?② 缓存使用时注意事项?

  • 使用缓存的哪种数据结构
  • 缓存过期淘汰策略
  • 缓存需要持久化么?持久化方式
  • 缓存需要分布式么?分布式锁并发控制
  • 缓存3大问题
  • 缓存一致性问题

7.5.缓存一致性问题

延迟双删

Canal+定时器兜底

7.6.缓存更新策略

常见的缓存更新策略共有3种:

  • Cache Aside(旁路缓存)策略;
  • Read/Write Through(读穿 / 写穿)策略;
  • Write Back(写回)策略;

7.6.1.Cache Aside(旁路缓存)策略

Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。

写策略的步骤:

  • 先更新数据库中的数据,再删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

注意,写策略的步骤的顺序不能倒过来,即不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。

Cache Aside 策略适合读多写少的场景,不适合写多的场景。

7.6.2.Read/Write Through(读穿 / 写穿)策略

Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。

1.Read Through 策略

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

2.Write Through 策略

当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:

  • 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
  • 如果缓存中数据不存在,直接更新数据库,然后返回;

下面是 Read Through/Write Through 策略的示意图:

Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。

7.6.3.Write Through 策略

Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。

Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。

Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。

但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。

这里贴一张 CPU 缓存与内存使用 Write Back 策略的流程图:

7.7.分布式锁​​​​​​​

解决的问题:当我们请求一个分布式锁的时候,成功了;但是,此时slave还没有复制该锁,masterDown了;之后发生了主从切换,应用程序继续请求锁,会从新的master节点获取锁,也会成功。===> 这就会导致,同一个锁被获取了不止一次。

实现机制:过半加锁成功,才认为加锁成功;否则加锁失败,会释放锁

  • 在TTL时间内,保证过半的节点lock成功,才加锁成功

7.8.Redis的大Key如何处理?

7.8.1.什么是 Redis 大 key?

大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。

一般而言,下面这两种情况被称为大 key:

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

7.8.2.大 key 会造成什么问题?

大 key 会带来以下四种影响:

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大

7.8.3.如何找到大 key ?

1.redis-cli --bigkeys 查找大key

可以通过 redis-cli --bigkeys 命令查找大 key:

redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys

使用的时候注意事项:

  • 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
  • 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。

该方式的不足之处:

  • 这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;
  • 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;

2.使用 SCAN 命令查找大 key

使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。

对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。

对于集合类型来说,有两种方法可以获得它占用的内存大小:

  • 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令;
  • 如果不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。

3.使用 RdbTools 工具查找大 key

使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。

比如,下面这条命令,将大于 10 kb 的  key  输出到一个表格文件。

rdb dump.rdb -c memory --bytes 10240 -f redis.csv

7.8.4.如何删除大 key?

删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。

释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:

①分批次删除

对于删除大 Hash,使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段

对于删除大 List,通过 ltrim 命令,每次删除少量元素。

对于删除大 Set,使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除一个键。

对于删除大 ZSet,使用 zremrangebyrank 命令,每次删除 top 100个元素。

②异步删除(Redis 4.0版本以上)

从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除

7.9.Redis管道Pipeline

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

普通命令模式,如下图所示:

管道模式,如下图所示:

使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。

要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。

7.9.1.Redis cluster模式下如何查询多个key,具体的查询过程是怎样的?

查询单个key的过程

当通过Redis Cluster对某个key执行操作时,计算该key落在哪个哈希槽HASH_SLOT = CRC16(key) mod 16384

  1. 如果是节点自身,则直接进行处理

  2. 如果是其他节点,会向客户端返回一个MOVED错误。客户端可以根据返回结果中的MOVED错误信息,解析出负责该key的节点ip和端口,并与之建立连接,然后重新执行即可

查询多个key的过程

对redis cluster进行批量操作主要以pipeline的方式实现

  1. 对某个key执行操作时,计算该key落在哪个哈希槽HASH_SLOT = CRC16(key) mod 16384

  2. 按照 redis node 将这批 key 进行分组

  3. 计算 slot 定位对应 redis node 的连接,每组 key 就能分别进行 pipeline 逻辑了

7.10.生产环境中Redis是怎么部署的?

Q:Redis 是主从架构?集群架构?用了哪种集群方案?有没有做高可用保证?

Redis cluster集群模式,10 台机器,5 台机器部署了 Redis 主实例,另外 5 台机器部署了 Redis 的从实例,每个主实例挂了一个从实例,5 个节点对外提供读写服务。

Q:有没有开启持久化机制确保可以进行数据恢复?

有,AOF+RDB混合持久化

Q:线上 Redis 给几个 G 的内存?设置了哪些参数?

32G 内存+ 8 核 CPU + 1T 磁盘,但是分配给 Redis 进程的是 10g 内存,一般线上生产环境,Redis 的内存尽量不要超过 10g,超过 10g 可能会有问题。

Q:压测后你们 Redis 集群承载多少 QPS?

每个节点的读写高峰 QPS 可能可以达到每秒 5 万,5 台机器最多是 25 万读写请求每秒。

机器是什么配置?

5 台机器对外提供读写,一共有 50g 内存。

因为每个主实例都挂了一个从实例,所以是高可用的,任何一个主实例宕机,都会自动故障迁移,Redis 从实例会自动变成主实例继续提供读写服务。

你往内存里写的是什么数据?每条数据的大小是多少?

商品数据,每条数据是 10kb。100 条数据是 1mb,10 万条数据是 1g。常驻内存的是 200 万条商品数据,占用内存是 20g,仅仅不到总内存的 50%。目前高峰期每秒就是 3500 左右的请求量。

其实大型的公司,会有基础架构的 team 负责缓存集群的运维。

7.11.场景:有台 8 核机器,只部署 Redis,有什么办法可以尽可能提高 Redis 性能

首先,我们了解了目前主流的架构:多核CPU架构、NUMA架构。

在多核CPU架构下,Redis如果在不同的核上运行,就需要频繁地进行上下文切换,这个过程会增加Redis的执行时间,客户端也会观察到较高的尾延迟了。所以,建议你在Redis运行时,把实例和某个核绑定,这样,就能重复利用核上的L1、L2缓存,可以降低响应延迟。

为了提升Redis的网络性能,我们有时还会把网络中断处理程序和CPU核绑定。在这种情况下,如果服务器使用的是NUMA架构,Redis实例一旦被调度到和中断处理程序不在同一个CPU Socket,就要跨CPU Socket访问网络数据,这就会降低Redis的性能。所以,我建议你把Redis实例和网络中断处理程序绑在同一个CPU Socket下的不同核上,这样可以提升Redis的运行性能。

虽然绑核可以帮助Redis降低请求执行时间,但是,除了主线程,Redis还有用于RDB和AOF重写的子进程,以及4.0版本之后提供的用于惰性删除的后台线程。当Redis实例和一个逻辑核绑定后,这些子进程和后台线程会和主线程竞争CPU资源,也会对Redis性能造成影响。所以,我给了你两个建议:

  • 如果你不想修改Redis代码,可以把按一个Redis实例一个物理核方式进行绑定,这样,Redis的主线程、子进程和后台线程可以共享使用一个物理核上的两个逻辑核。
  • 如果你很熟悉Redis的源码,就可以在源码中增加绑核操作,把子进程和后台线程绑到不同的核上,这样可以避免对主线程的CPU资源竞争。不过,如果你不熟悉Redis源码,也不用太担心,Redis 6.0出来后,可以支持CPU核绑定的配置操作了,我将在第38讲中向你介绍Redis 6.0的最新特性。

Redis的低延迟是我们永恒的追求目标,而多核CPU和NUMA架构已经成为了目前服务器的主流配置,所以,希望你能掌握绑核优化方案,并把它应用到实践中。


参考:https://www.bilibili.com/video/BV17L411X7aT/?spm_id_from=333.788&vd_source=c55975f66082f8af59048d0ef31d17f9

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值