本文是对《软件架构探索》 一书的读书笔记,并加上一些自己的理解和注释

本文开始记录《软件架构探索》的事务处理部分,对软件架构中的事务处理机制进行学习。


事务处理(2)

4. 共享事务

与全局事务正好相反,共享事务是指多个服务共享一个数据源

4.1 共享数据库连接

对于全局事务的一种理论可行的方案是让各个服务共享数据库连接。严格意义上的 “不同服务节点共享数据库连接” 是很难做到的,所以为了实现共享事务,就必须新增一个用于处理数据库连接的 “代理服务器” 的中间角色,可以将它视为一个独立于各个服务的远程数据库连接池,或者作为数据库代理。

4.2 共享数据库连接悖论

之所以强调理论可行,是因为该方案与实际生产系统中的压力方向相悖。在一个服务集群中,数据库才是压力最大而且最不容易伸缩拓展的重灾区,所以现实中只有用于对多数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的服务代理。

由于没有理由让多个微服务去共享数据库。所以,共享事务在实际应用中并不常用。尽管拆分微服务后仍然共享数据库的情况现实中其实并不少见,但不赞同将共享事务作为一种常规的解决方案来考量。


5. 分布式事务

以下所说的分布式事务特指 “多个服务同时访问多个数据源的事务处理” 的处理机制。

5.1 CAP 与 ACID

开始分布式事务之前,需要先从 CAP 与 ACID 的矛盾说起。

