容错设计又叫弹力设计,其中着眼于分布式系统的各种“容忍”能力,包括容错能力(服务 隔离、异步调用、请求幂等性)、可伸缩性(有 / 无状态的服务)、一致性(补偿事务、重 试)、应对大流量的能力(熔断、降级)。可以看到,在确保系统正确性的前提下,系统的可用性是弹力设计保障的重点。
隔离设计
what:
顾名思义,它是指将系统按照一定的原则划分为若干个服务模块,各个模块之间相对独立,无强依赖。当有故障发生时,能将问题和影响隔离在某个模块内部,而不扩散风险,不波及其它模块,不影响整体的系统服务。它是借鉴于造船行业。
why:
任何软件系统,故障是不可避免的,并且大多数还是不可预测的,因此,我们只能在系统的设计之初就充分的考虑好应对措施,如何在故障发生时,去尽最大可能的止损和减少故障范围。
没有人敢说他的系统是百分百可用,我们能做的就是,使用一切方法去减少故障的影响面,尽可能的去提高系统的整体可用率。
而把系统分离成子服务,将子服务进行一定程度隔离的做法,能保证在有不可预测的故障发生时,缩小故障范围的最佳手段。
我们需要定义好隔离业务的大小和粒度,过大和过小都不好。这需要认真地做业务上的需求 和系统分析。
how:
1.按服务/功能做隔离
如果我们要设计个电商平台,可以将其中的用户系统、订单系统、支付系统、仓储系统都分别进行独立隔离,这样做就是从服务层面实现了故障的隔离效果
2.按用户分类隔离:
先部署多套一模一样的业务服务,然后将用户根据一定的特征去做分类,让不同分类的用户去访问不同的业务实例,达到分流和隔离的效果。
3.在代码层面也可以做隔离设计,比如不同业务使用不同的线程池,防止某个业务故障直接影响到其他的业务
异步通讯设计:
what:
通讯一般来说分同步和异步两种。同步通讯就像打电话,需要实时响应,而异步通讯就像发邮 件,不需要马上回复。
why:
1.整个同步调用链的性能会由慢的那个服务所决定
2.同步调用会导致调用方一直在等待被调用方完成,如果一层接一层地同步调用下去,所有的 参与方会有相同的等待时间。这会非常消耗调用方的资源。
3.同步调用不好的是,如果被调用方有问题,那么其调用方就会跟着出问题,会出现多 米诺骨牌效应。
所以,异步通讯相对于同步通讯来说,除了可以增加系统的吞吐量之外,大的一个好处是其可 以让服务间的解耦更为彻底,系统的调用方和被调用方可以按照自己的速率而不是步调一致,各个服务间的性能不受干扰相对独立。从 而可以更好地保护系统,让系统更有弹力。
how:
有几种方式
1.请求响应式 在这种情况下,发送方(sender)会直接请求接收方(receiver),被请求方接收到请求后,直 接返回——收到请求,正在处理。
对于返回结果,有两种方法,一种是发送方时不时地去轮询一下,问一下干没干完。另一种方式 是发送方注册一个回调方法,也就是接收方处理完后回调请求方。这种架构模型在以前的网上支 付中比较常见,页面先从商家跳转到支付宝或银行,商家会把回调的 URL 传给支付页面,支付 完后,再跳转回商家的 URL。
很明显,这种情况下还是有一定耦合的。是发送方依赖于接收方,并且要把自己的回调发送给接 收方,处理完后回调。
2.发布订阅的方式
通过消息中间件(Broker),发送方(sender)和接收方(receiver)都互相看不到对方, 它们看得到的是一个 Broker,发送方向 Broker 发送消息,接收方向 Broker 订阅消息。
在 Broker 这种模式下,发送方的服务和接收方的服务大程度地解耦。但是所有人都依赖于一 个总线,所以这个总线就需要有如下的特性:
必需是高可用的,因为它成了整个系统的关键;
必需是高性能而且是可以水平扩展的;
必需是可以持久化不丢数据的。
要做到这三条还是比较难的,所以一般下游系统会提供查询接口给上游调用。
注意点:
消息顺序很难保证,业务最好设计成不依赖消息顺序的。
消息传递中,可能有的业务逻辑会有像 TCP 协议那样的 send 和 ACK 机制。比如:A 服务 发出一个消息之后,开始等待处理方的 ACK,如果等不到的话,就需要做重传。此时,需要 处理方有幂等的处理,即同一件消息无论收到多少次都只处理一次。
关于幂等性设计,现在我们一般是采用先查询的方式,但是绝大多数请求应该都不会是重新发过来的,所以让 100% 的请求都到这个存储里去查一下,这会导致处理流程可能会很慢。
所以,好是当这个存储出现冲突的时候会报错。也就是说,我们收到交易请求后,直接去存储 里记录这个 ID(相对于数据的 Insert 操作),如果出现 ID 冲突了的异常,那么我们就知道这个之前已经有人发过来了,所以就不用再做了。
补偿事务:
what:
业务补偿主要做两件事。
- 努力地把一个业务流程执行完成。
- 如果执行不下去,需要启动补偿机制,回滚业务流程
why:
在很多情况下,我们是无法做到强一致的ACID 的。特别是我们需要跨多个系统的时候。BASE理论强调软状态与最终一致性,
如果一个事务失败了或是超时了,我们需要 不断地重试,努力地达到最终我们想要的状态。然后,如果我们不能达到这个我们想要的状态, 我们需要把整个状态恢复到之前的状态。另外,如果有变化的请求,我们需要启动整个事务的业务更新机制。
how:
因为要把一个业务流程执行完成,需要这个流程中所涉及的服务方支持幂等性。并且在上游 有重试机制
需要小心维护和监控整个过程的状态
补偿的业务逻辑和流程不一定非得是严格反向操作。有时候可以并行,有时候,可能会更简 单。总之,设计业务正向流程的时候,也需要设计业务的反向补偿流程
业务补偿的业务逻辑是强业务相关的,很难做成通用的
下层的业务方最好提供短期的资源预留机制。就像电商中的把货品的库存预先占住等待用户 在 15 分钟内支付。如果没有收到用户的支付,则释放库存。然后回滚到之前的下单操作,等待用户重新下单。
重试设计:
重试的场景:
调用超时、被调用端返回了某种可以重试的错误(如繁忙中、流控中、维护中、资源不足等)。
而对于一些别的错误,则最好不要重试,比如:业务级的错误(如没有权限、或是非法数据等错 误),技术上的错误(如:HTTP 的 503 等,这种原因可能是触发了代码的 bug,重试下去没 有意义)。
重试的策略:
指数级退避策略。在这种情况下,每一次重试所需要的休息时间都会翻倍增加。这种机制主要是用来让被调用方能够有更多的时间来从容处理我们的请求。类似TCP 的拥塞控制
注意点:
要确定什么样的错误下需要重试;
重试的时间和重试的次数。这种在不同的情况下要有不同的考量。有时候,而对一些不是很 重要的问题时,我们应该更快失败而不是重试一段时间若干次。比如一个前端的交互需要用 到后端的服务。这种情况下,在面对错误的时候,应该快速度失败报错(比如:网络错误请 重试)。而面对其它的一些错误,比如流控,那么应该使用指数退避的方式,以避免造成更 多的流量。
如果超过重试次数,或是一段时间,那么重试就没有意义了
需要考虑被调用方是否有幂等的设计。如果没有,那么重试是不安全的,可能会导致 一个相同的操作被执行多次。
熔断机制:
借鉴于我们电闸上的 " 保险丝 ",当电压有问题时(比如短路),自动跳闸,此时电路 就会断开,我们的电器就会受到保护。
重试机制,如果错误太多, 或是在短时间内得不到修复,那么我们重试也没有意义了,此时应该开启我们的熔断操作,尤其 是后端太忙的时候,使用熔断设计可以保护后端不会过载。
熔断器可以使用状态机来实现,内部模拟以下几种状态。
闭合(Closed)状态:我们需要一个调用失败的计数器,如果调用失败,则使失败次数加 1。如果最近失败次数超过了在给定时间内允许失败的阈值,则切换到断开 (Open) 状态。此 时开启了一个超时时钟,当该时钟超过了该时间,则切换到半断开(Half-Open)状态。该 超时时间的设定是给了系统一次机会来修正导致调用失败的错误,以回到正常工作的状态。
在 Closed 状态下,错误计数器是基于时间的。在特定的时间间隔内会自动重置。这能够防 止由于某次的偶然错误导致熔断器进入断开状态。也可以基于连续失败的次数。
断开 (Open) 状态:在该状态下,对应用程序的请求会立即返回错误响应,而不调用后端的 服务。这样也许比较粗暴,有些时候,我们可以 cache 住上次成功请求,直接返回缓存(当 然,这个缓存放在本地内存就好了),如果没有缓存再返回错误(缓存的机制最好用在全站 一样的数据,而不是用在不同的用户间不同的数据,因为后者需要缓存的数据有可能会很 多)。
半开(Half-Open)状态:允许应用程序一定数量的请求去调用服务。如果这些请求对服务 的调用成功,那么可以认为之前导致调用失败的错误已经修正,此时熔断器切换到闭合状态 (并且将错误计数器重置)。
如果这一定数量的请求有调用失败的情况,则认为导致之前调用失败的问题仍然存在,熔断器切 回到断开状态,然后重置计时器来给系统一定的时间来修正错误。半断开状态能够有效防止正在 恢复中的服务被突然而来的大量请求再次拖垮。
熔断设计的重点:
错误的类型。需要注意的是请求失败的原因会有很多种。需要根据不同的错误情况来调整相 应的策略。所以,熔断和重试一样,需要对返回的错误进行识别。一些错误先走重试的策略 (比如限流,或是超时),重试几次后再打开熔断。一些错误是远程服务挂掉,恢复时间比 较长;这种错误不必走重试,可以直接打开熔断策略。
日志监控。熔断器应该能够记录所有失败的请求,以及一些可能会尝试成功的请求,使得管 理员能够监控使用熔断器保护的服务的执行情况。
测试服务是否可用。在断开状态下,熔断器可以采用定期地 ping 一下远程的服务的健康检查 接口,来判断服务是否恢复,而不是使用计时器来自动切换到半开状态。这样做的一个好处 是,在服务恢复的情况下,不需要真实的用户流量就可以把状态从半开状态切回关闭状态。 否则在半开状态下,即便服务已恢复了,也需要用户真实的请求来恢复,这会影响用户的真 实请求。
手动重置。在系统中对于失败操作的恢复时间是很难确定的,提供一个手动重置功能能够使 得管理员可以手动地强制将熔断器切换到闭合状态。同样的,如果受熔断器保护的服务暂时 不可用的话,管理员能够强制将熔断器设置为断开状态。
并发问题。相同的熔断器有可能被大量并发请求同时访问。熔断器的实现不应该阻塞并发的 请求或者增加每次请求调用的负担。尤其是其中的对调用结果的统计,一般来说会成为一个 共享的数据结构,这个会导致有锁的情况。在这种情况下,最好使用一些无锁的数据结构, 或是 atomic 的原子操作。这样会带来更好的性能。
资源分区。有时候,我们会把资源分布在不同的分区上。比如,数据库的分库分表,某个分 区可能出现问题,而其它分区还可用。在这种情况下,单一的熔断器会把所有的分区访问给 混为一谈,从而,一旦开始熔断,那么所有的分区都会受到熔断影响。或是出现一会儿熔断 一会儿又好,来来回回的情况。所以,熔断器需要考虑这样的问题,只对有问题的分区进行 熔断,而不是整体。
限流设计:
保护系统不会在过载的情况下导致问题,那么,我们就需要限流,熔断机制也是限流的一种。
限流的策略:
-
拒绝服务
-
服务降级
限流的实现方式:
计数器方式
队列算法
漏斗算法
令牌桶算法
降级设计:
本质是为了解决资源不足和访问量过大的问题
降级需要牺牲掉的东西有:
降低一致性。从强一致性变成最终一致性
停止次要功能。停止访问不重要的功能,从而释放出更多的资源。
简化功能。把一些功能简化掉,比如,简化业务流程,或是不再返回全量数据,只返回部分 数据
要点:
对于降级,一般来说是要牺牲业务功能或是流程,以及一致性的。所以,我们需要对业务做非常 仔细的梳理和分析。我们很难通过不侵入业务的方式来做到功能降级。
在设计降级的时候,需要清楚地定义好降级的关键条件,比如,吞吐量过大、响应时间过慢、失 败次数过多,有网络或是服务故障,等等,然后做好相应的应急预案。这些预案最好是写成代码 可以快速地自动化或半自动化执行的。
功能降级需要梳理业务的功能,哪些是 must-have 的功能,哪些是 nice-to-have 的功能;哪 些是必需要死保的功能,哪些是可以牺牲的功能。而且需要在事前设计好可以简化的或是用来应 急的业务流程。当系统出问题的时候,就需要走简化应急流程。
降级的时候,需要牺牲掉一致性,或是一些业务流程:对于读操作来说,使用缓存来解决,对于 写操作来说,需要异步调用来解决。并且,我们需要以流水账的方式记录下来,这样方便对账, 以免漏掉或是和正常的流程混淆。
降级的功能的开关可以是一个系统的配置开关。做成配置时,你需要在要降级的时候推送相应的 配置。另一种方式是,在对外服务的 API 上有所区分(方法签名或是开关参数),这样可以由 上游调用者来驱动。
比如:一个网关在限流时,在协议头中加入了一个限流程度的参数,让后端服务能知道限流在发 生中。当限流程度达到某个值时,或是限流时间超过某个值时,就自动开始降级,直到限流好
转。
对于数据方面的降级,需要前端程序的配合。一般来说,前端的程序可以根据后端传来的数据来 决定展示哪些界面模块。比如,当前端收不到商品评论时,就不展示。为了区分本来就没有数 据,还是因为降级了没有数据的两种情况,在协议头中也应该加上降级的标签。
因为降级的功能平时不会总是会发生,属于应急的情况,所以,降级的这些业务流程和功能有可 能长期不用而出现 bug 或问题,对此,需要在平时做一些演练。