iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Software Development

spring boot 3 學習筆記系列 第 28

Day28 - Spring Data JPA 進階實戰:從高效查詢到平行處理

  • 分享至 

  • xImage
  •  

在先前的單元中,我們已經掌握了如何建立實體 (Entity) 和資料倉儲 (Repository)。現在,我們將深入探討更貼近真實世界應用的三大主題:如何精準地查詢資料、如何處理多使用者同時操作資料的衝突,以及如何優雅地串連相關的資料表。

今日將帶您從基礎的查詢參數設定,到複雜的 JOIN 查詢,最後再到關鍵的平行處理控制機制——樂觀鎖與悲觀鎖。

學習目標

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

  1. 熟悉查詢參數 (@Param):學會如何安全地傳遞查詢條件,有效防止 JPQL 注入 (JPQL Injection) 風險。
  2. 學會撰寫 JOIN 查詢:掌握 INNER JOINLEFT JOIN 的使用時機,並了解如何使用 FETCH JOIN 優化效能,解決 N+1 查詢問題。
  3. 掌握平行處理機制:深刻理解並實作樂觀鎖 (Optimistic Locking, @Version)悲觀鎖 (Pessimistic Locking, LockModeType),確保在多使用者環境下的資料一致性。

高效且精準的查詢

1. 查詢參數 (@Param) 的重要性

在撰寫查詢時,我們最不該做的事情就是直接用字串拼接來組合 JPQL (Java Persistence Query Language) 語句。

錯誤的作法 (有安全風險!)

// 千萬不要這樣做!
String email = "user@example.com";
TypedQuery<Customer> query = em.createQuery(
  "SELECT c FROM Customer c WHERE c.email = '" + email + "'", Customer.class);