CAP 定理是分布式计算领域所公认的著名定理。这个定理里描述了一个分布式的系统中,涉及到共享数据问题时,以下三个特性最多只能同时满足其中两个:

  • 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。
  • 可用性(Availability):代表系统不间断地提供服务的能力。

    理解可用性要先理解与其密切相关两个指标:可靠性和可维护性。可靠性使用平均无故障时间(MTBF)来度量;可维护性使用平均可修复时间(MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如99.9999%可用,即代表平均年故障修复时间为32秒。

  • 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成 “网络分区” 时,系统仍能正确地提供服务的能力。
5.1.1 CAP 定理示例

使用《软件架构探索》书中的一个场景示例来进一步的了解 CAP 定理。

假设 Fenix’s Bookstore 的服务拓扑如下图所示,一个来自最终用户的交易请求,将交由账号、商家和仓库服务集群中某一个节点来完成响应:

系统中,每一个单独的服务节点都有着自己的数据库,假设某次交易请求分别由 “账号节点 1”、“商家节点 2”、“仓库节点 N” 联合进行响应。当用户购买一件价值 100 元的商品后,账号节点 1 首先应给该用户账号扣减 100 元货款,要把这次交易变动告知本集群的节点 2 到节点 N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况:

  • 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一商品时,被分配给到另一个节点处理,由于看到账户上有不正确的余额而错误地发生了原本无法进行的交易,此为一致性问题
  • 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易,此为可用性问题
  • 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点之间的连接中断而仍然能够正确地提供服务,此为分区容忍性

以上还仅仅涉及到了账号服务集群自身的 CAP 问题,对于整个 Fenix’s Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题,如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售。又如,因涉及到仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题,等等。

5.1.2 一致性与可用性的矛盾

由于永远可靠的通讯在分布式系统中是必定不成立的。只要用到网络来共享数据,分区现象就始终会存在。

  • 如果保证一致性,当一台服务器必须进行写操作是,会锁定其他服务器的读操作和写操作,只有数据同步后才会解除锁定。在锁定期间,其他服务器不能读写,导致没有可用性。
  • 如果保证可用性,那么在写操作时必然不能锁定其他服务器,导致一致性不成立。

综上所述,在分布式环境中,无法同时做到一致性和可用性。系统设计时只能选择一个目标。

5.1.3 CAP 取舍分析
  • 如果放弃分区容错性(CA without P),意味着我们将假设节点之间的通讯永远都是可靠的,然而永远可靠的通讯在分布式系统中是必定不成立的。只要用到网络来共享数据,分区现象就始终会存在。所以我们无法舍弃分区容错性。
  • 如果放弃可用性(CP without A),意味着一旦发生分区,节点之间的信息同步时间可以无限期的延长,此时,问题相当于退化到前面“全局事务”中讨论的一个系统使用多个数据源的场景之中。在现实中,选择放弃可用性的情况一般用于对数据质量要求很高的场合。如,银行、证卷这些涉及到金钱交易的服务,宁可中断也不能出错。
  • 如果放弃一致性(AP without C),意味着一旦发生分区,节点之间所提供的数据可能不一致。AP 系统是目前设计分布式系统的主流选择。因为建立分布式的目的就是为了提高可用性,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值。
5.1.4 最终一致性

事务处理的目的就是获得 “一致性”,而在分布式环境中,“一致性” 却又成为了不得不被放弃的属性。但无论如何,至少要确保在最终交付的时候是正确的。在前面 CAP、ACID 中讨论的一致性被称为 “强一致性”,而把牺牲了一致性的 AP 系统又要尽可能的获得正确的结果的行为被称为追求 “弱一致性”。在弱一致性里,人们又总结出了一种稍强一点的特例。被称为 “最终一致性”,它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果。

在分布式事务中,也不得不从追求强一致性,降低为追求获得 “最终一致性”。由于一致性的定义变动,“事务” 一词同样也被拓展了,人们把使用 ACID 的事务称为 “刚性事务”,而把以下介绍的几种分布式事务的常见做法称为 “柔性事务”。

5.2 可靠事件队列

5.2.1 可靠事件队列示例

在可靠事件队列中,使用《软件架构探索》书中的一个场景示例来进行说明。

在这个示例中,目标是交易过程中正确修改账户、仓库和商家服务中的数据,下面列出了时序图和修改过程的具体的步骤:

  1. 用户向 Fenix’s Bookstore 应用发送了交易请求:购买一本价值100元的《深入理解Java虚拟机》。

  2. Fenix’s Bookstore 首先会对用户账户扣款、商家账户收款、库存商品出库这三个操作进行出错概率评估,根据出错概率大小来安排它们的操作顺序。如,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或者账户余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那顺序就应该安排成最容易出错的最先进行,即:账户扣款 → 仓库出库 → 商家收款。

  3. 帐号服务进行扣款业务,如扣款成功,则在自己的数据库中建立一张消息表,存入一条消息:“事务ID:某UUID,扣款:100元(状态:已完成),仓库出库《深入理解Java虚拟机》:1本(状态:进行中),某商家收款:100元(状态:进行中)”。注意,“扣款业务” 和 “写入消息” 是使用同一本地事务写入数据库的。

  4. 在系统中建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去。

    这时候可能产生以下几种可能的情况:
    1. 商家和仓库服务都完成了收款和出库工作,并向帐号服务返回执行结果,帐号服务将消息状态从 “进行中” 更新为 “已完成”。整个事务顺利结束,达到最终一致性的结果。
    2. 商家或仓库服务中至少一个因网络原因,未能收到来自帐号服务的消息。此时,由于帐号服务中存储的消息状态一直处于 “进行中”,所以消息服务器在每次轮询时会持续地向未响应的服务重复发送消息。所以所有被消息服务器发送的消息都必须具备幂等性,确保一个事务中的动作只会被处理一次。
    3. 商家或仓库服务中至少一个无法完成工作,如,仓库发现没有库存。此时,仍然会持续重发消息,直到操作成功或被人工介入为止。由此可见,可靠事件队列只要第一步业务完成,后续就没有回滚的概念,只许成功,不许失败。
    4. 商家和仓库服务都完成了收款和出库的工作,但回复的应答消息因网络问题丢失了。此时,帐号服务仍然会重发消息,但因为操作幂等性,所以不会导致重复收款出库,只会导致商家、仓库服务器重新发送应答消息,此过程重复直至双方网络通讯恢复正常。

5.3 TTC 事务

5.3.1 可靠事件队列的不足

前面介绍的可靠事件队列虽然能保证最终的结果是相对可靠的,但整个过程完全没有任何隔离性可言。如,缺乏隔离性会带来的一个显而易见的问题便是 “超售” :完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。

5.3.2 TTC 事务处理机制

如果业务需要隔离,那通常就应该重点考虑 TCC 方案,它适合用于需要强隔离性的分布式事务中。TTC 要求业务处理过程必须拆分为 “预留业务资源” 和 “确认/释放消费资源” 两个子过程。分为以下三个阶段:

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

继续使用上一个示例进行说明,TCC 的执行过程如下图所示:

  1. 用户向 Fenix’s Bookstore 应用发送了交易请求:购买一本价值100元的《深入理解Java虚拟机》。
  2. 创建事务,生成事务 ID,记录在活动日志中,进入 Try 阶段:
    • 帐号服务:检查业务可行性,可行的话,将用户的 100 员设置为 “冻结” 状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    • 仓库服务:检查业务可行性,可行的话,将仓库中的一本《深入理解Java虚拟机》设置为 “冻结” 状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。
    • 商家服务:检查业务可行性,不需要冻结资源。
  3. 如果第 2 步所有业务都返回业务可行,将活动日志中的状态记录改为 Confirm,进入 Confirm 阶段:
    • 帐号服务:完成业务操作(扣除被冻结的 100 元)。
    • 仓库服务:完成业务操作(标记被冻结的书为出库状态,扣除相应库存)。
    • 商家服务:完成业务操作(收款 100 元)。
  4. 如果第 3 步全部完成,事务宣告顺利完成,如果第 3 步中任何一方出现异常,根据活动日志中的记录,重复执行该服务的 Confirm 操作。
  5. 如果第 2 步有任意一方返回业务不可行,或任意一方超时,将活动日志的状态记录改为 Cancel,进入 Cancel 阶段:
    • 帐号服务:取消业务操作(释放被冻结的 100 元)。
    • 仓库服务:取消业务操作(释放被冻结库存)。
    • 商家服务:取消业务操作。
  6. 如果第 5 步全部完成,事务宣告以失败回滚结束,如果第 5 步有任意一方出现异常,根据活动日志中的记录,重复执行该服务的 Cancel 操作。

5.4 SAGA 事务

5.4.1 TTC 事务的不足

TCC 事务具有较强的隔离性,避免了 “超售” 的问题,但它仍不能满足所有的场景,TCC 的主要缺陷是它的业务侵入性很强。如,用户、商家的帐号余额由银行管理,购物过程中通过 U 盾或扫码支付,在银行帐号中直接划转货款。就无法完成冻结款项、解冻、扣款这样的操作。我们只能考虑另外一种柔性事务方案:SAGA 事务。

5.4.2 SAGA 事务处理机制

SAGA 事务大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。原本 SAGA 事务的目的是为了避免大事务长时间锁定数据库的资源,后来才发展成将一个分布式环境的大事务分解为一系列本地事务的设计模式。SAGA 事务由两部分操作组成:

  • 大事务拆分为若干小事务,将整个分布式事务 T 分解为 n 个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式事务正常提交,其对数据的影响应等价于连续有序成功提交 Ti。
  • 为每个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。Ti 与 Ci 必须满足以下条件:
    • Ti 与 Ci 都具备幂等性。
    • Ti 与 Ci 满足交换律,即先执行Ti还是先执行Ci,其效果都是一样的。
    • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。

如果 T1 到 Tn 均成功提交,那么事务顺利完成,否则,要采取以下两种恢复策略之一:

  • 正向恢复:如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止。这种恢复方式不需要补偿,适用于事务最终都要成功的场景。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
  • 方向恢复:如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止。这里要求 Ci 必须执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

SAGA 必须保证所有子事务都得以提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。

6. 附录:参考资料

共享事务 | 软件架构探索:The Fenix Project

分布式事务 | 软件架构探索:The Fenix Project

评论