Redis核心技术与实战学习笔记(基础篇)

306 阅读46分钟

前言:本篇为学习笔记--来源自极客时间-Redis核心技术与实战 作者--蒋德钧

感兴趣的小伙伴可以去订阅他的课程,写的非常精彩。

Redis的两大维度,三大主线

image.png

问题查找:Redis问题查找画像图

image.png

基本架构:一个K-V数据库应该包含哪些

一个键值库包括了访问框架索引模块操作模块存储模块

![img](30e0e0eb0b475e6082dd14e63c13ed44.jpg)

底层数据结构:Redis快在哪里,又有哪些慢操作?

这里先做个概述:

redis表现快的原因: 1、在内存中进行操作 2、高效的数据结构

redis表现慢的原因: 1、哈希表的冲突问题和 rehash 可能带来的操作阻塞。

它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。

键和值用什么结构组织?

为了实现从键到值的快速访问,Redis 使用了一个哈希表(全局哈希表)来保存所有键值对。

一个哈希表,其实就是一个数组,数组每个元素称为一个哈希桶 entry, entry 中存储的是 key 和 value 的指针,如果出现哈希冲突通过拉链法解决,也就是 entry 中多一个 next 指针,指向下一个在此位置的 entry .

哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素

image.png

为什么哈希表操作变慢了?

哈希表的冲突问题rehash可能带来的操作阻塞

解决:Redis 解决哈希冲突问题的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

image.png

链式哈希带来的问题:这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。对于追求“快”的 Redis 来说,这是不太能接受的。

解决:Redis 会对哈希表做 rehash操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。

其实这里和java中hashMap非常类似,都存在hash冲突,只不过redis为了提高性能,会将所有数据重hash一遍,hashMap 为了提高效率会将链表转换成红黑树。

rehash怎么做?存在的问题?渐进式rehash

其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表1哈希表2

一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表2 分配更大的空间,例如是当前哈希表1 大小的两倍;

  2. 把哈希表1 中的数据重新映射并拷贝到哈希表2 中;

  3. 释放哈希表1 的空间。

rehash带来的问题:但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。

解决: 为了避免这个问题,Redis 采用了渐进式rehash

简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;

等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries

场景驱动:假设访问一个key1,哈希后落到哈希桶1,然后遍历链表 在获取对key1对的value1后,会将哈希桶1中的所有键值对copy迁移到全局哈希表2,同理,假设key2哈希后落到哈希桶2上,在返回查到的value2后,会将哈希桶2上的键值对都迁移到全局哈希表2上。

渐进式rehash过程

  1. 为ht[1]分配空间

  2. 索引计数器rehashidx置零

  3. 一次rehash之后,ht[0]上键值对放到ht[1],rehashidx加一

  4. 全部rehash之后,rehashidx属性设置为-1

提问:后续对位置1数据的请求(查,改)是在表2还是表1?以及新的数据进来是存储在表2还是表1?

在rehash期间,字典的删除、查找、修改等在两个哈希表上进行。现在ht[0]里面找,找不到再去ht[1]找。新增则直接在ht[1]增加。

思考:String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。

键值对中值的数据类型

  1. String 2. List 3. Hash 4. Set 5. Sorted Set 6. Bitmap 7. GeoHash 8. HyperLogLog 9. Streams

除了String外,我们把List,Hash,Set,Sorted Set都属于集合类型

键值对中值的数据类型的底层数据结构

  • 简单动态字符串 O(1)
  • 双向链表 O(n)
  • 压缩列表 O(n)
  • 哈希表 O(1)
  • 跳表 O(logN)
  • 整数数组 O(n)

String:通过全局hash表查到值就能直接操作 集合类型:有两种底层实现结构,哈希表跳表实现“”,整数数组压缩链表``节省内存空间

image.png

压缩列表,跳表的特点

  1. 压缩列表类似于一个数组,不同的是:压缩列表在表头有三个字段zlbytes,zltailzllen分别表示长度列表尾的偏移量列表中的entry的个数,压缩列表尾部还有一个zlend,表示列表结束 所以压缩列表定位第一个和最后一个是O(1),但其他就是O(n),但是压缩链表存在的意义在于:空间的紧凑节省空间

image.png

  1. 跳表:是在链表的基础上增加了多级索引,通过索引的几次跳转,实现数据快速定位

