关于微服务系统中数据一致性的总结

你好,我是看山。

从单体架构到分布式架构,从巨石架构到微服务架构。系统之间的交互越来越复杂,系统间的数据交互量级也是指数级增长。作为一个系统,我们要保证逻辑的自洽和数据的自洽。

数据自洽有两方面要求:

  1. 抛开代码,数据能够自己验证自己的准确性,也就是数据彼此之间不矛盾
  2. 所有数据准确且符合期望

为了实现这两点,需要实现数据的一致性,为了实现一致性,就需要用到事务。

需要注意一下,本文所设计的数据一致性,不是多数据副本之间保持数据一致性,而是系统之间的业务数据保持一致性。

本地事务

在早期的系统中,我们可以通过关系型数据库的事务保证数据的一致性。这种事务有四个基本要素:ACID。

  • A(Atomicity,原子性):整个事务中的所有操作,要么全部完成,要么全部失败,不可能停滞在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • C(Consistency,一致性):一个事务可以封装状态改变(除非它是一个只读的)。事务必须始终保持系统处于一致的状态,不管在任何给定的时间并发事务有多少。
  • I(Isolation,隔离性):隔离状态执行事务,使它们好像是系统在给定时间内执行的唯一操作。如果有两个事务,运行在相同的时间内,执行相同的功能,事务的隔离性将确保每一事务在系统中认为只有该事务在使用系统。这种属性有时称为串行化,为了防止事务操作间的混淆,必须串行化或序列化请求,使得在同一时间仅有一个请求用于同一数据。
  • D(Durability,持久性):在事务完成以后,该事务对数据库所作的更改便持久的保存在数据库之中,并不会被回滚。

这四个要素是关系型数据库的根本。无论系统多么复杂,只要使用同一个关系型数据库,我们就可以借助事务保证数据一致性。基于对关系型数据库的信任,我们可以认为本地事务是可靠的,开发过程中不需要额外的工作。从架构的角度,关系型数据库也是一个单独的系统,那关系型数据库与应用之间也是形成了分布式。所以我们先研究一下这种简单的分布式系统如何实现 ACID。

首先,A(原子性)和 D(持久性)是彼此之间密不可分的两个属性:原子性保证了事务的所有操作,要么全部完成,要么全部失败,不可能停滞在中间某个环节;持久性保证了一旦事务完成,该事务对数据库所作的更改便持久的保存在数据库之中,不会因为任何原因而导致其修改的内容被撤销或丢失。

众所周知,数据必须写入到磁盘后才能保证持久化,仅仅保存在内存中,一旦出现系统崩溃、主机断电等情况,数据就会丢失。所以,关键是“写入磁盘”要实现原子性和持久性,然而这个动作存在中间态:正在写入。所以,现代的关系型数据库通常采用追加日志记录的方式。将修改数据所需的全部信息(包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等),以顺序追加的形式记录到磁盘中。只有在日志记录全部落盘,数据库在日志中看到代表事务成功提交的“提交记录”后,才会根据日志上的信息对真正的数据进行修改。修改完成后,再在日志中加入一条“结束记录”表示事务已完成持久化,这种事务实现方法被称为“提交日志”。

本地事务

我们能够通过日志保证一个事务的原子性和持久性,那如果出现多个事务访问同一个资源呢?作为程序猿都知道,多个线程/进程访问同一个资源,这个资源就称为临界资源,想要解决临界资源占用冲突的方式很简单,就是加锁。关系型数据库为我们准备了三种锁:

  • 写锁(Write Lock):同一个时刻,只有有一个事务对数据加写锁,所以写锁也被称为排它锁(exclusive Lock)。数据被加了写锁后,其他事务不能写入数据,也不能对其添加读锁(注意,是不能加读锁,但是可以读取数据)。
  • 读锁(Read Lock):同一时刻,多个事务可以对数据添加读锁,所以读锁也被称为共享锁(Shared Lock)。数据库被添加读锁后,数据不能被添加写锁。
  • 范围锁(Range Lock):对一个范围的数据添加写锁,这个范围的数据不能被写入。也可以算作写锁的批量行为。

