在使用负载均衡策略时,我们需要提到集群这一概念。集群的构建一方面能够为实现负载均衡提供基础,另一方面,它也能够有效应对服务访问出错的场景,这就是集群容错。在分布式系统运行过程中,远程调用发生失败的现象不可避免。为了应对服务访问失败,集群容错是一种简单高效的技术组件。那么,什么是集群容错?常见的又有哪些集群容错策略呢?
集群容错是服务容错的其中一种实现方式。我们知道,所谓集群,就是同时存在一个服务的多个实例。一旦我们访问其中一个实例出现问题,原则上可以访问其他实例来获取结果。当然,和负载均衡一样,主流的分布式服务框架也都内置了集群容错机制。例如Dubbo框架就包含一组非常常用的集群容错实现策略。
集群容错技术
服务依赖失败是我们在设计分布式系统时所需要重点考虑的服务可用性问题,因为服务依赖失败会造成失败扩散,从而形成服务访问的雪崩效应。让我们先从这个过程开始讲起。
雪崩效应
下图展示了雪崩效应的示意图,可以看到A、B、C、D、E这5个服务存在依赖关系,服务A为服务提供者,服务B为服务A的消费者,服务C、D和E则是服务B的消费者。现在,假如服务A变成不可用,就会引起服务B的不可用,并将这种不可用性逐渐扩散到服务C、D和E时, 从而造成了整个服务体系发生雪崩。
服务雪崩的产生是一种扩散效应,我们可以对上图中的现象进行剥离,先从服务A和服务B这两个服务之间的交互进行切入。下图展示了雪崩效应产生的三个阶段,即首先服务提供者A发生不可用,然后服务消费者B不断进行重试加大了访问流量,最后导致服务B自身也不可用。
在上图中,通过用户不断提交服务请求或代码逻辑自动重试等手段,服务B会进一步加大对服务A的访问流量。因为服务B使用同步调用, 会产生大量的等待线程占用系统资源。一旦线程资源被耗尽,服务B本身也将处于不可用状态。这一过程在整个服务访问链路上进行扩散,就形成了雪崩效应。
显然,应对雪崩效应的切入点不在于服务提供者,而在于服务消费者。我们不能保证所有服务提供者都不会失败,但是我们要想办法确保服务消费者不受已失败的服务提供者的影响,或者说需要将服务消费者所受到的这种影响降到最低,这就是服务容错的本质需求。而集群容错可以很好的应对这一需求。
集群容错的策略
在上一讲中,我们已经介绍了集群和客户端负载均衡,从服务容错的角度讲,负载均衡不失为是一种可行的容错策略。而我们今天要介绍的集群容错则是在负载均衡的基础上添加了各种容错策略,包括常见的Failover(失效转移)、Failback(失败通知)、Failsafe(失败安全)和Failfast(快速失败)以及不大常见的Forking(分支)和Broadcast(广播)等。我们一一来看一下。
Failover是最常见、最实用的集群容错策略。Failover即失效转移,当发生服务调用异常时,重新在集群中查找下一个可用的服务实例。为了防止无限重试,通常对失败重试最大次数进行限制,如下图所示。
相较Failover,Failback的采用了不同的实现方式,它会记录每一次失败的请求,然后再基于一定的策略执行重试操作。显然,这种容错策略适合于那种时效性不高的操作,常见的包括发送短信等消息通知类业务。
Failsafe的意思是失败安全,该策略并不会对所发生的异常做直接的干预,而是将它们记录下来,确保后续可以根据日志记录找到引起异常的原因并解决。
还有一种比较容易混淆的策略称为Failfast,该策略在获取服务调用异常时立即报错。显然,Failfast已经彻底放弃了重试机制,等同于没有容错,一般用于非幂等性的写入操作。另一方面,在特定场景中可以使用该策略确保非核心业务服务只调用一次,为重要的核心服务节约宝贵时间。
以上三种集群容错策略之间的区别可以参考下图。
除了这些常见的集群容错机制之外,在一些分布式服务框架中,还实现了一些特殊的策略,例如提供分支调用机制的Forking策略和提供广播机制的Broadcast策略。这些策略的使用场景比较少,这里不做具体展开。
Dubbo集群容错原理解析
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方法,如下所示。
@SPI(FailoverCluster.NAME)
public interface Cluster {
@Adaptive
<T> Invoker<T> join(Directory<T> directory) throws RpcException;
}
Cluster接口中包含另一个与集群相关的重要概念,即前面提到的Directory。Directory本质上代表多个Invoker,我们需要知道可以通过它获取一个有效Invoker的列表。
换一个角度,Dubbo中的Cluster也相当于是一种代理对象,它在Directory的基础上向开发人员暴露一个具体的Invoker,而在暴露这个Invoker的过程中,万一发生了异常情况,Cluster就会自动嵌入集群容错机制。那么,Cluster是如何做到这一点的呢?在Dubbo中,实际上提供了一组不同类型的Cluster对象,而每一个Cluster对象就代表着一种具体的集群容错机制,如下图所示。
上述方案中,Dubbo默认使用的是FailoverCluster。我们来看一下这个默认实现,如下所示。
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的实现逻辑也是类似,它的主要步骤包括从Directory获得Invoker列表、基于LoadBalance实现负载均衡,并基于doInvoke方法完成在远程调用中嵌入容错机制。
这里的doInvoke就是模板方法,需要FailoverClusterInvoker等子类分别实现,如下所示。
public abstract class AbstractClusterInvoker<T> implements ClusterInvoker<T> {
protected abstract Result doInvoke(Invocation invocation, List<Invoker<T>> invokers, LoadBalance loadbalance) throws RpcException;
}
AbstractClusterInvoker类的代码有点长,但理解起来并不是很复杂。通过观察该类中的代码实现,可以看到存在一批以select结尾的方法,包括select、doselect、reselect以及LoadBalance本身的select方法。我们基于这些select方法梳理整体的处理流程,并给出如下所示的伪代码。
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对象。下图进一步展示了Invoker对象的可用性判断逻辑。
至于reselect方法,它的主要实现过程同样也是借助于LoadBalance的select方法完成对Invoker的重新选择。Dubbo会使用一个标志位对传递给LoadBalance的Invoker对象的可用性进行过滤,然后将过滤之后且未被选择的Invoker对象列表交给LoadBalance执行负载均衡。
以上几个方法中,只有select方法的修饰符是protected的,可以被AbstractClusterInvoker的各个子类根据需要进行直接调用。显然,因为AbstractClusterInvoker提供了模板方法,因此它的子类势必是在doInvoke方法中调用这些select方法。
我们来看一下FailoverClusterInvoker的doInvoke方法,这个方法的执行逻辑同样不是很复杂。Failover的意思很简单,就是失败重试,所以可以想象doInvoke方法中应该包括一个重试的循环操作。通过翻阅代码,我们确实发现了这样一个for循环,裁剪后的代码结构如下所示。
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中的其他集群容错实现方案交由读者自行进行理解和分析。