Spiga

分布式架构之弹力设计

2025-08-24 23:12:10

对于分布式系统的容错设计,在英文中又叫 Resiliency(弹力)。意思是,系统在不健康、不顺,甚至出错的情况下有能力 hold 得住,挺得住,还有能在这种逆境下力挽狂澜的能力。其中着眼于分布式系统的各种“容忍”能力,包括服务隔离、异步调用、请求幂等性、可伸缩性(有 / 无状态的服务)、一致性(补偿事务、重试)、应对大流量的能力(熔断、降级)。可以看到,在确保系统正确性的前提下,系统的可用性是弹力设计保障的重点。

我们很难计算我们设计的系统有多少的可用性,因为影响一个系统的因素实在是太多了,除了软件设计,还有硬件,还有第三方服务(如电信联通的宽带 SLA),当然包括“建筑施工队的挖掘机”,宕机原因主要有以下这些:

  • 网络问题。网络链接出现问题,网络带宽出现拥塞……
  • 性能问题。数据库慢 SQL、Java Full GC、硬盘 IO 过大、CPU 飙高、内存不足……
  • 安全问题。被网络攻击,如 DDoS 等。
  • 运维问题。系统总是在被更新和修改,架构也在不断地被调整,监控问题……
  • 管理问题。没有梳理出关键服务以及服务的依赖关系,运行信息没有和控制系统同步……
  • 硬件问题。硬盘损坏、网卡出问题、交换机出问题、机房掉电、挖掘机问题……

一个分布式系统的故障是非常复杂的,因为故障是分布式的、多米诺骨牌式的。所以,要充分地意识到下面两个事。

  • 故障是正常的,而且是常见的。
  • 故障是不可预测突发的,而且相当难缠。

这就是为什么我们把这个设计叫做弹力(Resiliency)。

  • 一方面,在好的情况下,这个事对于我们的用户和内部运维来说是完全透明的,系统自动修复不需要人的干预。
  • 另一方面,如果修复不了,系统能够做自我保护,而不让事态变糟糕。

一、隔离设计

隔离设计对应的单词是 Bulkheads,中文翻译为隔板,这个概念来自于船舱里防漏水的隔板。我们的软件设计当然也“漏水”,所以为了不让“故障”蔓延开来,需要使用“隔板”技术,来将架构分隔成多个“船舱”来隔离故障。

1. 按服务的种类来做分离

上图中,我们将系统分成了用户、商品、社区三个板块。这三个块分别使用不同的域名、服务器和数据库,做到从接入层到应用层再到数据层三层完全隔离。这样一来,在物理上来说,一个板块的故障就不会影响到另一板块。

上面这种架构虽然在系统隔离上做得比较好,但是也存在以下一些问题。

  • 如果我们需要同时获得多个板块的数据,那么就需要调用多个服务,这会降低性能。这里性能降低指的是响应时间,而不是吞吐量(相反,在这种架构下,吞吐量可以得到提高)。

    对于这样的问题,一般来说,我们需要小心地设计用户交互,最好不要让用户在一个页面上获得所有的数据。必要的时候还可以使用数据冗余来减少跨服务调用。

  • 如果有大数据平台,就需要把这些数据都抽取到一个数据仓库中进行计算,这也增加了数据合并的复杂度。

    对于这个问题,我们需要一个框架或是一个中间件来对数据进行相应的抽取。

  • 如果我们的业务逻辑或是业务流程需要跨板块的话,那么一个板块的故障也会导致整个流程走不下去,同样会导致整体业务故障。

    对于这个问题,我们需要保证这个业务流程中各个子系统的高可用性,并且在业务流程上做成 Step-by-Step 的方式,这样用户交互的每一步都可以保存,以便故障恢复后可以继续执行,而不是从头执行。

  • 如果需要有跨板块的交互也会变得有点复杂。

    我们需要一个类似于 Pub/Sub 的高可用、且可以持久化的消息订阅通知中间件来打通各个板块的数据和信息交换。

  • 最后还会有在多个板块中分布式事务的问题。

    对此,我们需要“二阶段提交”这样的方案,或者其他分布式一致性的实现技术。

2. 按用户的请求来做分离

在上图中,我们将用户分成不同的组,并把后端的同一个服务根据这些不同的组分成不同的实例。让同一个服务对于不同的用户进行冗余和隔离,这样一来,当服务实例挂掉时,只会影响其中一部分用户,而不会导致所有的用户无法访问。

这就是所谓的“多租户”模式。对于一些比较大的客户,我们可以为他们设置专门独立的服务实例,或是服务集群与其他客户隔离开来,对于一些比较小的用户来说,可以让他们共享一个服务实例,这样可以节省相关的资源。

通常来说多租户的做法有三种。

  • 完全独立的设计。每个租户有自己完全独立的服务和数据。独立的数据分区,共享的服务。
  • 多租户的服务是共享的,但数据是分开隔离的。
  • 共享的服务,共享的数据分区。每个租户的数据和服务都是共享的。

这三种方案各有优缺点,如图所示。

  • 如果使用完全独立的方案,在开发实现上和资源隔离度方面会非常好,然而,成本会比较高,计算资源也会有一定的浪费。
  • 如果使用完全共享的方案,在资源利用和成本上会非常好,然而,开发难度非常大,而且数据和资源隔离非常不好。

一般来说,技术方案会使用折中方案,也就是中间方案,服务是共享的,数据通过分区来隔离,而对于一些比较重要的租户(需要好的隔离性),则使用完全独立的方式。

然而,在虚拟化技术非常成熟的今天,我们完全可以使用“完全独立”(完全隔离)的方案,通过底层的虚拟化技术(如 Docker)来实现物理资源的共享和成本的节约。

3. 隔离设计的重点

  • 需要定义好隔离业务的大小和粒度,过大和过小都不好。这需要认真地做业务上的需求和系统分析。
  • 无论是做系统板块还是多租户的隔离,都需要考虑系统的复杂度、成本、性能、资源使用的问题,找到一个合适的均衡方案,或是分布实施的方案尤其重要,这其中需要定义好要什么和不要什么。因为,我们不可能做出一个什么都能满足的系统。
  • 隔离模式需要配置一些高可用、重试、异步、消息中间件,流控、熔断等设计模式的方式配套使用。
  • 不要忘记了分布式系统中的运维的复杂度的提升,要能驾驭得好的话,还需要很多自动化运维的工具,尤其是使用像容器或是虚拟机这样的虚拟化技术可以帮助我们更方便地管理,和对比资源更好地利用。否则做出来了也管理不好。
  • 最后,需要一个非常完整的能够看得到所有服务的监控系统,这点非常重要。

二、异步通讯设计

隔离设计通常都需要对系统做解耦设计,而把一个单体系统解耦,不单单是把业务功能拆分出来,拆分完后还会面对很多的问题。其中一个重要的问题就是这些系统间的通讯。

1. 同步和异步通信

通讯一般来说分同步和异步两种。

同步调用会带来如下几个问题:

  • 同步调用需要被调用方的吞吐不低于调用方的吞吐。否则会导致被调用方因为性能不足而拖死调用方。换句话说,整个同步调用链的性能会由最慢的那个服务所决定。
  • 同步调用会导致调用方一直在等待被调用方完成,如果一层接一层地同步调用下去,所有的参与方会有相同的等待时间。这会非常消耗调用方的资源。因为调用方需要保存现场(Context)等待远端返回,所以对于并发比较高的场景来说,这样的等待可能会极度消耗资源。
  • 同步调用只能是一对一的,很难做到一对多。
  • 同步调用最不好的是,如果被调用方有问题,那么其调用方就会跟着出问题,于是会出现多米诺骨牌效应,故障一下就蔓延开来。

异步通讯相对于同步通讯来说,除了可以增加系统的吞吐量之外,最大的一个好处是其可以让服务间的解耦更为彻底,系统的调用方和被调用方可以按照自己的速率而不是步调一致,从而可以更好地保护系统,让系统更有弹力。

2. 异步通信的三种方式

  • 请求响应式

    • 发送方(sender)会直接请求接收方(receiver),被请求方接收到请求后,直接返回——收到请求,正在处理。
    • 对于返回结果,有两种方法
      • 一种是发送方时不时地去轮询一下,问一下干没干完。
      • 另一种方式是发送方注册一个回调方法,也就是接收方处理完后回调请求方。
  • 通过订阅的方式

    • 接收方(receiver)会来订阅发送方(sender)的消息,发送方会把相关的消息或数据放到接收方所订阅的队列中,而接收方会从队列中获取数据。
    • 发送方并不关心订阅方的处理结果,它只是告诉订阅方有事要干,收完消息后给个 ACK 就好了,你干成啥样我不关心。

    为什么要做成这样?好了,重点来了!

    • 请求响应的方式就像函数调用一样,这种方式有数据有状态的往来(也就是说需要有请求数据、返回数据,服务里面还可能需要保存调用的状态),所以服务是有状态的。如果我们把服务的状态给去掉(通过第三方的状态服务来保证),那么服务间的依赖就只有事件了。
    • 分布式系统的服务设计是需要向无状态服务(Stateless)努力的,这其中有太多的好处,无状态意味着你可以非常方便地运维。所以,事件通讯成为了异步通讯中最重要的一个设计模式。

    但是,在这种方式下,接收方需要向发送方订阅事件,所以是接收方依赖于发送方。这种方式还是有一定的耦合。

  • 通过 Broker 的方式

    所谓 Broker,就是一个中间人,发送方(sender)和接收方(receiver)都互相看不到对方,它们看得到的是一个 Broker,发送方向 Broker 发送消息,接收方向 Broker 订阅消息。如下图所示。

    这是完全的解耦。所有的服务都不需要相互依赖,而是依赖于一个中间件 Broker。这个 Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。

    在 Broker 这种模式下,发送方的服务和接收方的服务最大程度地解耦。但是所有人都依赖于一个总线,所以这个总线就需要有如下的特性:

    • 必须是高可用的,因为它成了整个系统的关键;
    • 必须是高性能而且是可以水平扩展的;
    • 必须是可以持久化不丢数据的。

    现在开源软件或云平台上 Broker 的软件是非常成熟的,所以节省了我们很多的精力。

3. 事件驱动设计

通过订阅的方式和通过 Broker 的方式的异步通信就是比较著名的事件驱动架构(EDA – Event Driven Architecture)。事件驱动最好是使用 Broker 方式,服务间通过交换消息来完成交流和整个流程的驱动。

一个订单处理流程。下单服务通知订单服务有订单要处理,而订单服务生成订单后发出通知,库存服务和支付服务得到通知后,一边是占住库存,另一边是让用户支付,等待用户支付完成后通知配送服务进行商品配送。

每个服务都是“自包含”的。所谓“自包含”也就是没有和别人产生依赖。而要把整个流程给串联起来,我们需要一系列的“消息通道(Channel)”。各个服务做完自己的事后,发出相应的事件,而又有一些服务在订阅着某些事件来联动。

事件驱动方式的好处至少有五个。

  • 服务间的依赖没有了,服务间是平等的,每个服务都是高度可重用并可被替换的。
  • 服务的开发、测试、运维,以及故障处理都是高度隔离的。
  • 服务间通过事件关联,所以服务间是不会相互 block 的。
  • 在服务间增加一些 Adapter(如日志、认证、版本、限流、降级、熔断等)相当容易。
  • 服务间的吞吐也被解开了,各个服务可以按照自己的处理速度处理。

任何设计都有好有不好的方式,事件驱动的架构也会有一些不好的地方。

  • 业务流程不再那么明显和好管理。整个架构变得比较复杂。解决这个问题需要有一些可视化的工具来呈现整体业务流程。
  • 事件可能会乱序。这会带来非常 Bug 的事。解决这个问题需要很好地管理一个状态机的控制。
  • 事务处理变得复杂。需要使用两阶段提交来做强一致性,或是退缩到最终一致性。

