在先前的單元中,我們已經學會了如何定義實體 (Entity) 和資料倉儲 (Repository)。然而,在真實世界的應用程式中,單純的新增或查詢是不夠的。我們經常需要執行一系列的資料庫操作,並確保這些操作要嘛「全部成功」,要嘛「全部失敗」,這就是交易 (Transaction) 的核心概念。
今日將帶您深入 Spring 最強大的功能之一:宣告式交易管理 (@Transactional
)。我們將從它的運作原理開始,逐步探索交易的傳播行為與隔離級別,並透過實際的專案範例,學習如何確保資料在複雜操作下的一致性。
完成今日學習後,你將能夠:
@Transactional
的運作原理:明白 Spring 如何透過代理 (Proxy) 機制,神奇地為您管理交易的開始 (Begin)、提交 (Commit) 與回滾 (Rollback)。REQUIRED
與 REQUIRES_NEW
的差異,知道在複雜的服務呼叫中如何控制交易邊界。READ COMMITTED
、REPEATABLE READ
與 SERIALIZABLE
如何在多使用者環境下保護您的資料不受干擾。@Transactional
的運作原理:幕後的魔法師當我們在一個方法上加上 @Transactional
註解 (Annotation) 時,Spring 並不是直接執行這個方法。相反地,它會利用面向切面程式設計 (Aspect-Oriented Programming, AOP) 的概念,為這個物件建立一個代理 (Proxy)。
您可以把這個代理想像成一位盡責的保全人員:
@Transactional
方法時,這個呼叫會先被代理攔截。代理會檢查當前是否有正在進行的交易,並根據您的設定(例如傳播行為)決定是建立一個新交易,還是加入現有的交易。// 代理物件的偽代碼
function transactionalMethodProxy(...args) {
// 1. 檢查並開啟交易
TransactionManager.beginTransaction();
try {
// 2. 呼叫你真正的業務邏輯
const result = originalMethod(...args);
// 3. 成功,提交交易
TransactionManager.commit();
return result;
} catch (error) {
// 4. 失敗,回滾交易
TransactionManager.rollback();
throw error;
}
}
⚠️ 重要觀念:
@Transactional
只對從外部呼叫的方法生效。如果一個類別內部的 a 方法呼叫了同一個類別的 b 方法(b 有 @Transactional
),那麼 b 方法的交易註解 (Annotation) 將會失效。@Transactional
註解 (Annotation) 只能應用在 public
方法上。如果用在 private
或 protected
方法上,Spring 會默默地忽略它,不會報錯也不會生效。交易傳播行為定義了當一個已經有交易的方法,去呼叫另一個也有交易的方法時,交易應該如何運作。
這是最常見也是預設的傳播行為。它的規則是:「如果當前已經存在一個交易,就加入這個交易;如果沒有,就建立一個新的交易。」
Service A
的 methodA()
呼叫 Service B
的 methodB()
,兩者都有 @Transactional(propagation = Propagation.REQUIRED)
。methodB()
會加入 methodA()
建立的交易中。它們是生命共同體,只要任何一方失敗,整個交易(包含 A 和 B 的操作)都會一起回滾 (Rollback)。這個行為比較強勢,它的規則是:「我不管現在有沒有交易,我就是要建立一個全新的、獨立的交易。如果當前有交易,就先把它掛起。」
Service A
的 methodA()
呼叫 Service B
的 methodB()
,methodB()
設定了 @Transactional(propagation = Propagation.REQUIRES_NEW)
。methodB()
時,methodA()
的交易會被暫停。Spring 會為 methodB()
開啟一個全新的交易。
methodB()
成功,它的交易會獨立提交。methodB()
失敗,只有 methodB()
的交易會回滾 (Rollback)。methodA()
的交易不受影響(除非 methodA()
沒有處理 methodB()
拋出的例外,導致自己也失敗)。methodB()
結束後,methodA()
被掛起的交易會恢復執行。傳播行為 | 當前有交易 | 當前無交易 | 說明 |
---|---|---|---|
REQUIRED |
加入現有交易 | 建立新交易 | (預設) 大家在同一條船上,一榮俱榮,一損俱損。 |
REQUIRES_NEW |
掛起現有交易,建立新交易 | 建立新交易 | 我是獨立的,我的成敗與他人無關。 |
在多使用者同時存取資料庫的環境下,為了避免資料錯亂,資料庫定義了不同的隔離級別來規範一個交易中的操作對其他並行交易的可見度。不適當的隔離級別可能導致以下問題:
SELECT * FROM users WHERE age > 20
),第二次查詢回傳了第一次查詢中沒有的「幻影」紀錄,因為另一個交易在此期間插入了新的資料並已提交。PostgreSQL 支援以下幾種隔離級別,Spring 可以透過 @Transactional(isolation = ...)
來設定。
隔離級別 | 髒讀 | 不可重複讀 | 幻讀 | 說明 (PostgreSQL) |
---|---|---|---|---|
READ_UNCOMMITTED |
允許 | 允許 | 允許 | PostgreSQL 不支援,會自動提升為 READ_COMMITTED 。 |
READ_COMMITTED |
防止 | 允許 | 允許 | (PostgreSQL 預設) 只能讀取到已經提交的資料,避免了髒讀。 |
REPEATABLE_READ |
防止 | 防止 | 允許 | 確保在交易期間重複讀取同一筆資料時,結果總是一致的。 |
SERIALIZABLE |
防止 | 防止 | 防止 | 最高級別,完全模擬循序執行,效能最低,但資料最安全。 |
💡 經驗法則:在大部分的 Web 應用中,使用資料庫預設的
READ_COMMITTED
級別已經足夠。只有在對資料一致性有極高要求的特定業務場景(例如金融交易、庫存扣減)時,才需要考慮提升隔離級別。
在開始實作交易管理功能之前,我們需要先準備好相關的實體 (Entity) 與資料倉儲 (Repository)。
我們需要三個實體:Customer
、AllowedDomain
(客戶允許的網域) 和 AuditLog
(操作日誌)。
1. AllowedDomain
實體
請注意 domainName
欄位上的 @Column(unique = true)
,這是我們稍後用來模擬交易失敗的關鍵。
package com.example.demo.entities;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "allowed_domains")
public class AllowedDomain {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(name = "domain_name", unique = true, nullable = false)
private String domainName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// Getters and Setters...
}
2. AuditLog
實體
這是一個簡單的實體,用來記錄我們的操作日誌。
package com.example.demo.entities;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String message;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
public AuditLog() {
this.createdAt = LocalDateTime.now();
}
public AuditLog(String message) {
this();
this.message = message;
}
// Getters and Setters...
}
3. Customer
實體
確保 Customer
實體中包含了與 AllowedDomain
的關聯,並設定 cascade = CascadeType.ALL
,這樣儲存 Customer
時,JPA 就會自動儲存其關聯的 AllowedDomain
。同時,我們也加入一個方便的輔助方法 addDomain
。
package com.example.demo.entities;
// ... imports ...
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "customers")
public class Customer {
// ... id, name, email, status 等欄位 ...
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<AllowedDomain> allowedDomains = new HashSet<>();
/**
* 輔助方法,方便地建立 Customer 和 AllowedDomain 之間的雙向關聯。
*/
public void addDomain(AllowedDomain domain) {
allowedDomains.add(domain);
domain.setCustomer(this);
}
// Getters and Setters...
}
接下來,為每個實體建立對應的 Spring Data JPA Repository 介面。
CustomerRepository.java
package com.example.demo.repositories;
import com.example.demo.entities.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface CustomerRepository extends JpaRepository<Customer, UUID> {}
AllowedDomainRepository.java
package com.example.demo.repositories;
import com.example.demo.entities.AllowedDomain;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AllowedDomainRepository extends JpaRepository<AllowedDomain, UUID> {}
AuditLogRepository.java
package com.example.demo.repositories;
import com.example.demo.entities.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {}
現在,我們將這些理論應用到 Customer
專案中。
我們需要一個服務,能夠同時建立 Customer
和他所屬的 AllowedDomain
。這兩個操作必須是原子 (Atomic) 的,也就是要嘛一起成功,要嘛一起失敗。
第一步:建立 CustomerRegistrationService
package com.example.demo.services;
import com.example.demo.entities.Customer;
import com.example.demo.entities.AllowedDomain;
import com.example.demo.enums.CustomerStatus;
import com.example.demo.repositories.CustomerRepository;
import com.example.demo.repositories.AllowedDomainRepository; // 假設你已建立
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
@Service
public class CustomerRegistrationService {
private final CustomerRepository customerRepository;
private final AllowedDomainRepository allowedDomainRepository;
public CustomerRegistrationService(CustomerRepository customerRepository, AllowedDomainRepository allowedDomainRepository) {
this.customerRepository = customerRepository;
this.allowedDomainRepository = allowedDomainRepository;
}
/**
* 正常的客戶註冊服務。
* 因為有 @Transactional,內部的所有資料庫操作都會在同一個交易中。
*/
@Transactional
public Customer registerCustomer(String name, String email, Set<String> domains) {
// 1. 建立 Customer 物件
Customer customer = new Customer();
customer.setName(name);
customer.setEmail(email);
customer.setStatus(CustomerStatus.ACTIVE);
// 2. 建立 AllowedDomain 物件並關聯
domains.forEach(domainName -> {
AllowedDomain domain = new AllowedDomain();
domain.setDomainName(domainName);
customer.addDomain(domain); // 使用我們在 Customer 中建立的輔助方法
});
// 3. 儲存 Customer
// 由於 Customer 中設定了 cascade = CascadeType.ALL,
// JPA 會自動一併儲存關聯的 AllowedDomain
return customerRepository.save(customer);
}
/**
* 模擬註冊失敗的情境。
* 我們會故意儲存一個重複的 domain,這將觸發資料庫的唯一約束錯誤。
*/
@Transactional
public void registerCustomerWithFailure(String name, String email, String domainName) {
// 先在資料庫中建立一個客戶和其 domain,以確保該 domain 存在
Customer initialCustomer = new Customer();
initialCustomer.setName("Initial Corp");
initialCustomer.setEmail("initial@corp.com");
initialCustomer.setStatus(CustomerStatus.ACTIVE);
initialCustomer.addDomain(new AllowedDomain(domainName));
customerRepository.saveAndFlush(initialCustomer); // 使用 saveAndFlush 確保立即寫入
// 現在開始我們真正的交易,並嘗試註冊一個包含重複 domain 的新客戶
System.out.println("--- 開始註冊失敗的交易 ---");
Customer customer = new Customer();
customer.setName(name);
customer.setEmail(email);
customer.setStatus(CustomerStatus.ACTIVE);
AllowedDomain duplicateDomain = new AllowedDomain();
duplicateDomain.setDomainName(domainName); // 這是將會導致失敗的重複 domain
customer.addDomain(duplicateDomain);
// 這一步會拋出 DataIntegrityViolationException,因為 domainName 不是唯一的
// 由於例外發生,整個交易會回滾。
// 也就是說,這個新的 customer 也不會被存入資料庫。
customerRepository.save(customer);
System.out.println("--- 註冊失敗的交易結束 ---");
}
}
測試與驗證:
呼叫 registerCustomer("Gogoro", "contact@gogoro.com", Set.of("gogoro.com", "gogoro.tw"))
。
customers
和 allowed_domains
資料表中看到成功新增的資料。呼叫 registerCustomerWithFailure("Test Corp", "contact@test.com", "gogoro.com")
。
DataIntegrityViolationException
。檢查資料庫,你會發現名為 "Test Corp" 的客戶完全沒有被建立,完美地展示了交易的回滾能力。為了演示 REQUIRED
和 REQUIRES_NEW
的區別,我們需要建立兩個服務:一個主服務和一個日誌服務。
第一步:建立 AuditLogService
這個服務負責記錄操作日誌,我們將讓它的交易行為獨立。
package com.example.demo.services;
import com.example.demo.entities.AuditLog; // 假設你已建立一個簡單的 AuditLog Entity
import com.example.demo.repositories.AuditLogRepository; // 假設你已建立
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditLogService {
private final AuditLogRepository auditLogRepository;
public AuditLogService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
/**
* 使用 REQUIRES_NEW 傳播。
* 無論外部是否有交易,這個方法都會在一個全新的、獨立的交易中執行。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String message) {
auditLogRepository.save(new AuditLog(message));
System.out.println("日誌已記錄 (獨立交易): " + message);
}
/**
* 使用 REQUIRES_NEW,但會失敗。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logWithFailure(String message) {
auditLogRepository.save(new AuditLog(message));
System.out.println("日誌已記錄 (獨立交易),但即將拋出例外...");
throw new RuntimeException("日誌服務內部錯誤!");
}
}
第二步:修改 CustomerService
來呼叫日誌服務
我們需要修改 CustomerService
,注入 AuditLogService
並加入新的方法來展示交易傳播的各種情境。
CustomerService.java
package com.example.demo.services;
import com.example.demo.entities.Customer;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final AuditLogService auditLogService;
public CustomerService(CustomerRepository customerRepository, AuditLogService auditLogService) {
this.customerRepository = customerRepository;
this.auditLogService = auditLogService;
}
/**
* 案例A:主交易成功,獨立的日誌交易也成功。
*/
@Transactional
public Customer updateNameAndLog(UUID customerId, String newName) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setName(newName);
// 呼叫日誌服務。因為日誌服務是 REQUIRES_NEW,它會在自己的交易中執行。
auditLogService.log("客戶 " + customerId + " 名稱已更新為 " + newName);
return customerRepository.save(customer);
}
/**
* 案例B:主交易失敗,但獨立的日誌交易成功提交。
*/
@Transactional
public void updateNameAndLogButMainFails(UUID customerId, String newName) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setName(newName);
customerRepository.save(customer);
// 呼叫日誌服務,日誌會被成功記錄並提交。
auditLogService.log("準備更新客戶 " + customerId + " 名稱為 " + newName);
// 模擬主交易失敗
throw new RuntimeException("主服務發生錯誤,客戶名稱更新將回滾!");
}
/**
* 案例C:主交易成功,但獨立的日誌交易失敗。
*/
@Transactional
public Customer updateNameButLogFails(UUID customerId, String newName) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setName(newName);
Customer savedCustomer = customerRepository.save(customer);
try {
// 呼叫失敗的日誌服務。這個日誌操作會回滾。
auditLogService.logWithFailure("嘗試記錄客戶 " + customerId + " 的更新...");
} catch (Exception e) {
// 我們必須捕捉例外,否則它會傳播出去,導致主交易也回滾!
System.err.println("日誌服務失敗,但主交易不受影響。錯誤: " + e.getMessage());
}
// 由於我們捕捉了例外,主交易會成功提交。
return savedCustomer;
}
}
測試與驗證:
案例 A:呼叫 updateNameAndLog
audit_logs
表中也多了一筆日誌。案例 B:呼叫 updateNameAndLogButMainFails
audit_logs
表中仍然多了一筆日誌(因為它的獨立交易已成功提交)。案例 C:呼叫 updateNameButLogFails
audit_logs
表中沒有新增日誌(因為它的獨立交易回滾了)。雖然 @Transactional
方便又強大,但在某些極端情況下,我們可能需要更精細的控制。例如,一個方法中包含了一個非常耗時的非資料庫操作(如呼叫外部 API),我們不希望資料庫連線在 API 呼叫期間被長時間佔用。
這時,我們可以使用 TransactionTemplate
。
建立 AdvancedCustomerService
package com.example.demo.services;
import com.example.demo.entities.Customer;
import com.example.demo.enums.CustomerStatus;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.UUID;
@Service
public class AdvancedCustomerService {
private final TransactionTemplate transactionTemplate;
private final CustomerRepository customerRepository;
public AdvancedCustomerService(PlatformTransactionManager transactionManager, CustomerRepository customerRepository) {
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.customerRepository = customerRepository;
}
public void processPayment(UUID customerId) {
// 第一步:在一個簡短的交易中更新客戶狀態為「處理中」
transactionTemplate.execute(status -> {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setStatus(CustomerStatus.INACTIVE); // 假設 INACTIVE 代表處理中
customerRepository.save(customer);
return null; // execute 方法需要一個回傳值
});
// 第二步:執行耗時的 API 呼叫,此時資料庫連線已釋放!
try {
System.out.println("正在呼叫外部支付 API,可能需要 5 秒鐘...");
Thread.sleep(5000);
System.out.println("API 呼叫成功!");
// 第三步:在另一個簡短的交易中更新最終狀態
transactionTemplate.execute(status -> {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setStatus(CustomerStatus.ACTIVE); // 回復為 ACTIVE
customerRepository.save(customer);
return null;
});
} catch (Exception e) {
// 如果 API 失敗,在另一個交易中將狀態改回
transactionTemplate.execute(status -> {
Customer customer = customerRepository.findById(customerId).orElseThrow();
customer.setStatus(CustomerStatus.ACTIVE);
// 可以在這裡加上錯誤註記
customerRepository.save(customer);
return null;
});
}
}
}
💡 何時使用?
99% 的情況下,
@Transactional
都是最佳選擇。只有當您明確需要將非交易性、耗時的操作(如網路 I/O、檔案處理)從資料庫交易中分離出來以優化資源使用時,才考慮使用TransactionTemplate
。
為了方便測試上述所有交易情境,我們可以建立一個 TransactionDemoController
。
package com.example.demo.controllers;
import com.example.demo.entities.Customer;
import com.example.demo.services.AdvancedCustomerService;
import com.example.demo.services.CustomerRegistrationService;
import com.example.demo.services.CustomerService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/demo")
public class TransactionDemoController {
private final CustomerRegistrationService registrationService;
private final CustomerService customerService;
private final AdvancedCustomerService advancedCustomerService;
public TransactionDemoController(CustomerRegistrationService registrationService, CustomerService customerService, AdvancedCustomerService advancedCustomerService) {
this.registrationService = registrationService;
this.customerService = customerService;
this.advancedCustomerService = advancedCustomerService;
}
// --- 範例 1 的測試端點 ---
@PostMapping("/register/success")
public Customer registerSuccess() {
return registrationService.registerCustomer("Gogoro", "contact@gogoro.com", Set.of("gogoro.com", "gogoro.tw"));
}
@PostMapping("/register/fail")
public ResponseEntity<String> registerFail() {
try {
// 使用一個會重複的 domain name 來觸發失敗
registrationService.registerCustomerWithFailure("Test Corp", "contact@test.com", "gogoro.com");
return ResponseEntity.ok("咦?交易竟然沒有失敗?");
} catch (Exception e) {
return ResponseEntity.status(500).body("交易如預期般失敗並回滾!錯誤:" + e.getMessage());
}
}
// --- 範例 2 的測試端點 ---
private Customer getFirstCustomer() {
// 為了方便測試,我們先建立一個客戶
return registrationService.registerCustomer("Test Customer", "test@customer.com", Set.of("customer.com"));
}
@PostMapping("/prop/case-a")
public Customer propagationCaseA() {
Customer customer = getFirstCustomer();
return customerService.updateNameAndLog(customer.getId(), "New Name A");
}
@PostMapping("/prop/case-b")
public ResponseEntity<String> propagationCaseB() {
Customer customer = getFirstCustomer();
try {
customerService.updateNameAndLogButMainFails(customer.getId(), "New Name B");
return ResponseEntity.ok("咦?主交易竟然沒有失敗?");
} catch (Exception e) {
return ResponseEntity.status(500).body("主交易如預期般失敗並回滾,但日誌已獨立提交!錯誤: " + e.getMessage());
}
}
@PostMapping("/prop/case-c")
public Customer propagationCaseC() {
Customer customer = getFirstCustomer();
return customerService.updateNameButLogFails(customer.getId(), "New Name C");
}
// --- 程式化交易的測試端點 ---
@PostMapping("/programmatic/process")
public ResponseEntity<String> programmaticProcessing() {
Customer customer = getFirstCustomer();
advancedCustomerService.processPayment(customer.getId());
return ResponseEntity.ok("已觸發程式化交易處理,請查看後端日誌。");
}
}
啟動您的 Spring Boot 應用程式後,可以使用 curl
或任何 API 工具來測試這些端點。
# 測試原子性 - 成功案例
curl -X POST http://localhost:8080/demo/register/success
# 測試原子性 - 失敗案例 (請先執行成功案例,以建立 gogoro.com)
curl -X POST http://localhost:8080/demo/register/fail
# 測試交易傳播 - 案例A (主成功,日誌成功)
curl -X POST http://localhost:8080/demo/prop/case-a
# 測試交易傳播 - 案例B (主失敗,日誌成功)
curl -X POST http://localhost:8080/demo/prop/case-b
# 測試交易傳播 - 案例C (主成功,日誌失敗)
curl -X POST http://localhost:8080/demo/prop/case-c
# 測試程式化交易
curl -X POST http://localhost:8080/demo/programmatic/process
今天我們深入了解了 Spring 交易管理的核心。您現在不僅知道如何使用 @Transactional
,更理解了它背後的代理機制,以及如何透過設定傳播行為和隔離級別來應對複雜的業務場景。透過完整的範例程式碼與測試指南,您可以親手驗證每個交易情境的結果。記住,交易是保證資料一致性的基石,熟練掌握它,將使您的應用程式更加健壯與可靠。