一致性是一个抽象的、具有多重含义的计算机术语, 在不同的应用场景下有不同的定义 和含义。在传统 IT 时代, 一致性通常指强一致性,强一致性通常体现在“你中有我、我中有你、浑然一体”;而在互联网时代, 一致性的含义远远超出了它的原有含义。在讨论互联网时 代的一致性之前,我们先了解一下互联网时代的特点。互联网时代信息量巨大, 需要非常强 大的计算能力,不但要求对用户的响应速度快,还要求吞吐量指标向外扩展(即水平伸缩), 于是单节点的服务器无法满足人们的需求,服务节点开始池化。想想那个经典的故事, 一只
筷子一折就断, 一把筷子怎么折都折不断,可见人多力量大的思想有多么重要。但是人多也
不一定能解决所有事情,还得有序、合理地分配任务,井进行有效的管理,于是互联网时代 谈论最多的话题就是拆分。拆分一般分为水平拆分和垂直拆分,这并不单指对数据库或者缓 存的拆分,主要是表达一种分而治之的思想和逻辑。
- 水平拆分指由于单一节点无法满足性能需求,需要扩展为多个节点,多个节点具有一致 的功能,组成一个服务池, 一个节点服务一部分的请求量,所有节点共同处理大规模 高井发的请求量。
- 垂直拆分指按照功能进行拆分,秉着“专业的人干专业的事”的原则,把一个复杂的功 能拆分为多个单一、简单的功能,不同的单一功能组合在一起,和未拆分前完成的功 能是一样的。由于每个功能职责单一、简单,使得维护和变更都变得更简单、容易、 安全,所以更易于产品版本的选代,还能够快速地进行敏捷发布和上线。
在这样的互联网时代, 一致性指分布式服务化系统之间的弱一致性,包括应用系统的一致 性和数据的一致性。
无论是水平拆分还是垂直拆分,都解决了特定场景下的特定问题,然而,拆先后的系统或 者服务化的系统的最大问题就是一致性问题:对于这么多具有单一功能的模块,或者同一个功 能池中的多个节点,如何保证它们的信息、工作进度、状态一致并且协调有序地工作呢?
下面我们将对服务化系统中最难解决的一致性问题进行研究和探讨
本节会列举几种不一致的实际案例, 在后续会陆续针对这些案例所涉及的问题和场景 给出不同的解决方案和设计模式。
案例 1:下订单和扣库存
电商系统中有一个经典的案例, 即下订单和扣库存如何保持一致。 如果先下订单,扣库存 失败,那么将会导致超卖;如果下订单不成功,扣库存成功,那么会导致少卖。 这两种情况都
会导致运营成本增加,在严重情况下需要赔付。
案例 2:同步调用超时
服务化的系统间调用常常因为网络问题导致系统间调用超时,即使是网络状况很好的机房, 在亿次流量的基数下,同步调用超时也是家常便饭。系统 A 同步调用系统 B 超时,系统 A 可 以明确得到超时反馈,但是无法确定系统 B 是否已经完成了预设的功能。 于是,系统 A 不知道 应该继续做什么,如何反馈给使用方。
曾经有一个 B2B 支付产品的客户要求在接口超时的情况下重新通知他们,这在技术上难以 实现,因为服务器本身可能并不知道自己超时,可能会继续正常地返回数据,只是客户端并没 有接收到结果, 因此这不是一种合理的解决方案。
案例 3:异步回调超时
此案例和上一个同步超时的案例类似,不过这是一个受理模式的场景,使用了异步回调返 回处理结果,系统 A 同步调用系统 B 发起指令,系统 B 采用受理模式,受理后则返回成功信息, 然后系统 B 处理后异步通知系统 A 处理结果。在这个过程中,如果系统 A 由于某种原因迟迟 没有收到回调结果,那么这两个系统间的状态就不一致, 互相认知的状态不同会导致系统间发 生错误,在严重情况下会影响核心链路上的交易的状态准确性,甚至会导致资金损失。
案例 4:掉单
在分布式系统中,两个系统协作处理一个流程,分别为对方的上下游,如果一个系统中存在一个请求(通常指订单),另外一个系统不存在,则会导致掉单,掉单的后果很严重,有时也 会导致资金损失。
案例 5:系统间状态不一致
此案例与上面掉单的案例类似,不同的是两个系统间都存在请求,但是请求的状态不一致。
案例 6:缓存和数据库不一致
交易系统基本上离不开关系型数据库,依赖关系型数据库提供的 ACID 特性,但是在大规 模、高并发的互联网系统里, 一些特殊的场景对读操作的性能要求极高,服务于交易的数据库 难以抗住大规模的读流量,通常需要在数据库前增加一层缓存,那么缓存和数据库之间的数据 如何保持一致性?是要保持强一致性还是弱一致性呢?
案例 7:本地缓存节点间不一致
一个服务池上的多个节点为了满足较高的性能需求,需要使用本地缓存,这样每个节点都 会有一份缓存数据的复制,如果这些数据是静态的、不变的,就永远不会有问题,但是如果这 些数据是半静态的或者经常被更新的,则被更新时各个节点的更新是有先后顺序的,在更新的 瞬间,在某个时间窗口内各个节点的数据是不一致的,如果这些数据是为某个开关服务的,则 想象一下重复的请求进入了不同的节点(在 failover 重试或者补偿的场景下,重复请求是一定会 发生的,也是服务化系统必须处理的), 一个请求进入了开关打开的逻辑,同时另外一个请求进 入了开关关闭的逻辑, 会导致请求被处理两次,最坏的情况是导致资金损失。
案例 8:缓存数据结构不一致
这个案例时有发生,某系统需要在缓存中暂存某种类型的数据, 该数据由多个数据元素组成, 其中,某个数据元素需要从数据库或者服务中获取,如果一部分数据元素获取失败,则由于程序 处理不正确,仍然将不完全的数据存入缓存中,在缓存使用者使用时很有可能因为数据的不完全 而抛出异常,例如 Nul!PointerException 等,然后可能因为没有合理处理异常而导致程序出错。
接下来针对前面抛出的一致性问题,逐个进行分析并提出解决方案,最后形成通用的设计模式。
ACID 在英文中的意思是“酸”, BASE 的意思是“碱”,这里讲的是“酸碱平衡”的故事。
如何保证强一致性呢?计算机专业的同学在学习关系型数据库时都学习了 ACID 原理,这 里对 ACID 做个简单介绍。
关系型数据库天生用于解决具有复杂事务场景的问题,完全满足 ACID 的特性。
ACID 指如下内容。
• A: Atomicity,原子性。
• C: Consistency, 一致性。
• I: Isolation,隔离性。
• D: Durability,持久性。
具有 ACID 特性的数据库支持强一致性,强一致性代表数据库本身不会出现不一致, 每个事 务都是原子的,或者成功或者失败,事物间是隔离的,互相完全不受影响,而且最终状态是持久 落盘的。因此,数据库会从一个明确的状态过渡到另外一个明确的状态,中间的临时状态是不会 出现的,如果出现也会及时地自动修复,因此是强一致的。 3 个典型的关系型数据库 Oracle、 MySQL、 DB2 都能保证强一致性,通常是通过多版本控制协议(MVCC)来实现的。
如果在为交易的相关系统(电商系统)做技术选型,则交易的存储应该只考虑关系型数据库,对于核心 系统,如果需要较好的性能,则可以考虑使用更强悍的硬件,这种向上扩展 (升级硬件)虽然 成本较高,却是最简单、有效的。另外, NoSQL 完全不适合交易场景,主要用来做数据分析、 ETL、报表、数据挖掘、推荐、日志处理、调用链跟踪等非核心交易场景。
在2中提到的案例 1 在数据量较小的情况下,可以利用关系型数据库的强一致性解决, 也就 是把订单表和库存表放在同一个关系型数据库中,利用关系型数据库进行下订单和扣库存两个 紧密相关的操作,达到订单和库存实时一致的结果。
然而,前面提到,互联网项目大多数具有大规模、高并发的特性,必须使用拆分的理念,
对高井发的压力“分而治之、大而化小、小而化了飞否则难以满足动辄亿级流量的需求,即使 使用关系型数据库,单机也难以满足存储和吞吐量上的性能需求。对于案例1 ,应尽量保证将 订单和库存放入同一个数据库分片,这样通过关系型数据库就解决了不一致的问题。
但有时事与愿违,由于业务规则的限制,我们无法将相关数据分到同一个数据库分片,这 时就需要实现最终一致性。
由于对系统或者数据进行了拆分,我们的系统不再是单机系统,而是分布式系统,针对分 布式系统的 CAP 原理包含如下三个元素。
• C: Consistency, 一致性。 在分布式系统中的所有数据备份,在同一时刻具有同样的值, 所有节点在同一时刻读取的数据都是最新的数据副本。
• A: Availability,可用性,好的响应性能。完全的可用性指的是在任何故障模型下,服 务都会在有限的时间内处理完成井进行响应。
• P: Partition tolerance,分区容忍性。尽管网络上有部分消息丢失,但系统仍然可继续工 作。
CAP 原理证明,任何分布式系统只可同时满足以上两点,无法三者兼顾。 由于关系型数据 库是单节点无复制的,因此不具有分区容忍性,但是具有一致性和可用性,而分布式的服务化 系统都需要满足分区容忍性,那么我们必须在一致性和可用性之间进行权衡。如果在网络上有 消息丢失,也就是出现了网络分区,则复制操作可能会被延后,如果这时我们的使用方等待复 制完成再返回,则可能导致在有限时间内无法返回,就失去了可用性;而如果使用方不等待复 制完成, 而在主分片写完后直接返回,则具有了可用性,但是失去了一致性。
BASE 思想解决了 CAP 提出的分布式系统的一致性和可用性不可兼得的问题,
BASE 是“碱”的意思, ACID 是“酸”的意思,基于这两个名词提出了“酸碱平衡”的理论,简单来说就是在不同的场景下,可以分别利用 ACID 和 BASE 来解决分布式服务化系统的 一致性问题。
BASE 思想与 ACID 原理截然不同,它满足 CAP 原理,通过牺牲强一致性获得可用性, 一 般应用于服务化系统的应用层或者大数据处理系统中,通过达到最终一致性来尽量满足业务的 绝大多数需求。
BASE 模型包含如下三个元素。
• BA: Basically Available,基本可用 。
• S: Soft State,软状态,状态可以在一段时间内不同步。
• E: Eventually Consistent,最终一致,在一定的时间窗口内, 最终数据达成一致即可。
软状态是实现 BASE 思想的方法,基本可用和最终一致是目标。以 BASE 思想实现的系统 由于不保证强一致性,所以系统在处理请求的过程中可以存在短暂的不一致,在短暂的不一致 的时间窗口内,请求处理处于临时状态中,系统在进行每步操作时,通过记录每个临时状态, 在系统出现故障时可以从这些中间状态继续处理未完成的请求或者退回到原始状态,最终达到 一致状态。
以转账为例,我们将用户 A 向用户 B 转账分成 4 个阶段:第 1个阶段,用户 A 准备转账; 第 2 个阶段,从用户 A 账户扣减余额:第 3 个阶段,对用户 B 增加余额;第 4 个阶段, 完成转 账。系统需要记录操作过程中每个步骤的状态, 一旦系统出现故障,系统便能够自动发现没有 完成的任务,然后根据任务所处的状态继续执行任务,最终彻底完成任务, 资金从用户 A 的账 户转账到用户 B 的账户,达到最终的一致状态。
在实际应用中,上面这个过程通常是通过持久化执行任务的状态和环境信息, 一旦出现问 题,则定时任务会捞取未执行完的任务,继续执行未执行完的任务, 直到执行完成,或者取消 己经完成的部分操作并回到原始状态。这种方法在任务完成每个阶段时,都要更新数据库中任 务的状态,这在大规模、高井发系统中不会有太好的性能, 一种更好的办法是用 Write-Ahead Log (写前日志),这和数据库的 Bin Log (操作日志〉相似,在进行每个操作步骤时,都先写入日志, 如果操作遇到问题而停止,则可以读取日志井按照步骤进行恢复,继续执行未完成的工作, 最 后达到一致的状态。 写前日志可以利用机械硬盘的追加写来达到较好的性能,然而这是一种专 业化的实现方式,多数业务系统还是使用数据库记录的字段来记录任务的执行状态,也就是记录中间的 “软状态飞 一个任务的状态流转一般可以通过数据库的行级锁来实现,这比使用写前 日志实现更简单、快速。
有了 BASE 思想作为基础,我们对复杂的分布式事务进行拆解,对其中的每个步骤都记录 其状态,有问题时可以根据记录的状态来继续执行任务,达到最终一致。通过这种方法我们可 以解决 2 节案例 1 中下订单和扣库存的一致性问题。
这一小节介绍了 ACID、 CAP 和 BASE 理论,由于 ACID 和 BASE 理论的名字与英文中的酸和 碱相同,因此我们称其为酸碱平衡理论,后续我们解决分布式系统中的一致性问题都是依据这 些原理的,下面是解决一致性问题的三条实践经验。
· 使用向上扩展(强悍的硬件)井运行专业的关系型数据库(例如 Oracle、DB2 和 MySQL), 能够保证强一致性,能用向上扩展解决的问题都不是问题。
·如果向上扩展的成本很高,则可以对廉价硬件运行的开源关系型数据库(例如 MySQL) 进行水平伸缩和分片,将相关数据分到数据库的同一个片上,仍然能够使用关系型数 据库保证事务。
·如果业务规则限制,无法将相关数据分到同一个分片上,就需要实现最终一致性,在记 录事务的软状态(中间状态、临时状态)时若出现不一致,则可以通过系统自动化或 者人工干预来修复不一致的问题。
国际开放标准组织 Open Group 定义了 DTS (分布式事务处理模型〉,模型中包含 4 种角色: 应用程序、事务管理器、资源管理器和通信资源管理器。事务管理器是统管全局的管理者,资 源管理器和通信资源管理器是事务的参与者。
JEE(Java 企业版〉规范也包含此分布式事务处理模型的规范,并在所有 AppServer 中进行 实现。在 JEE 规范中定义了 TX 协议和XA协议, TX 协议定义应用程序与事务管理器之间的接 口, XA 协议则定义事务管理器与资源管理器之间的接口。在过去使用 AppServer 如 WebSphere、WebLogic、 JBoss 等配置数据源时会看见类似 XADatasource 的数据源,这就是实现了分布式事 务处理模型的关系型数据库的数据源。在企业级开发 JEE 中,关系型数据库、 JMS 服务扮演资 源管理器的角色, 而 EJB 容器扮演事务管理器的角色。
下面我们介绍两阶段提交协议、 三阶段提交协议及阿里巴巴提出的 TCC,它们都是根据 DTS 这一思想演变而来的。
JEE 的 XA 协议就是根据两阶段提交来保证事务的完整性,并实现分布式服务化的强一致
性。
两阶段提交协议把分布式事务分为两个阶段, 一个是准备阶段,另一个是提交阶段。 准备
阶段和提交阶段都是由事务管理器发起的, 为了接下来讲解方便,我们将事务管理器称为协调 者, 将资源管理器称为参与者。
两阶段提交协议的流程如下所述。
· 准备阶段: 协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可 以完成,则会写 redo 或者 undo 日在、(Write-Ahead Log 的一种),然后锁定资源,执行 操作,但是并不提交。
· 提交阶段: 如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,则协 调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任 何一个参与者明确返回准备失败, 也就是预留资源或者执行操作失败,则协调者向参 与者发起中止指令,参与者取消己经变更的事务,执行 undo 日志,释放锁定的资源。
两阶段提交协议的成功场景如下图
我们看到两阶段提交协议在准备阶段锁定资源,这是一个重量级的操作, 能保证强一致性, 但是实现起来复杂、成本较高、不够灵活,更重要的是它有如下致命的问题。
. 阻塞:从上面的描述来看,对于任何一次指令都必须收到明确的响应,才会继续进行下 一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。
单点故障:如果协调者岩机,参与者没有协调者指挥,则会一直阻塞,尽管可以通过选 举新的协调者替代原有协调者,但是如果协调者在发送一个提交指令后岩机,而提交 指令仅仅被一个参与者接收,并且参与者接收后也岩机,则新上任的协调者无法处理 这种情况。
· 脑裂:协调者发送提交指令,有的参与者接收到并执行了事务,有的参与者没有接收到 事务就没有执行事务,多个参与者之间是不一致的。
上面的所有问题虽然很少发生,但都需要人工干预处理,没有自动化的解决方案,因此两 阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常的情况下,当前处理的操 作处于错误状态,需要管理员人工干预解决, 因此可用性不够好,这也符合 CAP 协议的一致性 和可用性不能兼得的原理。
三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题, 井且 把两个阶段增加为以下三个阶段。
· 询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是或不是,而不需 要做真正的操作,这个阶段超时会导致中止。
· 准备阶段: 如果在询问阶段所有参与者都返回可以执行操作,则协调者向参与者发送预 执行请求,然后参与者写 redo 和 undo 日志,执行操作但是不提交操作:如果在询问阶 段任意参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻 辑与两阶段提交协议的准备阶段是相似的。
· 提交阶段:如果每个参与者在准备阶段返回准备成功,也就是说预留资源和执行操作成 功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源: 如果任何参与者返回准备失败,也就是说预留资源或者执行操作失败,则协调者向参 与者发起中止指令,参与者取消已经变更的事务,执行 undo 日志,释放锁定的资源, 这里的逻辑与两阶段提交协议的提交阶段一致。
三阶段提交协议的成功场景示意图如下图
三阶段提交协议与两阶段提交协议主要有以下两个不同点。
. 增加了一个询问阶段,询问阶段可以确保尽可能早地发现无法执行操作而需要中止的行 为,但是它并不能发现所有这种行为,只会减少这种情况的发生。
· 在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,则协调者和 参与者都会继续提交事务,默认为成功,这也是根据概率统计超时后默认为成功的正 确性最大。
三阶段提交协议与两阶段提交协议相比,具有如上优点,但是一旦发生超时,系统仍然会 发生不一致,只不过这种情况很少见,好处是至少不会阻塞和永远锁定资源。
前两节讲解了两阶段提交协议和三阶段提交协议,实际上它们能解决2节案例 1 中分布式事 务的问题,但是遇到极端情况时,系统会产生阻塞或者不一致的问题,需要运营或者技术人员解决。 两阶段及三阶段方案中都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大 的问题,因此,在互联网的高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。
后来有人提出了 TCC 协议, TCC 协议将一个任务拆分成 Try、 Confirm、 Cancel 三个步骤, 正常的流程会先执行 T可,如果执行没有问题,则再执行 Confirm,如果执行过程中出了问题, 则执行操作的逆操作 Cancel。 从正常的流程上讲,这仍然是一个两阶段提交协议,但是在执行 出现问题时有一定的自我修复能力,如果任何参与者出现了问题,则协调者通过执行操作的逆 操作来 Cancel 之前的操作,达到最终的一致状态。
可以看出,从时序上来说,如果遇到极端情况,则 TCC 会有很多问题,例如,如果在取消 时一些参与者收到指令,而另一些参与者没有收到指令,则整个系统仍然是不一致的。对于这 种复杂的情况,系统首先会通过补偿的方式尝试自动修复,如果系统无法修复,则必须由人工 参与解决。
从 TCC 的逻辑上看,可以说 TCC 是简化版的三阶段提交协议,解决了两阶段提交协议的 阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。 然而, TCC 通过自动化补偿 手段,将需要人工处理的不一致情况降到最少,也是一种非常有用的解决方案。某著名 的互联 网公司在内部的一些中间件上实现了 TCC 模式。
我们给出一个使用 TCC 的实际案例,在秒杀的场景中,用户发起下订单请求,应用层先查 询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付, 由于某种原因用户支付失败或者支付超时,则系统会自动将锁定的库存解锁以供其他用户秒杀。
TCC协议的使用场景如下图 所示。
在大规模、 高井发服务化系统中, 一个功能被拆分成多个具有单一功能的子功能, 一个流 程会有多个系统的多个单一功能的服务组合实现,如果使用两阶段提交协议和三阶段提交协议, 则确实能解决系统间的一致性问题。 除了这两个协议的自身问题,其实现也比较复杂、成本比 较高,最重要的是性能不好,相比来看, rec 协议更简单且更容易实现,但是 rec 协议由于 每个事务都需要执行 Try,再执行 Confirm, 略显雕肿,因此,现实系统的底线是仅仅需要达到 最终一致性,而不需要实现专业的、复杂的一致性协议。 实现最终一致性有一些非常有效、简 单的模式,下面就介绍这些模式及其应用场景。
任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。 服务操作的使用方可以通过查询接口得知服务操作执行的状态,然后根据不同的状态来做不同的处理操作。
为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对 应的资源 ID 来标识,例如:请求流水号、订单号等。
首先,单笔查询操作是必须提供的,也鼓励使用单笔订单查询,这是因为每次调用需要占 用的负载是可控的。批量查询则根据需要来提供,如果使用了批量查询,则需要有合理的分页 机制,并且必须限制分页的大小,以及对批量查询的吞吐量有容量评估、熔断、隔离和限流等 措施。
查询模式如下图
对于 第二节的案例 2~案例 5,我们都需要使用查询模式来了解被调用服务的处理情况,决 定下一步做什么,例如是补偿未完成的操作还是回漆己经完成的操作。
有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操 作都处于不正常的状态,则我们需要修正操作中有问题的子操作,这可能需要重新执行未完成 的子操作,后者取消己经完成的子操作,通过修复使整个分布式系统达到一致。 为了让系统最 终达到一致状态而做的努力都叫作补偿。
对于服务化系统中同步调用的操作,若业务操作发起方还没有收到业务操作执行方的明确 返回或者调用超时,则可参考案例 2,这时业务发起方需要及时地调用业务执行方来获得操作 执行的状态,这里使用在前面学习的查询模式。 在获得业务操作执行方的状态后,如果业务执 行方已经完成预设工作,则业务发起方向业务的使用方返回成功;如果业务操作执行方的状态 为失败或者未知,则会立即告诉业务使用方失败,也叫作快速失败策略,然后调用业务操作的逆向操作,保证操作不被执行或者回滚己经执行的操作, 让业务使用方、业务操作发起方和业 务操作执行方最终达到一致状态。
· 自动恢复:程序根据发生不一致的环境,通过继续进行未完成的操作,或者回滚己经完 成的操作,来自动达到一致状态。
· 通知运营:如果程序无法自动恢复,并且设计时考虑到了不一致的场景,则可以提供运营功能,通过运营手工进行补偿。
· 技术运营:如果很不巧,系统无法自动回复,又没有运营功能,那么必须通过技术手段 来解决,技术手段包括进行数据库变更或者代码变更,这是最糟的一种场景,也是我 们在生产中尽量避免的场景。
异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求不太高的场 景中,通常把这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系 统通知给使用方。 这个方案的最大好处是能够对高并发流量进行消峰,例如:电商系统中的物 流、配迭,以及支付系统中的计费、入账等。
在实践中将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿 操作来实现异步确保模式,只要定时系统足够健壮,则任何任务最终都会被成功执行。
异步确保模式如下图
对于 第二节中的案例 3,若对某个操作迟迟没有收到响应,则通过查询模式、补偿模式和异 步确保模式来继续未完成的操作。
系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的, 需要通过补偿操作 来达到最终一致性的目的,但是如何来发现需要补偿的操作呢?
在操作主流程中的系统间执行校对操作,可以在事后异步地批量校对操作的状态,如果发 现不一致的操作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。
另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的 ID, 生成全 局唯一 ID 有以下两种方法。
· 持久型: 使用数据库表自增字段或者 Sequence 生成,为了提高效率,每个应用节点可 以缓存一个批次的 ID,如果机器重启则可能会损失一部分 ID,但是这并不会产生任何 问题。
· 时间型:一般由机器号、业务号、时间、单节点内自增 D 组成,由于时间一般精确到 秒或者毫秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增等。
在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是 前面提到的异步确保模型,为了让异步操作的调用方和被调用方充分解楠,也由于专业的消息 队列本身具有可伸缩、可分片、可持久等功能,我们通常通过消息队列实现异步化。对于消息 队列,我们需要建立特殊的设施来保证可靠的消息发送及处理机的幕等性。
1 ) 消息的可靠发送
消息的可靠发送可以认为是尽最大努力发送消息通知,有以下两种实现方法。
第 1 种,在发送消息之前将消息持久到数据库,状态标记为待发送, 然后发送消息,如果 发送成功,则将消息改为发送成功。定时任务定时从数据库捞取在一定时间内未发送的消息并 将消息发送。可靠消息发送模式 1 如下图
第 2 种,该实现方式与第 1 种类似,不同的是持久消息的数据库是独立的, 并不藕合在业 务系统中。发送消息前,先发送一个预消息给某个第三方的消息管理器,消息管理器将其持久 到数据库,并标记状态为待发送,在发送成功后,标记消息为发送成功。定时任务定时从数据库中捞取一定时间内未发送的消息,查询业务系统是否要继续发送,根据查询结果来确定消息 的状态。可靠消息发送模式 2 如下图
一些公司把消息的可靠发送实现在了中间件里,通过 Spring 的注入,在消息发送时自动持 久消息记录,如果有消息记录没有发送成功,则定时补偿发送。
2 )消息处理器的事等性
如果我们要保证可靠地发送消息,简单来说就是要保证消息一定发送出去,那么需要有重 试机制。有了重试机制后,消息就一定会重复,那么我们需要对重复的问题进行处理。
处理重复问题的最佳方式是保证操作的幕等性,幕等性的数学公式为:
f (j(x)) = f (x)
保证操作的幕等性的常用方法如下。
· 使用数据库表的唯一键进行滤重,拒绝重复的请求。
· 使用分布式表对请求进行滤重。
· 使用状态流转的方向性来滤重,通常使用数据库的行级锁来实现。
· 根据业务的特点,操作本身就是幕等的, 例如: 删除一个资源、增加一个资源、获得一 个资源等。
在大规模、高并发系统中的一个常见的核心需求就是亿级的读需求,显然,关系型数据库 并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存来抗住读流量。下面是 使用缓存来保证一致性的最佳实践。
· 如果性能要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存。
· 写缓存时数据一定要完整, 如果缓存数据的一部分有效, 另一部分无效,则宁可在需要 时回源数据库,也不要把部分数据放入缓存中。
· 使用缓存牺牲了一致性,为了提高性能,数据库与缓存只需要保持弱一致性,而不需要 保持强一致性,否则违背了使用缓存的初衷。
· 读的顺序是先读缓存,后读数据库,写的顺序要先写数据库,后写缓存。
这里的最佳实践能够避免 第二 节案例 6、案例 7 和案例 8 中的问题。
在服务化或者微服务架构里,传统的整体应用拆分成多个职责单一的微服务,微服务之间 通过某种网络通信协议互相通信和交互,完成特定的功能。然而,由于网络通信不稳定,我们 在设计系统时必须考虑到对网络通信的容错,特别是对调用超时问题的处理。
服务与服务之间的交互模式可以分为以下 3 类
在同步调用模式中,服务 1 调用服务 2,服务 1的线程阻塞等待服务 2 返回处理结果,如 果服务 2 一直不返回处理结果, 则服务 1 一直等待到超时为止。
同步调用模式如下图
同步调用模式适用于大规模、高并发的短小操作,而不适用于后端负载较高的场景,例如: 几乎所有 JDBC 的实现完全使用 BIO 同步阻塞模式。
在接口异步调用模式中,服务 1请求服务 2 受理某项任务,服务 2 受理后即刻返回给服务1其受理结果,如果受理成功,则服务 1继续做其他任务,而服务 2 异步地处理这项任务,直 到服务 2 处理完这项任务后,才反向地通知服务1任务已经完成,服务 1 再做后续处理。
接口异步调用模式如下图
接口异步调用模式适用于非核心链路上负载较高的处理环节,这个环节经常耗时较长,并且对时效性要求不高。 例如:在 B2C 电商系统中,一件商品售卖成功后,需要给相应的商户入 账收入,这个过程对时效性要求不高,可以使用接口异步调用模式。
消息队列异步处理模式利用消息队列作为通信机制,在这种交互模式中,通常服务 1 只需 将某种事件传递给服务 2,而不需要等待服务 2 返回结果。在这样的场景下,服务 1与服务 2 可以充分解楠,并且在大规模、高并发的微服务系统中,消息队列对流量具有消峰的功能。
消息队列异步处理模式如下图
消息队列异步处理模式与接口异步调用模式类似,多应用于非核心链路上负载较高的处理环节中,井且服务的上游不关心下游的处理结果,下游也不需要向上游返回处理结果。 例如: 在电商系统中,用户下订单支付且交易成功后,后续的物流处理适合使用消息队列异步处理模 式,因为物流发货属于物流和配送系统的职责,不应该影响交易,所以交易系统不需要对其有 感知。
以上三种交互模式普遍应用于服务化和微服务架构中,它们之间没有绝对的好坏,只需要 在特定场景下做出更适合的选择。
一些互联网公司试图通过规范来约束这三种方式的使用和选择,下面是两个不同的团队倡导的关于同步和异步选择的原则。
· 尽量使用异步来替换同步操作。
· 能用同步解决的问题,不要引入异步。
这两个原则从字面意义上看是完全不同的,甚至是矛盾的。实际上, 这里的原则都没有错,只不过原则抽象得太干净利落,以至于没有给出适合这些原则的环境信息。
第 1 条原则是从业务功能的角度出发的,也就是从与用户或者使用方的交互模式出发的, 如果业务逻辑允许,用户对产品的交互形态没有异议,则我们可以将一些耗时较长的、用户对 响应没有特别要求的操作异步化,以此来减少核心链路的层级,释放系统的压力。例如: 12306 在订票高峰期会开启订票异步模式, 在购票后用户并不会马上得知购票的结果,而是后续通过 查询得知结果,这样系统便赢得了为成千上万的用户处理购票逻辑的时间。
第 2 条原则是从技术和架构的角度出发的,这条原则应用的前提是同步能够解决问题, 这 隐含了一个含义:如果性能不是问题,或者所处理的操作是短小的轻量级处理逻辑,那么同步 调用方式是最理想不过的,因为这样不需要引入异步化的复杂处理流程。例如:所有 JDBC 的 实现使用同步阻塞的 BIO 模型,即访问数据库操作时无论是查询还是更新,原则上都是短小操 作,不需要异步化,而是在同步过程中完成请求的受理和处理过程,这也是为什么不推荐将大 数据存储到关系型数据库中,关系型数据库只存储交易相关的最小化核心信息。
本节介绍 4.1 节提出的交互模式下可能遇到的超时问题,井对每个问题给出相应的方法、 模式和解决方案。
在同步调用模式下,对外的接口会提供服务契约,契约定义了服务的处理结果会通过返回 值返回给使用方,对返回的状态定义分为以下两种。
· 成功和失败。
· 成功、失败和处理中。
我们将第 1 种定义称为两状态的同步接口,将第 2 种定义称为三状态的同步接口。
1 )两状态的同步接口
对于上面的第 1 种定义,服务契约中只规定了两种互斥的状态:成功和超时,服务处理结 果必须是成功的或者失败的,在这种情况下可能发生两种同步调用超时。
第 1种同步调用超时发生在使用方调用此同步接口的过程中,如下图 所示
针对这个问题,我们需要服务的使用中提到的查询模式,异步查询处理结果, 在获得明确的处理结果后,得知处理结果是成功还是失败,然后做相应的处理。如果处理结果 为成功,那么使用方可以继续下面的操作;如果结果为失败,那么调用方可以发起重试,请求 再次进行处理。然而,这里有一个问题,如果查询模式的返回状态是未知请求,那么在这种情况下使用方超时,服务 l 实际上没有接收到或者还没有接收到一开始的处理请求,服务使用方需要使用同一个请求 ID 进行重试,服务 l 也必须实现请求处理的幕等性。
第 2 种同步调用超时发生在内部服务 l 调用服务 2 的过程中,如下图
在使用方调用服务 l ,且服务 1 接收到请求后,同步调用服务 2,由于通信出现了问题, 所 以服务 l 得到超时的结果。这时服务 l 应该怎么做呢?是重试、取消还是快速失败?
我们看到上图 的左面,服务 1 对外接口的契约中包含两个返回状态: 成功或者失败,也 就是对于使用方来讲,不允许有中间的处理中的状态,对于这种服务内部超时的场景,必须使 用快速失败的策略: 针对这个超时错误,服务快速返回失败,同时在内部调用服务 2 的冲正接 口,服务 2 的冲正接口可以判断之前是否接收到请求,如果接收到请求井做了处理,则应该做 反向的回滚操作。如果服务 2 之前没有接收到处理请求,则忽略冲正请求,以此来实现服务的 幂等性。
2 )三状态的同步接口
对于上面的第 2 种定义,服务契约中规定了三种处理结果,状态值为:成功、失败和处理 中,对于超时等系统错误的请求,其实可以认为是处理中状态的一个特例,在这种场景的应用 里,超时被视为内部暂时的问题,随后可能被修复,因此,可能在一定的时间窗口内告知使用 方在处理中,随后修复问题井补偿执行,达到最大化请求处理成功的目标,不至于让使用方重 试,以提升用户体验。
服务处理结果可能是成功或者失败,也可能是处理中,在这种情况下可能发生两种同步调 用超时。
第 1 种同步调用超时发生在使用方调用此同步接口的过程中,如图
这种场景和两状态同步调用的接口超时场景类似,使用方调用服务 l 的接口,由于网络等原因获得超时的结果,这时使用方应该将超时看作处理中的一个特例,使用服务 l 的查询接口 后续补齐上一个请求的处理状态,可参照两状态同步调用的接口超时场景的方案。
第 2 种同步调用超时发生在内部服务 1 调用服务 2 的过程中
在使用方调用服务 1, 且服务 1 接收到请求后,同步调用服务 2,由于通信出现了问题,所 以服务1 得到超时的结果,这时服务 1 又应该怎么做呢?
这和两状态同步调用的内部超时场景不一样,两状态设计由于与使用方约定了契约,不是 成功就是失败,所以必须在同步调用时给予一个明确的结果,然而,在三状态同步调用的内部 超时场景下,可以返回给使用方一个中间状态,也就是处理中的结果,变相地把同步接口变成 异步接口 ,达到最终一致的效果。
在这种场景下,我们更倾向于给用户更好的体验,尽最大努力成功处理用户发来的请求。 因此,针对在服务 l 调用服务 2 时超时,我们会返回给用户处理中的状态,随后系统尽最大努 力补偿执行出错的部分,服务 1 需要通过服务 2 的查询接口得到最新的请求处理状态,如果服 务 2 没有明确回复, 则可以尝试重新发送请求,当然,这里需要服务 2 也实现了操作的幂等性。
在异步调用模式下,对外的接口也会提供服务契约,契约定义了服务的受理结果会通过返 回值返回给使用方,返回的状态通常为两个:受理和未受理。和三状态同步调用接口不同的是, 异步调用模式还有异步处理返回结果的通知,状态包括处理成功和处理失败。
不同阶段的网络通信产生的超时和处理方案如下。
1 ) 异步调用接口超时
异步调用接口超时如图
异步调用接口超时发生在使用方调用服务 1 的受理接口时,同两状态同步调用接口超时及三状态同步调用接口超时的场景是一样的,需要通过查询来补齐状态,并根据状态来判断后续的操 作,具体的解决方案参考两状态同步调用接口超时和三状态同步调用接口超时的解决方案。
2 )异步调用内部超时
异步调用内部超时如下图
异步调用内部超时发生在服务1 受理了使用方的请求后,服务 l 在处理请求时,在调用服务 2 的过程中超时,这和三状态同步调用内部超时的场景相似,由于异步调用模式使用的是受 理模式,所以一旦受理,我们便应该尽最大努力将用户请求的操作处理成功,因此,在服务 1调用服务 2 超时的场景下,服务 1 需要根据服务 2 的查询接口获得最新状态,根据状态补偿后 续的操作,这和三状态同步调用内部超时的解决方案一致,不同的是此场景下一旦处理成功, 则需要异步回调通知使用方,而在三状态同步调用内部超时的场景下,只需要等待使用方查询,
不需要通知,也无法实现通知。
3 ) 异步调用回调超时
异步调用回调超时如下图
回调超时的问题在生产中经常出现,通常发生于这样的场景下:服务 l 受理后成功地调用了依赖服务 2,获得了明确的处理结果,但是在将处理结果通知使用方时出现超时。由于使用 方有可能是公司内部的也可能是外部的,网络环境复杂多变,发生超时的概率很大,因此,大 多数公司都会开发一个通知子系统,用来专门处理回调通知。
由于服务 1通过回调通知使用方,所以服务 l 需要保证通知一定可送达,如果遇到超时,则服务 1 负责重新继续补偿,通常会设计一个通知时间按一定间隔递增的策略,例如:指数回 退, 直到通知成功为止,通知是否成功以对方的回写状态为准。
消息队列异步处理模式多用于疏松祸合的项目,这些项目通常是在主流程中无法处理耗时 的任务,恰好耗时的任务又不是核心流程的一部分,比如: 电商平台的物流、配送等。
这类交互使用消息队列进行解辑,电商交易系统成功处理交易后,需要发送消息到消息队 列服务器,后续的流程由物流平台处理,也不需要将处理结果反馈给交易平台。
使用消息队列解辑后,处理流程被分为两个阶段:生产者投递和消费者处理, 在不同的阶 段会产生不同的超时问题,解决方案如下。
1)消息队列的生产者超时
消息队列的生产者超时如下图
2 )消息队列的消费者超时
消息队列的消费者超时如下图
对于消息队列的处理机与消息队列之间的超时或者网络问题,通常可以通过消息队列提供 的机制来解决。
一般消息队列会提供如下两种方式来消费消息。
( 1 )自动增长消费的偏移量:在一个消费者从消息服务器中取走消息后,消息队列的消息
偏移量自动增加,即消息一旦被从消息队列中取走,则不再存在于服务器中,假如消息处理机
对此消息处理失败,则也无法从消息服务器中找回。
(2)手工提交消费的偏移量:在一个消费者从消息服务器中取走消息后,处理机先把消息 持久到本地数据库中,然后告诉消息服务器己经消费消息,消息服务器才会移除消息,如果在 没有告诉消息服务器己经消费消息之前,持久失败或者发生了其他问题,则消息仍然存在于消 息服务器中,消息处理器下次还可以继续消费消息。
如果允许丢消息,则我们使用第 1 种处理方式,这种方式的并发量高、性能好,但是如果 我们对消息处理的准确性要求较高,则必须采用第 2 种方式。
对于 4.3 节的多个场景来说,我们都需要对服务间同步超时造成的后果进行处理,而处理 方法有快速失败和内部补偿两种,补偿模式也有调用方补偿和接收方补偿两种,具体使用哪种方式呢?
我们先来看看生活中类似的问题:假如小明和杰森是员工和领导的关系,杰森将一项任务 交给小明,杰森将任务的目标和内容介绍给小明,然后小明说:“好的,老板,交给我,您不用 管了”,于是小明开启了完成任务的模式,甚至是不遗余力、不择手段地完成任务(暂不考虑公
司的奖惩制度)。小明会这样做,如下图所示。 小明使用了各种方法来完成任务,因为他答应完成任务, 这就形成了无形的契约。
假设小明和杰森仍然是员工和领导的关系,杰森将一项任务交给小明,杰森将任务的 目标 和内容介绍给小明,但是小明手上还有一个重要的任务最近要出结果,于是小明没有做任何表 态,由于某种原因杰森也没有要求小明表态,则这次谈话不了了之。
这时,小明会这样做,如下图
小明仍然忙于其他任务,无暇顾及杰森的新任务,而杰森的任务没有被分配出去, 会周期 性地找小明询问情况, 一旦小明有时间,则再次尝试将任务分配给小明 。
通过这个案例,我们很容易理解服务间调用超时补偿的原则。
本节介绍在应用微服务架构的过程中,对迁移场景下不一致问题的解决方案。
在大多数企业里,新项目和老项目 一般会共存,大家都在努力地去掉老项目 , 但是由于种 种原因总是去不掉,如果要彻底地去掉老项目,就必须有非常完善的迁移方案。
在迁移过程中必须使用开关,开关一般都会基于多个维度来设计,例如: 全局的、用户的、 角色的、商户的、产品的,等等。 如果在迁移过程中遇到问题,则我们需要关闭开关,迁移回 老的系统,这需要我们的新系统兼容老系统的数据,老系统也兼容新系统的数据。从某种意义 上来讲,迁移比实现新系统更加困难。
有的开关设计在应用层次,通过一个 curl 语句调用,没有权限控制,这样的开关在服务池 的每个节点中都有可能是不一致的;还有的系统将开关配置在中心化的配置系统、数据库或者 缓存等中,处理的每个请求都通过统一的开关来判断是否迁移等,这样的开关有一个致命的缺 点:在服务请求的处理过程中,开关可能会有变化,各节点之间的开关可能不同步、不一致, 导致重复的请求可能既走到新逻辑又走了老逻辑,如果新逻辑和老逻辑没有保证军等,则这个 请求就被重复处理了,如果是金融行业的应用,则可能会导致资金损失, 电商系统可能会发生 发货和退款同时进行等问题。
这里推荐使用订单开关,不管我们在什么维度上设计了开关,在接收到服务请求后,在请 求创建的关联实体 (例如:订单)上标记开关,对于以后的处理流程,包括同步的和异步的处 理流程,都通过订单上的开关来判断,而不是通过全局的或者基于配置的开关来判断,这样在订单创建时,开关己经确定,不再变更,若一份数据不再发生变化,那么它永远是并发安全的, 并且不会有不一致的问题。
这种模式在生产中的使用比较频繁,建议每个企业都把这种模式作为设计评审的一项,如 果不检查这一项,则很多开发人员都会偷懒,直接在配置或者数据库中做个开关就上线了 。
本章从一致性问题的实践出发,对大规模、高并发服务化系统的实践经验进行总结, 列举 了导致不一致的具体问题,并围绕这些具体问题,提出一致性原理如 ACID、 CAP 和 BASE 等: 并学习了两阶段、三阶段和 TCC 一致性协议,总结了实现最终一致性的查询模式、补偿模式、 异步确保模式、定期校对模式、可靠消息模式和缓存一致性模式等;最后针对服务化系统中同 步调用、异步调用、消息队列等应用场景详细分析了超时发生的场景和解决方案,以供大家在 开发服务化系统的过程中参考。