分布式缓存 - 图1

Redis 单节点问题

  1. 数据丢失问题:Redis是内存存储,服务重启可能会丢失数据
    1. 实现 Redis 数据持久化
  2. 并发能力问题:单节点Redis并发能力虽然不错,但也无法满足如:双11 这样的高并发场景
    1. 搭建主从集群,实现读写分离
  3. 故障恢复问题:如果Redis宕机,则服务不可用,需要一种自动的故障恢复手段
    1. 利用 Redis 哨兵,实现健康监测和自动恢复
  4. 存储能力问题:Redis基于内存,单节点能存储的数据量难以满足海量数据需求
    1. 像 ES 存储一样,搭建分片集群,利用插槽机制实现动态扩容

数据丢失-Redis 持久化

Redis 持久化两种方案:

  1. RDB 持久化
  2. AOF 持久化

RDB 持久化

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。

执行时机

RDB持久化在四种情况下会执行:

  • 执行save命令
  • 执行bgsave命令
  • Redis停机时
  • 触发RDB条件时

命令

  1. save

image.png
save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。只有在数据迁移时可能用到。

  1. bgsave

image.png
这个命令执行后会开启独立进程完成RDB,主进程可以持续处理用户请求,不受影响。

  1. 停机时

Redis停机时会执行一次save命令,实现RDB持久化。

  1. 触发 RDB 条件

Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:

  1. # 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB
  2. save 900 1
  3. save 300 10
  4. save 60 10000

RDB的其它配置也可以在redis.conf文件中设置:

  1. # 是否压缩 ,建议不开启,压缩也会消耗cpu,磁盘的话不值钱
  2. rdbcompression yes
  3. # RDB文件名称
  4. dbfilename dump.rdb
  5. # 文件保存的路径目录
  6. dir ./

RDB 原理

🧑🏻‍💻 子进程是怎么来的呢?

bgsave 开始时会 fork 主进程得到子进程,子进程共享主进程的内存数据。完成 fork 后读取内存数据并写入 RDB 文件。

fork 这个过程是阻塞的,主进程不能接受任何请求!!!

🧑🏻‍💻 fork 过程是什么样子的呢?

  1. 在 Liunx 中所有进程都无法直接操作物理内存,主进程实现对数据的 read 和 writer 时,OS 给每个进程分配一个虚拟内存(进程只能操作虚拟内存),OS 会维护虚拟内存与物理内存之间的映射关系(用页表);
  2. 读写过程找到真正的内存位置:进程 -> 页表 -> 物理内存;
  3. fork 过程:不是复制数据,而是仅仅 copy 页表,所以子进程和主进程对物理内存有相同的映射关系,这样就实现主子进程之间的数据共享,无需拷贝数据(这样就实现了 fork 过程的时间);
  4. 子进程写新的 RDB 文件替换旧的 RDB 文件;
  5. 在子进程写 RDB 文件(读数据)的时候,主进程有可能修改数据,会出现脏数据,为了避免这种情况,fork 采用的是 copy-on-write 技术:
    • 当主进程执行读操作时,访问共享内存;
    • 当主进程执行写操作时,则会拷贝一份数据,执行写操作,主进程下次读的时候,读拷贝的那份数据;
      • 如:数据B 拷贝出 数据B副本,主进程往数据B副本中写,下次也从数据B副本中读;

image.png

RDB 方式 bgsave 的基本流程?

  • fork 主进程得到一个子进程,共享内存空间;
  • 子进程读取内存数据并写入新的 RDB 文件;
  • 用新 RDB 文件替换旧的 RDB 文件;

RDB 的缺点?

  • RDB 执行间隔时间长,两次 RDB 之间写入数据有丢失的风险;
  • fork 子进程、压缩、写出 RDB 文件都比较耗时;
  • RDB 可能导致内存溢出,如:在子进程写 RDB 的时候,主进程对多份数据大量写操作,导致数据拷贝多份;

AOF 持久化

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

AOF 配置

AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:

  1. # 是否开启AOF功能,默认是no
  2. appendonly yes
  3. # AOF文件的名称
  4. appendfilename "appendonly.aof"

AOF的命令记录的频率也可以通过redis.conf文件来配:

  1. # 表示每执行一次写命令,立即记录到AOF文件
  2. appendfsync always
  3. # 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
  4. appendfsync everysec
  5. # 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
  6. appendfsync no

