分布式事务,一直是实现分布式系统过程中最大的挑战。在只有单个数据源的单服务系统当中,只要这个数据源支持事务,例如大部分关系型数据库,和一些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。分布式系统的业务流程,实际上就是一个完备的状态机,这个状态机是否包含了所有的事件,是否包含了所有的业务路径,包括正常的、异常的,合理的设计业务流程,才能更好地实现分布式事务。
热门评论
都能保证左右的数据修改都恢复到未修改的状态。—>都能保证所有的数据修改都恢复到未修改的状态
不同的事务并发放完相同的数据时—-> 不同的事务并发访问相同的数据时
为什么一个业务中多个数据库的操作先后完成的间隔时间违反了原子性呢?不是只要事务中的操作全部完成或者全部未完成就可以了吗,难道要同时完成?