engineering

N+1 query: vì sao một lần xuất sao kê bắn ra 5.001 câu SQL?

Xuất sao kê treo chục giây vì Hibernate bắn 5.001 câu SQL thay vì 1 — còn màn danh sách phân trang thì che giấu bug. Cơ chế N+1 query trong JPA và 4 cách fix.

OLHub Team3 tháng 7, 2026 · 7 phút đọc

PR có tiêu đề gọn gàng: "Tính năng xuất sao kê quý — done, đã demo sprint review." Cậu junior viết nó có quyền tự tin: demo chạy mượt, code sạch sẽ, dùng đúng Spring Data JPA chuẩn sách, thậm chí entity còn khai fetch = LAZY đúng khuyến nghị. Trong một hệ thống banking, tính năng đụng tới sao kê mà lọt qua release rồi thì không có cửa hotfix — nên PR nào chạm nó tôi cũng đọc chậm.

Đến đoạn vòng lặp dựng từng dòng sao kê, tôi dừng lại ở một lời gọi bé xíu: tx.getCounterpart().getName(). Tôi không comment gì về code cả, chỉ để lại đúng một câu hỏi trên PR: "Em đã chạy thử với một tài khoản cỡ 5.000 giao dịch chưa, hay mới chạy với data seed?"

Nửa tiếng sau cậu ấy mang laptop sang, mặt nghệt ra: với dump dữ liệu giả lập cỡ thật trên máy test, export một kỳ sao kê treo gần chục giây. Cậu ấy đã kịp đi một ngõ cụt quen thuộc trước khi sang: nghi thiếu index, thêm index — không nhanh lên giây nào, vì EXPLAIN từng câu query vốn đã dưới một mili giây. "Em không hiểu, màn lịch sử giao dịch em viết y hệt vậy mà có ai kêu chậm đâu anh."

Câu đó hay hơn cậu ấy tưởng — nó chứa luôn một nửa lời giải. Chúng tôi bật SQL log, chạy lại lần export, và đếm. Log không phải là một câu SQL. Nó là năm nghìn lẻ một câu.

Tiết lộ. Đây là N+1 query — bug hiệu năng kinh điển nhất của JPA/Hibernate. Câu query đầu lấy 5.000 giao dịch của kỳ sao kê (1 query). Nhưng mỗi dòng còn cần tên tài khoản đối ứng — một quan hệ @ManyToOne được nạp lazy: Hibernate chỉ đặt sẵn một proxy rỗng, và đúng lúc code chạm vào getName() để dựng dòng sao kê, nó âm thầm bắn thêm một câu SQL. Lặp 5.000 lần, ta có 1 + 5.000 = 5.001 câu. Từng câu đều dưới một mili giây — cộng round-trip lại thành nhiều giây. Và màn lịch sử "có ai kêu chậm đâu" không hề vô can: nó dính cùng một bug, chỉ là phân trang 20 dòng nên mỗi trang chỉ 21 query — đủ nhanh để không ai nhìn thấy.

Cơ chế bên dưới: LAZY proxy và vòng lặp âm thầm

Entity của màn hình đó trông rất chuẩn mực — thậm chí đúng best practice fetch = LAZY:

@Entity
public class BankTransaction {
    @Id
    private Long id;
    private long amount;          // don vi: dong
    private Instant postedAt;

    @ManyToOne(fetch = FetchType.LAZY)   // dung khuyen nghi: khong keo account khi chua can
    private Account counterpart;         // tai khoan doi ung
}

Khi Hibernate load một BankTransaction, nó không query bảng account. Thay vào đó nó đặt vào field counterpart một proxy — một object giả chỉ giữ mỗi id, chưa có dữ liệu thật. Chừng nào bạn không chạm vào, proxy nằm im và bạn tiết kiệm được một query. Đó là mặt tốt của lazy, và là lý do nó được khuyên dùng — cơ chế proxy này chúng tôi đã bóc kỹ trong bài Associations & Fetch Type.

Mặt tối lộ ra khi cái "chạm" nằm trong một vòng lặp — như lúc dựng từng dòng sao kê:

// Export sao ke: doc TOAN BO giao dich trong ky -- khong phan trang, va hop ly nhu vay
List<BankTransaction> txs =
    txRepo.findByAccountIdAndPostedAtBetween(accountId, from, to);   // 1 query
return txs.stream()
    .map(tx -> new StatementRow(
        tx.getPostedAt(),
        tx.getAmount(),
        tx.getCounterpart().getName()))   // cham proxy -> Hibernate ban them 1 query
    .toList();

Mỗi lần getName() chạm một proxy chưa nạp, Hibernate bắn một câu SELECT mới. (Chính xác hơn: chỉ đối tác chưa gặp trong session mới tốn query — nhưng tài khoản doanh nghiệp này chi lương và thanh toán cho hàng nghìn đối tác khác nhau, nên gần như dòng nào cũng là một đối tác mới.)

flowchart TD
    A["Query ky sao ke: 1 query lay 5000 dong"] --> B["Moi dong: field counterpart la proxy rong"]
    B --> C["Vong lap dung dong sao ke: goi getName()"]
    C -->|"proxy chua co data"| D["Ban them 1 SELECT toi bang account"]
    D --> C
    C --> E["Tong cong: 1 + 5000 = 5001 query"]

Điểm hiểm của N+1 là nó vô hình ở quy mô nhỏ — và hệ thống của bạn có sẵn hai lớp che giấu nó. Lớp thứ nhất là dữ liệu dev: seed 20 dòng thì tổng cộng 21 query, mắt thường không phân biệt nổi với 1 — vì thế demo sprint review mới "mượt". Lớp thứ hai tinh vi hơn: chính phân trang của màn danh sách. Màn lịch sử giao dịch đang chạy ngoài production dính đúng pattern này từ ngày đầu, nhưng mỗi trang chỉ 20 dòng nên chỉ 21 query — vài chục mili giây, không ai phàn nàn, "có ai kêu chậm đâu". Bug được phân trang "gây mê" suốt, và tính năng export — nơi đầu tiên phải quét toàn bộ dữ liệu — vừa viết ra đã đánh thức nó dậy. Không exception, không log lỗi, EXPLAIN từng câu đều đẹp — bug chỉ hiện ra ở con số tổng.

Kiểm chứng trong 2 phút

Bật SQL log trong application.properties rồi gọi lại endpoint và đếm:

spring.jpa.show-sql=true
Hibernate: select ... from bank_transaction where account_id=?
Hibernate: select ... from account where id=?
Hibernate: select ... from account where id=?
Hibernate: select ... from account where id=?
...   (lap lai ~5000 lan)

Thấy một câu query danh sách theo sau bởi một tràng query giống hệt nhau chỉ khác tham số — đó là dấu hiệu đặc trưng của N+1, không cần đoán thêm. Muốn con số chính xác thay vì đếm log, bật hibernate.generate_statistics=true để Hibernate tự báo số câu đã chạy cho mỗi request.

Fix cho endpoint export: bảo Hibernate nạp sẵn đối tác ngay trong query đầu bằng một JOIN, thay vì để proxy tự bắn lẻ tẻ:

@EntityGraph(attributePaths = "counterpart")
List<BankTransaction> findByAccountIdAndPostedAtBetween(Long accountId, Instant from, Instant to);

Số query của lần xuất sao kê từ 5.001 về 1 — một câu duy nhất đã JOIN sẵn bảng account. Thanh tiến trình của khách hết chỗ để đứng im.

Cái bẫy thứ hai: JOIN FETCH gặp phân trang

Cậu junior học rất nhanh — nhanh đến mức đem phản xạ mới áp luôn vào một endpoint khác: màn hình quản trị liệt kê tài khoản kèm danh sách giao dịch con (@OneToMany), có phân trang. Và lần này cùng một chiêu lại phản đòn:

// SAI: fetch COLLECTION (@OneToMany) ket hop phan trang
@EntityGraph(attributePaths = "transactions")
Page<Account> findAll(Pageable pageable);
// WARN HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

// DUNG voi to-one: fetch @ManyToOne + phan trang an toan tuyet doi
// (day cung la fix dung cho man lich su giao dich co phan trang: 21 query -> 2)
@EntityGraph(attributePaths = "counterpart")
Page<BankTransaction> findByAccountId(Long accountId, Pageable pageable);

// DUNG voi collection: phan trang tren entity cha truoc, nap con sau
// (2 buoc: query id theo trang -> fetch collection theo danh sach id)
// hoac @BatchSize / DTO projection -- xem 4 cach fix ben duoi

Vì sao? Khi JOIN với một collection, mỗi tài khoản nở thành nhiều dòng SQL (một dòng cho mỗi giao dịch con). LIMIT 20 lúc này cắt 20 dòng JOIN, không phải 20 tài khoản — có thể cắt ngang giữa chừng danh sách giao dịch của một tài khoản. Hibernate từ chối trả kết quả sai kiểu đó: nó bỏ LIMIT khỏi SQL, kéo toàn bộ bảng về rồi phân trang trong memory, và chỉ để lại đúng một dòng WARN HHH000104 dễ trôi mất giữa log (phân tích chi tiết). Bẫy này chỉ xảy ra với collection (@OneToMany, @ManyToMany) — fetch quan hệ to-one như counterpart ở trên không bao giờ dính, vì mỗi dòng cha chỉ JOIN ra đúng một dòng.

Bốn cách fix — chọn theo tình huống

CáchCòn mấy queryHợp khi nào
JOIN FETCH trong @Query1Cần entity đầy đủ, tự viết JPQL
@EntityGraph1Như JOIN FETCH nhưng chỉ cần annotation
@BatchSize1 + N/sizeCodebase lớn chưa refactor được — gom N query lẻ thành từng batch
DTO projection1Màn hình chỉ đọc — SELECT thẳng các cột cần, không entity, không proxy

Cả bốn (kèm cả MultipleBagFetchException khi fetch hai List cùng lúc) được mổ từng cách trong bài Cascade & N+1 — ở đây chỉ cần nhớ nguyên tắc chọn: to-one cứ fetch thoải mái; collection thì cân nhắc phân trang; màn hình chỉ đọc thì projection thẳng cho nhẹ.

PR được approve ngay chiều hôm đó, kèm con số trong description: lần xuất sao kê từ 5.001 câu SQL còn 1. Màn lịch sử — thứ "có ai kêu chậm đâu" — cũng được vá cùng đợt. Khách hàng không bao giờ thấy thanh tiến trình đứng im, và đó là điểm tôi thích nhất ở câu chuyện này: bug rẻ nhất là bug không bao giờ rời khỏi trang review. Còn cậu junior tự dán một mảnh giấy nhớ lên màn hình, câu mà tôi đã nói lúc đóng laptop: "ORM không xoá SQL đi đâu cả — nó chỉ giấu khỏi mắt em thôi." Từ đó mỗi lần viết endpoint mới, việc đầu tiên cậu ấy làm là bật SQL log chạy thử với bộ dữ liệu cỡ thật.

Muốn nắm trọn cơ chế association, lazy proxy và cả bốn cách fix với trade-off của từng cách, hai bài Associations & Fetch TypeCascade & N+1 trong khoá Spring REST API & Data JPA dành trọn hai buổi cho đúng chủ đề này.

Sẵn sàng học sâu hơn?

Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.

Đọc tiếp

Bài viết liên quan