Redis详解

目录

一、Redis基础数据结构

1、数据类型

2、底层数据结构解析

2.1、简单动态字符串(SDS)

QA: 简单动态字符串和C语言字符串的异同

2.2、压缩链表(ziplist)

QA:为什么ziplist特别省内存?

2.3、快表(quicklist)

2.4、Hash表 

QA:hash表的扩容过程(rehash)

QA: 什么是渐进式hash?

2.5、跳表(skiplist)

 QA:为什么不选用平衡树?

二、Redis持久化

1、RDB持久化

1.1、RDB持久化执行命令

1.2、bgsave执行流程

1.3、触发方式

1.4、相关配置

1.5、优缺点

QA:RDB持久化期间,如何保证数据一致性呢?

QA:在进行RDB持久化的这段时间,如果发生服务崩溃怎么办?

QA:可以每秒做一次快照吗?

2、AOF持久化

QA:为什么使用写后日志?

2.1、 AOF持久化执行流程

2.2、AOF写入策略

2.3、相关配置

2.4、AOF重写

QA:AOF重写会阻塞吗?

QA:AOF日志何时会重写?

QA:重写日志时,有新数据写入咋整?

QA:主线程fork出子进程的是如何复制内存数据的?

QA:在重写日志整个过程时,主线程有哪些地方会被阻塞?

QA:为什么AOF重写不复用原AOF日志?

3、混合持久化 

4、数据恢复 

QA:RDB和AOF如何选择?

三、Redis事务

1、Redis事务相关命令

1.1、Redis事务执行流程

2、事务命令使用

2.1、正常事务执行

2.2、事务取消

2.3、事务出现错误的处理

2.3.1、语法错误(事务不会执行)

2.3.2、 Redis类型错误(运行时错误:执行过的不能回滚)

 QA:Redis事务为啥不支持回滚?

3、Redis事务与ACID特性

四、高可用

1、主从复制

1.1、主从复制原理

1.2、全量复制

1.3、增量复制

QA:为什么会设计增量复制?

QA:如果在网络断开期间,repl_backlog_size环形缓冲区写满之后,从库是会丢失掉那部分被覆盖掉的数据,还是直接进行全量复制呢?

QA:为什么主从全量复制使用RDB而不使用AOF?

2、哨兵

2.1、哨兵集群构建(发布订阅机制)

QA:哨兵怎么和Redis库建立连接 ?

2.2、 哨兵实现原理

2.2.1、定时监控

2.2.2、主观下线和客观下线

2.2.3、领导者Sentinel节点选举

2.2.4、故障转移

QA:新的主节点怎么选出的?

QA:Raft算法选举流程?

2.3、集群

2.3.1、集群中数据如何分区?

1、节点取余分区

2、一致性哈希分区

3、虚拟槽分区

2.3.2、集群原理:

1、集群创建

1.1、设置节点

1.2、节点握手

1.3、分配槽(slot)

2、故障转移

2.1、故障发现

2.2、故障恢复

QA:部署Redis集群至少需要几个物理节点?

 3、集群伸缩

4、gossip协议

Gossip协议的使用:

基于Gossip协议的故障检测

4、QA

QA1: 为什么Redis Cluster的Hash Slot 是16384?

QA2: 为什么Redis Cluster中不建议使用发布订阅呢?

五、Redis的过期数据回收策略

1、过期数据回收策略

2、Redis内存溢出淘汰策略

六、Redis相关面试问题

1、Redis为什么那么快?

2、什么是缓存击穿、缓存穿透、缓存雪崩? 

2.1、缓存击穿

2.2、缓存穿透

2.3、缓存雪崩

3、布隆过滤器

4、如何保证缓存和数据库数据的⼀致性?

4.1、选择合适的缓存更新策略:

删除缓存而不是更新缓存

4.2、缓存不一致处理

5、如何保证本地缓存和分布式缓存的一致?


一、Redis基础数据结构

        Redis是基于内存key-value存储系统。对于Redis来说,所有的key都是字符串。我们在谈基础数据结构时,讨论的是value的数据类型,主要包括常见的5种数据类型,分别是:String、Hash、List、Set、Zset。

1、数据类型

  • String类型:常用于缓存
    • 常用命令:set、get、del、incr、decr、inceby、decrby
    • 底层通过SDS实现
  • List类型:常用于消息队列、微博的timeline
    • 常用命令:rpush、lpush、rpop、lpop、lrange
    • 底层通过quicklist实现
  • Set类型:常用于给用户打标签、点赞、点踩
    • 常用命令:sadd、smember,scard
    • 底层通过Hash表实现
  • Zset类型:常用于排行榜
    • 常用命令:zadd、zrange
    • 底层通过skiplist实现
  • Hash类型:常用于缓存
    • 常用命令:hset、hget、hgetall、hdel
    • 底层通过散列表实现

2、底层数据结构解析

参考:Redis进阶 - 持久化:RDB和AOF机制详解 | Java 全栈知识体系

2.1、简单动态字符串(SDS)

        String类型常用于缓存, 可以保存文本或者二进制数据。底层使用的是简单动态字符串(SDS),具有动态扩容的特点。Redis是通过C语言实现的,但是Redis没有直接使⽤C语⾔传统的字符串,⽽是使用的⾃⼰实现的简单动态字符串SDS的抽象类型。

        C语⾔的字符串不记录⾃身的⻓度信息。⽽SDS则保存了⻓度信息,这样将获取字符串⻓度的时间由O(N)降低到了O(1),同时可以避免缓冲区溢出和减少修改字符串⻓度时所需的内存重分配次数。

        其中sdshdr是头部, buf是真实存储用户数据的地方。在buf中, 用户数据后总跟着一个\0. 即图中 "数据" + "\0" 是为所谓的buf。

  • QA: 简单动态字符串和C语言字符串的异同

相同点:字符串以空字符’\0’结尾。