image.png 提问:整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢?

1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少

2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。

不同操作的复杂度

  • 单元素操作是基础;
  • 范围操作非常耗时;
  • 统计操作通常高效;
  • 例外情况只有几个。

第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作

例如,Hash 类型的 HGETHSETHDEL,Set 类型的 SADDSREMSRANDMEMBER 等。

这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);

Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。

这里,有个地方你需要注意一下集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGETHMSET,Set 类型的 SADD 也支持同时增加多个元素。

此时,这些操作的复杂度,就是由单个元素操作复杂度元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了

第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据

比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE

这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免

不过,Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞

第三,统计操作,是指集合类型对集合中所有元素个数的记录

例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表双向链表整数数组这些数据结构时,这些结构中专门记录了元素的个数统计因此可以高效地完成相关操作

第四,例外情况,是指某些数据结构的特殊记录

例如压缩列表双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOPRPOPLPUSHRPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。

高性IO模型:为什么单线程Redis能那么快

我们通常说,Redis 是单线程,主要是指 Redis 的网络IO 键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。

但 Redis 的其他功能,比如持久化异步删除集群数据同步等,其实是由额外的线程执行的。

Redis 为什么用单线程?

  1. 频繁切换线程带来的额外开销
  2. 线程同时访问共享资源的并发问题。为了避免这些问题,Redis 直接采用了单线程模式。

单线程的redis为什么这么快

  1. 基于内存的数据结构。
  2. 高效的数据结构。 例如哈希表和跳表。
  3. 多路复用机制。使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

基于多路复用的高性能 I/O 模型

基于linux select/epoll ,内核可同时监听多个监听套接字和 多个已连接套接字 ,一旦内核监听到套接字上有数据返回,立刻交给redis线程处理数据

为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。

简单来说 select 轮询遍历 文件对象的被监控的事件(accept, read, write),一旦某个文件对象的监控事件被触发(读或者写或者请求就绪),满足条件,这个事件就会被放到事件队列进行处理,处理的过程就是调用对应的回调函数。

基于多路复用的Redis高性能I/O模型

image.png

补充:Redis单线程处理IO请求性能瓶颈主要包括2个方面:

1、任意一个请求在server中一旦发生耗时,都会影响整个server的性能,也就是说后面的请求都要等前面这个耗时请求处理完成,自己才能被处理到。耗时的操作包括以下几种:

a、操作bigkey:写入一个bigkey在分配内存时需要消耗更多的时间,同样,删除bigkey释放内存同样会产生耗时;

b、使用复杂度过高的命令:例如SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大,例如lrange key 0 -1一次查询全量数据;

c、大量key集中过期:Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长;

d、淘汰策略:淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长;

e、AOF刷盘开启always机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能;

f、主从全量同步生成RDB:虽然采用fork子进程生成数据快照,但fork这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久;

2、并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。

针对问题1,一方面需要业务人员去规避,一方面Redis在4.0推出了lazy-free机制,把bigkey释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响。

针对问题2,Redis在6.0推出了多线程,可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能,当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的。

AOF日志:宕机了,Redis如何避免数据丢失?

目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照

AOF 日志是如何实现的?

image.png

提问:AOF 为什么要先执行命令再记日志呢?

传统数据库的日志,例如 redo log(重做日志),记录的是修改后的数据,而 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。

但是,为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况

思考:AOF 有两个潜在的风险。

首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。

其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了

解决:三种写回策略

三种写回策略

其实,对于这个问题,AOF 机制给我们提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值。

  • Always同步写回:每个写命令执行完,立马同步地将日志写回磁盘;--不可避免地会影响主线程性能;
  • Everysec每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;--但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失 折中方案
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。--只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了;

AOF日志文件太大了怎么办?

AOF日志文件过大的问题:

1.操作系统对文件大小有限制,超过则无法继续写入;

2.文件太大,写入的效率也会变

3.文件太大,恢复数据也很耗时

解决:AOF 重写机制

AOF 重写机制

AOF重写机制指的是,对过大的AOF文件进行重写,以此来压缩AOF文件的大小。 具体的实现是:检查当前键值数据库中的键值对,记录键值对的最终状态,从而实现对 某个键值对 重复操作后产生的多条操作记录压缩成一条的效果。进而实现压缩AOF文件的大小