根据这三种锁的不同组合,我们可以实现四种不同的事务隔离级别:

  • 可串行化(Serializable):写入的时候加写锁,读取的时候加读锁,范围读写的时候加范围锁。
  • 可重复度(Repeatable Read):写入的时候加写锁,读取的时候加读锁,范围读写的时候不加锁,这样会出现读取相同范围数据的时候,返回结果不同,即幻读(Phantom Read)。
  • 读已提交(Read Committed):写入的时候加写锁,读取的时候加读锁,读取完成后立马释放读锁。这样会出现同一个事务多次读取相同数据,返回结果不同,即不可重复读(Non-Repeatable Read)。
  • 读未提交(Read Uncommitted):写入的时候加写锁,读取的时候不加锁。这样就会读取到另一个还未提交的事务写入的数据,即脏读(Dirty Read)。

全局事务

随着系统规模不断扩大,业务量不断增加。单体应用不再满足需求,我们会拆分系统,然后拆分数据库。此时,同一个请求中,就会出现同时访问多个数据库的情况。为了解决这种情况的数据一致性问题,X/Open 组织在 1991 年(那个时候我还小)提出了一套 X/Open XA 的处理事务的架构。XA 的核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口,在一个事务管理器和多个资源管理器(Resource Manager)之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚。与 XA 架构配套的是两阶段提交协议(2PC,Two Phase Commitment Protocol)。在这个协议中,最关键的点就是,多个数据库的活动,均由一个事务协调器的组件来控制。具体的分为 5 个步骤:

  1. 应用程序调用事务管理器中的提交方法
  2. 事务管理器将联络事务中涉及的每个数据库,并通知它们准备提交事务(这是第一阶段的开始)
  3. 接收到准备提交事务通知后,数据库必须确保能在被要求提交事务时提交事务,或在被要求回滚事务时回滚事务。如果数据库无法准备事务,它会以一个否定响应来回应事务管理器。
  4. 事务管理器收集来自各数据库的所有响应。
  5. 在第二阶段,事务管理器将事务的结果通知给每个数据库。如果任一数据库做出否定响应,则事务管理器会将一个回滚命令发送给事务中涉及的所有数据库。如果数据库都做出肯定响应,则事务管理器会指示所有的资源管理器提交事务。一旦通知数据库提交,此后的事务就不能失败了。通过以肯定的方式响应第一阶段,每个资源管理器均已确保,如果以后通知它提交事务,则事务不会失败。

2PC

两阶段提交协议实现简单,但存在几个明显缺陷:

  • 单点问题:事务管理器在两段提交中具有举足轻重的作用,事务管理器等待资源管理器回复时可以有超时机制,允许资源管理器宕机,但资源管理器等待事务管理器指令时无法做超时处理。一旦宕机的不是其中某个资源管理器,而是事务管理器的话,所有资源管理器都会受到影响。如果事务管理器一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有资源管理器都必须一直等待。
  • 性能问题:两段提交过程中,所有资源管理器相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,事务管理器做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到资源管理器集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
  • 一致性风险:尽管提交阶段时间很短,但这仍是一段明确存在的危险期。如果事务管理器在发出准备指令后,根据收到各个资源管理器发回的信息确定事务状态是可以提交的,事务管理器会先持久化事务状态,并提交自己的事务,如果这时候网络断开,无法再通过网络向所有资源管理器发出 Commit 指令的话,就会导致部分数据(事务管理器的)已提交,但部分数据(资源管理器的)既未提交,也没有办法回滚,产生了数据不一致的问题。

能够发现问题,就能够想到办法解决。我们高中老师说了,只要意识不滑坡,办法总比困难多。所以又发展出了三阶段提交协议(3PC,Three Phase Commitment Protocol),能够缓解单点问题和准备阶段的性能问题。这个协议把 2PC 中的准备阶段拆分为 CanCommit 和 PreCommit,把提交阶段改名为 DoCommit。CanCommit 是询问阶段,让每个资源管理器根据自身情况判断该事务是否有可能完成。

