简介
1、事务
1.1、数据库事务
事务的 ACID特性:
-
原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态。
-
一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
-
隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读已提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
-
持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
1.2、单体事务和分布式事务
单体事务下,一个 @Transaction注解就可以实现事务,但是在分布式环境下,多服务相互调用链路中保证事务是一个难题,因为环境不同服务的环境是隔离的。Spring事务是在同一 JVM 下的事务。
所以分布式环境下的事务保证就需要约定,然后根据规约进行实现,比如 SpringCloud Alibaba Seata。就是来一个中间人,相当于中介,服务都需要经过中介,然后中介规定和实现事务。
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
2、分布式事务解决方案
2.1、XA分布式事务协议 - 2PC(两阶段提交实现)
PC 实际上指的是 Prepare 和 Commit,也就是说它分为两个阶段,一个是准备一个是提交,整个过程的参与者一共有两个角色,一个是事务的执行者,一个是事务的协调者,实际上整个分布式事务的运作都需要依靠协调者来维持。
2.1.1、准备阶段
一个分布式事务是由协调者来开启的,首先协调者会向所有的事务执行者发送事务内容,等待所有的事务执行者答复。
各个事务执行者执行本地的事务操作,但是事务做完后不进行提交,并将 undo 和 redo信息记录到事务日志中。
如果事务执行者执行事务成功,那么就告诉协调者成功 Yes,否则告诉协调者失败 No,不能提交事务。
2.1.2、提交阶段
当所有的执行者都反馈完成之后,进入第二阶段,即提交阶段。协调者会检查各个执行者的反馈内容,
如果所有的执行者都返回成功,那么就告诉所有的执行者可以提交事务了,最后再释放锁资源。
如果有至少一个执行者返回失败或是超时,那么就让所有的执行者都回滚,分布式事务执行失败。
2.1.3、优缺点
优点:
-
简单 缺点:
-
单点问题:事务协调者是非常核心的角色,一旦出现问题,将导致整个分布式事务不能正常运行。并且所有参与者会一直等待状态,无法完成其它操作。
-
数据不一致:如果提交阶段发生网络问题,导致部分事务执行者没有收到协调者发来的 Commit提交命令,这将导致部分执行者提交了,而部分执行者没提交,导致系统数据不一致,这样肯定是不行的。
-
同步阻塞:所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
-
太过保守:因为任意一个节点失败了,就会导致整个事务失败,没有完善的容错机制。
2.2、XA分布式事务协议 - 3PC(三阶段提交实现)
三阶段提交是在二阶段提交基础上的改进版本,主要是加入了超时机制,同时在协调者和执行者中都引入了超时机制。
2.2.1、CanCommit阶段
协调者向执行者发送 CanCommit请求,询问是否可以执行事务提交操作,然后开始等待执行者的响应。执行者接收到请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回 Yes响应,并进入预备状态,否则返回 No。
2.2.2、PreCommit阶段
协调者根据执行者的反应情况,来决定是否可以进入第二阶段事务的 PreCommit操作。如果所有的执行者都返回 Yes,则协调者向所有执行者发送 PreCommit请求,并进入 Prepared阶段,执行者接收到请求后,会执行事务操作,并将 undo 和 redo信息记录到事务日志中,如果成功执行,则返回成功响应。
如果所有的执行者至少有一个返回 No,则协调者向所有执行者发送 abort请求。所有的执行者在收到 abort请求或是超过一段时间没有收到任何请求时,会直接中断事务。
2.2.3、DoCommit阶段
该阶段进行真正的事务提交操作。如果协调者接收到所有执行者发送的成功响应,那么他将从 PreCommit状态进入到 DoCommit状态,并向所有执行者发送 doCommit请求,执行者接收到 doCommit请求之后,开始执行事务提交,并在完成事务提交之后释放所有事务资源,并最后向协调者发送确认响应,当协调者接收到所有执行者的确认响应之后,完成事务。
如果协调者并没有收到所有执行者发送的成功响应(也可能是响应超时),那么就会执行中断事务,协调者会向所有执行者发送 abort请求,执行者接收到 abort请求之后,利用其在 PreCommit阶段记录的 undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。执行者完成事务回滚之后,向协调者发送确认消息, 协调者接收到执行者反馈的确认消息之后,执行事务的中断。
(如果因为网络问题导致执行者没有收到 doCommit请求,那么执行者会在超时之后直接提交事务,虽然执行者只是猜测协调者返回的是 doCommit请求,但是因为前面的两个流程都是正常执行的,所以能够在一定程度上认为本次事务是成功的,因此会直接提交。)
2.2.4、优缺点
优点:
-
3PC 在 2PC 的第一阶段和第二阶段中插入一个准备阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。
-
一旦执行者无法及时收到来自协调者的信息之后,会默认 CanCommit阶段的执行中断操作或者 DoCommit阶段的Commit操作,这样就不会因为协调者单方面的故障导致全局出现问题。 缺点:
-
但是实际上 DoCommit阶段超时之后的 Commit决策本质上就是一个赌注罢了,如果此时协调者发送的是 abort请求但是超时未接收,那么就会直接导致数据一致性问题。
2.3、TCC(补偿事务)
补偿事务TCC 就是 Try、Confirm、Cancel,它也有三个阶段,并且它对业务有侵入性。
针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
2.3.1、Try阶段
-
Try 阶段主要是对业务系统做检测及资源预留 比如我们需要在借书时,将书籍的库存-1,并且用户的借阅量也-1,但是这个操作,除了直接对库存和借阅量进行修改之外,还需要将减去的值,单独存放到冻结表中,但是此时不会创建借阅信息。也就是说是预先把关键的东西给处理了,将业务资源预留出来。
比如 Bob 要向 Smith 转账,思路大概是: 我们有一个本地方法,里面依次调用:
-
首先在 Try阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
-
在 Confirm阶段,执行远程调用的转账的操作,转账成功则进行解冻。
-
如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
2.3.2、Confirm阶段
-
Confirm阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要 Try 成功,默认 Confirm 一定成功。 如果 Try阶段执行成功,那么就进入到 Confirm阶段,但是资源方面只能使用 Try阶段中预留在冻结表中的业务资源,如果创建成功,那么就对 Try阶段冻结的值进行解冻,整个流程就完成了。如果失败了,那么进入到 Cancel阶段。
2.3.3、Cancel阶段
Cancel阶段就是取消业务,然后把冻结的资源还回去,因为整个借阅操作没成功。
2.3.4、优缺点
优点:
-
跟 XA协议相比,TCC 就没有协调者这一角色的参与了,而是自主通过上一阶段的执行情况来确保正常,充分利用了集群的优势,性能也是有很大的提升 缺点:
-
缺点也很明显,它与业务具有一定的关联性,需要开发者去编写更多的补偿代码,同时并不一定所有的业务流程都适用于这种形式。
-
实现以及流程相对简单了一些,但数据的一致性比 2PC 也要差一些
2.3.5、TCC 的空回滚和业务悬挂
当某分支事务的 try阶段阻塞时,可能导致全局事务超时而触发二阶段的 cancel操作。在未执行 try操作时先执行了 cancel操作,这时 cancel 不能做回滚,就是空回滚。
对于已经空回滚的业务,如果以后继续执行 try,就永远不可能 confirm 或 cancel,这就是业务悬挂。应当阻止执行空回滚后的 try操作,避免悬挂。
2.4、本地消息表+消息队列(异步确保)
这种方式是无回滚的,主要是确保消息队列的异步消息的成功发送。
比如:支付宝、微信支付主动查询支付状态,对账单的形式。
2.4.1、简介
本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。
-
在分布式事务操作的一方,完成写业务数据的操作之后,向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。
-
之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。
-
在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。
2.4.2、优缺点
优点:
-
避免了分布式事务,实现了最终一致性。 缺点:
-
耦合度很高,消息表会耦合到业务系统中,如果没有封装好的解决方案,那就会有很多杂活需要处理。
3、分布式事务具体实践1
3.1、系统与系统之间的分布式事务问题
两个系统之间,任意一个系统发送错误,另一方都无法让报错方进行回滚。
3.1.1、事务问题代码简述
订单方设置了调用运单方的超时时间是 2s,然后让运单方睡个 3s 来模拟网络问题,那么因为订单方有事务,就会做一个回滚,但是在运单方这边可能只是网络波动或延迟,他还是会继续产生运单,那么就产生了分布式事务问题了,没有保证 ACID特性。
@Service public class OrderService { @Autowired private OrderDataBaseService orderDataBaseService; // 创建订单// 为订单创建整个方法添加事务 @Transactional(rollbackFor = Exception.class) public void createOrder(Order orderInfo) throws Exception { // 1: 订单信息--插入丁订单系统,订单数据库事务 orderDataBaseService.saveOrder(orderInfo); // 2:通過Http接口发送订单信息到运单系统 String result = dispatchHttpApi(orderInfo.getOrderId()); if(!"success".equals(result)) { throw new Exception("订单创建失败,原因是运单接口调用失败!"); } } // 模拟http请求接口发送,运单系统,将订单号传过去 private String dispatchHttpApi(String orderId) { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); // 链接超时 > 3秒 factory.setConnectTimeout(3000); // 处理超时 > 2秒 factory.setReadTimeout(2000); // 发送http请求 String url = "http://localhost:3000/dispatch/order?orderId="+orderId; RestTemplate restTemplate = new RestTemplate(factory);//异常 String result = restTemplate.getForObject(url, String.class); return result; } }
3.2、基于MQ的分布式事务整体设计思路
订单方在向运单方传递订单数据的时候,不通过 Restful、Dubbo等方式,而是通过 RabbitMQ 来传递。订单方把消息给 RabbitMQ,然后让 RabbitMQ 通过一系列策略来保证分布式事务的 ACID特性。
3.3、基于 MQ 的分布式事务消息的可靠生产
通过消息冗余的方式来保证生产者发送的消息一定会被 MQ 接收到。
3.3.1、RabbitMQ的确认机制来保证可靠生产
需要在配置文件中添加配置。
rabbit: publisher-confirm-type: correlated
这里其实就是在做初始化操作,开启一个确认回执机制,当调用 sendMessage方法,向 MQ 发送消息的时候,会收到回执,然后调用回执方法体内的业务。
@PostConstruct public void regCallback() { rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() { @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { System.out.println("cause:" + cause); // 如果 ack 为 true,代表消息已经被收到 String orderId = correlationData.getId(); if (!ack) { // 这里可能要进行其他的方式进行存储 System.out.println("MQ 队列应答失败,orderId是:" + orderId); return; } try { // 修改本地数据库的订单消息状态 } catch (Exception e) { System.out.println("本地消息状态修改失败,出现异常:" + e.getMessage()); } } }); } public void sendMessage(Order order){ // 通过 MQ 发送消息 rabbitTemplate.convertAndSend("交换机的名称", "路由key", JsonUtil.obj2String(order), new CorrelationData(order.getOrderId())); }
3.4、基于MQ的分布式事务消息的可靠消费
3.4.1、手动 ack 确认机制来保证可靠消费
消费者在消费过程中出现异常,那么 RabbitMQ 就会死循环去重试做消费。
3.4.1.1、解决消息重试的方案
消费者在消费过程中出现异常,那么 RabbitMQ 就会死循环去重试做消费,这样是非常浪费性能的。
控制死循环的方法其实很多,有很多种搭配方法,也可以全部都做。但是控制重发次数与 try+catch 是互斥的。
-
控制重发的次数
-
try + catch + 手动 ack
-
try + catch + 手动 ack + 死信队列处理 + 人工干预
3.5、保证可靠消费的一些方案
3.5.1、基于 MQ 的分布式事务消息的消息重发
3.5.2、基于MQ的分布式事务消息的死信队列消息转移 + 人工处理
如果死信队列报错就进行人工处理。