如何在微服务下保证事务的一致性

作者:京东科技 苗元

背景

随着业务的快速发展、业务复杂度越来越高,传统单体应用逐渐暴露出了一些问题,例如开发效率低、可维护性差、架构扩展性差、部署不灵活、健壮性差等等。而微服务架构是将单个服务拆分成一系列小服务,且这些小服务都拥有独立的进程,彼此独立,很好地解决了传统单体应用的上述问题,但是在微服务架构下如何保证事务的一致性呢?

1、事务的介绍

1.1 事务

1.1.1 事务的产生

数据库中的数据是共享资源,因此数据库系统通常要支持多个用户的或不同应用程序的访问,并且各个访问进程都是独立执行的,这样就有可能出现并发存取数据的现象,这里有点类似Java开发中的多线程安全问题(解决共享变量安全存取问题),如果不采取一定措施会出现数据异常的情况。列举一个简单的经典案例:比如用户用银行卡的钱还京东白条,银行卡扣款成功了,但是白条因为网络或者系统问题没有还款成功,就会出大问题,这时候我们就需要使用事务。

1.1.2 事务的概念

事务是数据库操作的最小工作单元,是作为单个逻辑工作单元执行的一系列操作;这些操作作为一个整体一起向系统提交,要么都执行、要么都不执行;事务是一组不可再分割的操作集合(工作逻辑单元)。例如:在关系数据库中,一个事务可以是一条SQL语句,一组SQL语句或整个程序。

1.1.3 事务的特性

事务的四大特征主要是:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),这四大特征大家或多或少都听说过,这里我做下简单介绍。

(1)原子性(Atomicity):事务内的操作要么全部成功,要么全部失败,不会在中间的某个环节结束。假如所有的操作都成功了,那么事务是成功的,只要其中任何一个操作失败,那么事务会进行回滚,回滚到操作最初的状态。

begin transaction;

update activity_acount set money = money-100 where name = '小明';

update activity_acount set money = money+100 where name = '小红';

commit transaction;

(2)一致性(Consistency):事务的执行使数据从一个状态转换为另一个状态,但是对于整个数据的完整性保持稳定。换一种说法是数据按照预期生效,数据的状态是预期的状态。比如数据库在一个事务执行之前和执行之后,都必须处于一致性状态,如果事务执行失败,那么需要自动回滚到原始状态,也就是事务一旦提交,其他事务查看到的结果一致,事务一旦回滚,其他事务也只能看到回滚前的状态。

举个通俗一点的例子:小明给小红转账100元,转账前和转账后数据是正确的状态,这叫一致性,如果小红没有收到100元或者收到金额少于100元,这就出现数据错误,就没有达到一致性。

(3)隔离性(Isolation):在并发环境中,不同事务同事修改相同的数据时,一个未完成的事务不会影响另外一个未完成的事务。

例如当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。

(4)持久性(Durability):事务一旦提交,其修改的数据将永远保存到数据库中,改变是永久性,即使接下来数据库发生故障也不应对其有任何影响。

通俗一点例子:A卡里有2000块钱,当A从卡里取出500,在不考虑外界因素干扰的情况下,那么A的卡里只能剩1500。不存在取了500块钱后,卡里一会剩1400,一会剩1500,一会剩1600的情况。

1.1.4 Mysql隔离级别

如果不考虑事务隔离性产生问题:脏读、不可重复读和幻读。

Mysql隔离级别分为4种:Read Uncommitted(读取未提交的)、Read Committed(读取提交的)、Repeatable Red(可重复读)、Serializaable(串行化)

(1)Read Uncommitted是隔离级别最低的一种事务级别。在这种隔离级别下,一个事务会读到另一个事务更新后但未提交的数据,如果另一个事务回滚,那么当前事务读到的数据就是脏数据,这就是脏读(Dirty Read)。

(2)在Read Committed隔离级别下,一个事务可能会遇到不可重复读(Non Repeatable Read)的问题。不可重复读是指,在一个事务内,多次读同一数据,在这个事务还没有结束时,如果另一个事务恰好修改了这个数据,那么,在第一个事务中,两次读取的数据就可能不一致。

(3)在Repeatable Read隔离级别下,一个事务可能会遇到幻读(Phantom Read)的问题。幻读是指,在一个事务中,第一次查询某条记录,发现没有,但是,当试图更新这条不存在的记录时,竟然能成功,并且,再次读取同一条记录,它就神奇地出现了,就好象发生了幻觉一样。