3PC 本质是通过一次问询,如果大家都说自己可以,那成事的可能性很大,减少了准备阶段直接锁定资源的重操作。由于事务失败回滚概率变小的原因,在三段式提交中,如果在 PreCommit 阶段之后发生了事务管理器宕机,即资源管理器没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了事务管理器单点问题的风险。

3PC

分布式事务

说到分布式事务,不得不提 CAP 理论:任何分布式系统只可同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)中的两点,没法三者兼顾。

CAP 理论

  • 一致性(Consistency):数据在任何时刻、任何分布式节点中所看到的都是符合预期的。
  • 可用性(Availability):系统不间断地提供服务的能力,可用性是由可靠性(Reliability)和可维护性(Serviceability)计算得出的比例值。可靠性通过平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性通过平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,公式为:A=MTBF/(MTBF+MTTR)。
  • 分区容错性(Partition Tolerance):分布式环境中部分节点因网络原因而彼此失联后,系统仍能正确地提供服务的能力。

CAP 理论定义是经过几次修改的,修改后的定义本质没有区别,只是在逻辑上更加严谨。本文为了好理解,使用了最容易让大众接收并理解的定义。

既然 CAP 不能兼顾,那我们来看看缺少其中一环会出现什么情况:

  • 选择 CA 放弃 P:即我们认为网络可靠不会出现分区情况,这种可靠是各个节点之间不会出现网络延迟、中断等情况,显然是不成立的。
  • 选择 CP 放弃 A:这样做就是抛弃了可用性,为了保证数据一致性,一旦出现网络异常,节点之间的信息同步时间可以无限制地延长。使用 CP 组合的一般用于对数据质量要求很高的场合,也就是为了保证数据完全一致,暂时不提供服务,直到网络完全恢复,这可能持续一个不确定的时间,尤其是在系统已经表现出高延迟时或者网络故障导致失去连接时。
  • 选择 AP 放弃 C:意味着一旦发生网络分区,优先提供服务可用,放弃数据一致性。这是目前分布式系统的主流选择,因为网络本身就是链接不同区域的服务器的,网络又是不可靠的,所以 P 不能被舍弃。同时,我们实现分布式系统就是为了提高可用性,这是我们的目的,不能舍弃。

这里需要再说明一下,我们选择 AP 放弃 C 不是放弃数据一致,而是暂时放弃强一致性(Strong Consistency),而是选择弱一致性,即最终一致性(Eventual Consistency):系统中的所有数据副本经过一段时间后,最终能够达到一致的状态。这里所说的一段时间,也要是用户可接受范围内的一段时间。

最终一致性也有一个理论支撑:BASE 理论(不得不说,理论界的缩写真牛啊,ACID 是酸,CAP 是帽子,BASE 是碱),内容主要包括:

  • 基本可用(Basically Available):当系统在出现不可预知故障的时候,允许损失部分可用性。比如,允许响应时间增长,允许部分非关键接口降级或熔断等。
  • 软状态(Soft State):软状态也称为弱状态,和硬状态相对。是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  • 最终一致性(Eventually Consistent):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

在工程实践中,最终一致性分为 5 种,这 5 种方式会结合使用,共同实现最终一致性:

  • 因果一致性(Causal consistency):如果节点 A 在更新完某个数据后通知了节点 B,那么节点 B 之后对该数据的访问和修改都是基于 A 更新后的值。于此同时,和节点 A 无因果关系的节点 C 的数据访问则没有这样的限制。
  • 读己之所写(Read your writes):节点 A 更新一个数据后,它自身总是能访问到自身更新过的最新值,而不会看到旧值。
  • 会话一致性(Session consistency):会话一致性将对系统数据的访问过程框定在了一个会话当中,系统能保证在同一个有效的会话中实现“读己之所写”的一致性,也就是说,执行更新操作之后,客户端能够在同一个会话中始终读取到该数据项的最新值。
  • 单调读一致性(Monotonic read consistency):如果一个节点从系统中读取出一个数据项的某个值后,那么系统对于该节点后续的任何数据访问都不应该返回更旧的值。
  • 单调写一致性(Monotonic write consistency):一个系统要能够保证来自同一个节点的写操作被顺序的执行。

