在先前的單元中,我們已經掌握了如何建立實體 (Entity) 和資料倉儲 (Repository)。現在,我們將深入探討更貼近真實世界應用的三大主題:如何精準地查詢資料、如何處理多使用者同時操作資料的衝突,以及如何優雅地串連相關的資料表。
今日將帶您從基礎的查詢參數設定,到複雜的 JOIN 查詢,最後再到關鍵的平行處理控制機制——樂觀鎖與悲觀鎖。
完成今日學習後,你將能夠:
@Param
):學會如何安全地傳遞查詢條件,有效防止 JPQL 注入 (JPQL Injection) 風險。INNER JOIN
和 LEFT JOIN
的使用時機,並了解如何使用 FETCH JOIN
優化效能,解決 N+1
查詢問題。@Version
) 與悲觀鎖 (Pessimistic Locking, LockModeType
),確保在多使用者環境下的資料一致性。@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);
這種作法有兩大缺點:
' OR 1=1 --
),繞過驗證,這就是所謂的 JPQL 注入攻擊。✅ 正確的作法:使用命名參數 (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);
}
好處:
當我們需要從關聯的資料表中獲取資料時(例如:查詢客戶和他們允許的登入網域),就需要使用 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
會回傳左邊資料表的所有紀錄,即使在右邊的資料表中沒有對應的關聯紀錄。例如,查詢「所有客戶,並同時列出他們各自擁有的允許網域」(即使某些客戶沒有任何網域)。
// 在 CustomerRepository.java 中
// 查詢所有客戶,並一併載入其網域資料
@Query("SELECT c FROM Customer c LEFT JOIN c.allowedDomains")
List<Customer> findAllWithDomains();
當我們查詢出一個客戶列表,然後逐一去取得每個客戶的關聯資料 (如 allowedDomains
) 時,會發生 N+1
查詢問題:
SELECT * FROM customers
(取得 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
可以確保每個客戶只回傳一次。
想像一個場景:兩位客服人員同時編輯同一個客戶的資料,然後幾乎同時按下儲存按鈕。如果沒有任何保護機制,後儲存的人會把先儲存的人的修改覆蓋掉,導致資料遺失。這就是並行問題。
JPA 提供了兩種主要的鎖定 (Locking) 機制來解決這個問題。
@Version
核心思想:「我很大可能不會跟別人衝突,所以我先修改。但在儲存前,我會檢查一下在我修改期間,有沒有其他人已經動過這筆資料了。」
@Version
註解 (Annotation) 的欄位(通常是數字類型)。version = 1
)。1
。version
變成 2
)。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 ...
}
優點:
缺點:
LockModeType
核心思想:「我認定一定會跟別人衝突,所以當我開始讀取資料時,就先把它鎖起來,不准任何其他人碰,直到我處理完畢為止。」
運作方式:
@Lock
註解 (Annotation),並指定鎖定模式。SELECT ... FOR UPDATE
。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);
}
優點:
缺點:
讓我們將以上所學應用到一個客戶管理的 API 專案中。
專案設定
首先,確保您的 Customer
Entity 已經加入了 @Version
欄位以支援樂觀鎖。
這個 API 可以根據 name
或 email
來篩選客戶。
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 的客戶。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 來更新客戶名稱,並人為地加入延遲,以模擬使用者操作時間。
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);
}
實驗指南:
準備:先建立一筆客戶資料,並取得其 UUID。
樂觀鎖測試:
PUT
/api/customers/{uuid}/optimistic?name=客服A修改
PUT
/api/customers/{uuid}/optimistic?name=客服B修改
悲觀鎖測試:
PUT
/api/customers/{uuid}/pessimistic?name=經理A修改
PUT
/api/customers/{uuid}/pessimistic?name=經理B修改
經理B修改
。今天我們學習了 Spring Data JPA 中三個非常實用的進階主題。現在您應該已經掌握:
@Param
進行安全、高效的參數化查詢。JOIN
和 FETCH 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);
}
}