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

2.springTest配置多數(shù)據(jù)源導致事務無法回滾
在重構大遷移的背景下,我們初步在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ù)源設為首選項
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對應新數(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ù)第一反應是懵逼的??,日志不會說謊,數(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為空,事務信息為空,所以導致未真實回滾。
google了下transactionInfo為空的case,https://github.com/alibaba/druid/issues/1635,鏈接是druid論壇小伙伴的一些回答。
博主的答案有點概括,看了之后也不是太明白(只能怪自己bug寫多了,人變傻了,理解能力也變差了,再次手動狗頭)
2.5.transactionInfo
設置transactionInfo的地方只有一處,即通過connection執(zhí)行sql的時候會對事務進行記錄。
### 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屬性被設置成了true,connection如下。

而在TransactionContext開啟事務的時候connection如下:

一個為DruidPooledConnection@12036,一個為DruidPooledConnection@11838,兩個DruidPooledConnection不同,所以springTest的環(huán)繞切面無法對事務進行回滾。
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í)行對應的數(shù)據(jù)源信息,從而找到對應的數(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的初次設置代碼如下

DataSourceTransactionManager設置了datasource信息,聰明的你可能馬上想到,DataSourceTransactionManager是我們自己在代碼中配置的。
我們把OldDataSourceTransactionManager的優(yōu)先級設置成了@Primary這才導致TransactionContext用的是OldDataSourceTransactionManager來管理事務。
現(xiàn)在我們只需要把TransactionContext的事務管理器設置成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());
}
}
總結
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
Spring Boot的FailureAnalyzer機制及如何解救應用啟動危機
本文探討了FailureAnalyzer工具,它不僅能幫助我們快速識別和處理代碼中的錯誤,還能極大地提升我們的開發(fā)效率,通過詳細的實例分析,我們了解了FailureAnalyzer如何通過自定義邏輯應對不同類型的異常,讓程序員能夠更好地定位問題并迅速找到解決方案,感興趣的朋友一起看看吧2025-01-01

