Spring Boot 05:資料寫進去了,為什麼還會回滾?
看到 INSERT 執行過,不代表資料最後一定留下來。這一課讓第一筆工單先寫入,再讓第二筆失敗,最後從交易外重新查資料。要觀察的是提交與回滾,不能只看方法有沒有被呼叫。
從應用到資料庫,中間有哪些物件
TicketService → TicketStore → JdbcClient
→ DataSource / connection pool → H2
Transaction proxy: begin → service work → commit / rollbackDataSource 提供取得連線的介面,連線池管理可重用的連線。JdbcClient 讓程式使用參數化 SQL 與結果映射。TicketStore 把本例的 SQL 集中在一處。H2 是這次使用的資料庫;Spring Boot 不等於資料庫,也不會讓不同資料庫的 SQL 自動相容。
// TicketStore.java
package com.keeponfirst.bootlab;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public class TicketStore {
private final JdbcClient jdbc;
public TicketStore(JdbcClient jdbc) { this.jdbc = jdbc; }
public void insert(Ticket ticket) {
jdbc.sql("INSERT INTO tickets (id, title) VALUES (:id, :title)")
.param("id", ticket.id()).param("title", ticket.title()).update();
}
public Optional<Ticket> find(String id) {
return jdbc.sql("SELECT id, title FROM tickets WHERE id = :id")
.param("id", id)
.query((rs, row) -> new Ticket(rs.getString("id"), rs.getString("title")))
.optional();
}
}insert 用 :id、:title 綁定參數,避免把輸入直接串接成 SQL。find 明確列出欄位並回傳 Optional,找不到就交給 Service 決定業務結果。這裡沒有使用 JPA entity、Session 或 lazy loading;把 JDBC 範例跑通,不能當成 Hibernate 4 → 6 的遷移證據。
交易邊界包住整個批次
// TicketService.java
package com.keeponfirst.bootlab;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TicketService {
private final TicketStore store;
private final LabProperties properties;
public TicketService(TicketStore store, LabProperties properties) {
this.store = store;
this.properties = properties;
}
public Ticket find(String id) {
return store.find(id).orElseThrow(MissingTicket::new);
}
@Transactional
public List<Ticket> createBatch(List<String> titles) {
var created = new ArrayList<Ticket>();
for (String raw : titles) {
String title = raw == null ? "" : raw.trim();
if (title.isBlank() || title.length() > properties.maxTitleLength()) {
throw new BadTitle();
}
var ticket = new Ticket(UUID.randomUUID().toString(), title);
store.insert(ticket);
created.add(ticket);
}
return List.copyOf(created);
}
}createBatch 是一個容器管理的 public 方法。外部呼叫經過 Spring 交易代理進入,整個迴圈屬於同一次交易。第一個 title 合法就寫入;第二個若空白,會拋出 BadTitle。它繼承 RuntimeException,在本例使用的預設回滾規則下會讓已加入該交易的資料庫操作回滾。
逐項檢查的順序是這個實驗的重要條件:要先發生一次寫入,再觀察後續失敗。正式批次也可能先驗完整份輸入,避免做到一半才失敗;那是不同的業務設計,不能用「根本沒寫過資料」的測試假裝已驗證回滾。
常見誤解:同一個類別內呼叫就一定有新交易嗎?
在預設 proxy 模式下,this.createBatch(...) 這種同物件內部呼叫不會重新經過代理;如果原本沒有交易,就不能只靠被呼叫方法上的 annotation 期待它開始。自己 new TicketService 也會繞過代理。需要不同交易邊界時,先釐清呼叫與管理方式,再考慮拆成另一個 Bean 或使用程式式交易。
另一個陷阱是 catch 後吞掉錯誤,讓方法看起來正常結束。預設規則通常對 RuntimeException 與 Error 回滾,checked exception 要核對 rollback 設定;實際專案也可能改過全域預設。不要把所有例外都寫成「Spring 一定會 rollback」。
動手:從交易外確認最後結果
@Test void aFailureAfterTheFirstInsertRollsBackEverything() {
assertThatThrownBy(() -> service.createBatch(List.of("first", " ")))
.isInstanceOf(BadTitle.class);
assertThat(count()).isZero();
}
@Test void successfulBatchReallyCommits() {
service.createBatch(List.of("first", "second"));
assertThat(count()).isEqualTo(2);
}這是下載檔中測試方法的擷取,需搭配同檔的注入、清理與 count helper。測試本身刻意沒有 @Transactional,讓 Service 自己負責交易;每個案例前會清空獨立的 H2 測試表。執行 mvn test,第一個案例應留下 0 筆,第二個留下 2 筆。
答案:0 與 2;然後問哪些資源在同一筆交易
在可還原副本移除 Service 的 @Transactional,再跑測試;預期回滾案例失敗,因為第一筆可能已經提交。恢復後重跑。這是檢查測試能否抓到交易語意改變,不是要把 annotation 刪掉當修正。
這份證據只涵蓋單一 H2 資料來源。若流程還送 MQ、呼叫遠端 API 或寫檔,它們不會因為同一個 Java 方法上有 annotation 就自動一起回滾。到 EAP/MDB 階段,必須重新核對 JTA、XA、RAR、實際 DataSource 與資源參與情況。
下載本系列完整工單 API 範例(ZIP)下一課:06:測試、除錯與部署邊界