ORM Anti-Patterns
部分內容由 LLM 生成,尚未經過人工驗證。
ORM(Object-Relational Mapping)使用的常見反模式與最佳實踐。
flowchart TB
subgraph N1["N+1 問題"]
Q1[Query 1: findAll Orders]
Q2[Query 2: getCustomer #1]
Q3[Query 3: getCustomer #2]
Q4[Query N+1: getCustomer #N]
Q1 --> Q2
Q1 --> Q3
Q1 --> Q4
end
subgraph Fix["JOIN FETCH 解法"]
QF[Single Query: Orders + Customers]
end
查詢問題
| 反模式 | 問題 | 最佳實踐 |
|---|---|---|
| N+1 查詢 | 載入 N 筆資料後,逐筆載入關聯資料 | 使用 JOIN FETCH 或 eager loading |
| 過度抓取 | SELECT * 載入不需要的欄位 | 使用 projection,只選取需要的欄位 |
| 動態嵌套子查詢 | ORM 產生複雜低效的 SQL | 檢查產生的 SQL,必要時用原生查詢 |
| Lazy Load 多次查詢 | 迴圈中觸發多次 lazy loading | 預先 fetch 或使用 batch fetching |
N+1 問題詳解
反模式:
// 1 次查詢取得所有訂單
List<Order> orders = orderRepository.findAll();
// N 次查詢取得每筆訂單的客戶
for (Order order : orders) {
Customer customer = order.getCustomer(); // 觸發 lazy load
System.out.println(customer.getName());
}產生的 SQL:
SELECT * FROM orders; -- 1 次
SELECT * FROM customers WHERE id = 1; -- N 次
SELECT * FROM customers WHERE id = 2;
SELECT * FROM customers WHERE id = 3;
...最佳實踐(JOIN FETCH):
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer();產生的 SQL:
SELECT o.*, c.* FROM orders o
JOIN customers c ON o.customer_id = c.id; -- 1 次最佳實踐(Entity Graph):
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findAll();過度抓取 (Over-fetching)
反模式:
// Entity 有 20 個欄位,但只需要 2 個
List<User> users = userRepository.findAll();
users.forEach(u -> System.out.println(u.getName()));最佳實踐(使用 Projection):
// Interface-based projection
public interface UserNameProjection {
String getName();
String getEmail();
}
List<UserNameProjection> findAllProjectedBy();
// DTO projection
@Query("SELECT new com.example.UserDTO(u.name, u.email) FROM User u")
List<UserDTO> findAllUserDTOs();Lazy Loading 陷阱
反模式(在迴圈中觸發):
List<Department> departments = deptRepository.findAll();
for (Department dept : departments) {
// 每次迭代都觸發一次查詢
int employeeCount = dept.getEmployees().size();
}最佳實踐(Batch Fetching):
// Hibernate 設定
@BatchSize(size = 25)
@OneToMany(mappedBy = "department")
private List<Employee> employees;
// 或在 application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25最佳實踐(子查詢 Fetch):
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "department")
private List<Employee> employees;批量操作問題
| 反模式 | 問題 | 最佳實踐 |
|---|---|---|
| 逐筆 INSERT/UPDATE | 每筆資料一次 round-trip | 使用 batch insert/update |
| JOIN 過多表 | 查詢複雜度高、難以優化 | 拆分查詢或使用原生 SQL |
| 索引不善用 | 查詢未利用資料庫索引 | 確保查詢條件對應索引欄位 |
| 排序分頁效率低 | 大表分頁 OFFSET 效能差 | 使用 keyset pagination |
批量操作優化
反模式(逐筆儲存):
for (User user : users) {
userRepository.save(user); // 每次一個 INSERT
}最佳實踐(批量插入):
// application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
// 批量儲存
@Transactional
public void saveAll(List<User> users) {
for (int i = 0; i < users.size(); i++) {
entityManager.persist(users.get(i));
if (i % 50 == 0) {
entityManager.flush();
entityManager.clear();
}
}
}最佳實踐(JDBC batch):
jdbcTemplate.batchUpdate(
"INSERT INTO users (name, email) VALUES (?, ?)",
users,
50,
(ps, user) -> {
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
}
);分頁效能問題
反模式(OFFSET 分頁):
// 第 10000 頁,需要跳過前 999900 筆
Pageable pageable = PageRequest.of(10000, 100);
Page<User> users = userRepository.findAll(pageable);產生的 SQL:
SELECT * FROM users ORDER BY id LIMIT 100 OFFSET 999900;
-- 資料庫需要掃描前 999900 筆才能回傳最佳實踐(Keyset Pagination):
@Query("SELECT u FROM User u WHERE u.id > :lastId ORDER BY u.id")
List<User> findNextPage(@Param("lastId") Long lastId, Pageable pageable);產生的 SQL:
SELECT * FROM users WHERE id > 999900 ORDER BY id LIMIT 100;
-- 使用索引直接定位,效能穩定查詢計畫檢查
開啟 SQL logging 檢查 ORM 產生的查詢:
Spring Boot / Hibernate:
# application.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE使用 p6spy 監控:
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>spring.datasource.url=jdbc:p6spy:postgresql://localhost/db
spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver