1. 概述
當處理嵌套事務時,可能會出現一些特定問題,這些問題與嵌套本身有關。 尤其常見的問題通常會導致 <em >UnexpectedRollbackException</em >》。 <strong >這發生在事務中的一個操作失敗,並且我們嘗試在同一事務中執行另一個數據庫操作時</strong >。 在這種情況下,我們通常會看到一個相當令人困惑的錯誤消息:Transaction rolled back because it has been marked as rollback-only>`。
在本教程中,我們將理解為什麼即使捕獲了異常,<em >UnexpectedRollbackException</em >> 仍然會發生。 此外,我們還將通過創建單獨的事務邊界來修復或規避它。 尤其是,我們可以通過使用不同的傳播級別或使用TransactionTemplate> 編程方式管理事務來實現。
2. 瞭解問題
對於這裏提供的示例,我們假設我們想要構建一個類似於 Baeldung 的博客網站後端。
重點在於一個使用場景,即通過將文章保存到數據庫來發布文章。無論保存操作是否成功,我們還希望在審計表中記錄相應的條目。
2.1. 模擬問題
讓我們從以下代碼開始,該代碼將 文章實例保存到數據庫並記錄 審計記錄,該代碼使用了 博客類:
博客類用於保存 文章實例到數據庫,並記錄關於結果的 審計記錄。
@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();
}
}
}乍一看,這似乎沒問題——如果保存文章失敗,我們捕獲異常並插入審計條目以指示失敗。但是,嘗試發佈一個無效的 Article 會拋出 UnexpectedRollbackException,錯誤消息為 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();
}
}由於文章由於缺少作者而無效,因此會拋出異常並回滾交易。 因此,不僅文章表單的插入操作失敗,而且審計記錄也未保存。
2.3. 只回滾事務
原因在於 Spring 如何管理事務。當我們使用 @Transactional 時,Spring 會為該方法啓動一個事務。如果 articleRepo.save() 拋出異常,Spring 會將當前事務標記為 只回滾。
當我們隨後嘗試在 try-catch 塊中持久化 Audit 條目,它仍然在同一事務中運行。方法結束時,Spring 嘗試提交,檢測到該事務已被標記為回滾,並拋出 UnexpectedRollbackException 異常。
3. 使用嵌套事務 via AOP
為了解決當前問題,我們可能需要在單獨的事務中執行 審計 插入操作。 通過使用 Spring 中的 @Transactional 註解並設置不同的傳播設置,可以實現這一點。
3.1. 傳播類型
具體來説,我們希望 審計操作在單獨的事務中執行。由於 Spring 使用 AOP 代理來處理 @Transactional,因此該解決方案需要一個單獨的代理。 實現所需結果的最簡單方法是提取一個專門用於與 審計數據交互的新類:
@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. 嵌套事務
我們所做的是,實際上創建了一個新的嵌套事務,同時保持了初始事務的完整性。這意味着 審計 操作將在其自身的獨立事務中運行,因此即使主事務最終失敗,也能成功提交:
下面我們編寫一個簡單的測試,以驗證這種新方法仍然由於無效的文章 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 異常,同時失敗記錄仍然存在於 審計 表中。
4. 使用順序事務,通過 <em TransactionTemplate</em>> 實現
與其使用嵌套事務,我們可以選擇在啓動第二個事務之前,確保首先提交或回滾第一個事務:
由於在 Spring AOP 中精確定義事務範圍可能比較複雜,因此我們現在使用 <em TransactionTemplate</em>> Bean。
允許我們通過編程方式定義事務邊界,從而對事務的開始和結束時間具有更精細的控制。例如,我們可以使用它來為保存 `` 啓動一個事務,並在發生失敗時,確保在將其記錄到審計表中之前關閉它。 這樣,JPA 就可以為審計操作啓動一個新的事務:
如果測試這個解決方案,我們可能會期望它能夠優雅地處理錯誤並返回一個空Optional。 Needless to say,它也應該在審計表中記錄失敗: 此時,我們確保交易的安全處理。 在本文中,我們探討了 Spring 中嵌套事務的常見問題,這可能導致 UnexpectedRollbackException。 尤其,我們研究了僅僅在事務方法中捕獲異常是否足夠,以及 Spring 如何將事務標記為 僅回滾。 隨後,我們介紹了兩個實用解決方案: 正如一貫的,本文中的代碼可在 GitHub 上找到:https://github.com/eugenp/tutorials/tree/master/persistence-modules/spring-boot-persistence-5。@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();
}
}
}@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. 結論