N+1 Query Problem: phát hiện và xử lý
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.
- Local với 10 dòng thì không thấy chậm. Production 10.000 dòng, mỗi round-trip 1ms → 10 giây.
- Đổi sang
EAGERkhông chữa được: vớifindAll()(JPQL), Hibernate vẫn load order trước rồi bắn từng SELECT user, vẫn N+1, mà còn bị ở mọi nơi khác. - Thêm
itemsvàitem.productthì thành 1 + N + N×M.
3. Giải pháp
- Phát hiện: bật SQL log / Hibernate statistics, đếm số câu query trong test.
- JOIN FETCH: một query lấy Order + User.
- @EntityGraph: khai báo fetch plan trên repository method.
- Batch fetching: gom N SELECT thành vài
WHERE id IN (...). - 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áp | Số query | Khi nào dùng | Lưu ý |
|---|---|---|---|
| JOIN FETCH | 1 | Cần Entity + quan hệ, use case cụ thể | Collection + phân trang = in-memory paging |
| @EntityGraph | 1 | Muốn khai báo fetch plan, tái dùng derived query | Cùng hạn chế như JOIN FETCH |
| Batch fetching | 1 + N/size | Nhiều lazy relationship, collection, phân trang | Không về 1 query, nhưng an toàn và cấu hình một lần |
| DTO projection | 1 | API đọc chỉ cần vài field | Không sửa được Entity; phải viết query |
| Đổi sang EAGER | Vẫn N+1 | Khô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
- Màn hình danh sách, API đọc: DTO projection + pagination.
- Use case ghi cần aggregate đầy đủ (sửa Order + items): JOIN FETCH hoặc EntityGraph theo id.
- Lưới an toàn: bật
default_batch_fetch_size(ví dụ 50) toàn cục. - Tắt
open-in-view; không để Jackson serialize Entity rồi lazy load trong lúc render JSON. - Đo, không đoán: SQL log, statistics, test đếm query,
EXPLAIN ANALYZEcho query đã gộp.
order.getItems() ngoài transaction nổ LazyInitializationException. LAZY vs EAGER và cách xử lý đúng là bài tiếp theo.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.
findById()với EAGER: Hibernate có thể JOIN.findAll()hoặc JPQL: query của bạn quyết định SQL chính, sau đó Hibernate bắn thêm SELECT cho từng quan hệ EAGER → vẫn N+1.- EAGER là cấu hình tĩnh trên mapping: mọi use case đều phải trả giá, kể cả chỗ không cần User. Và không có cách "tắt" EAGER cho một query.
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:
- APM / tracing (Micrometer Tracing, OpenTelemetry, các APM thương mại): một span HTTP có hàng trăm span JDBC giống nhau là dấu hiệu rõ ràng.
- PostgreSQL:
pg_stat_statementscho thấy câuselect ... from users where id=$1cócallsrất cao nhưng thời gian mỗi lần rất nhỏ. - Metrics: số query/request tăng đột biến; HikariCP
connections.usagekéo dài. - Chặn từ CI: test đếm số statement cho các API quan trọng, như ví dụ
@DataJpaTestở trên.
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
- N+1 = 1 query cha + N query con do lazy load trong vòng lặp.
- Phát hiện: SQL log, statistics, test đếm query, APM.
- Sửa: JOIN FETCH / EntityGraph (cần Entity), DTO (chỉ đọc), batch size (lưới an toàn).
- EAGER không chữa N+1. Fetch collection + phân trang = phân trang trong RAM.
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.