(4)Serializable是最严格的隔离级别。在Serializable隔离级别下,所有事务按照次序依次执行,因此,脏读、不可重复读、幻读都不会出现。虽然Serializable隔离级别下的事务具有最高的安全性,但是,由于事务是串行执行,所以效率会大大下降,应用程序的性能会急剧降低。如果没有特别重要的情景,一般都不会使用Serializable隔离级别。

如果没有指定隔离级别,数据库就会使用默认的隔离级别。在MySQL中,如果使用InnoDB,默认的隔离级别是Repeatable Read。

1.1.5 启动事务

在说明启动事务之前,首先大家先想一下事务的传播行为,事务传播行为用于解决两个被事务管理的方法互相调用问题。实际开发中将事务在service控制,如以下方法调用存在传播行为,如果serviceB也会产生一个代理对象,同时也会进行事务管理,执行serviceA和serviceB分别开启事务,上边的serviceA中funA方法内容不处于一个事务中了。

class serviceA{
    //此方法进行事务控制
    funA(){
        //在此方法中操作多个dao的操作,处于一个事务中
        userDao.insertUser();
        orderDao.insertOrder();
        //如果在这里调用另一个service的方法,此时存在事务传播
        serviceB.funB();
    }
}
class serviceB{
    funB(){
    }
}



解决方案就是,在启动类上添加注解 @EnableTransactionManagement,在执行事务的方法上面使用 @Transactional(isolation = Isolation.DEFAULT,propagation = Propagation.REQUIRED)设置隔离界别与事务传播。默认就是REQUIRED。

Spring的声明式事务为事务传播定义了几个级别,默认传播级别就是REQUIRED,它的意思是,如果当前没有事务,就创建一个新事务,如果当前有事务,就加入到当前事务中执行。其余的还有:

1.SUPPORTS:表示如果有事务,就加入到当前事务,如果没有,那也不开启事务执行。这种传播级别可用于查询方法,因为SELECT语句既可以在事务内执行,也可以不需要事务;

2.MANDATORY:表示必须要存在当前事务并加入执行,否则将抛出异常。这种传播级别可用于核心更新逻辑,比如用户余额变更,它总是被其他事务方法调用,不能直接由非事务方法调用;

3.REQUIRES_NEW:表示不管当前有没有事务,都必须开启一个新的事务执行。如果当前已经有事务,那么当前事务会挂起,等新事务完成后,再恢复执行;

4.NOT_SUPPORTED:表示不支持事务,如果当前有事务,那么当前事务会挂起,等这个方法执行完成后,再恢复执行;

5.NEVER:和NOT_SUPPORTED相比,它不但不支持事务,而且在监测到当前有事务时,会抛出异常拒绝执行;

6.NESTED:表示如果当前有事务,则开启一个嵌套级别事务,如果当前没有事务,则开启一个新事务。

1.2 本地事务

1.2.1 本地事务定义

定义:在单体应用中,我们执行多个业务操作使用的是同一个连接,操作同一个数据库,操作不同表,一旦有异常我们可以整体回滚。

其实在介绍事务的定义中,也介绍了一部分本地事务。本地事务通过ACID保证数据的强一致性,在我们实际开发过程中,我们或多或少都使用了本地事务。例如,MySQL事务处理使用begin开始事务、rollback回滚事务、commit确认事务。事务提交后,通过redo log记录变更,通过undo log 在失败时进行回滚,保证事务原子性。在我们日常使用Java语言开发时,都接触过Spring,Spring使用@Transactional注解就可以实现事务功能,前面我们也介绍过了。事实上,Spring封装了这些细节,在生成相关的Bean的时候,在需要注入相关的带有@Transactional注解的Bean时候用代理去注入,在代理中开启提交/回滚事务。

1.2.2 本地事务的缺点

随着业务的高速发展,面对海量数据,例如,上千万甚至上亿的数据,查询一次所花费的时间会变长,甚至会造成数据库的单点压力。因此,我们就要考虑分库与分表方案了。分库与分表的目的在于,减小数据库的单库单表负担,提高查询性能,缩短查询时间。这里,我们先来看下单库拆分的场景。事实上,分表策略可以归纳为垂直拆分和水平拆分。垂直拆分,把表的字段进行拆分,即一张字段比较多的表拆分为多张表,这样使得行数据变小。一方面,可以减少客户端程序和数据库之间的网络传输的字节数,因为生产环境共享同一个网络带宽,随着并发查询的增多,有可能造成带宽瓶颈从而造成阻塞。另一方面,一个数据块能存放更多的数据,在查询时就会减少 I/O 次数。水平拆分,把表的行进行拆分。因为表的行数超过几百万行时,就会变慢,这时可以把一张的表的数据拆成多张表来存放。水平拆分,有许多策略,例如,取模分表,时间维度分表等。这种场景下,虽然我们根据特定规则分表了,我们仍然可以使用本地事务。

但是,库内分表,仅仅是解决了单表数据过大的问题,但并没有把单表的数据分散到不同的物理机上,因此并不能减轻 MySQL 服务器的压力,仍然存在同一个物理机上的资源竞争和瓶颈,包括 CPU、内存、磁盘 IO、网络带宽等。对于分库拆分的场景,它把一张表的数据划分到不同的数据库,多个数据库的表结构一样。此时,如果我们根据一定规则将我们需要使用事务的数据路由到相同的库中,可以通过本地事务保证其强一致性。但是,对于按照业务和功能划分的垂直拆分,它将把业务数据分别放到不同的数据库中。这里,拆分后的系统就会遇到数据的一致性问题,因为我们需要通过事务保证的数据分散在不同的数据库中,而每个数据库只能保证自己的数据可以满足 ACID 保证强一致性,但是在分布式系统中,它们可能部署在不同的服务器上,只能通过网络进行通信,因此无法准确的知道其他数据库中的事务执行情况。

此外,不仅仅在跨库调用存在本地事务无法解决的问题,随着微服务的落地中,每个服务都有自己的数据库,并且数据库是相互独立且透明的。那如果服务 A 需要获取服务 B 的数据,就存在跨服务调用,如果遇到服务宕机,或者网络连接异常、同步调用超时等场景就会导致数据的不一致,这个也是一种分布式场景下需要考虑数据一致性问题。

当业务量级扩大之后的分库,以及微服务落地之后的业务服务化,都会产生分布式数据不一致的问题。既然本地事务无法满足需求,因此就需要分布式事务。

2、分布式事务定义

分布式事务定义:我们可以简单地理解,它就是为了保证不同数据库的数据一致性的事务解决方案。这里,我们有必要先来了解下 CAP 原则和 BASE 理论。CAP 原则是 Consistency(一致性)、Availablity(可用性)和 Partition-tolerance(分区容错性)的缩写,它是分布式系统中的平衡理论。在分布式系统中,一致性要求所有节点每次读操作都能保证获取到最新数据;可用性要求无论任何故障产生后都能保证服务仍然可用;分区容错性要求被分区的节点可以正常对外提供服务。事实上,任何系统只可同时满足其中二个,无法三者兼顾。对于分布式系统而言,分区容错性是一个最基本的要求。那么,如果选择了一致性和分区容错性,放弃可用性,那么网络问题会导致系统不可用。如果选择可用性和分区容错性,放弃一致性,不同的节点之间的数据不能及时同步数据而导致数据的不一致。

此时,BASE 理论针对一致性和可用性提出了一个方案,BASE 是 Basically Available(基本可用)、Soft-state(软状态)和 Eventually Consistent(最终一致性)的缩写,它是最终一致性的理论支撑。简单地理解,在分布式系统中,允许损失部分可用性,并且不同节点进行数据同步的过程存在延时,但是在经过一段时间的修复后,最终能够达到数据的最终一致性。BASE 强调的是数据的最终一致性。相比于 ACID 而言,BASE 通过允许损失部分一致性来获得可用性。

现在比较常用的分布式事务解决方案,包括强一致性的两阶段提交协议,三阶段提交协议,以及最终一致性的可靠事件模式、补偿模式,TCC 模式。

3、分布式事务-强一致性解决方案

3.1 二阶段提交协议

在分布式系统中,每个数据库只能保证自己的数据可以满足 ACID 保证强一致性,但是它们可能部署在不同的服务器上,只能通过网络进行通信,因此无法准确的知道其他数据库中的事务执行情况。因此,为了解决多个节点之间的协调问题,就需要引入一个协调者负责控制所有节点的操作结果,要么全部成功,要么全部失败。其中,XA 协议是一个分布式事务协议,它有两个角色:事务管理者和资源管理者。这里,我们可以把事务管理者理解为协调者,而资源管理者理解为参与者。

XA 协议通过二阶段提交协议保证强一致性。

二阶段提交协议,顾名思义,它具有两个阶段:第一阶段准备,第二阶段提交。这里,事务管理者(协调者)主要负责控制所有节点的操作结果,包括准备流程和提交流程。第一阶段,事务管理者(协调者)向资源管理者(参与者)发起准备指令,询问资源管理者(参与者)预提交是否成功。如果资源管理者(参与者)可以完成,就会执行操作,并不提交,最后给出自己响应结果,是预提交成功还是预提交失败。第二阶段,如果全部资源管理者(参与者)都回复预提交成功,资源管理者(参与者)正式提交命令。如果其中有一个资源管理者(参与者)回复预提交失败,则事务管理者(协调者)向所有的资源管理者(参与者)发起回滚命令。举个案例,现在我们有一个事务管理者(协调者),三个资源管理者(参与者),那么这个事务中我们需要保证这三个参与者在事务过程中的数据的强一致性。首先,事务管理者(协调者)发起准备指令预判它们是否已经预提交成功了,如果全部回复预提交成功,那么事务管理者(协调者)正式发起提交命令执行数据的变更。

注意的是,虽然二阶段提交协议为保证强一致性提出了一套解决方案,但是仍然存在一些问题。其一,事务管理者(协调者)主要负责控制所有节点的操作结果,包括准备流程和提交流程,但是整个流程是同步的,所以事务管理者(协调者)必须等待每一个资源管理者(参与者)返回操作结果后才能进行下一步操作。这样就非常容易造成同步阻塞问题。其二,单点故障也是需要认真考虑的问题。事务管理者(协调者)和资源管理者(参与者)都可能出现宕机,如果资源管理者(参与者)出现故障则无法响应而一直等待,事务管理者(协调者)出现故障则事务流程就失去了控制者,换句话说,就是整个流程会一直阻塞,甚至极端的情况下,一部分资源管理者(参与者)数据执行提交,一部分没有执行提交,也会出现数据不一致性。此时,读者会提出疑问:这些问题应该都是小概率情况,一般是不会产生的?是的,但是对于分布式事务场景,我们不仅仅需要考虑正常逻辑流程,还需要关注小概率的异常场景,如果我们对异常场景缺乏处理方案,可能就会出现数据的不一致性,那么后期靠人工干预处理,会是一个成本非常大的任务,此外,对于交易的核心链路也许就不是数据问题,而是更加严重的资损问题。

3.2 三阶段提交协议

二阶段提交协议诸多问题,因此三阶段提交协议就要登上舞台了。三阶段提交协议是二阶段提交协议的改良版本,它与二阶段提交协议不同之处在于,引入了超时机制解决同步阻塞问题,此外加入了预备阶段尽可能提早发现无法执行的资源管理者(参与者)并且终止事务,如果全部资源管理者(参与者)都可以完成,才发起第二阶段的准备和第三阶段的提交。否则,其中任何一个资源管理者(参与者)回复执行失败或者超时等待,那么就终止事务。总结一下,三阶段提交协议包括:第一阶段预备,第二阶段准备,第二阶段提交。

这里可能大家有点蒙,我再详细讲解一下三阶段提交的整体流程。

3PC主要是为了解决两阶段提交协议的单点故障问题和缩小参与者阻塞范围。 引入参与节点的超时机制之外,3PC把2PC的准备阶段分成事务询问(该阶段不会阻塞)和事务预提交,则三个阶段分别为CanCommit、PreCommit、DoCommit。

(1)第一阶段(CanCommit 阶段)

类似于2PC的准备(第一)阶段。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

1.事务询问:
	协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
2.响应反馈
	参与者接到CanCommit请求之后,正常情况下,
	如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。
	否则反馈No。

(2)第二阶段(PreCommit 阶段)

协调者根据参与者的反应情况来决定是否可以记性事务的PreCommit操作。根据响应情况,有以下两种可能:

如果响应Yes,则:

1.发送预提交请求:
	协调者向参与者发送PreCommit请求,并进入Prepared阶段。

2.事务预提交
	参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。

