伴鱼技术团队

Technology changes the world

伴鱼开放平台 上线了! 源于实践的解决方案,助力企业成就未来!

服务器连锁故障剖析

前言

连锁故障是由于正反馈循序导致问题不断扩大的故障。一个连锁故障通常是由于整个系统中一个很小的部分出现故障而引发,进而导致系统其它部分也出现故障。比如某一个服务的一个实例出现故障,导致负载均衡将该实例摘除而引起其它实例负载升高,最终该服务的所有实例像多米诺骨牌一样一个一个全部出现故障。

那么,一个正常运行的服务是怎么发生连锁故障的呢?

服务器过载

服务器过载是指服务器只能处理一定 QPS 的请求,当发往该服务器的 QPS 超出后,由于资源部够等原因导致崩溃、超时或者出现其他的异常情况,结果导致服务器成功处理的请求远远不及正常情况可处理的 QPS 。这种成功处理请求能力的下降可能会导致服务实例的崩溃等异常情况,当服务崩溃后,负载均衡器会将请求发送给其他的集群,使得其他的集群的实例也出现过载的情况,从而造成整个服务过载的故障。一个过程通常非常快,因为负载均衡器的响应速度通常是非常快的。

资源耗尽

资源耗尽会导致高延迟、高错误率或者低质量的回复发生。而这些问题不断导致负载上升直至过载,从而发生连锁故障。下面来分析不同资源耗尽对服务器产生的影响:

cpu 资源不足一般会导致请求变慢,有以下几种情况:

  • 正在处理的请求数量上升,这会导致同一时间服务器必须同时处理更多的请求,也将会导致其他的资源的需求上涨,包括内存,线程,文件描述符等等资源的上涨;
  • 正在等待处理的队列过长,这会导致请求的延迟上升,并且队列过长也会导致内存使用量上升;
  • 线程卡住,如果一个线程由于等待某一个锁而无法处理请求,可能服务器无法在合理的时间内处理健康检查请求而被重启;
  • cpu 死锁或者请求卡住,由于 cpu 死锁或者请求卡住,导致健康检查无法通过而被重启;
  • 由于 cpu 资源不足导致响应变慢引起 rpc 超时,而 rpc 超时可能会导致客户端的重试,造成系统的过载;
  • cpu 缓存效率下降,cpu 使用率越高,导致任务被分配到多个 cpu 核心上的几率越大,从而导致 cpu 核心的本地缓存失效,进而降低 cpu 处理的效率;

内存资源不足会导致以下的情况发生:

  • 任务崩溃,内存不足可能会导致任务被系统 oom 或者自身逻辑导致服务崩溃;
  • gc 速率加快,导致 cpu 使用率上升,cpu 使用率上升导致请求变慢,进一步导致内存上升。(gc 死亡螺旋)
  • 缓存命中率下降,可用内存的减少会导致缓存命中率的降低,导致向后端发送更多的 rpc,可能会导致后端服务过载;

线程不足会导致以下情况的发生:

  • 导致请求错误,这可能会导致客户端的重试,造成系统的过载;
  • 导致健康检查失败而被重启;
  • 增加的线程会消耗更多的内存;
  • 极端情况下会导致进程 id 不足;

文件描述符不足会导致以下情况的发生:

  • 导致无法建立新的网络连接导致请求错误;
  • 导致健康检查失败而被重启;

服务不可用

当资源耗尽导致服务的崩溃,比如内存耗尽等等,一个服务实例不可用,比如崩溃等等。由于负载均衡会自动忽略不健康的实例,导致其他健康实例的负载升高,从而导致连锁故障。

怎么避免连锁故障

那么怎么来应对连锁故障呢?一般来说可以采用下面的方法来避免连锁故障,按优先级排列为:

  • 进行压力测试,测试服务器的极限,同时测试过载情况下的失败模式;
  • 提供降级结果;
  • 在过载情况下服务主动拒绝请求;
  • 在反向代理层,针对请求的特性进行数量限制(ip),防止 ddos 攻击;
  • 在负载均衡层。在服务进入全局过载时进入主动丢弃请求。
  • 服务自身避免负载均衡的随机抖动导致过载;
  • 进行容量规范,容量规范只能减少连锁故障的可能性,不能避免连锁故障;