同时重写过程是由fork子进程 -- bgrewriteaof 来完成的,这也是为了避免阻塞主线程

AOF重写,一个拷贝 bgrewriteaof,两处日志是指重写的时候新的命令会在老的AOF新的AOF日志中都写入

RDB内存快照:宕机后,Redis如何快速恢复?

实现类似照片记录效果的方式,就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase 的缩写。

RDB文件是二进制数据

我们还要考虑两个关键问题

**对哪些数据做快照?**这关系到快照的执行效率问题;

**做快照时,数据还能被增删改吗?**这关系到 Redis 是否被阻塞,能否同时正常处理请求。

拿拍照片来举例子。我们在拍照时,通常要关注两个问题:如何取景?也就是说,我们打算把哪些人、哪些物拍到照片中;在按快门前,要记着提醒朋友不要乱动,否则拍出来的照片就模糊了

给哪些内存数据做快照?

Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

save:在主线程中执行,会导致阻塞

bgsave:创建一个子进程,专门用于写入 RDB 文件避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置

快照时数据能修改吗?

举例:假设有4GB数据要做快照,需要20s,在做快照的 20s 时间里,如果这 4GB 的数据都不能被修改,Redis 就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。

你可能会想到,可以用 bgsave 避免阻塞啊。

这里我就要说到一个常见的误区了,避免阻塞和正常处理写操作并不是一回事。此时,主线程的确没有阻塞,可以正常接收请求,但是,为了保证快照完整性,它只能处理读操作,因为不能修改正在执行快照的数据

允许修改

为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作

Redis会使用bgsave对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,如果主线程需要修改数据,这块数据就会被复制一份,生成该数据的副本。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据写入 RDB 文件。

下为示意图

image.png

可以每秒做一次快照吗?(全量快照和增量快照)

不可以的

全量快照的问题:虽然 bgsave 执行时不阻塞主线程,但是,如果频繁地执行全量快照,也会带来两方面的开销。

  1. 快照时间过短会加大磁盘写入压力
  2. 频繁fork子进程 fork过程会阻塞主线程--虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程

此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。

但是,这么做的前提是,我们需要记住哪些数据被修改了这会带来额外的空间开销问题

如果我们对每一个键值对的修改,都做个记录,那么,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。

而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节,这样的画,为了“记住”修改,引入的额外空间开销比较大。这对于内存资源宝贵的 Redis 来说,有些得不偿失

增量快照示意图

image.png

混合使用AOF日志和RDB的解决方案

前一节提到AOF日志记录所有操作记录,但有了RDB快照能力后AOF就不用记录所有操作了,只需要记录增量记录即可,记录量就小了。

若要恢复数据,可用RDB文件再加上AOF日志就可以全量恢复数据了。

在速度上,因为RDB是二进制数据流,可以快速恢复出redis数据,然后在此基础上小量的执行AOF操作命令,相比于只用AOF来恢复全量数据的操作,也不会太多影响到恢复速度。

如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

image.png

主从机制:主从库如何实现数据一致?

Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

读操作:主库、从库都可以接收;

写操作:首先到主库执行,然后,主库将写操作同步给从库。

Redis主从库和读写分离

image.png

主从库间如何进行第一次同步?

当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

例如,现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5),我们在实例 2 上执行以下这个命令后,实例2 就变成了实例1 从库,并从实例 1 上复制数据:

replicaof 172.16.19.3 6379

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

image.png

解释:

第一阶段是主从库间建立连接协商同步的过程,主要是为全量复制做准备。从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制

psync 命令包含了主库的 runID 和复制进度 offset 两个参数。

  • runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。
  • offset,此时设为 -1,表示第一次复制。

主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件

具体来说,主库执行 bgsave 命令生成 RDB 文件,接着将文件发给从库。

从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响从库需要先把当前数据库清空

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作

第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库

具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库重新执行这些操作。这样一来,主从库就实现同步了

主从级联模式分担全量复制时的主库压力(“主-从-从”模式)

一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。

fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。

那么,有没有好的解决方法可以分担主库压力呢?其实是有的,这就是“主 - 从 - 从”模式。

简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。

replicaof 所选从库的IP 6379

这样一来,这些从库就会知道,在进行同步时不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

image.png

那么,一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销

主从库间网络断了怎么办?(增量复制)