4. 异步通讯的设计重点

为什么要异步通讯:

  • 异步通讯最重要的是解耦服务间的依赖。最佳解耦的方式是通过 Broker 的机制。
  • 解耦的目的是让各个服务的隔离性更好,这样不会出现“一倒倒一片”的故障。
  • 异步通讯的架构可以获得更大的吞吐量,而且各个服务间的性能不受干扰相对独立。
  • 利用 Broker 或队列的方式还可以达到把抖动的吞吐量变成均匀的吞吐量,这就是所谓的“削峰”,这对后端系统是个不错的保护。
  • 服务相对独立,在部署、扩容和运维上都可以做到独立不受其他服务的干扰。

设计异步通信的时候需要注意如下事宜:

  • 用于异步通讯的中间件 Broker 成为了关键,需要设计成高可用不丢消息的。另外,因为是分布式的,所以可能很难保证消息的顺序,因此设计最好不依赖于消息的顺序。

  • 异步通讯会导致业务处理流程不那么直观,因为像接力一样,所以在 Broker 上需要有相关的服务消息跟踪机制,否则出现问题后不容易调试。

  • 因为服务间只通过消息交互,所以业务状态最好由一个总控方来管理,这个总控方维护一个业务流程的状态变迁逻辑,以便系统发生故障后知道业务处理到了哪一步,从而可以在故障清除后继续处理。

    这样的设计常见于银行的对账程序,银行系统会有大量的外部系统通讯,比如跨行的交易、跨企业的交易,等等。所以,为了保证整体数据的一致性,或是避免漏处理及处理错的交易,需要有对账系统,这其实就是那个总控,这也是为什么银行有的交易是 T+1(隔天结算),就是因为要对个账,确保数据是对的。

  • 消息传递中,可能有的业务逻辑会有像 TCP 协议那样的 send 和 ACK 机制。比如:A 服务发出一个消息之后,开始等待处理方的 ACK,如果等不到的话,就需要做重传。此时,需要处理方有幂等的处理,即同一条消息无论收到多少次都只处理一次。

三、幂等性设计

所谓幂等性设计,就是说,一次和多次请求某一个资源应该具有同样的副作用。

为什么需要这样的操作?说白了,就是在我们把系统解耦隔离后,服务间的调用可能会有三个状态,一个是成功(Success),一个是失败(Failed),一个是超时(Timeout)。前两者都是明确的状态,而超时则是完全不知道是什么状态。

比如,超时原因是网络传输丢包的问题,可能是请求时就没有请求到,也有可能是请求到了,返回结果时没有正常返回等等情况。于是我们完全不知道下游系统是否收到了请求,而收到了请求是否处理了,成功 / 失败的状态在返回时是否遇到了网络问题。总之,请求方完全不知道是怎么回事。

在这种情况下,一般有两种处理方式。

  • 一种是需要下游系统提供相应的查询接口。上游系统在 timeout 后去查询一下。如果查到了,就表明已经做了,成功了就不用做了,失败了就走失败流程。
  • 另一种是通过幂等性的方式。也就是说,把这个查询操作交给下游系统,我上游系统只管重试,下游系统保证一次和多次的请求结果是一样的。

1. 全局 ID

UUID 的问题

  • 字符串占用的空间比较大
  • 索引的效率非常低
  • 生成的 ID 太过于随机,完全不是人读的
  • 没有递增,如果要按前后顺序排序的话,基本不可能

全局唯一 ID 的算法中,Twitter 的开源项目 Snowflake。它是一个分布式 ID 的生成算法。它的核心思想是,产生一个 long 型的 ID,其中:

  • 41bits 作为毫秒数。大概可以用 69.7 年。
  • 10bits 作为机器编号(5bits 是数据中心,5bits 的机器 ID),支持 1024 个实例。
  • 12bits 作为毫秒内的序列号。一毫秒可以生成 4096 个序号。

其他的像 Redis 或 MongoDB 的全局 ID 生成都和这个算法大同小异。我们可以根据实际情况加上业务的编号。

2. 处理流程

对于幂等性的处理流程来说,说白了就是要过滤一下已经收到的交易。当收到请求的时候,我们就会到这个存储中去查询。如果查找到了,那么就不再做查询了,并把上次做的结果返回。如果没有查到,那么我们就记录下来。

这个流程有个问题。因为绝大多数请求应该都不会是重新发过来的,所以让 100% 的请求都到这个存储里去查一下,这会导致处理流程变得很慢。

所以,最好是当这个存储出现冲突的时候会报错。也就是说,我们收到交易请求后,直接去存储里记录这个 ID,如果出现 ID 冲突的异常,我们就知道这个之前已经有人发过来了,就不用再做了。比如:

insert into … values … on DUPLICATE KEY UPDATE …

如果是更新,那就带上状态。如果已经更新过了,状态就已经发生变化了,更新也就不会成功。比如:

update table set status = “paid” where id = xxx and status = “unpaid”;

3. HTTP 的幂等性

  • HTTP GET 方法用于获取资源,不应有副作用,所以是幂等的。
  • HTTP HEAD 和 GET 本质是一样的,区别在于 HEAD 不含有呈现数据,而仅仅是 HTTP 头信息,不应有副作用,也是幂等的。
  • HTTP OPTIONS 主要用于获取当前 URL 所支持的方法,所以也是幂等的。
  • HTTP DELETE 方法用于删除资源,有副作用,但它应该满足幂等性。
  • HTTP POST 方法用于创建资源,所对应的 URI 并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。
  • HTTP PUT 方法用于创建或更新操作,所对应的 URI 是要创建或更新的资源本身,有副作用,它应该满足幂等性。