這種作法有兩大缺點:

  1. 安全性風險:攻擊者可以傳入惡意的字串(例如 ' OR 1=1 --),繞過驗證,這就是所謂的 JPQL 注入攻擊
  2. 效能低落:每次查詢的字串都不同,JPA 無法快取 (Cache) 查詢計畫,導致效能下降。

正確的作法:使用命名參數 (Named Parameters)

我們應該使用 : 加上參數名稱作為佔位符 (Placeholder),並在 Repository 方法中使用 @Param 註解 (Annotation) 來綁定參數。這樣做不僅安全、高效,也讓程式碼更具可讀性。

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface CustomerRepository extends JpaRepository<Customer, UUID> {

    @Query("SELECT c FROM Customer c WHERE c.email = :email")
    Optional<Customer> findByEmailWithJpql(@Param("email") String email);
}

好處:

  • 安全:JPA 會自動處理特殊字元,從根本上杜絕注入攻擊。
  • 高效:查詢語句的結構是固定的,JPA 可以快取執行計畫。
  • 可讀性高:參數名稱一目了然,程式碼更易於維護。

2. 跨資料表查詢 (JOIN)

當我們需要從關聯的資料表中獲取資料時(例如:查詢客戶和他們允許的登入網域),就需要使用 JOIN

INNER JOIN (內連接)

INNER JOIN 只會回傳在兩張表中都存在關聯的紀錄。例如,查詢「所有至少擁有一個允許網域的客戶」。

// 在 CustomerRepository.java 中
@Query("SELECT c FROM Customer c JOIN c.allowedDomains d WHERE d.domainName = :domainName")
List<Customer> findCustomersByDomainName(@Param("domainName") String domainName);

這會產生類似以下的 SQL,只找出那些在 allowed_domains 表中有對應紀錄的客戶。

SELECT c.* FROM customers c
INNER JOIN allowed_domains d ON c.id = d.customer_id
WHERE d.domain_name = ?

LEFT JOIN (左連接)

LEFT JOIN 會回傳左邊資料表的所有紀錄,即使在右邊的資料表中沒有對應的關聯紀錄。例如,查詢「所有客戶,並同時列出他們各自擁有的允許網域」(即使某些客戶沒有任何網域)。

// 在 CustomerRepository.java 中
// 查詢所有客戶,並一併載入其網域資料
@Query("SELECT c FROM Customer c LEFT JOIN c.allowedDomains")
List<Customer> findAllWithDomains();

✨ 效能優化利器:FETCH JOIN

當我們查詢出一個客戶列表,然後逐一去取得每個客戶的關聯資料 (如 allowedDomains) 時,會發生 N+1 查詢問題

  1. 第 1 次查詢:SELECT * FROM customers (取得 N 筆客戶)
  2. 接下來的 N 次查詢:SELECT * FROM allowed_domains WHERE customer_id = ? (為每一筆客戶再發一次查詢)

FETCH JOIN 就是為了解決這個問題而生的,它告訴 JPA:「在查詢客戶的同時,請立即、一次性地把 allowedDomains 的資料也全部抓回來。」

// 在 CustomerRepository.java 中
@Query("SELECT DISTINCT c FROM Customer c LEFT JOIN FETCH c.allowedDomains")
List<Customer> findAllWithDomainsEagerly();

DISTINCT 關鍵字在這裡很重要,因為 JOIN 可能會導致客戶紀錄重複,DISTINCT 可以確保每個客戶只回傳一次。

處理平行存取 (Concurrency)

想像一個場景:兩位客服人員同時編輯同一個客戶的資料,然後幾乎同時按下儲存按鈕。如果沒有任何保護機制,後儲存的人會把先儲存的人的修改覆蓋掉,導致資料遺失。這就是並行問題

JPA 提供了兩種主要的鎖定 (Locking) 機制來解決這個問題。

1. 樂觀鎖 (Optimistic Locking) - @Version

核心思想:「我很大可能不會跟別人衝突,所以我先修改。但在儲存前,我會檢查一下在我修改期間,有沒有其他人已經動過這筆資料了。」

  • 運作方式:
  1. 在 Entity 中加入一個帶有 @Version 註解 (Annotation) 的欄位(通常是數字類型)。
  2. 當讀取資料時,會一併讀取版本號(例如 version = 1)。
  3. 當要更新資料時,JPA 會檢查資料庫中目前該筆紀錄的版本號是否仍然是 1
  4. 如果是,就更新資料,並將版本號加一(version 變成 2)。
  5. 如果不是(例如,其他人已經更新過,版本號變成了 2),表示發生了衝突,JPA 會拋出 OptimisticLockException,此時交易 (Transaction) 會被回滾 (Rollback)。

實作:

只需要在 Customer Entity 中加入一個欄位。

Customer.java

// ...
import jakarta.persistence.Version;

@Entity
public class Customer {
    // ... 其他欄位 ...

    @Version
    private Integer version; // 或 Long

    // ... Getters and Setters ...
}

優點:

  • 不需長時間鎖定資料庫,系統吞吐量 (Throughput) 高。
  • 非常適合讀多寫少的應用場景。

缺點:

  • 在高衝突環境下,會頻繁發生更新失敗和重試,反而降低效能。

2. 悲觀鎖 (Pessimistic Locking) - LockModeType

核心思想:「我認定一定會跟別人衝突,所以當我開始讀取資料時,就先把它鎖起來,不准任何其他人碰,直到我處理完畢為止。」

運作方式:

  1. 在 Repository 的查詢方法上使用 @Lock 註解 (Annotation),並指定鎖定模式。
  2. 當交易開始並執行此查詢時,JPA 會向資料庫(例如 PostgreSQL)發送一個特殊的 SQL 語句,如 SELECT ... FOR UPDATE
  3. 資料庫會鎖定這筆紀錄。任何其他試圖讀取(PESSIMISTIC_WRITE)或修改這筆紀錄的交易都必須等待,直到第一個交易完成並釋放鎖。

實作:

CustomerRepository 中定義一個帶有鎖定的查詢方法。

CustomerRepository.java

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.Lock;

public interface CustomerRepository extends JpaRepository<Customer, UUID> {

    // 根據 ID 查詢客戶,並加上寫入鎖
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Customer c WHERE c.id = :id")
    Optional<Customer> findByIdWithPessimisticWriteLock(@Param("id") UUID id);
}

優點:

  • 資料一致性極高,從根本上杜絕了更新遺失的問題。
  • 適合寫入頻繁、衝突機率高的場景。

缺點:

  • 長時間鎖定資料庫資源,可能導致其他使用者長時間等待,降低系統吞吐量。
  • 如果鎖定順序不當,容易引發死鎖 (Deadlock)。

測試範例

讓我們將以上所學應用到一個客戶管理的 API 專案中。

專案設定

首先,確保您的 Customer Entity 已經加入了 @Version 欄位以支援樂觀鎖。

API 1: 查詢客戶清單 (支援條件查詢)

這個 API 可以根據 nameemail 來篩選客戶。

CustomerRepository.java

// 支援條件查詢:名稱模糊比對或 Email 精確比對
@Query("SELECT c FROM Customer c WHERE " +
       "(:name IS NULL OR c.name LIKE %:name%) AND " +
       "(:email IS NULL OR c.email = :email)")
Page<Customer> findByCriteria(
    @Param("name") String name,
    @Param("email") String email,
    Pageable pageable
);

CustomerService.java

public Page<Customer> searchCustomers(String name, String email, Pageable pageable) {
    return customerRepository.findByCriteria(name, email, pageable);
}

CustomerController.java

@GetMapping
public Page<Customer> searchCustomers(
    @RequestParam(required = false) String name,
    @RequestParam(required = false) String email,
    Pageable pageable) {
    return customerService.searchCustomers(name, email, pageable);
}

測試:

  • GET /api/customers?name=Gogo:查詢名稱包含 "Gogo" 的客戶。
  • GET /api/customers?email=contact@gogoro.com:查詢特定 email 的客戶。

API 2: 查詢擁有特定網域的客戶 (JOIN 查詢)

CustomerService.java

public List<Customer> findCustomersByDomain(String domainName) {
    // 這裡我們直接使用之前在 Repository 中定義好的 JOIN 查詢
    return customerRepository.findCustomersByDomainName(domainName);
}

CustomerController.java

@GetMapping("/by-domain")
public List<Customer> getCustomersByDomain(@RequestParam String domainName) {
    return customerService.findCustomersByDomain(domainName);
}

測試:

  • GET /api/customers/by-domain?domainName=gogoro.com

API 3: 模擬鎖定衝突

我們建立一個 API 來更新客戶名稱,並人為地加入延遲,以模擬使用者操作時間。

CustomerService.java

@Transactional
public Customer updateNameOptimistically(UUID id, String newName) throws InterruptedException {
    Customer customer = customerRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("找不到客戶"));
    
    customer.setName(newName);
    
    // 模擬使用者操作耗時 5 秒
    Thread.sleep(5000); 
    
    return customerRepository.save(customer); // 儲存時,JPA 會檢查 @Version
}

