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