1. 认识故障和弹力设计
- 弹力设计又叫容错设计,着眼于分布式系统的各种‘容忍’能力,包括容错能力(服务隔离、异步调用、请求幂等性)、可伸缩性(有/无状态服务)、一致性(补偿事务、充实)、应对大流量的能力(熔断、降级)
- 故障不可避免、故障是正常且长久的、故障时不可预测突发的
- 故障分类
- 网路问题:网络连接出现问题,网络带宽出现拥塞
- 性能问题:数据库慢sql,Java Full GC,硬盘IO过大,CPU标高,内存不足
- 安全问题:被网络攻击,如DDoS等
- 运维问题:系统总是在被更新和修改,架构调整,监控问题
- 管理问题:没有梳理出关键服务以及服务的依赖关系,运行信息没有控制系统同步
- 硬件问题:硬盘损坏,网卡问题,交换机出问题,机房掉电
2. 隔离设计 Bulkheads
- 为什么要隔离:防止故障扩散,较小故障影响范围
- 系统各服务分离方式
- 按服务的种类来分离
- 优点:
- 服务和数据完全隔离,不会相互影响
- 缺点:
- 如果需要获取多个板块数据,需要调用多个服务,降低性能(响应时间变长);
- 如果有大数据平台,则需将不同服务的数据抽取到一个数仓中进行计算,增加了数据合并的复杂度;
- 业务流程跨服务,其中一个服务故障,也会导致整体业务异常。一方面保证服务的高可用性,另一方面在业务流程上做出step-by-step的方式,用户交互的每一步都可以保存,以便故障恢复后可以继续执行, 而不需要从头执行
- 多个服务中存在分布式事务的问题。
这种系统会引入大量的异步处理模型。
- 优点:
- 按用户的请求来分离(多租户模式)
数据通过租户id逻辑隔离,增加系统复杂度。
- 按服务的种类来分离
3. 异步通讯设计 Asynchronous
- 同步通讯
- 优点:系统间只耦合于接口,复杂度低,实时性高。
- 问题:
- 整个同步调用链路的性能由最慢的服务决定;
- 同步调用调用方需要等待被调用方完成,如果链路很长的长久,所有参与方都会进行等待,很消耗资源;
- 同步调用只能一对一,很难做到一对多。
- 异步通讯:
增加系统吞吐量,并且让服务解耦更加彻底。 - 异步通讯三种方式
- 请求响应式
- 发送方之间请求接收方,接受到请求后直接返回--收到请求,正在处理。
- 返回结果处理
- 发送方轮询
- 接收方处理完成后回调发送方
- 通过订阅方式(观察者模式?)
- 接收方(receiver)会来订阅发送方(sender)的消息,发送方会把相关的消息或数据放到接收方所订阅的队列中,而接收方会从队列中获取数据。
- 通过Broker的方式(消息中间件)
- Broker特性
- 高可用,影响整个系统;
- 高性能且可以水平扩展;
- 持久化保证不丢数据。
- Broker特性
- 请求响应式
- 事件驱动设计
- 上述2、3种方式属于事件驱动架构(EDA-Event Driver Architecture)。事件驱动最好是使用 Broker 方式,服务间通过交换消息来完成交流和整个流程的驱动。
- 好处:
- 服务间解耦,每个服务是高度可重用并可被替换的;
- 服务的开发、测试、运维与故障处理都是高度隔离;
- 服务间通过时间管理,不会相互block;
- 在服务间增加一些Adapter(如日志、认知、版本、限流、降级、熔断等)相当容易;
- 服务间的推土也被解开,各个服务可按照自己的处理速度处理。
- 问题:
- 业务流程不好管理,架构复杂;通过可视化工具来呈现整体业务流程;
- 事件可能乱序。通过状态机来控制;
- 事务处理变得复杂。需要使用两阶段提交来做强一致性,或者做到最终一致性。
- 上述2、3种方式属于事件驱动架构(EDA-Event Driver Architecture)。事件驱动最好是使用 Broker 方式,服务间通过交换消息来完成交流和整个流程的驱动。
4. 幂等性设计 Idempotency
- 含义:一个调用被发送多次所产生的副作用和被发送一次所产生的副作用是一样的。而服务调用有三种结果:成功、失败和超时,其中超时是我们需要解决的问题。
- 全局ID:雪花算法snowflake生成全局id,通过全局id识别为同一条业务数据
- 处理流程
5. 服务的状态 State
- 状态:保留程序的一些数据或者上下文
- 程序调用的结果
- 服务组合下的上下文
- 服务的配置
- 无状态的服务 Stateless Service
- 无状态的服务都被当作分布式服务设计的最佳实践和铁律。扩展性高,维护方便,降低代码复杂度。
- 为了做出无状态的服务,我们通常需要把状态保存到其他的地方。比如,不太重要的数据可以放到 Redis 中,重要的数据可以放到 MySQL 中,或是像 ZooKeeper/Etcd 这样的高可用的强一致性的存储中,或是分布式文件系统中。
- 有状态的服务 Stateful Service
- 好处: 数据本地化、更高的可用性和更强的一致性
6. 补偿事务 Compensating Transaction
- ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation,又称独立性)、持久性(Durability
- BASE:
- Basic Availability:基本可用。这意味着,系统可以出现暂时不可用的状态,而后面会快速恢复。
- Soft-state:软状态。它是我们前面的“有状态”和“无状态”的服务的一种中间状态。也就是说,为了提高性能,我们可以让服务暂时保存一些状态或数据,这些状态和数据不是强一致性的。
- Eventual Consistency:最终一致性,系统在一个短暂的时间段内是不一致的,但最终整个系统看到的数据是一致的。
- 区别:
- AICD保证强一致性,可伸缩性较差
- BASE强调高可用性。如果在短时间内,就算有数据不同步的风险,我们也允许新的交易发生,而在后面我们处理可能出现问题的事务,保证最终一致性。
- 业务补偿:
- 努力的把一个业务流程执完成,如果执行不下去,需要启动补偿机制,回滚业务流程。
- 重点:
- 业务流程中所涉及的服务方需保障幂等性,并且在上游需要有重试机制。
- 需要维护和监控整个过程的状态,不要把这些状态放到不同的组件中,最好是一个业务流程的控制方来做这个事,也就是一个工作流引擎。所以,这个工作流引擎是需要高可用和稳定的。这就好像旅行代理机构一样,我们把需求告诉它,它会帮我们搞定所有的事。如果有问题,也会帮我们回滚和补偿的。
- 设计业务正向流程的时候,也需要设计业务的反向补偿流程。
- 业务补偿的业务逻辑是强业务相关的,很难做成通用的。
- 下层的业务方最好提供短期的资源预留机制。就像电商中的把货品的库存预先占住等待用户在 15 分钟内支付。如果没有收到用户的支付,则释放库存。然后回滚到之前的下单操作,等待用户重新下单。
7. 重试设计 Retry
- 重试: 我们认为这个故障是暂时的,不是永久的,所以进行重试。需要明确什么场景需要\不需要充实。
- 重试场景:调用超时、被调用端返回了某种可以重试的错误(如繁忙中、流控中、维护中、资源不足等)
- 非重试场景:业务级的错误(没有权限、非法参数等)、技术上的错误(HTTP的503、遇到Bug等)
- 重试的策略
- 指数级退避(Exponential BackOff):每一次重试所需要的休息时间会成倍增加。主要是用来让被调用方有更多的时间来从容处理我们的请求。和TCP的拥塞控制相像
- Spring的重试策略(Spring Retry)
@Service public interface MyService { @Retryable( value = { SQLException.class }, maxAttempts = 2, backoff = @Backoff(delay = 5000)) void retryService(String sql) throws SQLException; ... } - 配置 @Retryable 注解,只对 SQLException 的异常进行重试,重试两次,每次延时 5000ms。相关的细节可以看Spring相应的文档。
- 重试设计的重点
- 确定什么样的错误需要重试
- 重试的时间和次数
- 如何知道下游系统已经恢复(熔断)
- 不侵入业务代码(1.通过注解的形式 2. 走Service Mesh)
- 有事务相关的操作,可能需要一个较长的时间来重试,需要把上下文存在本机或数据库中
- 需保证服务的幂等
8. 熔断设计 Circuit Breaker
- 熔断设计的含义:
- 可以防止应用不断尝试执行可能会失败的操作,使得应用程序继续执行而不用等待修正错误,或者浪费CPU时间去等待长时间的超市产生。
- 可以是应用程序能够诊断错误是否已经修正,如果已修正,会再次尝试调用操作
- 熔断器的实现(状态机)
- 闭合(Closed)状态:我们需要一个调用失败的计数器,如果调用失败,则使失败次数加 1。如果最近失败次数超过了在给定时间内允许失败的阈值,则切换到断开 (Open) 状态。此时开启了一个超时时钟,当该时钟超过了该时间,则切换到半断开(Half-Open)状态。该超时时间的设定是给了系统一次机会来修正导致调用失败的错误,以回到正常工作的状态。在 Closed 状态下,错误计数器是基于时间的。在特定的时间间隔内会自动重置。这能够防止由于某次的偶然错误导致熔断器进入断开状态。也可以基于连续失败的次数。
- 断开 (Open) 状态:在该状态下,对应用程序的请求会立即返回错误响应,而不调用后端的服务。这样也许比较粗暴,有些时候,我们可以 cache 住上次成功请求,直接返回缓存(当然,这个缓存放在本地内存就好了),如果没有缓存再返回错误(缓存的机制最好用在全站一样的数据,而不是用在不同的用户间不同的数据,因为后者需要缓存的数据有可能会很多)。
- 半开(Half-Open)状态:允许应用程序一定数量的请求去调用服务。如果这些请求对服务的调用成功,那么可以认为之前导致调用失败的错误已经修正,此时熔断器切换到闭合状态,同时将错误计数器重置。如果这一定数量的请求有调用失败的情况,则认为导致之前调用失败的问题仍然存在,熔断器切回到断开状态,然后重置计时器来给系统一定的时间来修正错误。半断开状态能够有效防止正在恢复中的服务被突然而来的大量请求再次拖垮。
- 熔断设计的重点
- 错误的类型。需要注意的是请求失败的原因会有很多种。你需要根据不同的错误情况来调整相应的策略。所以,熔断和重试一样,需要对返回的错误进行识别。一些错误先走重试的策略(比如限流,或是超时),重试几次后再打开熔断。一些错误是远程服务挂掉,恢复时间比较长;这种错误不必走重试,就可以直接打开熔断策略。
- 日志监控。熔断器应该能够记录所有失败的请求,以及一些可能会尝试成功的请求,使得管理员能够监控使用熔断器保护服务的执行情况。
- 测试服务是否可用。在断开状态下,熔断器可以采用定期地 ping 一下远程服务的健康检查接口,来判断服务是否恢复,而不是使用计时器来自动切换到半开状态。这样做的一个好处是,在服务恢复的情况下,不需要真实的用户流量就可以把状态从半开状态切回关闭状态。否则在半开状态下,即便服务已恢复了,也需要用户真实的请求来恢复,这会影响用户的真实请求。
- 手动重置。在系统中对于失败操作的恢复时间是很难确定的,提供一个手动重置功能能够使得管理员可以手动地强制将熔断器切换到闭合状态。同样的,如果受熔断器保护的服务暂时不可用的话,管理员能够强制将熔断器设置为断开状态。
- 并发问题。相同的熔断器有可能被大量并发请求同时访问。熔断器的实现不应该阻塞并发的请求或者增加每次请求调用的负担。尤其是其中对调用结果的统计,一般来说会成为一个共享的数据结构,它会导致有锁的情况。在这种情况下,最好使用一些无锁的数据结构,或是 atomic 的原子操作。这样会带来更好的性能。
- 资源分区。有时候,我们会把资源分布在不同的分区上。比如,数据库的分库分表,某个分区可能出现问题,而其它分区还可用。在这种情况下,单一的熔断器会把所有的分区访问给混为一谈,从而,一旦开始熔断,那么所有的分区都会受到熔断影响。或是出现一会儿熔断一会儿又好,来来回回的情况。所以,熔断器需要考虑这样的问题,只对有问题的分区进行熔断,而不是整体。
- 重试错误的请求。有时候,错误和请求的数据和参数有关系,所以,记录下出错的请求,在半开状态下重试能够准确地知道服务是否真的恢复。当然,这需要被调用端支持幂等调用,否则会出现一个操作被执行多次的副作用。
9. 限流设计 Throttle
保护系统不会在过载的情况下出现问题
- 设计目的
- 为了向用户承诺 SLA。我们保证我们的系统在某个速度下的响应时间以及可用性。
- 同时,也可以用来阻止在多租户的情况下,某一用户把资源耗尽而让所有的用户都无法访问的问题。
- 为了应对突发的流量。
- 节约成本。我们不会为了一个不常见的尖峰来把我们的系统扩容到最大的尺寸。而是在有限的资源下能够承受比较高的流量。
- 限流策略
- 拒绝服务: 把多出来的请求拒绝掉,可以把不正常或者带有恶意的高并发挡在门外。
- 服务降级: 关闭或是把后端服务做降级处理。这样可以让服务有足够的资源来处理更多的请求。降级有很多方式,一种是把一些不重要的服务给停掉,把 CPU、内存或是数据的资源让给更重要的功能;一种是不再返回全量数据,只返回部分数据。
- 特权请求: 所谓特权请求的意思是,资源不够了,我只能把有限的资源分给重要的用户,比如:分给权利更高的 VIP 用户。在多租户系统下,限流的时候应该保大客户的,所以大客户有特权可以优先处理,而其它的非特权用户就得让路了。
- 延时处理: 在这种情况下,一般会有一个队列来缓冲大量的请求,这个队列如果满了,那么就只能拒绝用户了,如果这个队列中的任务超时了,也要返回系统繁忙的错误了。使用缓冲队列只是为了减缓压力,一般用于应对短暂的峰刺请求。
- 弹性伸缩: 动用自动化运维的方式对相应的服务做自动化的伸缩。这个需要一个应用性能的监控系统,能够感知到目前最繁忙的 TOP 5 的服务是哪几个。然后去伸缩它们,还需要一个自动化的发布、部署和服务注册的运维系统,而且还要快,越快越好。否则,系统会被压死掉了。当然,如果是数据库的压力过大,弹性伸缩应用是没什么用的,这个时候还是应该限流。
限流的实现方式
- 计数器方式
- 维护一个计数器 Counter,当一个请求来时,就做加一操作,当一个请求处理完后就做减一操作。如果这个 Counter 大于某个数了(我们设定的限流阈值),那么就开始拒绝请求以保护系统的负载了。
- 队列算法
- 匀速队列,FIFO算法
- 优先级队列,先处理高优先级的队列,然后再处理低优先级的队列,可能导致低优先级的队列长时间得不到处理。
- 权重队列。有三个队列的权重分布是 3:2:1,这意味着我们需要在权重为 3 的这个队列上处理 3 个请求后,再去权重为 2 的队列上处理 2 个请求,最后再去权重为 1 的队列上处理 1 个请求,如此反复。
- 问题: 以队列的的方式处理请求,如果处理过慢,就会导致队列满,从而触发请求。 通过队列的长度来控制流量,在配置较难操作,如果队列过长,导致后段服务却在队列没有满就挂掉了。
- 匀速队列,FIFO算法
- 漏斗算法
- 在队列请求中加上一个限流器
- 在队列请求中加上一个限流器
- 令牌桶算法Token Bunket
- 主要是有一个中间人。在一个桶内按照一定的速率放入一些 token,然后,处理程序要处理请求时,需要拿到 token,才能处理;如果拿不到,则不处理。
- 漏斗算法中,处理请求是以一个常量和恒定的速度处理的,而令牌桶算法则是在流量小的时候“攒钱”,流量大的时候,可以快速处理。
- 令牌桶还可能做成第三方的一个服务,这样可以在分布式的系统中对全局进行流控
- 主要是有一个中间人。在一个桶内按照一定的速率放入一些 token,然后,处理程序要处理请求时,需要拿到 token,才能处理;如果拿不到,则不处理。
- 设计考量点:
- 架构的早期考虑
- 限流模块性能必须好,而且对流量的变化也是非常灵敏的,否则太过迟钝的限流,系统早因为过载而挂掉了。
- 限流应该有个手动的开关,这样在应急的时候,可以手动操作。
- 当限流发生时,应该有个监控事件通知。让我们知道有限流事件发生,这样,运维人员可以及时跟进。而且还可以自动化触发扩容或降级,以缓解系统压力。
- 当限流发生时,对于拒掉的请求,我们应该返回一个特定的限流错误码。这样,可以和其它错误区分开来。而客户端看到限流,可以调整发送速度,或是走重试机制。
- 限流应该让后端的服务感知到。限流发生时,我们应该在协议头中塞进一个标识,比如 HTTP Header 中,放入一个限流的级别,告诉后端服务目前正在限流中。这样,后端服务可以根据这个标识决定是否做降级。
10. 降级设计 degradation
本质是为了解决资源不足和访问量过大的问题。
- 降级方案(牺牲部分功能):
- 降低一致性:
- 从强一致性变成最终一致性
- 简化流程的一致性:
- 降低数据的一致性: 使用缓存的方式,降低数据库的压力。
- 简化流程的一致性:
- 从强一致性变成最终一致性
- 停止次要的功能:
- 把一些不重要的功能暂时停止,让系统释放出更多的资源。比如,电商中的搜索功能,用户的评论功能等。
- 简化功能:
- API设置两个版本,一个版本返回全量数据,另一个版本只返回部分或最小的可用数据。能释放更多的资源给交易系统这样的需要更多数据库资源的业务使用
- 降低一致性:
- 设计要点:
- 降级一般是要牺牲业务功能或是流程,以及一致性。需要对业务进行非常仔细的流程和分析;
- 清楚的定义好降级的关键条件,比如:吞吐量过大、相应时间过慢、失败次数过多、有网络或是服务障碍等。并做好相应的应急预案;
- 功能降级需要梳理业务的功能,哪些是must-have的功能,哪些是nice-to-have的功能, 哪些是必须死保的功能;
- 降级时牺牲一致性,对于读操作来说,读取缓存中的数据。 对于写操作,需要异步调用来操作,需要通过拉链表记录每一步操作,方便对账。
- 降级功能需支持手动配置。
- 实际应用:
- 功能降级:
- 通过降级开关控制功能可用不可用,一般为页面和按钮
- 简化业务操作流程,当降级后简化业务操作步骤,快速完成业务操作
- 服务降级:
- 读降级,降级前会读缓存,缓存中不存在的话读数据库,降级后读缓存,缓存中不存在的话,返回默认值,不再读数据库
- 写降级,将之前的同步写数据库降级为先写缓存,然后异步写库
- 服务调用降级,之前两个系统模块通过mq来交互,当mq消息积压或mq宕机出问题后,降级为服务直接调用
- 功能降级: