OLHub

Câu hỏi phỏng vấn JPA & bài toán N+1

Bộ đề này harvest từ khoá Spring REST & Data — mỗi câu link bài học đào sâu cơ chế, không dừng ở đáp án thuộc lòng.

Đề Spring9 câuJunior–Midmỗi câu ≥1 bài học
  1. 01

    N+1 query là gì, vì sao xảy ra — và vì sao ở local không ai nhận ra?

    Junior

    findAll() lấy 100 project là 1 query; nhưng mỗi field tasks chỉ là collection proxy chưa khởi tạo — vào loop gọi p.getTasks() là Hibernate mở query mới SELECT ... WHERE project_id = ? cho TỪNG project, tổng 101 query thay vì 1 câu JOIN. Không cơ chế nào tự gom, vì mỗi lần truy cập proxy là một lần khởi tạo độc lập. Nó khó bị phát hiện vì chi phí tỉ lệ thuận dữ liệu: local 10 project chỉ 11 query (~11ms, nhìn rất khoẻ), production 10.000 project thành 10.001 query — timeout và alert lúc 2 giờ sáng, cùng một dòng code.

    Cách bắt sớm rẻ nhất: bật logging.level.org.hibernate.SQL=DEBUG ở profile dev — thấy cùng một pattern query lặp lại với parameter khác nhau trong một request là N+1.
  2. 02

    LAZY khác EAGER thế nào? Default của từng loại quan hệ là gì?

    Junior

    LAZY: Hibernate trả về proxy (subclass CGLIB) chỉ chứa id, SQL thật chỉ chạy khi field bị truy cập lần đầu — hai câu SQL tách biệt. EAGER: Hibernate JOIN ngay trong query đầu tiên. Default theo JPA spec: @OneToMany@ManyToMany là LAZY, còn @ManyToOne@OneToOne là EAGER — và chính default EAGER này nên bị override thành LAZY, vì nó tạo chain (Order → Customer → Address kéo 4–5 JOIN dù chỉ cần order.total) và giấu chi phí: mỗi @ManyToOne EAGER là một JOIN ẩn.

    Ngoại lệ hay bị hỏi: @OneToOne phía inverse (mappedBy) không LAZY được — Hibernate buộc phải query mới biết field là null hay có giá trị, nên không thể tạo proxy null-safe.
  3. 03

    JOIN FETCH, @EntityGraph, @BatchSize, DTO projection — chọn gì khi nào để diệt N+1?

    Mid

    JOIN FETCH và @EntityGraph cùng cho 1 query JOIN và load entity đầy đủ — khác nhau ở chỗ EntityGraph là annotation declarative, chi phí refactor thấp và tái sử dụng được qua named graph. @BatchSize là quick win cho legacy: không sửa code, chỉ config, gom lazy load thành query IN theo lô — 100 project với batch 50 còn 2 query thay vì 100. DTO projection (SELECT new ...DTO(...)) là lựa chọn cho list endpoint read-only: 1 query GROUP BY, không kéo entity thừa. Khuyến nghị thực dụng: DTO cho màn hình danh sách, JOIN FETCH/EntityGraph khi cần entity đầy đủ để sửa.

    JOIN FETCH hai List cùng lúc ném MultipleBagFetchException ngay lúc khởi động — List là bag không dedup được tích Cartesian; fix: đổi một bên sang Set, hoặc tách hai query.
  4. 04

    LazyInitializationException vì sao xảy ra, và OSIV liên quan gì?

    Mid

    Proxy LAZY cần Hibernate Session để chạy query lúc truy cập lần đầu; mà Session gắn với transaction — transaction kết thúc là session đóng. Truy cập proxy sau đó (điển hình: trong controller hoặc lúc serialize JSON) là "could not initialize proxy — no Session". Ba cách xử lý đúng: map sang DTO ngay trong @Transactional(readOnly = true), hoặc @EntityGraph fetch trước phần cần dùng. Cách thứ ba là hiểu OSIV: Spring Boot mặc định open-in-view: true giữ session mở suốt request nên lỗi này bị CHE đi — đổi lại connection bị giữ lâu, là anti-pattern production.

    Set spring.jpa.open-in-view: false ngay từ đầu dự án — để lỗi LAZY lộ ra ở dev thay vì thành sự cố connection pool ở production.
  5. 05

    @Transactional hoạt động thế nào — và vì sao gọi nội bộ trong cùng class thì mất transaction?

    Mid

    @Transactional là AOP: Spring tạo CGLIB subclass (proxy) override method có annotation, đăng ký proxy vào container thay object gốc — mọi nơi inject đều nhận proxy. Luồng chạy: caller gọi proxy → TransactionInterceptor lấy connection, tắt autoCommit, BEGIN → delegate xuống method thật (connection chia sẻ qua thread-local) → return thì commit, RuntimeException thì rollback. Vì thế this.method() gọi thẳng object thật, BỎ QUA proxy — @Transactional trên method được gọi nội bộ không có hiệu lực; tương tự, annotation trên private method không bao giờ chạy vì CGLIB chỉ override được public.

    Fix self-invocation đúng cách là tách class riêng và inject bean mới (rõ dependency, dễ test) — không phải inject chính mình với @Lazy trừ khi bất khả kháng.
  6. 06

    @Transactional có rollback khi ném checked exception không?

    Mid

    Không — mặc định Spring chỉ rollback với RuntimeExceptionError; checked exception vẫn COMMIT bình thường. Hệ quả kinh điển: transfer tiền đã debit, bước credit ném BusinessException (checked) — transaction vẫn commit, tiền lệch mà không có lỗi nào ở tầng DB. Quy tắc này kế thừa từ spec EJB: checked = lỗi nghiệp vụ (được phép commit), runtime = lỗi hệ thống (phải rollback). Cách xử lý: khai báo tường minh @Transactional(rollbackFor = Exception.class), hoặc theo khuyến nghị hiện đại — cho business exception extend RuntimeException ngay từ đầu.

    Bẫy liên quan hay hỏi kèm: inner method REQUIRED ném RuntimeException thì transaction bị đánh dấu rollback-only NGAY LÚC NÉM — outer có catch cũng vô ích, commit sẽ ném UnexpectedRollbackException.
  7. 07

    Persistence context là gì? Vì sao sửa entity xong không cần gọi save() mà DB vẫn update?

    Junior

    Persistence context là map từ (entity class, primary key) sang instance mà EntityManager giữ trong phạm vi transaction — find cùng id hai lần trả về CÙNG MỘT instance, lần hai không chạm DB (L1 cache, luôn bật). Lúc load, Hibernate chụp snapshot các field; lúc flush (trước commit hoặc trước query liên quan), nó so state hiện tại với snapshot — field nào đổi thì tự sinh UPDATE. Đó là dirty checking: trong transaction, sửa entity managed là đủ, save() không cần thiết. SQL không chạy ngay tại thời điểm gọi persist/remove — chúng được dồn vào ActionQueue và thực thi lúc flush.

    Với @Transactional(readOnly = true), Hibernate tắt dirty checking và chuyển FlushMode.MANUAL — sửa entity trong đó sẽ IM LẶNG không persist, không exception nào báo.
  8. 08

    Vì sao equals/hashCode cho JPA entity khó — và vì sao không dùng Lombok @Data?

    Mid

    Ba cái bẫy: id còn null trước khi persist (hashCode đổi sau save → HashSet "mất dấu" entity đã bỏ vào); Hibernate trả về CGLIB proxy là subclass (so sánh bằng getClass() == sẽ sai, phải dùng instanceof); và field mutable đổi giá trị làm hashCode bất ổn định. Pattern chuẩn (Vlad Mihalcea): equals dùng instanceof + so sánh id null-safe (hai entity transient coi là khác nhau), còn hashCode trả HẰNG SỐ theo class — chấp nhận degrade O(N) khi entity làm key Map, đổi lấy tính ổn định xuyên suốt lifecycle. Lombok @Data vi phạm cả ba bẫy cùng lúc: equals trên mọi field, toString gọi mọi getter (trigger lazy load ngoài transaction), constructor xung đột yêu cầu no-arg của JPA.

    @EqualsAndHashCode(of = "id") nghe có vẻ đúng nhưng vẫn sai: hashCode dựa trên id vẫn đổi giữa lúc null và lúc có giá trị — vẫn mất dấu trong HashSet.
  9. 09

    OFFSET pagination chậm vì sao? Keyset khác gì, và Slice hơn Page ở đâu?

    Mid

    LIMIT 20 OFFSET 200000 buộc PostgreSQL đọc và VỨT 200.000 row trước khi trả 20 row — chi phí tuyến tính theo độ sâu trang: trang đầu 5ms, trang 10.000 lên ~5 giây rồi timeout; index giúp ~10 lần nhưng vẫn O(N). Keyset (cursor) pagination thay bằng WHERE (created_at, id) < (:cursor, :cursorId) — B-tree seek thẳng vị trí, O(log N) bất kể độ sâu; cursor phải composite với cột unique làm tiebreaker, nếu không row cùng giá trị sort sẽ bị lặp/sót. Còn Slice vs Page: Page chạy thêm COUNT(*) (2 SQL), Slice chỉ fetch size+1 row để biết hasNext — nhưng Slice VẪN dùng OFFSET cho data query, nó chỉ bỏ chi phí COUNT chứ không chữa OFFSET scan.

    Sort theo cột không unique (ORDER BY status) mà không thêm tiebreaker id — row lặp hoặc biến mất ở ranh giới trang, bug phân trang kinh điển.

Topic kế cùng track: Câu hỏi phỏng vấn Spring Boot Auto-Configuration

Trả lời trôi chảy bắt đầu từ hiểu cơ chế

Mỗi câu ở trên đều có bài học đứng sau. Học tuần tự cả khoá Spring REST API & Data JPA để không chỉ trả lời được, mà giải thích được vì sao.