@Transactional
public Customer updateNamePessimistically(UUID id, String newName) throws InterruptedException {
    Customer customer = customerRepository.findByIdWithPessimisticWriteLock(id)
        .orElseThrow(() -> new RuntimeException("找不到客戶"));
    
    customer.setName(newName);
    
    // 模擬使用者操作耗時 5 秒
    Thread.sleep(5000); 
    
    return customerRepository.save(customer); // 交易結束時,鎖會被釋放
}

CustomerController.java

@PutMapping("/{id}/optimistic")
public ResponseEntity<?> updateNameOptimistic(@PathVariable UUID id, @RequestParam String name) {
    try {
        Customer updatedCustomer = customerService.updateNameOptimistically(id, name);
        return ResponseEntity.ok(updatedCustomer);
    } catch (Exception e) {
        // 捕捉樂觀鎖異常
        return ResponseEntity.status(HttpStatus.CONFLICT).body("資料已被他人修改,請刷新後重試!");
    }
}

@PutMapping("/{id}/pessimistic")
public Customer updateNamePessimistic(@PathVariable UUID id, @RequestParam String name) throws InterruptedException {
    return customerService.updateNamePessimistically(id, name);
}

實驗指南:

  1. 準備:先建立一筆客戶資料,並取得其 UUID。

  2. 樂觀鎖測試

    • 開啟兩個終端機或 API 測試工具 (如 Postman)。
    • 幾乎同一時間,對同一個客戶 UUID 分別發送請求:
      • 工具 A: PUT /api/customers/{uuid}/optimistic?name=客服A修改
      • 工具 B: PUT /api/customers/{uuid}/optimistic?name=客服B修改
    • 觀察結果:第一個完成的請求會成功 (HTTP 200),並更新資料庫。幾秒鐘後,第二個請求會失敗,並回傳 HTTP 409 Conflict,提示「資料已被他人修改」。
  3. 悲觀鎖測試

    • 同樣,開啟兩個工具。
    • 幾乎同一時間,對同一個客戶 UUID 分別發送請求:
      • 工具 A: PUT /api/customers/{uuid}/pessimistic?name=經理A修改
      • 工具 B: PUT /api/customers/{uuid}/pessimistic?name=經理B修改
    • 觀察結果:第一個請求會鎖定資料並開始執行(等待 5 秒)。第二個請求會被阻塞 (Block),一直處於等待狀態。直到第一個請求完成並釋放鎖後,第二個請求才會獲得鎖,繼續執行。最終兩個請求都會成功,但資料庫中的名稱會是第二個請求的 經理B修改