如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据。

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。

从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步,增量复制只会把主从库网络断连期间主库收到的命令同步给从库

增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于repl_backlog_buffer这个缓冲区

repl_backlog_buffer 是一个环形缓冲区主库会记录自己写到的位置,从库则会记录自己已经读到的位置

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新写操作越多,这个值就会越大

同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

image.png

主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。主库只需要把它们同步给从库,就行了。

从库会心跳给主库上报 自己复制到哪了

Redis增量复制过程

image.png

问题:因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

解决:调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。

缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小

在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值

举个例子,如果主库每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把 repl_backlog_size 设为 4MB。

极端情况:如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量同步,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量同步的概率

提问:主从全量同步使用RDB而不使用AOF的原因

  1. RDB文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小。而AOF文件记录的是每一次写操作的命令写操作越多文件会变得很大,其中还包括很多对同一个key的多次冗余操作。在主从全量数据同步时,传输RDB文件可以尽量降低对主库机器网络带宽的消耗,从库在加载RDB文件时,一是文件小,读取整个文件的速度会很快,二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比RDB会慢得多,所以使用RDB进行主从全量同步的成本最低。
  2. 假设要使用AOF做全量同步,意味着必须打开AOF功能,打开AOF就要选择文件刷盘的策略选择不当会严重影响Redis性能。而RDB只有在需要定时备份和主从全量同步数据时才会触发生成一次快照。而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的

哨兵机制:主库挂了,如何不间断服务

哨兵机制的主要职责:

1、监控:通过PING来监控主从

2、选主:主库挂了,从从库中按一定的机制选择一个新主库

3、通知:通知其他从库和客户端新的主库信息

哨兵机制的基本流程

image.png

哨兵如何判断下线?(主观下线和客观下线)

主观下线

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就行了,因为从库的下线影响一般不太大,集群的对外服务不会间断。

客观下线

但是,如果检测的是主库,可能误判,一般会发生在集群网络压力较大网络拥塞,或者是主库本身压力较大的情况下

它通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

客观下线”的标准就是,当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。

客观下线判断示意图

image.png

哨兵如何选定新主库?(筛选->打分)

A.筛选过程:

​ 1、从库是否在线

​ 2、网络连接状态 -- 你使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒 内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了,如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库

B、三轮打分过程。

只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。

第一轮:优先级最高的从库得分高(优先级)。

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。

比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

第二轮:和旧主库同步程度最接近的从库得分高(复制进度)。

repl_backlog_buffer 这个缓冲区重,它的 slave_repl_offset 需要最接近 master_repl_offset

如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

就像下图所示,旧主库的 master_repl_offset 是 1000,从库 1、2 和 3 的 slave_repl_offset 分别是 950、990 和 900,那么,从库 2 就应该被选为新主库。

image.png

第三轮:ID 号小的从库得分高(ID号)。

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。

目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库

提问:哨兵在操作主从切换的过程中,客户端能否正常地进行请求操作?

如果客户端使用了读写分离,那么读请求可以在从库上正常执行,不会受到影响。但是由于此时主库已经挂了,而且哨兵还没有选出新的主库,所以在这期间写请求会失败

失败持续的时间 = 哨兵切换主从的时间 + 客户端感知到新主库 的时间。

如果不想让业务感知到异常,客户端只能把写失败的请求先缓存起来或写入消息队列中间件中,等哨兵切换完主从后,再把这些写请求发给新的主库,但这种场景只适合写入请求返回值不敏感的业务,而且还需要业务层做适配,另外主从切换时间过长,也会导致客户端或消息队列中间件缓存写请求过多,切换完成之后重放这些请求的时间变长

哨兵集群:哨兵挂了,主从库还能切换吗?

实际上,一旦多个实例组成了哨兵集群,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主从库切换的工作,包括判定主库是不是处于下线状态,选择新主库,以及通知从库和客户端

如果你部署过哨兵集群的话就会知道,在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置主库的 IP 和端口,并没有配置其他哨兵的连接信息

sentinel monitor <master-name> <ip> <redis-port> <quorum> 

思考:这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?

基于 pub/sub(发布/订阅) 机制的哨兵集群组成

哨兵之间怎么知道彼此的地址端口?