所以,对于 POST 的方式,很可能会出现多次提交的问题,对此,一般的幂等性的设计如下:

  • 首先,在表单中需要隐藏一个 token,这个 token 可以是前端生成的一个唯一的 ID。用于防止用户多次点击了表单提交按钮,而导致后端收到了多次请求,却不能分辨是否是重复的提交。这个 token 是表单的唯一标识。(这种情况其实是通过前端生成 ID 把 POST 变成了 PUT。)
  • 然后,当用户点击提交后,后端会把用户提交的数据和这个 token 保存在数据库中。如果有重复提交,那么数据库中的 token 会做排它限制,从而做到幂等性。
  • 当然,更为稳妥的做法是,后端成功后向前端返回 302 跳转,把用户的前端页跳转到 GET 请求,把刚刚 POST 的数据给展示出来。如果是 Web 上的最好还把之前的表单设置成过期,这样用户不能通过浏览器后退按钮来重新提交。这个模式又叫做 PRG 模式(Post/Redirect/Get)。

四、服务的状态

1. 无状态的服务 Stateless

一直以来,无状态的服务都被当作分布式服务设计的最佳实践和铁律。因为无状态的服务对于扩展性和运维实在是太方便了。没有状态的服务,可以随意地增加和减少节点,同样可以随意地搬迁。

基本上来说,无状态的服务和“函数式编程”的思维方式如出一辙。在函数式编程中,一个铁律是,函数是无状态的。换句话说,函数是 immutable 不变的,所有的函数只描述其逻辑和算法,根本不保存数据,也不会修改输入的数据,而是把计算好的结果返回出去,哪怕要把输入的数据重新拷贝一份并只做少量的修改。

但是,现实世界是一定会有状态的。这些状态可能表现在如下的几个方面。

  • 程序调用的结果。
  • 服务组合下的上下文。
  • 服务的配置。

为了做出无状态的服务,我们通常需要把状态保存到其他的地方。比如,不太重要的数据可以放到 Redis 中,重要的数据可以放到 MySQL 中。

这会导致这些服务需要耦合第三方有状态的存储服务。一方面是有依赖,另一方面也增加了网络开销,导致服务的响应时间也会变慢。

所以,第三方的这些存储服务也必须要做成高可用高扩展的方式。而且,为了减少网络开销,还需要在无状态的服务中增加缓存机制。然而,下次这个用户的请求并不一定会在同一台机器,所以,这个缓存会在所有的机器上都创建,也算是一种浪费吧。

2. 有状态的服务 Stateful

有状态的服务有这些好处:

  • 数据本地化(Data Locality)。一方面状态和数据是本机保存,这方面不但有更低的延时,而且对于数据密集型的应用来说,这会更快。
  • 更高的可用性和更强的一致性。也就是 CAP 原理中的 A 和 C。

对于有状态的服务,我们需要对于客户端传来的请求,都必须保证其落在同一个实例上,这叫 Sticky Session 或是 Sticky Connection。这样一来,我们完全不需要考虑数据要被加载到不同的节点上去,而且这样的模型更容易理解和实现。

无状态的服务需要我们把数据同步到不同的节点上,而有状态的服务通过 Sticky Session 做数据分片(当然,同步有同步的问题,分片也有分片的问题,这两者没有谁比谁好)。

Sticky Session 是怎么实现的呢?

最简单的实现就是用持久化的长连接。就算是 HTTP 协议也要用长连接。或是通过一个简单的哈希(hash)算法,比如,通过 uid 求模的方式,走一致性哈希的玩法,也可以方便地做水平扩展。

这种方式也会带来问题,那就是,节点的负载和数据并不会很均匀。尤其是长连接的方式,连上了就不断了。所以,玩长连接的玩法一般都会有一种叫“反向压力 (Back Pressure)”。也就是说,如果服务端成为了热点,那么就主动断连接,这种玩法也比较危险,需要客户端的配合,否则容易出 Bug。

如果要做到负载和数据均匀的话,我们需要有一个元数据索引来映射后端服务实例和请求的对应关系,还需要一个路由节点,这个路由节点会根据元数据索引来路由,而这个元数据索引表会根据后端服务的压力来重新组织相关的映射。

我们也可以把这个路由节点给去掉,让有状态的服务直接路由。要做到这点,一般来说,有两种方式。一种是直接使用配置,在节点启动时把其元数据读到内存中,但是这样一来增加或减少节点都需要更新这个配置,会导致其它节点也一同要重新读入。

另一种比较好的做法是使用到 Gossip 协议,通过这个协议在各个节点之间互相散播消息来同步元数据,这样新增或减少节点,集群内部可以很容易重新分配(听起来好,实现很复杂!)。

3. 服务状态的容错设计

在容错设计中,服务状态是一件非常复杂的事。尤其对于运维来说,调度服务就需要调度服务的状态,迁移服务的状态就需要迁移服务的数据。

很多系统的高可用的设计都会采取数据在运行时就复制的方案,比如:Kafka、Redis 或是 ElasticSearch 等等。在运行时进行数据复制就需要考虑一致性的问题,所以,强一致性的系统一般会使用两阶段提交。

这要求所有的节点都需要有一致的结果,这是 CAP 里的 CA 系统。而有的系统采用的是大多数人一致就可以了,比如 Paxos 算法,这是 CP 系统。

五、补偿事务

1. ACID和BASE

传统关系型数据库系统的事务都有 ACID 属性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation,又称独立性)、持久性(Durability)

  • 原子性:整个事务中的所有操作,要么全部完成,要么全部失败,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性:在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
  • 隔离性:两个事务的执行是互不干扰的,一个事务不可能看到其他事务运行时中间某一时刻的数据。两个事务不会发生交互。
  • 持久性:在事务完成以后,该事务对数据库所做的更改便持久地保存在数据库之中,并不会被回滚。

事务的 ACID 属性保证了数据库的一致性,然而,这对于分布式系统来说,尤其是微服务来说,这样的方式是很难满足高性能要求的。

