线上治理是根据量化分析的结果,通过相应的预案对线上服务的运行状况进行调整,保证线上服务正常运行,接下来讨论线上服务常见的预案,以及如何保证预案的自动触发和自动调整。
故障快速定位和止损的理想处理方式是将故障定位和预案执行打通,当出现故障时,能够判断出故障的大体类型以及对应的预案,并触发预案的自动执行。
当然实际的故障处理过程中有很多地方需要考虑,虽然并不是所有故障都能提前建立相应的预案,但我们可以根据历史故障和一些先验知识将故障进行归类,建立相应的预案。另外,建立预案时应尽量方便执行和触发,如果不方便执行,很难短时间内处理故障,最关键的问题是判断预案触发的时机,以及当前是否应该执行预案。
线上服务稳定性故障大体可以归类为如下原因。
1) 变更引起的故障 。变更是稳定性故障的最主要来源,系统广义的变更源很多,最常见的服务变更一般包含应用变更、配置变更和数据变更。除了服务变更之外,环境和硬件的变化,比如网络带宽变化、机房链路变化等,也可以归为广义的变更范畴。
2) 流量和容量变化引起的故障 。这类故障对应于之前稳定性保障中分析的输入流量突变,如果服务提前没有足够的应对机制,会导致一定的稳定性隐患。
3) 依赖故障 。依赖服务故障会影响调用依赖服务的上游服务,依赖服务故障又分为强依赖服务故障和弱依赖服务故障,这两者会有相应的处理方式。
4) 机房、网络等硬件和环境故障 。硬件和环境故障的特点是没有办法预测,随机性和偶然性因素很大,并且一旦发生往往是系统级别的问题,会产生很严重的后果。
5) 其他 。比如ID生成器溢出导致的故障。
故障的场景化是指根据上述稳定性故障的大类,再细化出一些方便识别判断的场景,比如入口流量突增、接入层故障、强依赖服务故障、弱依赖服务故障等细分的场景。划分这些场景的目的是发生故障时,进一步识别故障的根因(不一定是最根本原因,而是从止损的角度进行归类)。因此可以对那些比较容易通过可观测性指标判断出故障的场景类型,并且方便制定相应的场景化预案的故障,进行场景化归类。
对于降级、限流和冗余切流这几类比较明确的场景,可以基于Metric进行故障判断,并且和预案自动打通,以依赖服务故障为例,可以根据Metric成功率指标同环比变化,判断依赖服务是否有异常,如果通过Metric发现当前确实有异常,先查询变更管理平台,依赖服务当前是否有相关的变更操作。如果有变更,建议依赖服务接口人立即进行变更回滚操作;如果没有变更操作,再判断当前对依赖服务的调用是强依赖还是弱依赖,如果是弱依赖,可以启动自动降级预案对依赖服务进行降级,如果是强依赖,降级肯定不能解决问题,可以通过预先制定好的冗余切换预案,启动服务级、集群级或者机房级别的流量切换。
基于Metric的场景和预案打通,目标是朝着故障定位自动化和智能化的方向演进,但需要根据实际情况逐步推进,对于一些不太容易判断的场景,建议谨慎操作,避免可能的误判,同时要定期对预案进行演练,保障预案触发的有效性。
限流降级和切流是服务治理的3个利器,分别解决上游、下游和服务自身故障问题。这些服务治理特性和实现本身并不复杂,使用时的关键和难点在于 触发的具体时机 ,比如什么时候启动限流、什么情况下启动降级等,下面以限流为例详细讨论如何设置治理参数以及限流的触发时机。
以登录服务为例,登录过程中需要使用加密算法对密码进行校验,一次登录过程CPU耗时1s,登录服务线上使用的服务器是40核,因此一台机器上登录服务的QPS大概是40左右。当某一秒有200个请求进到登录服务,我们希望服务可以在接下来的5s内,每秒完成40个请求,这很好理解,也符合常理,因为这样才能达到稳定输出的服务目标。但事实符合我们上面的预期吗,我们以一个简单的golang程序为例来看看。
package main import "fmt" import "golang.org/x/crypto/bcrypt" import "time" import "runtime" func test(cost int, id int){ fmt.Println(time.Now(), "BEGIN test ", id) code, _ := bcrypt.GenerateFromPassword([]byte("password"), cost) fmt.Println(time.Now(), "END test ", id, code[0]) } func main() { runtime.GOMAXPROCS(1) for i:=0; i<5; i++ { go test(17, i) } time.Sleep(1e16) }
程序运行完,结果如下:
2019-01-15 00:24:46.451649842 +0800 CST BEGIN test 1 2019-01-15 00:24:46.451685368 +0800 CST BEGIN test 2 2019-01-15 00:24:46.451693975 +0800 CST BEGIN test 3 2019-01-15 00:24:46.451699783 +0800 CST BEGIN test 4 2019-01-15 00:25:36.89918498 +0800 CST END test 0 36 2019-01-15 00:25:37.090741796 +0800 CST END test 2 36 2019-01-15 00:25:37.098754534 +0800 CST END test 3 36 2019-01-15 00:25:37.183491458 +0800 CST END test 1 36 2019-01-15 00:25:37.18871652 +0800 CST END test 4 36
通过观察上面的输出日志,我们可以看出结果并不是每秒稳定输出,而是在一段时间后,这一批请求都在很接近的时间被处理完。和我们上述的预期不太相符。这涉及Golang的协程调度,并不是等到协程运行到阻塞点才交还CPU,这在逻辑上也是正确且应该的,目的是防止协程实现存在问题而将CPU全部消耗掉。但这个逻辑对于CPU密集型的服务而言,在遇到尖峰流量时,就会产生我们不希望的结果。
回到上面讨论过的具体例子,200个请求进入服务后,如果上游设置的超时时间比5s短,这200个请求对外就全部超时了。也就是说,只要有超出CPU处理能力范围的大流量到达登录服务时,如果不做特殊处理,这些请求无一例外全部超时。这个问题会影响CPU密集型的Golang服务,采用类似调度策略的Java/C++服务也有类似问题。
具体如何解决呢 ?很容易想到用单接口限流来解决。通过处理能力计算出每秒最多能接收的请求量,超出就抛弃请求,防止大流量对服务质量的冲击。这就是尖峰限流。
对于登录服务来说,可以把限流阈值设置为40QPS,超出部分就丢弃,通过单接口限流解决这个问题,但这个方案的问题也很明显:如果高CPU占用接口在梳理时被遗漏了,该接口消耗了绝大多数CPU,导致限流失效;同时新增加的接口,靠人和经验来评估限流值,也很容易遗漏设置。
继续思考,我们会发现这个方案最重要的问题:接口不止有一个,而它们使用相同的CPU资源。那每个接口允许的限流值怎么设置,到底要给其他接口留多少余量呢?
以登录服务为例,如果设置成40QPS就是不给其他接口留余量,但由于前述的Golang CPU调度特性,极端情况下只要有其他接口流量,所有登录请求都会超时,整体有效吞吐变成零。那应该设置成多少,20、10?我们会发现无论多少都不适合,因为不知道极端情况下流量特点到底是什么样的。也就是说,单接口限流可以满足突发流量的需求,但不足以完成极端情况下的流量调度需求。如果一个服务同时有I/O消耗型接口与CPU消耗型的接口,通过简单的接口限流方案(即手工设置所有接口的最大QPS)无法兼顾效率与吞吐,且存在严重的维护问题(说不定哪天某个新接口就忘记设置限流了)。
对于同时有I/O消耗型接口与CPU消耗型接口的服务,我们可以通过部署拆分将CPU消耗型接口和I/O消耗型接口独立部署,让它们互不影响。但这样并不能从根本上解决问题。因为计算型的接口不止一个,总不可能每一个计算型接口都单独部署集群。只要设置过的人都会遇到这个疑惑:到底要设置成多少合适?每个接口的限流设置稍微高一点,请求压力一大系统就可能总体过载。因此考虑到安全原则,实际情况中接口限流都是设置成非常安全但低效的阈值,但这样会导致大量的机器资源浪费,因此,我们必须抛弃这种安全但低效的阈值管理方式,寻找一种更加科学合理的流控方式。
再细化一下流控的需求,我们需要的流控方案应该是:足够安全、高效,能保证持续吞吐,能充分利用CPU的。其中的核心是持续吞吐和高效。最后实际产出的技术方案是多重级联的自动流控,具体设计思路大体如下。
设计思路 :任何接口在极限流量下,只要超出处理能力,就一定会出现接口超时,但因为有突发流量的控制,对外吞吐能力是稳定的。只要超时率超过一定比例,就启动阈值的上升和下降,控制进入系统的请求包数量。
为了控制程序对CPU的使用,留给其他系统进程(如各种Agent),同时为了防止通过流量阈值一次进入太多的请求,我们控制最多起40个Golang协程用于处理CPU消耗型的接口(这也是我们的突发限流)。其他请求列队等待。这里面需要注意的是,出队时要注意时间,如果请求已经超时需要直接抛弃,但这个时间不需要很长,同时需要结合SLA,考虑在秒级平缓瞬时请求流量。
过载保护,即实际采用方案的连接数超过一定阈值就主动拒绝请求。这很容易理解,但里面有个小细节是拒绝的方式:不能简单地忽略请求,而应该快速断开连接或者回复服务器当前太忙消息给调用方。