在第 22 课时中,我们已经了解了熔断的基本原理和断路器在服务高可用架构中的重要性。那本课时我们继续往下剖析,来详细介绍熔断主要预防的服务雪崩现象的形成和危害,以及推荐使用的断路器中间件Hystrix 的使用方法和相关原理。

分布式系统中的服务雪崩

在分布式系统中,由于业务上的划分,一次完整的请求可能需要借助好几个不同的模块协力完成,在微服务架构中就是需要多个服务实例协力完成。请求会在这些服务实例中传递,服务之间的调用会产生新的请求,它们共同组成一次服务调用链,关系如下图所示:

微服务服务调用链示意图

通过该时序图,我们可以看到:客户端(Client)发起了一次请求 Request1,网关(Gateway)在接收到请求后将它转发(标记为 Request2)给 Service-A;由于这次请求涉及 Service-B 中的数据,所以 Service-A 又向 Service-B 发起了一次 Request3 获取对应的数据;处理结束后,将结果返回给网关,由网关将结果返回给客户端。这里的 Request2 和 Request3 就共同组成了这次调用的调用链。

至于服务雪崩,我们在第 22 课时曾讲解过,服务雪崩是指当调用链的某个环节(特别是服务提供方服务)不可用时,将会导致其上游环节不可用,并最终将这种影响扩大到整个系统中,导致整个系统的不可用。如下图所示:

服务雪崩示意图

从图中我们可以看到,服务雪崩一般有 3 个阶段流程。

第一阶段是服务提供者不能用。 如上图所示,在初始阶段,一切运行良好,网关、Service-A 和 Service-B 可以响应客户端的各种请求。但在某一个时间节点,服务提供者 Service-B 由于网络故障或者请求过载不可用,而无法及时响应各类请求。

第二阶段是服务调用者不可用。在服务提供者不可用之后,客户端可能会因为错误提示或者长时间的阻塞而不断发送相同的请求到网关中,网关再次将请求转发给 Service-A 进行处理,Service-A 根据业务流程也向 Service-B 发起数据请求;同时,上一阶段中 Service-A 对 Service-B 超时或者失败的请求可能会因为 Service-A 中的重试机制再次请求 Service-B。但这些请求都无法从 Service-B 中获取到有效的返回,最坏的结果就是都被阻塞,无法及时响应。Service-A 会因为发起过多对 Service-B 的请求而逐渐累积一堆等待线程,耗尽线程池中的资源,从而无法响应其他请求,最终导致自身的不可用。

最后一阶段是整个系统的不可用。Service-A 中的等待请求同样阻塞了转发请求的网关。在多线程阻塞型的网关中,大量等待请求将会产生大量的阻塞线程,使得网关没有足够的资源处理其他请求,进而导致整个系统无法对外提供服务。

为了避免服务雪崩现象的出现,我们就需要及时“壮士断腕”,在必要的时候暂时切断对异常服务提供者的调用,以保证部分服务可用和整体系统的稳定性。这时断路器(Circuit Breaker)就该登场了。

断路器

如上图所示,我们在 Serivce-A 向 Service-B 的请求中增加了一根“保险丝”——断路器。它统计一段时间内 Service-A 对 Serivice-B 请求的响应结果,在超时或者失败次数过多的情况下,阻断 Service-A 对 Service-B 的请求,直接返回相关的异常处理结果,使得 Service-A 中的请求线程能够及时返回,避免资源耗尽而不可用,从而保护了服务调用者和避免了服务级联失败。

到这里,相信你已经迫不及待地准备在项目中使用断路器保护自己的服务不受分布式系统服务雪崩的影响。但自研断路器组件往往费时费力,秉承着不能重复“造轮子”的理念,我这里推荐你使用 Hystrix。

Hystrix 简介

Hystrix 是 Netflix 开源的一个优秀的服务间断路器。它能够在服务提供者出现故障时,隔离服务调用者和服务提供者,防止服务级联失败;同时提供失败回滚逻辑,使系统快速从异常中恢复。

Hystrix 完美地实现了断路器模式,同时还提供了信号量和线程隔离的方式保护服务调用者的线程资源,对延迟和失败提供强大的容错能力,进而保护和控制系统。

典型的 Hystrix 编程形式如下所示:

1
2
3
4
5
6
7
err := hystrix.Do("test_command", func() error {
    // 远程调用&或者其他需要保护的方法
    return nil
  }, func(err error) error{
    // 失败回滚方法
    return nil
  })

被 hystrix 包装的远程调用逻辑都会封装为一个 hystrix 命令,其内包含远程调用逻辑和失败回滚逻辑,根据 hystrix 命名唯一确认一个 hystrix 命令。

上述代码执行时会先根据 hystrix 命令的命名获取到对应的断路器,并判断断路器是否打开。如果断路器打开,说明此时服务调用已经被熔断了,将直接执行失败回滚逻辑,不执行真正的远程调用逻辑;如果断路器关闭或者处于半开状态,则执行远程调用逻辑。

接下来我们将通过一个简单的例子来了解 Hystrix 的使用方式。

Hystrix 基础使用案例

我们以常见的电商商品系统为例,商品详情页面需要展示下游评论系统的具体评论信息,但是实时最新的评论信息相对来说并不是不可或缺的,紧急情况下可以不进行显示。下图展示了相关的服务交互图:

商品系统和评论系统交互图

