分布式事务,一直是实现分布式系统过程中最大的挑战。在只有单个数据源的单服务系统当中,只要这个数据源支持事务,例如大部分关系型数据库,和一些MQ服务,如activeMQ等,我们就可以很容易的实现事务。
本地事物
大家可能都知道什么是事务,但是我们还是再来看一下它的定义。事务的概念来自于数据库事务,在数据库事务定义中,事务是一个执行的逻辑单元,它需要提供一个一致、可靠的数据操作。它主要包括下面两个目标:当出现任何错误,包括系统宕机、部分失败等,都能保证左右的数据修改都恢复到未修改的状态。
不同的事务并发放完相同的数据时,提供适当的隔离机制。
我们常说的ACID其实,其实是某些数据库特有的事务的实现方式,也就是实现了原子性、一致性、隔离性和持久性。
分布式系统的实现原则
那么在分布式系统当中,我们应该怎么样去实现事务呢?这就需要从分布式系统的原则说起。分布式系统的实现原则有几种说法,如BASE原理、ACP原理。
其中ACP是:A: 可用性(Availability)
C: 一致性(Consistency)
P: 分区容错性(Tolerance of network Partition)
A和P没什么好说的,就是分布式系统的基本特性,C(一致性)就是指在分布式系统当中,多个节点之间数据的一致性,包括一个节点修改的数据,通过另一个节点访问的时候也能看到;以及当一个操作需要修改多个数据源的数据的时候,多个修改要都能够完成,或者都不完成。
这里的一致性,我们可以看做是上面说的数据库事务的ACID特性中,原子性、一致性,甚至是隔离性的统一。如果以ACID这4个特性为要求来实现分布式系统,在现实当中是不可能的,其中原子性就没有办法实现。如果一个业务请求,要修改多个数据库中的数据,那么这多个数据库的操作,就无法实现原子性,势必会有一个先后,在第一个数据库上完成以后,再在第二个数据库上完成,那么这期间的一点点时间,就违反了原子性。
所以,我们往往无法在分布式系统中实现完全的一致性,所以就有了BASE理论。BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的缩写。BASE理论是对CAP中一致性和可用性权衡的结果,要求实现最终一致性即可。
其中,Soft state(软状态)是指,在一个业务操作过程中,允许出现一个中间状态,也就是软状态,而不要求原子性那样,要么都完成,要么都不完成。例如在下单的时候,出现一个“正在处理”的状态。由于有这个软状态,那我的一致性,就不要求是强一致性,而是最终一致性,也就是说,只要最终这个请求能处理完,所有的数据状态都是处理完的状态;如果期间出错了,所有的数据也都一致,该失败的失败、该退钱的退钱、该重置的重置。
所以,确定了分布式系统的实现原则是最终一致性以后,同时也明确了我们实现分布式事务的原则,也是最终一致性。
其实,不管是数据库事务的ACID特性,还是分布式事务的最终一致性,其实,都是根据事务的定义和它的两个目标,所采取的不同的实现方式。
那么我们应该怎么实现这个最终一致性呢?
首先,任何一个分布式系统,总是由一个个的系统组成,也就是一个个的服务,这些服务又可以部署多个。同时,我们的整个系统也需要一定的方式相互作用、相关通信。有时候,我们可以让一个服务直接调用另一个服务的接口(如果有提供的话);还有时候,我们可以让两个服务通过一个MQ之类的消息中间件通信,共同完成一些业务。但是,无论如何,大部分情况下,分布式系统的一个服务总是会访问多个数据源。最典型的例子就是通过MQ接受一个事件,然后出发一些操作,再把结果发送到另一个队列里。
对于这种每个服务访问多个数据源的情况,其实就是一个最简单的分布式事务的场景。如果大家在网上搜“Spring分布式事务实现”,搜到的结果也都是在说这个场景下的分布式事务实现过程。
要实现这个事务,首先需要对Spring的事物机制有一定了解。对于这种情况,最简单的就是使用Spring的JTA事务管理。但是,我们知道,JTA事务管理是通过两阶段提交实现的,在很多情况下,它的效率是很低的。因为它在多个数据源修改数据的时候,这些数据一直都处在被锁的状态,知道多个数据源的事务都提交完成,才会释放。
如果不用JTA,Spring也给我们提供了几种方式,来近似的实现分布式事务(注意这里说的近似)。例如:事务同步,也就是提交一个事物的时候,通过Listener等方式通知另一个事务也提交。但是这种情况下,如果第二个事务提交的时候出错了,第一个事物就无法回滚,因为他已经提交完成了。
链式事务,就是将多个事务,包装在一个链式事务管理器当中,在提交事务的时候,一次提交里面的事务。对于这种实现,也存在上面说的问题。
还有其他的一些方式,就不过多说明。
所以,使用Spring在单服务多数据源的情况下,实现分布式事务,实际上没办法完全实现事务的,因为出错的时候不能保证都会滚。那么这时候,就需要再通过其他机制来补充。首先就是重试,也就是在出错的时候,重试之前的操作。这在有MQ的时候比较常用,因为一般的MQ服务器,在你读消息以后,处理的时候如果出错了,那么这个读消息的操作不会被提交。那这个消息就会被重新读到,重新出发刚才的操作。这时候,我们就需要考虑这个方法的幂等性,保证在重复消息的时候不会重复处理数据。
其次,我们需要自己处理一些错误。例如上面的情况,重试几次以后,一直没有成功,那么这时候就需要走失败逻辑。有时候,我们也可以通过一个定时器来检查一定时间内没有完成的失败操作。
有些情况下,我们还需要考虑其他各种错误,如网络错误、超时,系统宕机等等。
大家可以试想一下,分布式系统越复杂,它的各种出错的情况就越多,我们需要考虑的补救措施就越多。那这种修修补补的实现分布式事务的最终一致性的做法,始终不是一个好的办法。但是,使用Spring解决单服务的分布式系统,始终是分布式事务实现的基础。我们可以用其他的模式来方便我们解决分布式事务,但是在每个服务当中,我们还是要经常使用事务同步、链式事务等,来实现事务。我们用Spring来保证绝大多数情况下的事务问题,而对于特殊的错误情况,就采用其他的模式来解决。
分布式事务实现的模式
刚才说了我们用其他模式来觉得分布式事务问题,那么都有什么模式呢?
消息驱动(Event Driven)模式
消息驱动模式是,当某个业务请求需要由多个服务参与完成的时候,这些服务之前不直接通信,而是通过一个MQ中间件来通信。比如对于一个订单支付的请求,接收到支付完成的请求后,通过MQ,通知订单服务去完成订单,订单服务再去通知商品服务去减库存,再通知物流服务去发起物流流程。
那么,对于每一个服务来说,都需要先从一个队列读取一个消息,完成自己的业务操作,再往另一个队列发送一个消息,这就需要操作一个数据和一个MQ服务器。这也就是上面说的单服务的分布式事务实现。对于这种模式而言,我们用事务同步保证在每个服务中,在大部分情况下都能保证事务。即使偶尔出现网络错误、系统错误等,通过重试就能解决大部分问题。如果重试一直不能解决,那就再处理失败逻辑。
我们使用这种方式,最重要的,就是对这个消息、和他的处理流程的编排,其次,它也是一种响应式的编程思维。
事件溯源(Event Sourcing)模式
Event Sourcing在上面说的消息驱动的基础上,进一步提升事件(也就是之前的消息)的地位,让它成为系统的一等公民。也就是说,怎么的系统不是基于原先那些实体的,而是基于事件的,一个事件就代表一个业务操作和业务数据状态的更改。至于业务数据,我们不需要把它保存在数据库中,即使保存,也只是为了查询数据方便而保存。
在Event Sourcing模式中,每个服务完成某个逻辑的方式,跟上面说的消息驱动模式差不多,就是对于用户的每个操作,会产生一个事件(可能多个),这个事件会被某个服务的某个处理方法处理,它也有可能再产生其他的事件,再由其他服务处理,直到完成整个业务流程。但是,它跟消息驱动的最大区别就是,在Event Sourcing的服务里,业务状态数据不一定要保存在数据库中,就算保存,出错了也没关系,反正它可以根据Event事件重新生成。所以这个地方的事务,我们只需要保证Event保存成功即可。当然,我们需要其他的机制,方便我们能够重新生成业务数据,而这,一般都是实现Event Sourcing的框架来提供。
TCC(Try-Confirm-Cancel)模式
除了上面说的通过一个中间价关联不同的服务,在有些分布式系统当中,我们的不同的服务可以直接通信,例如Spring Cloud微服务框架就提供Rest方式访问别的服务。那么这时候,就相当于,我的一个服务除了访问自己的数据库以外,还要访问别的服务,这里的这个服务就可以当做是一个数据库。我们可能要调用别的服务的某个接口完成一些业务。
在这种情况下,一个服务提供的接口,不可能实现事务,也就是先操作数据,再Commit,如果出错了再Rollback。但是,我们可以借鉴事务的这种处理思路,来自己提供类似事务的方法,这就是TCC模式。一个事物是通过Do-Commit/Rollback来实现的,在TCC模式中,是通过给每一个服务间调用的操作接口,提供一套Try-Confirm/Cancel接口。
还是举一个例子,就是用户下单以后支付完成。支付完成的时候由先订单服务处理,然后调用商品服务去减库存。大家用Spring Cloud的话,可能就在商品服务里写一个接口直接做减库存的操作,但是在TCC模式下,我们需要3个接口。首先是减库存的Try接口,在这里,我们要检查业务数据的状态、检查商品库存够不够,然后做资源的预留,也就是在某个字段上设置预留的状态。然后在Confirm接口里,完成库存减1的操作。在Cancel接口里,把之前预留的字段重置。
这可能听着有点繁琐,感觉可以一次完成的事情,为什么要分成2步,首先这么做是为了能够在出错的时候正确的重置库存数据,其次这个预留操作跟Confirm操作是两个请求,中间可能会有其他并发请求。从理论上说,只要我们在Try接口里面预留资源的逻辑是正确的,那么,即使Confirm的时候出错了,我也可以通过重试Confirm请求来完成
使用数据库保存事务状态
这其实不是一种模式,只是一种方式。例如在TCC模式下,在准备调用Confirm接口的时候,目标服务突然宕机了,或者发起请求的服务突然宕机或出错了,导致这个Confirm请求一直没有被调用。那么,在系统恢复以后,我该怎么完成之前的事务呢?除了上面说的用定时器定期检查未完成的操作以外(需要能够通过某种数据状态判断业务没有执行完成后),我们还可以用数据库来记录事务的运行状态。
例如在TCC模式中,每当一个服务A要使用TCC模式调用另一个服务B的时候,服务A将这个TCC的事务状态写到数据库中,根据具体实现,可能是在调用前记录当前事务的状态,调用完成再保存该调用的参数和结果状态,这个事务完成以后(也就是调用完Confirm,或Cancel以后),再更新成完成的状态。那么,通过合理的设计,我们就能在各种出错情况下,保证能继续完成这个事务,或取消这个事务。
总结
总之,对于分布式事务来说,没有一个简单的像本地事物一样的实现方式,我们总是需要根据分布式系统的设计,根据业务需求,选择某种方式来保证数据的一致性。而且,在实现分布式事务的过程中,业务流程的设计也至关重要,不管是用TCC、消息驱动、还是EventSourcing。分布式系统的业务流程,实际上就是一个完备的状态机,这个状态机是否包含了所有的事件,是否包含了所有的业务路径,包括正常的、异常的,合理的设计业务流程,才能更好地实现分布式事务。