Loading...
墨滴

jrh

2021/11/15  阅读:90  主题:橙心

Java 面试八股文之中间件篇(四)

前言

这是系列文章【 Java 面试八股文】中间件篇的第四期。

【 Java 面试八股文】系列会陆续更新 Java 面试中的高频问题,旨在从问题出发,带你理解 Java 基础,数据结构与算法,数据库,常用框架等。该系列前几期文章可以通过下方给出的链接进行查看~

按照惯例——首先要做几点说明:

  1. 【 Java 面试八股文】中的面试题来源于社区论坛,书籍等资源;感谢使我读到这些宝贵面经的作者们。
  2. 对于【 Java 面试八股文】中的每个问题,我都会尽可能地写出我自己认为的“完美解答”。但是毕竟我的身份不是一个“真理持有者”,只是一个秉承着开源分享精神的 “knowledge transmitter” & 菜鸡,所以,如果这些答案出现了错误,可以留言写出你认为更好的解答,并指正我。非常感谢您的分享。
  3. 知识在于“融释贯通”,而非“死记硬背”;现在市面上固然有很多类似于“Java 面试必考 300 题” 这类的文章,但是普遍上都是糟粕,仅讲述其果,而不追其源;希望我的【 Java 面试八股文】可以让你知其然,且知其所以然~

那么,废话不多说,我们正式开始吧!

往期文章

Redis 篇(三)

1、Redis 主从复制


在线上的生产环境中,如果我们对 Redis 的可用性,可靠性要求较高,则可以配置 Redis 集群。单个 Redis 节点的读写能力有限,且存在不稳定性,如果出现宕机的情况便没有可用的服务了。配置 Redis 集群可以分担单个节点的压力,实现扩容,提高整体服务的可用性及可靠性。

Redis 常用的集群方案有以下三种:

  • 主从复制
  • 哨兵模式
  • Cluster 模式

接下来,我们先重点看一下 Redis 主从复制的相关内容。

Redis 的主从复制是在从节点上发起的。假设目前我开启了两个 Redis 实例,分别监听本机的 6379 端口与 6380 端口。

在连接 6380 端口的 redis-cli 客户端上,我输入了命令:

127.0.0.1:6380> SLAVEOF 127.0.0.1 6379

便可以让监听 6380 端口的实例成为 6379 端口的实例的从服务器。当我们在主节点上进行更新操作时,从节点便会实时同步主节点的更新。

我们可以通过 SLAVEOF <masterip> <masterport> 命令来建立主从复制关系,也可以通过 SLAVEOF NO ONE 命令来断开主从复制。

主从复制的实现原理

Redis 主从复制分为同步(sync)与命令传播(command propagate)两个阶段。

sync

同步操作用于将从服务器的数据更新至主服务器的当前状态。当我们在客户端上向从服务器发送 SLAVEOF 命令后,从服务器便会连接到主服务器并发送 SYNC 命令来完成数据的全量同步,其过程如下图所示:

  1. 首先 Slave 从服务器会连接到主服务器,并发送 SYNC 命令
  2. Master 主服务器接收到 SYNC 命令后便会执行 BGSAVE 命令生成 RDB 文件,并使用缓冲区记录在此后执行的所有写命令
  3. 在 RDB 文件生成后,Master 向所有的 Slave 发送快照文件,并在发送期间继续记录被执行的写命令
  4. Slave 收到快照文件后,便会丢弃所有的旧数据,载入新的快照
  5. Slave 在快照载入完毕后,Master 便会向 Slave 发送缓冲区中的写命令
  6. Slave 执行来自 Master 缓冲区的写命令完成数据的全量同步

command propagate

从节点不仅要做到与当前主节点的状态保持一致,还要保证后续主节点一旦执行写操作,从节点也可以与主节点保持实时的同步更新。而这种同步更新便是通过命令传播(command propagate)完成的。

在命令传播阶段,主节点会向从节点发送写命令。为了确保主从节点进行信息交互时,双方都能保持在线状态,主从节点会通过心跳机制来进行监测。关于心跳机制,稍后会做介绍。

全量重同步与部分重同步