總結

今天我們學習了 Spring Data JPA 中三個非常實用的進階主題。現在您應該已經掌握:

  • 使用 @Param 進行安全、高效的參數化查詢
  • 利用 JOINFETCH JOIN 進行跨表查詢並優化效能。
  • 根據應用場景,選擇樂觀鎖(適用於低衝突、讀多寫少的場景)或悲觀鎖(適用於高衝突、寫多讀少的場景)來確保資料一致性

將這些技巧應用到您的專案中,將能打造出更健壯、更高效、更安全的應用程式!

相關完整程式碼

Customer.java

package com.example.demo.entities;

import com.example.demo.enums.CustomerStatus;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

import java.util.HashSet;
import java.util.Set;
import java.util.UUID;

@Entity
@Table(name = "customers")
// 魔法發生的地方:所有查詢都會自動加上這個條件
// 使用新的 @SQLRestriction 註解來取代已棄用的 @Where
@SQLRestriction("deleted = false")
// 當執行 delete 操作時,Hibernate 會執行這條 SQL 來代替真正的 DELETE
@SQLDelete(sql = "UPDATE customers SET deleted = true WHERE id = ?")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "name", length = 50, nullable = false)
    @Size(min = 2, max = 50)
    private String name;

    // unique=true 會在資料庫中為此欄位建立一個 UNIQUE 約束
    @Column(name = "email", unique = true, nullable = false)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private CustomerStatus status;

    @Column(name = "deleted", nullable = false)
    private boolean deleted = false;

    /**
     * 樂觀鎖版本欄位
     * JPA 會自動管理此欄位,用於處理並行更新
     */
    @Version
    private Integer version;

    /**
     * 一對多關聯 (One-to-Many).
     * 這一方 (Customer) 是關聯的反向方。
     * mappedBy = "customer": 告訴 JPA,這段關係的設定由 AllowedDomain 實體中的 "customer" 屬性負責。
     * JPA 會去 AllowedDomain.java 裡找那個 customer 欄位上的 @JoinColumn 設定。
     * cascade = CascadeType.ALL: 級聯操作。當我們對 Customer 執行操作 (儲存、刪除) 時,
     * 相關的 AllowedDomain 也會一併被操作。這在新增時非常方便。
     * orphanRemoval = true: "孤兒移除"。如果一個 AllowedDomain 從這個 Set 集合中被移除,
     * 那麼這個 AllowedDomain 在資料庫中也應該被刪除。
     */
    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<AllowedDomain> allowedDomains = new HashSet<>();

    /**
     * 一對多關聯 (One-to-Many) - Customer 和 Order
     * 反向方:Order 擁有 customer_id 外鍵
     */
    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<Order> orders = new HashSet<>();

    

    // Helper method to sync both sides of the association
    public void addDomain(AllowedDomain domain) {
        allowedDomains.add(domain);
        domain.setCustomer(this);
    }

    public void removeDomain(AllowedDomain domain) {
        allowedDomains.remove(domain);
        domain.setCustomer(null);
    }

    // Helper method to sync both sides of the association for orders
    public void addOrder(Order order) {
        orders.add(order);
        order.setCustomer(this);
    }

    public void removeOrder(Order order) {
        orders.remove(order);
        order.setCustomer(null);
    }

    // ... 省略 getter 和 setter
}

CustomerRepository.java

package com.example.demo.repositories;