3.响应反馈
	如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。



假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。则有:

1.发送中断请求:
	协调者向所有参与者发送abort请求。

2.中断事务
	参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。



(3)第三阶段(doCommit 阶段)

该阶段进行真正的事务提交,也可以分为执行提交和中断事务两种情况。

如果执行成功,则有如下操作:

1.发送提交请求
	协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态。
	并向所有参与者发送doCommit请求。

2.事务提交
	参与者接收到doCommit请求之后,执行正式的事务提交。
	并在完成事务提交之后释放所有事务资源。

3.响应反馈
	事务提交完之后,向协调者发送ACK响应。

4.完成事务
	协调者接收到所有参与者的ACK响应之后,完成事务。



协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务(注意这是没有收到二段段最后的ACK,这里要理解清楚)。则有如下操作:

1.发送中断请求
	协调者向所有参与者发送abort请求

2.事务回滚
	参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,
	并在完成回滚之后释放所有的事务资源。

3.反馈结果
	参与者完成事务回滚之后,向协调者发送ACK消息

4.中断事务
	协调者接收到参与者反馈的ACK消息之后,执行事务的中断。



最关键的****:在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时(1、协调者出现问题;2、协调者和参与者出现网络故障),会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是它有理由相信:成功提交的几率很大)

三阶段提交协议很好的解决了二阶段提交协议带来的问题,是一个非常有参考意义的解决方案。但是,极小概率的场景下可能会出现数据的不一致性。因为三阶段提交协议引入了超时机制,一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

4、分布式事务-最终一致性解决方案

4.1 TCC模式

二阶段提交协议和三阶段提交协议很好的解决了分布式事务的问题,但是在极端情况下仍然存在数据的不一致性,此外它对系统的开销会比较大,引入事务管理者(协调者)后,比较容易出现单点瓶颈,以及在业务规模不断变大的情况下,系统可伸缩性也会存在问题。注意的是,它是同步操作,因此引入事务后,直到全局事务结束才能释放资源,性能可能是一个很大的问题。因此,在高并发场景下很少使用。因此,需要另外一种解决方案:TCC 模式。注意的是,很多读者把二阶段提交等同于二阶段提交协议,这个是一个误区,事实上,TCC 模式也是一种二阶段提交。

TCC 模式将一个任务拆分三个操作:Try、Confirm、Cancel。假如,我们有一个 func() 方法,那么在 TCC 模式中,它就变成了 tryFunc()、confirmFunc()、cancelFunc() 三个方法。

在 TCC 模式中,主业务服务负责发起流程,而从业务服务提供 TCC 模式的 Try、Confirm、Cancel 三个操作。其中,还有一个事务管理器的角色负责控制事务的一致性。例如,我们现在有三个业务服务:交易服务,库存服务,支付服务。用户选商品,下订单,紧接着选择支付方式进行付款,然后这笔请求,交易服务会先调用库存服务扣库存,然后交易服务再调用支付服务进行相关的支付操作,然后支付服务会请求第三方支付平台创建交易并扣款,这里,交易服务就是主业务服务,而库存服务和支付服务是从业务服务。

我们再来梳理下,TCC 模式的流程。第一阶段主业务服务调用全部的从业务服务的 Try 操作,并且事务管理器记录操作日志。第二阶段,当全部从业务服务都成功时,再执行 Confirm 操作,否则会执行 Cancel 逆操作进行回滚。

注意****:我们要特别注意操作的幂等性。幂等机制的核心是保证资源唯一性,例如重复提交或服务端的多次重试只会产生一份结果。支付场景、退款场景,涉及金钱的交易不能出现多次扣款等问题。事实上,查询接口用于获取资源,因为它只是查询数据而不会影响到资源的变化,因此不管调用多少次接口,资源都不会改变,所以是它是幂等的。而新增接口是非幂等的,因为调用接口多次,它都将会产生资源的变化。因此,我们需要在出现重复提交时进行幂等处理。

