Spring配置多數(shù)據(jù)源導(dǎo)致事物無法回滾問題
環(huán)境
- spring 4.3.13
- Druid 鏈接池1.1.0
- mysql 5.1.41
- mybatis 3.4.6
1.spring-test簡介
1.1spring-test類圖

整個spring-test交互流程分為三部分(對應(yīng)上圖三種顏色):
1.測試啟動,構(gòu)建spring容器,并將applicationContext注入到TestContext,構(gòu)造測試上下文容器
2.TestContextManager從spring容器中獲取數(shù)據(jù)源事務(wù)管理器DataSourceTransactionManager(配置多數(shù)據(jù)源的時候,如果沒有特別申明會注入默認的數(shù)據(jù)源)
3.spring-test手動開啟一個事務(wù),執(zhí)行用戶測試用例(事務(wù)操作參考Mybatis執(zhí)行流程),spring-test手動關(guān)閉事務(wù)(根據(jù)TransactionInfo中記錄的sql列表對事務(wù)中的數(shù)據(jù)庫操作進行回滾,避免單測對數(shù)據(jù)庫造成污染)
1.2簡單的流程示意圖

2.springTest配置多數(shù)據(jù)源導(dǎo)致事務(wù)無法回滾
在重構(gòu)大遷移的背景下,我們初步在A工程接入了新老兩個數(shù)據(jù)源(請不要吐槽一個工程里面配多個數(shù)據(jù)源,手動狗頭)。
簡單的示例如下:
新數(shù)據(jù)源配置–可略過不看
/**
* 新數(shù)據(jù)源
@author vincilovfang
*/
@Configuration
@MapperScan(basePackages = "com.spring.test", sqlSessionTemplateRef = "newSqlSessionTemplate")
public class NewDataSourceConfig {
@Value("${newJdbc.url}")
private String url;
@Value("${newJdbc.username}")
private String username;
@Value("${newJdbc.password}")
private String password;
@Bean(name = "newDataSource")
public DataSource buildDataSource() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
// set其他屬性
return dataSource;
}
@Bean(name = "newSqlSessionFactory")
public SqlSessionFactory buildSqlSessionFactory(
@Qualifier("newDataSource") DataSource dataSource) {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
//...set其他屬性
}
@Bean(name = "newSqlSessionTemplate")
public SqlSessionTemplate buildSqlSessionTemplate(
@Qualifier("newSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean(name = "newTransactionManager")
public DataSourceTransactionManager buildTransactionManager(
@Qualifier("newDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
舊數(shù)據(jù)源配置,這個地方必須將新老數(shù)據(jù)源中的一個指定為優(yōu)先項,否則spring啟動會報錯。
為避免影響已有功能,這里暫時將舊數(shù)據(jù)源設(shè)為首選項
No qualifying bean of type 'javax.sql.DataSource' available: expected single matching bean but found 2: newDataSource,oldDataSource
/**
* 舊數(shù)據(jù)源
@author vincilovfang
*/
@Configuration
@MapperScan(basePackages = "com.spring.test", sqlSessionTemplateRef = "oldSqlSessionTemplate")
public class OldDataSourceConfig {
@Value("${jdbc.url}")
private String url;
@Value("${jdbc.username}")
private String username;
@Value("${jdbc.password}")
private String password;
@Bean(name = "oldDataSource")
@Primary
public DataSource buildDataSource() {
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
//... set其他屬性
return dataSource;
}
@Bean(name = "oldSqlSessionFactory")
@Primary
public SqlSessionFactory buildSqlSessionFactory(@Qualifier("oldDataSource") DataSource dataSource) {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
//...set其他屬性
}
@Bean(name = "oldSqlSessionTemplate")
@Primary
public SqlSessionTemplate buildSqlSessionTemplate(
@Qualifier("oldSqlSessionFactory") SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
@Bean(name = "oldTransactionManager")
@Primary
public DataSourceTransactionManager buildTransactionManager(
@Qualifier("oldDataSource") DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
單測示例–DemoDO對應(yīng)新數(shù)據(jù)源里面的數(shù)據(jù)表
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = SpringBootStarter.class)
public class NoRollbackDemoTest extends MockitoTimorTestBase {
@Resource
private DemoDOMapper demoDOMapper;
@Test
public void testDemo() {
DemoDO demoDO = createData(DemoDO.class);
demoDO.setCpId(2341233453L);
demoDOMapper.insertDemo(demoDO);
Demo demo = demoRepository.getDemo(2341233453L);
Assert.assertEquals(demoDO.getCpId(), demo.getCpId());
}
}
2.1.springTest默認事物回滾
但數(shù)據(jù)庫里面數(shù)據(jù)并未回滾

2.2.跟蹤日志也顯示回滾
[main:TransactionContext.java:139] _am||traceid=||spanid=||Rolled back transaction for test context [DefaultTestContext@143640d5 testClass = NoRollbackDemoTest, testInstance = com.spring.test.xxx.infrastructure.persistence.NoRollbackDemoTest@6d0fe80c, testMethod = testNoRollbackCase@NoRollbackDemoTest, testException = [null], mergedContextConfiguration = [WebMergedContextConfiguration@6295d394 testClass = NoRollbackDemoTest, locations = '{}'...
看到數(shù)據(jù)庫里面的臟數(shù)據(jù)第一反應(yīng)是懵逼的??,日志不會說謊,數(shù)據(jù)庫臟數(shù)據(jù)也是存在的。
根據(jù)日志提示,追蹤TransactionContext的源碼,在springTest開始之前、之后,分別會執(zhí)行startTransaction、endTransaction
2.3.開啟回滾–TransactionContext
### TransactionContext
void startTransaction() {
if (this.transactionStatus != null) {
throw new IllegalStateException(
"Cannot start a new transaction without ending the existing transaction first.");
}
this.flaggedForRollback = this.defaultRollback;
this.transactionStatus = this.transactionManager.getTransaction(this.transactionDefinition);
++this.transactionsStarted;
if (logger.isInfoEnabled()) {
logger.info(String.format(
"Began transaction (%s) for test context %s; transaction manager [%s]; rollback [%s]",
this.transactionsStarted, this.testContext, this.transactionManager, flaggedForRollback));
}
}
void endTransaction() {
if (logger.isTraceEnabled()) {
logger.trace(String.format(
"Ending transaction for test context %s; transaction status [%s]; rollback [%s]",
this.testContext, this.transactionStatus, this.flaggedForRollback));
}
if (this.transactionStatus == null) {
throw new IllegalStateException(String.format(
"Failed to end transaction for test context %s: transaction does not exist.", this.testContext));
}
try {
if (this.flaggedForRollback) {
this.transactionManager.rollback(this.transactionStatus);
}
else {
this.transactionManager.commit(this.transactionStatus);
}
}
finally {
this.transactionStatus = null;
}
if (logger.isInfoEnabled()) {
logger.info(String.format("%s transaction for test context %s.",
(this.flaggedForRollback ? "Rolled back" : "Committed"), this.testContext));
}
}
繼續(xù)走查源碼類時序圖如圖4

2.4.執(zhí)行回滾–DruidPooledConnection
### DruidPooledConnection
public void rollback() throws SQLException {
if (transactionInfo == null) {
return;
}
if (holder == null) {
return;
}
DruidAbstractDataSource dataSource = holder.getDataSource();
dataSource.incrementRollbackCount();
try {
conn.rollback();
} catch (SQLException ex) {
handleException(ex);
} finally {
handleEndTransaction(dataSource, null);
}
}
發(fā)現(xiàn)在在DruidPooledConnection中 transactionInfo為空,事務(wù)信息為空,所以導(dǎo)致未真實回滾。
google了下transactionInfo為空的case,https://github.com/alibaba/druid/issues/1635,鏈接是druid論壇小伙伴的一些回答。
博主的答案有點概括,看了之后也不是太明白(只能怪自己bug寫多了,人變傻了,理解能力也變差了,再次手動狗頭)
2.5.transactionInfo
設(shè)置transactionInfo的地方只有一處,即通過connection執(zhí)行sql的時候會對事務(wù)進行記錄。
### DruidPooledConnection
protected void transactionRecord(String sql) throws SQLException {
if (transactionInfo == null && (!conn.getAutoCommit())) {
DruidAbstractDataSource dataSource = holder.getDataSource();
dataSource.incrementStartTransactionCount();
transactionInfo = new TransactionInfo(dataSource.createTransactionId());
}
if (transactionInfo != null) {
List<String> sqlList = transactionInfo.getSqlList();
if (sqlList.size() < MAX_RECORD_SQL_COUNT) {
sqlList.add(sql);
}
}
}
代碼中conn的autoCommit屬性被設(shè)置成了true,connection如下。

而在TransactionContext開啟事務(wù)的時候connection如下:

一個為DruidPooledConnection@12036,一個為DruidPooledConnection@11838,兩個DruidPooledConnection不同,所以springTest的環(huán)繞切面無法對事務(wù)進行回滾。
2.6.connection創(chuàng)建
現(xiàn)在的問題是為什么TransactionContext.startTransaction中的conn和單測執(zhí)行中的conn不是一個。
接下來要做的是確定在TransactionContext和單測中,connection分別是怎么創(chuàng)建的。
TransactionContext.startTransaction獲取connection流程如下

單測中,通過代碼執(zhí)行棧信息分析代碼邏輯執(zhí)行的時候是如何獲取DruidPooledConnection,這里的主要執(zhí)行流程即為Mybatis執(zhí)行時序圖

其中mybatis中mapperProxy中記錄了每個sql執(zhí)行對應(yīng)的數(shù)據(jù)源信息,從而找到對應(yīng)的數(shù)據(jù)源進行數(shù)據(jù)庫操作。
根據(jù)debug信息棧發(fā)現(xiàn),在SqlSessionTemplate中沒有Connection信息,但是在SqlSessionInterceptor中已經(jīng)存在了(debug圖中標紅圈部分)

根據(jù)棧信息能看出connection由SpringManagedTransaction持有,繼續(xù)跟蹤SpringManagedTransaction源碼查看connection的創(chuàng)建
### SpringManagedTransaction
private void openConnection() throws SQLException {
this.connection = DataSourceUtils.getConnection(this.dataSource);
this.autoCommit = this.connection.getAutoCommit();
this.isConnectionTransactional = DataSourceUtils.isConnectionTransactional(this.connection, this.dataSource);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(
"JDBC Connection ["
+ this.connection
+ "] will"
+ (this.isConnectionTransactional ? " " : " not ")
+ "be managed by Spring");
}
}
connetciton是通過dataSource獲取的,由于單測的DemoDO在新數(shù)據(jù)源中,這里的this.dataSource為新數(shù)據(jù)源(mybatis的源頭mapperProxy會記錄每條sql需要的數(shù)據(jù)源),進一步跟蹤源碼我們找到是通過
TransactionSynchronizationManager里面的resource獲取connectionHolder
### TransactionSynchronizationManager
private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");
private static Object doGetResource(Object actualKey) {
Map<Object, Object> map = resources.get();
if (map == null) {
return null;
}
Object value = map.get(actualKey);
// Transparently remove ResourceHolder that was marked as void...
if (value instanceof ResourceHolder && ((ResourceHolder) value).isVoid()) {
map.remove(actualKey);
// Remove entire ThreadLocal if empty...
if (map.isEmpty()) {
resources.remove();
}
value = null;
}
return value;
}
debug發(fā)現(xiàn)resources這個里面的記錄的是舊數(shù)據(jù)源信息,所以返回connection為空,便新創(chuàng)建了一個Connection。
到這里我們基本清楚了,TransactionContext用的是舊數(shù)據(jù)源創(chuàng)建的連接(spring依賴注入優(yōu)先注入了舊數(shù)據(jù)源),而單測中用的是新數(shù)據(jù)源創(chuàng)建的連接,所以TransactionContext無法對單測進行回滾。
resources的初次設(shè)置代碼如下

DataSourceTransactionManager設(shè)置了datasource信息,聰明的你可能馬上想到,DataSourceTransactionManager是我們自己在代碼中配置的。
我們把OldDataSourceTransactionManager的優(yōu)先級設(shè)置成了@Primary這才導(dǎo)致TransactionContext用的是OldDataSourceTransactionManager來管理事務(wù)。
現(xiàn)在我們只需要把TransactionContext的事務(wù)管理器設(shè)置成NewDataSourceTransactionManager即可。
2.7.最終的單測代碼
如下
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = SpringBootStarter.class)
@Transactional(transactionManager = "newDataSourceTransactionManager")
public class NoRollbackDemoTest extends MockitoTimorTestBase {
@Resource
private DemoDOMapper demoDOMapper;
@Test
public void testDemo() {
DemoDO demoDO = createData(DemoDO.class);
demoDO.setCpId(2341233453L);
demoDOMapper.insertDemo(demoDO);
Demo demo = demoRepository.getDemo(2341233453L);
Assert.assertEquals(demoDO.getCpId(), demo.getCpId());
}
}
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
解決Spring Data Jpa 實體類自動創(chuàng)建數(shù)據(jù)庫表失敗問題
這篇文章主要介紹了解決Spring Data Jpa 實體類自動創(chuàng)建數(shù)據(jù)庫表失敗問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-09-09
SpringBoot整合Mybatis Plus多數(shù)據(jù)源的實現(xiàn)示例
本文主要介紹了SpringBoot整合Mybatis Plus多數(shù)據(jù)源的實現(xiàn)示例,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-11-11
springcloud-gateway整合jwt+jcasbin實現(xiàn)權(quán)限控制的詳細過程
這篇文章主要介紹了springcloud-gateway整合jwt+jcasbin實現(xiàn)權(quán)限控制,基于springboot+springcloud+nacos的簡單分布式項目,項目交互采用openFeign框架,單獨提取出來成為一個獨立的model,需要的朋友可以參考下2023-02-02
一文帶你徹底了解Java8中的Lambda,函數(shù)式接口和Stream
這篇文章主要為大家詳細介紹了解Java8中的Lambda,函數(shù)式接口和Stream的用法和原理,文中的示例代碼簡潔易懂,感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2023-08-08
SpringBoot整合screw實現(xiàn)自動生成數(shù)據(jù)庫設(shè)計文檔
使用navicat工作的話,導(dǎo)出的格式是excel不符合格式,還得自己整理。所以本文將用screw工具包,整合到springboot的項目中便可以自動生成數(shù)據(jù)庫設(shè)計文檔,非常方便,下面就分享一下教程2022-11-11
Java時區(qū)轉(zhuǎn)換及Date類實現(xiàn)原理解析
這篇文章主要介紹了Java時區(qū)轉(zhuǎn)換及Date類實現(xiàn)原理解析,文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2019-11-11

