iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Software Development

spring boot 3 學習筆記系列 第 29

Day29- Spring Data JPA 交易管理實戰:從 @Transactional 到高併發控制

  • 分享至 

  • xImage
  •  

在先前的單元中,我們已經學會了如何定義實體 (Entity) 和資料倉儲 (Repository)。然而,在真實世界的應用程式中,單純的新增或查詢是不夠的。我們經常需要執行一系列的資料庫操作,並確保這些操作要嘛「全部成功」,要嘛「全部失敗」,這就是交易 (Transaction) 的核心概念。

今日將帶您深入 Spring 最強大的功能之一:宣告式交易管理 (@Transactional)。我們將從它的運作原理開始,逐步探索交易的傳播行為與隔離級別,並透過實際的專案範例,學習如何確保資料在複雜操作下的一致性。

學習目標

完成今日學習後,你將能夠:

  • 瞭解 Spring @Transactional 的運作原理:明白 Spring 如何透過代理 (Proxy) 機制,神奇地為您管理交易的開始 (Begin)、提交 (Commit) 與回滾 (Rollback)。
  • 熟悉交易傳播行為 (Transaction Propagation):精通 REQUIREDREQUIRES_NEW 的差異,知道在複雜的服務呼叫中如何控制交易邊界。
  • 掌握交易隔離級別 (Isolation Level):理解 READ COMMITTEDREPEATABLE READSERIALIZABLE 如何在多使用者環境下保護您的資料不受干擾。
  • 實作原子性操作:學會處理多筆寫入/更新時的「要嘛全成功、要嘛全失敗」,確保資料的完整性。
  • 了解程式化交易管理:知道在何種特殊情境下,可以選擇手動控制交易。

@Transactional 的運作原理:幕後的魔法師

當我們在一個方法上加上 @Transactional 註解 (Annotation) 時,Spring 並不是直接執行這個方法。相反地,它會利用面向切面程式設計 (Aspect-Oriented Programming, AOP) 的概念,為這個物件建立一個代理 (Proxy)

您可以把這個代理想像成一位盡責的保全人員:

  1. 方法呼叫前:當外部程式碼呼叫您的 @Transactional 方法時,這個呼叫會先被代理攔截。代理會檢查當前是否有正在進行的交易,並根據您的設定(例如傳播行為)決定是建立一個新交易,還是加入現有的交易。
  2. 執行原方法:代理接著才會呼叫您真正的業務邏輯方法。
  3. 方法成功後:如果您的方法順利執行完成且沒有拋出任何執行階段例外 (Runtime Exception),代理會在方法結束後提交 (Commit) 這次的交易,將所有變更永久寫入資料庫。
  4. 方法失敗時:如果方法執行過程中拋出了執行階段例外(預設情況下),代理會捕捉到這個例外,並回滾 (Rollback) 這次的交易,撤銷在此交易中所做的所有變更,就像什麼事都沒發生過一樣。
// 代理物件的偽代碼
function transactionalMethodProxy(...args) {
    // 1. 檢查並開啟交易
    TransactionManager.beginTransaction();
    try {
        // 2. 呼叫你真正的業務邏輯
        const result = originalMethod(...args);
        // 3. 成功,提交交易
        TransactionManager.commit();
        return result;
    } catch (error) {
        // 4. 失敗,回滾交易
        TransactionManager.rollback();
        throw error;
    }
}

⚠️ 重要觀念:

  • 僅限外部呼叫:因為 Spring 使用代理機制,@Transactional 只對從外部呼叫的方法生效。如果一個類別內部的 a 方法呼叫了同一個類別的 b 方法(b 有 @Transactional),那麼 b 方法的交易註解 (Annotation) 將會失效
  • 僅限 public 方法@Transactional 註解 (Annotation) 只能應用在 public 方法上。如果用在 privateprotected 方法上,Spring 會默默地忽略它,不會報錯也不會生效。

交易的核心要素 (1):傳播行為 (Propagation)

交易傳播行為定義了當一個已經有交易的方法,去呼叫另一個也有交易的方法時,交易應該如何運作。

REQUIRED (預設行為)