不同点:

  • SDS保存了字符串的长度,C语言字符串没有。
    • 优点1: SDS可以在O(1)复杂度获取字符串长度信息。
    • 优点2: SDS可以杜绝缓冲区溢出。在C语言中,两个字符串拼接时,没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于SDS,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作。
    • 优点3: 减少内存重新分配次数。SDS修改时, 如append操作,当新的字符串长度小于1M时,redis会分配他们所需大小一倍的空间,当大于1M的时候,就为他们额外多分配1M的空间,下次append无需再分配。
  • SDS可以存储二进制和文本,C语言字符串只能存储二进制数据。
  • SDS是二进制安全的【以len长度判断是否到字符串尾部],C语言字符串不是二进制安全【以’\0’判断是否到字符串尾部】。

2.2、压缩链表(ziplist)

        ziplist是为节约内存⽽开发的顺序性数据结构,它可以包含任意多个节点,每个节点可以保存⼀个字节数组或者整数值(存的是二进制不是字符串形式)。它能在O(1)的时间复杂度下完成list两端的push和pop操作。

  • zlbytes: 存储的是整个ziplist所占用的内存的字节数
  • zltail: 指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作
  • zllen: 指的是整个ziplist中entry的数量. 
  • entry: 列表节点。一般结构为”<prevlen> <encoding> <entry-data>“,其中prevlen代表上一个元素的长度,encoding表示entry类型和长度,entry-date是真实数据。
  • zlend: 是一个终止字节
QA:为什么ziplist特别省内存?
  • ziplist节省内存是相对于普通的list来说的,如果是普通的数组,那么它每个元素占用的内存是一样的且取决于最大的那个元素(很明显它是需要预留空间的);
  • 所以ziplist在设计时就很容易想到要尽量让每个元素按照实际的内容大小存储所以增加encoding字段,针对不同的encoding来细化存储大小;
  • 这时候还需要解决的一个问题是遍历元素时如何定位下一个元素呢?在普通数组中每个元素定长,所以不需要考虑这个问题;但是ziplist中每个data占据的内存不一样,所以为了解决遍历,需要增加记录上一个元素的length,所以增加了prelen字段
  • ziplist的缺点:ziplist不预留空间,删除节点就立即缩容,每次写操作都需要扩容。

2.3、快表(quicklist)

        它是一种以ziplist为结点的双端链表结构.。宏观上,quicklist是一个链表;微观上,链表中的每个结点都是一个ziplist。

        Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是说当元素少时使用 ziplist,当元素多时用 linkedlist。但考虑到链表的附加空间相对较高,prevnext 指针就要占去 16 个字节(64 位操作系统占用 8 个字节),另外每个节点的内存都是单独分配,会家具内存的碎片化,影响内存管理效率。后来 Redis 新版本(3.2)对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist,quicklist是综合考虑了时间效率与空间效率引入的新型数据结构。

2.4、Hash表 

        Redis的Hash表也是通过散列表实现的,当哈希冲突时,使用链地址法解决。

QA:hash表的扩容过程(rehash)
  1. 会基于原哈希表创建一个大小等于 ht[0].used*2n 的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
  2. 重新利用哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
  3. 所有键值对都迁徙完毕后,释放原哈希表的内存空间。
QA: 什么是渐进式hash?

        渐进式hash是指rehash的过程不是一次完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash。这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。

2.5、跳表(skiplist)

        skiplist是有序列表 (Zset) 的底层结构。跳跃表的性能可以和平衡树相比较,都是O(logN)。而且在实现方面比平衡树要优雅,但是跳表需要的存储空间比较大,属于利用空间来换取时间的数据结构。

 QA:为什么不选用平衡树?

作者的回答:https://news.ycombinator.com/item?id=1171423

There are a few reasons:

They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.

They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

About the Append Only durability & speed, I don't think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway.
About threads: our experience shows that Redis is mostly I/O bound. I'm using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores), and using the "Redis Cluster" solution that I plan to develop in the future.

1、平衡树不方便进行范围查询

2、新增节点或者删除节点可能要面临树的旋转与调整,复杂度高

3、算法复杂度低

二、Redis持久化

QA:为什么需要持久化?

        Redis是个基于内存的数据库。那服务一旦宕机,内存中的数据将全部丢失。通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,1、会对数据库带来巨大的压力,2、数据库的性能不如Redis。导致程序响应慢。所以对Redis来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。

1、RDB持久化

        RDB 就是 Redis DataBase 的缩写,中文名为快照/内存快照,RDB持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

1.1、RDB持久化执行命令

  • save命令:阻塞当前Redis服务器,直到RDB过程完成为止,对于内存 比较大的实例会造成长时间阻塞,线上环境不建议使用

  • bgsave命令:Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。

1.2、bgsave执行流程

  • redis客户端执行bgsave命令或者自动触发bgsave命令;
  • 主进程判断当前是否已经存在正在执行的子进程,如果存在,那么主进程直接返回;
  • 如果不存在正在执行的子进程,那么就fork一个新的子进程进行持久化数据,fork过程是阻塞的,fork操作完成后主进程即可执行其他操作;
  • 子进程先将数据写入到临时的rdb文件中,待快照数据写入完成后再原子替换旧的rdb文件;
  • 同时发送信号给主进程,通知主进程rdb持久化完成,主进程更新相关的统计信息(info Persitence下的rdb_*相关选项)。

1.3、触发方式

可以分为手动触发和自动触发。手动触发分为 save 和 bgsave 命令。自动触发主要有以下几种case:

  • redis.conf中配置save m n,即在m秒内有n次修改时,自动触发bgsave生成rdb文件;

  • 主从复制时,从节点要从主节点进行全量复制时也会触发bgsave操作,生成当时的快照发送到从节点;

  • 执行debug reload命令重新加载redis时也会触发bgsave操作;

  • 默认情况下执行shutdown命令时,如果没有开启aof持久化,那么也会触发bgsave操作;

1.4、相关配置

// 文件名称
dbfilename dump.rdb

// 文件保存路径
dir /home/work/app/redis/data/

// 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes

// 是否压缩
rdbcompression yes

// 导入时是否检查
rdbchecksum yes

dbfilename:RDB文件在磁盘上的名称。

dir:RDB文件的存储路径。默认设置为“./”,也就是Redis服务的主目录。