三种策略对比:

配置项 刷盘时机 优点 缺点
always 同步刷盘 可靠性高,几乎不丢数据 性能影响大
everysec 每秒刷盘 性能适中 最多丢失1秒数据
no OS 控制 性能最好 可靠性较差,可能丢失大量数据

AOF 文件重写

  1. 执行 bgrewriteaof

因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

image.png

如图,AOF原本有三个命令,但是 set num 123 和 set num 666 都是对num的操作,第二次会覆盖第一次的值,因此第一个命令记录下来没有意义。所以重写命令后,AOF文件内容就是:mset name jack num 666

  1. Redis也会在触发阈值时自动去重写AOF文件。

阈值也可以在redis.conf中配置:

  1. # AOF文件比上次文件 增长超过多少百分比则触发重写
  2. auto-aof-rewrite-percentage 100
  3. # AOF文件体积最小多大以上才触发重写
  4. auto-aof-rewrite-min-size 64mb

AOD 原理

AOF 持久化功能的实现可以分为三个步骤:命令追加、文件写入、文件同步。

  1. redis 打开 AOF 持久化功能之后,redis 在执行完一个写命令后,把执行的命令首先追加到 redis 内部的 aof_buf 缓冲区末尾,此时缓冲区的记录还没有写到 Appendonly.aof 文件中;
  2. 缓冲区的写命令会被写入到 AOF 文件,这一过程是文件写入过程;
  3. 对于操作系统来说,调用write函数并不会立刻将数据写入到硬盘,为了将数据真正写入硬盘,还需要调用 fsync 函数,调用 fsync 函数即是文件同步的过程,只有经过了文件的同步过程,写命令才真正的被保存到了 AOF 文件中。
    1. 选项 Appendfsync 就是配置同步的频率的。

RDB 和 AOF 的区别


RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整,两次备份之间会丢失 相对完整,取决于刷盘策略
文件大小 会有压缩,文件体积小 记录命令,文件体积很大
宕机恢复速度 很快
数据恢复优先级 低,因为数据完整性不如AOF 高,因为数据完整性更高
系统资源占用 高,大量CPU和内存消耗
子进程写 RDB 文件时存在主进程大量写
低,主要是磁盘IO资源
但AOF重写时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失,追求更快的启动速度 对数据安全性要求较高常见

混合持久

混合持久化并不是一种全新的持久化方式,而是对已有方式的优化。混合持久化只发生于 AOF 重写过程。使用了混合持久化,重写后的新 AOF 文件前半段是 RDB 格式的全量数据,后半段是 AOF 格式的增量数据。

混合持久化本质是通过 AOF 后台重写(bgrewriteaof 命令)完成的,不同的是当开启混合持久化时,fork 出的子进程先将当前全量数据以 RDB 方式写入新的 AOF 文件,然后再将 AOF 重写缓冲区(aof_rewrite_buf_blocks)的增量命令以 AOF 方式写入到文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。


主从架构

数据同步原理

全量同步

主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:

image.png
repl_baklog 是一块内存,用于记录 master 在做 bgsave 期间,到 master 的命令,然后再发送到 slave

这里有一个问题,master如何得知salve是第一次来连接呢??

有几个概念,可以作为判断依据:

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

因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据。

因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。

master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。

master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。

因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致

如图:
image.png
完整流程描述:

  • slave节点请求增量同步
  • master节点判断replid,发现不一致,拒绝增量同步
  • master将完整内存数据生成RDB,发送RDB到slave
  • slave清空本地数据,加载master的RDB
  • master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步

增量同步

全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
image.png

那么master怎么知道slave与自己的数据差异在哪里呢?

repl_backlog 原理

master怎么知道slave与自己的数据差异在哪里呢?
这就要说到全量同步时的repl_baklog文件了。
这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset:
image.png

slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:
image.png

直到数组被填满:
image.png

此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分。
但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset:
image.png

如果master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:

image.png
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了。只能做全量同步。

注意:repl_backlog 大小有上限,写满后会覆盖最早的数据。如果 slave 断开时间过久,导致尚未备份的数据被覆盖,则无法基于 log 做增量同步,只能再次全量同步。

主从同步优化

主从同步可以保证主从数据的一致性,非常重要。

