前言
最近在接手一个供应链系统中的项目,接触到了内部的分布式事务框架,所以趁着春节这段时间好好了解下分布式事务相关的知识内容。在本篇中介绍了事务的相关概念与业务应用场景,以及分布式事务的几种主流设计方案以及实现原理。
1. 事务的基本概念
1.1 事务的概念
事务一般指的是逻辑上的一组操作,或者作为单个逻辑单元执行的一系列操作,其中事务存在ACID四大特性,通常应用使用客户端与数据库交互如下左图,在一个会话中会开启事务,然后进行N个数据操作,最后提交/回滚事务。
以MySQL数据库为例,实现事务主要靠undo和redo日志完成的,MySQL事务的执行流程如上右所示。这些事务在业务开发中经常会使用到。MySQL从5.0.3版本开始支持XA事务,并且只有InnodDB引擎支持XA事务,本质上是一种基于两阶段提交的分布式事务,参与操作的多个事务要么全部提交成功,要么全部提交失败,在使用时InnoDB的引擎事务隔离要设置成串行化。
XA事务模型中由一个事务管理器、一个或多个资源管理器和一个应用程序组成。
- 在Prepare阶段,事务管理器接收到所有资源管理器返回的结果信息,资源管理器在收到指令后,执行数据的修改操作并记录相关的日志信息,然后向事务管理器返回可否提交的结果信息;
- 在Commit阶段,事务管理器接收到所有资源管理器返回的结果信息,如果某个或多个资源管理器向事务管理器返回的结果为不可提交或超时,则事务管理器向素有的资源管理器发送回滚指令。
使用Docker可方便构造实验环境,具体的命令如下。
#启动mysql1容器,绑定宿主机3306端口
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD='123456' --name mysql mysql:latest#启动mysql2容器,绑定宿主机3307端口
docker run -d -p 3307:3307 -e MYSQL_ROOT_PASSWORD='123456' --name mysql mysql:latest#进入容器控制台
docker exec -it [containId] /bin/bash
#进入连接 docker容器内的数据库
mysql -u root -p
#更改连接限制
use mysql;
update user set host = '%' where user = 'root' and host='127.0.0.1';
grant all privileges on *.* to 'root'@'%' with grant option;
flush privileges;
可以通过Java代码写个MySQL的XA事务的demo,这里使用了JDBC直连两个数据库,在实际业务开发中,很少使用JDBC来操作,会使用第三方框架实现XA事务。
public class XaDemo {
public static MysqlXADataSource getDataSource(String connStr, String user, String pwd) {
try {
MysqlXADataSource ds = new MysqlXADataSource();
ds.setUrl(connStr);
ds.setUser(user);
ds.setPassword(pwd);
return ds;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public static void main(String[] arg) {
String connStr1 = "jdbc:mysql://127.0.0.1:3306/test";
String connStr2 = "jdbc:mysql://127.0.0.1:3307/test";
try {
//从不同数据库获取数据库数据源
MysqlXADataSource ds1 = getDataSource(connStr1, "root", "123456");
MysqlXADataSource ds2 = getDataSource(connStr2, "root", "123456");
//数据库1获取连接
XAConnection xaConnection1 = ds1.getXAConnection();
XAResource xaResource1 = xaConnection1.getXAResource();
Connection connection1 = xaConnection1.getConnection();
Statement statement1 = connection1.createStatement();
//数据库2获取连接
XAConnection xaConnection2 = ds2.getXAConnection();
XAResource xaResource2 = xaConnection2.getXAResource();
Connection connection2 = xaConnection2.getConnection();
Statement statement2 = connection2.createStatement();
//创建事务分支的xid
Xid xid1 = new MysqlXid(new byte[] { 0x01 }, new byte[] { 0x02 }, 100);
Xid xid2 = new MysqlXid(new byte[] { 0x011 }, new byte[] { 0x012 }, 100);
try {
//事务分支1关联分支事务sql语句
xaResource1.start(xid1, XAResource.TMNOFLAGS);
int update1Result = statement1.executeUpdate(
"update account_from set money=money - 50 where id=1");
xaResource1.end(xid1, XAResource.TMSUCCESS);
//事务分支2关联分支事务sql语句
xaResource2.start(xid2, XAResource.TMNOFLAGS);
int update2Result = statement2.executeUpdate(
"update account_to set money= money + 50 where id=1");
xaResource2.end(xid2, XAResource.TMSUCCESS);
// 两阶段提交协议第一阶段
int ret1 = xaResource1.prepare(xid1);
int ret2 = xaResource2.prepare(xid2);
// 两阶段提交协议第二阶段
if (XAResource.XA_OK == ret1 && XAResource.XA_OK == ret2) {
xaResource1.commit(xid1, false);
xaResource2.commit(xid2, false);
}
} catch (Exception e) {
....
}
} catch (Exception e) {
....
}
}
}
1.2 分布式系统的演进
刚开始的时候公司的网站流量较小,只需要一个应用将所有的功能代码打包到一个服务并部署到服务器上,就能支撑公司的业务需求。随着企业的不断发展,单节点的应用无法满足业务需求,会将原来的项目系统垂直拆分成互不相干的几个应用,以便更好地进行横向扩展,提升整体系统性能,当垂直应用越来越多时,就会将一些重复的业务代码抽象成一个个服务,供其他系统模块调用,最后可能会进一步拆分成微服务架构,这样每个微服务都有自己的数据库。
1.3 分布式事务的场景
将一个大的系统拆分成多个可以独立部署的应用服务,需要各个服务做些特出处理才能完成事务操作,常见的有以下几种场景。
- 跨JVM进程: 以电商系统域举例,订单微服务有自己的订单数据库,在更新订单时则会通过调用库存服务去扣减对应的商品数量,库存服务也有自己的库存数据库,两个微服务属于两个进程造成了跨 JVM的事务;
- 跨数据库实例: 单体系统中经常有访问多个数据库实例的场景,也是跨数据源访问产生的分布式事务,例如订单数据和库存数据存放在不通的数据库实例中,当用户发起退款时,会同时操作用户的订单数据和交易数据;
- 多服务访问数据库: 也有可能是订单和库存微服务同时访问同一个数据库,本质上是通过不同的数据库会话来操作数据库,此时就会产生分布式事务。
、
1.4 数据一致性问题
在分布式场景下,当网络、服务器或者系统软件发生异常或故障,都可能会导致数据库不一致、多个缓存节点数据不一致等场景。
- 调用超时场景: 一般是A服务通过同步或异步方式调用服务B,由于网络、服务引起的异常,而导致服务A和服务B的数据不一致的问题。
- 缓存与数据库不一致场景: 在高并发场景下,一些热数据会缓存到Redis或其他缓存组件中,如果对数据库进行新增、修改和删除时,缓存中的数据如果没有即时更新,这就会导致缓存与数据库中数据不一致;
- 多个缓存节点数据不一致场景: 主要是指缓存内容在各个节点之间不一致,例如在Redis集群中,由于网络异常等原因引起的脑裂问题,就会导致多个缓存节点数据不一致;
- 数据多副本场景: 数据库或缓存中间件为了更好的性能,都是以主备或集群的方式来部署,当网络、服务器或系统软件出现故障时,可能会导致一部分副本写入成功,一部分副本写入失败,这就造成了不同数据副本数据不一致。
2.强一致性解决方案
强一致行分布式事务要求在任意时刻查询参与全局事务的各节点数据都是一致的,在强一致性事务的典型解决方案中包括DTP模型/2PC模型/3PC模型三种,强一致性事务解决方案具有数据一致性高,适用于银行转账等业务中, 在任意时刻都能查询到最新写入的数据等优点,但其实现的技术复杂度也较高, 牺牲了可用性,在高并发的场景也表现较差。
2.1 DTP事务模型
DTP模型定了实现分布式事务的规范和API,各个厂商根据规范实现的具体框架也不尽相同。但总的来说,还是由应用程序、资源管理器、事务管理器三大部分组成,其中资源管理器可以理解为数据库管理系统或者消息服务管理器,事务管理器负责协调和管理模型中的事务,为应用程序提供编程接口等。
2.2 2PC事务模型
2PC模型是指两阶段提交协议模型,分为Prepare和Commit阶段,在Prepare阶段,事务管理器给每个参与全局事务的资源管理器发送Prepare消息,资源管理器要么返回失败,要么在本地执行相应的事务,将事务写入本地的Redo Log文件和Undo Log文件中,此时,事务并没有提交。 在Commit阶段,如果事务管理器收到了参与全局事务的资源管理器返回的失败消息,则直接给Prepare阶段执行成功的资源管理器发送回滚消息,否则会发送commit消息,相应的资源管理器根据事务管理器发送过来的消息指令,执行事务的回滚或提交,并且释放事务处理过程中使用的锁资源。
在2PC模型中所有参与事务的节点都会对其占用的公共资源加锁,导致其他访问公共资源的进程或者线程阻塞,如果事务管理器发生故障,则资源管理器就会一直阻塞直到超时,很容易发生单点故障。
2.3 3PC事务模型
3PC模型是指三阶段提交模型,分为CanCommit、PreCommit和doCommit或doRollback三个阶段,大致的流程与2PC模型相同,主要是解决了单点故障问题,减少了事务并发执行过程中产生的阻塞现象,在3PC模型中,如果资源管理器无法及时收到来自事务管理器发出的消息,那么资源管理器就会执行事务的操作,而不是一直持有事务的资源并处于阻塞的状态,但是这种机制会导致事务不一致的问题。
3.最终一致性解决方案
最终一致性并不像强一致性要求参与事务的各个节点的数据保持一致,查询任意节点的数据都能得到最新的数据结果,这样就会导致在高并发场景下,系统的性能会受到影响,而最终一致性分布式事务解决方案可以允许中间状态,只要一段时间后能够达到数据的最终一致状态即可。
3.1 TCC
TCC是一种典型的解决分布式事务问题的方案,主要解决跨服务调用场景下的分布式事务问题,广泛应用于分布式事务场景。具有强隔离性、严格一致性要求的业务场景,也适用于执行时间短的业务,核心思想是将一个完整的事务分为Try/Confirm/Cancel三个阶段。
- Try阶段:不会执行任何业务逻辑,仅做业务的一致性检查和预留相应的资源,这些资源能够保持操作隔离;
- Confirm阶段: 当Try阶段所有分支事务执行成功后就开始执行Confirm阶段,一般Try阶段执行成功后Confirm阶段也应该会执行成功,如果执行出错后, 就需要引入重试机制或人工处理,对出错的事务进行干预;
- Cancel阶段: 在业务执行异常或者出现错误的情况下,需要回滚事务的操作,执行分支事务的取消操作,并且释放Try阶段预留的资源。如果 Cancel阶段出错了,也会引入重试机制或人工处理。
TCC模型是在应用层实现具体逻辑,锁定的资源粒度小,而且不会锁定全部资源,提升了系统的性能,Confirm和Cancel阶段实现了幂等,能够保证分布式事务执行完毕的数据一致性。同时在使用该模式的时候也要注意空回滚问题/幂等问题/悬挂问题等。
3.2 可靠消息最终一致性分布式事务方案
可靠性最终一致性的基本原理都是事务发起方(消息发送者) 执行本地事务成功后发出一条消息,事务参与方(消息消费者)接收到事务发起方发过来的消息,并成功执行本地事务。事务发起方和参与方最终的数据能够达到一致的状态。
- 基于本地消息表: 通过分布式系统处理任务,比如同步数据等操作,通过消息或者日志的形式异步执行,这些消息或日志存储在本地文件中,也可以存储到本地数据库的数据表中,还可以存储到消息中间件,通过一定的规则可以重试,具体的流程如下左图,该方案是业界比较成熟的方案,使用较多没有明显的缺点,但无法保证各个服务节点之间的数据的强一致性,在某个时刻可能无法查询到已提交的数据,消息表会耦合到业务库中,需要额外手动维护消息数据,且不利于消息数据的扩展,由于每个消息服务是业务系统独有的,并能做到公用,每次需要实现公共分布式事务时,都需要重新开发。
- 基于独立消息服务: 在本地消息表的基础上将消息服务独立出来,并将消息数据从本地数据表独立成单独的消息数据库,具体流程如下右图。在该方案中消息服务独立部署、开发、维护,与业务解耦,具有更好的扩展性和维护性,降低了重复开发的成本,弱化了对消息中间的依赖性。
阿里的RocketMQ消息中间件提供了事务消息的功能,使用者可调用提供的事务消息的接口,该事务消息具备多状态是完成事务的核心要素。相对于普通producer,在TransactionMQProducer中存储了消息的状态,并能设置消息回调和状态查询接口。
public interface RocketMQLocalTransactionListener {
RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg);
RocketMQLocalTransactionState checkLocalTransactoin(Message msg);
}
TransactionMQProducer producer = new TransactionMQProducer("ProducerGroup");
producer.setNameServer("xxxx");
producer.start();
producer.setTransactionListerner(transactionListerner);
SendResult result = producer.sendMessageInTransaction(msg, null);
该方案中对消息中间件的依赖程度很高,所以要特别注意在使用消息中间件的几个方面。
- 消息发送一致性: 事务发起方执行本地事务与产生消息和发送消息的整体一致性,也就是说发起方执行事务成功后就一定将其产生的消息成功发送出去,这里需要实现消息发送的确认机制,事务发起方与参与方都要与中间件有发送的确认机制和重试机制;
- 消息接收一致性: 需要设置消息中间件重复投递消息的最大次数,事务参与方接收消息需要实现接口幂等特性,并让事务参与方与消息之间有确认机制,在投递多次失败后,消息会被放入死信队列,需要引入监控与人工干预机制;
- 消息的可靠性: 包括了发送/存储/消费三个阶段的可靠性,在保证消息发送可靠性时,引入了回调确认机制,在事务发起方提供回调接口,在消息发送异常时,消息服务也可以通过一定的机制回调事务发起方提供的回调接口,获取事务发起方的事务执行状态和消息数据,确保数据一定会被消息服务成功接收。如果事务发起方在一定时间内未收到消息服务返回的确认信息,就会触发消息重试机制,按照一定的规则重发消息;消息存储的可靠性主要是靠持久化,而且是多副本的冗余复制;在消费的可靠性主要实现参与方的重试机制与幂等机制,按照一定的规则获取消息中间件的数据,以确保事务发起方成功消费消息。
3.3 最大努力通知型分布式事务方案
最大努力通知型分布式事务解决方案适用于数据最终能达到一致性的场景,并且对时间敏感度较低的场景,该方案多用于跨企业,跨系统之间实现事务的一致, 如支付成功后通知商户款成功等。使用该方案需要具备以下几点要求:
- 使用到的服务模式具有可查询、幂等操作,对最终一致性的时间敏感度低,短则几秒或几分钟,长则数天才能达到事务的一致性;
- 业务被动方对业务的处理结果不会影响到业务主动方对业务的处理结果;
- 业务主动方完成业务处理操作后向业务被动方发送通知消息,允许消息丢失;
- 业务主动方可以根据一定的策略设置阶梯形通知规则,在通知失败后,按照规则重复通知, 直到通知次数达到设置的最大次数为止;
- 业务主动方需要提供查询接口给业务被动方按照需求进行校对查询,以便恢复可能丢失的业务消息。
参考资料
- jianshu.com/p/a7d1c4f2542c 《XA事务》
- 《深入理解分布式事务原理与实战》
- www.cnblogs.com/zengkefu/p/…《MySQL两阶段提交协议详解》