stop-writes-on-bgsave-error:上文提到的在快照进行过程中,主进程照样可以接受客户端的任何写操作的特性,是指在快照操作正常的情况下。如果快照操作出现异常(例如操作系统用户权限不够、磁盘空间写满等等)时,Redis就会禁止写操作。这个特性的主要目的是使运维人员在第一时间就发现Redis的运行错误,并进行解决。一些特定的场景下,您可能需要对这个特性进行配置,这时就可以调整这个参数项。该参数项默认情况下值为yes,如果要关闭这个特性,指定即使出现快照错误Redis一样允许写操作,则可以将该值更改为no。

rdbcompression:该属性将在字符串类型的数据被快照到磁盘文件时,启用LZF压缩算法。Redis官方的建议是请保持该选项设置为yes,因为“it’s almost always a win”。

rdbchecksum:从RDB快照功能的version 5 版本开始,一个64位的CRC冗余校验编码会被放置在RDB文件的末尾,以便对整个RDB文件的完整性进行验证。这个功能大概会多损失10%左右的性能,但获得了更高的数据可靠性。所以如果您的Redis服务需要追求极致的性能,就可以将这个选项设置为no。

1.5、优缺点

优点:

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

缺点:

  • RDB方式实时性不够,无法做到秒级的持久化;
  • 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
  • RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;
  • 版本兼容RDB文件问题;

针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决

QA:RDB持久化期间,如何保证数据一致性呢

        RDB中的核心思路是Copy-on-Write,来保证在进行快照操作的这段时间,需要压缩写入磁盘上的数据在内存中不会发生变化。

        例如:如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

QA:在进行RDB持久化的这段时间,如果发生服务崩溃怎么办

         在没有将数据全部写入到磁盘前,这次快照操作都不算成功。如果出现了服务崩溃的情况,将以上一次完整的RDB快照文件作为恢复内存数据的参考。

        也就是说,在RDB持久化操作过程中不能影响上一次的备份数据。Redis服务会在磁盘上创建一个临时文件进行数据操作,待操作成功后才会用这个临时文件替换掉上一次的备份。

QA:可以每秒做一次快照吗

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

  • 一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  • 另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。

2、AOF持久化

        AOF全称是append only file, 是“写后”日志。Redis先执行命令,把数据写入内存,然后才记录日志。日志里记录的是Redis收到的每一条写命令(读命令不需要),这些命令是以文本形式保存。

PS: 大多数的数据库采用的是写前日志(WAL),例如MySQL,通过写前日志(redolog)和两阶段提交,实现数据和逻辑的一致性。

QA:为什么使用写后日志?

Redis要求高性能,采用写日志有两方面好处:

  • 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前的写操作

但这种方式存在潜在风险:

  • 如果命令执行完成,写日志之前宕机了,会丢失数据。
  • 主线程写磁盘压力大,导致写盘慢,阻塞后续操作。

2.1、 AOF持久化执行流程

AOF日志记录Redis的每个写命令,步骤分为:命令追加(append)、文件写入(write)和文件同步(sync)。

1)所有的写入命令会追加到aof_buf(缓冲区)中。

2)AOF缓冲区根据对应的策略向硬盘做同步操作。

3)随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩 的目的。

4)当Redis服务器重启时,可以加载AOF文件进行数据恢复。

2.2、AOF写入策略

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;

Everysec,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;

No,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

三种写回策略的优缺点:上面的三种写回策略体现了一个重要原则:trade-off,取舍,指在性能和可靠性保证之间做取舍。

2.3、相关配置

// appendonly参数开启AOF持久化
appendonly no

// AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

// AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./

// 同步策略
// appendfsync always
appendfsync everysec
// appendfsync no

// aof重写期间是否同步
no-appendfsync-on-rewrite no

// 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

// 加载aof出错如何处理
aof-load-truncated yes

// 文件重写策略
aof-rewrite-incremental-fsync yes

appendonly:默认情况下AOF功能是关闭的,将该选项改为yes以便打开Redis的AOF功能。

appendfilename:这个参数项很好理解了,就是AOF文件的名字。

appendfsync:这个参数项是AOF功能最重要的设置项之一,主要用于设置“真正执行”操作命令向AOF文件中同步的策略。什么叫“真正执行”呢?还记得Linux操作系统对磁盘设备的操作方式吗? 为了保证操作系统中I/O队列的操作效率,应用程序提交的I/O操作请求一般是被放置在linux Page Cache中的,然后再由Linux操作系统中的策略自行决定正在写到磁盘上的时机。而Redis中有一个fsync()函数,可以将Page Cache中待写的数据真正写入到物理设备上,而缺点是频繁调用这个fsync()函数干预操作系统的既定策略,可能导致I/O卡顿的现象频繁 。与上节对应,appendfsync参数项可以设置三个值,分别是:always、everysec、no,默认的值为everysec。

no-appendfsync-on-rewrite:always和everysec的设置会使真正的I/O操作高频度的出现,甚至会出现长时间的卡顿情况,这个问题出现在操作系统层面上,所有靠工作在操作系统之上的Redis是没法解决的。为了尽量缓解这个情况,Redis提供了这个设置项,保证在完成fsync函数调用时,不会将这段时间内发生的命令操作放入操作系统的Page Cache(这段时间Redis还在接受客户端的各种写操作命令)。

auto-aof-rewrite-percentage:上文说到在生产环境下,技术人员不可能随时随地使用“BGREWRITEAOF”命令去重写AOF文件。所以更多时候我们需要依靠Redis中对AOF文件的自动重写策略。Redis中对触发自动重写AOF文件的操作提供了两个设置:auto-aof-rewrite-percentage表示如果当前AOF文件的大小超过了上次重写后AOF文件的百分之多少后,就再次开始重写AOF文件。例如该参数值的默认设置值为100,意思就是如果AOF文件的大小超过上次AOF文件重写后的1倍,就启动重写操作。

auto-aof-rewrite-min-size:参考auto-aof-rewrite-percentage选项的介绍,auto-aof-rewrite-min-size设置项表示启动AOF文件重写操作的AOF文件最小大小。如果AOF文件大小低于这个值,则不会触发重写操作。注意,auto-aof-rewrite-percentage和auto-aof-rewrite-min-size只是用来控制Redis中自动对AOF文件进行重写的情况,如果是技术人员手动调用“BGREWRITEAOF”命令,则不受这两个限制条件左右。