可以从以下几个方面来优化Redis主从就集群:

  • 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
  • Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
  • 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

主从从架构图:

image.png

小结

简述全量同步和增量同步区别?

  • 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
  • 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave

什么时候执行全量同步?

  • slave节点第一次连接master节点时
  • slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

什么时候执行增量同步?

  • slave节点断开又恢复,并且在repl_baklog中能找到offset时

故障恢复-哨兵

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。

哨兵的结构如图:

image.png

哨兵的作用如下:

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

集群监控原理

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

  • 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线

  • 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

image.png

集群故障恢复原理

一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
  • 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
  • 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
  • 最后是判断slave节点的运行id大小,越小优先级越高。

当选出一个新的master后,该如何实现切换呢?

流程如下:

  • sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master
  • sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
  • 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点

image.png

小结

Sentinel的三个作用是什么?

  • 监控
  • 故障转移
  • 通知

Sentinel如何判断一个redis实例是否健康?

  • 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线
  • 如果大多数sentinel都认为实例主观下线,则判定服务下线

故障转移步骤有哪些?

  • 首先选定一个slave作为新的master,执行slaveof no one
  • 然后让所有节点都执行slaveof 新master
  • 修改故障节点配置,添加slaveof 新master

搭建哨兵集群

参考:Redis 部署之哨兵部署

RedisTemplate

在Sentinel集群监管下的Redis主从集群,其节点会因为自动故障转移而发生变化,Redis的客户端必须感知这种变化,及时更新连接信息。Spring的RedisTemplate底层利用lettuce实现了节点的感知和自动切换。

下面,我们通过一个测试来实现RedisTemplate集成哨兵机制。

代码参考:code-demo 中 Redis 中 RedisSentinel

  1. 依赖

    1. <dependency>
    2. <groupId>org.springframework.boot</groupId>
    3. <artifactId>spring-boot-starter-data-redis</artifactId>
    4. </dependency>
  2. 配置 Redis 地址

然后在配置文件application.yml中指定redis的sentinel相关信息:

  1. spring:
  2. redis:
  3. sentinel:
  4. master: mymaster
  5. nodes:
  6. - 192.168.150.101:27001
  7. - 192.168.150.101:27002
  8. - 192.168.150.101:27003
  1. 配置读写分离

在项目的启动类中,添加一个新的bean:

  1. @Bean
  2. public LettuceClientConfigurationBuilderCustomizer clientConfigurationBuilderCustomizer(){
  3. return clientConfigurationBuilder -> clientConfigurationBuilder.readFrom(ReadFrom.REPLICA_PREFERRED);
  4. }

这个bean中配置的就是读写策略,包括四种:

  • MASTER:从主节点读取
  • MASTER_PREFERRED:优先从master节点读取,master不可用才读取replica
  • REPLICA:从slave(replica)节点读取
  • REPLICA _PREFERRED:优先从slave(replica)节点读取,所有的slave都不可用才读取master

分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,如图:
image.png

分片集群特征:

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

参考:Redis 部署-分片集群

散列插槽

插槽原理

Redis会把每一个master节点映射到0~16383共16384个插槽(hash slot)上,查看集群信息时就能看到:

image.png

数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:

  • key中包含”{}”,且“{}”中至少包含1个字符,“{}”中的部分是有效部分
  • key中不包含“{}”,整个key都是有效部分

例如:key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。

image.png

如图,在7001这个节点执行set a 1时,对a做hash运算,对16384取余,得到的结果是15495,因此要存储到103节点。

到了7003后,执行get num时,对num做hash运算,对16384取余,得到的结果是2765,因此需要切换到7001节点

小结

Redis如何判断某个key应该在哪个实例?

  • 将16384个插槽分配到不同的实例
  • 根据key的有效部分计算哈希值,对16384取余
  • 余数作为插槽,寻找插槽所在实例即可

如何将同一类数据固定的保存在同一个Redis实例?

  • 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀

集群伸缩

redis-cli —cluster提供了很多操作集群的命令,可以通过下面方式查看:

image.png

比如,添加节点的命令:
image.png

需求分析

需求:向集群中添加一个新的master节点,并向其中存储 num = 10

  • 启动一个新的redis实例,端口为7004
  • 添加7004到之前的集群,并作为一个master节点
  • 给7004节点分配插槽,使得num这个key可以存储到7004实例

