前言
可能由於業務上的某些需求,我們的系統中有時往往要連接多個數據庫,這就產生了多數據源問題。
多數據源的情況下,一般我們要做到可以自動切換,此時會涉及到事務註解 Transactional 不生效問題和分佈式事務問題。
關於多數據源方案,筆者在網上看過一些例子,然而大部分都是錯誤示例,根本跑不通,或者沒辦法兼容事務。
今天,我們就一點點來分析這些問題產生的根源和相應的解決方法。
一、多數據源
爲了劇情的順利開展,我們模擬的業務是創建訂單和扣減庫存。
所以,我們先創建訂單表和庫存表。注意,把他們分別放到兩個數據庫中。
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、數據庫連接
通過YML文件先把兩個數據庫都配置一下。
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、配置DataSource
我們知道,Mybatis執行一條SQL語句的時候,需要先獲取一個Connection。這時候,就交由Spring管理器到DataSource中獲取連接。
Spring中有個具有路由功能的DataSource,它可以通過查找鍵調用不同的數據源,這就是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;
}
}
可以看到,該抽象類的核心就是先設置多個數據源到Map集合中,然後根據Key可以獲取不同的數據源。
那麼,我們就可以重寫這個determineCurrentLookupKey方法,它返回的是一個數據源的名稱。
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
DataSourceType.DataBaseType dataBaseType = DataSourceType.getDataBaseType();
return dataBaseType;
}
}
然後還需要一個工具類,來保存當前線程的數據源類型。
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;
}
}
這些都搞定之後,我們還需要把這個DataSource配置到Spring容器中去。下面這個配置類的作用如下:
- 創建多個數據源DataSource,ds1 和 ds2;
- 將ds1 和 ds2 數據源放入動態數據源DynamicDataSource;
- 將DynamicDataSource注入到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、設置路由鍵
上面的配置都完成之後,我們還需要想辦法動態的改變數據源的鍵值,這個就跟系統的業務相關了。
比如在這裏,我們有兩個Mapper接口,創建訂單和扣減庫存。
public interface OrderMapper {
void createOrder(Order order);
}
public interface StorageMapper {
void decreaseStorage(Order order);
}
那麼,我們就可以搞一個切面,在執行訂單的操作時,切到數據源ds1,執行庫存操作時,切到數據源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、測試
現在就可以寫一個Service方法,通過REST接口測試一下啦。
public class OrderServiceImpl implements OrderService {
@Override
public void createOrder(Order order) {
storageMapper.decreaseStorage(order);
logger.info("庫存已扣減,商品代碼:{},購買數量:{}。創建訂單中...",order.getCommodityCode(),order.getCount());
orderMapper.createOrder(order);
}
}
不出意外的話,業務執行完成後,兩個數據庫的表都已經有了變化。
但此時,我們會想到,這兩個操作是需要保證原子性的。所以,我們需要依賴事務,在Service方法上標註Transactional。
如果我們在createOrder方法上添加了Transactional註解,然後在運行代碼,就會拋出異常。
### 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
這就說明,如果加上了 Spring 的事務,我們的數據源切換不過去了。這又是咋回事呢?
二、事務模式,爲啥不能切換數據源
要想搞清楚原因,我們就得來分析分析如果加上了Spring事務,它又幹了哪些事情呢 ?
我們知道,Spring的自動事務是基於AOP實現的。在調用包含事務的方法時,會進入一個攔截器。
public class TransactionInterceptor{
public Object invoke(MethodInvocation invocation) throws Throwable {
//獲取目標類
Class<?> targetClass = AopUtils.getTargetClass(invocation.getThis());
//事務調用
return invokeWithinTransaction(invocation.getMethod(), targetClass, invocation::proceed);
}
}
1、創建事務
在這裏面呢,首先就是開始創建一個事務。
protected Object doGetTransaction() {
//DataSource的事務對象
DataSourceTransactionObject txObject = new DataSourceTransactionObject();
//設置事務自動保存
txObject.setSavepointAllowed(isNestedTransactionAllowed());
//給事務對象設置ConnectionHolder
ConnectionHolder conHolder = TransactionSynchronizationManager.getResource(obtainDataSource());
txObject.setConnectionHolder(conHolder, false);
return txObject;
}
在這一步,重點是給事務對象設置了ConnectionHolder屬性,不過此時還是爲空。
2、開啓事務
接下來,就是開啓一個事務,這裏主要是通過ThreadLocal將資源和當前的事務對象綁定,然後設置一些事務狀態。
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、執行Mapper接口
開啓事務之後,就開始執行目標類真實方法。在這裏,就會開始進入Mybatis的代理對象。。哈哈,框架嘛,就各種代理。
我們知道,Mybatis在執行SQL的之前,需要先獲取到SqlSession對象。
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;
}
拿到SqlSession之後,就開始調用Mybatis的執行器,準備執行SQL語句。在執行SQL之前呢,當然需要先拿到Connection連接。
public Connection getConnection() throws SQLException {
//通過數據源獲取連接
//比如我們配置了多數據源,此時還會正常切換
if (this.connection == null) {
openConnection();
}
return this.connection;
}
我們看openConnection方法,它的作用就是從數據源中獲取一個Connection連接。如果我們配置了多數據源,此時是可以正常切換的。如果加了事務,之所以沒有切換數據源,是因爲第二次調用時,this.connection != null
,返回的還是上一次的連接。
這是因爲,在第二次獲取SqlSession的時候,當前線程是從ThreadLocal中拿到的,所以不會重複獲取Connection連接。
至此,在多數據源情況下,如果加了Spring事務,不能動態切換數據源的原因,我們應該都明白了。
在這裏,筆者插播一道面試題:
- Spring是如何保證事務的?
那就是將多個業務操作,放到同一個數據庫連接中,一起提交或回滾。
- 怎麼做到,都在一個連接中呢?
這裏就是各種ThreadlLocal的運用,想辦法將數據庫資源和當前事務綁定到一起。
三、事務模式,怎麼支持切換數據源
上面我們已經把原因搞清楚了,接下來就看怎麼支持它動態切換數據源。
其他配置都不變的情況下,我們需要創建兩個不同的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);
}
然後自定義一個CustomSqlSessionTemplate,來代替Mybatis中原有的sqlSessionTemplate,把上面定義的兩個SqlSessionFactory注入進去。
@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;
}
在定義的CustomSqlSessionTemplate中,其他都一樣,主要看獲取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;
}
}
在這裏,重點就是我們可以根據不同的數據源獲取不同的SqlSessionFactory。如果SqlSessionFactory不一樣,那麼在獲取SqlSession的時候,就不會在ThreadLocal中拿到,從而每次都是新的SqlSession對象。
既然SqlSession也不一樣,那麼在獲取Connection連接的時候,每次都會去動態數據源中去獲取。
原理就是這麼個原理,我們來走一把。
修改完配置之後,我們把Service方法加上事務的註解,此時數據也是可以正常更新的。
@Transactional
@Override
public void createOrder(Order order) {
storageMapper.decreaseStorage(order);
orderMapper.createOrder(order);
}
可以切換數據源只是第一步,我們需要的保證可以保證事務操作。假如在上面的代碼中,庫存扣減完成,但是創建訂單失敗,庫存是不會回滾的。因爲它們分別屬於不同的數據源,根本不是同一個連接。
四、XA協議分佈式事務
要解決上面那個問題,我們只能考慮XA協議。
關於XA協議是啥,筆者不再過多的描述。我們只需知道,MySQL InnoDB存儲引擎是支持XA事務的。
那麼XA協議的實現,在Java中叫做Java Transaction Manager,簡稱JTA。
如何實現JTA呢?我們藉助Atomikos框架,先引入它的依賴。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jta-atomikos</artifactId>
<version>2.2.7.RELEASE</version>
</dependency>
然後,只需把DataSource對象改成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;
}
這樣配完之後,獲取Connection連接的時候,拿到的其實是MysqlXAConnection對象。在提交或者回滾的時候,走的就是MySQL的XA協議了。
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);
}
}
通過引入Atomikos和修改DataSource,在多數據源情況下,即便業務操作中間發生錯誤,多個數據庫也是可以正常回滾的。
另外一個問題,是否應該使用XA協議?
XA協議看起來看起來比較簡單,但它也有一些缺點。比如:
- 性能問題,所有參與者在事務提交階段處於同步阻塞狀態,佔用系統資源,容易導致性能瓶頸,無法滿足高併發場景;
- 如果協調者存在單點故障問題,如果協調者出現故障,參與者將一直處於鎖定狀態;
- 主從複製可能產生事務狀態不一致。
在MySQL官方文檔中也列舉了一些XA協議的限制項:
https://dev.mysql.com/doc/refman/8.0/en/xa-restrictions.html
另外,筆者在實際的項目裏,其實也沒有用過,通過這樣的方式來解決分佈式事務問題,此例僅做可行性方案探討。
總結
本文通過引入SpringBoot+Mybatis的多數據源場景,分析瞭如下問題:
- 多數據源的配置和實現;
- Spring事務模式,多數據源不生效的原因和解決方法;
- 多數據源,基於XA協議的分佈式事務實現。
由於篇幅有限,本文示例不包含所有的代碼。如有需要,請到GitHub自取。
https://github.com/taoxun/multipledb2.git
原創不易,客官們點個贊再走嘛,這將是筆者持續寫作的動力~