一致性关系模型

有了理论之后,我们来说一下实现最终一致性的几种模式。

可靠事件模式

可靠事件模式属于事件驱动架构:当某个事件发生时,例如更新一个业务实体,服务会向消息代理发布一个事件。消息代理会向订阅事件的服务推送事件,当订阅这些事件的服务接收此事件时,就可以完成自己的业务,也可能会引发更多的事件发布。

我们通过一个例子来解释一下这种模式,用户下单成功后,订单系统需要通知库存系统减库存。

可靠事件模式

  1. 订单系统根据用户操作完成下单操作。此时会使用同一个本地事务保存订单信息和写入事件。
  2. 另外一个消息服务会轮询事件表,将状态是“进行中”的事件以消息形式发送到消息服务中。如果发送失败,因为是轮询任务,会在下一次轮询的时候再次发送。(此处有一些优化点,本例为了简化模型,不展开)
  3. 消息服务向订阅下单消息的库存服务发送下单成功消息,库存服务开始处理。此时会有这么集中情况:
    1. 库存服务扣减库存成功,消息服务接收到处理成功响应。消息服务将响应结果返回给订单服务,订单服务中事件接收器将事件修改为“已完成”。
    2. 库存服务扣减库存失败,消息服务接收到处理失败响应。此时消息服务会再次向库存服务发送消息,直到得到成功响应。如果失败次数达到阈值,可以告警通知人工介入。
    3. 消息服务给订单服务返回结果时,发生失败,订单服务没有接收到成功响应。这个时候,事件轮询逻辑会再次将事件发送给消息服务。这样,库存服务会重复收到扣减库存的消息,所以要求库存服务做好幂等。库存服务发现消息已经处理过,直接返回成功。

这种靠着持续重试来保证可靠性的解决方案,叫做“最大努力交付”(Best-Effort Delivery),也是“可靠”两个字的来源。

可靠事件模式还有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),指的就是将最有可能出错或最核心的业务以本地事务的方式完成后,采用不断重试的方式(不限于消息服务)来促使同一个分布式事务中的其他关联业务全部完成。找到最可能出错的方式是提前做好出错概率的先验评估,才能够知道哪块最容易出错。找到最核心的业务的方式是找到那种只要成功,其他业务必须成功的那块业务。

这里我们再补充两个概念:

  • 业务异常:业务逻辑产生错误的情况,比如账户余额不足、商品库存不足等。
  • 技术异常:非业务逻辑产生的异常,如网络连接异常、网络超时等。

TCC 模式

TCC(Try-Confirm-Cancel)是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认/释放消费资源”两个子过程,由统一的服务协调调度不同业务系统的子过程。分为以下三个阶段:

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需要用到的业务资源(保障隔离性)。
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,需要满足幂等性。
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,需要满足幂等性。

TCC 模式

  1. 订单系统创建事务,生成事务 ID(用于作为识别请求幂等的标识),通过活动管理器记录活动日志。
  2. 进入 Try 阶段
    1. 调用账户系统,检查账户余额是否充足,如果充足,冻结需要的金额,此时账户余额是临界资源,需要通过排它锁或乐观锁保证冻结操作的安全性。
    2. 调用库存系统,检查商品库存是否充足,如果充足,锁定需要的库存,锁库操作也需要加锁保证安全
  3. 如果所有业务返回成功,记录活动日志为 Confirm,进入 Confirm 阶段:
    1. 调用账户系统,扣减冻结的金额
    2. 调用库存系统,扣减锁定的库存
  4. 第 3 步操作中如果全部完成,事务宣告结束。如果第 3 步中任何一方出现异常,都会根据活动日志中的记录,重复执行 Confirm 操作,即进行最大努力交付。所以各业务系统的 Confirm 操作需要实现幂等性。
  5. 如果第 2 步有任何一方失败(包括业务异常和技术异常),将活动日志记录为 Cancel,进入 Cancel 阶段:
    1. 调用账户系统,释放冻结的金额
    2. 调用库存系统,释放锁定的库存
  6. 第 5 步操作中如果全部完成,事务宣告失败。如果第 5 步中任何一方出现异常(包括业务异常和技术异常),都会根据活动日志中的记录,重复执行 Cacel 操作,即最大努力交付。所以各业务系统的 Cancel 操作也需要实现幂等性。

