N+1 Query Problem: phát hiện và xử lý

4 câu hỏi~25 phútTừ khóa: N+1, SQL log, statistics, JOIN FETCH, @EntityGraph, @BatchSize, default_batch_fetch_size, DTO projection, pagination với fetch collection

Giờ tới N+1 nhé, nghe nói câu này phỏng vấn hỏi nhiều lắm.

Đúng, đây là câu hỏi Spring Data JPA cực kỳ phổ biến, và cũng là lỗi hiệu năng hay gặp nhất trên production.

1. Bài toán: danh sách Order kèm tên User

Màn hình admin của shop-app cần: 100 order mới nhất, mỗi dòng có tên khách hàng.

@Transactional(readOnly = true)
public List<OrderRow> listOrders() {
    List<Order> orders = orderRepository.findAll();
    List<OrderRow> rows = new ArrayList<>();
    for (Order order : orders) {
        rows.add(new OrderRow(order.getId(), order.getUser().getName()));
    }
    return rows;
}

Order.user là @ManyToOne(fetch = LAZY) (bài 4.3.5). 100 order thuộc 100 user khác nhau, chưa có trong Persistence Context.

2. Vấn đề

-- Query 1
SELECT * FROM orders;
-- Query 2
SELECT * FROM users WHERE id = 1;
-- Query 3
SELECT * FROM users WHERE id = 2;
...
-- Query 101
SELECT * FROM users WHERE id = 100;

101 query = 1 query lấy Orders + N = 100 query lấy Users. Đó là N+1.

3. Giải pháp

  1. Phát hiện: bật SQL log / Hibernate statistics, đếm số câu query trong test.
  2. JOIN FETCH: một query lấy Order + User.
  3. @EntityGraph: khai báo fetch plan trên repository method.
  4. Batch fetching: gom N SELECT thành vài WHERE id IN (...).
  5. DTO projection: chỉ select field cần, không load Entity.

4. Code từng bước

Bước 1: Dependency. Như cũ. Muốn assert số query trong test có thể thêm thư viện như datasource-proxy (test scope).

Bước 2: Config để phát hiện

spring:
  jpa:
    open-in-view: false            # tắt để lazy load ngoài service lộ lỗi sớm
    properties:
      hibernate:
        generate_statistics: true  # chỉ bật dev/test
        default_batch_fetch_size: 50
logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.stat: debug      # in "N JDBC statements executed"

Bước 3: Code Java