import com.example.demo.entities.Customer;
import jakarta.persistence.LockModeType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface CustomerRepository extends JpaRepository<Customer, UUID> {

    /**
     * 使用 @Param 進行安全的參數化查詢
     * 根據 email 查詢客戶 (JPQL 方式)
     */
    @Query("SELECT c FROM Customer c WHERE c.email = :email")
    Optional<Customer> findByEmailWithJpql(@Param("email") String email);

    /**
     * 使用 INNER JOIN 查詢擁有特定網域的客戶
     * 只會回傳在兩張表中都存在關聯的紀錄
     */
    @Query("SELECT c FROM Customer c JOIN c.allowedDomains d WHERE d.domainName = :domainName")
    List<Customer> findCustomersByDomainName(@Param("domainName") String domainName);

    /**
     * 使用 LEFT JOIN 查詢所有客戶,並同時列出他們各自擁有的允許網域
     * 即使某些客戶沒有任何網域也會被查詢出來
     */
    @Query("SELECT c FROM Customer c LEFT JOIN c.allowedDomains")
    List<Customer> findAllWithDomains();

    /**
     * 使用 FETCH JOIN 優化效能,解決 N+1 查詢問題
     * 在查詢客戶的同時,立即一次性地把 allowedDomains 的資料也全部抓回來
     */
    @Query("SELECT DISTINCT c FROM Customer c LEFT JOIN FETCH c.allowedDomains")
    List<Customer> findAllWithDomainsEagerly();

    /**
     * 支援條件查詢:名稱模糊比對或 Email 精確比對
     * 可以根據 name 或 email 來篩選客戶
     */
    @Query("SELECT c FROM Customer c WHERE " +
           "(:name IS NULL OR c.name LIKE %:name%) AND " +
           "(:email IS NULL OR c.email = :email)")
    Page<Customer> findByCriteria(
        @Param("name") String name,
        @Param("email") String email,
        Pageable pageable
    );

    /**
     * 悲觀鎖:根據 ID 查詢客戶,並加上寫入鎖
     * 當交易開始並執行此查詢時,會向資料庫發送 SELECT ... FOR UPDATE
     * 其他試圖讀取或修改這筆記錄的交易都必須等待
     */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Customer c WHERE c.id = :id")
    Optional<Customer> findByIdWithPessimisticWriteLock(@Param("id") UUID id);
}

CustomerService.java

package com.example.demo.services;

import com.example.demo.entities.Customer;
import com.example.demo.repositories.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

/**
 * Customer 服務層類別
 * 提供客戶相關的業務邏輯處理
 */
@Service
public class CustomerService {

    @Autowired
    private CustomerRepository customerRepository;

    /**
     * 根據條件搜尋客戶 (支援條件查詢)
     * @param name 客戶名稱 (模糊比對,可為 null)
     * @param email 客戶 email (精確比對,可為 null)
     * @param pageable 分頁資訊
     * @return 符合條件的客戶分頁結果
     */
    public Page<Customer> searchCustomers(String name, String email, Pageable pageable) {
        return customerRepository.findByCriteria(name, email, pageable);
    }

    /**
     * 根據網域名稱查詢擁有該網域的客戶 (JOIN 查詢)
     * @param domainName 網域名稱
     * @return 擁有該網域的客戶清單
     */
    public List<Customer> findCustomersByDomain(String domainName) {
        // 使用 INNER JOIN 查詢,只回傳有對應網域的客戶
        return customerRepository.findCustomersByDomainName(domainName);
    }

    /**
     * 查詢所有客戶並一併載入其網域資料 (使用 FETCH JOIN 優化效能)
     * @return 所有客戶及其網域資料
     */
    public List<Customer> findAllCustomersWithDomains() {
        // 使用 FETCH JOIN 一次性載入關聯資料,解決 N+1 查詢問題
        return customerRepository.findAllWithDomainsEagerly();
    }

    /**
     * 樂觀鎖:更新客戶名稱 (模擬使用者操作時間)
     * @param id 客戶 ID
     * @param newName 新的客戶名稱
     * @return 更新後的客戶資料
     * @throws InterruptedException 線程中斷異常
     */
    @Transactional
    public Customer updateNameOptimistically(UUID id, String newName) throws InterruptedException {
        Customer customer = customerRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("找不到客戶"));
        
        customer.setName(newName);
        
        // 模擬使用者操作耗時 5 秒
        Thread.sleep(5000);
        
