vlambda博客
学习文章列表

服务治理那些事-容错之断路器

微服务架构将某个单一的复杂系统拆分成多个可独立自治的服务,服务之间通过松耦合的方式进行交互,这为我们带来了许多好处,包括低耦合、可重用性、业务敏捷性和分布式部署能力、强扩展性。但于此同时,也给我们引进了新的挑战。

在微服务场景下,每个模块之间的互通都通过远程调用的方式来代替传统单体架构中的内存调用,在获得扩展性、重用性的同时,也使得整个体系结构因为网络的不稳定性、资源的过量使用变得更加脆弱。当一项或多项依赖服务持续不可用或出现高延迟时,将会导致整个服务级联失败。而且,服务客户端中的重试逻辑会使呈现高延迟的服务状况恶化,最终使整个系统陷入瘫痪。就像多米诺骨牌效应一样推倒整个链路上的系统。

为了提高服务的可用性,避免级联影响,最经典的解决办法之一就是在服务之间的RPC边界引入断路器来构建容错和弹性的系统,当关键服务不可用或具有高延迟时,该系统仍然可以正常提供服务,并在服务之间切断级联故障的传播路径。防止影响本系统服务和上游调用方。



断路器介绍

什么是断路器,引用百度百科中对断路器的介绍:

断路器是指能够关合、承载和开断正常回路条件下的电流并能在规定的时间内关合、承载和开断异常回路条件下的电流的开关装置。当它们发生严重的过载或者短路及欠压等故障时能自动切断电路,防止事故扩大,保证安全运行。

在软件设计中,断路器是一种常用的微服务容错设计模式,其实现功能类似在电路中的应用一样,主要在系统过载或长时间得到异常反馈时进行及时切断和故障源的联系,保障当前系统的可靠可用。具体到断路器的实现,其需要考虑到的点非常多,例如如何跟踪和断定服务是否过载、切断后又如何进行平滑地开启等问题。

微服务系统中的断路器设计模式定义,断路器通常和某一个具体操作绑定,例如一个RPC调用、一个耗时的资源获取等操作。除此之外断路器通常与某种跟踪统计器相关联,跟踪其上绑定的操作每一次调用的初始和结束状态,并记录操作的故障信息等,之后根据策略判断是否转换断路器的状态。断路器有三种状态决定当前的行为,转换关系如下图所示:

服务治理那些事-容错之断路器

  1. Closed状态:断路器此时处于关闭状态,此时该断路器包装的操作可以正常执行。并且断路器会维护最近失败次数的计数,如果对操作的调用失败,则会增加该计数。如果统计失败数在给定的时间段内超过了指定的阈值,则断路器会立即转换到Open状态。同时,断路器将启动一个计时器,并且当该计时器到期时,该代理将进入Half-Open状态。

  2. Open状态:此时断路器处于打开状态。在计时器到期之前,所有对该断路器绑定操作的调用请求都将被直接拒绝,并且返回异常(或者其他降级结果)。当计时器到期时,将会由此状态转化为Half-Open状态。

  3. Half-Open状态:此时断路器处于半开状态。断路器会允许指定数量的对该操作的调用请求进行正常执行。

  4. 如果放行的请求都成功了,则任务该操作的故障已经恢复了,重置失败调用的计数器,并进入Closed状态。

  5. 如果其中一个请求失败了,则断路器认为故障仍然存在,因此它将恢复为Open状态,并重新启动超时计时器,以使系统有更多时间从故障中恢复。



断路器的具体实现

断路器目前有很多经典实现,Java圈内使用广泛的有Netflix开源的Hystrix、阿里开源的Sentinel以及后起之秀resilience4j。除此之外,笔者在这里推荐一款Golang超级精简的实现gobreaker,对Go熟悉的朋友也可以通过该实现深入的了解断路器的原理。

本文选择目前应用最广、呼声最高的Hystrix来详细剖析下断路器的核心实现,尽管Hystrix早已经宣布进入维护状态,不再更新。但是目前来看Hystrix功能已经很完善,其仍然是生产使用和学习的第一选择。

OK,我们进入正题,Hystrix中为我们抽象了com.netflix.hystrix.HystrixCircuitBreaker接口,

Circuit-breaker logic that is hooked into {@link HystrixCommand} execution and will stop allowing executions if failures have gone past the defined threshold.

