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ù)源的時候,如果沒有特別申明會注入默認(rèn)的數(shù)據(jù)源)
3.spring-test手動開啟一個事務(wù),執(zhí)行用戶測試用例(事務(wù)操作參考Mybatis執(zhí)行流程),spring-test手動關(guān)閉事務(wù)(根據(jù)TransactionInfo中記錄的sql列表對事務(wù)中的數(shù)據(jù)庫操作進(jìn)行回滾,避免單測對數(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)先項(xiàng),否則spring啟動會報錯。
為避免影響已有功能,這里暫時將舊數(shù)據(jù)源設(shè)為首選項(xiàng)
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默認(rèn)事物回滾
但數(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)致未真實(shí)回滾。
google了下transactionInfo
為空的case,https://github.com/alibaba/druid/issues/1635,鏈接是druid論壇小伙伴的一些回答。
博主的答案有點(diǎn)概括,看了之后也不是太明白(只能怪自己bug寫多了,人變傻了,理解能力也變差了,再次手動狗頭)
2.5.transactionInfo
設(shè)置transactionInfo
的地方只有一處,即通過connection執(zhí)行sql的時候會對事務(wù)進(jìn)行記錄。
### 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ù)進(jì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í)行對應(yīng)的數(shù)據(jù)源信息,從而找到對應(yīng)的數(shù)據(jù)源進(jìn)行數(shù)據(jù)庫操作。
根據(jù)debug信息棧發(fā)現(xiàn),在SqlSessionTemplate中沒有Connection信息,但是在SqlSessionInterceptor中已經(jīng)存在了(debug圖中標(biāo)紅圈部分)
根據(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ù)源),進(jìn)一步跟蹤源碼我們找到是通過
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無法對單測進(jìn)行回滾。
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)驗(yàn),希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
Spring Boot的FailureAnalyzer機(jī)制及如何解救應(yīng)用啟動危機(jī)
本文探討了FailureAnalyzer工具,它不僅能幫助我們快速識別和處理代碼中的錯誤,還能極大地提升我們的開發(fā)效率,通過詳細(xì)的實(shí)例分析,我們了解了FailureAnalyzer如何通過自定義邏輯應(yīng)對不同類型的異常,讓程序員能夠更好地定位問題并迅速找到解決方案,感興趣的朋友一起看看吧2025-01-01線程池運(yùn)用不當(dāng)引發(fā)的一次線上事故解決記錄分析
遇到了一個比較典型的線上問題,剛好和線程池有關(guān),另外涉及到死鎖、jstack命令的使用、JDK不同線程池的適合場景等知識點(diǎn),同時整個調(diào)查思路可以借鑒,特此記錄和分享一下2024-01-01Java實(shí)現(xiàn)用位運(yùn)算維護(hù)狀態(tài)碼
位運(yùn)算是一種非常高效的運(yùn)算方式,在算法考察中比較常見,那么業(yè)務(wù)代碼中我們?nèi)绾问褂梦贿\(yùn)算呢,感興趣的小伙伴快跟隨小編一起學(xué)習(xí)一下吧2024-03-03