這是最常見也是預設的傳播行為。它的規則是:「如果當前已經存在一個交易,就加入這個交易;如果沒有,就建立一個新的交易。」

  • 場景Service AmethodA() 呼叫 Service BmethodB(),兩者都有 @Transactional(propagation = Propagation.REQUIRED)
  • 結果methodB() 會加入 methodA() 建立的交易中。它們是生命共同體,只要任何一方失敗,整個交易(包含 A 和 B 的操作)都會一起回滾 (Rollback)。

REQUIRES_NEW

這個行為比較強勢,它的規則是:「我不管現在有沒有交易,我就是要建立一個全新的、獨立的交易。如果當前有交易,就先把它掛起。」

  • 場景Service AmethodA() 呼叫 Service BmethodB()methodB() 設定了 @Transactional(propagation = Propagation.REQUIRES_NEW)
  • 結果:當執行到 methodB() 時,methodA() 的交易會被暫停。Spring 會為 methodB() 開啟一個全新的交易。
    • 如果 methodB() 成功,它的交易會獨立提交。
    • 如果 methodB() 失敗,只有 methodB() 的交易會回滾 (Rollback)。methodA() 的交易不受影響(除非 methodA() 沒有處理 methodB() 拋出的例外,導致自己也失敗)。
    • methodB() 結束後,methodA() 被掛起的交易會恢復執行。
傳播行為 當前有交易 當前無交易 說明
REQUIRED 加入現有交易 建立新交易 (預設) 大家在同一條船上,一榮俱榮,一損俱損。
REQUIRES_NEW 掛起現有交易,建立新交易 建立新交易 我是獨立的,我的成敗與他人無關。

交易的核心要素 (2):隔離級別 (Isolation)

在多使用者同時存取資料庫的環境下,為了避免資料錯亂,資料庫定義了不同的隔離級別來規範一個交易中的操作對其他並行交易的可見度。不適當的隔離級別可能導致以下問題:

  • 髒讀 (Dirty Read):一個交易讀取到了另一個交易尚未提交的修改。如果後者最終回滾了,前者讀到的就是無效的「髒」資料。
  • 不可重複讀 (Non-repeatable Read):在同一個交易內,兩次執行相同的查詢,但得到了不同的結果,因為在這兩次查詢之間,有另一個交易修改了這些資料並已提交
  • 幻讀 (Phantom Read):在同一個交易內,兩次執行相同的範圍查詢(例如 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)。

第一步:定義實體 (Entities)

我們需要三個實體:CustomerAllowedDomain (客戶允許的網域) 和 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...
}

第二步:建立資料倉儲 (Repositories)

接下來,為每個實體建立對應的 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 專案中。

範例 1:註冊客戶服務(原子性操作)

我們需要一個服務,能夠同時建立 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("--- 註冊失敗的交易結束 ---");
    }
}

測試與驗證:

  1. 呼叫 registerCustomer("Gogoro", "contact@gogoro.com", Set.of("gogoro.com", "gogoro.tw"))

    • 結果:你會在 customersallowed_domains 資料表中看到成功新增的資料。
  2. 呼叫 registerCustomerWithFailure("Test Corp", "contact@test.com", "gogoro.com")

    • 結果:應用程式會拋出 DataIntegrityViolationException。檢查資料庫,你會發現名為 "Test Corp" 的客戶完全沒有被建立,完美地展示了交易的回滾能力。

範例 2:模擬交易傳播行為 (Propagation)

為了演示 REQUIREDREQUIRES_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 表中沒有新增日誌(因為它的獨立交易回滾了)。

程式化交易管理 (Programmatic Transaction Management)

雖然 @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("已觸發程式化交易處理,請查看後端日誌。");
    }
}

使用 cURL 測試

啟動您的 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,更理解了它背後的代理機制,以及如何透過設定傳播行為和隔離級別來應對複雜的業務場景。透過完整的範例程式碼與測試指南,您可以親手驗證每個交易情境的結果。記住,交易是保證資料一致性的基石,熟練掌握它,將使您的應用程式更加健壯與可靠。

相關資料來源


上一篇
Day28 - Spring Data JPA 進階實戰:從高效查詢到平行處理
下一篇
Day30 - Spring Boot 3 學習筆記:總結與心得
系列文
spring boot 3 學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言