        return customerRepository.save(customer); // 儲存時,JPA 會檢查 @Version
    }

    /**
     * 悲觀鎖:更新客戶名稱 (模擬使用者操作時間)
     * @param id 客戶 ID
     * @param newName 新的客戶名稱
     * @return 更新後的客戶資料
     * @throws InterruptedException 線程中斷異常
     */
    @Transactional
    public Customer updateNamePessimistically(UUID id, String newName) throws InterruptedException {
        Customer customer = customerRepository.findByIdWithPessimisticWriteLock(id)
            .orElseThrow(() -> new RuntimeException("找不到客戶"));
        
        customer.setName(newName);
        
        // 模擬使用者操作耗時 5 秒
        Thread.sleep(5000);
        
        return customerRepository.save(customer); // 交易結束時,鎖會被釋放
    }
}

CustomerController.java

package com.example.demo.controllers;

import com.example.demo.entities.Customer;

import com.example.demo.services.CustomerService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/**
 * Customer 控制器
 * 提供客戶相關的 REST API 端點
 */
@RestController
@RequestMapping("/api/customers")
public class CustomerController {

    @Autowired
    private CustomerService customerService;

    /**
     * API 1: 查詢客戶清單 (支援條件查詢)
     * 根據 name 或 email 來篩選客戶
     * 
     * GET /api/customers?name=Gogo
     * GET /api/customers?email=contact@gogoro.com
     * GET /api/customers?name=Go&email=contact@example.com&page=0&size=10
     */
    @GetMapping("/search")
    public ResponseEntity<Page<Customer>> searchCustomers(
        @RequestParam(required = false) String name,
        @RequestParam(required = false) String email,
        Pageable pageable) {
        // 搜尋客戶的邏輯
        Page<Customer> customers = customerService.searchCustomers(name, email, pageable);
        return ResponseEntity.ok(customers);
    }

    /**
     * API 2: 查詢擁有特定網域的客戶 (JOIN 查詢)
     * 
     * GET /api/customers/by-domain?domainName=gogoro.com
     */
    @GetMapping("/by-domain")
    public ResponseEntity<List<Customer>> getCustomersByDomain(@RequestParam String domainName) {
        List<Customer> customers = customerService.findCustomersByDomain(domainName);
        return ResponseEntity.ok(customers);
    }

    /**
     * API 3: 查詢所有客戶並一併載入其網域資料 (FETCH JOIN 優化)
     * 
     * GET /api/customers/with-domains
     */
    @GetMapping("/with-domains")
    public ResponseEntity<List<Customer>> getAllCustomersWithDomains() {
        List<Customer> customers = customerService.findAllCustomersWithDomains();
        return ResponseEntity.ok(customers);
    }

    /**
     * API 4: 樂觀鎖測試 - 更新客戶名稱
     * 模擬使用者操作時間,用於測試樂觀鎖衝突
     * 
     * PUT /api/customers/{id}/optimistic?name=客服A修改
     */
    @PutMapping("/{id}/optimistic")
    public ResponseEntity<?> updateNameOptimistic(@PathVariable UUID id, @RequestParam String name) {
        try {
            Customer updatedCustomer = customerService.updateNameOptimistically(id, name);
            return ResponseEntity.ok(updatedCustomer);
        } catch (Exception e) {
            // 捕捉樂觀鎖異常
            return ResponseEntity.status(HttpStatus.CONFLICT)
                .body("資料已被他人修改,請刷新後重試!Error: " + e.getMessage());
        }
    }

    /**
     * API 5: 悲觀鎖測試 - 更新客戶名稱
     * 模擬使用者操作時間,用於測試悲觀鎖行為
     * 
     * PUT /api/customers/{id}/pessimistic?name=經理A修改
     */
    @PutMapping("/{id}/pessimistic")
    public ResponseEntity<Customer> updateNamePessimistic(@PathVariable UUID id, @RequestParam String name) throws InterruptedException {
        Customer customer = customerService.updateNamePessimistically(id, name);
        return ResponseEntity.ok(customer);
    }
}

相關資料來源


上一篇
Day27 - Spring Data JPA 效能優化與生命週期管理:從批次處理到安全刪除
下一篇
Day29- Spring Data JPA 交易管理實戰:從 @Transactional 到高併發控制
系列文
spring boot 3 學習筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言