那么,如何保证幂等机制呢?事实上,我们有很多实现方案。其中,一种方案就是常见的创建唯一索引。在数据库中针对我们需要约束的资源字段创建唯一索引,可以防止插入重复的数据。但是,遇到分库分表的情况是,唯一索引也就不那么好使了,此时,我们可以先查询一次数据库,然后判断是否约束的资源字段存在重复,没有的重复时再进行插入操作。注意的是,为了避免并发场景,我们可以通过锁机制,例如悲观锁与乐观锁保证数据的唯一性。这里,分布式锁是一种经常使用的方案,它通常情况下是一种悲观锁的实现。但是,很多人经常把悲观锁、乐观锁、分布式锁当作幂等机制的解决方案,这个是不正确的。除此之外,我们还可以引入状态机,通过状态机进行状态的约束以及状态跳转,确保同一个业务的流程化执行,从而实现数据幂等。

4.2 补偿模式

我们提到了重试机制。事实上,它也是一种最终一致性的解决方案:我们需要通过最大努力不断重试,保证数据库的操作最终一定可以保证数据一致性,如果最终多次重试失败可以根据相关日志并主动通知开发人员进行手工介入。注意的是,被调用方需要保证其幂等性。重试机制可以是同步机制,例如主业务服务调用超时或者非异常的调用失败需要及时重新发起业务调用。重试机制可以大致分为固定次数的重试策略与固定时间的重试策略。除此之外,我们还可以借助消息队列和定时任务机制。消息队列的重试机制,即消息消费失败则进行重新投递,这样就可以避免消息没有被消费而被丢弃,例如 JMQ 可以默认允许每条消息最多重试 多少 次,每次重试的间隔时间可以进行设置。定时任务的重试机制,我们可以创建一张任务执行表,并增加一个“重试次数”字段。这种设计方案中,我们可以在定时调用时,获取这个任务是否是执行失败的状态并且没有超过重试次数,如果是则进行失败重试。但是,当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。

除了重试机制之外,也可以在每次更新的时候进行修复。例如,对于社交互动的点赞数、收藏数、评论数等计数场景,也许因为网络抖动或者相关服务不可用,导致某段时间内的数据不一致,我们就可以在每次更新的时候进行修复,保证系统经过一段较短的时间的自我恢复和修正,数据最终达到一致。需要注意的是,使用这种解决方案的情况下,如果某条数据出现不一致性,但是又没有再次更新修复,那么其永远都会是异常数据。

