第四章、解耦数据库
通过第三章的描述,把应用拆解耦成微服务只是单体应用向微服务迁移的一部分,而另一部分是对单体的数据库进行解耦拆分。在了解如何拆分之前,我们先了解下不拆分会怎么样。
模式:共享数据库
第一章提到过耦合分为三种:领域耦合、暂时耦合和实现耦合。在使用数据库的时候通常会发生实现耦合,因为普遍采用所有应用程序访问同一个数据库的这种情况。这种被称作共享数据库模式,如下图所示:
在微服务架构中,共享数据库会带来很多问题,最严重的问题是我们不知道哪些数据被哪些应用所共享。在对共享数据库进行微服务化改造时,首先可以考虑使用视图的方式减轻上述问题带来的影响。但视图中是只读数据,即便使用视图的方式将底层数据和应用程序隔离开,但对于多个应用都可以编辑的数据没有任何的办法。这时引出了另一个问题,就是哪些数据由哪些程序所控制?
如上图所示,如果三个应用程序都会修改订单表中的数据,那么这三个应用可能需要了解彼此的处理逻辑,当一方发生变化时,另一方的逻辑可能也需要改变,这就会引发不同步的情况。可以想想,如果一个程序在开发过程中修改了订单表中的某个字段的数据类型,那么其它程序如果跟着做修改,就会引发问题。
共享数据库的这个模式不是一个推荐的方式,但在现实情况下必然会存在。要牢记我们的目标是每个微服务都拥有自己的数据。以下两种情况可以适当的使用共享数据库模式来降低系统迁移的复杂性:1)当处理只读的数据时。例如将邮政编码、城市简称等几乎不变的数据维护到一个共享的数据库Schema中。2)使用一个单独的服务将共享数据库的访问暴露成API的方式,然后让其它的服务都访问这个服务。这时虽然在数据库和原来的访问者中间加了一层,但是好处是今后对数据库的改造对原有的访问者服务都是透明的。
在对一个单体应用的数据库进行微服务改造时,首先要做的应该是将原有的一个Schema根据划分为多个微服务化的Schema。对数据库的改造通常都是比较敏感的,稍不注意就会引起灾难性的结果。因此改造需要划分成若干个小的步骤,并控制每一步的影响范围。实际上,在如TOGAF这类企业级架构方法论中也提到,从架构愿景到最终落地需要经历迁移规划的阶段,在架构设计中需要先根据当前情况描述基线架构,再根据愿景设计目标架构,进而对两者进行差距分析,最后在迁移规划时根据差距分析的结果进行迁移路线图的设计。这个过程应该是普适的。
文中提到了Schema和数据库实例的关系,这点其实大部分人都应该清楚,数据库实例之间是物理隔离,每个实例中可以拥有多个Schema作为逻辑隔离。微服务应用需要唯一拥有自己的数据,那么应该使用Schema还是实例呢?这需要看实际情况,如果使用的是云平台提供的分布式数据库,那么推荐使用Schema,如果是自建的数据库,那么需要根据可用性考虑采用哪一种,如果只有一个实例,那么这个实例宕机会引起所有服务的Schema不可用。使用Schema的好处是某些情况下可以跨Schema访问共享数据。
在迁移过程中,可能会遇到很多问题,并不是所有问题都能立刻得到解决,但我们要做的是,首先一点一滴的改进让情况不再继续恶化,给后面合适的处理时机提供基础。下面的一些模式的初衷也是这一点。
模式:数据库视图
数据库视图模式是一种防止情况继续恶化时可以采用的方法。适用的场景是多个微服务希望共享一个数据源时,并且这些微服务都只是从这个数据源中读取数据。这种方式尤其适合使用一个数据库实例多个Schema时,解耦数据库的中间过程。因为同一个数据库实例中的Schema间可以进行连接查询,这样一来,将公共数据抽离成一个单独的视图Schema后,在程序中仍然可以使用表连接的方式获取数据。视图还可以限制服务能够访问的数据粒度,隐藏服务不能访问的信息。
这个模式的意义在于。当我们构建单体应用的时候,往往是将数据库作为一个公开协议供不同的应用程序访问的。每个应用程序可能使用相同的数据库用户名和密码。这样的结果是,当我们需要优化数据库性能或者为了微服务而做数据库拆分时,完全不知道数据库被哪些应用程序访问,也不清楚这些应用程序是怎么使用数据库的,是读写还是只读?这里建议,即便使用单体的开发架构,也应为不同的数据库客户端分配不同的用户名和密码,为后期的优化改造提供基础。众多用户名密码的维护可能会带来一些困扰,可以使用专门的密钥存储工具来解决这个问题。
在使用微服务架构对数据库进行拆分时,由于无法清楚的了解数据库被哪些应用以什么方式访问,因此有人提出了一个不合理的解决方案:直接修改原本被各个应用程序共享的数据库用户名密码,自顾自的修改数据库结构,然后等着应用程序的所有者找上门。虽然这个方法看似极不合理,但是现实中,我之前的团队就曾这么干过。
这种情况,更合理的解决办法是:首先为各个应用程序分配专属的数据库用户名和密码,然后将这些应用程序分为读/写数据库和只读数据库两类。我们通过使用视图来解耦只读数据的应用程序和数据库。具体做法是,建立一个新的Shema来容纳视图,建立和原有表结构一致的视图,然后修改应用程序代码中访问新的Shema。如果能够判断出每个客户端只读的具体数据,可以进一步调整视图只包含各个应用程序所需要的数据列,隐藏其它列。如下图所示:
实际应用中,数据库视图模式是最为其它模式的备选模式。因为我们的目的是使用微服务架构。所有的数据都应拆分给负责的服务管理。当我们无法使用其它模式对当前的单体数据库进行拆分时,可以使用这个备选方案让情况不再进一步恶化。通过视图将应用程序和底层数据库解耦开,这样我们就可以进一步对底层数据库进行优化,只要保证视图的稳定就可以不影响现有应用程序。
模式:将数据库包装成服务
这种模式也是一种防止情况继续恶化的方式。我们的目的一直都是将数据拆分给每个负责的服务中进行管理。根据数据库视图模式的启发,很容易想到,可以使用一个很薄的或者称为贫血的服务将数据库的访问包装起来。任何对数据库的增上改查都通过调用这个服务来完成,服务要做的只是完成实际的数据库调用。这种方式的好处是,我们不但可以将应用程序和数据库解耦开,而且达成了我们通过服务访问数据的目标,更重要的是,相比使用视图的方式,服务包装的方式不但可以提供只读数据的访问,还可以提供数据的写操作。这种模式如下图所示:
这种方法的适用场景除了在数据库视图模式中提到的外,当数据库中的某一块非常难拆分,或者任何变动所带来的风险都会很大时,可以通过将其完整的包装成一个服务来实现解耦。解耦后,如果需要继续对其进行拆分,就可以逐步的进行了。通过这种模式,将该服务及底层数据的所有权分配给一个独立的团队,可以完整的对其使用微服务的相关技术和治理手段。对于上游服务来说,通过使用声明式的API,会让它们从理解数据库的实现上解放出来,完全专注于自己的业务功能。
相比于数据库视图模式,用服务包装数据库的模式带来了诸多优点的同时,也带来了挑战,因为它需要对上游的应用程序进行较大的改造,以便使用服务API的方式进行数据交互。
模式:数据库作为服务接口
这种模式尤其适合对接报表系统或者其它需要通过数据库的方式对接的外部系统。对于报表系统来说,其需要从大量的表中获取相关的数据。这在单体数据库中是非常容易办到的,但是在微服务的世界中,数据被分散到每个单独的服务中,根据所有权,外部服务通过有数据所有权的服务对外暴漏的接口访问数据。当微服务应用中需要输出报表时,往往是比较困难的。在另一种情况中,我们可能对接了一些外部系统,这些系统必须通过访问数据库的方式获取数据,而我们又希望通过某种方式将我们自己的数据库和外部系统进行解耦,这样我们就可以自由的对数据库进行持续优化而不用担心影响到外部系统。
通过上述的分析,我们可以看出,这类场景中,均是对数据库只读操作。数据库作为服务接口模式的目标是为这类系统建立一个单独的数据库,通过一个映射引擎将我们自己数据库中的数据同步到这个单独的数据库中。当在微服务应用需要报表时,每个服务将自身的数据同步到这个单独的数据库中,然后报表系统在这个单独的数据库中完成数据的查询。如下图所示:
从上述描述中可以看出。需要提供额外的映射引擎来完成数据的同步,这就带来了延迟的问题。作为服务接口的数据库中的数据会落后于实际产生的数据。具体落后的事件根据不同的实现和需求会有很大的差异。如果需要较低的时延,可以采用一些变更数据采集系统,这类系统通常可以将源数据库的变化立刻同步给目标数据库。另一种实现方式是自行编写程序,定时批量的进行数据同步,可以全量也可以增量。第三种方式是在发生数据变更时触发事件,然后使用消息中心传播事件,以此来更新作为服务接口的数据库。这里的建议是使用变更数据采集系统来完成同步,因为其它两种方式在实际应用中,由于需要自己维护,因此可用性通常不是很高。
这种模式和数据库视图模式非常像。对比两者,数据库视图模式依赖具体的数据库来建立视图,如果使用的是Oracle数据库,那么双方都需要依赖Oracle数据库的使用方式。而数据库作为服务接口模式可以允许双方使用各自的技术栈;同时,数据库作为服务接口模式的灵活性要高于数据库视图模式,但这同样带来了更大的花费。因此如果能够使用数据库视图,可以优先使用视图。
模式:聚合的导出单体
上述的模式都没有解决单体数据库拆分的问题,只是保证情况不再进一步恶化。从这个模式开始,我们正式进入解决拆分问题的模式讨论。
当微服务应用需要访问一个尚且还是单体应用的数据时。我们可以先对这个单体应用进行重构,对所需的数据和在单体应用中存在的相关实体进行分析,结合微服务的拆分方法,识别出表示这些数据的聚合对象和聚合根对象。然后使用API接口的方式将所需的数据对外暴露。需要注意的是,此时代码和接口还都在单体应用内。如下图所示:
通过在单体应用中使用API的方式对外暴露出外部服务所需要的数据。一方面满足了服务的数据需求,另一方面,也有助于识别单体应用中的限界上下文。当后续需要将这些限界上下文拆分成独立的服务时,对上游服务的实现几乎无影响。
随着接口的逐渐稳定,以及领域知识逐步的深入理解,当我们需要从单体应用中剥离单独的服务时,情况就变得自然而然。如下图所示:
对外部服务而言,这种方式相比于使用类似数据库视图模式来直接访问单体数据库会复杂一些。但从长远来看,这样做是最合理的。这里可能会有些疑问,既然已经识别除了聚合并暴露出了接口,为什么不直接将其拆分为独立的服务呢。这里依然需要强调一个从单体到微服务的拆分原则,为了减少风险和尽量少的影响现有单体应用,改造过程应分为若干步骤,每一步尽可能地小。这样当出现问题时可以快速地回滚。
模式:变更数据所有权
在聚合地导出单体模式中,通过外部系统所需数据来引导拆分。那么,当我们已经决定要从单体应用中拆分出服务,这时服务相关的数据怎么处理呢?这就是变更数据所有权模式要解决的问题。如下图所示:
从上图可以看出,我们的目标是让单体应用原本通过数据库的方式直接访问所需数据,变更为单体应用通过调用服务的接口来获取数据。即:将原本所有权是单体应用的数据,变更所有权到新拆分出来的服务上。拆分过程可能很复杂,可能需要考虑打破外键约束、打破事务边界等等。这些问题会在后续的模式中详细讨论。
如果单体应用只是获取只读数据,那么考虑简单实现也可以使用数据库视图这类模式实现。
这个模式强调服务所有的数据必须由服务全权管理,并从原本所在的地方迁移到微服务管理的数据库中。
模式:在应用中同步数据
第三章的扼杀无花果模式中提到了同时运行单体和服务两种方式,以便当单体出现问题的时候可以快速的回滚到原本的方式上。但这样带来了一个问题,就是如果两种方式都使用各自的数据库,那么之间如何进行同步?如下图所示:
同步的选择性很多,但大多涉及到同步的时机,不同的时机导致不同的延迟事件,这在一些对延迟敏感的场景是极其重要的。又或者为了保证数据的实时同步,而采用单体和服务都使用同一个数据源,但这样一方面限制了服务的演化,另一方面也可能出现数据库的单点故障。
一种同步的方式是让应用程序自己处理两个数据源之间的同步。原书中对该模式的描述是只针对单体应用,首先用旧数据库中的数据初始化新数据库,然后修改原来的代码同时向两个数据库写入相同的数据,但从旧数据库中读取数据,用旧数据库中的数据作为可信数据,接着变更可信数据为新数据库,从新数据库中读取数据。但这似乎对微服务拆分没有太大的帮助,书中的解释是这样做的价值在于才拆分应用代码之前可以用这种方法先拆分数据库。个人认为原书的模式更适合替换数据源的场景。我觉得如果优化一下,可以对实际情况更有帮助。优化后的步骤如下(图示是从原书中截取的,因此没有体现优化后的意图,仅作为帮助理解的参考):
- 批量同步数据:首先确认服务需要使用的存在于单体应用中的数据,然后将其整体拷贝到服务数据库中。拷贝过程中,单体应用持续运行,会产生新的数据写入。因此在拷贝技术后,还需要将新数据变更同步到服务数据库中,这一点可以使用变更数据采集系统来实现。
- 同步写操作,从旧的数据库中读数据:调整单体程序,让每一次的数据写操作都分别在两个数据库中执行;读操作还是在旧数据库中执行。服务程序此时可以进行相应的测试工作。
- 同步写操作,从新的数据库中读数据:这一阶段将可信数据库从单体数据库变更为服务数据库。修改服务程序,将数据的写操作在两个数据库中都执行一次。从新的数据库中读取数据。这一步时,就可以将流量切换到服务程序上了。当出现问题需要回滚时,将流量切回原单体应用即可。在平稳运行一段时间后,原单体应用内的相关程序和数据就可以下线了。
该模式如果正确实现,则两个数据源将一直保持同步,可以在两个环境间无缝的切换。
原书中最后提出到,如果同时将该模式应用在单体程序和服务程序上,将会非常复杂。因此上面经过我优化的方案中,同一时刻仅有一个程序对外提供服务。同时可用的复杂情况如下图所示:
模式:追踪者写入
该模式和在应用中同步数据模式的意图非常像,但是它针对可信源的变更强调逐步进行。并且这种模式是从先创建一个微服务开始的。这个模式更像是原书作者针对我在上一个模式中提出的问题而给出的另一种优化方案。
上一个模式的特点是一个时刻有且只有一个可信的数据源,这原本是件很好的事情,但是实际应用中会发现,即使可以回滚,整体的切换也会带来较高的风险。切换前单体数据源作为可信源,切换后服务数据源变为可信源,问题是在切换过程中可能出现各种问题。因此追踪者写模式提供了逐步切换的思路。
这个模式的名称的含义是,可以先同步一小部分数据,然后再逐步增加,同时逐步的增加数据消费方的数量。如下图所示:
上面的第一幅图表示先向支发货服务同步基本信息,然后到第二幅图同步联系方式,最后一幅图同步支付信息。
对于消费者服务而言,单体应用和新的服务应用均作为可信源,优先使用服务应用获得。也就是说,当消费者服务需要的数据在新的服务应用中已经同步时,调用服务的接口来获取,则使用单体应用原有的方式获取。
该模式的目的是让所有数据的消费者都优先使用新的服务获取数据,包括原有的单体应用自身也应调用服务提供的接口完成数据同步,这也需要一个逐步的过程,如下图所示:
上面第一幅图说明了,当服务只同步了基础信息后,单体应用需要修改代码保证对基本信息的写操作在两个数据源中都执行一次,而读操作需要调用服务应用的接口完成。而其它没有同步的数据依然由旧的数据源提供。这种方式保证了单体应用和新拆分出的服务应用同时对外提供能力,并且服务应用中的能力逐步的增强直至可以代替原单体应用为止。当需要回滚的时候,只需换回原有单体应用的数据访问方式即可。
这个模式的最大问题是数据的同步性,数据如果没有正确的同步到新的数据源,可能会引起混乱。由上图可以看出,新老数据源都支持同步数据的写入,并且两个应用同时对外提供服务,为了避免混乱,建议仅使用一个应用进行写操作,向另一个数据源同步写数据。实现时可以参考上一个模式的方法,例如当单体应用和服务应用同步好基本信息后,先由单体应用负责数据的写入,在单体应用的代码中同时向两个数据源中执行写操作,而读操作完全交给服务应用。运行一段时间,确认新的服务应用在数据读取方面没有问题后,再将基本信息的写入全部交给新服务,在新服务的代码中同时向两个数据源中执行写操作。这时的单体应用在基本信息方面的能力完全作为回滚时的备份。接下来继续其它相关数据的迁移。当所有数据迁移完毕,最终将单体应用中的代码和数据都清理掉。
在应用中同步数据和追踪者写入模式小结
通过对应用中同步数据和追踪者写入模式的讨论,我们不难发现,前者是先拆分数据库,然后再建立微服务对新拆分的数据进行管理;而后者则是先建立微服务,然后将其所需的数据一点一点的迁移到微服务自己的数据库中。那么这里需要讨论一下,哪一种方式更好呢?这里我们先给出一个微服务拆分结束的定义:只有当微服务拥有其所相关的代码及数据的所有权后,才能说这个服务拆分结束。这样就有三种选择:1)先拆数据库,再拆代码;2)先拆代码,再拆数据库;3)数据库和代码同时拆。每个方法都有优缺点:先拆数据库的缺点是原本可以一条SELECT语句检索到的数据可能需要分成多条然后在内存中合并,优点是当出现问题可以在不影响消费者的前提下进行代码的回滚。缺点是短期效果不明显。因此这种方法通常是有着最保守的考虑;现实中,更多的团队采用先拆代码的方式,因为这种方式在微服务的视角上立竿见影,这是它的优点。其缺点是,团队往往仅仅对代码拆分后就当作是微服务拆分结束了,忘记了数据库的拆分,这是相当危险的;第三种同时拆的方式风险太大,在单体应用向微服务转变的时候不建议使用。
先拆数据库如下图所示:
先拆代码如下图所示:
代码和数据同时拆如下图所示:
模式:每个限界上下文一个Repository(先拆数据库)
这个模式适用于先拆数据库的场景。数据库的拆分需要对单体应用的代码进行重构。该模式建议使用ORM框架将代码和数据库映射起来,并且为每一个限界上下文提供一个单独的Repository。这有助于理解代码中的哪一部分使用了哪些数据。如下图所示:
这些Repository最终可以作为服务边界的划分依据。该模式非常有助于理解单体应用如何拆分成独立的部分。
模式:每个限界上下文一个数据库(先拆数据库)
该模式也适用于先拆数据库的情况。微服务的核心概念是“独立可部署性”,这要求微服务必须拥有自己的数据。在进行代码拆分之前,可以通过为每个限界上下文使用独立的数据库来清楚地表明边界。如下图所示:
这一模式的目的实际上是将单体应用重构成一个模块化的单体应用。为微服务改造提供有力的支持。但是实际应用过程中,模块化的单体应用最终是否有必要拆分成微服务,这个需要视情况而定,也许模块化的单体应用就已经满足需求了。
该模式被原书作者强烈建议用于新产品的开发。这是因为作者不赞同新产品适用微服务架构,因为新产品往往对领域知识理解的不深入,并且产品初期需求也并不稳定,可能需要快速的试错,而研究表明,单体应用在复杂性低的系统中的生产力比微服务架构要高。
模式:单体作为数据访问层(先拆代码)
该模式适用于先拆代码的情况。其思想是,先将代码拆分出去作为一个微服务,这个服务依然使用单体的数据库中限界上下文边界内的数据表,当需要额外的数据时,并不直接访问单体数据库,而是通过单体应用暴露出来的接口进行访问。如下图所示:
这种模式也可以作为从单体应用中识别其它候选服务的方法,那些被单体导出的接口最终可能会被移入新的服务之中。
模式:拆分表
在对数据库进行拆分的时候,会遇到一些问题。接下来的两个模式用于应对这些问题。首先是拆分表模式。在这种情况,需要对一张表中的数据做垂直拆分,即数据表中的数据属于不同的微服务所有。如下图所示:
有时候,多个服务可能会共同使用一张表中的某个字段。考虑到减少接口调用,这时该字段可能会在两个服务的数据库中都有,但需要根据业务逻辑来判断那个服务为控制方,由控制方对数据进行写操作,然后通过接口调用、事件消息等方式向其它拥有该字段的服务同步数据。这样做会导致最终一致性的事务。
模式:将外键关系写入代码
在原本的单体应用中,表间经常会有主外键关系。在进行信息检索的时候,也常用表连接从不同表中获取需要的字段。通过建立外键约束,底层的数据库引擎会保证数据的一致性,并且数据库引擎在检索时也会使用外键进行性能优化,确保表连接能够尽快的执行。但在微服务中,主外键关系被不同的服务打断了。我们需要考虑两个问题,当检索的信息分布在不同的微服务之中,我们不能再通过表连接关联查询时怎么办?另一个问题是如何处理一致性问题?
针对第一个问题,解决的办法是在服务表中存入数据的主键,然后根据主键将表连接操作拆分成多条语句。逻辑上来说,表连接也是这样实现的,只是它由数据库引擎自动处理。具体方法如下图所示:
这种方法需要权衡时延,将原本由数据库处理的表连接变为服务间接口的调用再在内存中组装数据,很可能会增加整体的延迟。在应用微服务架构的时候,非常有必要使用链路追踪系统来跟踪这种延迟,判断是否符合预期并有针对性地优化。
对于另一个数据一致性的问题,则需要通过代码逻辑来处理了,处理的方式有:1)删除前先检查。这是一种不推荐的方式。其具体做法是当要删除某个记录时,先询问其它服务是否有用到该记录,如果没有则删除。之所以不推荐这种方式,是因为可能出现服务返回没有使用该记录的响应后,立刻被插入该条记录,解决这个问题需要使用锁。同时这个方法需要询问所有相关的服务。结合这两点来说,该方法是不现实的。2)优雅地删除。其它服务接受引用的数据被删除的事实。当发现引用的数据被删除时,通过优雅的提示信息等方式告知该数据已不可用。这里可以使用HTTP状态码401,不同于404,401状态码表示该资源之前存在过,只是不可用了。另一种优雅的处理方式是把需要的信息直接拷贝一份放在自己服务的数据库中,但这需要额外的同步机制。3)不允许删除。将删除操作都用状态为来进行逻辑删除,保证数据永远存在。
在实际处理一致性问题时,通常结合后两种方法,以此确保系统有正确的响应。因为即便数据永不被删除,也可能由于服务不可用或者状态回滚而导致不一致的情况。
模式:被共享的静态资源
这实际上不是一个模式,而是一个模式组,包含了用来处理被共享的静态资源的一组模式,这里放在一起进行介绍。具体包括:
- 冗余静态数据
如下图所示:
将静态数据在每个服务的数据库中都拷贝一份。这个模式初看很疯狂,但其实际的意义。因为静态数据是极少被更新的,比如国家编码、邮政编码等。另一方面,这些静态信息即便不同步,对系统而言的影响也是可以讨论的。例如缺少了某个国家编码,对系统的影响可能仅仅是下拉列表中不会出现这个国家。但不管怎样,这种方式应该尽可能地少用。
- 专用静态数据库
如下图所示:
这种方式通过建立一个共享的静态数据库来管理静态数据。所有服务都可以连接这个数据库只读的获取信息。当使用一个数据库实例时,甚至可以在服务内部使用表连接。这种方式的缺点是有单点故障的可能,并且共享数据库的改变会影响到所有服务。当静态数据的数据量很大的时候,比较适合使用这种方式。
- 静态数据类库
如下图所示:
将静态数据打入类库,然后供所有服务引用。这种方式的好处是可以使用强数据类型。缺点是与技术耦合,不同的技术需要不同的类库,需要对类库版本进行细致的管理,并且当静态数据发生变动,需要所有服务都更新类库才能保证一致性,这与微服务的独立可部署性有些背离。但考虑到静态数据不一致可能带来的影响,有时一段时间的不一致是可以接受的。
- 静态数据服务
如下图所示:
这种方式通过一个专用的静态数据服务对外提供数据。这似乎是最好的解决方案。但在实际应用时,也有其局限性。例如在某个企业内创建一个服务需要层层审批,那么使用这种方式带来的收益远不如直接使用共享静态数据库的方式管理静态数据。另外,服务间的调用也会增加延迟,这也是需要考虑的。
事务问题
在我翻译的另一篇文章《超越分布式事务》中,作者介绍了在分布式系统中使用一致性的事务是多么的困难。微服务架构的系统作为一种分布式系统,也存在这个问题。解决的方式主要有两种:
- 两阶段提交(分布式事务)
也被称作2PC,是最常用的处理分布式事务的方法。两阶段分为:投票阶段和提交阶段。在投票阶段中央事务协调器和所有事务相关方交互,询问变更是否可以被处理。如果所有的相关方都表示没有问题并已为变更预留资源,就进入提交阶段,否则整个操作被丢弃,中央协调器会请求每个相关方执行回滚操作,释放预留资源。两阶段提交无法保证ACID中的隔离性,因为在提交阶段,中央协调器会分别要求各相关方提交,每个相关方提交是有时间差的,有的提交了,有的还没有,而隔离性要求不能看到事务的中间状态。2PC的根本问题在于它在投票阶段需要预留资源,这需要某种意义上的锁,一旦出现锁,就需要对其管理,避免死锁等,这并不容易。另一个问题是,当投票阶段通过,但提交阶段无法成功时,这可能需要人工介入。中央协调器需要多次和多个服务交互,并且处理各种异常情况,一个完整的事务的延迟可能很长。基于这些问题,原文作者不建议使用2PC这种分布式事务方式
- Sagas(非分布式事务)
不同于2PC,Saga不需要长时间的锁定资源。通过将一个大事务分割为多个边界在一个数据库中的小事务,然后独立的执行每一个小事务。Saga也被称作长事务,因为事务从开始到结束可能经历很久。虽然事务整体可能耗时很久,但其中的每一个子事务的执行通常都很快。Saga不能提供ACID中要求的原子性,因此它是最终一致性的,但每个子事务都满足ACID。下图展示了使用Saga处理下单操作的流程:
上图中每一个矩形都是一个单独的子事务。当出现问题需要恢复时,Saga提供两种恢复机制:向后恢复和向前恢复。向后恢复会按照事务执行顺序反向的调用各子事务的回滚;向前恢复会重试发生错误的事务,然后继续向下执行。这两种机制通常一起使用。
在实现Saga时有两种模式:编排和协同。编码模式使用一个中央编排器负责事务流程的执行,实现时可以采用一些微服务架构的工作流引擎。如下图所示:
协同模式使用事件消息机制,没有中央协调器,各个服务中间完全解耦。如下图所示:
这种方式的缺点是很难清楚的明白事情是怎样发生的。对业务过程缺乏显示的表达。
作者建议首先应避免使用分布式事务。在使用Saga时,如果是一个团队内部,推荐使用编排模式,团队之间推荐使用协同模式。