这里需要两个新的功能:

  • 添加一个节点到集群中
  • 将部分插槽分配到新插槽

创建新的 Redis 实例

创建一个文件夹:

  1. mkdir 7004

拷贝配置文件:

  1. cp redis.conf /7004

修改配置文件:

  1. sed /s/6379/7004/g 7004/redis.conf

启动

  1. redis-server 7004/redis.conf

添加新节点到redis

添加节点的语法如下:

image.png

执行命令:

  1. redis-cli --cluster add-node 192.168.150.101:7004 192.168.150.101:7001

通过命令查看集群状态:

  1. redis-cli -p 7001 cluster nodes

如图,7004加入了集群,并且默认是一个master节点:

image.png

但是,可以看到7004节点的插槽数量为0,因此没有任何数据可以存储到7004上

转移插槽

我们要将num存储到7004节点,因此需要先看看num的插槽是多少:

image.png

如上图所示,num的插槽为2765.

我们可以将0~3000的插槽从7001转移到7004,命令格式如下:

image.png

具体命令如下:

建立连接:

image.png

得到下面的反馈:

image.png

询问要移动多少个插槽,我们计划是3000个:

新的问题来了:

image.png

那个node来接收这些插槽??

显然是7004,那么7004节点的id是多少呢?

image.png

复制这个id,然后拷贝到刚才的控制台后:

image.png

这里询问,你的插槽是从哪里移动过来的?

  • all:代表全部,也就是三个节点各转移一部分
  • 具体的id:目标节点的id
  • done:没有了

这里我们要从7001获取,因此填写7001的id:

image.png

填完后,点击done,这样插槽转移就准备好了:

image.png

确认要转移吗?输入yes:

然后,通过命令查看结果:

image.png

可以看到:

image.png

目的达成。


故障转移

集群初识状态是这样的:

image.png

其中7001、7002、7003都是master,我们计划让7002宕机。


自动故障转移

当集群中有一个master宕机会发生什么呢?

直接停止一个redis实例,例如7002:

  1. redis-cli -p 7002 shutdown

1)首先是该实例与其它实例失去连接

2)然后是疑似宕机:

image.png

3)最后是确定下线,自动提升一个slave为新的master:

image.png

4)当7002再次启动,就会变为一个slave节点了:

image.png


手动故障转移

利用cluster failover命令可以手动让集群中的某个master宕机,切换到执行cluster failover命令的这个slave节点,实现无感知的数据迁移。其流程如下:
image.png
这种failover命令可以指定三种模式:

  • 缺省:默认的流程,如图1~6歩
  • force:省略了对offset的一致性校验
  • takeover:直接执行第5歩,忽略数据一致性、忽略master状态和其它master的意见

案例需求:在7002这个slave节点执行手动故障转移,重新夺回master地位

步骤如下:

1)利用redis-cli连接7002这个节点

2)执行cluster failover命令

如图:
image.png

效果:
image.png

RedisTemplate 访问分片集群

RedisTemplate底层同样基于lettuce实现了分片集群的支持,而使用的步骤与哨兵模式基本一致:

  1. 引入redis的starter依赖
  2. 配置分片集群地址
  3. 配置读写分离

与哨兵模式相比,其中只有分片集群的配置方式略有差异,如下:

  1. spring:
  2. redis:
  3. cluster:
  4. nodes:
  5. - 192.168.150.101:7001
  6. - 192.168.150.101:7002
  7. - 192.168.150.101:7003
  8. - 192.168.150.101:8001
  9. - 192.168.150.101:8002
  10. - 192.168.150.101:8003

其他

Docker 中进入 Redis

  1. 进入 Redis

    1. docker exec :在运行的容器中执行命令
    2. # 语法
    3. docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
    4. # OPTIONS说明:
    5. -d :分离模式: 在后台运行
    6. -i :即使没有附加也保持STDIN 打开
    7. -t :分配一个伪终端
  2. 命令

    1. # 启动 redis 服务
    2. docker exec -it redis-test redis-server
    3. # 连接 redis 客户端
    4. docker exec -it docker-redis-name/id redis-cli
    5. # password
    6. auth [username] password

参考

  1. Redis:RDB、AOF、混合持久化