定时校对也是一种非常重要的解决手段,它采取周期性的进行校验操作来保证。关于定时任务框架的选型上,业内比较常用的有单机场景下的 Quartz,以及分布式场景下 Elastic-Job、XXL-JOB、SchedulerX 等分布式定时任务中间件,咱公司有分布式调用平台( https://schedule.jd.com/ )。关于定时校对可以分为两种场景,一种是未完成的定时重试,例如我们利用定时任务扫描还未完成的调用任务,并通过补偿机制来修复,实现数据最终达到一致。另一种是定时核对,它需要主业务服务提供相关查询接口给从业务服务核对查询,用于恢复丢失的业务数据。现在,我们来试想一下电商场景的退款业务。在这个退款业务中会存在一个退款基础服务和自动化退款服务。此时,自动化退款服务在退款基础服务的基础上实现退款能力的增强,实现基于多规则的自动化退款,并且通过消息队列接收到退款基础服务推送的退款快照信息。但是,由于退款基础服务发送消息丢失或者消息队列在多次失败重试后的主动丢弃,都很有可能造成数据的不一致性。因此,我们通过定时从退款基础服务查询核对,恢复丢失的业务数据就显得特别重要了。

4.3 可靠事件模式

在分布式系统中,消息队列在服务端的架构中的地位非常重要,主要解决异步处理、系统解耦、流量削峰等问题。多个系统之间如果使用同步通信,则很容易造成阻塞,同时会将这些系统耦合在一起,因此,引入消息队列后,一方面解决了同步通信机制造成的阻塞,另一方面通过消息队列实现了业务解耦。

可靠事件模式,通过引入可靠的消息队列,只要保证当前的可靠事件投递并且消息队列确保事件传递至少一次,那么订阅这个事件的消费者保证事件能够在自己的业务内被消费即可。这里是否只要引入了消息队列就可以解决问题了呢?事实上,只是引入消息队列并不能保证其最终的一致性,因为分布式部署环境下都是基于网络进行通信,而网络通信过程中,上下游可能因为各种原因而导致消息丢失。

其一,主业务服务发送消息时可能因为消息队列无法使用而发生失败。对于这种情况,我们可以让主业务服务(生产者)发送消息,再进行业务调用来确保。一般的做法是,主业务服务将要发送的消息持久化到本地数据库,设置标志状态为“待发送”状态,然后把消息发送给消息队列,消息队列先向主业务服务(生产者)返回消息队列的响应结果,然后主业务服务判断响应结果执行之后的业务处理。如果响应失败,则放弃之后的业务处理,设置本地的持久化消息标志状态为“失败”状态。否则,执行后续的业务处理,设置本地的持久化消息标志状态为“已发送”状态。

此外,消息队列接收消息后,也可能从业务服务(消费者)宕机而无法消费。JMQ有ACK机制,如果消费失败,会重试,如果成功,会从消息队列中删除此条消息。那么,消息队列如果一直重试失败而无法投递,会在一定次数之后主动丢弃,当然我们也可以设置为一直重试,这种方式不推荐。我们需要如何解决呢?我们在上个步骤中,主业务服务已经将要发送的消息持久化到本地数据库。因此,从业务服务消费成功后,它也会向消息队列发送一个通知消息,此时它是一个消息的生产者。主业务服务(消费者)接收到消息后,最终把本地的持久化消息标志状态为“完成”状态。这就是使用“正反向消息机制”确保了消息队列可靠事件投递。当然,补偿机制也是必不可少的。定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。大家也可能会说,消费成功之后可以用RPC调用主业务服务,首先这样主业务服务要额外提供一个RPC的接口;另外也会对从业务服务造成业务的复杂度和耗时影响。这里要注意从业务服务要保证幂等性。

了解了“可靠事件模式”的方法论后,现在我们来看一个真实的案例来加深理解。首先,当用户发起退款后,自动化退款服务会收到一个退款的事件消息,此时,如果这笔退款符合自动化退款策略的话,自动化退款服务会先写入本地数据库持久化这笔退款快照,紧接着,发送一条执行退款的消息投递到给消息队列,消息队列接受到消息后返回响应成功结果,那么自动化退款服务就可以执行后续的业务逻辑。与此同时,消息队列异步地把消息投递给退款基础服务,然后退款基础服务执行自己业务相关的逻辑,执行失败与否由退款基础服务自我保证,如果执行成功则发送一条执行退款成功消息投递到给消息队列。最后,定时任务会从数据库扫描在一定时间内未完成的消息并重新投递。这里,需要注意的是,自动化退款服务持久化的退款快照可以理解为需要确保投递成功的消息,由“正反向消息机制”和“定时任务”确保其成功投递。此外,真正的退款出账逻辑在退款基础服务来保证,因此它要保证幂等性。当出现执行失败的状态并且超过重试次数时,就说明这个任务永久失败了,需要开发人员进行手工介入与排查问题。

总结一下,引入了消息队列并不能保证可靠事件投递,换句话说,由于网络等各种原因而导致消息丢失不能保证其最终的一致性,因此,我们需要通过“正反向消息机制”确保了消息队列可靠事件投递,并且使用补偿机制尽可能在一定时间内未完成的消息并重新投递。

5、总结

Google Chubby的作者Mike Burrows说过, there is only one consensus protocol, and that’s Paxos” – all other approaches are just broken versions of Paxos. 意思是世上只有一种一致性算法,那就是Paxos,所有其他一致性算法都是Paxos算法的不完整版。上面都是以Paxos算法理论为基础具象化的方案。Google 的 Chubby、MegaStore、Spanner 等系统,ZooKeeper 的 ZAB 协议,还有更加容易理解的 Raft 协议都有Paxos算法的影子,感兴趣的可以去看Paxos算法详细说明,这里就不再赘述了。

现在在做活动平台相关项目,经常短时间要完成一个活动组件,一般没有完整的考虑微服务下保证事务的一致性或者一套统一的标准,所以需要微服务下保证事务的一致性SOP,这样每个可以保证每个活动快速搭建和安全运行。后续会推出活动平台在微服务下保证事务一致性的整体方案。大家可能以前都听过或者在写代码过程中或多或少都考虑过,也或多或少使用过前面提到过的这些方案,但是没有一个系统性的了解或者完善的方案调研,希望通过这篇文章能让大家有个稍微完整的了解,文章中有任何不足或者大家有更好的方案,欢迎一起共同探讨。

posted @ 2023-04-27 16:18  京东云技术团队  阅读(570)  评论(0编辑  收藏  举报