redis的发布/订阅机制,每个哨兵都把自己的信息发送给主库,然后从主库订阅其他哨兵的消息,这样就可以互相知道其他哨兵的地址了

思考:怎么保证后面上报的能够活得到之前redis哨兵注册的信息?

redis哨兵是定时发布自己的信息到 master+slave__sentinel__:hello 管道(频道),同时也会订阅 master+slave__sentinel__:hello 管道(频道),这样redis哨兵就可以彼此感知到对方的存在

哨兵集群的组成示意图

image.png

哨兵是如何知道从库的 IP 地址和端口的呢?

哨兵向主库发送INFO命令,主库接收到命令后,就把从库列表返回给哨兵,因此哨兵就可以与每个从库建立连接,实现监控。

哨兵与从库建立连接示意图

image.png

基于 pub/sub 机制的客户端事件通知

主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以,哨兵还需要完成把新主库的信息告诉客户端这个任务

而且,在实际使用哨兵时,我们有时会遇到这样的问题:如何在客户端通过监控了解哨兵进行主从切换的过程呢?比如说,主从切换进行到哪一步了?这其实就是要求,客户端能够获取到哨兵集群在监控、选主、切换这个过程中发生的各种事件。

从本质上说,哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供 pub/sub 机制客户端可以从哨兵订阅消息

相关频道

image.png

知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。

具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。

举个例子,你可以执行如下命令,来订阅“所有实例进入客观下线状态的事件”:

SUBSCRIBE +odown

当然,你也可以执行如下命令,订阅所有的事件:

PSUBSCRIBE  *

当哨兵把新主库选择出来后客户端就会看到下面的 switch-master 事件。

这个事件表示主库已经切换了,新主库的 IP 地址和端口信息已经有了。这个时候,客户端就可以用这里面的新主库地址和端口进行通信了。

switch-master <master name> <oldip> <oldport> <newip> <newport>

有了这些事件通知,客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控到主从库切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度

由哪个哨兵执行主从切换?(Leader选举)

具体由哪个哨兵执行主从切换的过程也需要进行投票选举

任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

image.png 一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”

这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。

例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为“客观下线”了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

Leader选举

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:

  1. 拿到半数以上的赞成票
  2. 拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

image.png 哨兵选Leader失败的话,会等待一段之间(哨兵故障转移超时时间的 2 倍),再重新选举.

这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播

如果网络压力较大或有短时堵塞,就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。

注意: 如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置 3 个哨兵实例。这一点很重要,你在实际应用时可不能忽略了。

经验: 要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值 down-after-milliseconds。我们曾经就踩过一个“坑”。当时,在我们的项目中,因为这个值在不同的哨兵实例上配置不一致,导致哨兵集群一直没有对有故障的主库形成共识,也就没有及时切换主库,最终的结果就是集群服务不稳定。所以,你一定不要忽略这条看似简单的经验。

提问:1主4从,5个哨兵,哨兵配置quorum为2,如果3个哨兵故障,当主库宕机时,哨兵能否判断主库“客观下线”?能否自动切换?

1、哨兵集群可以判定主库“主观下线”。由于quorum=2,所以当一个哨兵判断主库“主观下线”后,询问另外一个哨兵后也会得到同样的结果,2个哨兵都判定“主观下线”,达到了quorum的值,因此,哨兵集群可以判定主库为“客观下线”。

2、但哨兵不能完成主从切换。哨兵标记主库“客观下线后”,在选举“哨兵领导者”时,一个哨兵必须拿到超过多数的选票(5/2+1=3票)但目前只有2个哨兵活着,无论怎么投票,一个哨兵最多只能拿到2票,永远无法达到多数选票的结果

切片集群:数据增多了,是该加内存还是加实例?

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存

image.png

如何保存更多数据?

  • 纵向扩展升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。就像下图中,原来的实例内存是 8GB,硬盘是 50GB,纵向扩展后,内存增加到 24GB,磁盘增加到 150GB。
  • 横向扩展:横向增加当前 Redis 实例的个数,就像下图中,原来使用 1 个 8GB 内存、50GB 磁盘的实例,现在使用三个相同配置的实例。

image.png

在面向百万、千万级别的用户规模时,横向扩展的 Redis 切片集群会是一个非常好的选择。

要想把切片集群用起来,我们就需要解决两大问题

  1. 数据切片后,在多个实例之间如何分布?
  2. 客户端怎么确定想要访问的数据在哪个实例上?

