分布式设计模式:弹力设计篇
参考:
接下来的几章将主要谈一下分布式系统中一些比较关键的设计模式,其中包括容错、性能、管理等几个方面:
- 容错设计又叫弹力设计,其中着眼于分布式系统的各种“容忍”能力,包括容错能力(服务隔离、异步调用、请求幂等性)、可伸缩性(有 / 无状态的服务)、一致性(补偿事务、重试)、应对大流量的能力(熔断、降级)。可以看到,在确保系统正确性的前提下,系统的可用性是弹力设计保障的重点。
- 管理篇会讲述一些管理分布式系统架构的一些设计模式,比如网关方面的,边车模式,还有一些刚刚开始流行的,如 Service Mesh 相关的设计模式。
- 性能设计篇会讲述一些缓存、CQRS、索引表、优先级队列、业务分片等相关的架构模式。
这一章先讲弹力设计部分。
# 1. 认识故障和弹力设计
# 1.1 系统的可用性测量
对于分布式系统的容错设计,在英文中又叫 Resiliency(弹力)。意思是,系统在不健康、不顺,甚至出错的情况下有能力 hold 得住,挺得住,还有能在这种逆境下力挽狂澜的能力。
容错主要是为了可用性,那么,我们是怎样计算一个系统的可用性的呢?下面是一个工业界里使用的一个公式:
其中:
- MTTF 是 Mean Time To Failure,平均故障前的时间,即系统平均能够正常运行多长时间才发生一次故障。系统的可靠性越高,MTTF 越长。(注意:从字面上来说,看上去有 Failure 的字样,但其实是正常运行的时间。)
- MTTR 是 Mean Time To Recovery,平均修复时间,即从故障出现到故障修复的这段时间,这段时间越短越好。
这个公式就是计算系统可用性的,也就是我们常说的,多少个 9,如下表所示。
根据上面的这个公式,为了提高可用性,我们要么提高系统的无故障时间,要么减少系统的故障恢复时间。
SLA 的几个 9 就是能持续提供可用服务的级别。
# 1.2 故障原因
在工业界中,会把服务不可用的因素分成两种:一种是有计划的,一种是无计划的。
无计划的:
- 系统级故障,包括主机、操作系统、中间件、数据库、网络、电源以及外围设备。
- 数据和中介的故障,包括人员误操作、硬盘故障、数据乱了。
- 还有自然灾害、人为破坏,以及供电问题等。
有计划的:
- 日常任务:备份,容量规划,用户和安全管理,后台批处理应用。
- 运维相关:数据库维护、应用维护、中间件维护、操作系统维护、网络维护。
- 升级相关:数据库、应用、中间件、操作系统、网络,包括硬件升级。
# 1.3 故障不可避免
对于大规模的分布式系统,出现故障基本上就是常态,甚至还有些你根本就不知道会出问题的地方。如果你在云平台上,或者使用了“微服务”,面对大量的 IoT 设备以及不受控制的用户流量,那么系统故障会更为复杂和变态。因为上面这些因素增加了整个系统的复杂度。
所以,要充分地意识到下面两个事:
- 故障是正常的,而且是常见的。
- 故障是不可预测突发的,而且相当难缠。
这告诉我们,不要尝试着去避免故障,而是要把处理故障的代码当成正常的功能做在架构里写在代码里。这就是为什么我们把这个设计叫做弹力(Resiliency)。
- 一方面,在好的情况下,这个事对于我们的用户和内部运维来说是完全透明的,系统自动修复不需要人的干预。
- 另一方面,如果修复不了,系统能够做自我保护,而不让事态变糟糕。
这就是所谓的“弹力”——能上能下。这让我想到三国杀里赵云的技能——“能进能退乃真正法器”。
# 1.3 小结
这一节主要讲了弹力设计的真正目的,并对系统可用性的衡量指标和故障的各种原因有所了解。
# 2. 隔离设计 Bulkheads
隔离设计对应的单词是 Bulkheads,中文翻译为隔板,原意是船舱中防止漏水蔓延的隔板。在软件设计中,也是为了防止故障蔓延,来使用隔板对架构隔离故障。
在分布式软件架构中,我们需要对系统进行分离,一般来说有两种方式:
- 以服务的种类来做分离
- 以用户来做分离
# 2.1 按服务的种类来做分离
下面这个图中,说明了按服务种类来做分离的情况:
上图中,我们将系统分成了用户、商品、社区三个板块。这三个块分别使用不同的域名、服务器和数据库,做到从接入层到应用层再到数据层三层完全隔离。这样一来,在物理上来说,一个板块的故障就不会影响到另一板块。
在亚马逊,每个服务都有自己的一个数据库,每个数据库中都保存着和这个业务相关的数据和相应的处理状态。而每个服务从一开始就准备好了对外暴露。同时,这也是微服务所推荐的架构方式。
然而任何架构都有其好和不好的地方,上面这种架构虽然在系统隔离上做得比较好,但是也存在以下一些问题:
- 如果我们需要同时获得多个板块的数据,那么就需要调用多个服务,这会降低性能。注意,这里性能降低指的是响应时间,而不是吞吐量(相反,在这种架构下,吞吐量可以得到提高)。
- 对于这样的问题,一般来说,我们需要小心地设计用户交互,最好不要让用户在一个页面上获得所有的数据。对于目前的手机端来说,因为手机屏幕尺寸比较小,所以,也不可能在一个屏幕页上展示太多的内容。
- 如果有大数据平台,就需要把这些数据都抽取到一个数据仓库中进行计算,这也增加了数据合并的复杂度。对于这个问题,我们需要一个框架或是一个中间件来对数据进行相应的抽取。
- 如果我们的业务逻辑或是业务流程需要跨板块的话,那么一个板块的故障也会导致整个流程走不下去,同样会导致整体业务故障。
- 对于这个问题,一方面,我们需要保证这个业务流程中各个子系统的高可用性,并且在业务流程上做成 Step-by-Step 的方式,这样用户交互的每一步都可以保存,以便故障恢复后可以继续执行,而不是从头执行。
- 如果需要有跨板块的交互也会变得有点复杂。对此我们需要一个类似于 Pub/Sub 的高可用、且可以持久化的消息订阅通知中间件来打通各个板块的数据和信息交换。
- 最后还会有在多个板块中分布式事务的问题。对此,我们需要“二阶段提交”这样的方案。
可见,隔离了的系统在具体的业务场景中还是有很多问题的,是需要我们小心和处理的。对此,我们不可掉以轻心。根据我的经验,这样的系统通常会引入大量的异步处理模型。
# 2.2 按用户的请求来做分离
下图是一个按用户请求来做分离的图示:
在这个图中,可以看到,我们将用户分成不同的组,并把后端的同一个服务根据这些不同的组分成不同的实例。让同一个服务对于不同的用户进行冗余和隔离,这样一来,当服务实例挂掉时,只会影响其中一部分用户,而不会导致所有的用户无法访问。
这种分离和上面按功能的分离可以融合。说白了,这就是所谓的“多租户”模式。对于一些比较大的客户,我们可以为他们设置专门独立的服务实例,或是服务集群与其他客户隔离开来,对于一些比较小的用户来说,可以让他们共享一个服务实例,这样可以节省相关的资源。
对于“多租户”的架构来说,会引入一些系统设计的复杂度。一方面,如果完全隔离,资源使用上会比较浪费,如果共享,又会导致程序设计的一些复杂度。通常来说多租户的做法有三种:
- 完全独立的设计。每个租户有自己完全独立的服务和数据。
- 独立的数据分区,共享的服务。多租户的服务是共享的,但数据是分开隔离的。
- 共享的服务,共享的数据分区。每个租户的数据和服务都是共享的。
通过上图,可以看到:
- 如果使用完全独立的方案,在开发实现上和资源隔离度方面会非常好,然而,成本会比较高,计算资源也会有一定的浪费。
- 如果使用完全共享的方案,在资源利用和成本上会非常好,然而,开发难度非常大,而且数据和资源隔离非常不好。
一般来说,技术方案会使用折中方案,也就是中间方案,服务是共享的,数据通过分区来隔离,而对于一些比较重要的租户(需要好的隔离性),则使用完全独立的方式。
然而,在虚拟化技术非常成熟的今天,我们完全可以使用“完全独立”(完全隔离)的方案,通过底层的虚拟化技术(Hypervisor 的技术,如 KVM,或是 Linux Container 的技术,如 Docker)来实现物理资源的共享和成本的节约。
# 2.3 隔离设计的重点
要能做好隔离设计,我们需要有如下的一些设计考量:
- 我们需要定义好隔离业务的大小和粒度,过大和过小都不好。这需要认真地做业务上的需求和系统分析。
- 无论是做系统板块还是多租户的隔离,你都需要考虑系统的复杂度、成本、性能、资源使用的问题,找到一个合适的均衡方案,或是分布实施的方案尤其重要,这其中需要你定义好要什么和不要什么。因为,我们不可能做出一个什么都能满足的系统。
- 隔离模式需要配置一些高可用、重试、异步、消息中间件,流控、熔断等设计模式的方式配套使用。
- 不要忘记了分布式系统中的运维的复杂度的提升,要能驾驭得好的话,还需要很多自动化运维的工具,尤其是使用像容器或是虚拟机这样的虚拟化技术可以帮助我们更方便地管理,和对比资源更好地利用。否则做出来了也管理不好。
- 最后,你需要一个非常完整的能够看得到所有服务的监控系统,这点非常重要。
# 2.4 小结
这一节介绍了分布式系统设计中两种常见的隔离设计:
- 按服务种类隔离
- 按用户隔离(即多租户)
# 3. 异步通讯设计 Asynchronous
当把系统进行解耦拆分,所要面对的一个重要问题就是这些系统间的通讯。
通讯一般来说分同步和异步两种。同步通讯就像打电话,需要实时响应,而异步通讯就像发邮件,不需要马上回复。
同步调用虽然让系统间只耦合于接口,而且实时性也会比异步调用要高,但是我们也需要知道同步调用会带来如下几个问题:
- 同步调用需要被调用方的吞吐不低于调用方的吞吐。否则会导致被调用方因为性能不足而拖死调用方。换句话说,整个同步调用链的性能会由最慢的那个服务所决定。
- 同步调用会导致调用方一直在等待被调用方完成,如果一层接一层地同步调用下去,所有的参与方会有相同的等待时间。这会非常消耗调用方的资源。
- 同步调用只能是一对一的,很难做到一对多。
- 最不好的是,如果被调用方有问题,那么其调用方就会跟着出问题,于是会出现故障的多米诺骨牌效应,故障一下就蔓延开来。
所以,异步通讯相对于同步通讯来说,除了可以增加系统的吞吐量之外,最大的一个好处是其可以让服务间的解耦更为彻底,系统的调用方和被调用方可以按照自己的速率而不是步调一致,从而可以更好地保护系统,让系统更有弹力。
# 3.1 异步通讯的三种方式
# 3.1.1 请求响应式
发送方(sender)会直接请求接收方(receiver),被请求方接收到请求后,直接返回——“收到请求,正在处理”。
对于返回结果,有两种方法:
- 一种是发送方时不时地去轮询一下,问一下干没干完。
- 另一种方式是发送方注册一个回调方法,也就是接收方处理完后回调请求方。
这种架构模型在以前的网上支付中比较常见,页面先从商家跳转到支付宝或银行,商家会把回调的 URL 传给支付页面,支付完后,再跳转回商家的 URL。
很明显,这种情况下还是有一定耦合的。是发送方依赖于接收方,并且要把自己的回调发送给接收方,处理完后回调。
# 3.1.2 通过订阅的方式
接收方(receiver)会来订阅发送方(sender)的消息,发送方会把相关的消息或数据放到接收方所订阅的队列中,而接收方会从队列中获取数据。
这种方式下,发送方并不关心订阅方的处理结果,它只是告诉订阅方有事要干,收完消息后给个 ACK 就好了,你干成啥样我不关心。这个方式常用于像 MVC(Model-View-Control)这样的设计模式下,如下图所示。
这就好像下订单的时候,一旦用户支付完成了,就需要把这个事件通知给订单处理以及物流,订单处理变更状态,物流服务需要从仓库服务分配相应的库存并准备配送,后续这些处理的结果无需告诉支付服务。
为什么要做成这样?前一小节的调用方式就像函数调用一样,有数据状态的往来,调用方需要保存调用状态,而如果我们把服务的状态给去掉(通过第三方的状态服务来保证),那么服务间的依赖就只有事件了。你知道,分布式系统的服务设计是需要向无状态服务(Stateless)努力的,这其中有太多的好处,无状态意味着你可以非常方便地运维。所以,事件通讯成为了异步通讯中最重要的一个设计模式。就上面支付的那个例子,商家这边只需要订阅一个支付完成的事件,这个事件带一个订单号,而不需要让支付方知道自己的回调 URL,这样的异步是不是更干净一些?
但是,在这种方式下,接收方需要向发送方订阅事件,所以是接收方依赖于发送方。这种方式还是有一定的耦合。
# 3.1.3 通过 Broker 的方式
所谓 Broker,就是一个中间人,发送方(sender)和接收方(receiver)都互相看不到对方,它们看得到的是一个 Broker,发送方向 Broker 发送消息,接收方向 Broker 订阅消息。如下图所示:
这是完全的解耦。所有的服务都不需要相互依赖,而是依赖于一个中间件 Broker。这个 Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。
在 Broker 这种模式下,发送方的服务和接收方的服务最大程度地解耦。但是所有人都依赖于一个总线,所以这个总线就需要有如下的特性:
- 必须是高可用的,因为它成了整个系统的关键;
- 必须是高性能而且是可以水平扩展的;
- 必须是可以持久化不丢数据的。
要做到这三条还是比较难的。当然,好在现在开源软件或云平台上 Broker 的软件是非常成熟的,所以节省了我们很多的精力。
# 3.2 事件驱动设计
上述的第二种和第三种方式就是比较著名的事件驱动架构(EDA – Event Driven Architecture)。正如前面所说,事件驱动最好是使用 Broker 方式,服务间通过交换消息来完成交流和整个流程的驱动。
如下图所示,这是一个订单处理流程。下单服务通知订单服务有订单要处理,而订单服务生成订单后发出通知,库存服务和支付服务得到通知后,一边是占住库存,另一边是让用户支付,等待用户支付完成后通知配送服务进行商品配送:
每个服务都是“自包含”的。所谓“自包含”也就是没有和别人产生依赖。而要把整个流程给串联起来,我们需要一系列的“消息通道(Channel)”。各个服务做完自己的事后,发出相应的事件,而又有一些服务在订阅着某些事件来联动。
事件驱动方式的好处至少有五个:
- 服务间的依赖没有了,服务间是平等的,每个服务都是高度可重用并可被替换的。
- 服务的开发、测试、运维,以及故障处理都是高度隔离的。
- 服务间通过事件关联,所以服务间是不会相互 block 的。
- 在服务间增加一些 Adapter(如日志、认证、版本、限流、降级、熔断等)相当容易。
- 服务间的吞吐也被解开了,各个服务可以按照自己的处理速度处理。
事件驱动的架构也会有一些不好的地方:
- 业务流程不再那么明显和好管理。整个架构变得比较复杂。解决这个问题需要有一些可视化的工具来呈现整体业务流程。
- 事件可能会乱序。这会带来非常 Bug 的事。解决这个问题需要很好地管理一个状态机的控制。
- 事务处理变得复杂。需要使用两阶段提交来做强一致性,或是退缩到最终一致性。
# 3.3 异步通讯的设计重点
首先,我们需要知道,为什么要异步通讯:
- 异步通讯最重要的是解耦服务间的依赖。最佳解耦的方式是通过 Broker 的机制。
- 解耦的目的是让各个服务的隔离性更好,这样不会出现“一倒倒一片”的故障。
- 异步通讯的架构可以获得更大的吞吐量,而且各个服务间的性能不受干扰相对独立。
- 利用 Broker 或队列的方式还可以达到把抖动的吞吐量变成均匀的吞吐量,这就是所谓的“削峰”,这对后端系统是个不错的保护。
- 服务相对独立,在部署、扩容和运维上都可以做到独立不受其他服务的干扰。
但我们需要知道这样的方式带来的问题,所以在设计成异步通信的时候需要注意如下事宜:
- 用于异步通讯的中间件 Broker 成为了关键,需要设计成高可用不丢消息的。另外,因为是分布式的,所以可能很难保证消息的顺序,因此你的设计最好不依赖于消息的顺序。
- 异步通讯会导致业务处理流程不那么直观,因为像接力一样,所以在 Broker 上需要有相关的服务消息跟踪机制,否则出现问题后不容易调试。
- 因为服务间只通过消息交互,所以业务状态最好由一个总控方来管理,这个总控方维护一个业务流程的状态变迁逻辑,以便系统发生故障后知道业务处理到了哪一步,从而可以在故障清除后继续处理。
- 这样的设计常见于银行的对账程序,银行系统会有大量的外部系统通讯,比如跨行的交易、跨企业的交易,等等。所以,为了保证整体数据的一致性,或是避免漏处理及处理错的交易,需要有对账系统,这其实就是那个总控,这也是为什么银行有的交易是 T+1(隔天结算),就是因为要对个账,确保数据是对的。
- 消息传递中,可能有的业务逻辑会有像 TCP 协议那样的 send 和 ACK 机制。比如:A 服务发出一个消息之后,开始等待处理方的 ACK,如果等不到的话,就需要做重传。此时,需要处理方有幂等的处理,即同一条消息无论收到多少次都只处理一次。
# 3.4 小结
总结一下这一节。
首先,同步调用有四个问题:影响吞吐量、消耗系统资源、只能一对一,以及有多米诺骨牌效应。于是,我们想用异步调用来避免该问题。
异步调用有三种方式:请求响应、直接订阅和中间人订阅。
最后,我介绍了事件驱动设计的特点和异步通讯设计的重点。
# 4. 幂等性设计 Idempotency
幂等性设计:一次和多次请求某一个资源应该具有同样的副作用。用数学表达就是
比如绝对值函数:
为什么我们需要这样的操作?在我们把系统解耦隔离后,服务间的调用可能会有三个状态,一个是成功(Success),一个是失败(Failed),一个是超时(Timeout)。前两者都是明确的状态,而超时则是完全不知道是什么状态,这时调用方就会重试。而“重试”就可能给我们的系统带来不一致的副作用。在这种情况下,一般有两种处理方式:
- 一种是需要下游系统提供相应的状态查询接口。上游系统在 timeout 后去查询一下。如果查到了,就表明已经做了,成功了就不用做了,失败了就走失败流程。
- 另一种是通过幂等性的方式。也就是说,把这个查询操作交给下游系统,我上游系统只管重试,下游系统保证一次和多次的请求结果是一样的。
对于第一种方式,需要对方提供一个查询接口来做配合。而第二种方式则需要下游的系统提供支持幂等性的交易接口。
由于重试而可能产生副作用的场景有:
- 订单创建接口,第一次调用超时了,然后调用方重试了一次。是否会多创建一笔订单?
- 订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次。是否会多扣一次库存?
- 当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次。是否会多扣一次钱?
# 4.1 全局 ID
要做到幂等性的交易接口,需要有一个全局唯一的标识,来标志交易是同一笔交易。
为了解决标识分配的问题,我们需要使用一个不会冲突的算法:
- UUID:但生成的字符串占用的空间比较大,索引的效率非常低,生成的 ID 太过于随机,完全不是人读的,而且没有递增,如果要按前后顺序排序的话,基本不可能。
- Snowflake:一个分布式 ID 的生成算法,更合适
Snowflake 算法
Snowflake 是一个分布式 ID 的生成算法。它的核心思想是,产生一个 long 型的 ID,其中:
- 41bits 作为毫秒数。大概可以用 69.7 年。
- 10bits 作为机器编号(5bits 是数据中心,5bits 的机器 ID),支持 1024 个实例。
- 12bits 作为毫秒内的序列号。一毫秒可以生成 4096 个序号。
其他的像 Redis 或 MongoDB 的全局 ID 生成都和这个算法大同小异。你可以根据实际情况加上业务的编号。
# 4.2 处理流程
对于幂等性的处理流程来说,说白了就是要过滤一下已经收到的交易。要做到这个事,我们需要一个存储来记录收到的交易。于是,当收到交易请求的时候,我们就会到这个存储中去查询。如果查找到了,那么就不再做查询了,并把上次做的结果返回。如果没有查到,那么我们就记录下来。
但是,上面这个流程有个问题。因为绝大多数请求应该都不会是重新发过来的,所以让 100% 的请求都到这个存储里去查一下,这会导致处理流程变得很慢。
所以,最好是当这个存储出现冲突的时候会报错。也就是说,我们收到交易请求后,直接去存储里记录这个 ID(相对于数据的 Insert 操作),如果出现 ID 冲突了的异常,那么我们就知道这个之前已经有人发过来了,所以就不用再做了。比如,数据库中你可以使用 insert into … values … on DUPLICATE KEY UPDATE … 这样的操作。
对于更新的场景来说,如果只是状态更新,可以使用如下的方式。如果出错,要么是非法操作,要么是已被更新,要么是状态不对,总之多次调用是不会有副作用的。
update table set status = “paid” where id = xxx and status = “unpaid”;
当然,网上还有 MVCC 通过使用版本号等其他方式,我觉得这些都不标准,我们希望我们有一个标准的方式来做这个事,所以,最好还是用一个 ID。
因为我们的幂等性服务也是分布式的,所以这个存储也是共享的。这样每个服务就变成没有状态的了。但是,这个存储就成了一个非常关键的依赖,其扩展性和可用性也成了非常关键的指标。你可以使用关系型数据库,或是 key-value 的 NoSQL(如 MongoDB)来构建这个存储系统。
# 4.3 HTTP 的幂等性
- GET 方法用于获取资源,不应有副作用,所以是幂等的。
- HEAD 方法与 GET 本质是一样的,区别在于 HEAD 不含有呈现数据,而仅仅是 HTTP 头信息,不应有副作用,也是幂等的。HEAD 方法可以用来做探活使用。
- OPTIONS 主要用于获取当前 URL 所支持的方法,所以也是幂等的。
- DELETE 方法用于删除资源,有副作用,但它应该满足幂等性。比如:DELETE
http://www.forum.com/article/4231
,调用一次和 N 次对系统产生的副作用是相同的,即删掉 ID 为 4231 的帖子。 - POST 方法用于创建资源,所对应的 URI 并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。比如:POST
http://www.forum.com/articles
的语义是在http://www.forum.com/articles
下创建一篇帖子,HTTP 响应中应包含帖子的创建状态以及帖子的 URI。两次相同的 POST 请求会在服务器端创建两份资源,它们具有不同的 URI;所以,POST 方法不具备幂等性。 - PUT 方法用于创建或更新操作,所对应的 URI 是要创建或更新的资源本身,有副作用,它应该满足幂等性。比如:PUT
http://www.forum/articles/4231
的语义是创建或更新 ID 为 4231 的帖子。对同一 URI 进行多次 PUT 的副作用和一次 PUT 是相同的;因此,PUT 方法具有幂等性。
所以,对于 POST 的方式,很可能会出现多次提交的问题,就好比,我们在论坛中发帖时,有时候因为网络有问题,可能会对同一篇贴子出现多次提交的情况。对此,一般的幂等性的设计如下:
- 首先,在表单中需要隐藏一个 token,这个 token 可以是前端生成的一个唯一的 ID。用于防止用户多次点击了表单提交按钮,而导致后端收到了多次请求,却不能分辨是否是重复的提交。这个 token 是表单的唯一标识。
- 然后,当用户点击提交后,后端会把用户提交的数据和这个 token 保存在数据库中。如果有重复提交,那么数据库中的 token 会做排它限制,从而做到幂等性。
- 当然,更为稳妥的做法是,后端成功后向前端返回 302 跳转,把用户的前端页跳转到 GET 请求,把刚刚 POST 的数据给展示出来。如果是 Web 上的最好还把之前的表单设置成过期,这样用户不能通过浏览器后退按钮来重新提交。这个模式又叫做 PRG 模式 (opens new window)(Post/Redirect/Get)。
# 4.4 小结
首先,幂等性的含义是,一个调用被发送多次所产生的副作用和被发送一次所产生的副作用是一样的。而服务调用有三种结果:成功、失败和超时,其中超时是我们需要解决的问题。
解决手段可以是超时后查询调用结果,也可以是在被调用的服务中实现幂等性。为了在分布式系统中实现幂等性,我们需要实现全局 ID。
最后给出了幂等性接口的处理流程。
# 5. 服务的状态 State
为了把服务做成无状态的,我们需要引入第三方的存储。在这一节,我们来聊聊服务的状态这个话题。
所谓“状态”,就是为了保留程序的一些数据或是上下文。比如之前幂等性设计中所说的需要保留每一次请求的状态,或者业务流程的运行上下文 Context。我们的代码中基本上到处都是这样的状态。
# 5.1 无状态的服务 Stateless
一直以来,无状态的服务都被当作分布式服务设计的最佳实践和铁律。因为 stateless 的服务在扩展性和运维上太方便了。
基本上来说,无状态的服务和函数式编程的思维方式如出一辙。在函数式编程中,一个铁律是,函数是无状态的。换句话说,函数是 immutable 不变的,所有的函数只描述其逻辑和算法,根本不保存数据,也不会修改输入的数据,而是把计算好的结果返回出去,哪怕要把输入的数据重新拷贝一份并只做少量的修改。
函数式编程可以参考 函数式编程 (opens new window)
但是,现实世界是一定会有状态的。这些状态可能表现在如下的几个方面:
- 程序调用的结果
- 服务组合下的上下文
- 服务的配置
为了做出无状态的服务,我们通常需要把状态保存到其他的地方。比如,不太重要的数据可以放到 Redis 中,重要的数据可以放到 MySQL 中,或是像 ZooKeeper/Etcd 这样的高可用的强一致性的存储中,或是分布式文件系统中。
于是,我们为了做成无状态的服务,会导致这些服务需要耦合第三方有状态的存储服务。一方面是有依赖,另一方面也增加了网络开销,导致服务的响应时间也会变慢。所以,第三方的这些存储服务也必须要做成高可用高扩展的方式。而且,为了减少网络开销,还需要在无状态的服务中增加缓存机制。然而,下次这个用户的请求并不一定会在同一台机器,所以,这个缓存会在所有的机器上都创建,也算是一种浪费吧。
这种“转移责任”的玩法也催生出了对分布式存储的强烈需求。但正如之前所说,“由于数据层的 schema 众多,所以很难做出一个放之四海皆准的分布式存储系统”。
这也是为什么无状态的服务需要依赖于像 ZooKeeper/Etcd 这样的高可用的有强一致的服务,或是依赖于底层的分布式文件系统(像开源的 Ceph 和 GlusterFS)。而现在分布式数据库也开始将服务和存储分离,也是为了让自己的系统更有弹力。
# 5.2 有状态的服务 Stateful
我们比较一下 Stateful 和 Stateless 的优劣。
如前面所说,无状态服务在程序 Bug 上和水平扩展上有非常优秀的表现,但是其需要把状态存放在一个第三方存储上,增加了网络开销,而在服务内的缓存需要在所有的服务实例上都有(因为每次请求不会都落在同一个服务实例上),这是比较浪费资源的。
而有状态的服务有这些好处:
- 数据本地化(Data Locality)。一方面状态和数据是本机保存,这方面不但有更低的延时,而且对于数据密集型的应用来说,这会更快。
- 更高的可用性和更强的一致性。
为什么会这样呢?因为对于有状态的服务,我们需要对于客户端传来的请求,都必须保证其落在同一个实例上,这叫 Sticky Session 或是 Sticky Connection。这样一来,我们完全不需要考虑数据要被加载到不同的节点上去,而且这样的模型更容易理解和实现。
可见,最重要的区别就是,无状态的服务需要我们把数据同步到不同的节点上,而有状态的服务通过 Sticky Session 做数据分片(当然,同步有同步的问题,分片也有分片的问题,这两者没有谁比谁好,都有 trade-off)。
这种 Sticky Session 是怎么实现的呢?
最简单的实现就是用持久化的长连接。就算是 HTTP 协议也要用长连接。或是通过一个简单的哈希(hash)算法,比如,通过 uid 求模的方式,走一致性哈希的玩法,也可以方便地做水平扩展。
然而,这种方式也会带来问题,那就是,节点的负载和数据并不会很均匀。尤其是长连接的方式,连上了就不断了。所以,玩长连接的玩法一般都会有一种叫“反向压力 (Back Pressure)”。也就是说,如果服务端成为了热点,那么就主动断连接,这种玩法也比较危险,需要客户端的配合,否则容易出 Bug。
如果要做到负载和数据均匀的话,我们需要有一个元数据索引来映射后端服务实例和请求的对应关系,还需要一个路由节点,这个路由节点会根据元数据索引来路由,而这个元数据索引表会根据后端服务的压力来重新组织相关的映射。
当然,我们可以把这个路由节点给去掉,让有状态的服务直接路由。要做到这点,一般来说,有两种方式:
- 一种是直接使用配置,在节点启动时把其元数据读到内存中,但是这样一来增加或减少节点都需要更新这个配置,会导致其它节点也一同要重新读入。
- 另一种比较好的做法是 Gossip 协议。通过这个协议在各个节点之间互相散播消息来同步元数据,这样新增或减少节点,集群内部可以很容易重新分配
在有状态的服务上做自动化伸缩的是有一些相关的真实案例的:
- Facebook 的 Scuba。这是一个分布式的内存数据库,它使用了静态的方式,也就是上面的第一种方式。
- Uber 的 Ringpop 是一个开源的 Node.js 的根据地理位置分片的路由请求的库。
- 微软的 Orleans,Halo 4 就是基于其开发的,其使用了 Gossip 协议,一致性哈希和 DHT 技术相结合的方式。用户通过其 ID 的一致性哈希算法映射到一个节点上,而这个节点保存了这个用户对应的 DHT,再通过 DHT 定位到处理用户请求的位置。这个项目也是开源的。
关于可扩展的有状态服务,这里强烈推荐 Twitter 的美女工程师 Caitie McCaffrey 的演讲 Youtube 视频《Building Scalable Stateful Service》(演讲 PPT),其文字版是在 High Scalability 上的这篇文章《Making the Case for Building Scalable Stateful Services in the Modern Era》。
# 5.3 服务状态的容错设计
在容错设计中,服务状态是一件非常复杂的事。尤其对于运维来说,因为你要调度服务就需要调度服务的状态,迁移服务的状态就需要迁移服务的数据。在数据量比较大的情况下,这一点就变得更为困难了。虽然上述有状态的服务的调度通过 Sticky Session 的方式是一种方式,但我依然觉得理论上来说虽然可以这么干,实际在运维的过程中,这么干还是件挺麻烦的事儿,不是很好的玩法。
很多系统的高可用的设计都会采取数据在运行时就复制的方案,比如:ZooKeeper、Kafka、Redis 或是 ElasticSearch 等等。在运行时进行数据复制就需要考虑一致性的问题,所以,强一致性的系统一般会使用两阶段提交。
这要求所有的节点都需要有一致的结果,这是 CAP 里的 CA 系统。而有的系统采用的是大多数人一致就可以了,比如 Paxos 算法,这是 CP 系统。
但我们需要知道,即使是这样,当一个节点挂掉了以后,在另外一个地方重新恢复这个节点时,这个节点需要把数据同步过来才能提供服务。然而,如果数据量过大,这个过程可能会很漫长,这也会影响我们系统的可用性。
所以,我们需要使用底层的分布式文件系统,对于有状态的数据不但在运行时进行多节点间的复制,同时为了避免挂掉,还需要把数据持久化在硬盘上,这个硬盘可以是挂载到本地硬盘的一个外部分布式的文件卷。这样当节点挂掉以后,以另外一个宿主机上启动一个新的服务实例时,这个服务可以从远程把之前的文件系统挂载过来。然后,在启动的过程中就装载好了大多数的数据,从而可以从网络其它节点上同步少量的数据,因而可以快速地恢复和提供服务。
这一点,对于有状态的服务来说非常关键。所以,使用一个分布式文件系统是调度有状态服务的关键。
# 5.4 小结
这一节主要分享了 Stateless 和 Stateful 服务:
- Stateless 的好处是容易运维和伸缩,但需要底层有分布式的数据库支持。
- Stateful 通过 Sticky Session、一致性 Hash 和 DHT 等技术实现状态和请求的关联,并将数据同步到分布式数据库中;利用分布式文件系统,还能在节点挂掉时快速启动新实例。
# 6. 补偿事务 Compensating Transaction
在一个分布式系统中,一个业务流程往往需要组合一组服务,而且需要业务上的一致性保证。也就是说,如果其中某一个步骤失败了,要么回滚,要么不断重试保证所有步骤都成功。
如果需要强一致性,那业务层需要使用“两阶段提交”这样的方式。但实际上,多数业务只需要最终一致性就够了。
# 6.1 ACID 与 BASE
ACID 保证了原子性、一致性、隔离性和持久性,但这在分布式系统中很难满足高性能的要求。为了提高性能,出现了 ACID 的一个变种 BASE:
- Basic Availability:基本可用。这意味着,系统可以出现暂时不可用的状态,而后面会快速恢复。
- Soft-state:软状态。它是我们前面的“有状态”和“无状态”的服务的一种中间状态。也就是说,为了提高性能,我们可以让服务暂时保存一些状态或数据,这些状态和数据不是强一致性的。
- Eventual Consistency:最终一致性,系统在一个短暂的时间段内是不一致的,但最终整个系统看到的数据是一致的。
可以看到,BASE 系统是允许或是容忍系统出现暂时性问题的。我们要做的是把故障处理写入代码中,这就是 Design for Failure。
BASE 的系统倾向于设计出更加有弹力的系统,这种系统的设计特点是,要保证在短时间内,就算是有数据不同步的风险,我们也应该允许新的交易可以发生,而后面我们在业务上将可能出现问题的事务给处理掉,以保证最终的一致性。而 ACID 却需要严格许多。
举个例子,网上卖书的场景。
- ACID 的玩法就是,大家在买同一本书的过程中,每个用户的购买请求都需要把库存锁住,等减完库存后,把锁释放出来,后续的人才能进行购买。于是,在 ACID 的玩法下,我们在同一时间不可能有多个用户下单,我们的订单流程需要有排队的情况,这样一来,我们就不可能做出性能比较高的系统来。
- BASE 的玩法是,大家都可以同时下单,这个时候不需要去真正地分配库存,然后系统异步地处理订单,而且是批量的处理。因为下单的时候没有真正去扣减库存,所以,有可能会有超卖的情况。而后台的系统会异步地处理订单时,发现库存没有了,于是才会告诉用户你没有购买成功。
ACID 更加强调一致性,BASE 更加强调可用性。
# 6.2 业务补偿
我们很多要做的事情是需要多个步骤,而且要保证每一件事都成功,否则整件事就做不到。但在完成一件事的过程中,中间有某个步骤可能突然失败或者发生改变,这个时候我们需要进行业务补偿,来将服务做成幂等性的。
比如我们出门旅游,如果我们突然订不到返程机票,那我们就去不了了,这时候需要把去的机票和酒店都给取消了,或者将返程的机票换成高铁,这些都是业务上的补偿。
一般来说,业务的事务补偿都是需要一个工作流引擎的。亚马逊是一个超级喜欢工作流引擎的公司,这个工作流引擎把各式各样的服务给串联在一起,并在工作流上做相应的业务补偿,整个过程设计成为最终一致性的。
对于业务补偿来说,首先需要将服务做成幂等性的,如果一个事务失败了或是超时了,我们需要不断地重试,努力地达到最终我们想要的状态。然后,如果我们不能达到这个我们想要的状态,我们需要把整个状态恢复到之前的状态。另外,如果有变化的请求,我们需要启动整个事务的业务更新机制。
# 6.3 业务补偿的设计重点
业务补偿主要做两件事:
- 努力地把一个业务流程执行完成。
- 如果执行不下去,需要启动补偿机制,回滚业务流程。
所以,下面是几个重点:
- 因为要把一个业务流程执行完成,需要这个流程中所涉及的服务方支持幂等性。并且在上游有重试机制。
- 我们需要小心维护和监控整个过程的状态,所以,千万不要把这些状态放到不同的组件中,最好是一个业务流程的控制方来做这个事,也就是一个工作流引擎。所以,这个工作流引擎是需要高可用和稳定的。这就好像旅行代理机构一样,我们把需求告诉它,它会帮我们搞定所有的事。如果有问题,也会帮我们回滚和补偿的。
- 补偿的业务逻辑和流程不一定非得是严格反向操作。有时候可以并行,有时候可以串行,可能会更简单。总之,设计业务正向流程的时候,也需要设计业务的反向补偿流程。
- 我们要清楚地知道,业务补偿的业务逻辑是强业务相关的,很难做成通用的。
- 下层的业务方最好提供短期的资源预留机制。就像电商中的把货品的库存预先占住等待用户在 15 分钟内支付。如果没有收到用户的支付,则释放库存。然后回滚到之前的下单操作,等待用户重新下单。
# 6.4 小结
这一节首先介绍了 ACID 和 BASE 两种不同级别的一致性。
要实现 BASE 事务,需要实现补偿逻辑,因为事务可能失败,此时需要协调各方进行撤销。补偿的各个步骤可以根据具体业务来确定是串行还是并行。由于补偿事务是和业务强相关的,所以必须实现在业务逻辑里。