Giải pháp 1: JOIN FETCH

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        SELECT o
        FROM Order o
        JOIN FETCH o.user
        """)
    List<Order> findAllWithUser();
}
SELECT o.*, u.*
FROM orders o
INNER JOIN users u ON o.user_id = u.id;   -- 1 query thay vì 101

Giải pháp 2: @EntityGraph

@EntityGraph(attributePaths = {"user"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithUserGraph();

// hoặc trên derived query, kèm nhiều cấp:
@EntityGraph(attributePaths = {"user", "items", "items.product"})
Optional<Order> findWithDetailsById(Long id);

EntityGraph mô tả quan hệ cần fetch; SQL cụ thể (thường LEFT JOIN) do Hibernate quyết định.

Giải pháp 3: Batch fetching

// toàn cục: hibernate.default_batch_fetch_size=50 (ở trên)
// hoặc từng chỗ:
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<OrderItem> items = new ArrayList<>();
SELECT * FROM orders;
SELECT * FROM users WHERE id IN (?, ?, ..., ?);   -- 50 id một lần
SELECT * FROM users WHERE id IN (?, ?, ..., ?);
-- 1 + ceil(100/50) = 3 query

Giải pháp 4: DTO projection

public record OrderSummary(Long orderId, String userName) {}

@Query("""
    SELECT new com.example.shop.dto.OrderSummary(o.id, u.name)
    FROM Order o
    JOIN o.user u
    ORDER BY o.id DESC
    """)
Page<OrderSummary> findSummaries(Pageable pageable);

Chỉ select 2 cột, không tạo Entity, không snapshot, không dirty checking. Thường là lựa chọn tốt nhất cho API đọc.

Bước 4: Test

curl "http://localhost:8080/admin/orders?page=0&size=100"

# Trước khi sửa (log statistics):
#   101 JDBC statements executed
# Sau khi dùng DTO projection:
#   2 JDBC statements executed   (1 select + 1 count cho Page)

Tốt hơn nữa là khóa lại bằng test, để N+1 không quay lại:

@DataJpaTest
class OrderRepositoryTest {
    @Autowired OrderRepository repository;
    @Autowired EntityManagerFactory emf;

    @Test
    void listOrdersUsesOneQuery() {
        Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
        stats.clear();
        repository.findAllWithUser().forEach(o -> o.getUser().getName());
        assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
    }
}

5. Cách hoạt động

N+1 (LAZY, không xử lý)
findAll() ──► SELECT orders ──► [Order#1 user=proxy] ... [Order#100 user=proxy]
loop: order.getUser().getName()
          └─ proxy chưa init ──► SELECT users WHERE id=?   × 100

JOIN FETCH / EntityGraph
SELECT orders JOIN users ──► Order + User đã đầy đủ ──► loop không SQL

Batch fetch (size=50)
SELECT orders
getUser() lần đầu ──► Hibernate gom 50 proxy chưa init
                  ──► SELECT users WHERE id IN (50 id)   × 2

DTO projection
SELECT o.id, u.name FROM orders JOIN users ──► List<OrderSummary>

Bẫy pagination + JOIN FETCH collection:

@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);   // ✘

JOIN với collection làm mỗi order thành nhiều dòng, nên LIMIT ở SQL không còn đúng. Hibernate cảnh báo HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory, tức là load hết vào RAM rồi mới cắt trang. Cách đúng: phân trang id trước, rồi fetch theo id; hoặc dùng batch fetching cho collection.

@Query("SELECT o.id FROM Order o ORDER BY o.id DESC")
Page<Long> findPageIds(Pageable pageable);

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.id IN :ids")
List<Order> findWithItemsByIds(@Param("ids") List<Long> ids);

Thêm một giới hạn: không JOIN FETCH hai collection List cùng lúc (MultipleBagFetchException), và kể cả với Set thì kết quả là tích Descartes, rất lớn.

6. So sánh

Giải phápSố queryKhi nào dùngLưu ý
JOIN FETCH1Cần Entity + quan hệ, use case cụ thểCollection + phân trang = in-memory paging
@EntityGraph1Muốn khai báo fetch plan, tái dùng derived queryCùng hạn chế như JOIN FETCH
Batch fetching1 + N/sizeNhiều lazy relationship, collection, phân trangKhông về 1 query, nhưng an toàn và cấu hình một lần
DTO projection1API đọc chỉ cần vài fieldKhông sửa được Entity; phải viết query
Đổi sang EAGERVẫn N+1Không dùngẢnh hưởng mọi truy vấn khác

7. Khi nào nên dùng / không nên dùng

Vậy cứ đổi hết sang EAGER cho khỏi N+1 được không?

Không nên. Bạn hiểu gần đúng là EAGER "load luôn", chỉ cần chỉnh một điểm: EAGER chỉ đảm bảo dữ liệu được load, không đảm bảo bằng một JOIN.

Quy tắc: mapping luôn LAZY, mỗi use case tự chọn fetch plan (JOIN FETCH, EntityGraph, DTO).

Trên production làm sao phát hiện N+1 khi không đọc log từng request?

Đúng, production không bật org.hibernate.SQL được vì quá nhiều log. Một số cách:

Phỏng vấn hỏi N+1 thì trả lời sao?

Q1. N+1 query problem là gì?

🇻🇳 Tiếng Việt:

N+1 xảy ra khi một query load danh sách N Entity cha, rồi khi truy cập quan hệ lazy của từng Entity, Hibernate chạy thêm một query cho mỗi Entity. Tổng là 1 + N query. Ví dụ 100 order kèm user thành 101 query. Ở local thường không thấy, lên production dữ liệu lớn thì rất chậm.

🇬🇧 English:

N+1 occurs when one query loads N parent entities and then accessing a lazy association on each of them triggers one additional query per entity, so we get 1 + N queries. For example, listing 100 orders with their users produces 101 queries. It is usually invisible locally but becomes very slow with production data.

Q2. Xử lý N+1 thế nào?

🇻🇳 Tiếng Việt:

Đầu tiên đo: bật SQL log hoặc Hibernate statistics, viết test đếm query. Sau đó chọn theo use case: JOIN FETCH hoặc EntityGraph khi cần Entity kèm quan hệ; DTO projection khi API chỉ đọc vài field; batch fetching như lưới an toàn, đặc biệt với collection và phân trang. Không đổi sang EAGER.

🇬🇧 English:

First I measure it with SQL logging or Hibernate statistics and a test that counts queries. Then I choose per use case: JOIN FETCH or an EntityGraph when I need the entity with its association, a DTO projection when the API only reads a few fields, and batch fetching as a safety net, especially for collections and pagination. Switching to EAGER is not a fix.

Q3. Dùng JOIN FETCH với pagination có vấn đề gì?

🇻🇳 Tiếng Việt:

Nếu fetch một collection, mỗi Entity cha thành nhiều dòng nên database không thể LIMIT đúng. Hibernate cảnh báo và phân trang trong bộ nhớ, tức là load toàn bộ dữ liệu. Em phân trang theo id trước rồi fetch theo danh sách id, hoặc dùng batch fetching.

🇬🇧 English:

When fetching a collection, each parent becomes several rows, so the database cannot apply LIMIT correctly. Hibernate logs a warning and paginates in memory, which means loading everything. I paginate the ids first and then fetch the entities by those ids, or I rely on batch fetching.

Cách nhớ ngắn gọn


Tự kiểm tra

100 order, 100 user khác nhau, Order.user LAZY, lặp gọi getUser().getName(): bao nhiêu query?

101 (1 orders + 100 users).

Với default_batch_fetch_size=50 thì còn bao nhiêu?

3: 1 orders + 2 câu users WHERE id IN (50 id).

Vì sao EAGER không chữa N+1 với findAll()?

JPQL quyết định SQL chính; Hibernate load quan hệ EAGER bằng các SELECT bổ sung cho từng Entity.

Cảnh báo HHH90003004 nghĩa là gì?

Fetch collection kèm phân trang, Hibernate phải phân trang trong bộ nhớ sau khi load toàn bộ kết quả.

API danh sách chỉ cần orderId và userName nên dùng gì?

DTO projection (SELECT new ...OrderSummary(o.id, u.name)) + Pageable.