我们知道,从节点通过向主节点发送 SYNC 命令请求全量数据,主节点使用命令传播实现主从节点的实时同步。不过试想一下,主从同步正在进行时,处于命令传播阶段的主从节点因为网络原因,导致中断了命令传播。这个时候,如果重新连接,从节点便会再次向主节点发送 SYNC 命令,这个过程是非常低效的,因为它本身就是一次全量同步的过程。

重新执行 SYNC 命令就意味着主节点需要再次执行一次 BGSAVE 命令来生成 RDB 文件。BGSAVE 命令会占用大量的 CPU,磁盘 I/O 资源;并且,主节点需要将 RDB 文件发送给从节点,占用了主从节点的带宽;在从节点载入 RDB 文件期间,会因为阻塞而导致无法处理请求。

Redis 2.8 开始,为了解决上述问题,提供了两种“重同步”的策略(重同步,顾名思义,指的是主从同步时,发生网络中断之类的异常,如何重新恢复主从同步的状态),即:全量重同步(full resynchronization)与部分重同步(partial resynchronization)。

从服务器使用 PSYNC 命令来代替 SYNC 命令。从节点向主节点发送 PSYNC 命令来请求同步数据,而系统会根据主从节点的“状态”,使用这两种策略中的一种。

当从节点首次连接主节点时,主节点会选择全量重同步的策略将数据复制给从节点。这个过程和我们讲过的 SYNC + command propagate 是一致的。我们着重来讲一下部分重同步是怎么一回事儿。

所谓的部分重同步就是规避了主节点再次执行 BGSAVE 命令生成并发送 RDB 文件,再让从节点载入 RDB 这一过程。部分重同步会直接发送主从服务器断线期间,主服务器执行的写命令,大大节省了资源和时间。我们可以理解为,部分重同步就是忽略了 SYNC,而只是执行了主节点向从节点命令传播这一过程。

部分重同步的实现依赖于以下三个重要概念:

  • 复制偏移量(replication offset)
  • 复制积压缓冲区(replication backlog)
  • 服务器运行 ID(run_id)

复制偏移量

主节点和从节点分别维护一个复制偏移量。复制偏移量代表的是主节点向从节点发送数据的字节数。主节点每次向从节点发送 n 字节数据时,便会在自己的偏移量 offset 上加 n;而从节点每次收到主节点发送的 n 字节数据时,也会在自己的偏移量 offset 上加 n。在命令传播阶段,从节点会通过心跳机制定期向主节点发送 REPLCONF ACK{offset} 命令,其中便会带上自身的复制偏移量。主节点通过从节点发送的 offset 与自己的 offset 进行对比,如果有数据丢失,主节点便会推送丢失的那段数据。举个例子🌰:

主节点的 offset 为 100,从节点的 offset 为 80。这就说明从节点丢失了 81~100 之间的数据,主节点便需要将这段数据推送给从节点,而这些数据存储的位置便是复制积压缓冲区。

复制积压缓冲区

复制积压缓冲区是由主节点维护的一个固定长度,先进先出的队列,其默认大小为 1MB。

在命令传播阶段,主节点除了将写命令发送给从节点,还会发送一份给复制积压缓冲区作为备份。复制积压缓冲区存储了每个字节值与对应的复制偏移量:

因为复制积压缓冲区是固定大小的,所以它保存的是主节点近期执行的写命令。如果主从节点的 offset 差值大于缓冲区的长度时,则无法使用部分重同步。我们可以通过配置来修改复制积压缓冲区的大小(repl-backlog-size),而复制积压缓冲区大小,可以通过从服务器断线后重新连接到主服务器的平均时间(秒) × 主服务器平均每秒产生的写命令的大小来估算。

举个例子🌰:

假设从服务器断线后,平均需要 5 秒才能重新连接到主服务器,主服务器平均每秒大约产生 1 MB 的写命令,那么我们就可以将复制积压缓冲区大小设置为 5 MB,保险起见,可以设置为 10 MB。这样就可以满足绝大部分情况下,一旦从服务器断线都可以使用部分重同步来解决。