数据切片和实例的对应分布关系

切片集群和 Redis Cluster 的联系与区别

Redis Cluster是切片集群的一种实现方案。

Redis Cluster:实现切片集群

Redis Cluster方案采用哈希槽(Hash Slot),处理数据与实例之间的映射关系;一个切片集群有16384个哈希槽,哈希槽类似于数据分区,每个键值对都会根据它的key被映射到一个哈希槽中。 映射步骤:根据key按照CRC16计算一个16bit值,再用16bit值对16384取模,每个模数代表一个相应编号的哈希槽

有5个哈希槽的Redis Cluster示意图

image.png 示意图中的切片集群一共有 3 个实例,同时假设有 5 个哈希槽,我们首先可以通过下面的命令手动分配哈希槽:

实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。

redis-cli -h 172.16.19.3p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5p 6379 cluster addslots 4

注意:手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。

客户端如何定位数据?

哈希槽分布在哪个实例上?

在定位键值对数据时,它所处的哈希槽是可以通过计算得到的,这个计算可以在客户端发送请求时来执行。但是,要进一步定位到实例,还需要知道哈希槽分布在哪个实例上

一般来说,客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端。但是,在集群刚刚创建的时候,每个实例只知道自己被分配了哪些哈希槽,是不知道其他实例拥有的哈希槽信息的。

那么,客户端为什么可以在访问任何一个实例时,都能获得所有的哈希槽信息呢?

这是因为,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

实例和哈希槽的对应关系发生变化怎么办

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:

  • 在集群中,实例有新增删除,Redis 需要重新分配哈希槽
  • 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。

Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。

那客户端又是怎么知道重定向时的新实例的访问地址呢?

当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址。

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

解释:客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。

通过返回的 MOVED 命令,就相当于把哈希槽所在的新实例的信息告诉给客户端了。这样一来,客户端就可以直接和 172.16.19.5 连接,并发送操作请求了。

image.png 注意

客户端向实例 2 发送请求,但此时,Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。在这种迁移部分完成的情况下,客户端就会收到一条 ASK 报错信息

GET hello:key
(error) ASK 13320 172.16.19.5:6379

解释:客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例3上,但是这个哈希槽正在迁移。

此时,客户端需要先给 172.16.19.5 这个实例3发送一个 ASKING 命令。这个命令的意思是,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据

在下图中,Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。

客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。ASK 命令表示两层含义:

第一,表明 Slot 数据还在迁移中;

第二,ASK 命令把客户端所请求数据的最新实例地址返回给客户端,此时,客户端需要给实例 3 发送 ASKING 命令,然后再发送操作命令

image.png

注意: 和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息

所以,在上图中,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求。这也就是说,ASK 命令的作用只是让客户端能给新实例发送一次请求,而不像 MOVED 命令那样,会更改本地缓存,让

后续所有命令都发往新实例

Redis Cluster为什么不采用把key直接映射到实例的方式,而采用哈希槽的方式?

1、整个集群存储key的数量是无法预估的,key的数量非常多时,直接记录每个key对应的实例映射关系,这个映射表会非常庞大,这个映射表无论是存储在服务端还是客户端都占用了非常大的内存空间

2、Redis Cluster采用无中心化的模式(无proxy,客户端与服务端直连),客户端在某个节点访问一个key,如果这个key不在这个节点上,这个节点需要有纠正客户端路由到正确节点的能力(MOVED响应),这就

需要节点之间互相交换路由表,每个节点拥有整个集群完整的路由关系。如果存储的都是key与实例的对应关系,节点之间交换信息也会变得非常庞大,消耗过多的网络资源,而且就算交换完成,相当于每个节点都

需要额外存储其他节点的路由表,内存占用过大造成资源浪费。

3、当集群在扩容、缩容、数据均衡时,节点之间会发生数据迁移,迁移时需要修改每个key的映射关系,维护成本高

4、而在中间增加一层哈希槽,可以把数据和节点解耦key通过Hash计算,只需要关心映射到了哪个哈希槽,然后再通过哈希槽和节点的映射表找到节点,相当于消耗了很少的CPU资源,不但让数据分布更均匀,

还可以让这个映射表变得很小,利于客户端和服务端保存,节点之间交换信息时也变得轻