那条横线是整个服务的一个可靠负载边界,由于网络抖动,造成客户端重试,进而造成了一波重视流量的小高峰,这个小高峰变成了压垮骆驼的最后稻草,一个服务节点打垮,流量被负载到其他正常节点,正常节点继续被打垮,最后一批服务节点不可用,导致整个核心服务链路不可用。
一个完整的流量类似这种,客户端连接API服务,API服务有各种下游服务,比如商品,LBS,优惠券,订单等服务。服务之间通过RPC衔接,某些服务通过MQ异步通信。因为链路上某些服务不可用,导致了上游服务进行重试,由于打车高峰期,用户因为APP不可用,也会手动重试,重试调用加上手动重试导致瞬时整个链路的QPS超过正常负载,进而引起后续的雪崩。
有的人考虑是不是可以降级呢?但是在核心链路上有些服务是不可降级的,比如商品服务的库存能力,订单及支付服务,所以对于核心链路的可用性不是一两个服务的可用性,而是全链路上所有服务的可用性,可用性及稳定性治理是贯穿于核心链路全部服务的。
有人说了是不是可以冗余?确实可以冗余,但是对于高并发链路上的服务来说,过分的冗余成本巨大,所以很多公司每隔一段时间都会推一次资源治理,目标提高团队内部的资源最优。
总结来说讲了两点:限流和重试。
对于突增流量只有两种方式:扩容和限流。
扩容是应对短时流量高峰的有效手段,在监控指标发现流量有突增后,快速进行扩容,如果扩容资源可以接住陡增流量,且平稳过去是最完美的。
如果没法做到快速扩容,或是扩容可能导致链路下游服务流量进一步陡增的话,限流则是最好的选择,将服务本身负载之外的流量限制在外,减少流量对于链路的侵入,对于超出处理能力的部分请求,应果断拒绝请求。当然限流是有损的,滴滴和外卖在应对每次单量创新高时的限流本身会限掉几百万订单。
每个服务需要实时反映出自己的负载,超出这个负载指标上限时,可以拒绝后续的服务。
服务调用是链路的,上游服务调用下游服务需要感知到下游服务的负载情况,上游服务自己调整策略。
所以我们需要有我们做出限流决定的表征指标。
这里举一个leveldb的例子,说明了当patch数量过多时,导致minor或major压缩长时间做不完,甚至永远做不完,所以可以选择patch数量代表表征负载指标。
有些指标反映了系统负载到一定瓶颈了,包括核心业务指标,系统指标。
如果发现是个别机器有问题,可以进行置换,如果发现某个机房存在问题,可以找运维看下网络或交换机是否存在问题,这时可能不只是你一家服务有问题,sre应该拉人进群,拉zoom排查了。
我们得到了服务或节点本身的负载表征之后,就可以进行限流了,限流主要有两种方法:漏斗和令牌桶。
- 漏斗特点是流速均衡;
- 令牌桶特点特定时间窗口内总数一致;
两种场景的限流,一种是同步线程模型,一种是异步线程模型。
同步线程模型是串行的,增加调用次数以及判断阈值,更像是漏斗机制,控制更均衡。
异步线程模型由于是多线程并行执行的,不太好控制到均衡的流量,所以通过token机制控制并行时间窗口内的总负载一致。
但是这个token具体设置多少呢?一个参考是工作线程数的倍数,有个面试题是围绕于这里,就是你们线程池数量一般设置多少。但是token和线程数可能有关,但是更应该根据压测效果得来。
- 不应该过度追求资源利用率,让服务压着cpu或其他资源跑,根据压测结果设置一个不那么激进的值即可
- 如果没有考虑超时时间,排队的任务处理完了,但是调用端早超时了,这个排队和限流就没有意义了
- 因为限流本身是有损的,所以并不是所有场景都可以进行限流的,比如Master对于Slave心跳的请求,比如我们做核心链路就不优先考虑限流和降级,而是考虑容量规划
- 降级走MQ,任务最终被消费,只不过需要拿到一个token即可
- 比如限流根据负载情况自适应修改,大部分场景没有必要,工程上越简单越不容易出错,架构遵循:简单,合适,可演进原则
因为前面提到了服务调用者需要根据服务提供者的繁忙程度进行重试,大家先考虑下我们服务设计过程中为什么要重试?
在分布式系统中,网络是不可靠的,为应对网络不可靠导致的通信问题,一般需要重试;
对于分布式存储系统中,因为很多算法是基于超半数确认算法实现的,如何确保自己获取的值是准确的呢?可以并行发起多个req,如果多个resp里面超过半数的数据是一样的就认为这些是正确的。或者是幂等多次读取不一样。
第三点应该是更符合我们工程化场景的,因为造成需要重试的主要来源于两方面:功能性的和非功能性的。功能性的比如输入参数就是有问题,提示你失败了,你就没必要重试了;非功能性的比如网络丢包导致的,或是对方服务正在fullgc,下一次重试你可能就好了。所以究竟是功能性或是非功能性的重试,需要和对方约定好返回值,调用方根据返回值进行判断重试方式。
需要注意的是如果查询接口还好,如果是写接口,需要确定对方已经进行了幂等处理,否则不建议重试。
1.如果服务层、存储层不能保证高可用,服务整体的稳定性无从谈起。
2.比如有些case本身应该是架构合理性的问题,他服务没有挂,他的存储也没有挂,但是提供了错误的值,缓存与数据库数据不一致的脏数据问题,数据一致性在架构设计上没有考虑到。
3.如果在线链路没有高性能,就会存在不确定性,比如每个节点最终可以承载多少并发,我的线程持有,连接池持有长时间不回收,导致后续的常量请求仍然不可以被消费,导致block,影响节点吞吐,进而影响服务集群吞吐。
在工程维度,除了前面提到的限流,在稳定性建设上还有哪些手段呢?套用八股文大概有这些解:限流,熔断,降级,隔离,缓存等等。
合理的架构设计和合理的技术选型是新加的,比如缓存失效导致权限失效这个case。理由是redis触发淘汰机制,导致部分数据被淘汰,用户请求数据获取权限失败,导致了这个case,本质上是技术选型上存在问题,redis更多是被利用到缓存场景,解决高并发场景下对于db的压力,但是他是“缓”存,是易失的,所以必须有相关的兜底策略,就是在数据淘汰之后,如果通过其他方式再次拿到数据,不产生这种问题。所以很多时候架构设计不合理或是技术选型不对,也会埋下坑,对后续的系统稳定性带来挑战。
对于核心链路和不可降级链路来说,最重要最有效的方式是通过容量规划,保障系统资源可以支撑流量新高,而不是限流这种有损的方式,当然这个是三位一体的体系化方案,需要有较好的压测工具实现随时随地的流量复现,有准确的告警机制,可以准确反映系统指标,有很好的容器扩容平台,进线容器的快速扩缩容。
超时治理为什么重要,因为他会影响到你单机的吞吐,如果单机吞吐提不上去,你整个机器的吞吐就上不去,资源利用率也就上不去。
主要存在于三种模型,同步模型,在高并发请求下,每个请求都是一个独立的线程,所以高并发情况下,导致资源标高,系统吞吐降低。
- 异步模型虽然可以将线程数量从应用层面剥离到中间件层面,但是本质上还是消耗的宿主机的资源,资源利用率还是下不来。
- 响应式编程,可以很好的将请求分散到独立空闲的线程碎片上,提高资源利用率,但是编程成本高,用不好反而出现难以处理的bug,且大部分中间件不支持。
- 其他的解决方案类似于golang、kotlin的协程,可以以更小的代价实现并发。
- 事件驱动方法可以有效利用现有资源,让多个收件人可以共享一个硬件线程。相比于同步的传统应用程序,一个非阻塞的应用程序在高负载下拥有更低的延迟和更高的吞吐,这将导致更低的成本,提高了资源利用率。