如何预防一个服务故障崩掉整个系统?【熔断】
上一篇我们聊了微服务的全链路日志问题,这里我们就来聊聊所有微服务会遇到的第二个问题——熔断。
可能你想说,熔断不是流量大时才会出现吗?我们公司流量又不大,应该不用考虑熔断的问题吧。其实不是的,这里你存在一定误区,希望通过下面内容的内容能助你走出误区。
一、业务场景
为了便于理解下面的内容,我们先从一个业务背景入手。
一个新零售架构系统中,有一个通用用户服务(很多页面都会使用),它包含两个接口。
第一个接口是用户状态接口,包含用户车辆所在位置,并且在用户信息展示页面都会使用到,比如客服系统中的用户信息页面。
第二个接口是需要我们返回用户一个可操作的权限列表,它包含一个通用权限,也包含用户定制权限,而且每次用户打开 App 时都会使用它。
而这两个接口分别会碰到相应问题,我们分开讨论下。
(一)、第一个接口会遇到的问题:请求慢
用户状态的接口、服务间的调用关系如下图所示。
在 Basic Data Service 中,有个接口 /currentCarLocation 需要调用第三方系统的数据,但第三方响应速度很慢且有时还会抽风,导致响应时间更长,接口经常出现超时报错。
有一次,用户反馈 App 整体运行速度慢到无法接受的程度。通过后台监控,我们查看了几个 Thread Dump ,发现 User API 与 Basic Data Service 的线程请求数爆满,且所有的线程都在访问第三方接口。因为连接数满了,其他页面便不再受理 User API 的请求,最终导致 App 整体出现了卡顿。
之前我们针对这个问题做过相关处理,考虑响应时间长,我们就把超时的时间设置很长,虽然超时报错概率小了,其他页面也保持正常,但是会导致客服后台查看用户信息的页面响应时间长。
(二)、第二个接口会遇到的问题:流量洪峰缓存超时
用户权限的接口、服务间的调用关系与上面类似,如下图所示。
关于服务间的关系调用具体流程分为以下三个步骤:
App 访问 User API;
User API 访问基础数据服务的接口 /commonAccesses;
基础数据服务提供一个通用权限列表。因为权限列表对所有用户都一样,所以我们把它放在了 Redis 中,如果通用权限在 Redis 中找不到,我们再去数据库中查找。
接下来聊聊服务间的关系调用流程中,我们曾经遇到过的一些问题。
有一次,因为历史代码的原因,在流量高峰时 Redis 中的通用权限列表超时了,那一瞬间所有的线程都需要去数据库中读取数据,导致 DB 中的 CPU 立马飙到了 100%。
DB 挂后,紧接着 Basic Data Service 也挂了,因所有的线程堵塞了,我们获取不到数据库连接,导致 Basic Data Service 无法接受新的请求。
而 User API 因调用了 Basic Data Service 的线程出现了堵塞,以至于 User API 服务的所有线程也出现堵塞,即 User API 也挂了,导致 App 上的所有操作都不能使用,事情就闹大了。
二、覆盖场景
为了解决以上两个问题,我们需要引入一个技术,且它还得满足以下两个条件。
(一)、线程隔离
针对第一个问题,我们希望的处理方式是这样,比如 User API 中每个服务配置的最大连接数是 1000,每次 API 调用 BasicDataService 的 /currentCarLocation 的速度就会很慢。
因此,我们希望控制 /currentCarLocation 的调用请求数,保证不超过 50 条,以此保证至少还有 950 条的连接可用来处理常规请求。如果 /currentCarLocation 的调用请求数超过 50 条,我们就设计一些备用逻辑进行处理,比如在界面上给用户进行提示。
(二)、熔断
针对第二个问题,因那时 DB 没有死锁,流量洪峰缓存超时单纯是因为压力太大,此时我们可以使用 Basic Data Service 暂缓一点儿时间,让它不接受新的请求,这样 Redis 的数据会被补上,数据库的连接也会降下来,我们的服务也就没事了。
因此,我们希望这个技术能实现以下两点需求:
发现近期某个接口的请求老出异常、有猫腻,先别访问接口的服务;
发现某个接口的请求老超时,先判断接口的服务是否不堪重负,如果不堪重负,先别访问它。
了解了这个技术需要满足的条件后,我们就可以有针对性地进行选型了。
三、Hystrix 的设计思路
这次的技术选型过程很简单,我们使用的是 Spring Cloud 中的 Hystrix 组件,市面上使用 Spring Cloud 都是因为需要使用它的组件。
关于 Hystrix,我还想多提一嘴。Spring Cloud Hystrix 的设计思想是事前配置熔断机制,也就是说,要事先预见流量是什么情况?系统负载能力如何?然后预先配置好熔断的机制。但这种操作的缺点是,一旦实际流量或系统状况与预测的不一样,那么预先配置好的机制就达不到预期的效果。
因此,开源 Hystrix 的公司 Netflix 想使用一个动态适应更灵活的熔断机制。不过 2018 年后官方已不再开发新功能,转向开发 Resilience4j 了,对于原有功能只做简单维护。
接下来我们讨论下 Hystrix 为什么能满足我们的需求。
(一)、线程隔离机制
在 Hystrix 机制中,当前服务与其他接口存在强依赖关系,且每个依赖都有一个隔离的线程池。
比如下面这张架构图,当前服务调用接口 A 时,并发线程的最大个数是 10,调用接口 M 时,并发线程的最大个数是 5。
一般来说,当前服务依赖的一个接口响应慢时,当前运行的线程会一直处于未释放状态,最终把所有的连接线程卷入慢接口中。为此,在隔离线程的过程中,Hystrix 的做法是每个依赖接口(也可以配置成几个接口共用)维护一个线程池,然后通过线程池的大小、排队数等隔离每个服务对依赖接口的调用,这样就不会出现前面的问题。
当然,在 Hystrix 机制中,我们除了使用线程池来隔离线程,还可以使用信号量(计数器)。
比如还是调用接口 A,因并发线程的最大个数是 10,在信号量隔离的机制中,Hystix 并不使用 1 个 size 为 10 的线程池来隔离,而是使用一个信号 semaphoresA,每当调用接口 A 时 semaphoresA++,A 调用完后 semaphoresA--,semaphoresA 一旦超过 10,不再调用。
这里留一个小问题:semaphoresA 如果超过 10,业务代码会如何?
因为我们在使用线程池时经常需要切换线程,资源损耗较大,而信号量的优点恰巧就是切换快,大大解决了我们的烦恼。不过它也有一个缺点,即接口一旦开始调用就无法中断。因为调用依赖的线程是当前请求的主线程,不像线程隔离,调用依赖的是另外 1 个线程,当前请求的主线程可以根据超时时间把它中断。
这也就是说我们的第一个问题有救了,那第二个问题如何解决呢?这就涉及接下来我们要说的熔断机制。
(二)、熔断机制
关于 Hystrix 熔断机制的设计思路,我们将从以下几个方面来说说。
1、在哪种条件下会触发熔断?
熔断判断规则是某段时间内调用失败数超过特定的数量或比率时,就会触发熔断。那这个数据是如何统计出来的呢?
在 Hystrix 机制中,我们会配置一个不断滚动的统计时间窗口 metrics.rollingStats.timeInMilliseconds,在每个统计时间窗口中,当调用接口的总数量达到 circuitBreakerRequestVolumeThreshold,且接口调用超时或异常的调用次数与总调用次数的占比超过 circuitBreakerErrorThresholdPercentage,此时就会触发熔断。
2、熔断了会怎么样?
如果熔断被触发了,在 circuitBreakerSleepWindowInMilliseconds 的时间内,我们便不再对外调用接口,而是直接调用本地的一个降级方法,如下代码所示:
3、熔断后怎么恢复?
circuitBreakerSleepWindowInMilliseconds 到时间后,Hystrix 首先会放开对接口的限制(断路器状态 HALF-OPEN),然后尝试使用 1 个请求去调用接口,如果调用成功,则恢复正常(断路器状态 CLOSED),如果调用失败或出现超时等待,就需要再重新等待 circuitBreakerSleepWindowInMilliseconds 的时间,之后再重试。
学到这,你可能就想问了,这个不断滚动的时间窗口,到底是什么意思?
(三)、滚动(滑动)时间窗口
比如我们把滑动事件的时间窗口设置为 10 秒,并不是说我们需要在 1 分 10 秒时统计一次,1 分 20 秒时再统计一次,而是我们需要统计每一个 10 秒的时间窗口。
因此,我们还需要设置一个 metrics.rollingStats.numBuckets,假设我们设置 metrics.rollingStats.numBuckets 为 10,表示时间窗口划分为 10 小份,每 1 份是 1 秒。然后我们就会 1 分 0 秒 - 1 分 10 秒统计 1 次、1 分 1 秒 - 1 分 11 秒统计 1 次、1 分 2 秒 - 1 分 12 秒统计 1 次……(即每隔 1 秒都有 1 个时间窗口。)
下图就是 1 个 10 秒时间窗口,我们把它分成了 10 个桶。
每个桶中 Hystrix 首先会统计调用请求的成功数、失败数、超时数和拒绝数,再单独统计每 10 个桶的数据(到了第 11 个桶时就是统计第 2 个桶到第 11 个桶的合计数据)。
说到这,你可能会觉得知识有点割裂,接下来我把 Hystrix 调用接口的请求处理流程说一下。
(四)、Hystrix 调用接口的请求处理流程
这是 1 次调用成功的流程,如下图所示:
这是 1 次调用失败的流程,如下图所示:
Hystrix 调用接口的请求处理流程结束后,我们就可以直接启用它了。在 Spring Cloud 中启用 Hystrix 的操作也比较简单,我们不过多赘述了。
最后,关于 Hystrix,它还有包含 request caching(请求缓存) 和 request collapsing(请求合并)这两个功能,因为它们与熔断关系不大,这里我们也就不讲了。
四、注意事项
把 Hystrix 的设计思路搞清楚后,使用它之前我们还需要考虑几个注意事项:
(一)、数据一致性
这里,通过一个例子我们就好理解了。
假设服务 A 更新了数据库,在调用服务 B 时直接降级了,那服务 A 的数据库更新是否需要回滚?
我们再举一个复杂点的例子,比如服务 A 调用了服务 B,服务 B 调用了服务 C,我们在服务 A 中成功更新了数据库并成功调用了服务 B,而服务 B 调用服务 C 时降级了,直接调用了 Fallback 方法,此时就会出现两个问题:服务 B 向服务 A 返回成功还是失败?服务 A 的数据库更新需不需要回滚?
以上两个例子体现的就是数据一致性的问题。关于这个问题并没有一个固定的设计标准,只是在不同需求下使用熔断时,我们结合具体的情况设计即可。
(二)、超时降级
比如服务 A 调用服务 B 时,因为调用过程中 B 没有在设置的时间内返回结果,被判断超时了,所以服务 A 又调用了降级的方法,其实服务 B 在接收到服务 A 的请求后,已经在执行工作并且没有中断。等服务 B 处理成功后,还是会返回处理成功的结果给服务 A。可是服务 A 又已经走了降级的方法,而服务 B 又已经把工作做完了,此时就会导致服务 B 中的数据出现异常。
(三)、用户体验
请求触发熔断后,一般会出现以下三种情况:
用户读数据的请求时遇到有些接口降级了,导致部分数据获取不到,这时我们需要在界面上给用户提供一定的提示,或让用户发现不了这部分数据的缺失;
用户写数据的请求时,熔断触发降级后,有些写操作就会改为异步,后续处理对用户没有任何影响,但我们要根据实际情况判断是否需要给用户提供一定的提示;
用户写数据的请求时,熔断触发降级后,操作可能就回滚掉,此时我们必须提示让用户重新操作。
因此,服务调用触发了熔断降级时,我们需要把这些情况都考虑到以此保证用户体验,而不是仅仅保证服务器不宕机。
(四)、熔断监控
熔断使用上线后,其实我们只是完成了熔断设计的第一步。因为 Hystrix 是一个事前配置的熔断框架,关于熔断配置到底对不对,效果好不好,我们只有实际使用后才知道。
为此,实际使用时,我们还需要盯着 Hystrix 的监控面板查看各个服务的熔断数据,然后根据实际情况再做调整。只有这样,我们才能在真正使用熔断时将服务器的异常损失降到最低。
五、总结
目前,市面上的熔断框架已经设计得非常好了。对于使用熔断的人来说,虽然可以通过简单配置或代码书写实现使用,但是因为它是高并发中非常核心的一个技术,所以我们有必要搞懂它的原理机制及使用场景。
六、联系我
微信公众号:服务端技术精选
个人博客网站:http://www.jiangyi.cool
评论