分布式系统(九)

集群的构建一方面能够为实现负载均衡提供基础,另一方面,它也能够有效应对服务访问出错的场景,这就是集群容错。

在分布式系统运行过程中,远程调用发生失败的现象不可避免。为了应对服务访问失败,集群容错是一种简单高效的技术组件。

那么,什么是集群容错?常见的又有哪些集群容错策略呢?

问题背景

就技术体系而言,我们可以把应对远程调用失败场景的各种手段和方法统称为服务容错(Fault Tolerance),而集群容错是服务容错的其中一种实现方式。

我们知道,所谓集群,就是同时存在一个服务的多个实例。一旦我们访问其中一个实例出现问题,原则上可以访问其他实例来获取结果。

围绕这个过程,技术上有很多值得面试官考查的点,包括:

  • 如何判断集群中当前有哪些服务实例是不可用的?
  • 如果某一个服务实例不可用,选择下一个服务实例的策略有哪些?
  • 如果访问所选择的下一个服务实例仍然失败,我们应该怎么做?
  • 为了快速判断集群中某个服务是否存在可用的实例,有什么办法?

当然,和负载均衡一样,主流的分布式服务框架也都内置了集群容错机制。例如 Dubbo 框架就包含一组非常常用的集群容错实现策略。

问题分析

对于集群容错,我们首先还是有必要分析远程调用发生依赖失败的影响,或者说我们需要引入集群容错机制的原因,这里就引出一个非常适合作为面试话题来展开的概念,即“雪崩效应(Avalanche Effect)”。

雪崩效应是我们引入容错思想和模式的根本需求

我们还是需要理论联系实际。集群容错的几种代表性实现策略在 Dubbo 等主流的开源框架都有体现。

技术体系

正如前面提到的,服务依赖失败是我们在设计分布式系统时所需要重点考虑的服务可用性问题,因为服务依赖失败会造成失败扩散,从而形成服务访问的雪崩效应。让我们先从这个过程开始讲起。

雪崩效应

显然,应对雪崩效应的切入点不在于服务提供者,而在于服务消费者。

我们不能保证所有服务提供者都不会失败,但是我们要想办法确保服务消费者不受已失败的服务提供者的影响,或者说需要将服务消费者所受到的这种影响降到最低,这就是服务容错的本质需求。

而集群容错可以很好地应对这一需求。

集群容错的策略

在上一讲中,我们已经介绍了集群和客户端负载均衡,从服务容错的角度讲,负载均衡不失为是一种可行的容错策略。而我们今天要介绍的集群容错则是在负载均衡的基础上添加了各种容错策略,包括常见的:

  • Failover(失效转移)
  • Failback(失败通知)
  • Failsafe(失败安全)
  • Failfast(快速失败)
  • 以及不大常见的 Forking(分支)和 Broadcast(广播) 等
  1. Failover最常见、最实用的集群容错策略。Failover 即失效转移,当发生服务调用异常时,重新在集群中查找下一个可用的服务实例。
  2. 相较 FailoverFailback 则采用了不同的实现方式,它会记录每一次失败的请求,然后再基于一定的策略执行重试操作。显然,这种容错策略适合于那种时效性不高的操作,常见的包括发送短信等消息通知类业务。
  3. Failsafe 的意思是失败安全,该策略并不会对所发生的异常做直接的干预,而是将它们记录下来,确保后续可以根据日志记录找到引起异常的原因并解决。
  4. 还有一种比较容易混淆的策略称为 Failfast,该策略在获取服务调用异常时立即报错。
  • 显然,Failfast 已经彻底放弃了重试机制,等同于没有容错,一般用于非幂等性的写入操作。
  • 另一方面,在特定场景中可以使用该策略确保非核心业务服务只调用一次,为重要的核心服务节约宝贵时间。

除了这些常见的集群容错机制之外,在一些分布式服务框架中,还实现了一些特殊的策略,例如提供分支调用机制的 Forking 策略和提供广播机制的 Broadcast 策略。

源码解析

Dubbo 中的集群

服务容错的实现方法和策略有很多,我们接下来重点讨论 Dubbo 中主要采用的集群容错实现策略和底层原理。

Dubbo 中的整个集群结构如下图所示。

这张图比较复杂,涉及到 Dubbo 中关于集群管理和服务调用的诸多概念。

为了讨论集群容错,我们必须首先理解这种图中的相关概念,进而把握 Dubbo 对集群的抽象。