所以,当从节点将 offset 发送给主节点后,主节点便会根据 offset 与复制积压缓冲区的大小来决定是否可以使用部分重同步。如果 offset 之后的数据仍然在复制积压缓冲区内,则执行部分重同步;否则还是执行全量重同步。

服务器运行 ID

任何一个 Redis 节点,在启动时都会产生一个服务器运行 ID,这个 ID 用来标识唯一的一个 Redis 节点。

当主从节点首次同步时,主节点会将自己的 ID 发送给从节点,从节点会将该 ID 号保存起来。当主从服务器断线重连时,从节点会将这个 ID 号发送给主节点,主节点根据自己的 ID 与从节点发送的 ID 号是否相等来判断之前有没有与这台服务器同步过。如果不相等,则会直接进行全量同步。

心跳机制

我们在上文中说过,命令传播阶段,主节点会向从节点发送写命令,从节点会向主节点发送复制偏移量。为了确保主从节点进行信息交互时,双方都能保持在线状态,主从节点会通过心跳机制来进行监测。

主节点会定期地向从节点发送 PING 命令,该命令的作用是对从节点进行超时判断,来监测从节点是否在线。

而从节点会定期地向主节点发送 REPLCONF ACK{offset} 命令,该命令的第一个作用是从节点向主节点汇报自己的复制偏移量;第二个作用是用来判断主节点是否复制超时。

Redis 主从复制的优缺点

Redis 主从复制方式建立集群的优点为配置简单,主节点负责写,从节点负责读可以有效地降低读的压力。不过这种方式的缺点也很明显:当主节点宕机后,我们需要手动地将一台服务器切换为主服务器,这就需要人工干预,费时且费力,还会造成一段时间内写服务不可用。

我们来看一下 Redis 的第二种配置集群的方案:Redis Sentinel。

2、Redis Sentinel


Redis Sentinel 即:哨兵模式。

在上文中,我们提到了主从复制的缺点。而 Redis Sentinel 基于主从复制,引入了“哨兵”来监控与自动处理故障,是一种高可用的方案。它提供了以下几种功能:

  • 监控(Monitoring)
  • 自动故障转移(Automatic Failover)
  • 配置提供程序(Configuration Provider)
  • 通知(Notification)

监控功能指的是,哨兵会不断检查主节点与从节点是否有出现故障,并且这些哨兵节点之间也可以进行监控;自动故障转移功能为,当主节点出现故障时,哨兵便会自动地将失效主节点的一个从节点升级为新的主节点;配置提供程序的功能为,我们可以通过哨兵节点来获取到主节点的状态信息;通知功能为,哨兵节点可以将故障转移的结果通知给客户端。

部署

Redis Sentinel 的本质就是一个 Redis 实例/节点。我们可以通过 Redis 安装目录下的 sentinel.conf 文件进行配置。

譬如,我配置了 3 个哨兵节点与 1 个 Master 节点以及 2 个 Slave 节点:

服务类型 IP Port
Master 192.168.1.128 6379
Slave 192.168.1.129 6379
Slave 192.168.1.130 6379
Sentinel 192.168.1.131 26379
Sentinel 192.168.1.132 26379
Sentinel 192.168.1.133 26379

Redis 主从服务器的配置如下:

# master redis.conf
port 6379
# ... ...

# slave1 redis.conf
slaveof 192.168.1.128 6379
port 6379
# ... ...

# slave2 redis.conf
slaveof 192.168.1.128 6379
port 6379
# ... ...

配置完成后,使用 redis-server redis.conf 命令,按照顺序依次启动主节点与从节点。

Redis Sentinel 配置如下,每个哨兵的配置均相同:

# Sentinel1 sentinel.conf
port 26379
# sentinel monitor <master-name> <ip> <redis-port> <quorum>
sentinel monitor mymaster 192.168.1.128 6379 2
# ... ...

这里面,sentinel monitor mymaster 192.168.1.128 6379 2 配置的含义为,哨兵节点监控着 IP 为 192.168.1.128,端口号为 6379 的主节点;最后的 2 的含义为,至少需要 2 个哨兵节点同意,才能判定主节点为故障节点并进行故障转移。

配置完毕后,使用 redis-sentinel sentinel.conf 开启三个哨兵节点。

