在 Spring 中處理 UnexpectedRollbackException
1.概述
處理嵌套事務時,可能會出現一些與嵌套本身相關的特定問題。具體來說,一個常見問題通常會導致UnexpectedRollbackException
異常。當事務中的某個操作失敗,而我們嘗試在同一事務中執行另一個資料庫操作時,就會發生這種情況。在這種情況下,我們通常會看到一條相當令人困惑的錯誤訊息: Transaction rolled back because it has been marked as rollback-only
。
在本教程中,我們將了解為什麼即使捕獲了異常,也會出現UnexpectedRollbackException
異常。此外,我們將探討如何透過建立單獨的交易邊界來修復或規避該異常。具體來說,我們可以透過使用不同的傳播層級或使用TransactionTemplate
以程式方式管理事務來實現這一點。
2. 理解問題
對於這裡的例子,我們想像我們想要建立一個類似 Baeldung 的部落格網站的後端。
重點放在一個用例:我們嘗試透過將文章儲存到資料庫來發布文章。無論保存操作成功或失敗,我們都希望在審計表中記錄相應的條目。
2.1. 重現問題
讓我們從**Blog
類別開始,該類別將Article
實例保存到資料庫中,並寫入有關結果的Audit
記錄**:
@Component
class Blog {
private final ArticleRepo articleRepo;
private final AuditRepo auditRepo;
// constructor
@Transactional
public Optional<Long> publishArticle(Article article) {
try {
article = articleRepo.save(article);
auditRepo.save(
new Audit("SAVE_ARTICLE", "SUCCESS", "saved: " + article.getTitle()));
return Optional.of(article.getId());
} catch (Exception e) {
String errMsg = "failed to save: %s, err: %s".formatted(article.getTitle(), e.getMessage());
auditRepo.save(
new Audit("SAVE_ARTICLE", "FAILURE", errMsg));
return Optional.empty();
}
}
}
乍一看,這似乎沒什麼問題——如果保存文章失敗,我們會捕獲異常並插入一條審計條目來表明失敗。然而,嘗試發布無效的文章會拋出UnexpectedRollbackException
異常,並顯示錯誤訊息“ Article
Transaction rolled back because it has been marked as rollback-only
。
2.2. 測試
讓我們寫一個整合測試來確認這個行為。例如,我們可以嘗試發布一篇作者為null
文章:
@SpringBootTest
class ArticleServiceIntegrationTest {
@Autowired
private Blog articleService;
@Autowired
private ArticleRepo articleRepo;
@Autowired
private AuditRepo auditRepo;
@BeforeEach
void afterEach() {
articleRepo.deleteAll();
auditRepo.deleteAll();
}
@Test
void whenPublishingAnInvalidArticle_thenThrowsUnexpectedRollbackException() {
assertThatThrownBy(
() -> articleService.publishArticle(new Article("Test Article", null)))
.isInstanceOf(UnexpectedRollbackException.class)
.hasMessageContaining("marked as rollback-only");
assertThat(auditRepo.findAll())
.isEmpty();
}
}
可以看到,由於缺少作者訊息,導致Article
無效,因此拋出了異常並回滾了事務。結果,不僅插入article
表的操作失敗, audit
記錄也未保存。
2.3. 僅回滾事務
原因在於 Spring 的事務管理方式。當我們使用@Transactional
時,Spring 會為該方法啟動一個事務。如果articleRepo.save()
拋出異常,Spring 會將目前交易標記為rollback-only
) 。
當我們嘗試在try-catch
區塊中持久化Audit
條目時,它仍然在同一個事務中運行。在方法的末尾, Spring嘗試提交,偵測到交易已被標記為回滾,於是拋出了UnexpectedRollbackException
。
3. 透過 AOP 使用嵌套事務
為了解決當前的問題,我們可能需要在單獨的事務中執行Audit
插入。一種方法是使用 Spring @Transactional
註解,並設定不同的傳播設定。
3.1. 傳播類型
具體來說,我們希望Audit
操作在其自己的事務中運作。由於Spring 使用 AOP 代理來處理@Transactional
,因此此解決方案需要一個單獨的代理。實現所需結果最簡單的方法是提取一個專用於與Audit
資料互動的新類別:
@Service
class AuditService {
private final AuditRepo auditRepo;
// constructor
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit(String action, String status, String message) {
auditRepo.save(new Audit(action, status, message));
}
}
我們可以看到, saveAudit()
也是@Transactional
。預設情況下,如果在另一個事務方法中呼叫它,它將參與現有事務。但是,我們使用Propagation.REQUIRES_NEW
覆寫了預設行為,因此 Spring 總是會建立一個新的事務來保存Audit
實體。
現在,我們可以更新主服務來呼叫此方法。讓我們建立一個新方法publishArticle_v2()
,以便我們可以輕鬆地比較這兩種方法:
@Transactional
public Optional<Long> publishArticle_v2(Article article) {
try {
article = articleRepo.save(article);
auditService.saveAudit("SAVE_ARTICLE", "SUCCESS", "saved: " + article.getTitle());
return Optional.of(article.getId());
} catch (Exception e) {
auditService.saveAudit("SAVE_ARTICLE", "FAILURE", "failed to save: " + article.getTitle());
return Optional.empty();
}
}
因此,我們可以使用 AOP 和@Transactional
註解來定義事務範圍,但這需要增加額外的抽象層,以便 Spring 能夠建立必要的 AOP 代理程式。在這個例子中,我們必須引入一個AuditService,
其唯一目的是委託給AuditRepo
並覆蓋事務傳播層級以啟動新的事務。
3.2. 嵌套事務
我們在這裡所做的實際上是在保持初始事務不變的情況下創建一個新的嵌套事務。這意味著Audit
操作在其自己的獨立事務中運行,因此即使主事務最終失敗,它也可以成功提交:
讓我們寫一個簡單的測試來驗證這種新方法是否仍會因為Article
無效而引發異常,但會成功地將失敗操作的記錄插入audit
表:
@Test
void whenPublishingAnInvalidArticle_thenSavesFailureToAudit() {
assertThatThrownBy(
() -> articleService.publishArticle_v2(new Article("Test Article", null)))
.isInstanceOf(Exception.class);
assertThat(auditRepo.findAll())
.extracting("description")
.containsExactly("failed to save: Test Article");
}
如預期的那樣,主事務可以自由回滾並拋出 Java 異常,而失敗仍然記錄在audit
表中。
4. 透過TransactionTemplate
使用順序事務
除了使用巢狀事務之外,我們還可以確保在啟動第二個事務之前提交或回滾第一個事務:
由於使用 Spring AOP 定義精確的事務範圍可能比較棘手,因此我們這次使用TransactionTemplate
bean。
TransactionTemplate
使我們能夠以程式設計方式定義事務邊界,從而更精細地控制事務的開始和結束時間。例如,我們可以使用它來啟動一個用於保存Article
事務,並在發生故障時確保在將其記錄到審計表之前將其關閉。這樣,JPA 就可以為審計操作啟動一個新的事務:
@Component
class Blog {
private final ArticleRepo articleRepo;
private final AuditRepo auditRepo;
private final TransactionTemplate transactionTemplate;
// constructor
public Optional publishArticle_v3(final Article article) {
try {
Article savedArticle = transactionTemplate.execute(txStatus -> {
Article saved = articleRepo.save(article);
auditRepo.save(
new Audit("SAVE_ARTICLE", "SUCCESS", "saved: " + article.getTitle()));
return saved;
}); // <-- transaction ends here
return Optional.of(savedArticle.getId());
} catch (Exception e) {
auditRepo.save(
new Audit("SAVE_ARTICLE", "FAILURE", "failed to save: " + article.getTitle()));
return Optional.empty();
}
}
}
如果我們測試這個解決方案,我們可能會期望它能夠優雅地處理錯誤並傳回一個空的Optional
。不用說,它還應該在審計表中記錄失敗:
@Test
void whenPublishingAnInvalidArticle_thenRecoverFromError_andSavesFailureToAudit() {
Optional<Long> id = articleService.publishArticle_v3(new Article("Test Article", null));
assertThat(id).isEmpty();
assertThat(auditRepo.findAll())
.extracting("description")
.containsExactly("failed to save: Test Article");
}
至此,我們確保交易的安全處理。
5. 結論
在本文中,我們探討了 Spring 中嵌套事務的一個常見陷阱,該陷阱可能導致UnexpectedRollbackException
。具體來說,我們研究了為什麼僅僅在事務方法內部捕獲異常是不夠的,以及 Spring 如何將事務標記為rollback-only
。
之後,我們介紹了兩個實用的解決方案:
- 使用具有不同傳播方式的單獨事務邊界
- 使用
TransactionTemplate
以程式設計方式管理事務
即使主要操作失敗,這兩種方法都會保存關鍵的Audit
記錄。
與往常一樣,本文中的程式碼可在 GitHub 上取得。