分布式事务一站式解决方案与实现
1 本地事务
1.1 事务的概述
事务指逻辑上的一组操作,组成这组操作的各个单元,要么全部成功,要么全部不成功。从而确保了数据的准确与
安全。
1.2 事务的四大特性
- 原子性(Atomicity)
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。 - 一致性(Consistency)
事务必须使数据库从一个一致性状态变换到另外一个一致性状态。
例如转账前A有1000,B有1000。转账后A+B也得是2000。 - 隔离性(Isolation)
事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,每个事务不能被其他事务的操作数
据所干扰,多个并发事务之间要相互隔离。 - 持久性(Durability)
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对
其有任何影响。
1.3 事务的隔离级别
不考虑事务隔离级别会在数据并发读写的时候出现以下情况(以下情况全是错误):
脏读:一个线程中的事务读到了另一个线程中未提交的数据;
不可重复读:一个线程中的事务读到了另外一个线程中已经提交的update的数据(前后内容不一样)
虚读(幻读):一个线程中的事务读到了另外一个线程中已经提交的insert的数据(前后条数不一样)
数据库共定义了四种隔离级别
- Serializable(串行化):
提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行。如果仅仅通过“行级锁”是无法实现事务序列化的,必须通过其他机制保证新插入的数据不会被刚执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻像读。
隔离级别最高 - Repeatable read(可重复读):
可重复读是指在一个事务内,多次读同一数据。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,即使第二个事务对数据进行修改,第一个事务两次读到的的数据是一样的。这样就发生了在一个事务内两次读到的数据是一样的,因此称为是可重复读。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。这样避免了不可重复读取和脏读,但是有时可能出现幻象读。(读取数据的事务)这可以通过“共享读锁”和“排他写锁”实现。
隔离级别第二 - Read committed(读已提交):
读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行。该隔离级别避免了脏读,但是却可能出现不可重复读。事务A事先读取了数据,事务B紧接了更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。
隔离级别第三 - Read uncommitted(读未提交):
如果一个事务已经开始写数据,则另外一个事务则不允许同时进行写操作,但允许其他事务读此行数据。该隔离级别可以通过“排他写锁”实现。这样就避免了更新丢失,却可能出现脏读。以上情况均无法保证。(读未提交) 最低 。
隔离最低级别
注意:级别依次升高,效率依次降低
MySQL的默认隔离级别是:REPEATABLE READ。(Oracle的默认是:READ COMMITTED)
2 分布式事务
2.1 分布式事务概念
分布式事务是由本地事务演变而来的。分布式事务即组成事务的各个单元处于不同数据库服务器上。比如,电商系统中的生成订单,账户扣款,减少库存,增加会员积分等等,他们就是组成事务的各个单元,它们要么全部发生,要么全部不发生,从而保证最终一致,数据准确。
2.2 分布式事务解决方案分类
刚性事务
刚性事务指的就是遵循本地事务四大特性(ACID)的强一致性事务。它的特点就是强一致性,要求组成事务的各个 单元马上提交或者马上回滚,没有时间弹性,要求以同步的方式执行。通常在单体架构项目中应用较多,一般都是 企业级应用(或者局域网应用)。例如:生成合同时记录日志,付款成功后生成凭据等等。但是,在时下流行的互 联网项目中,这种刚性事务来解决分布式事务就会有很多的弊端。其中最明显的或者说最致命的就是性能问题。如 下图所示:
因为某个参与者不能自己提交事务,必须等待所有参与者执行OK了之后,一起提交事务,那么事务锁住的时间就 变得非常长,从而导致性能非常低下。
柔性事务
柔性事务是针对刚性事务而说的,我们刚才分析了刚性事务,它有两个特点,第一个强一致性,第二个近实时性 (NRT)。而柔性事务的特点是不需要立刻马上执行(同步性),且不需要强一致性。它只要满足基本可用和最终一致就可以了。要想真正的明白,需要从BASE理论和CAP理论说起。
刚性事务和柔性事务比较
事务类型 | 时间要求 | 一致性要求 | 应用类型 | 应用场景 |
---|---|---|---|---|
刚性事务 | 立即 | 强一致性 | 企业级应用(单体架构) | 订单/订单项/日志 |
柔性事务 | 有时间弹性 | 最终一致性 | 互联网应用(分布式架构) | 订单/支付/库存 |
3 分布式事务解决方案
3.1 二阶段提交(2PC)
将一个分布式的事务过程拆分成两个阶段: 准备阶段 和 事务提交 。为了让整个数据库集群能够正常的运行,该协议指定了一个 协调者 单点,用于协调整个数据库集群各节点的运行。为了简化描述,我们将数据库集群中的各个节点称为 参与者,三阶段提交协议中同样包含协调者和参与者这两个角色定义。
如果第一阶段执行失败进入回滚,如下图:
两阶段提交缺点:事务协调者存在单点故障;第一阶段存在同步阻塞,当事务参与者过多效率较低;
3.2 三阶段提交协议(3PC)
针对两阶段提交存在的问题,三阶段提交协议通过引入一个 预询盘 阶段,以及超时策略来减少整个集群的阻塞时间,提升系统性能。三阶段提交的三个阶段分别为:预询盘(can_commit)、预提交(pre_commit),以及事务提交(do_commit)。
如果事务执行失败进入回滚,如下图;
两阶段提交协议中所存在的长时间阻塞状态发生的几率还是非常低的,所以虽然三阶段提交协议相对于两阶段提交协议对于数据强一致性更有保障,但是因为效率问题,两阶段提交协议在实际系统中反而更加受宠。
3.3 TCC+补偿型方案(3PC)
TCC分别指的是Try,Confirm,Cancel。它是补偿型分布式事务解决方案。何为补偿呢?其实我们把TCC这3个部 分分别做什么捋清楚,就很容理解了。首先,我们先来看下它们的主要作用:
Try 阶段主要是对业务系统做检测及资源预留。
Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
在Try阶段进行尝试提交事务,当Try执行OK了,Confirm执行,且默认认为它一定 成功。但是当Try提交失败了,则由Cancel处理回滚和资源释放。
TCC事务的处理流程与2PC两阶段提交做比较,首先TCC是柔性事务,只要符合最终一致性即可。而2PC是刚性事 务,它是强一致性的,在任何一个分布式阶段没有返回执行成功或失败的结果时,其事务一直会处于等待状态。并 且2PC是利用DTP模型和XA规范,要求数据库支持XA规范,且通常都是在跨库的DB层面。
而TCC则在应用层面的处理,需要通过自己编写逻辑代码来实现补偿。它的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同 的失败原因实现不同的回滚策略。
3.4 最终一致性方案
3.4.1 本地消息表
这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于 ebay。它和MQ事务消息的实现思路都是一样的,都是利用MQ通知不同的服务实现事务的操作。不同的是,针对 消息队列的信任情况,分成了两种不同的实现。本地消息表它是对消息队列的稳定性处于不信任的态度,认为消息 可能会出现丢失,或者消息队列的运行网络会出现阻塞,于是在数据库中建立一张独立的表,用于存放事务执行的 状态,配合消息队列实现事务的控制。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
3.4.2 MQ事务消息
有一些第三方的MQ是支持事务消息的,比如RocketMQ,ActiveMQ,他们支持事务消息的方式也是类似于采用的 二阶段提交。但是有一些常用的MQ也不支持事务消息,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例,其思路大致为: 第一阶段Prepared消息,会拿到消息的地址。 第二阶段执行本地事务。 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了 RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方 需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证 了消息发送与本地事务同时成功或同时失败。下图描述了它的工作原理:
正常情况
异常情况
本地消息表与MQ消息表对比
分类 | 共同点 | 优势 | 弊端 |
---|---|---|---|
本地消息表 | 都需要自己写业务补偿 | 一种非常经典的实现,避免了分布式事务,实现了最终一致性。 | 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理 |
MQ事务消息 | 都需要自己写业务补偿 | 实现了最终一致性,不需要依赖 本地数据库事务。 用消息队列的方式实现分布式事 务,效率较高 | 目前主流MQ中有ActiveMQ RocketMQ支持 事务消息 实现难度较大,和业务耦合比较紧密 |
4 分布式事务实现
4.1 atomikos实现二阶段提交
atomikos一套开源的,JTA规范的实现。它是基于二阶段提交思想实现分布式事务的控制。是分布式刚性事务的一 种解决方案。在当下互联网开发中选择此种解决方案的场景也不是很多了。实现原理参考IBM社区
场景说明
一个企业级应用项目:进销存系统。系统要对针对库存记录访问日志。并且,库存系统数据库和日志数据库不是同
一个数据库。代码详见GitHub
服务拓扑图
测试代码
@Slf4j
@Service
@Transactional(rollbackOn = Exception.class)
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private LogMapper logMapper;
@Override
public void addOrder(OrderInfo orderInfo) {
int insert = orderMapper.insertOrderInfo(orderInfo);
log.info("order库新增sql执行条数:{}",insert);
//测试1 异常回滚
int i=1/0;
LogInfo logInfo=new LogInfo();
logInfo.setId((new Random().nextInt()));
logInfo.setCreateTime(new Date());
logInfo.setContent(orderInfo.toString());
int insert1 = logMapper.insertLogInfo(logInfo);
log.info("logs库新增sql执行条数:{}",insert1);
//测试2 异常回滚
// int i=1/0;
}
}
测试结果
测试1:业务执行前出现异常,数据库未进行插入操作,数据库无数据。
测试2:业务执行后出现异常
异常发生前控制台日志显示插入成功,但是数据库中并没有数据,当异常出现后,实际数据从头到尾并没有提交
测试3:无异常情况,logs和order数据库表中均有数据
二阶段提交总结
二阶段提交作为早期分布式事务的解决方案,逐渐的淡出了主流方案的圈子。这里面其最重要的原因就是它是刚性事务,即需要满足强一致性。它的优点就是可以在多数据库间实现事务控制,而摆脱单一数据库使用事务的宿命。但是阻塞式这个缺点确是致命的,因为参与全局事务的数据库被动听从事务管理器的命令,执行或放弃事务,如果运行事务管理器的机器宕机,那整个系统就不能用了。当然,在极端情况下还可能同时影响其他系统,如果事务管理器挂了,但是这个数据库的表锁还没释放,因为数据库还在等待事务管理器的命令,因此,使用这个数据库的其他应用也会收到影响。
4.2 RocketMQ实现分布式事务
事务消息,它是消息队列中一种特殊的消息类型,只不过不是所有的消息队列产品都支持事务消息。目前支持事务 消息的队列一个是阿里的RocketMQ(已经被apache收录),一个是ActiveMQ。
RocketMQ事务消息(Transactional Message)是指应用本地事务和发送消息操作可以被定义到全局事务中,要 么同时成功,要么同时失败。RocketMQ的事务消息提供类似 X/Open XA 的分布事务功能,通过事务消息能达到 分布式事务的最终一致。
此处我们需要明确一件事,分布式事务和事务消息两者并没有关系,事务消息仅仅保证本地事务和MQ消息发送形 成整体的原子性,而投递到MQ服务器后,消费者是否能一定消费成功是无法保证的。
4.2.1 RocketMQ事务消息执行流程
完整流程
事务执行正常流程
本地事务执行异常流程
事务回查
4.2.2 案例场景
以订单、支付为例,在下单成功后,马上紧跟着的就是需要付款。只有付款成功了之后,订单的状态才会改为已付款,进而继续走出库,发货,物流等等的流程,而如果订单迟迟不付款的话,超过一个时限之后就自动关闭了。下图描述了场景的业务流程:
4.2.3 核心代码
支付服务
PayController
@RestController
public class PayController {
@Resource
private TransactionListener transactionListener;
@RequestMapping(value = "/pay/updateOrder", method = RequestMethod.POST)
public String payOrder(@RequestParam("payid") String id, @RequestParam("ispay") int ispay) {
try {
//1.创建消息的生产者
TransactionMQProducer transactionMQProducer = new TransactionMQProducer("txmessage_trans-client-group");
//2.指定服务器地址nameserver
transactionMQProducer.setNamesrvAddr("127.0.0.1:9876");
//3.设置消息回查的监听器
transactionMQProducer.setTransactionListener(transactionListener);
//4.创建消息对象
Message message = new Message("txmessage_topic",
"txmessage_tags",
"txmessage_keys",
"txmessage_事务消息".getBytes(RemotingHelper.DEFAULT_CHARSET));
//启动生产者
transactionMQProducer.start();
//5.准备数据
Map<String,Object> payArgs = new HashMap<>();
payArgs.put("id",id);
payArgs.put("ispay",ispay);
//6.发送消息
transactionMQProducer.sendMessageInTransaction(message,payArgs);
//7.释放资源(关闭消息发送者)
transactionMQProducer.shutdown();
}catch (Exception e){
e.printStackTrace();
return "发送消息给mq失败";
}
//如果没有问题,
return "发送消息给mq成功";
}
}
PayTransactionListener
@Component
public class PayTransactionListener implements TransactionListener {
/**
* 对于MQ来说,它就3个状态 1正在执行 2执行成功 3执行失败
* 只有在执行成功时(事务状态是Commit时)才发送消息
* 如果执行失败了(事务状态是rollback),就不再发送消息,同时会把half(收取支付凭证)消息删除。
*/
//用于存储事务的唯一标识和事务的执行状态
private ConcurrentHashMap<String,Integer> transMap = new ConcurrentHashMap<>();
@Autowired
private PayService payService;
/**
* 获取本地事务执行的状态(执行本地事务,返回执行结果)
* @param message
* @param o
* @return
*/
@Override
public LocalTransactionState executeLocalTransaction(Message message, Object o) {
//1.获取当前事务消息的唯一标识
String transactionId = message.getTransactionId();
//2.设置事务的执行状态是正在执行
transMap.put(transactionId,1);
//3.获取参数
Map<String,Object> payArgs = (Map<String,Object>)o;
String id = (String)payArgs.get("id");
Integer ispay = (Integer)payArgs.get("ispay");
try {
//4.执行更新支付状态
System.out.println("支付状态更新开始");
Pay pay = new Pay();
pay.setId(id);
pay.setIspay(ispay);
payService.update(pay);
System.out.println("支付状态更新成功");
// int i=1/0;
//5.记录执行状态
transMap.put(transactionId,2);
}catch (Exception e){
e.printStackTrace();
//设置事务状态
transMap.put(transactionId,3);
//返回本地事务状态为Rollback
System.out.println("本地事务执行的结果是"+LocalTransactionState.ROLLBACK_MESSAGE);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
System.out.println("本地事务执行的结果是"+LocalTransactionState.COMMIT_MESSAGE);
return LocalTransactionState.COMMIT_MESSAGE;
}
/**
* 回查本地事务执行状态
* @param messageExt
* @return
*/
@Override
public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
//1.获取当前事务消息的唯一标识
String transactionId = messageExt.getTransactionId();
//2.根据事务id,从map中获取事务执行的状态
Integer state = transMap.get(transactionId);
LocalTransactionState localTransactionState = null;
//3.判断事务状态执行的结果
switch (state){
case 2:
//执行成功
System.out.println("本地事务执行的结果是"+LocalTransactionState.COMMIT_MESSAGE);
localTransactionState = LocalTransactionState.COMMIT_MESSAGE;
break;
case 3:
//执行失败
System.out.println("本地事务执行的结果是"+LocalTransactionState.ROLLBACK_MESSAGE);
localTransactionState = LocalTransactionState.ROLLBACK_MESSAGE;
break;
case 1:
//正在执行
System.out.println("本地事务执行的结果是"+LocalTransactionState.UNKNOW);
localTransactionState = LocalTransactionState.UNKNOW;
break;
default:
return null;
}
return localTransactionState;
}
}
订单服务
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args)throws Exception {
//启动方法
SpringApplication.run(OrderApplication.class);
//1.创建事务消息的消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("txmessage_trans-client-group");
//2.设置nameserver的地址
consumer.setNamesrvAddr("127.0.0.1:9876");
//3.设置单次消费的消息数量
consumer.setConsumeMessageBatchMaxSize(5);
//4.设置消息消费顺序
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
//5.设置监听消息的信息topic和tags
consumer.subscribe("txmessage_topic","txmessage_tags");
//6.编写监听消息的监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
for(MessageExt messageExt : msgs){
//取出消息内容
String topic = messageExt.getTopic();
String tags = messageExt.getTags();
String keys = messageExt.getKeys();
String msg = new String(messageExt.getBody(),"UTF-8");
String transactionId = messageExt.getTransactionId();
System.out.println("获取到的消息:topic="+topic+",tags="+tags+",keys="+keys+",transactionId="+transactionId+",message="+msg);
}
}catch (Exception e){
e.printStackTrace();
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
//启动消费者
System.out.println("启动消费者");
consumer.start();
}
}
4.3 Seata实现分布式事务
由于篇幅有限,详情查看 《Seata实现分布式事务》