所以,为了提高性能,出现了 ACID 的一个变种 BASE。

  • Basic Availability:基本可用。这意味着,系统可以出现暂时不可用的状态,而后面会快速恢复。
  • Soft-state:软状态。它是我们前面的“有状态”和“无状态”的服务的一种中间状态。也就是说,为了提高性能,我们可以让服务暂时保存一些状态或数据,这些状态和数据不是强一致性的。
  • Eventual Consistency:最终一致性,系统在一个短暂的时间段内是不一致的,但最终整个系统看到的数据是一致的。

BASE 系统是允许或是容忍系统出现暂时性问题的,这样一来,我们的系统就能更有弹力。在分布式系统的世界里,故障是不可避免的,我们能做的就是把故障处理当成功能写入代码中。

BASE 的系统倾向于设计出更加有弹力的系统,这种系统的设计特点是,要保证在短时间内,就算是有数据不同步的风险,我们也应该允许新的交易可以发生,而后面我们在业务上将可能出现问题的事务给处理掉,以保证最终的一致性。

有趣的是,ACID 和 BASE 是一个对立的东西。从本质上来讲,ACID强调的是一致性(CAP 中的 C),而BASE强调的是可用性(CAP 中的 A)。

2. 业务补偿

在很多情况下,我们是无法做到强一致的 ACID 的。特别是需要跨多个系统的时候。比如,在我们的日常生活中,我们经常会遇到这样的情况,就是要找很多方协调很多事,而且要保证我们每一件事都成功,否则整件事就做不到。

分布式系统中,当条件不满足,或是有变化的时候,需要从业务上做相应的整体事务的补偿。

一般来说,业务的事务补偿都是需要一个工作流引擎的。这个工作流引擎把各式各样的服务给串联在一起,并在工作流上做相应的业务补偿,整个过程设计成为最终一致性的。

对于业务补偿来说,首先需要将服务做成幂等性的,如果一个事务失败了或是超时了,我们需要不断地重试,努力地达到最终我们想要的状态。然后,如果我们不能达到这个我们想要的状态,我们需要把整个状态恢复到之前的状态。另外,如果有变化的请求,我们需要启动整个事务的业务更新机制。

所以,一个好的业务补偿机制需要做到下面这几点。

  • 要能清楚地描述出要达到什么样的状态(比如:请假、机票、酒店这三个都必须成功,租车是可选的),以及如果其中的条件不满足,那么,我们要回退到哪一个状态。这就是所谓的整个业务的起始状态定义。
  • 当整条业务跑起来的时候,我们可以串行或并行地做这些事。对于旅游订票是可以并行的,但是对于网购流程(下单、支付、送货)是不能并行的。总之,我们的系统需要努力地通过一系列的操作达到一个我们想要的状态。如果达不到,就需要通过补偿机制回滚到之前的状态。这就是所谓的状态拟合。
  • 对于已经完成的事务进行整体修改,可以考虑成一个修改事务。

3. 业务补偿的设计重点

业务补偿主要做两件事。

  • 努力地把一个业务流程执行完成。
  • 如果执行不下去,需要启动补偿机制,回滚业务流程。

所以,下面是几个重点。

  • 因为要把一个业务流程执行完成,需要这个流程中所涉及的服务方支持幂等性。并且在上游有重试机制。
  • 需要小心维护和监控整个过程的状态,所以,千万不要把这些状态放到不同的组件中,最好是一个业务流程的控制方来做这个事,也就是一个工作流引擎。所以,这个工作流引擎是需要高可用和稳定的。这就好像旅行代理机构一样,我们把需求告诉它,它会帮我们搞定所有的事。如果有问题,也会帮我们回滚和补偿的。
  • 补偿的业务逻辑和流程不一定非得是严格反向操作。有时候可以并行,有时候可以串行,可能会更简单。总之,设计业务正向流程的时候,也需要设计业务的反向补偿流程。
  • 要清楚地知道,业务补偿的业务逻辑是强业务相关的,很难做成通用的。
  • 下层的业务方最好提供短期的资源预留机制。就像电商中的把货品的库存预先占住等待用户在 15 分钟内支付。如果没有收到用户的支付,则释放库存。然后回滚到之前的下单操作,等待用户重新下单。

六、重试设计

1. 重试的场景

关于重试,这个模式应该是一个很普遍的设计模式了。当我们把单体应用服务化,尤其是微服务化,本来在一个进程内的函数调用就成了远程调用,这样就会涉及到网络上的问题,而网络是不稳定的。

所以,我们需要一个重试的机制。但是,我们需要明白的是,“重试”的语义是我们认为这个故障是暂时的,而不是永久的,所以,我们会去重试。

设计重试时,我们需要定义出什么情况下需要重试,例如,调用超时、被调用端返回了某种可以重试的错误(如繁忙中、流控中、维护中、资源不足等)。

而对于一些别的错误,则最好不要重试,比如:业务级的错误(如没有权限、或是非法数据等错误),技术上的错误(如:500错误)。

2. 重试的策略

  • 关于重试的设计,一般来说,都需要有个重试的最大值,经过一段时间不断的重试后,就没有必要再重试了。
  • 在重试的设计中,我们一般都会引入,Exponential Backoff 的策略,也就是所谓的“指数级退避”。在这种情况下,每一次重试所需要的休息时间都会成倍增加。这种机制主要是用来让被调用方能够有更多的时间来从容处理我们的请求。