商品系统相对于下游的评论系统来说,级别更高,不应该受到评论系统的错误影响,为避免上面我们所说的服务雪崩问题,这二者之间的交互需要使用断路器进行熔断保护。其示例代码如下:

 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
27
28
29
30
func GetGoodsComments(id string) (common.CommentListVO, error)  {
   var result common.CommentListVO
   serviceName := "Comments"
   err := hystrix.Do(serviceName, func() error{
      requestUrl := url.URL{
         Scheme:"http",
         Host: "127.0.0.1" + ":" + "8081",
         Path:"/comments/detail",
         RawQuery:"id=" + id,
      }
      resp, err := http.Get(requestUrl.String())
      if err != nil{
         return err
      }
      body, _ := ioutil.ReadAll(resp.Body)
      jsonErr := json.Unmarshal(body, result)
      if jsonErr != nil {
        return jsonErr
      }
return nil
   }, func(e error) error {
      // 断路器打开时的处理逻辑,本示例是直接返回错误提示
      return errors.New("Http errors!")
   })
   if err == nil {
      return result, nil
   } else {
      return result, err
   }
}

我们启动 Goods 服务和 Comments 服务,接着我们访问 Goods 的 /goods/detail 接口,查询固定商品的详情,Goods 服务会发起对 Comments 服务的远程调用。访问地址如下:

1
http://localhost:12312/goods/detail/id=1

可以获取到正确的响应,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
    "detail": {
        "Id": "1",
        "Name": "Name",
        "Comments": {
            "Id": "",
            "CommentList": [
               {
                 "Id":"1",
                 "Desc":"Comments",
                 "Score":2.0,
                 "ReplyId":"0"
               }
            ]
        }
    },
    "error": ""
}

然后,我们关闭 Comments 服务模仿下游服务出现错误的场景,继续访问上述地址,得到的结果包含如下内容:

1
"error": "Get http://127.0.0.1:8081/comments/detail?id=1: dial tcp 127.0.0.1:8081: connect: connection refused"

可以看到返回结果中直接把异常输出了,同时日志中也会输出上述的 connection refused 信息,这说明 hystrix.Do 中包装代码被执行,但是找不到下游服务实例,无法发起调用,返回了错误码。

接着,我们再多次访问上述地址,发现被包装的代码会被一直执行,并没有触发断路器打开逻辑。这是为什么呢?

原来 hystrix 的默认配置是访问请求数在 20 次以上才会触发断路器计算的逻辑,我们可以在程序启动时修改断路器最低启动阈值为 4 次,来验证断路器打开后效果,在 Goods 的 main 函数中添加 hystrix 的配置信息,如下所示:

1
2
3
4
5
6
7
8
func main()  {
  .....
  // 设置断路器最低启动阈值为 4
  hystrix.ConfigureCommand("Comments", hystrix.CommandConfig{
    RequestVolumeThreshold:4,
  })
  .....
}

重新启动 Goods 服务,连续访问接口失败 4 次之后,继续访问将不会持续出现 connection refused 的错误日志,说明此时断路器已经打开,直接执行了失败回滚函数,返回异常结果。

如果 5 秒之后我们重新访问接口,将会发现请求重新执行了 hystrix.Do 中的远程调用代码,这是因为断路器打开之后的超时时间已经结束(默认为 5 秒钟),断路器进入了半开状态,允许程序重新执行远程调用,试探下游服务是否恢复可用状态,因为 Comments 服务处于一直不可用的状态,请求失败后,断路器又回到的打开状态。

Hystrix 原理

看完 Hystrix 使用的简单案例后,我们再来了解一下 Hystrix 断路器的相关原理,分析一下 Hystrix.Do 的关键实现。

Hystrix 断路器原理示意图

从上述的流程图中,可以看到:

Hystrix.Do 或 Go 函数执行时,都会调用 Hystrix.Doc 函数,然后交由对应的 HystrixCommand 处理。

HystrixCommand 会首先调用 allowRequest 函数判断当前是否处在熔断状态中,如果不是则直接放行,执行包装定义的代码逻辑;如果是的话,还要看是否到达预定熔断时长,如果熔断时长到了,也是放行,否则直接执行预先设定的错误逻辑。

HystrixCommand 执行结束前都会调用 markSuccess(duration) 或 markFailure(duration) 函数,统计一下在一定的 duration(时间间隔)内有多少调用是成功的,另有多少调用是失败的。

isOpen 函数会判断当前是否需要打开断路器,计算一下本个周期内的请求整体错误率,如果高于一个阈值,那么打开熔断,否则关闭。

Hystrix 会在内存中为每个 HystrixCommand 维护一个数组,其记录着每一个周期的请求结果的统计。该数组只能维护固定数量的周期数据,超过一定时间的周期数据会被删除掉,这样才能添加新的周期数据进入数组。

小结

在本课时,我为你讲述了 Hystrix 的使用和原理,作为开源的熔断组件,Hystrix 能在下游服务调用出现问题时,主动打开断路器,返回预先设置的默认值,防止服务雪崩现象,这为我们提供了开箱即用的服务自我保护能力。

那么除了使用 Hystrix 作为熔断组件外,你知道还有哪些组件或者中间件可以为服务提供熔断功能呢?欢迎你在评论区积极留言讨论。

-– ### 精选评论 ##### *印: > 有源码吗 ######     讲师回复: >     https://github.com/longjoy/micro-go-course/tree/master/section24 ##### Ther: > 讲的很好😀