避免连锁故障的具体的策略为:

队列管理

提前规划好请求队列容量,当队列满时服务器主动拒绝新的请求。另外在服务器过载的时候,后进先出的队列模式比先入先出要好。

流量抛弃

流量抛弃有两个方式:

  • 服务器流量抛弃:在服务器临近过载时,主动抛弃一定量的负载。比如 cpu 达到一定得使用率、内存达到一定得使用率或者服务器请求队列容量达到最大值的时候,服务器端可以对一些流量直接抛弃。
  • 客户端流量抛弃:反向代理或者负载均衡层在系统快进入或者已经进入连锁故障的情况下,直接抛弃一部分流量。

流量抛弃可以和一些策略进行结合,比如请求的优先级,用户的优先级等等。同时流量抛弃和截止时间配合起来效果非常不错。

优雅降级

优雅降级是在接受该请求的情况下,通过降低回复的质量来大幅较少服务器的计算量。流量抛弃已经让服务器直接少处理了很多请求,但是对于已经接受的请求,服务器是需要处理的,这个时候如果有优雅降级机制,能大大较少服务器的计算量,并且能一定程度的保证用户体验。

重试控制

  • 使用随机化,指数型递增的重试周期,防止重试风暴;
  • 限制每个请求的重试次数,防止在服务器过载的情况下,出现重试出错,出错重试导致连锁故障;
  • 考虑全局重试预算。比如每个服务每分钟只容许重试 60 次,重全局的角度控制重试的范围和力度;
  • 不要在多个层数上重试,一个高层的请求可能会导致各层的重试,所以重试的时候,一定要明确在一个层面重试,防止多层重试导致重试的放大;
  • 使用明确的错误码,将可重试错误和不可重试错误分开,不可重试的错误一定不要重试。一般来说临时错误是可以重试的,非临时错误或者服务过载的时候,就不应该再进行重试;

请求延迟和截止时间

在顶层给每一个请求增加一个截止时间,并且在每一层进行传递,同时每一层在请求之前进行检查,过期的请求直接抛弃。在服务器过载的情况下,请求的延迟会加大,请求会在队列中排队等待很长的时间,比如一个请求等待 30s 后才开始执行,但是对于客户端来说,用户早已经放弃等待该请求的结果了,所以这对这样的请求继续执行是没有意义的,只会浪费服务器的计算资源,进一步加速了连锁故障。所以对于这样的请求,应该在请求前直接抛弃,将服务器的计算资源应用在其他有意义的请求上面;

保持调用栈永远向下

同层通信容易导致分布式死锁,比如一个服务实例 a 由于线程池没有空闲线程而将请求挂起,这个时候如果实例 b 将将请求转发到实例 a 而导致实例 b 线程的消耗,在最坏的情况下可能会导致连锁故障的发生。一个比较好的方式是将同层通信的逻辑转交给客户端来处理,比如一个前端需要后后端通信,但是猜错了后端服务,这个时候后端服务应该返回正确的后端服务,让客户端再次发起请求,而不是直接代理请求到正确的后端服务。

连锁故障测试

测试直到出现故障,再继续测试,通过测试发现连锁故障出现的原因;并且也应该测试非关键性的后端,确保它们的不可用不会影响到系统中的其他关键组件,比如它们会不会影响请求的时延,会不会导致正常请求的超时等等。

总结

当一个系统过载的时候,一定需要牺牲一些东西的,这样比尝试继续请求而导致所有请求都不能正常服务要好。理解这些临界点以及超过这些临界点后系统的行为模式,是我们避免连锁故障必须掌握的。

一般来说,我们为了降低服务背景错误率或者优化稳定状态的改变反而会让服务更容易出现事故。比如在请求失败时的重试、负载自动转移、自动杀掉不健康的服务器、增加缓存提高性能或者降低延迟等等这些手段都是为了优化正常情况下服务器的性能,但是这也会提高大规模服务故障的几率。所以一点要小心评估这些改变!

欢迎关注我的其它发布渠道