Skip to content

Latest commit

 

History

History
421 lines (234 loc) · 26.5 KB

week06-07-微服务可用性设计.md

File metadata and controls

421 lines (234 loc) · 26.5 KB

隔离

隔离,本质上是对系统或资源进行分割,从而实现当系统发生故障时能限定传播范围和影响范围,即发生故障后只有出问题的服务不可用,保证其他服务仍然可用。

服务隔离

动静分离、读写分离

动静隔离:

小到 CPU 的 cacheline false sharing、数据库 mysql 表设计中避免 bufferpool 频繁过期,隔离动静表,大到架构设计中的图片、静态资源等缓存加速。本质上都体现的一样的思路,即加速/缓存访问变换频次小的。比如 CDN 场景中,将静态资源和动态 API 分离,也是体现了隔离的思路:

  • 降低应用服务器负载,静态文件访问负载全部通过CDN。

  • 对象存储存储费用最低。

  • 海量存储空间,无需考虑存储架构升级。

  • 静态CDN带宽加速,延迟低。

轻重隔离

核心、快慢、热点

核心隔离

业务按照 Level 进行资源池划分(L0/L1/L2)。

  • 核心/非核心的故障域的差异隔离(机器资源、依赖资源)。
  • 多集群,通过冗余资源来提升吞吐和容灾能力

物理隔离

线程、进程、集群、机房

隔离设计源于船舶行业,一般而言无论大船还是小船,都会有一些隔板,将船分为不同的空间,这样如果有船舱漏水一般只会影响这一小块空间,不至于把整个船都给搞沉了。

同样我们的软件服务也是一个道理,我们要尽量避免出现一个问题就把这个业务给搞挂的情况出现。

img 一般而言类似的文章都会告诉大家服务隔离应该分为哪里类型,或者那些级别,然后一般分别怎么做,今天我们换一个套路,我们从一个服务演进的角度来看,我们服务隔离是怎么做的

服务的演进

PS: 接下来的部分架构纯属瞎扯,但是道理是差不多的,辛苦各位凑合看一下了

如下图,今天天气不错,我们开始创业(天气和创业有啥关系???),搞了一个电商网站,由于前期人手不足技术也不够,就一个服务和一个数据库就开始对外提供服务了 v1.jpg

随着货物的不断上架,我们发现产品相关介绍的图片、视频等信息占用了我们服务的大部分带宽,并且也不太好管理,用户访问呢也比较慢,影响了剁手的体验,这时候我们做了一波优化,把静态资源的数据使用云服务商提供的对象存储给保存了起来,然后在前面接入了一个 CDN 给用户提供更好的体验。

这就是第一次隔离,动静隔离,我们使用对象存储和 CDN 将静态资源和动态 API 进行了隔离 v2.jpg

然后突然有一天我们发现,用户大量投诉说什么垃圾网站,突然访问这么慢,进过紧锣密鼓的排查发现,原来是我们的运营同学在后台进行数据统计准备出报告的时候影响了生产的数据库,导致影响了我们的用户。

这怎么能行呢,怎么可以影响我们的衣食父母,所以我们有进行了一波优化,我们对数据库进行了主从分离,然后将运营后台和我们的商城主服务做了拆分,后续所有的统计查询请求我们都从从库查询,其他请求才会去修改主库。

