分布式事务
上节中讨论的数据库事务是解决“单个数据库数据不一致”的问题,而在一些具有规模的网站系统当中,数据库往往不止一个,一旦出现多个数据库,则会出现多数据库的数据不一致问题。
多个数据库的数据不一致问题一般有两种场景,如图4.76所示。
图4.76 多数据库的数据不一致场景
场景一,由于“应用拆分”和“数据库分离”造成的多数据库数据不一致问题,例如,用户余额存放在“资金”数据库中,优惠券存放在“优惠券”数据库,当发生交易时,会出现扣减余额成功,但扣减优惠券失败的情况;场景二,当数据库中的数据个数超过千万级时,一般都需要进行分库,这也会造成多数据库的数据不一致问题,例如,用户1的余额存在数据库A中,而用户2的余额存在数据库B中,当用户1向用户2转账时,会出现用户1扣除金额成功,而用户2增加金额失败的情况。
解决“多数据库的数据不一致问题”的方式被称为分布式事务,确切地说,分布式事务不是某种具体的方式,而是解决“多数据库的数据不一致问题”的方式的统称,分布式事务的实现方式本身是开放的。
说明:关于分布式事务的相关理论有CAP原则和BASE理论;关于分布式事务的相关算法有2PC、3PC、TCC及本地消息表等。
分布式事务是行业内一大难题,在选取分布式事务具体方案之前,需要分析清楚是否真的需要分布式事务。很多时候,造成场景一的原因,是由于过度设计造成的,即在系统内部划分出很多没必要的独立模块。例如,图4.76中所举的例子,优惠券本身可以算是资金的一部分,如果把资金和优惠券合并成一个模块,则不需要分布式事务。
注意:以下介绍的分布式事务的解决方法都不是唯一方法,具体实施需要根据实际情况而定。
1.XA事务
XA(eXtended Architecture)是由X/Open组织提出的分布式事务的规范,XA事务能较好地解决“多数据库的数据不一致”问题。目前,比较流行的数据库(如Oracle、MySQL等)都支持XA事务。
注意:Oracle执行XA事务的性能要优于MySQL,另外,MySQL最好选用5.7或之后的版本,因为MySQL 5.7之前的版本对XA事务的支持都是有缺陷的。
XA事务可以简单地理解为多个数据库同时执行数据库事务,但与执行普通数据库事务不同的是,最后的提交阶段需要先检查所有事务的状态(是否允许提交)后才能提交。第三方软件使用数据库XA事务的流程如图4.77所示。以Java为例,通过JDBC使用XA事务的代码如代码4.55所示。
图4.77 第三方软件使用数据库XA事务的流程
代码4.55 通过JDBC使用XA事务
//连接数据库1,并获取资源管理器对象rm_1
Connection conn_1 = DriverManager.getConnection(
"jdbc:mysql://ip:port/xxx",
"userName",
"password");
XAConnection xaConn_1 = new MysqlXAConnection((com.mysql.jdbc.Connection)
conn_1, false);
XAResource rm_1 = xaConn_1.getXAResource();//连接数据库2,并获取资源管理器对象rm_2
Connection conn_2 = DriverManager.getConnection(
"jdbc:mysql://ip:port/xxx",
"userName",
"password");
XAConnection xaConn_2 = new MysqlXAConnection((com.mysql.jdbc.Connection)
conn_2, false);
XAResource rm_2 = xaConn_2.getXAResource();
//设置全局事务ID
//xxx可以用UUID.randomUUID().toString()生成
byte[] gtrid = "xxx".getBytes();
try{
//操作数据库1
//生成数据库1的事务ID,gtrid为全局事务ID,bqual_1为分支限定符
byte[] bqual_1 = "db_1".getBytes();
Xid xid_1 = new MysqlXid(gtrid, bqual_1, 1);
//启动数据库1的事务
rm_1.start(xid_1, XAResource.TMNOFLAGS);
//使用conn_1执行SQL语句(数据库1执行SQL语句)
…
//SQL语句执行结束,迁移事务状态
rm_1.end(xid_1, XAResource.TMSUCCESS);
//操作数据库2
//生成数据库2的事务ID,bqual_2需要与数据库1的值有所区别
byte[] bqual_2 = "db_2".getBytes();
Xid xid_2 = new MysqlXid(gtrid, bqual_2, 1);
//启动数据库2的事务
rm_2.start(xid_2, XAResource.TMNOFLAGS);
//使用conn_2执行SQL语句(数据库2执行SQL语句)
…
//SQL语句执行结束,迁移事务状态
rm_2.end(xid_2, XAResource.TMSUCCESS);
//两段提交
//准备阶段,获取两个数据库的事务状态
int prepare_1 = rm_1.prepare(xid_1);
int prepare_2 = rm_2.prepare(xid_2);
//提交阶段,根据两个事务的状态决定提交还是回滚
if (prepare_1 == XAResource.XA_OK && prepare_2 == XAResource.XA_OK) {
rm_1.commit(xid_1, false);
rm_2.commit(xid_2, false);
} else { //一个数据库事务存在问题,则回滚
rm_1.rollback(xid_1);
rm_2.rollback(xid_2);
}
} catch(XAException e) {
//发生错误,回滚事务
rm_1.rollback(xid_1);rm_2.rollback(xid_2);
}
XA事务在使用上是简单的,但是由于XA事务是同时对多个数据库执行数据库事务,因此会同时浪费多个数据库的性能。在大型网站系统当中,XA事务一般是不被提倡的,因为大型网站系统需要应对高并发场景,在高并发压力下,XA事务往往会大量阻塞任务,从而引发超时等异常。不过,在一个大型网站系统中,并发压力的分布往往是不均等的,也就是说,存在并发压力不大的模块,而在这些模块中使用XA事务也是可以的。因此,XA事务的好处是使用简单,但不适合用于并发压力大的功能模块。
2.JTA
JTA(Java Transaction API)是Java的事务管理框架。通过使用JTA,开发者可以更简单地实现XA事务。值得一提的是,JTA只是简化了编码,并没有改变XA事务性能差的状况。下面以Spring Boot为例,介绍使用JTA实现XA事务的过程。
说明:JTA实际上只是提供了事务管理的统一接口,它本身不负责具体的实现,具体的实现交由Atomikos或JOTM等事务管理器完成。
(1)引入JTA依赖包。需要在工程配置文件(build.gradle)中添加JTA的依赖包,如代码4.56所示,其中,选用Atomikos作用具体的事务管理器,数据库操作框架选用JDBC Template。
代码4.56 在build.gradle中添加JTA依赖包
…
dependencies {
…
//在dependencies中添加JTA的依赖包,选用Atomikos作为具体的事务管理器
implementation 'org.springframework.boot:spring-boot-starter-jta
atomikos'
//添加数据库操作框架依赖包,这里选用JDBC Template
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'com.alibaba:druid:1.0.26' //数据库连接池依赖
runtimeOnly '
mysql:mysql-connector-java' //MySQL驱动依赖…
}
…
添加完依赖包后,需要同步工程配置。JTA的依赖包在同步工程配置后才会被下载和引入。在IntelliJ IDEA中,只需要单击“同步”按钮即可同步工程配置,如图4.78所示。
图4.78 在IntelliJ IDEA中同步build.gradle配置
(2)配置数据库信息。配置数据库连接信息需要在后端应用程序的配置文件(默认是application.properties)中设置。与平常配置数据库不同的是,这里需要配置两个数据库连接信息,如代码4.57所示,其中,数据库1通过Spring Boot提供的默认字段配置,数据库2通过自定义字段配置。
说明:默认情况下,Spring Boot只提供一个数据库的连接配置,如果需要连接多个数据库,则需要通过额外代码完成多个数据库的连接。代码4.57 在配置文件中添加数据库连接信息
…
#配置说明可参考4.4.4小节中的代码4.50
#数据库1的连接信息,通过Spring Boot的默认字段配置,也可通过自定义字段配置
spring.datasource.jdbc-url=jdbc:mysql://ip:port/xxx
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.max-active=20
spring.datasource.max-idle=8
spring.datasource.initial-size=10
#数据库2的连接信息,通过自定义字段配置,dao.extradb为自定义字段
dao.extradb.jdbc-url=jdbc:mysql://ip:port/xxx
dao.extradb.username=root
dao.extradb.password=password
dao.extradb.driver-class-name=com.mysql.cj.jdbc.Driver
dao.extradb.type=com.alibaba.druid.pool.DruidDataSourcedao.extradb.max-active=20
dao.extradb.max-idle=8
dao.extradb.initial-size=10
…
配置文件虽然配置了多个数据库连接信息,但默认情况下Spring Boot只接受一个数据库的设置,因此需要添加额外代码(新建一个Java文件)关联这些数据库连接信息,如代码4.58所示。
说明:代码4.58是全局设置,不需要被其他文件引用,存放位置最好在Dao层内,以方便开发者查阅。
代码4.58 在配置文件中添加数据库连接信息
package com.example.demo.dao.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
@Configuration
public class DataBaseConfig {
//关联数据库1的连接信息,并设置为默认连接
//设置标识,关联函数DataBaseTemplate1()中的参数
@Bean(name="dataBaseConfig_1")
@Primary //默认数据库标识
//spring.datasource为数据库1的连接信息字段
@ConfigurationProperties(prefix="spring.datasource")
DataSource DataBaseConfig1(){
return DataSourceBuilder.create().build();
}
@Bean(name="databaseTemplate_1")
@Primary //默认数据库标识
public JdbcTemplate DataBaseTemplate1(@Qualifier("dataBaseConfig_1")
DataSource data){
return new JdbcTemplate(data);
}
//关联数据库2的连接信息
//设置标识,关联函数DataBaseTemplate2()中的参数
@Bean(name="dataBaseConfig_2")//dao.extradb为数据库2的自定义连接信息字段
@ConfigurationProperties(prefix = "dao.extradb")
DataSource DataBaseConfig2(){
return DataSourceBuilder.create().build();
}
@Bean(name="databaseTemplate_2")
public JdbcTemplate DataBaseTemplate2(@Qualifier("dataBaseConfig_2")
DataSource data){
return new JdbcTemplate(data);
}
}
(3)在Dao层中操作多个数据库,如代码4.59所示。在实际编码中,对不同数据库的操作最好分为不同的文件。
代码4.59 在Dao层中操作多个数据库
package com.example.demo.dao;
//引用JDBCTemplate的类
import org.springframework.jdbc.core.JdbcTemplate;
import … //省略其他引用的类
@Repository("TestDao")
public class TestDao {
//获取数据库1(默认数据库)的JdbcTemplate对象,此对象会被自动注入
@Autowired
private JdbcTemplate jdbcTemplate_1;
//获取数据库2的JdbcTemplate对象,databaseTemplate_2为代码4.58中的标识,
此对象会被自动注入
@Autowired
@Qualifier("databaseTemplate_2")
private JdbcTemplate jdbcTemplate_2;
//在数据库1增加一条记录
public String Create_1(String value1, String value2){
try{
//SQL语句,?为占位符,后续通过参数替换
String sqlString = "INSERT INTO formName VALUES (?, ?)";
//执行SQL语句
int result = jdbcTemplate.update(sqlString, value1, value2);
if(result > 0){
return "success";
}else {
return "fail";
}
}catch(Exception e){return "fail";
}
}
//在数据库2增加一条记录
public String Create_2(String value1, String value2){
try{
String sqlString = "INSERT INTO formName VALUES (?, ?)";
int result = jdbcTemplate.update(sqlString, value1, value2);
if(result > 0){
return "success";
}else {
return "fail";
}
}catch(Exception e){
return "fail";
}
}
(4)在业务层(Service层)中标记需要使用数据库事务的方法,如代码4.60所示,其中,@Transactional为XA事务标记。
说明:代码4.60中的@Transactional标识与数据库事务的标识相同,但其本质上是XA事务。
代码4.60 在Service层中添加事务标记
…
@Service("XXX")
public class XXXService {
@Resource(name = "TestDao")
private TestDao _testDao;
…
//标记数据库事务,当异常发生时,会自动撤销所有数据库操作。函数中不能用catch捕获
异常
@Transactional
public JSONObject ServiceFunction(JSONObject requestParam, JSONObject
returnParam){
//调用代码4.59中的函数1(操作数据库1)
_testDao.Create_1(…);
//调用代码4.59中的函数2(操作数据库2)
_testDao.Create_1 (…);
return returnParam;
}
}
3.本地消息表
当多个数据库操作分别处在不同的程序中却又需要保持数据一致性时(图4.76中的场景一),一般采用“本地消息表”实现分布式事务。“本地消息表”这个方案的核心是将分布式事务拆分成多个数据库事务进行处理,并通过额外的检查机制确保多个数据库事务都正常完成,以达到数据最终一致的效果。“本地消息表”方案的工作原理如图4.79所示。其中,检查程序可以是程序1本身,本地消息表的实体可以是本地文件、数据库中的表等。
图4.79 “本地消息表”方案的工作原理
“本地消息表”方案是解决分布式事务的一种思路,而其具体的实现是开放的,针对不同的应用场景或软件形态会有不同的实现方式。针对后端应用程序而言,可参照图4.80所示的工作流程,其中,检查程序最好是独立的一个程序,本地消息表一般是数据库中的表(可以为数据库1或数据库2中的表,也可以是独立数据库中的表)。
图4.80 后端应用程序采用“本地消息表”实现分布式事务的工作流程
说明:图4.80中的检查程序也可以是后端应用程序1中的定时任务,但是后端应用程序1可能被部署在多个服务器上,如果是这样的话,则需要使用Quartz等分布式定时器框架,以避免发生定时任务被多次执行的情况。
“本地消息表”方案的实现在实际编码中比较麻烦,而且会增加后端结构的复杂性。而性能方面,一般来说“本地消息表”方案更胜一筹。但是,因为“本地消息表”方案的具体实现是五花八门的,而且其内部可能会用到XA事务,所以很难评定与单纯使用XA事务的性能对比。
4.其他
除了“XA事务”和“本地消息表”这两种分布式事务解决方案以外,还有很多其他解决思路或方案,如2PC(两段式提交,XA事务是其中一种实现)、3PC(三段式提交)、基于消息队列的解决方案及TCC(事务补偿)方案等。但是,无论采用哪种方案,分布式事务都会增加系统复杂度和限制数据库性能,所以架构设计应该尽量避开分布式事务。如果不能完全避开分布式事务,则需要对系统内的分布式事务提供统一的规范,以防止“五花八门”的分布式事务解决方案出现在网站系统当中(限制系统混乱度)。