是不是感觉 TCC 与 2PC 的很像,两者的区别在于,TCC 位于业务代码层面,属于白盒,2PC 位于基础设施层面,属于黑盒。所以 TCC 有更高的灵活性,可以根据需要,调整资源锁定的粒度。

TCC 在业务执行过程中可以预留资源,解决了可靠事件模式的资源隔离问题。但是,TCC 还有两个明显缺点:

  1. TCC 将基础设施层的逻辑上移到业务代码,对业务有很高的侵入性,需要更高的开发成本,开发成本提升,相对应的维护成本、开发人员的素质等,都会有更高的要求。
  2. TCC 要求资源可以锁定、占用或释放,但是有的资源属于外部系统,没有办法实现锁定。

鉴于上面的两个缺点,我们看看 SAGA 是否可以弥补。

SAGA 模式

SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。SAGA 模式的提出远早于分布式事务概念的提出(再次对前辈大佬佩服的五体投地),它源于 1987 年普林斯顿大学的 Hector Garcia-Molina 和 Kenneth Salem 在 ACM 发表的一篇论文《SAGAS》。文中提出了一种提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务集合,后来发展成将一个分布式环境中的大事务分解为一系列本地事务的设计模式。在有的文章中,将这种模式称为业务补偿模式,SAGA 是对事务形式的描述,业务补偿是对事务行为的描述,其本质是一样的。

SAGA 模式有两种实现:

  • 正向恢复(Forward Recovery):顺序执行各个子事务,如果遇到某个子事务执行失败,将一直重试该操作,知道成功,然后继续执行下一个子事务。比如用户下单支付成功了,就一定要扣减库存。
  • 反向恢复(Backward Recovery):顺序执行各个子事务,如果遇到某个子事务执行失败,将执行该子事务的补偿操作(避免因为技术异常造成的失败,补偿操作需要幂等),然后倒序执行已经成功的子事务的补偿操作。这种一般是可取消的批量操作,比如出行订票,需要购买飞机票、订酒店、买门票,如果买门票失败了,飞机票和酒店就可以取消了。

SAGA 模式

根据这两种实现,SAGA 可以分为两部分:

  • 子事务(Normal Transactions):大事务拆分若干个小事务,将整个事务 T 分解为 n 个子事务,命名为 T1、T2、…、Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交 Ti 等价。
  • 补偿事务(Compensating Transactions):每个子事务对应的补偿动作,命名为 C1、C2、…、Cn

子事务与补偿动作需要满足一些条件:

  1. Ti与 Ci必须对应
  2. 补偿动作 Ci一定会执行成功,即需要实现最大努力交付。
  3. Ti与 Ci需要具备幂等性

文末总结

本文主要总结了本地事务、全局事务、最终一致性等方式实现数据自洽。重点介绍了实现最终一致性的集中模式:可靠事件模式、TCC 模式、SAGA 模式等。数据的一致性一直是个难题,随着微服务化之后,数据一致性更加困难,有困难不怕,只要不放弃,总会解决的。

参考


你好,我是看山。游于码界,戏享人生。如果文章对您有帮助,请点赞、收藏、关注。我还整理了一些精品学习资料,关注公众号「看山的小屋」,回复“资料”即可获得。

个人主页:https://www.howardliu.cn
个人博文:关于微服务系统中数据一致性的总结
CSDN 主页:https://kanshan.blog.csdn.net/
CSDN 博文:关于微服务系统中数据一致性的总结

公众号:看山的小屋