是滴,我们又做了一次隔离,一个是将数据库做了主从隔离,另外一个按照不同的用户属性,做了用户隔离。当然这是比较宏观的在这过程中我们肯定也会对数据中的表进行一些拆分设计,例如将经常变化的数据和不太经常变化的数据分配到两张表等。 v3.jpg](https://img.lailin.xyz/im

不知道是随着一些爆款活动的推出,以及不知名大 V 的推广使得我们的业务欣欣向上,还是我们新来的技术总监为了 KPI 我们进行了轰轰烈烈的微服务改造的活动,最后经过长达一年多的改造,我们的服务架构改造成了下面这个模样。

我们的请求在访问之前都会先经过 WAF 防火墙,然后再到对应的 CDN 节点然后经过我们的 API 网关到 BFF 层。然后 BFF 层再去调用各种服务聚合成业务数据并且返回。

我们其实又做了一层按照服务的隔离,我们将一个单体服务拆分成了一个个的小服务,就不会出现评论挂掉了导致整个服务挂掉无法下单的情况。 v4.png

微服务改造完成之后我们发现,的确整体的服务质量都好了很多,但是突如其来的一个 bug 导致我们的监控大量告警,这是为什么呢,原来是我们的推荐服务出现了一个内存泄漏的问题,然后我们的服务限制做的不够好根本没有设置任何限制,这就导致它占用了资源池中的大量资源,让我们的其他服务资源紧缺。

然后我们就又做了一个改造,我们把支付和商城这种最重要的业务单独放在了一个池子里面,对于像评论推荐这种没有那么重要的业务放在共享的资源池当中。

所以这一次我们按照服务的优先级进行了隔离 v5.png

然后突然有一天,我们的一个商品成为了爆款,大量用户涌入访问,成功将我们的 cache 打挂,后续我们做了什么改动呢,我们将 remote cache 提升为了 local cache,在 sdk 当中自动识别出热点流量,然后将其缓存,大大减少了 redis 的压力

这一次我们就将热点数据进行了隔离 V6.png

好啦,瞎扯结束总结一下吧

总结

通过上面纯属虚构的面向事故驱动的例子,我们可以发现,我们隔离设计一般分为以下几种:

  • 服务隔离
    • 动静隔离:例如上面讲到的 CDN
    • 读写隔离:例如上面讲到的主从,除此之外还有常见的 CQRS 模式,分库分表等
  • 轻重隔离
    • 核心隔离:例如上面讲到将核心业务独立部署,非核心业务共享资源
    • 热点隔离:例如上面讲到的 remote cache 到 local cache
    • 用户隔离:不同的用户可能有不同的级别,例如上面讲到的外部用户和管理员
  • 物理隔离
    • 线程:常见的例子就是线程池,这个在 Golang 中一般不用过多考虑,runtime 已经帮我们管理好了(后续有一个系列讲这个)
    • 进程:我们现在一般使用容器化服务,跑在 k8s 上这就是一种进程级别的隔离
    • 机房:我们目前在 K8s 的基础上做一些开发,常见的一种做法就是将我们的服务的不同副本尽量的分配在不同的可用区,实际上就是云厂商的不同机房,避免机房停电或者着火之类的影响
    • 集群:非常重要的服务我们可以部署多套,在物理上进行隔离,常见的有异地部署,也可能就部署在同一个区域

超时

超时控制,我们的组件能够快速失效(fail fast),因为我们不希望等到断开的实例直到超时。没有什么比挂起的请求和无响应的界面更令人失望。这不仅浪费资源,而且还会让用户体验变得更差。我们的服务是互相调用的,所以在这些延迟叠加前,应该特别注意防止那些超时的操作。

  • 网路传递具有不确定性。
  • 客户端和服务端不一致的超时策略导致资源浪费。
  • “默认值”策略。
  • 高延迟服务导致 client 浪费资源等待,使用超时传递: 进程间传递 + 跨进程传递。

超时控制是微服务可用性的第一道关,良好的超时策略,可以尽可能让服务不堆积请求,尽快清空高延迟的请求,释放 Goroutine。

怎么解决控制超时

  • 一、实际业务开发中,我们依赖的微服务的超时策略并不清楚,或者随着业务迭代耗时超生了变化,意外的导致依赖者出现了超时。

服务提供者定义好 latency SLO,更新到 gRPC Proto 定义中,服务后续迭代,都应保证 SLO。

 package google.example.library.v1;
 
 service LibraryService {
     // Lagency SLO: 95th in 100ms, 99th in 150ms.
     rpc CreateBook(CreateBookRequest) returns (Book); 
     rpc GetBook(GetBookRequest) returns Book);
     rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
 }
  • 二、避免出现意外的默认超时策略,或者意外的配置超时策略。

1.kit 基础库兜底默认超时,比如 100ms,进行配置防御保护,避免出现类似 60s 之类的超大超时策略。