3. 重试设计的重点

  • 要确定什么样的错误下需要重试;

  • 重试的时间和重试的次数。

    这种在不同的情况下要有不同的考量。有时候,面对一些不是很重要的问题时,我们应该更快失败而不是重试一段时间若干次。比如一个前端的交互需要用到后端的服务。这种情况下,在面对错误的时候,应该快速失败报错(比如:网络错误请重试)。

    而面对如流控,那么应该使用指数退避的方式,以避免造成更多的流量。如果超过重试次数,或是一段时间,那么重试就没有意义了,这个时候对新的请求直接返回错误就好了。但是,这样一来,如果后端恢复了,我们怎么知道呢,此时需要使用我们的熔断设计了。

  • 重试还需要考虑被调用方是否有幂等的设计。

  • 重试的代码比较简单也比较通用,完全可以不用侵入到业务代码中。

  • 对于有事务相关的操作。我们可能会希望能重试成功,而不至于走业务补偿那样的复杂的回退流程。对此,我们可能需要一个比较长的时间来做重试,但是我们需要保存请求的上下文,这可能对程序的运行有比较大的开销,因此,有一些设计会先把这样的上下文暂存在本机或是数据库中,然后腾出资源来做别的事,过一会再回来把之前的请求从存储中捞出来重试。

七、熔断设计

熔断机制这个词的灵感来源于我们电闸上的“保险丝”,当电压有问题时,自动跳闸,此时电路就会断开,我们的电器就会受到保护。

在分布式系统设计中,也应该有这样的方式。前面说过重试机制,如果错误太多,或是在短时间内得不到修复,那么重试也没有意义了,此时应该开启熔断操作,尤其是后端太忙的时候,使用熔断设计可以保护后端不会过载。

1. 熔断设计

熔断器可以使用状态机来实现,内部模拟以下几种状态。

  • 闭合(Closed)状态:需要一个调用失败的计数器,如果调用失败,则使失败次数加 1。如果最近失败次数超过了在给定时间内允许失败的阈值,则切换到断开 (Open) 状态。此时开启了一个超时时钟,当该时钟超过了该时间,则切换到半断开(Half-Open)状态。该超时时间的设定是给了系统一次机会来修正导致调用失败的错误,以回到正常工作的状态。在 Closed 状态下,错误计数器是基于时间的。在特定的时间间隔内会自动重置。这能够防止由于某次的偶然错误导致熔断器进入断开状态。也可以基于连续失败的次数。
  • 断开 (Open) 状态:在该状态下,对应用程序的请求会立即返回错误响应,而不调用后端的服务。这样也许比较粗暴,有些时候,可以 cache 住上次成功请求,直接返回缓存(当然,这个缓存放在本地内存就好了),如果没有缓存再返回错误(缓存的机制最好用在全站一样的数据,而不是用在不同的用户间不同的数据,因为后者需要缓存的数据有可能会很多)。
  • 半开(Half-Open)状态:允许应用程序一定数量的请求去调用服务。如果这些请求对服务的调用成功,那么可以认为之前导致调用失败的错误已经修正,此时熔断器切换到闭合状态,同时将错误计数器重置。如果这一定数量的请求有调用失败的情况,则认为导致之前调用失败的问题仍然存在,熔断器切回到断开状态,然后重置计时器来给系统一定的时间来修正错误。半断开状态能够有效防止正在恢复中的服务被突然而来的大量请求再次拖垮。

2. 熔断设计的重点

  • 错误的类型。需要注意的是请求失败的原因会有很多种。需要根据不同的错误情况来调整相应的策略。所以,熔断和重试一样,需要对返回的错误进行识别。一些错误先走重试的策略(比如限流,或是超时),重试几次后再打开熔断。一些错误是远程服务挂掉,恢复时间比较长;这种错误不必走重试,就可以直接打开熔断策略。
  • 日志监控。熔断器应该能够记录所有失败的请求,以及一些可能会尝试成功的请求,使得管理员能够监控使用熔断器保护服务的执行情况。
  • 测试服务是否可用。在断开状态下,熔断器可以采用定期地 ping 一下远程服务的健康检查接口,来判断服务是否恢复,而不是使用计时器来自动切换到半开状态。这样做的一个好处是,在服务恢复的情况下,不需要真实的用户流量就可以把状态从半开状态切回关闭状态。否则在半开状态下,即便服务已恢复了,也需要用户真实的请求来恢复,这会影响用户的真实请求。
  • 手动重置。在系统中对于失败操作的恢复时间是很难确定的,提供一个手动重置功能能够使得管理员可以手动地强制将熔断器切换到闭合状态。同样的,如果受熔断器保护的服务暂时不可用的话,管理员能够强制将熔断器设置为断开状态。
  • 并发问题。相同的熔断器有可能被大量并发请求同时访问。熔断器的实现不应该阻塞并发的请求或者增加每次请求调用的负担。尤其是其中对调用结果的统计,一般来说会成为一个共享的数据结构,它会导致有锁的情况。在这种情况下,最好使用一些无锁的数据结构或原子操作。这样会带来更好的性能。
  • 资源分区。有时候,我们会把资源分布在不同的分区上。比如,数据库的分库分表,某个分区可能出现问题,而其它分区还可用。在这种情况下,单一的熔断器会把所有的分区访问给混为一谈,从而,一旦开始熔断,那么所有的分区都会受到熔断影响。或是出现一会儿熔断一会儿又好,来来回回的情况。所以,熔断器需要考虑这样的问题,只对有问题的分区进行熔断,而不是整体。
  • 重试错误的请求。有时候,错误和请求的数据和参数有关系,所以,记录下出错的请求,在半开状态下重试能够准确地知道服务是否真的恢复。当然,这需要被调用端支持幂等调用,否则会出现一个操作被执行多次的副作用。

八、限流设计

保护系统不会在过载的情况下出现问题,我们就需要限流。

在一些系统中都可以看到这样的设计,比如,数据库访问的连接池,应用程序的线程池,还有 Nginx 下的用于限制瞬时并发连接数的 limit_conn 模块,限制每秒平均速率的 limit_req 模块,还有限制 MQ 的生产速,等等。

1. 限流的策略