It will then allow single retries after a defined sleepWindow until the execution succeeds at which point it will again close the circuit and allow executions again.

Hystrix将某个操作和其提供的熔断降级等逻辑包装在一起称为命令,这其中也包括了断路器的逻辑,之后对该操作的调用都会受到Hystrix的容错保护。

HystrixCircuitBreaker接口定义断路器的几个重要方法,如下所示:

public interface HystrixCircuitBreaker {    // 判断当前熔断器是否允许请求调用,幂等的读操作    boolean allowRequest();       // 判断当前熔断器是否在Open状态    boolean isOpen();            // 熔断器处在half-close状态时,请求成功会调用该方法    void markSuccess();       // 熔断器处在half-close状态时,请求失败会调用       void markNonSuccess();       // 在请求调用时,判断当前熔断器是否允许调用,非幂等    boolean attemptExecution();  
}

其中唯一可能引起困惑的是allowRequestattemptExecution方法,这两者方法看起来作用类似,但是这里有个细节问题,allowRequest是特意暴露出来给用户调用检查断路器此刻是否允许正常请求调用的,其是一个幂等的读操作,而attemptExecution是个非幂等的写操作,具体这么设计的原因感兴趣的读者可以看https://github.com/Netflix/Hystrix/issues/1541,这是题外话了。接着上面的内容,该接口在Hystrix中提供两个实现,如下:

服务治理那些事-容错之断路器

NoOpCircuitBreaker类主要是在使用Hystrix时但是对没激活断路器功能时提供的一个空实现。我们主要来看另一个实现类HystrixCircuitBreakerImpl

大部分方法的功能上面都讲过,这里挑几个重点讲解:

status记录当前熔断器所处的状态,通过CAS更新。

circuitOpened记录最近一次记录进入Open状态的时间戳,-1表示当前处在Close状态。

isAfterSleepWIndow方法判断会根据上一次熔断Open的时间加上配置的熔断窗口,来判断是否可以进入Half-Open状态,允许部分请求通过去探测是否故障恢复。在Hystrix中,Half-Open状态只允许一个请求通过。

subscribeToStream方法会订阅当前熔断器的统计器,当最近的统计窗口错误数达到一定比例时,则会设置当前熔断器为Open状态。

断路器的逻辑无外乎三种状态之间的切换,我们现在将这些方法串起来。Hystrix由于大量使用了RxJava的类库,因此这里只截取断路器的核心实现,对RxJava不太了解的同学可以绕过一些代码细节,只了解其具体实现思路即可。

核心方法在com.netflix.hystrix.AbstractCommand#applyHystrixSemantics中,

 private Observable<R> applyHystrixSemantics(final AbstractCommand<R> _cmd) {
        executionHook.onStart(_cmd);

        if (circuitBreaker.attemptExecution()) {     // ①
            final TryableSemaphore executionSemaphore = getExecutionSemaphore();
            final AtomicBoolean semaphoreHasBeenReleased = new AtomicBoolean(false);
            final Action0 singleSemaphoreRelease = new Action0() {
                @Override
                public void call() {
                    if (semaphoreHasBeenReleased.compareAndSet(falsetrue)) {
                        // 释放信号量
                        executionSemaphore.release();
                    }
                }
            };

            final Action1<Throwable> markExceptionThrown = new Action1<Throwable>() {
                @Override
                public void call(Throwable t) {
                   // 标记该操作异常
                    eventNotifier.markEvent(HystrixEventType.EXCEPTION_THROWN, commandKey);
                }
            };

            if (executionSemaphore.tryAcquire()) {    // ②
                try {
                    executionResult = executionResult.setInvocationStartTime(System.currentTimeMillis());
                    return executeCommandAndObserve(_cmd)     
                            .doOnError(markExceptionThrown)  // ③
                            .doOnTerminate(singleSemaphoreRelease)  // ④
                            .doOnUnsubscribe(singleSemaphoreRelease);  // ⑤
                } catch (RuntimeException e) {
                    return Observable.error(e);
                }
            } else {
                return handleSemaphoreRejectionViaFallback();  // ⑥
            }
        } else {
            return handleShortCircuitViaFallback();  // ⑦
        }  
}

注释①处通过调用attemptExecution判断当前熔断器是否允许请求执行操作,该方法其包括了断路器的仲裁请求允许通过与否的主要逻辑:

    @Override
    public boolean attemptExecution() {
        // 当前是否强制断路器Open状态,则不允许请求
        if (properties.circuitBreakerForceOpen().get()) {
            return false;
        }
        // 当前是否强制断路器Close状态,则允许请求
        if (properties.circuitBreakerForceClosed().get()) {
            return true;
        }
        // `circuitOpened`为-1表示处在Close状态,则允许请求
        if (circuitOpened.get() == -1) {
            return true;
        } else {
            // isAfterSleepWindow判断是否熔断窗口到期
            if (isAfterSleepWindow()) {
                // 熔断窗口到期,则Open状态原子转移到Half-Open状态。
                // CAS保证该状态下只有一个请求通过
                if (status.compareAndSet(Status.OPEN, Status.HALF_OPEN)) {
                    return true;
                } else {
                    return false;
                }
            } else {
                //还在熔断窗口期内,不允许请求
                return false;
            }
        }
    }

如果attemptExecution返回false,则说明现在处在熔断状态,所有请求都需要快速失败。跳到注释⑦处,调用handleShortCircuitViaFallbackhandleShortCircuitViaFallback方法主要处理熔断之后的操作,即当前操作是否指定了对应的fallback方法,如果有则执行fallback,并返回fallback的结果,否则抛出异常。

否则允许请求,继续往下执行。

继续看注释②,我们通常在加锁操作中看到tryAcquire这样的方法名。这里只会对使用了信号量隔离方式的情况进行判断当前操作是否达到了允许的并发上线。

  1. 如果达到了,则直接跳转到注释⑥处。handleSemaphoreRejectionViaFallbackhandleShortCircuitViaFallback方法类似,即当前操作是否指定了对应的fallback方法,如果有则执行fallback,并返回fallback的结果。

  2. 否则抛出信号量超出的异常。

这一部分其实涉及到了另一个分布式系统中常用的设计模式-船舱隔离设计。篇幅原因,打算放到下一章节讲解。

注释③、④、⑤处:RxJava的语法,单从方法名上很好理解。

  1. executeCommandAndObserve(_cmd)执行我们保证的操作。该方法中还会对操作的最终结果进行统计记录,例如在熔断器当前是Half-Open状态时调用成功则通过熔断器方法的markSuccess方法变更状态为CLOSE状态,并且重置当前统计的数据。例如在Half-Open状态时调用失败,则通过熔断器方法的markNonSuccess方法变更状态为Open状态。并且设置当前时间为熔断时间,方便之后的请求进来判断熔断器是否处在Half-Open状态窗口内。

  2. 并且操作调用异常doOnError时执行  markExceptionThrown标记本次操作异常。

  3. 处理操作结束doOnTerminatesh时执或者在取消订阅doOnUnsubscribe的情况下执行singleSemaphoreRelease释放我们前面获取的信号量。



使用断路器的注意事项

尽管断路器在微服务架构下对于瞬时故障(网络超时、资源不可用、资源过度使用等)时,保证故障不发生级联拖垮整个系统的场景下很有效。但是在实际生产中也应该遵从具体的场景,正确地使用断路器,下面是目前业界对于断路器使用的一些共识:

  1. 断路器Open状态时的处理时,会对所有对该操作的请求进行拒绝。我们需要根据业务进行调整使其调用对应的替代操作以尝试执行相同的任务或获取相同的数据,或者将异常报告给上游调用方,像Feign等框架都支持和典型的断路器实现例如Hystrix进行注入对应的fallback以在调用错误、超时以及在断路器Open状态下直接调用fallback处理。

  2. 处理对应用程序中本地私有资源的访问,例如内存中的数据结构。在这种环境下,使用断路器会额外增加系统开销。

  3. 断路器通常情况下不应该替代处理应用程序业务逻辑中的异常,这是毫无意义的。

推荐阅读

《断路器模式》https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker

《船舱设计模式》https://docs.microsoft.com/en-us/azure/architecture/patterns/bulkhead

《断路器-Martin·Fowler》https://martinfowler.com/bliki/CircuitBreaker.html

《微服务中的断路器》https://techblog.constantcontact.com/software-development/circuit-breakers-and-microservices/

《通过简单的实际示例了解CircuitBreaker设计模式》https://itnext.io/understand-circuitbreaker-design-pattern-with-simple-practical-example-92a752615b42





系列文章精彩回顾