2.配置中心公共模版,对于未配置的服务使用公共配置。

  • 三、超时传递: 当上游服务已经超时返回 504,但下游服务仍然在执行,会导致浪费资源做无用功。超时传递指的是把当前服务的剩余 Quota 传递到下游服务中,继承超时策略,控制请求级别的全局超时控制。

进程内超时控制:一个请求在每个阶段(网络请求) 开始前,就要检查是否还有足够的剩余来处理请求,以及继承他的超时策略,使用 Go 标准库的 context.WithTimeout。

func (c *asiiConn) Get(ctx context.Context, key string) (result *Item, err error) {
	c.conn.SetWriteDeadline(shrinkDeadline(ctx, c.writeTimeout))
	if _, err = fmt.Fprintf(c.rw, "gets %s\r\n", key); err != nil {
  • 四、A gRPC 请求 B,1s超时。

    B 使用了300ms 处理请求,再转发请求 C。 C 配置了600ms 超时,但是实际只用了500ms。 到其他的下游,发现余量不足,取消传递。

    在需要强制执行时,下游的服务可以覆盖上游的超时传递和配额。 在 gRPC 框架中,会依赖 gRPC Metadata Exchange,基于 HTTP2 的 Headers 传递 grpc-timeout 字段,自动传递到下游,构建带 timeout 的 context。

超时注意事项:

  • 双峰分布: 95%的请求耗时在100ms内,5%的请求可能永远不会完成(长超时)。
  • 对于监控不要只看mean,可以看看耗时分布统计,比如 95th,99th。
  • 设置合理的超时,拒绝超长请求,或者当Server 不可用要主动失败。

超时决定着服务线程耗尽。

过载保护

令牌桶算法

是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:

  • 假设限制2r/s,则按照500毫秒的固定速率往桶中添加令牌。

  • 桶中最多存放 b 个令牌,当桶满时,新添加的令牌被丢弃或拒绝。

  • 当一个 n 个字节大小的数据包到达,将从桶中删除n 个令牌,接着数据包被发送到网络上。

  • 如果桶中的令牌不足 n 个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。

    token-bucket rate limit algorithm: /x/time/rate

漏桶算法

作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:

  • 一个固定容量的漏桶,按照常量固定速率流出水滴。

  • 如果桶是空的,则不需流出水滴。

  • 可以以任意速率流入水滴到漏桶。

  • 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。

    leaky-bucket rate limit algorithm: /go.uber.org/ratelimit

漏桶和令牌桶的最大的区别就是,令牌桶是支持突发流量的,但是漏桶是不支持的。但是 uber 的这个库通过引入弹性时间的方式也让漏桶算法有了类似令牌桶能够应对部分突发流量的能力,并且实现上还非常的简单,值得学习。

这两种算法最大的一个问题就是他们都属于需要提前设置阈值的算法,基于 QPS 进行限流的时候最麻烦的就是这个阈值应该怎么设定。一般来说我们可以通过压测来决定这个阈值。

  • 但是如果每个系统上线前都要经过很严格的压测,那么成本相对来说会比较大
  • 并且我们很多时候压测都会在测试环境进行压测,测试环境一般来说和生产环境会有一定的差异,即使我们在生产环境做了压测,现在我们的应用都是以容器的形式跑在不同的宿主机上的,每台宿主机上的差异,以及不同的负载都会导致这个压测时的结果不一定就一定是正确的
  • 当我们的机器型号、数量等发生改变时,之前压测的指标能不能用其实是一个问题,这些数据对于系统负载的影响其实不是线性的,举个例子之前一台机器,后面再加一台,负载就一定能到 2 倍么?其实是不一定的
  • 如果需要修改限流的值,虽然之前我们将令牌桶的限流是可以动态调整,但是靠人去调整,如果真出现问题然后再叫运维或者是开发同学去调整可能黄花菜都凉了

既然这种方式有这么多的缺点,那有没有办法解决呢?答案就是今天讲到的 自适应限流

自适应限流

自适应限流怎么做

前面我们遇到的主要问题就是每个服务实例的限流阈值实际应该是动态变化的,我们应该根据系统能够承载的最大吞吐量,来进行限流,当当前的流量大于最大吞吐的时候就限制流量进入,反之则允许通过。那现在的问题就是

  • 系统的吞吐量该如何计算?
  • 什么时候系统的吞吐量就是最大的吞吐量了?

计算吞吐量:利特尔法则 L = λ * W

利特尔法则由麻省理工大学斯隆商学院(MIT Sloan School of Management)的教授 John Little﹐于 1961 年所提出与证明。它是一个有关提前期与在制品关系的简单数学公式,这一法则为精益生产的改善方向指明了道路。 —- MBA 智库百科 (mbalib.com)

image.png

如上图所示,如果我们开一个小店,平均每分钟进店 2 个客人(λ),每位客人从等待到完成交易需要 4 分钟(W),那我们店里能承载的客人数量就是 2 * 4 = 8 个人

同理,我们可以将 λ 当做 QPS, W 呢是每个请求需要花费的时间,那我们的系统的吞吐就是 L = λ * W ,所以我们可以使用利特尔法则来计算系统的吞吐量。

什么时候系统的吞吐量就是最大的吞吐量?

吞吐量(TPS)、QPS每秒查询率(Query Per Second) 、并发数、响应时间(RT)

首先我们可以通过统计过去一段时间的数据,获取到平均每秒的请求量,也就是 QPS,以及请求的耗时时间,为了避免出现前面 900ms 一个请求都没有最后 100ms 请求特别多的情况,我们可以使用滑动窗口算法来进行统计。

最容易想到的就是我们从系统启动开始,就把这些值给保存下来,然后计算一个吞吐的最大值,用这个来表示我们的最大吞吐量就可以了。但是这样存在一个问题是,我们很多系统其实都不是独占一台机器的,一个物理机上面往往有很多服务,并且一般还存在一些超卖,所以可能第一个小时最大处理能力是 100,但是这台节点上其他服务实例同时都在抢占资源的时候,这个处理能力最多就只能到 80 了

所以我们需要一个数据来做启发阈值,只要这个指标达到了阈值那我们就进入流控当中。常见的选择一般是 CPU、Memory、System Load,这里我们以 CPU 为例

只要我们的 CPU 负载超过 80% 的时候,获取过去 5s 的最大吞吐数据,然后再统计当前系统中的请求数量,只要当前系统中的请求数大于最大吞吐那么我们就丢弃这个请求。

kratos 自适应限流分析

限流公式

// PS: 官方文档这里写的是 cpu > 800 AND (Now - PrevDrop) < 1s
// 应该是写错了,等下看源码就知道了
(cpu > 800 OR (Now - PrevDrop) < 1s) AND (MaxPass * MinRt * windows / 1000) < InFlight
  • cpu > 800 表示 CPU 负载大于 80% 进入限流

  • (Now - PrevDrop) < 1s 这个表示只要触发过 1 次限流,那么 1s 内都会去做限流的判定,这是为了避免反复出现限流恢复导致请求时间和系统负载产生大量毛刺

  • (MaxPass * MinRt * windows / 1000) < InFlight
    

    判断当前负载是否大于最大负载

    • InFlight 表示当前系统中有多少请求
    • (MaxPass * MinRt * windows / 1000) 表示过去一段时间的最大负载
    • MaxPass 表示最近 5s 内,单个采样窗口中最大的请求数
    • MinRt 表示最近 5s 内,单个采样窗口中最小的响应时间
    • windows 表示一秒内采样窗口的数量,默认配置中是 5s 50 个采样,那么 windows 的值为 10。

熔断

无论是令牌桶、漏桶还是自适应限流的方法,总的来说都是服务端的单机限流方式。虽然服务端限流虽然可以帮助我们抗住一定的压力,但是拒绝请求毕竟还是有成本的。如果我们的本来流量可以支撑 1w rps,加了限流可以支撑在 10w rps 的情况下仍然可以提供 1w rps 的有效请求,但是流量突然再翻了 10 倍,来到 100w rps 那么服务该挂还是得挂。

所以我们的可用性建设不仅仅是服务端做建设就可以万事大吉了,得在整个链路上的每个组件都做好自己的事情才行,今天我们就来一起看一下客户端上的限流措施:熔断。

熔断器

img

如上图所示,熔断器存在三个状态:

  1. 关闭(closed): 关闭状态下没有触发断路保护,所有的请求都正常通行
  2. 打开(open): 当错误阈值触发之后,就进入开启状态,这个时候所有的流量都会被节流,不运行通行
  3. 半打开(half-open): 处于打开状态一段时间之后,会尝试尝试放行一个流量来探测当前 server 端是否可以接收新流量,如果这个没有问题就会进入关闭状态,如果有问题又会回到打开状态

hystrix-go

熔断器中比较典型的实现就是 hystrix,Golang 也有对应的版本,我们先来看一下 hystrix-go 是怎么实现的

降级

通过降级回复来减少工作量,或者丢弃不重要的请求。而且需要了解哪些流量可以降级,并且有能力区分不同的请求。我们通常提供降低回复的质量来答复减少所需的计算量或者时间。我们自动降级通常需要考虑几个点:

  • 确定具体采用哪个指标作为流量评估和优雅降级的决定性指标(如,CPU、延迟、队列长度、线程数量、错误等)。
  • 当服务进入降级模式时,需要执行什么动作?
  • 流量抛弃或者优雅降级应该在服务的哪一层实现?是否需要在整个服务的每一层都实现,还是可以选择某个高层面的关键节点来实现?

同时我们要考虑一下几点:

  • 优雅降级不应该被经常触发 - 通常触发条件现实了容量规划的失误,或者是意外的负载。
  • 演练,代码平时不会触发和使用,需要定期针对一小部分的流量进行演练,保证模式的正常。
  • 应该足够简单。

重试

当请求返回错误(例: 配额不足、超时、内部错误等),对于 backend 部分节点过载的情况下,倾向于立刻重试,但是需要留意重试带来的流量放大:

  • 限制重试次数和基于重试分布的策略(重试比率: 10%)。
  • 随机化、指数型递增的重试周期: exponential ackoff + jitter。
  • client 测记录重试次数直方图,传递到 server,进行分布判定,交由 server 判定拒绝。
  • 只应该在失败的这层进行重试,当重试仍然失败,全局约定错误码“过载,无须重试”,避免级联重试。

负载均衡

数据中心内部的负载均衡 在理想情况下,某个服务的负载会完全均匀地分发给所有的后端任务。在任何时刻,最忙和最不忙的节点永远消耗同样数量的CPU。

目标:

  • 均衡的流量分发。
  • 可靠的识别异常节点。
  • scale-out,增加同质节点扩容。
  • 减少错误,提高可用性。

我们发现在 backend 之间的 load 差异比较大:

  • 每个请求的处理成本不同。
  • 物理机环境的差异: 服务器很难强同质性。 存在共享资源争用(内存缓存、带宽、IO等)。
  • 性能因素: FullGC。 JVM JIT。

参考JSQ(最闲轮训)负载均衡算法带来的问题,缺乏的是服务端全局视图,因此我们目标需要综合考虑:负载+可用性。

微服务可用性设计总结

接下来我们就一起来串联我们之前讲到的和课程上讲到的一些内容总结一下可用性应该怎么做。

微服务可用性设计总结

如上图所示,我们从一个简单的用户访问出发,用户访问到我们的服务一般是先通过我们的移动客户端或者是浏览器,然后请求再依次通过 CDN、防火墙、API网关、BFF以及各个后端服务,整条链路还是比较长的。

我们上图其实已经一部分体现了隔离设计,所以后面我就不再提了。

1. 移动客户端/浏览器

客户端是触及用户的第一线,所以这一层做的可用性优化尤为的重要

  • 降级:

    降级的本质是提供给用户有损服务,所以在触及用户的第一线如何安抚好或者说如何骗过用户的眼睛尤为重要

    • 本地缓存,客户端需要有一些本地缓存数据,不仅可以加速用户首屏的加载时间,还可以在后端服务出现故障的时候起到一定的缓冲作用
    • 降级数据兼容,服务端有时为了降级会返回一些 mock 数据或者是空数据,这些数据一定要和客户端的对接好,如果没有对接好很容易就会出现异常或者是白屏
  • 流控:

    在服务出现问题的时候,用户总是会不断的主动尝试重试,如果不加以限制会让我们本就不堪重负的后端服务雪上加霜

    • 所以在客户端需要做类似熔断的流控措施,常见的思路有指数级退让,或者是通过服务端的返回获取冷却的时间

2. BFF/Client

BFF 是我们后端服务的桥头堡,当请求来到 BFF 层的时候,BFF 既是服务端,又是客户端,因为它一般需要请求很多其他的后端服务来完成数据的编排,提供客户端想要的数据

  • 超时控制:

    超时控制需要注意的两点是默认值和超时传递

    • 默认值,基础库需要有一些默认值,避免客户端用户漏填,错填,举个例子,如果开发填写一个明显过大的值 100s 才超时,这时候我们基础库可以直接抛出错误,或者是警告只有手动忽略才可以正常启动。我之前有一个应用就是因为忘记配置超时时间,依赖的服务 hang 住导致我的服务也无法正常服务了,即使我之前做了缓存也没有用,因为之前的逻辑是只有请求报错才会降级使用缓存数据。
    • 超时传递,例如我们上图,假设我们整个请求的超时时间配置的 500ms,BFF 里面首先经过一些逻辑判断消耗了 100ms,然后去请求 redis,我们给 redis 配置的超时时间 max_con 是 500ms,这时候就不能用 500ms 作为超时时间,得用 min(请求剩余的超时时间,max_con)也就是 400ms 作为我们的超时时间,同样我们请求下游的服务也需要将超时时间携带到 header 信息里面,这样下游服务就可以继承上游的超时时间来进行超时判断。
  • 负载均衡:

    一般我们比较常用的负载均衡策略就是轮训,或者说加个权重,这个比较大的问题就是,我们的服务性能并不是每个实例都一样,受到宿主机的型号,当前机器上服务的数量等等因素的影响,并且由于我们的服务是在随时漂移和变化的,所以我们没有办法为每个实例配上合适的权重。

    • 所以我们可以根据一些统计数据,例如 cpu、load 等信息获取当前服务的负载情况,然后根据不同的负载情况进行打分,然后来进行流量的分配,这样就可以将我们的流量比较合理的分配到各个实例上了。
  • 重试:

    重试一定要注意避免雪崩

    • 当我们的服务出现一些错误的时候,我们可以通过重试来解决,例如如果部分实例过载导致请求很慢,我们通过重试,加上面的负载均衡可以将请求发送到正常的实例,这样可以提高我们的 SLA
    • 但是需要的注意的是,重试只能在错误发生的地方进行重试,不能级联重试,级联重试很容易造成雪崩,一般的做法就是约定一个 code 只要出现这个 code 我们就知道下游已经尝试过重试了,我们就不要再重试了
  • 熔断:

    一般来说如果只是部分实例出现了问题,我们通过负载均衡阶段+重试一般就可以解决,但如果服务整体出现了问题,作为客户端就需要使用熔断的措施了。

    • 熔断常见的有开启,关闭,半开启的状态,例如 hystrix-go 的实现,但是这种方式比较死板,只要触发了熔断就一个请求都无法放过,所以就又学习了 Google SRE 的做法,同构计算概率来进行判断,没有了半开启的状态,开启的时候也不会说是一刀切。
  • **降级:**当我们请求一些不那么重要的服务出现错误时,我们可以通过降级的方式来返回请求,降级一般在 BFF 层做,可以有效的防止污染其他服务的缓存。常见的讨论有返回 mock 数据,缓存数据,空数据等

3. Server

BFF 其实也是服务端,但是为了流畅的讲解,主要将其作为了客户端的角色。服务端主要的是限流的措施,当流量从 BFF 来到我们的服务之后,我们会使用令牌桶算法尝试获取 token,如果 token 不够就丢弃,如果 token 足够就完成请求逻辑。

我们的 token 是从哪里来的呢?

拦截器会定时的向 Token Server 上报心跳数据,包含了一些统计信息,同时从 Token Server 获取一定数量的 Token,当 Token Server 接受到请求之后会通过最大最小公平分享的算法,根据每个服务实例上报的统计信息进行 Token 的分配。

这个其实就是之前没有讲到的分布式限流的思路,在单个服务实例上又使用了单机限流的算法