这样,我们便部署好了主从节点与哨兵节点。

Jedis 客户端访问 Redis 哨兵

Jedis 对哨兵模式提供了很好的访问支持。在 Jedis 中,我们可以通过 JedisSentinelPool 来获取并配置哨兵节点。示例代码如下:

public void testSentinel() {
    String masterName = "mymaster";
    Set<String> sentinels = new HashSet<>();
    sentinels.add("192.168.1.131:26379");
    sentinels.add("192.168.1.132:26379");
    sentinels.add("192.168.1.133:26379");

    JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels);
    Jedis jedis = pool.getResource();
    jedis.set("test""test");
    pool.close();
}

哨兵的工作原理

我们来看一下,在哨兵模式下,如果主节点发生宕机,系统是如何进行自动故障转移的。

首先,每一个哨兵节点具有三个定时任务来监控并同步其他节点的信息。

任务一:每一个 Sentinel 默认每隔 10 秒向主从节点发送 INFO 命令,来获取最新的拓扑结构图。如果在主节点上新加入了一个从节点,Sentinel 通过 INFO 命令的回复马上就可以感知到:

任务二:每一个哨兵节点默认每隔 2 秒向 Redis 数据节点的指定频道上发布自己对主节点的判断以及当前哨兵节点的信息。同时,其他的哨兵节点会订阅该频道以了解其他哨兵节点的信息与它们对主节点的判断(发布/订阅模式):

任务三:每一个哨兵节点默认每隔 1 秒会向所有节点发送 PING 命令进行心跳检测来判断其他节点的状态:

当一个哨兵节点通过定时任务判断主节点服务不可用时,该哨兵节点会将主节点进行“主观下线”,与此同时,该哨兵会通过 sentinel is-masterdown-by-addr 指令获取其他哨兵节点对主节点的判断,如果当前哨兵节点对主节点主观下线的票数超过了我们定义的 quorum 值,则主节点会被判定为“客观下线”。

在主节点被判定为客观下线后,哨兵节点之间会通过一个选举算法(Raft 算法)选出一个 Leader,并由这个 Leader 来进行故障转移操作。

Leader Sentinel 节点会从原主节点的从节点中选出一个新的主节点,选举流程如下:

  1. 首先过滤掉所有主观下线的节点
  2. 选择 slave-priority 最高的节点,如果有则返回,没有就继续下面的流程
  3. 选择出复制偏移量 offset 最大的节点,如果有则返回,没有就继续下面的流程
  4. 选择 run_id(服务器运行 ID) 最小的节点

在选择完毕后,Leader Sentinel 节点会通过 SLAVEOF NO ONE 命令让选择出来的从节点成为主节点,然后通过 SLAVEOF 命令让其他的节点成为该节点的从节点。

Redis Sentinel 的优缺点

Redis 哨兵模式解决了主从同步不能自动故障转移的问题,实现了高可用的系统。但哨兵模式仍然是存在一些缺点的,譬如难以在线扩容,Redis 容量受限于单机配置,无法实现写操作的负载均衡等。

而构建 Redis 集群的最后一种常用方案——Cluster 则解决了上述问题。接下来我们就一起来看一下 Redis Cluster 的相关内容。

3、Redis Cluster


Redis 3.0 开始引入了去中心化分片集群 Redis Cluster。

我们知道,传统的 Redis 集群是基于主从复制或哨兵来实现的,无论是哪一种方式,集群中都只有一个主节点提供写服务。而 Redis Cluster 则采用多主多从的方式,支持开启多个主节点,每个主节点上可以挂载多个从节点。Cluster 会将数据进行分片,将数据分散到多个主节点上,而每个主节点都可以对外提供读写服务。这种做法使得 Redis 突破了单机内存大小限制,扩展了集群的存储容量。并且 Redis Cluster 也具备高可用性,因为每个主节点上都至少有一个从节点,当主节点挂掉时,Redis Cluster 的故障转移机制会将某个从节点切换为主节点。

Redis Cluster 是一个去中心化的集群,每个节点都会与其他节点保持互连,使用 gossip 协议来交换彼此的信息,以及探测新加入的节点信息。并且 Redis Cluster 无需任何代理,客户端会直接与集群中的节点直连:

数据 sharding

Redis Cluster 的数据分区方案是面试中考察的重点。我们知道,集群中的数据被分散到了多个主节点上,譬如你有 N 个 Master Node,你会设计什么样的算法将数据落到不同的节点上?

在这个算法设计中,我们需要考虑到以下两点:

  • 负载均衡
  • 支持扩容

负载均衡指的是,如果我们有 N 个 Master Node,那么这些 Key 应该尽可能地平均分散到每个节点中,这样才能保证每个节点对外提供的读写服务压力是一致的。同时,我们要考虑到集群的可扩展性,也就是要支持扩容。

方案一:hash 算法

很快地,我们便会想到这种方案:

hash(key) % N

我们对 Key 进行哈希取模,这样便可以使得每个 Key 可以均匀地落到 N 个节点上。不过这种方案对扩容是不友好的,如果我们想要在集群中增加一个节点或者是某个节点下线了导致集群中减少一个节点,那么我们之前存储的所有 Key 就要重新计算分布的位置,导致所有的缓存全部失效。

方案二:一致性 hash 算法

一致性 hash 算法将整个哈希值空间组织成一个虚拟的圆环,其范围为 0 ~ 2^32 - 1。该算法的原理是,我们会先对一个 Key 计算 hash 值,确定它在环上的位置,然后从该位置沿着环顺指针地走,找到的第一个节点,便是这个 Key 该映射到的服务器节点。

当我们向集群中增加或减少一个节点时,就无需像哈希取模算法一样对整个集群的 Key 重新计算位置了。一致性哈希算法将增减节点的影响限制在相邻的节点上,譬如我们在 node2 与 node4 之间增加一个节点,则只有 node4 中的一部分数据会迁移到新增节点上;如果我们想要将 node4 节点置为下线状态,则 node4 节点的数据只会迁移到 node 3 中,其他节点无影响。

一致性哈希算法最主要的问题在于,当节点比较少时,增删节点对单个节点的影响会很大,从而导致出现数据不均衡的情况。拿上图来举例,当我们删除任意一个节点,都会导致集群中的某一个节点的数据量由总数据的 1/4 变为 1/2。

方案三:带虚拟节点的一致性 hash 算法

该方案在一致性哈希的基础上,引入了虚拟节点这一概念。原本是由实际节点来“抢占” 哈希环的位置,现在则是将虚拟节点分配给实际节点,然后由虚拟节点来抢。在引入了虚拟节点这一概念后,数据到实际节点的映射关系就变成了数据到虚拟节点,再由虚拟节点到实际节点了。Redis 集群便是采用了这种方案。一个集群包含 16384 个哈希槽(hash slot)也就是 16384 个虚拟节点。譬如,我们的集群有三个节点,那么:

  • Master[0] 负责处理 0 ~ 5460 号 slot
  • Master[1] 负责处理 5461 ~ 10922 号 slot
  • Master[2] 负责处理 10923 ~ 16383 号 slot

当我们在集群中新增了一个节点 Master[3],那么集群只需要将 Master[0]Master[1]Master[2] 中负责的一部分 hash slot 分配给 Master[3] 节点就可以了;如果要移除某一个节点,也只需要将该节点负责的 hash slot 分配给其他的节点即可。这样集群便实现了良好的可扩容性。同时,虚拟节点的数量很多,有 16384 个,这些 hash slot 在哈希环上分布均匀,可以实现负载均衡。

集群搭建

在这一部分,我会从头到尾详细地教大家搭建一个 3 主 3 从的高可用 Redis 集群。为了方便起见,所有的节点都配置在同一台主机上,使用不同的端口号(9000~9005)来进行区分。

  • 操作系统:MacOS 10.15.7
  • Redis:6.0.9

首先,我们在 Redis 的安装目录下创建名为 9000~9005 这六个目录。

然后,将 Redis 安装目录下的 redis.conf 文件拷贝到这六个目录下,并命名为 redis-cluster.conf。

接着,我们将这六个目录下的 redis-cluster.conf 文件按照以下的方式进行修改:

# 配置集群节点对应的端口号,以 9000 为例
port 9000
# 守护进程开启
daemonize yes
# 关闭保护模式
protected-mode no
# 将集群开启
cluster-enabled yes
cluster-config.file nodes-9000.conf

并创建启动脚本 start-cluster.sh:

for port in {9000...9005}
do
redis-cli -c -p $port -h 127.0.0.1 shutdown
rm -f $port/dump*
rm -f $port/nodes*
done;
#start redis
for port in {9000..9005}
do redis-server $port/redis-cluster.conf
done;
#create cluster
echo yes|src/redis-cli --cluster create  127.0.0.1:9000 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 127.0.0.1:9004 127.0.0.1:9005  --cluster-replicas 1

与关闭服务脚本 stop-cluster.sh

for port in {9000..9005}
do
redis-cli -c -p $port shutdown
done;

启动脚本中,最后的命令的参数 –cluster-replicas 1 表示希望为集群的每个主节点都创建一个从节点(一主一从),也就是说,当前的脚本会开启 3 主 3 从的节点。

当我们依次开启 9000~9005 所有的 Redis 服务后(通过命令 redis-server redis-cluster.conf),便可以执行启动脚本了。在此之前,我们可以通过命令:

ps -ef | grep redis

来查看这些 Redis 实例是否有正确启动。

查看完毕并确认无误后,执行脚本:

sh start-cluster.sh
>>> Performing hash slots allocation on 6 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
Adding replica 127.0.0.1:9004 to 127.0.0.1:9000
Adding replica 127.0.0.1:9005 to 127.0.0.1:9001
Adding replica 127.0.0.1:9003 to 127.0.0.1:9002
>>> Trying to optimize slaves allocation for anti-affinity
[WARNING] Some slaves are in the same host as their master
M: 2dc2d12c5ae83c471a0162cf52dd133bb30f8025 127.0.0.1:9000
   slots:[0-5460] (5461 slots) master
M: 0035f8f68c3155e11acc1b43d8c33dc604e2e676 127.0.0.1:9001
   slots:[5461-10922] (5462 slots) master
M: 5cf5e38343546e70f26b00da824f2d63cb3ee03d 127.0.0.1:9002
   slots:[10923-16383] (5461 slots) master
S: e009034c50eb4553f74daefd1fef65820ccaedf0 127.0.0.1:9003
   replicates 5cf5e38343546e70f26b00da824f2d63cb3ee03d
S: 1a35332ff7dc8f06752eba84cbfcab9ed1b5d813 127.0.0.1:9004
   replicates 2dc2d12c5ae83c471a0162cf52dd133bb30f8025
S: 3768f1ce6e966caac1c134f03f011bc80cfe0513 127.0.0.1:9005
   replicates 0035f8f68c3155e11acc1b43d8c33dc604e2e676
# ... ...   

可以看到,我们的集群配置成功。M 代表主节点,集群为这些主节点自动分配了 hash slot;S 代表从节点。

我们可以进入任意一个节点:

redis-cli -c -p 9000

测试:

127.0.0.1:9000> set name kim
-> Redirected to slot [5798] located at 127.0.0.1:9001
OK
127.0.0.1:9001> get name
"kim"

设置 Key 为 name,Value 为 Kim。对该键进行计算:CRC16(key) % 16384,计算出对应的 hash slot 为 5798,所以会归属端口号为 9001 的主节点管理,此时会自动切换到该节点上。我们再次使用 get 命令,获取值成功。

总结

在今天的文章中,我总结了 Redis 集群相关的知识点。希望本篇文章能够对你有所帮助~

参考文章:

  • https://www.cnblogs.com/Eugene-Jin/p/10819601.html
  • https://www.cnblogs.com/kismetv/p/9236731.html
  • https://www.cnblogs.com/kismetv/p/9609938.html
  • https://www.cnblogs.com/kismetv/p/9853040.html

好啦,至此为止,这篇文章就到这里了,感谢您的阅读与支持~~欢迎大家关注我的公众号,在这里希望你可以收获更多的知识,我们下一期再见!

jrh

2021/11/15  阅读:90  主题:橙心

作者介绍

jrh