限流的目的是通过对并发访问进行限速,相关的策略一般是,一旦达到限制的速率,那么就会触发相应的限流行为。一般来说,触发的限流行为如下:

  • 拒绝服务。把多出来的请求拒绝掉。一般来说,好的限流系统在受到流量暴增时,会统计当前哪个客户端来的请求最多,直接拒掉这个客户端,这种行为可以把一些不正常的或者是带有恶意的高并发访问挡在门外。
  • 服务降级。关闭或是把后端服务做降级处理。这样可以让服务有足够的资源来处理更多的请求。降级有很多方式,一种是把一些不重要的服务给停掉,把 CPU、内存或是数据的资源让给更重要的功能;一种是不再返回全量数据,只返回部分数据。
  • 特权请求。所谓特权请求的意思是,资源不够了,我只能把有限的资源分给重要的用户,比如:分给权利更高的 VIP 用户。在多租户系统下,限流的时候应该保大客户的,所以大客户有特权可以优先处理,而其它的非特权用户就得让路了。
  • 延时处理。在这种情况下,一般会有一个队列来缓冲大量的请求,这个队列如果满了,那么就只能拒绝用户了,如果这个队列中的任务超时了,也要返回系统繁忙的错误了。使用缓冲队列只是为了减缓压力,一般用于应对短暂的峰刺请求。
  • 弹性伸缩。动用自动化运维的方式对相应的服务做自动化的伸缩。这个需要一个应用性能的监控系统,能够感知到目前最繁忙的 TOP 5 的服务是哪几个。然后去伸缩它们,还需要一个自动化的发布、部署和服务注册的运维系统,而且还要快,越快越好。否则,系统会被压死掉了。当然,如果是数据库的压力过大,弹性伸缩应用是没什么用的,这个时候还是应该限流。

2. 限流的实现方式

  • 计数器方式:最简单的限流算法就是维护一个计数器 Counter,当一个请求来时,就做加一操作,当一个请求处理完后就做减一操作。如果这个 Counter 大于某个数了,就开始拒绝请求以保护系统的负载了。

  • 队列算法:队列流控是以队列的方式来处理请求。如果处理过慢,那么就会导致队列满,而开始触发限流。这样的算法需要用队列长度来控制流量,在配置上比较难操作。如果队列过长,导致后端服务在队列没有满时就挂掉了。

  • 漏斗算法 Leaky Bucket:漏斗算法其实就是在队列请求中加上一个限流器,来让 Processor 以一个均匀的速度处理请求。

  • 令牌桶算法 Token Bucket:令牌桶算法,主要是有一个中间人。在一个桶内按照一定的速率放入一些 token,然后,处理程序要处理请求时,需要拿到 token,才能处理;如果拿不到,则不处理。

    令牌桶的算法和漏斗算法不一样的是,漏斗算法中,处理请求是以一个常量和恒定的速度处理的,而令牌桶算法则是在流量小的时候“准备Token”,流量大的时候,可以一次发出队列里有的请求,而后就受到令牌桶的流控限制。

    另外,令牌桶还可能做成第三方的一个服务,这样可以在分布式的系统中对全局进行流控,这也是一个很好的方式。

3. 基于响应时间的动态限流

上面的算法有个不好的地方,就是需要设置一个确定的限流值。这就要求我们每次发布服务时都做相应的性能测试,找到系统最大的性能值。

在很多时候,我们并不知道这个限流值,或是很难给出一个合适的值。其基本会有如下的一些因素:

  • 实际情况下,很多服务会依赖于数据库。所以,不同的用户请求,会对不同的数据集进行操作。就算是相同的请求,可能数据集也不一样,比如,现在很多应用都会有一个时间线 Feed 流,不同的用户关心的主题人人不一样,数据也不一样。而且数据库的数据是在不断变化的,可能前两天性能还行,因为数据量增加导致性能变差。在这种情况下,我们很难给出一个确定的一成不变的值,因为关系型数据库对于同一条 SQL 语句的执行时间其实是不可预测的。
  • 不同的 API 有不同的性能。我们要在线上为每一个 API 配置不同的限流值,这点太难配置,也很难管理。
  • 而且,现在的服务都是能自动化伸缩的,不同大小的集群的性能也不一样,所以,在自动化伸缩的情况下,要动态地调整限流的阈值,这点太难做到了。

基于这些原因,限流的值是很难被静态地设置成恒定的一个值。

我们想使用一种动态限流的方式。这种方式,不再设定一个特定的流控值,而是能够动态地感知系统的压力来自动化地限流。

这方面设计的典范是 TCP 协议的拥塞控制的算法。TCP 使用 RTT - Round Trip Time 来探测网络的延时和性能,从而设定相应的“滑动窗口”的大小,以让发送的速率和网络的性能相匹配。这个算法是非常精妙的,我们完全可以借鉴在我们的流控技术中。

我们记录下每次调用后端请求的响应时间,然后在一个时间区间内(比如,过去 10 秒)的请求计算一个响应时间的 P90 或 P99 值,也就是把过去 10 秒内的请求的响应时间排个序,然后看 90% 或 99% 的位置是多少。

这样,我们就知道有多少请求大于某个响应时间。如果这个 P90 或 P99 超过我们设定的阈值,那么我们就自动限流。

4. 限流的设计要点

  1. 为了向用户承诺 SLA。我们保证我们的系统在某个速度下的响应时间以及可用性。
  2. 同时,也可以用来阻止在多租户的情况下,某一用户把资源耗尽而让所有的用户都无法访问的问题。
  3. 为了应对突发的流量。
  4. 节约成本。我们不会为了一个不常见的尖峰来把我们的系统扩容到最大的尺寸。而是在有限的资源下能够承受比较高的流量。