2.4、AOF重写

        AOF会记录每个写命令到AOF文件,随着时间越来越长,AOF文件会变得越来越大。Redis通过创建一个新的AOF文件来替换现有的AOF,新旧两个AOF文件保存的数据相同,但新AOF文件没有了冗余命令

QA:AOF重写会阻塞吗

        AOF重写过程是由后台进程bgrewriteaof 来完成的。主线程fork出后台的bgrewriteaof子进程,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。

        所以aof在重写时,在fork进程时是会阻塞住主线程的。

QA:AOF日志何时会重写

有两个配置项控制AOF重写的触发:

auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB。

auto-aof-rewrite-percentage: 这个值的计算方式是,当前aof文件大小和上一次重写后aof文件大小的差值,再除以上一次重写后aof文件大小。也就是当前aof文件比上一次重写后aof文件的增量大小,和上一次重写后aof文件大小的比值。

QA:重写日志时,有新数据写入咋整

        重写过程总结为:“一个拷贝,两处日志”。在fork出子进程时的拷贝,以及在重写时,如果有新数据写入,主线程就会将命令记录到两个aof日志内存缓冲区中。如果AOF写回策略配置的是always,则直接将命令写回旧的日志文件,并且保存一份命令至AOF重写缓冲区,这些操作对新的日志文件是不存在影响的。(旧的日志文件:主线程使用的日志文件,新的日志文件:bgrewriteaof进程使用的日志文件)

        而在bgrewriteaof子进程完成会日志文件的重写操作后,会提示主线程已经完成重写操作,主线程会将AOF重写缓冲中的命令追加到新的日志文件后面。这时候在高并发的情况下,AOF重写缓冲区积累可能会很大,这样就会造成阻塞,Redis后来通过Linux管道技术让aof重写期间就能同时进行回放,这样aof重写结束后只需回放少量剩余的数据即可。

最后通过修改文件名的方式,保证文件切换的原子性。

        在AOF重写日志期间发生宕机的话,因为日志文件还没切换,所以恢复数据时,用的还是旧的日志文件。

QA:主线程fork出子进程的是如何复制内存数据的

        fork采用操作系统提供的写时复制(copy on write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成阻塞。fork子进程时,子进程时会拷贝父进程的页表,即虚实映射关系(虚拟内存和物理内存的映射索引表),而不会拷贝物理内存。这个拷贝会消耗大量cpu资源,并且拷贝完成前会阻塞主线程,阻塞时间取决于内存中的数据量,数据量越大,则内存页表越大。拷贝完成后,父子进程使用相同的内存地址空间。

        但主进程是可以有数据写入的,这时候就会拷贝物理内存中的数据。如下图(进程1看做是主进程,进程2看做是子进程)

         在主进程有数据写入时,而这个数据刚好在页c中,操作系统会创建这个页面的副本(页c的副本),即拷贝当前页的物理数据,将其映射到主进程中,而子进程还是使用原来的的页c。

QA:在重写日志整个过程时,主线程有哪些地方会被阻塞
  • fork子进程时,需要拷贝虚拟页表,会对主线程阻塞。
  • 主进程有bigkey写入时,操作系统会创建页面的副本,并拷贝原有的数据,会对主线程阻塞。
  • 子进程重写日志完成后,主进程追加aof重写缓冲区时可能会对主线程阻塞。
QA:为什么AOF重写不复用原AOF日志

两方面原因:

  1. 父子进程写同一个文件会产生竞争问题,影响父进程的性能。
  2. 如果AOF重写过程中失败了,相当于污染了原本的AOF文件,无法做恢复数据使用。

3、混合持久化 

        重启 Redis 时,我们很少使用 RDB 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

        Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是 自持久化开始到持久化结束 的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小,避免了重写开销,避免了频繁 fork操作 对主线程的影响。

4、数据恢复 

        当Redis发生了故障,可以从RDB或者AOF中恢复数据。恢复的过程也很简单,把RDB或者AOF文件拷贝到Redis的数据目录下,如果使用AOF恢复,配置文件开启AOF,然后启动redis-server即可。

Redis 启动时加载数据的流程:

  1. AOF持久化开启且存在AOF文件时,优先加载AOF文件。
  2. AOF关闭或者AOF文件不存在时,加载RDB文件。
  3. 加载AOF/RDB文件成功后,Redis启动成功。
  4. AOF/RDB文件存在错误时,Redis启动失败并打印错误信息

QA:RDB和AOF如何选择?

  • 一般来说, 如果想达到足以媲美数据库的 数据安全性,应该 同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入 AOF 文件来恢复原始的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。
  • 如果 可以接受数分钟以内的数据丢失,那么可以 只使用 RDB 持久化
  • 有很多用户都只使用 AOF 持久化,但并不推荐这种方式,因为定时生成 RDB 快照(snapshot)非常便于进行数据备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快,除此之外,使用 RDB 还可以避免 AOF 程序的 bug。
  • 如果只需要数据在服务器运行的时候存在,也可以不使用任何持久化方式。

三、Redis事务

        Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

1、Redis事务相关命令

MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务相关的命令。

  • MULTI :开启事务,redis会将后续的命令逐个放入队列中,然后使用EXEC命令来原子化执行这个命令系列。
  • EXEC:执行事务中的所有操作命令。
  • DISCARD:取消事务,放弃执行事务块中的所有命令。
  • WATCH:监视一个或多个key, 如果事务在执行前,这个key(或多个key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。
  • UNWATCH:取消WATCH对所有key的监视。

1.1、Redis事务执行流程

2、事务命令使用

2.1、正常事务执行

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"22"

2.2、事务取消

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 33
QUEUED
127.0.0.1:6379> set k2 34
QUEUED
127.0.0.1:6379> DISCARD
OK

2.3、事务出现错误的处理

2.3.1、语法错误(事务不会执行)

        在开启事务后,修改k1值为11,k2值为22,但k2语法错误,最终导致事务提交失败,k1、k2保留原值。

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> sets k2 22
(error) ERR unknown command `sets`, with args beginning with: `k2`, `22`, 
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"
2.3.2、 Redis类型错误(运行时错误:执行过的不能回滚)

        在开启事务后,修改k1值为11,k2值为22,但将k2的类型作为List,在运行时检测类型错误,最终导致事务提交失败,此时事务并没有回滚,而是跳过错误命令继续执行, 结果k1值改变、k2保留原值

127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k1 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> lpush k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"v2"
 QA:Redis事务为啥不支持回滚?

不支持回滚的优点:

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR 命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 INCR , 回滚是没有办法处理这些情况的。

3、Redis事务与ACID特性

  • 原子性:Redis具备一定的原子性,但是不支持回滚。(针对运行时错误,Redis不能保证原子性)。
  • 一致性:具备一致性。
  • 隔离性:具备隔离性。
  • 持久性:无法保证持久性。RDB和AOF都是异步的。

参考:腾讯二面:Redis 事务支持 ACID 么?-腾讯云开发者社区-腾讯云

四、高可用

1、主从复制

主从复制的作用主要包括:

  • 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
  • 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
  • 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。

主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接收;
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库。

1.1、主从复制原理

在2.8版本之前只有全量复制,而2.8版本后有全量和增量复制。

  • 全量(同步)复制:比如第一次同步时
  • 增量(同步)复制:只会把主从库网络断连期间主库收到的命令,同步给从库

1.2、全量复制

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

// replicaof 主实例ip 端口
replicaof 172.16.19.3 6379

// slaveof 主实例ip 端口
slaveof 172.16.19.3 6379

全量复制的三个阶段:

  • 第一阶段是主从库间建立连接、协商同步的过程。从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。offset,此时设为 -1,表示第一次复制。主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
  • 第二阶段,主库将所有数据同步给从库。主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
  • 第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

1.3、增量复制

在 Redis 2.8 版本引入了增量复制

QA:为什么会设计增量复制

        如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新进行一次全量复制,开销非常大。从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步。

增量复制流程

  • repl_backlog_buffer:它是为了从库断开之后,如何找到主从差异数据而设计的环形缓冲区,从而避免全量复制带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量复制,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量复制的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer。
  • replication bufferRedis和客户端通信也好,和从库通信也好,Redis都需要给分配一个 内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在增量同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer。
QA:如果在网络断开期间,repl_backlog_size环形缓冲区写满之后,从库是会丢失掉那部分被覆盖掉的数据,还是直接进行全量复制呢
  1. 一个从库如果和主库断连时间过长,造成它在主库repl_backlog_buffer的slave_repl_offset位置上的数据已经被覆盖掉了,此时从库和主库间将进行全量复制。

  2. 每个从库会记录自己的slave_repl_offset,每个从库的复制进度也不一定相同。在和主库重连进行恢复时,从库会通过psync命令把自己记录的slave_repl_offset发给主库,主库会根据从库各自的复制进度,来决定这个从库可以进行增量复制,还是全量复制。

QA:为什么主从全量复制使用RDB而不使用AOF?

1、RDB文件是压缩的二进制数据,很小。传输RDB文件可以尽量降低对主库机器网络带宽的消耗

2、从库在加载RDB文件时,一是文件小,读取整个文件的速度会很快,二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,恢复速度相比RDB会慢得多。

2、假设要使用AOF做全量复制,意味着必须打开AOF功能,打开AOF就要选择文件刷盘的策略,选择不当会严重影响Redis性能。而RDB只有在需要定时备份和主从全量复制数据时才会触发生成一次快照。而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的。

2、哨兵

Redis Sentinel ,它由两部分组成,哨兵节点和数据节点:

  • 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据,对数据节点进行监控。
  • 数据节点: 主节点和从节点都是数据节点;

在复制的基础上,哨兵实现了 自动化的故障恢复 功能,下面是官方对于哨兵功能的描述

  • 监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
  • 自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
  • 配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
  • 通知(Notification):哨兵可以将故障转移的结果发送给客户端。

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。

2.1、哨兵集群构建(发布订阅机制)

        在主从集群中,主库上有一个名为__sentinel__:hello 的频道,不同哨兵就是通过它来相互发现,实现互相通信的。在下图中,哨兵 1 把自己的 IP(172.16.19.3)和端口(26579)发布到__sentinel__:hello 频道上,哨兵 2 和 3 订阅了该频道。那么此时,哨兵 2 和 3 就可以从这个频道直接获取哨兵 1 的 IP 地址和端口号。然后,哨兵 2、3 可以和哨兵 1 建立网络连接。哨兵2和3也通过这种方式可以建立连接。

QA:哨兵怎么和Redis库建立连接 ?

        这是由哨兵向主库发送 INFO 命令来完成的。就像下图所示,哨兵 2 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵 1 和 3 可以通过相同的方法和从库建立连接

2.2、 哨兵实现原理

哨兵模式是通过哨兵节点完成对数据节点的监控、下线、故障转移。 

Redis Sentinel工作流程

2.2.1、定时监控

三个定时任务

Redis Sentinel通过三个定时监控任务完成对各个节点发现和监控:

  1. 每隔10秒,每个Sentinel节点会向主节点和从节点发送info命令获取最新的拓扑结构
  2. 每隔2秒,每个Sentinel节点会向Redis数据节点的__sentinel__:hello 频道上发送该Sentinel节点对于主节点的判断以及当前Sentinel节点的信息
  3. 每隔1秒,每个Sentinel节点会向主节点、从节点、其余Sentinel节点发送一条ping命令做一次心跳检测,来确认这些节点当前是否可达
2.2.2、主观下线和客观下线

主观下线就是哨兵节点认为某个节点有问题,客观下线就是超过一定数量的哨兵节点认为主节点有问题。

主观下线和客观下线

  1. 主观下线:每个Sentinel节点会每隔1秒对主节点、从节点、其他Sentinel节点发送ping命令做心跳检测,当这些节点超过 down-after-milliseconds没有进行有效回复,Sentinel节点就会对该节点做失败判定,这个行为叫做主观下线。

  2. 客观下线:当Sentinel主观下线的节点是主节点时,该Sentinel节点会通过sentinel is- master-down-by-addr命令向其他Sentinel节点询问对主节点的判断,当超过 <quorum>个数,Sentinel节点认为主节点确实有问题,这时该Sentinel节点会做出客观下线的决定

2.2.3、领导者Sentinel节点选举

Sentinel节点之间会做一个领导者选举的工作,选出一个Sentinel节点作为领导者进行故障转移的工作。Redis使用了Raft算法实现领导者选举。

2.2.4、故障转移

领导者选举出的Sentinel节点负责故障转移,过程如下:

故障转移

  1. 在从节点列表中选出一个节点作为新的主节点,这一步是相对复杂一些的一步
  2. Sentinel领导者节点会对第一步选出来的从节点执行slaveof no one命令让其成为主节点
  3. Sentinel领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点
  4. Sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点
QA:新的主节点怎么选出的?

 选出新的主节点,大概分为这么几步:

  1. 过滤:“不健康”(主观下线、断线)、5秒内没有回复过Sentinel节 点ping响应、与主节点失联超过down-after-milliseconds*10秒。
  2. 选择slave-priority(从节点优先级)最高的从节点列表,如果存在则返回,不存在则继续。
  3. 选择复制偏移量最大的从节点(复制的最完整),如果存在则返 回,不存在则继续。
  4. 选择runid最小的从节点。【runid最小说明是最早开始复制的从节点】

新的主节点

QA:Raft算法选举流程?

任何一个想成为 Leader 的哨兵,要满足两个条件

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

Redis使用了Raft算法实现领导者选举,大致流程如下:

  1. 每个在线的Sentinel节点都有资格成为领导者,当它确认主节点主观 下线时候,会向其他Sentinel节点发送sentinel is-master-down-by-addr命令, 要求将自己设置为领导者。
  2. 收到命令的Sentinel节点,如果没有同意过其他Sentinel节点的sentinel is-master-down-by-addr命令,将同意该请求,否则拒绝。
  3. 如果该Sentinel节点发现自己的票数已经大于等于max(quorum, num(sentinels)/2+1),那么它将成为领导者。
  4. 如果此过程没有选举出领导者,将进入下一次选举。

领导者Sentinel节点选举

2.3、集群

        前面说到了主从存在高可用和分布式的问题,哨兵解决了高可用的问题,而集群就是终极方案,一举解决高可用和分布式问题。

Redis 集群示意图

  1. 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力

  2. 高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。

2.3.1、集群中数据如何分区?

分布式的存储中,要把数据集按照分区规则映射到多个节点,常见的数据分区规则三种: 

分布式数据分区

1、节点取余分区

        节点取余分区,非常好理解,使用特定的数据,比如Redis的键,或者用户ID之类,对响应的hash值取余:hash(key)%N,来确定数据映射到哪一个节点上。

        不过该方案最大的问题是,当节点数量变化时,如扩容或收缩节点,数据节点映射关 系需要重新计算,会导致数据的重新迁移。

节点取余分区

2、一致性哈希分区

        将整个 Hash 值空间组织成一个虚拟的圆环,然后将缓存节点的 IP 地址或者主机名做 Hash 取值后,放置在这个圆环上。当我们需要确定某一个 Key 需 要存取到哪个节点上的时候,先对这个 Key 做同样的 Hash 取值,确定在环上的位置,然后按照顺时针方向在环上“行走”,遇到的第一个缓存节点就是要访问的节点。

        比如说下面 这张图里面,Key 1 和 Key 2 会落入到 Node 1 中,Key 3、Key 4 会落入到 Node 2 中,Key 5 落入到 Node 3 中,Key 6 落入到 Node 4 中。

一致性哈希分区

        这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中 相邻的节点,对其他节点无影响。

但它还是存在问题:

  • 缓存节点在圆环上分布不平均,会造成部分缓存节点的压力较大
  • 当某个节点故障时,这个节点所要承担的所有访问都会被顺移到另一个节点上,会对后面这个节点造成力。
3、虚拟槽分区

        这个方案 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。

虚拟槽分配

        在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽解耦了数据和实际节点 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 4 个实际节点,假设为其分配 16 个槽(0-15);

  • 槽 0-3 位于 node1;4-7 位于 node2;以此类推....

如果此时删除 node2,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 node1,槽 6 分配给 node3,槽 7 分配给 node4,数据在其他节点的分布仍然较为均衡。

2.3.2、集群原理:

参考:Redis常见面试题
Redis分片

Redis集群通过数据分区来实现数据的分布式存储,通过自动故障转移实现高可用。

1、集群创建

数据分区是在集群创建的时候完成的。

集群创建

1.1、设置节点

        Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis运行在集群模式下。

节点和握手

1.2、节点握手

        节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信, 达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命 令:cluster meet{ip}{port}。完成节点握手之后,一个个的Redis节点就组成了一个多节点的集群。

1.3、分配槽(slot)

        Redis集群把所有的数据映射到16384个槽中。每个节点对应若干个槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过 cluster addslots命令为节点分配槽。

分配槽

2、故障转移

Redis集群的故障转移和哨兵的故障转移类似,但是Redis集群中所有的节点都要承担状态维护的任务。

2.1、故障发现

        Redis集群内节点通过ping/pong消息实现节点通信,集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong 消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节 点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。

主观下线

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当 半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。

主观下线和客观下线

2.2、故障恢复

        故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它 的从节点中选出一个替换它,从而保证集群的高可用。

故障恢复流程

  1. 资格检查 每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障 的主节点。
  2. 准备选举时间 当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该 时间后才能执行后续流程。
  3. 发起选举 当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程。
  4. 选举投票 持有槽的主节点处理故障选举消息。投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节 点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个 从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。

    选举投票

  5. 替换主节点 当从节点收集到足够的选票之后,触发替换主节点操作。
QA:部署Redis集群至少需要几个物理节点?

在投票选举的环节,故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2 个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到 3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题。

 3、集群伸缩

Redis集群提供了灵活的节点扩容和收缩方案,可以在不影响集群对外服务的情况下,为集群添加节点进行扩容也可以下线部分节点进行缩容。

集群的伸缩

其实,集群扩容和缩容的关键点,就在于槽和节点的对应关系,扩容和缩容就是将一部分数据迁移给新节点。

例如下面一个集群,每个节点对应若干个槽,每个槽对应一定的数据,如果希望加入1个节点希望实现集群扩容时,需要通过相关命令把一部分槽和内容【槽中的key】迁移给新节点。然后通过广播通知所有主节点。

扩容实例

缩容也是类似,先把槽和数据迁移到其它节点,再把对应的节点下线。

4、gossip协议

        gossip 协议(gossip protocol)又称 epidemic 协议(epidemic protocol),是基于流行病传播方式的节点或者进程之间信息交换的协议。 在分布式系统中被广泛使用,比如我们可以使用 gossip 协议来确保网络中所有节点的数据一样。Gossip协议已经是P2P网络中比较成熟的协议了。Gossip协议的最大的好处是,即使集群节点的数量增加,每个节点的负载也不会增加很多,几乎是恒定的。这就允许Consul管理的集群规模能横向扩展到数千个节点

        Gossip算法又被称为反熵(Anti-Entropy),熵是物理学上的一个概念,代表杂乱无章,而反熵就是在杂乱无章中寻求一致,这充分说明了Gossip的特点:在一个有界网络中,每个节点都随机地与其他节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其他节点,也可能仅知道几个邻居节点,只要这些节可以通过网络连通,最终他们的状态都是一致的,当然这也是疫情传播的特点。https://www.backendcloud.cn/2017/11/12/raft-gossip/。

        上面的描述都比较学术,其实Gossip协议对于我们吃瓜群众来说一点也不陌生,Gossip协议也成为流言协议,说白了就是八卦协议,这种传播规模和传播速度都是非常快的,你可以体会一下。所以计算机中的很多算法都是源自生活,而又高于生活的。

Gossip协议的使用:

Redis 集群是去中心化的,彼此之间状态同步靠 gossip 协议通信,集群的消息有以下几种类型:

  • Meet 通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
  • Ping 节点每秒会向集群中其他节点发送 ping 消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等。
  • Pong 节点收到 ping 消息后会回复 pong 消息,消息中同样带有自己已知的两个节点信息。
  • Fail 节点 ping 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。
基于Gossip协议的故障检测

        集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此交换各个节点状态信息,检测各个节点状态:在线状态疑似下线状态PFAIL已下线状态FAIL

  • 自己保存信息:当主节点A通过消息得知主节点B认为主节点D进入了疑似下线(PFAIL)状态时,主节点A会在自己的clusterState.nodes字典中找到主节点D所对应的clusterNode结构,并将主节点B的下线报告添加到clusterNode结构的fail_reports链表中,并后续关于结点D疑似下线的状态通过Gossip协议通知其他节点。
  • 一起裁定:如果集群里面,半数以上的主节点都将主节点D报告为疑似下线,那么主节点D将被标记为已下线(FAIL)状态,将主节点D标记为已下线的节点会向集群广播主节点D的FAIL消息,所有收到FAIL消息的节点都会立即更新nodes里面主节点D状态标记为已下线。
  • 最终裁定:将 node 标记为 FAIL 需要满足以下两个条件:
    • 有半数以上的主节点将 node 标记为 PFAIL 状态。
    • 当前节点也将 node 标记为 PFAIL 状态。

4、QA

QA1: 为什么Redis Cluster的Hash Slot 是16384?

        我们知道一致性hash算法是2的16次方,为什么hash slot是2的14次方呢?作者原始回答在新窗口打开

        在redis节点发送心跳包时需要把所有的槽放到这个心跳包里,以便让节点知道当前集群信息,16384=16k,在发送心跳包时使用char进行bitmap压缩后是2k(2 * 8 (8 bit) * 1024(1k) = 16K),也就是说使用2k的空间创建了16k的槽数。

        虽然使用CRC16算法最多可以分配65535(2^16-1)个槽位,65535=65k,压缩后就是8k(8 * 8 (8 bit) * 1024(1k) =65K),也就是说需要需要8k的心跳包,作者认为这样做不太值得;并且一般情况下一个redis集群不会有超过1000个master节点,所以16k的槽位是个比较合适的选择。

QA2: 为什么Redis Cluster中不建议使用发布订阅呢?

        在集群模式下,所有的publish命令都会向所有节点(包括从节点)进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重了带宽负担,对于在有大量节点的集群中频繁使用pub,会严重消耗带宽,不建议使用。(虽然官网上讲有时候可以使用Bloom过滤器或其他算法进行优化的)

五、Redis的过期数据回收策略

1、过期数据回收策略

Redis主要有2种:

  1. 惰性删除:惰性删除指的是当我们查询key的时候才对key进⾏检测,如果已经达到过期时间,则删除。显然,他有⼀个缺点就是如果这些过期的key没有被访问,那么他就⼀直⽆法被删除,⽽且⼀直占⽤内存。
  2. 定期删除:定期删除指的是Redis每隔⼀段时间对数据库做⼀次检查,删除⾥⾯的过期key。由于不可能对所有key去做轮询来删除,所以Redis会每次随机取⼀些key去做检查和删除。

在这里插入图片描述

2、Redis内存溢出淘汰策略

        Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略,Redis支持六种策略:

  1. noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返 回客户端错误信息,此 时Redis只响应读操作。
  2. volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直 到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
  3. allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性, 直到腾出足够空间为止。
  4. allkeys-random:随机删除所有键,直到腾出足够空间为止。
  5. volatile-random:随机删除过期键,直到腾出足够空间为止。
  6. volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果 没有,回退到noeviction策略。

Redis六种内存溢出控制策略

六、Redis相关面试问题

参考:https://juejin.cn/post/7094237187343908900#heading-20

1、Redis为什么那么快?

  1. 完全基于内存操作
  2. 使⽤单线程(单进程单线程),避免了线程切换和竞态产生的消耗
  3. 基于⾮阻塞的IO多路复⽤机制
  4. C语⾔实现,优化过的数据结构,基于⼏种基础的数据结构,redis做了⼤量的优化,性能极⾼ 

    Redis使用IO多路复用和自身事件模型

2、什么是缓存击穿、缓存穿透、缓存雪崩? 

2.1、缓存击穿

        一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在DB上。

缓存击穿

解决⽅案:

  1. 加锁更新,⽐如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。

    加锁更新

  2. 将过期时间组合写在value中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。

2.2、缓存穿透

        缓存穿透指的查询缓存和数据库中都不存在的数据,这样每次请求直接打到数据库,就好像缓存不存在一样。

        缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。缓存穿透可能会使后端存储负载加大,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。缓存穿透可能有两种原因:1、自身业务代码问题;2、恶意攻击,爬虫造成空命中。

缓存穿透

解决办法

  • 缓存空值/默认值:一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。

缓存空值/默认值

        缓存空值有两大问题:

  1. 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  2. 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。 例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。 这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。

  • 布隆过滤器:除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。(布隆过滤器里会保存数据是否存在,如果判断数据不在,就不会访问存储)

布隆过滤器

  • 两种解决方案的对比:

缓存空对象核布隆过滤器方案对比

2.3、缓存雪崩

        某⼀时刻发⽣⼤规模的缓存失效的情况,例如缓存服务宕机、大量key在同一时间过期,这样的后果就是⼤量的请求进来直接打到DB上,可能导致整个系统的崩溃,称为雪崩。

缓存雪崩

解决方法:

缓存雪崩是三大缓存问题里最严重的一种,我们来看看怎么预防和处理。

  • 提高缓存可用性
    • 集群部署:通过集群来提升缓存的可用性,可以利用Redis本身的Redis Cluster或者第三方集群方案如Codis等。
    • 多级缓存:设置多级缓存,第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。
  • 过期时间
    • 均匀过期:为了避免大量的缓存在同一时间过期,可以把不同的 key 过期时间随机生成,避免过期时间太过集中。
    • 热点数据永不过期。【并不是缓存真的不过期。是不设置过期时间,但是通过后台任务定时刷新,重建缓存
  • 熔断降级
    • 服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。
    • 服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。

3、布隆过滤器

        布隆过滤器是一个连续的数据结构,每个存储位存储都是一个bit,即0或者1, 来标识数据是否存在。存储数据的时时候,使用K个不同的哈希函数将这个变量映射为bit列表的的K个点,把它们置为1。

布隆过滤器

我们判断缓存key是否存在,同样,K个哈希函数,映射到bit列表上的K个点,判断是不是1:

  • 如果全不是1,那么key不存在;
  • 如果都是1,也只是表示key可能存在。

布隆过滤器也有一些缺点:

  1. 它在判断元素是否在集合中时是有一定错误几率,因为哈希算法有一定的碰撞的概率。
  2. 不支持删除元素。

4、如何保证缓存和数据库数据的⼀致性?

        根据CAP理论,在保证可用性和分区容错性的前提下,无法保证一致性,所以缓存和数据库的绝对一致是不可能实现的,只能尽可能保存缓存和数据库的最终一致性

4.1、选择合适的缓存更新策略:

  • 删除缓存而不是更新缓存

        当一个线程对缓存的key进行写操作的时候,如果其它线程进来读数据库的时候,读到的就是脏数据,产生了数据不一致问题。相比较而言,删除缓存的速度比更新缓存的速度快很多,所用时间相对也少很多,读脏数据的概率也小很多。

删除缓存和更新缓存

  • 先更数据,后删缓存

        先更数据库还是先删缓存?这是一个问题。如果先更数据,后删缓存。更新数据,耗时可能在删除缓存的百倍以上。在缓存中不存在对应的key,数据库又没有完成更新的时候,如果有线程进来读取数据,并写入到缓存,那么在更新成功之后,这个key就是一个脏数据。

毫无疑问,先删缓存,再更数据库,缓存中key不存在的时间的时间更长,有更大的概率会产生脏数据

        目前最流行的缓存读写策略cache-aside-pattern就是采用先更数据库,再删缓存的方式

先更数据库还是先删缓存

4.2、缓存不一致处理

如果不是并发特别高,对缓存依赖性很强,其实一定程序的不一致是可以接受的。但是如果对一致性要求比较高,那就得想办法保证缓存和数据库中数据一致。缓存和数据库数据不一致常见的两种原因:1、缓存key删除失败;2、并发导致写入了脏数据。

缓存一致性

解决方案: 

  • 消息队列保证key被删除:可以引入消息队列,把要删除的key或者删除失败的key丢尽消息队列,利用消息队列的重试机制,重试删除对应的key。【这种方案看起来不错,缺点是对业务代码有一定的侵入性】

消息队列保证key被删除

  • 数据库订阅+消息队列保证key被删除:可以用一个服务(比如阿里的 canal)去监听数据库的binlog,获取需要操作的数据。然后用一个公共的服务获取订阅程序传来的信息,进行缓存删除操作。【这种方式降低了对业务的侵入,但其实整个系统的复杂度是提升的,适合基建完善的大厂】

数据库订阅+消息队列保证key被删除

  • 延时双删防止脏数据:还有一种情况,是在缓存不存在的时候,写入了脏数据,这种情况在先删缓存,再更数据库的缓存更新策略下发生的比较多,解决方案是延时双删。【简单说,就是在第一次删除缓存之后,过了一段时间之后,再次删除缓存。这种方式的延时时间设置需要仔细考量和测试。】

延时双删

  • 设置缓存过期时间兜底:这是一个朴素但是有用的办法,给缓存设置一个合理的过期时间,即使发生了缓存数据不一致的问题,它也不会永远不一致下去,缓存过期的时候,自然又会恢复一致。

5、如何保证本地缓存和分布式缓存的一致?

        在日常的开发中,我们常常采用两级缓存:本地缓存+分布式缓存。所谓本地缓存,就是对应服务器的内存缓存,比如Caffeine,分布式缓存基本就是采用Redis。那么问题来了,本地缓存和分布式缓存怎么保持数据一致?

        Redis缓存,数据库发生更新,直接删除缓存的key即可,因为对于应用系统而言,它是一种中心化的缓存。但是本地缓存,它是非中心化的,散落在分布式服务的各个节点上,没法通过客户端的请求删除本地缓存的key,所以得想办法通知集群所有节点,删除对应的本地缓存key。 可以采用消息队列的方式:

  • 采用Redis本身的Pub/Sub机制,分布式集群的所有节点订阅删除本地缓存频道,删除Redis缓存的节点,同事发布删除本地缓存消息,订阅者们订阅到消息后,删除对应的本地key。 但是Redis的发布订阅不是可靠的,不能保证一定删除成功。
  • 引入专业的消息队列,比如RocketMQ,保证消息的可靠性,但是增加了系统的复杂度。
  • 设置适当的过期时间兜底,本地缓存可以设置相对短一些的过期时间。

本地缓存/分布式缓存保持一致

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值