事情起因是,摸魚的時候在某平臺刷到一篇spring事務相關的博文,文章最後貼了一張圖。裡面關於巢狀事務的表述明顯是錯誤的。
更奇怪的是,這張圖有點印象。在必應搜尋關鍵詞PROPAGATION_NESTED
出來的第一篇文章,裡面就有這這部份內容,也是結尾部份完全一模一樣。
更關鍵的是,人家原文是表格,這位倒好,估計是怕麻煩,直接給截成圖片了。
而且這篇文章其實在評論區已經被人指出來這方面的問題了,但是這位作者依然不加驗證的直接拿走了。
這位作者可不是個小號,是某年度的人氣作者。
可能是有自己的公眾號,得保持一定的更新頻率?
好傢伙,沒經過驗證,一部份錯誤的內容就這樣被持續擴大傳播了。
在必應搜尋關鍵詞PROPAGATION_NESTED
出來文章,前兩篇都是CSDN,都是一樣的文章一樣的錯誤。另外幾篇文章也或多或少有些表述不清的地方。因此嘗試來寫一寫這方面的東西。
順便吐槽一下CSDN,我好多篇文章都被這上面的某些作者給扒過去,然後搜尋一模一樣的標題,權重比我還高,出來排第一位的反而是CSDN的盜版文章。
1.當我們在談論巢狀事務的時候,巢狀的是什麼?
當看到`巢狀事務`第一反應想到是這樣式的:
但這更像PROPAGATION_REQUIRES_NEW
啊,感興趣可以去打斷點執行一下。PROPAGATION_REQUIRES_NEW
事務傳播下,方法A呼叫方法B就是這樣,
// 事務A doBegin()
// 事務B doBegin()
// 事務B doCommit()
// 事務A doCommit()
而在PROPAGATION_NESTED
事務傳播下,打了個斷點,會發現只會執行一次doBegin和doCommit:
事務A doBegin()
事務A doCommit()
我們用程式碼輸出更加直觀。
定義兩個方法serviceA和serviceB,使用前者呼叫後者。前者事務傳播使用REQUIRED
,後者使用PROPAGATION_NESTED
。
@Transactional(propagation = Propagation.REQUIRED)
public void serviceA(){
Tcity tcity2 = new Tcity();
tcity2.setId(0);
tcity2.setStateCode("5");
tcity2.setCnCity("測試城市2");
tcity2.setCountryCode("ALB");
tcityMapper.insertSelective(tcity2);
transactionInfo();
test2.serviceB();
}
@Transactional(rollbackFor = Exception.class, propagation = Propagation.NESTED)
public void serviceB() {
Tcity tcity = new Tcity();
tcity.setId(0);
tcity.setStateCode("5");
tcity.setCnCity("測試城市");
tcity.setCountryCode("ALB");
tcityMapper.insertSelective(tcity);
tcityMapper.selectAll2();
transactionInfo();
這裡的transactionInfo()使用事務同步器管理器TransactionSynchronizationManager
註冊一個事務同步器TransactionSynchronization
。
這樣在事務完成之後afterCompletion
會輸出當前事務是commit
還是rollback
,這樣也便於測試,比起去重新整理資料庫看有沒有寫入,更加方便快捷直觀。
同時使用TransactionSynchronizationManager.getCurrentTransactionName()
可以得到當前事務的名稱,這樣可以直觀的看到當前方法使用的是同一個事務還是不同的事務。
protected void transactionInfo() {
String transactionName = TransactionSynchronizationManager.getCurrentTransactionName();
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
log.info("transactionName:{}, active:{}", transactionName, active);
if (!active) {
log.info("transaction :{} not active", transactionName);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_COMMITTED) {
log.info("transaction :{} commit", transactionName);
} else if (status == STATUS_ROLLED_BACK) {
log.info("transaction :{} rollback", transactionName);
} else {
log.info("transaction :{} unknown", transactionName);
}
}
});
}
執行測試程式碼:
@RunWith(SpringRunner.class)
@SpringBootTest
public class Test {
@Autowired
private Test1 test1;
@org.junit.Test
public void test(){
test1.serviceA();
}
}
輸出:
可以非常直觀地觀察到3點情況:
1.透過上圖示記為1的地方,可以看到兩個方法使用了一個事務com.nyp.test.service.propagation.Test1.serviceA
。
2.透過上圖示記為2的地方,以及箭頭順序,可以看到事務執行順序類似於(事實上不是,只是事務同步器的問題,下文有說明):
// 事務A doBegin()
// 事務B doBegin()
// 事務A doCommit()
// 事務B doCommit()
3.透過事務同步器列印日誌發現commit執行了兩次。
以上2,3兩點與前面打斷點的結論貌似是有點衝突。
1.1巢狀事務究竟有幾個事務
原始碼版本:spring-tx 5.3.25
透過原始碼,可以很直觀地觀察到,useSavepointForNestedTransaction()
預設返回true,這樣就不會開啟一個新的事務(startTransaction
), 而是建立一個新的savepoint
。
相當於在方法A的時候會開啟一個新的事務,在呼叫方法B的時候,會在方法A之後方法B之前建立一個檢查點。
類似於在原來的A方法上手動新增檢查點。
@Transactional(propagation = Propagation.REQUIRED)
public void serviceA(){
Object savePoint = null;
try {
Tcity tcity2 = new Tcity();
tcity2.setId(0);
tcity2.setStateCode("5");
tcity2.setCnCity("測試城市2");
tcity2.setCountryCode("ALB");
tcityMapper.insertSelective(tcity2);
transactionInfo();
savePoint = TransactionAspectSupport.currentTransactionStatus().createSavepoint();
test2.serviceB();
} catch (Exception exception) {
exception.printStackTrace();
TransactionAspectSupport.currentTransactionStatus().rollbackToSavepoint(savePoint);
}
}
然後透過檢查點,將一個邏輯事務
分為多個物理事務
。
我這可不是在亂講啊,我是有備而來。
https://github.com/spring-projects/spring-framework/issues/8135
上面是spring 在github官方社群07年的一個貼子,Juergen Hoeller
有一段回覆。
Juergen Hoeller
是誰?他是spring的聯合創始人,事務這一塊的主要開發者。
PROPAGATION_NESTED的不同之處在於,它使用具有多個儲存點的單個物理事務,可以回滾到這些儲存點。這種部分回滾允許內部事務範圍觸發其範圍的回滾,而外部事務可以繼續進行物理事務,儘管已經回滾了一些操作。這通常對映到JDBC儲存點上,因此只適用於JDBC資源事務(Spring的DataSourceTransactionManager)。
在巢狀事務中,整體是一個邏輯事務,透過savepoint在jdbc物理層面把呼叫方法分割成一個個的物理事務。
因為spring層面只有一個邏輯事務,所以透過斷點只執行了一次doBegin()和doCommit(),但實際上執行了兩次preCommit(),如果有savepoint那就不執行commit(),
這也能回答上面2,3兩點問題的疑問。
所以上面方法A呼叫方法B進行巢狀事務,右(下)圖比左(上)圖更形象準確:
1.2 savepoint
savepoint是JDBC的一種機制,spring運用savepoint來實現了巢狀事務。
在資料庫操作中,預設autocommit為true,意味著一條SQL一個事務。也可以將autocommit設定為false,將多條SQL組成一個事務,一起commit或者rollback。
以上都是常規操作,在一個事務中所以資料庫操作全部捆綁在一起。在某些特定情況下,在一個事務中,使用者只希望rollback其中某部份,這時候可以用到savepoint。
記我們忘掉@Transactional
,以程式設計式事務的方式來手動設定一個savepoint。
方法A,寫入一條使用者記錄,並設定一個檢查點。
@Autowired
private PlatformTransactionManager platformTransactionManager;
public void serviceA(){
TransactionStatus status = platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
Object savePoint = null;
try {
Person person = new Person();
person.setName("張三");
personDao.insertSelective(person);
transactionInfo();
// 設定一個savepoint
savePoint = status.createSavepoint();
test2.serviceB();
} catch (Exception exception) {
exception.printStackTrace();
// 這裡輸出兩次commit,到rollback到51行,會插入一條資料
status.rollbackToSavepoint(savePoint);
// 這裡會兩次rollback
// platformTransactionManager.rollback(status);
}
platformTransactionManager.commit(status);
}
方法B寫入一條日誌記錄。並在此模擬一個異常。
public void serviceB() {
TLog tLog = new TLog();
tLog.setOprate("user");
transactionInfo();
tLogDao.insertSelective(tLog);
int a = 1 / 0;
}
測試希望達到的效果是,日誌寫入失敗,但使用者記錄寫入成功。很明顯,如果不使用savepoint是達不到的。因為兩個方法是一個事務,在方法B中報錯了,丟擲異常,使用者和日誌的資料庫操作都將回滾。
測試輸出日誌:
[2023-04-24 14:40:18.740] INFO 88384 [main] [com.nyp.test.service.propagation.Test1] : transactionName:null, active:true
[2023-04-24 14:40:18.742] INFO 88384 [main] [com.nyp.test.service.propagation.Test2] : transactionName:null, active:true
java.lang.ArithmeticException: / by zero
......省略
[2023-04-24 14:40:18.747] INFO 88384 [main] [com.nyp.test.service.propagation.Test1] : transaction :null commit
[2023-04-24 14:40:18.747] INFO 88384 [main] [com.nyp.test.service.propagation.Test2] : transaction :null commit
資料庫也表明使用者寫入成功,日誌寫入失敗。
2.一開始的問題,B先回滾A再正常提交?
本文開始的問題是方法A事務傳播為PROPAGATION_REQUIRED
,方法B事務傳播為PROPAGATION_NESTED
。方法A呼叫B,methodA正常,methodB拋異常。
這種情況下會發生什麼?
B先回滾,A再正常提交
這種說法為什麼會有問題,有什麼問題?
2.1 先B後A的順序有問題嗎?
透過前面事務同步器列印的日誌我們得知,事務以test1.serviceA()執行doBegin(),test2.serviceB()執行doBegin(),test1.serviceA()執行doCommit(),test2.serviceB()執行doCommit()
這樣的順序執行。
但是果真如此嗎?
透過原始碼我們首先得知,preCommit()在commit()方法之前,在preCommit()會做savepoint的判斷,如果有檢查點就不執行commit()。
- 同時方法B只是一個savepoint不是一個真正的事務,並不會執行事務同步器。
- 方法A是一個真正的事務,所以會執行commit(),同時也會執行上面的事務同步器。
這裡的事務同步器是一個Arraylist,它的執行順序即是arraylist的遍歷順序,僅僅只代表加入的先後,並不代表事務真正commit/rollback的順序。
從1,2兩點可以得出結論,先B後A的順序並沒有問題。
同時,根據1,在巢狀事務中使用事務同步器要特別小心,在檢查點的時候並不會執行同步器,同時會掩蓋真正的操作。
比如方法B回滾了,但因為方法B只是個savepoint,所以事務同步器不會執行。等到方法A執行完操作事務同步器的時候,也只會反應外層事務即方法A的事務結果。
2.2 真正的問題
如果B回滾,A是commit還是rollback取決於方法A是否繼續把異常往上拋。
讓我們先暫時忘掉巢狀事務,測試一個REQUIRES_NEW的案例。
同樣的方法A事務傳播為REQUIRES
,方法B為REQUIRES_NEW
。
此時方法A和方法B為兩個彼此獨立的事務。
方法A呼叫方法B,方法B丟擲異常。
此時,方法B肯定會回滾,但方法A呢?按理說彼此獨立,那肯定是commit了。
但真的如此嗎?
(1). 方法A不做異常處理。
測試結果:
可以看到確實是兩個事務,但兩個事務都rollback了。因為方法A雖然沒有報異常,但它接到了方法B的異常且往上拋了,spring只會認為方法A同樣也丟擲了異常。因此兩個事務都需要回滾。
(2).方法A處理了異常。
將方法A程式碼try-catch住,再執行。
日誌有點多不做截圖,
[2023-04-24 16:10:30.669] INFO 96664 [main] [com.nyp.test.service.propagation.Test1] : transactionName:com.nyp.test.service.propagation.Test1.serviceA, active:true
[2023-04-24 16:10:30.672] INFO 96664 [main] [com.nyp.test.service.propagation.Test2] : transactionName:com.nyp.test.service.propagation.Test2.serviceB, active:true
[2023-04-24 16:10:30.687] INFO 96664 [main] [com.nyp.test.service.propagation.Test2] : transaction :com.nyp.test.service.propagation.Test2.serviceB rollback
java.lang.ArithmeticException: / by zero
省略
[2023-04-24 16:10:30.689] INFO 96664 [main] [com.nyp.test.service.propagation.Test1] : transaction :com.nyp.test.service.propagation.Test1.serviceA commit
可以看到兩個單獨的事務,事務B回滾了,事務A提交了。
雖然我們這小節說的是REQUIRES_NEW
,但巢狀事務是一樣的道理。
如果B回滾,當方法A繼續往上拋異常,則A回滾;當方法A處理了異常不往上拋,則A提交。
3. 場景
在2.2小節中,我們舉了REQUIRES_NEW
的例子來說明,有的同學可能就會有點疑問了。既然事務B回滾了,事務A都要根據情況來判斷是否回滾,那這樣巢狀事務跟REQUIRES_NEW
有啥區別?
還是拿註冊的場景來說。往資料庫寫1條使用者記錄,再寫1條註冊成功操作日誌。
- 如果日誌寫入失敗,使用者寫入不受影響。這種情況下,
REQUIRES_NEW
和巢狀事務都能實現。而且很明顯REQUIRES_NEW
還沒那麼彎彎繞繞。
2.考慮另外一種情況,如果使用者寫入失敗了,那這時候我想要日誌寫入也失敗。因為使用者都沒了,就不存在註冊操作成功的操作日誌了。
這種場景,在方法B為REQUIRES_NEW
模式下,列印輸出
可以看到方法B提交了,也就是說使用者註冊失敗了,但使用者註冊成功的操作日誌卻寫入成功了。
我們再來看看巢狀事務的情況下:
方法A傳播級別為REQUIRED,並模擬一個異常。
@Transactional(propagation = Propagation.REQUIRED)
public void serviceA(){
Person person = new Person();
person.setName("李四");
personDao.insertSelective(person);
transactionInfo();
test2.serviceB();
int a = 1 / 0;
}
方法B事務傳播級別為NESTED。
@Transactional(propagation = Propagation.NESTED)
public void serviceB() {
TLog tLog = new TLog();
tLog.setOprate("user");
transactionInfo();
tLogDao.insertSelective(tLog);
}
執行日誌
可以看到同一個邏輯事務下的兩段物理事務都回滾了,達到了我們預期的效果。
4.小結
1.方法A事務傳播為REQUIRED,方法B事務傳播為NESTED。方法A呼叫方法B,當B丟擲異常時,
如果A處理了異常,此時事務A提交。否則,事務A回滾。
2.REQUIRED_NEW和NESTED在有些場景下可以實現相同的功能,但在某些特定場景下只能NESTED實現。
3.NESTED底層邏輯是JDBC的savepoint。父事務類似於一個邏輯事務,savepoint將各方法分割了若干物理事務。
4.在巢狀事務中使用事務同步器時需要特別小心。
看到這裡點個讚唄`