在设计上,还要有以下的考量。

  • 限流应该是在架构的早期考虑。当架构形成后,限流不是很容易加入。
  • 限流模块性能必须好,而且对流量的变化也是非常灵敏的,否则太过迟钝的限流,系统早因为过载而挂掉了。
  • 限流应该有个手动的开关,这样在应急的时候,可以手动操作。
  • 当限流发生时,应该有个监控事件通知。
  • 当限流发生时,对于拒掉的请求,我们应该返回一个特定的限流错误码。这样,可以和其它错误区分开来。而客户端看到限流,可以调整发送速度,或是走重试机制。
  • 限流应该让后端的服务感知到。限流发生时,我们应该在协议头中塞进一个标识,比如 HTTP Header 中,放入一个限流的级别,告诉后端服务目前正在限流中。这样,后端服务可以根据这个标识决定是否做降级。

九、降级设计

所谓的降级设计(Degradation),本质是为了解决资源不足和访问量过大的问题。当资源和访问量出现矛盾的时候,在有限的资源下,为了能够扛住大量的请求,我们就需要对系统进行降级操作。也就是说,暂时牺牲掉一些东西,以保障整个系统的平稳运行。

1. 降级牺牲东西

  • 降低一致性:从强一致性变成最终一致性。

    降低数据的一致性一般来说会使用缓存的方式,或是直接就去掉数据。比如,在页面上不显示库存的具体数字,只显示有还是没有库存这两种状态。

  • 停止次要的功能:停止访问不重要的功能,从而释放出更多的资源。

    停止次要的功能也是一种非常有用的策略。把一些不重要的功能给暂时停止掉,让系统释放出更多的资源来。比如,电商中的搜索功能,用户的评论功能,等等。等待访问的峰值过去后,我们再把这些功能给恢复回来。

    当然,最好不要停止次要的功能,首先可以限制次要的功能的流量,或是把次要的功能退化成简单的功能,最后如果量太大了,我们才会进入停止功能的状态。

  • 简化功能:把一些功能简化掉,比如,简化业务流程,或是不再返回全量数据,只返回部分数据。

    比如:一个 API 会有两个版本,一个版本返回全量数据,另一个版本只返回部分或最小的可用的数据。

2. 降级设计的要点

  • 对于降级,一般来说是要牺牲业务功能或是流程,以及一致性的。所以,我们需要对业务做非常仔细的梳理和分析。我们很难通过不侵入业务的方式来做到功能降级。
  • 在设计降级的时候,需要清楚地定义好降级的关键条件,比如,吞吐量过大、响应时间过慢、失败次数过多,有网络或是服务故障,等等,然后做好相应的应急预案。这些预案最好是写成代码可以快速地自动化或半自动化执行的。
  • 功能降级需要梳理业务的功能,哪些是必须要死保的功能,哪些是可以牺牲的功能。
  • 降级的时候,需要牺牲掉一致性,或是一些业务流程:对于读操作来说,使用缓存来解决,对于写操作来说,需要异步调用来解决。并且,我们需要以流水账的方式记录下来,这样方便对账,以免漏掉或是和正常的流程混淆。
  • 降级的功能的开关可以是一个系统的配置开关。做成配置时,你需要在要降级的时候推送相应的配置。
  • 对于数据方面的降级,需要前端程序的配合。一般来说,前端的程序可以根据后端传来的数据来决定展示哪些界面模块。为了区分本来就没有数据,还是因为降级了没有数据的两种情况,在协议头中也应该加上降级的标签。
  • 因为降级的功能平时不会总是会发生,属于应急的情况,所以,降级的这些业务流程和功能有可能长期不用而出现 bug 或问题,对此,需要在平时做一些演练。

十、弹力设计总图

首先,我们的服务不能是单点,所以,我们需要在架构中冗余服务,也就是说有多个服务的副本。这需要使用到的具体技术有:

  • 负载均衡 + 服务健康检查
  • 服务发现 + 动态路由 + 服务健康检查
  • 自动化运维

然后,我们需要隔离我们的业务,要隔离我们的服务我们就需要对服务进行解耦和拆分,这需要使用到以前的相关技术。

  • bulkheads 模式:业务分片 、用户分片、数据库拆分。
  • 自包含系统:所谓自包含的系统是从单体到微服务的中间状态,其把一组密切相关的微服务给拆分出来,只需要做到没有外部依赖就行。
  • 异步通讯:服务发现、事件驱动、消息队列、业务工作流。
  • 自动化运维:需要一个服务调用链和性能监控的监控系统。

然后,接下来,我们就要进行能让整个架构接受失败的相关处理设计,也就是所谓的容错设计。这会用到下面的这些技术。

  • 错误方面:调用重试 + 熔断 + 服务的幂等性设计。
  • 一致性方面:强一致性使用两阶段提交、最终一致性使用异步通讯方式。
  • 流控方面:使用限流 + 降级技术。
  • 自动化运维方面:网关流量调度,服务监控。

在上面这个图上,我们可以看到,有三大块的东西。

  • 冗余服务。通过冗余服务的复本数可以消除单点故障。这需要服务发现,负载均衡,动态路由和健康检查四个功能或组件。
  • 服务解耦。通过解耦可以做到把业务隔离开来,不让服务受影响,这样就可以有更好的稳定性。在水平层面上,需要把业务或用户分片分区(业分做隔离,用户做多租户)。在垂直层面上,需要异步通讯机制。因为应用被分解成了一个一个的服务,所以在服务的编排和聚合上,需要有工作流来把服务给串联起来。而一致性的问题又需要业务补偿机制来做反向交易。
  • 服务容错。服务容错方面,需要有重试机制,重试机制会带来幂等操作,对于服务保护来说,熔断,限流,降级都是为了保护整个系统的稳定性,并在可用性和一致性方面在出错的情况下做一部分的妥协。

当然,除了这一切的架构设计外,你还需要一个或多个自动运维的工具,否则,如果是人肉运维的话,那么在故障发生的时候,不能及时地做出运维决定,也就空有这些弹力设计了。比如:监控到服务性能不够了,就自动或半自动地开始进行限流或降级。

弹力设计开发和运维对于运维工具来说,至少需要两个系统:

  • 一个是像 APM 这样的服务监控;
  • 另一个是服务调度的系统,如:Docker + Kubernetes。