上图展现了 Dubbo 中的几个重要技术组件,我们一一来展开。

  • Invoker: 在 Dubbo 中,Invoker 是一个核心概念,代表的就是一个具体的可执行对象。
  • Directory: 即目录,代表一个集合,内部包含了一组 Invoker 对象。
  • Router: 即路由器,根据路由规则在一组 Invoker 中选出符合规则的一部分 Invoker。
  • LoadBalance: 即负载均衡,对经过 Router 过滤之后的一部分 Invoker 执行各种负载均衡算法,从而确定一个具体的 Invoker。
  • Cluster: 即集群,从 Directory 中获取一组 Invoker,并对外伪装成一个 Invoker。这样,我们在使用 Cluster 时就像是在使用一个 Invoker 一样,而在这背后则隐藏了容错机制。

基于上述分析,今天内容所要介绍的重点是 Cluster 。我们首先来看看 Dubbo 中 Cluster 接口的定义,该接口只包含一个 join 方法,如下所示:

1
2
3
4
5
@SPI(FailoverCluster.NAME)
public interface Cluster {
@Adaptive
<T> Invoker<T> join(Directory<T> directory) throws RpcException;
}

Cluster 接口中包含另一个与集群相关的重要概念,即前面提到的 DirectoryDirectory 本质上代表多个 Invoker,我们需要知道可以通过它获取一个有效 Invoker 的列表。

换一个角度,Dubbo 中的 Cluster 也相当于是一种代理对象,它在 Directory 的基础上向开发人员暴露一个具体的 Invoker,而在暴露这个 Invoker 的过程中,万一发生了异常情况,Cluster 就会自动嵌入集群容错机制。

在 Dubbo 中,实际上提供了一组不同类型的 Cluster 对象,而每一个 Cluster 对象就代表着一种具体的集群容错机制。

上述方案中,Dubbo 默认使用的是 FailoverCluster。我们来看一下这个默认实现,如下所示:

1
2
3
4
5
6
7
public class FailoverCluster implements Cluster {
public final static String NAME = "failover";

public <T> Invoker<T> join(Directory<T> directory) throws RpcException {
return new FailoverClusterInvoker<T>(directory);
}
}

可以看到该类非常简单,join 方法只是根据传入的 Directory 构建一个新的 FailoverClusterInvoker 实例。

而查看其他的 Cluster 接口实现,可以发现它们的处理方式与 FailoverCluster 类似,都是返回一个新的 Invoker。

Dubbo 中的集群容错机制

显然,想要理解 Dubbo 中的集群容错机制,重点是要分析上图中所示的各种 ClusterInvoker 对象。

这里,我们同样选择默认的 FailoverClusterInvoker 作为分析入口。在深入 FailoverClusterInvoker 之前,我们发现该类存在一个基类,即 AbstractClusterInvoker,而 AbstractClusterInvoker 又实现了 Invoker 接口,它们之间的关系如下图所示:

从设计模式角度讲,AbstractClusterInvoker 采用的是很典型的模板方法设计模式。

模板方法设计模式的一般实现过程就是为整个操作流程提供一种框架代码,然后再提取抽象方法供子类进行实现。上图中就展示了模板方法的设计思想。

AbstractClusterInvoker 的实现逻辑也是类似,它的

主要步骤包括

  1. Directory 获得 Invoker 列表、
  2. 基于 LoadBalance 实现负载均衡,
  3. 并基于 doInvoke 方法完成在远程调用中嵌入容错机制。

这里的 doInvoke 就是模板方法,需要 FailoverClusterInvoker 等子类分别实现,如下所示:

1
2
3
public abstract class AbstractClusterInvoker<T> implements ClusterInvoker<T> {
protected abstract Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException;
}

AbstractClusterInvoker 类的代码有点长,但理解起来并不是很复杂。

通过观察该类中的代码实现,可以看到存在一批以 select 结尾的方法,包括 selectdoselectreselect 以及 LoadBalance 本身的 select 方法。

我们基于这些 select 方法梳理整体的处理流程,并给出如下所示的伪代码:

1
2
3
4
5
6
7
8
9
select() {
checkSticky();//粘滞连接
doselect() {
loadbalance.select();
reselect() {
loadbalance.select();
}
}
}

上述伪代码清晰展示了这些 select 方法的嵌套过程,从而能够更好地帮助你梳理代码执行流程。

首先,select 方法的第一部分内容提供了“粘滞连接”机制。所谓粘滞连接(Sticky Connection),就是为每一次请求维护一个状态确保始终由同一个服务提供者对来自同一客户端的请求进行响应

这点和我们在上一讲中提到的源 IP 哈希负载均衡算法比较类似,你可以做一些回顾。

在 Dubbo 中,使用粘滞连接的目的是减少重复创建连接的成本,提高远程调用的效率

我们可以通过 URL 传入的“sticky”参数对该行为进行控制。

处理完粘滞连接之后,select 方法就借助于 doselect 方法执行下一步操作。doselect 方法执行了一系列的判断来最终明确目标 Invoker 对象。

首先,我们需要判断当前是否存在可用的 Invoker 对象,如果没有则直接返回。如果有,那么就分如下几种情况:

  • 如果只有一个 Invoker 对象,那么该 Invoker 对象就是目标 Invoker 对象;
  • 如果有两个 Invoker 对象,则使用轮询机制选择其中一个进行返回;
  • 如果有两个以上的 Invoker 对象,这时候就会借助于 LoadBalance 的 select 方法,通过负载均衡算法来最终确定一个目标 Invoker 对象。

在获取了目标 Invoker 对象之后,Dubbo 并不会直接就使用这个对象,因为我们需要考虑该对象的可用性。

如果该 Invoker 对象不可用或者已经使用过,那么就需要通过 reselect 方法重新进行选择。

而如果在 Invoker 列表中已经没有可用的 Invoker 对象了,那么也就只能直接使用当前选中的这个 Invoker 对象。

至于 reselect 方法,它的主要实现过程同样也是借助于 LoadBalance 的 select 方法完成对 Invoker 的重新选择。Dubbo 会使用一个标志位对传递给 LoadBalance 的 Invoker 对象的可用性进行过滤,然后将过滤之后且未被选择的 Invoker 对象列表交给 LoadBalance 执行负载均衡。

以上几个方法中,只有 select 方法的修饰符是 protected 的,可以被 AbstractClusterInvoker 的各个子类根据需要进行直接调用。显然,因为 AbstractClusterInvoker 提供了模板方法,因此它的子类势必是在 doInvoke 方法中调用这些 select 方法。

我们来看一下 FailoverClusterInvoker 的 doInvoke 方法,这个方法的执行逻辑同样不是很复杂。Failover 的意思很简单,就是失败重试,所以可以想象 doInvoke 方法中应该包括一个重试的循环操作。通过翻阅代码,我们确实发现了这样一个 for 循环,裁剪后的代码结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
for (int i = 0; i < len; i++) {
// 由于Invoker对象列表可能已经发生变化,所以在执行重试操作前需要进行重新选择
if (i > 0) {
// 验证当前Invoker对象是否可用
checkWhetherDestroyed();
// 重新获取所有服务提供者
copyinvokers = list(invocation);
// 重新检查这些Invoker对象
checkInvokers(copyinvokers, invocation);
}

// 通过父类的select方法获取invoker
Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);

try {
// 发起远程调用
Result result = invoker.invoke(invocation);
return result;
} catch (RpcException e) {
// 如果是业务异常,直接抛出
}

}

// 如果for循环执行完毕还是没有找到一个合适的invoker,则直接抛出异常
throw new RpcException();

上述代码中的循环次数来自于 URL 传入的重试次数,默认重试次数是 2。在重试之前,由于 Invoker 对象列表可能已经发生变化,所以需要对当前 Invoker 对象是否可用进行验证,并根据需要进行重新选择。注意到在每一次循环中,我们首先调用父类 AbstractClusterInvoker 中的 select 方法,并将该方法返回的 Invoker 对象保存到一个 invoked 集合中,表示该 Invoker 对象已经被选择和使用。

一旦确定了目标 Invoker 对象,我们就可以通过该对象所提供的 invoke 方法执行远程调用。调用过程可能成功、也可能失败,而失败的结果也分两种情况,如果是业务失败则直接抛出异常,反之我们就继续执行循环。如果整个循环都结束了还是没有成功地完成调用过程,那么最终也会抛出异常。

至此,基于 FailoverClusterInvoker 的集群容错机制讲解完毕。Dubbo 中的其他集群容错实现方案交由你自行进行理解和分析。

解题要点

第一个阶段是先解释什么是服务容错。
1. 我们需要明确由于存在服务自身失败以及网络瞬态等因素,为了确保服务访问过程的可靠性,服务容错是必不可少的。
2. 然后重点是要提到服务的雪崩效应,即在分布式环境下,由于服务依赖失败导致整个服务访问链路不可用。
3. 那么雪崩效应究竟是怎么形成的呢?就是因为服务没有做到容错而导致的。

第二阶段,我们需要进一步掌握服务容错实现层面的知识点。
1. 对于开发人员而言,相对于原理部分的内容,具体的实现过程反而更加容易把握,多少都能回答一些。
2. 针对集群容错的实现,包括 Failover(失效转移)、Failback(失败通知)、Failsafe(失败安全)和 Failfast(快速失败)。

小结

本讲内容对集群容错的设计思想和实现策略进行了详细的展开。

集群容错的实现策略有很多,我们基于 Dubbo 给出了该框架中内置的几种实现方案的底层原理。