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.
01
N+1 query là gì, vì sao xảy ra — và vì sao ở local không ai nhận ra?
JuniorfindAll()lấy 100 project là 1 query; nhưng mỗi fieldtaskschỉ là collection proxy chưa khởi tạo — vào loop gọip.getTasks()là Hibernate mở query mớiSELECT ... 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ậtlogging.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.02
LAZY khác EAGER thế nào? Default của từng loại quan hệ là gì?
JuniorLAZY: 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:
@OneToManyvà@ManyToManylà LAZY, còn@ManyToOnevà@OneToOnelà 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ầnorder.total) và giấu chi phí: mỗi@ManyToOneEAGER là một JOIN ẩn.Ngoại lệ hay bị hỏi:@OneToOnephí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.03
JOIN FETCH, @EntityGraph, @BatchSize, DTO projection — chọn gì khi nào để diệt N+1?
MidJOIN FETCH và
@EntityGraphcù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.@BatchSizelà quick win cho legacy: không sửa code, chỉ config, gom lazy load thành queryINtheo 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 haiListcùng lúc némMultipleBagFetchExceptionngay lúc khởi động — List là bag không dedup được tích Cartesian; fix: đổi một bên sangSet, hoặc tách hai query.04
LazyInitializationException vì sao xảy ra, và OSIV liên quan gì?
MidProxy 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@EntityGraphfetch trước phần cần dùng. Cách thứ ba là hiểu OSIV: Spring Boot mặc địnhopen-in-view: truegiữ 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.Setspring.jpa.open-in-view: falsengay từ đầu dự án — để lỗi LAZY lộ ra ở dev thay vì thành sự cố connection pool ở production.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@Transactionallà 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 →TransactionInterceptorlấy connection, tắt autoCommit, BEGIN → delegate xuống method thật (connection chia sẻ qua thread-local) → return thì commit,RuntimeExceptionthì rollback. Vì thếthis.method()gọi thẳng object thật, BỎ QUA proxy —@Transactionaltrên method được gọi nội bộ không có hiệu lực; tương tự, annotation trênprivatemethod 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.06
@Transactional có rollback khi ném checked exception không?
MidKhông — mặc định Spring chỉ rollback với
RuntimeExceptionvàError; checked exception vẫn COMMIT bình thường. Hệ quả kinh điển: transfer tiền đã debit, bước credit némBusinessException(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 extendRuntimeExceptionngay 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.07
Persistence context là gì? Vì sao sửa entity xong không cần gọi save() mà DB vẫn update?
JuniorPersistence context là map từ (entity class, primary key) sang instance mà EntityManager giữ trong phạm vi transaction —
findcù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.08
Vì sao equals/hashCode cho JPA entity khó — và vì sao không dùng Lombok @Data?
MidBa 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ùnginstanceof); và field mutable đổi giá trị làm hashCode bất ổn định. Pattern chuẩn (Vlad Mihalcea): equals dùnginstanceof+ 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@Datavi 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.09
OFFSET pagination chậm vì sao? Keyset khác gì, và Slice hơn Page ở đâu?
MidLIMIT 20 OFFSET 200000buộ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ằngWHERE (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ònSlicevsPage: Page chạy thêmCOUNT(*)(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 tiebreakerid— 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.