Preface
Perhaps due to certain business requirements, our system sometimes needs to connect to multiple databases, which creates the problem of multiple data sources.
In the case of multiple data sources, we generally have to do automatic switching, at this time it will involve the transaction annotation Transactional ineffective problem and distributed transaction problem.
Regarding the multi-data source solution, the author has seen some examples on the Internet, but most of them are wrong examples, which can not work at all, or there is no way to be compatible with transactions.
Today, we will analyze the root causes of these problems and the corresponding solutions little by little.
1. Multiple data sources
For the smooth development of the plot, our simulated business is to create orders and deduct inventory.
So, we first create the order table and inventory table. Note that they are placed in two databases.
CREATE TABLE `t_storage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) DEFAULT NULL,
`count` int(11) DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `commodity_code` (`commodity_code`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
CREATE TABLE `t_order` (
`id` bigint(16) NOT NULL,
`commodity_code` varchar(255) DEFAULT NULL,
`count` int(11) DEFAULT '0',
`amount` double(14,2) DEFAULT '0.00',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1. Database connection
Configure both databases first through YML files.
spring:
datasource:
ds1:
jdbc_url: jdbc:mysql://127.0.0.1:3306/db1
username: root
password: root
ds2:
jdbc_url: jdbc:mysql://127.0.0.1:3306/db2
username: root
password: root
2. Configure DataSource
We know that when Mybatis executes a SQL statement, it needs to get a Connection first. At this time, it is handed over to the Spring manager to obtain the connection to the DataSource.
There is a DataSource with routing function in Spring, which can call different data sources through search keys, which is AbstractRoutingDataSource.
public abstract class AbstractRoutingDataSource{
//数据源的集合
@Nullable
private Map<Object, Object> targetDataSources;
//默认的数据源
@Nullable
private Object defaultTargetDataSource;
//返回当前的路由键,根据该值返回不同的数据源
@Nullable
protected abstract Object determineCurrentLookupKey();
//确定一个数据源
protected DataSource determineTargetDataSource() {
//抽象方法 返回一个路由键
Object lookupKey = determineCurrentLookupKey();
DataSource dataSource = this.targetDataSources.get(lookupKey);
return dataSource;
}
}
As you can see, the core of this abstract class is to first set multiple data sources to the Map collection, and then obtain different data sources based on the Key.
Then, we can rewrite the determineCurrentLookupKey method, which returns the name of a data source.
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
DataSourceType.DataBaseType dataBaseType = DataSourceType.getDataBaseType();
return dataBaseType;
}
}
Then you need a tool class to save the data source type of the current thread.
public class DataSourceType {
public enum DataBaseType {
ds1, ds2
}
// 使用ThreadLocal保证线程安全
private static final ThreadLocal<DataBaseType> TYPE = new ThreadLocal<DataBaseType>();
// 往当前线程里设置数据源类型
public static void setDataBaseType(DataBaseType dataBaseType) {
if (dataBaseType == null) {
throw new NullPointerException();
}
TYPE.set(dataBaseType);
}
// 获取数据源类型
public static DataBaseType getDataBaseType() {
DataBaseType dataBaseType = TYPE.get() == null ? DataBaseType.ds1 : TYPE.get();
return dataBaseType;
}
}
After all these are done, we still need to configure this DataSource to the Spring container. The role of the following configuration class is as follows:
- Create multiple data sources DataSource, ds1 and ds2;
- Put the ds1 and ds2 data sources into the dynamic data source DynamicDataSource;
- Inject DynamicDataSource into SqlSessionFactory.
@Configuration
public class DataSourceConfig {
/**
* 创建多个数据源 ds1 和 ds2
* 此处的Primary,是设置一个Bean的优先级
* @return
*/
@Primary
@Bean(name = "ds1")
@ConfigurationProperties(prefix = "spring.datasource.ds1")
public DataSource getDateSource1() {
return DataSourceBuilder.create().build();
}
@Bean(name = "ds2")
@ConfigurationProperties(prefix = "spring.datasource.ds2")
public DataSource getDateSource2() {
return DataSourceBuilder.create().build();
}
/**
* 将多个数据源注入到DynamicDataSource
* @param dataSource1
* @param dataSource2
* @return
*/
@Bean(name = "dynamicDataSource")
public DynamicDataSource DataSource(@Qualifier("ds1") DataSource dataSource1,
@Qualifier("ds2") DataSource dataSource2) {
Map<Object, Object> targetDataSource = new HashMap<>();
targetDataSource.put(DataSourceType.DataBaseType.ds1, dataSource1);
targetDataSource.put(DataSourceType.DataBaseType.ds2, dataSource2);
DynamicDataSource dataSource = new DynamicDataSource();
dataSource.setTargetDataSources(targetDataSource);
dataSource.setDefaultTargetDataSource(dataSource1);
return dataSource;
}
/**
* 将动态数据源注入到SqlSessionFactory
* @param dynamicDataSource
* @return
* @throws Exception
*/
@Bean(name = "SqlSessionFactory")
public SqlSessionFactory getSqlSessionFactory(@Qualifier("dynamicDataSource") DataSource dynamicDataSource)
throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dynamicDataSource);
bean.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources("classpath*:mapping/*.xml"));
bean.setTypeAliasesPackage("cn.youyouxunyin.multipledb2.entity");
return bean.getObject();
}
}
3. Set the routing key
After the above configuration is completed, we still need to find a way to dynamically change the key value of the data source, which is related to the business of the system.
For example, here, we have two Mapper interfaces to create orders and deduct inventory.
public interface OrderMapper {
void createOrder(Order order);
}
public interface StorageMapper {
void decreaseStorage(Order order);
}
Then, we can create an aspect. When executing an order operation, switch to the data source ds1, and when executing an inventory operation, switch to the data source ds2.
@Component
@Aspect
public class DataSourceAop {
@Before("execution(* cn.youyouxunyin.multipledb2.mapper.OrderMapper.*(..))")
public void setDataSource1() {
DataSourceType.setDataBaseType(DataSourceType.DataBaseType.ds1);
}
@Before("execution(* cn.youyouxunyin.multipledb2.mapper.StorageMapper.*(..))")
public void setDataSource2() {
DataSourceType.setDataBaseType(DataSourceType.DataBaseType.ds2);
}
}
4. Test
Now you can write a Service method and test it through the REST interface.
public class OrderServiceImpl implements OrderService {
@Override
public void createOrder(Order order) {
storageMapper.decreaseStorage(order);
logger.info("库存已扣减,商品代码:{},购买数量:{}。创建订单中...",order.getCommodityCode(),order.getCount());
orderMapper.createOrder(order);
}
}
Not surprisingly, after the execution of the business, the tables in the two databases have changed.
But at this time, we will think that these two operations need to ensure atomicity. Therefore, we need to rely on transactions and mark Transactional on the Service method.
If we add Transactional annotation to the createOrder method, and then run the code, an exception will be thrown.
### Cause: java.sql.SQLSyntaxErrorException: Table 'db2.t_order' doesn't exist
; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException:
Table 'db2.t_order' doesn't exist] with root cause
This means that if Spring's transaction is added, our data source cannot be switched. What is going on here?
2. Transaction mode, why can't you switch the data source
To find out the reason, we have to analyze and analyze if Spring transaction is added, what does it do?
We know that Spring's automatic transaction is based on AOP. When calling a method that contains a transaction, an interceptor is entered.
public class TransactionInterceptor{
public Object invoke(MethodInvocation invocation) throws Throwable {
//获取目标类
Class<?> targetClass = AopUtils.getTargetClass(invocation.getThis());
//事务调用
return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
}
}
1. Create a transaction
In it, the first thing is to create a transaction.
protected Object doGetTransaction() {
//DataSource的事务对象
DataSourceTransactionObject txObject = new DataSourceTransactionObject();
//设置事务自动保存
txObject.setSavepointAllowed(isNestedTransactionAllowed());
//给事务对象设置ConnectionHolder
ConnectionHolder conHolder = TransactionSynchronizationManager.getResource(obtainDataSource());
txObject.setConnectionHolder(conHolder, false);
return txObject;
}
In this step, the focus is to set the ConnectionHolder property to the transaction object, but it is still empty at this time.
2. Open the transaction
Next, it is to start a transaction, here is mainly to bind resources and current transaction objects through ThreadLocal, and then set some transaction status.
protected void doBegin(Object txObject, TransactionDefinition definition) {
Connection con = null;
//从数据源中获取一个连接
Connection newCon = obtainDataSource().getConnection();
//重新设置事务对象中的connectionHolder,此时已经引用了一个连接
txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
//将这个connectionHolder标记为与事务同步
txObject.getConnectionHolder().setSynchronizedWithTransaction(true);
con = txObject.getConnectionHolder().getConnection();
con.setAutoCommit(false);
//激活事务活动状态
txObject.getConnectionHolder().setTransactionActive(true);
//将connection holder绑定到当前线程,通过threadlocal
if (txObject.isNewConnectionHolder()) {
TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder());
}
//事务管理器,激活事务同步状态
TransactionSynchronizationManager.initSynchronization();
}
3. Execute the Mapper interface
After the transaction is opened, the real method of the target class is executed. Here, you will begin to enter the proxy object of Mybatis. . Haha, the framework is all kinds of agents.
We know that Mybatis needs to obtain the SqlSession object before executing SQL.
public static SqlSession getSqlSession(SqlSessionFactory sessionFactory, ExecutorType executorType,
PersistenceExceptionTranslator exceptionTranslator) {
//从ThreadLocal中获取SqlSessionHolder,第一次获取不到为空
SqlSessionHolder holder = TransactionSynchronizationManager.getResource(sessionFactory);
//如果SqlSessionHolder为空,那也肯定获取不到SqlSession;
//如果SqlSessionHolder不为空,直接通过它来拿到SqlSession
SqlSession session = sessionHolder(executorType, holder);
if (session != null) {
return session;
}
//创建一个新的SqlSession
session = sessionFactory.openSession(executorType);
//如果当前线程的事务处于激活状态,就将SqlSessionHolder绑定到ThreadLocal
registerSessionHolder(sessionFactory, executorType, exceptionTranslator, session);
return session;
}
After getting the SqlSession, I started to call the Mybatis executor to prepare to execute SQL statements. Before executing SQL, of course, you need to get the Connection connection first.
public Connection getConnection() throws SQLException {
//通过数据源获取连接
//比如我们配置了多数据源,此时还会正常切换
if (this.connection == null) {
openConnection();
}
return this.connection;
}
We look at the openConnection method, its role is to obtain a Connection connection from the data source. If we configure multiple data sources, we can switch normally at this time. If a transaction is added, the reason why the data source is not switched is because in the second call, this.connection != null will return the last connection.
This is because when the SqlSession is obtained for the second time, the current thread is obtained from ThreadLocal, so the Connection connection will not be obtained repeatedly.
So far, in the case of multiple data sources, if Spring transaction is added, the reason why the data source cannot be dynamically switched, we should all understand.
Here, the author inserts an interview question:
- How does Spring guarantee transactions?
That is to put multiple business operations into the same database connection and submit or roll back together.
- How to do it, all in one connection?
Here is the use of various ThreadlLocals to find ways to bind database resources and current transactions together.
Three, transaction mode, how to support switching data sources
We have figured out the reasons above, and then we will see how to support it to dynamically switch data sources.
With the other configurations unchanged, we need to create two different sqlSessionFactory.
@Bean(name = "sqlSessionFactory1")
public SqlSessionFactory sqlSessionFactory1(@Qualifier("ds1") DataSource dataSource){
return createSqlSessionFactory(dataSource);
}
@Bean(name = "sqlSessionFactory2")
public SqlSessionFactory sqlSessionFactory2(@Qualifier("ds2") DataSource dataSource){
return createSqlSessionFactory(dataSource);
}
Then customize a CustomSqlSessionTemplate to replace the original sqlSessionTemplate in Mybatis, and inject the two SqlSessionFactory defined above.
@Bean(name = "sqlSessionTemplate")
public CustomSqlSessionTemplate sqlSessionTemplate(){
Map<Object,SqlSessionFactory> sqlSessionFactoryMap = new HashMap<>();
sqlSessionFactoryMap.put("ds1",factory1);
sqlSessionFactoryMap.put("ds2",factory2);
CustomSqlSessionTemplate customSqlSessionTemplate = new CustomSqlSessionTemplate(factory1);
customSqlSessionTemplate.setTargetSqlSessionFactorys(sqlSessionFactoryMap);
customSqlSessionTemplate.setDefaultTargetSqlSessionFactory(factory1);
return customSqlSessionTemplate;
}
In the defined CustomSqlSessionTemplate, everything else is the same, mainly depends on the method of obtaining SqlSessionFactory.
public class CustomSqlSessionTemplate extends SqlSessionTemplate {
@Override
public SqlSessionFactory getSqlSessionFactory() {
//当前数据源的名称
String currentDsName = DataSourceType.getDataBaseType().name();
SqlSessionFactory targetSqlSessionFactory = targetSqlSessionFactorys.get(currentDsName);
if (targetSqlSessionFactory != null) {
return targetSqlSessionFactory;
} else if (defaultTargetSqlSessionFactory != null) {
return defaultTargetSqlSessionFactory;
}
return this.sqlSessionFactory;
}
}
Here, the point is that we can obtain different SqlSessionFactory according to different data sources. If the SqlSessionFactory is not the same, then when the SqlSession is obtained, it will not be obtained in the ThreadLocal, so it is a new SqlSession object every time.
Since SqlSession is different, when getting Connection connection, it will go to the dynamic data source to get it every time.
The principle is such a principle, let's take a walk.
After modifying the configuration, we add a transaction annotation to the Service method, and the data can be updated normally at this time.
@Transactional
@Override
public void createOrder(Order order) {
storageMapper.decreaseStorage(order);
orderMapper.createOrder(order);
}
Being able to switch data sources is only the first step, and the guarantees we need can guarantee transaction operations. If in the above code, the inventory deduction is completed, but the order creation fails, the inventory will not be rolled back. Because they belong to different data sources, they are not the same connection at all.
Four, XA protocol distributed transaction
To solve the above problem, we can only consider the XA protocol.
Regarding what the XA protocol is, the author will not describe too much. We only need to know that the MySQL InnoDB storage engine supports XA transactions.
Then the realization of XA protocol is called Java Transaction Manager, JTA for short in Java.
How to implement JTA? We use the Atomikos framework to introduce its dependencies first.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
<version>2.2.7.RELEASE</version>
</dependency>
Then, just change the DataSource object to AtomikosDataSourceBean.
public DataSource getDataSource(Environment env, String prefix, String dataSourceName){
Properties prop = build(env,prefix);
AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
ds.setXaDataSourceClassName(MysqlXADataSource.class.getName());
ds.setUniqueResourceName(dataSourceName);
ds.setXaProperties(prop);
return ds;
}
After this configuration, when you get the Connection connection, you actually get the MysqlXAConnection object. When submitting or rolling back, the XA protocol of MySQL is taken.
public void commit(Xid xid, boolean onePhase) throws XAException {
//封装 XA COMMIT 请求
StringBuilder commandBuf = new StringBuilder(300);
commandBuf.append("XA COMMIT ");
appendXid(commandBuf, xid);
try {
//交给MySQL执行XA事务操作
dispatchCommand(commandBuf.toString());
} finally {
this.underlyingConnection.setInGlobalTx(false);
}
}
By introducing Atomikos and modifying DataSource, in the case of multiple data sources, even if there is an error in the business operation, multiple databases can be rolled back normally.
Another question, should I use the XA protocol?
The XA protocol looks simpler, but it also has some disadvantages. such as:
- Performance problems, all participants are in a synchronized blocking state during the transaction commit phase, occupying system resources, easily causing performance bottlenecks and failing to meet high concurrency scenarios;
- If the coordinator has a single point of failure, if the coordinator fails, the participants will always be locked;
- Master-slave replication may produce inconsistent transaction status.
Some restrictions of the XA protocol are also listed in the official MySQL documentation:
https://dev.mysql.com/doc/refman/8.0/en/xa-restrictions.html
In addition, the author has not actually used it in actual projects. To solve the distributed transaction problem in this way, this example only discusses the feasibility scheme.
to sum up
This article analyzes the following issues by introducing the multiple data source scenario of SpringBoot+Mybatis:
- Configuration and realization of multiple data sources;
- Spring transaction mode, the reasons and solutions for the failure of multiple data sources;
- Multiple data sources, distributed transaction implementation based on XA protocol.
Author: quiet place
Source: Nuggets