99 Ngày Spring — Ngày 40: Tổng kết tối ưu JPA
Từ Ngày 31 tới giờ ta đã đi qua gần như trọn tầng dữ liệu: truy vấn dẫn xuất, phân trang, Specification, projection, Flyway, JdbcClient, caching, Redis, nhiều datasource và các khuôn mẫu entity. Hôm nay gom tất cả thành một thứ dùng được ngay: checklist tối ưu JPA — bảy vấn đề hay gặp nhất, xếp theo thứ tự đáng sửa trước, kèm cách phát hiện và cách chữa.

1. N+1 query — thủ phạm số một
Nếu bạn chỉ sửa được một thứ trong toàn bộ ứng dụng, hãy sửa cái này. N+1 là nguyên nhân của phần lớn những trang "tự nhiên chậm", và nó ẩn rất kỹ vì mỗi câu truy vấn riêng lẻ đều nhanh — chỉ là có tới hai trăm câu.
// CHECKLIST TỐI ƯU JPA — gom từ Ngày 31 tới 39, theo thứ tự ĐÁNG SỬA TRƯỚC.
// ═══ 1. N+1 QUERY — thủ phạm số một, không có đối thủ ═══
// Triệu chứng: trang chạy 3 giây, log hiện 201 câu SELECT gần giống hệt nhau.
List<DonHang> ds = repo.findAll(); // 1 câu
for (DonHang d : ds) d.getKhachHang().getTen(); // + N câu nữa 💥
// Cách sửa, theo thứ tự nên thử:
@EntityGraph(attributePaths = "khachHang") // 1️⃣ gọn nhất
List<DonHang> findAll();
@Query("select d from DonHang d join fetch d.khachHang") // 2️⃣ khi cần kiểm soát
List<DonHang> layKemKhach();
// 3️⃣ Với collection thì fetch join làm nhân dòng và PHÁ phân trang —
// dùng batch fetch thay vì cố nhồi tất cả vào một câu:
spring.jpa.properties.hibernate.default_batch_fetch_size: 100
// 201 câu → 3 câu. Một dòng cấu hình, hiệu quả nhất trong cả checklist.
// PHÒNG BỆNH: đặt fetch = LAZY cho MỌI quan hệ. @ManyToOne mặc định là EAGER —
// đây là mặc định tệ nhất của JPA, sửa lại ngay khi khai entity.Phòng bệnh trước: đặt
fetch = LAZYcho mọi quan hệ.@ManyToOnemặc địnhEAGERlà mặc định tệ nhất của JPA.default_batch_fetch_sizelà dòng cấu hình đáng giá nhất trong bài này: biến 201 câu thành 3 mà không phải sửa một dòng code.Với collection thì đừng fetch join khi có phân trang — nó nhân dòng lên và Hibernate buộc phải phân trang trong bộ nhớ.
2–5. Lấy quá nhiều, phân trang sai, thiếu index, transaction quá rộng
Bốn vấn đề tiếp theo ít ồn ào hơn N+1 nhưng cộng lại thì nặng ngang. Điểm chung của cả bốn: chúng chỉ lộ ra khi dữ liệu lớn dần, nên môi trường dev với vài trăm dòng sẽ không bao giờ cho bạn thấy.
// ═══ 2. LẤY QUÁ NHIỀU DỮ LIỆU ═══
// Màn danh sách hiện 3 cột mà SELECT cả 15 cột + toàn bộ quan hệ.
// Ngày 33 đã có lời giải: PROJECTION — chỉ lấy đúng thứ cần.
interface DonHangTomTat { Long getId(); String getMa(); BigDecimal getTong(); }
List<DonHangTomTat> findAllBy(Pageable p); // nhẹ hơn hàng chục lần
// ═══ 3. PHÂN TRANG SAI CÁCH ═══
// OFFSET lớn thì database vẫn phải quét qua tất cả rồi vứt đi:
// trang 1 → nhanh
// trang 5000 → chậm kinh khủng, dù cùng LIMIT 20
// Feed cuộn vô hạn thì dùng KEYSET pagination (Ngày 31):
where id < :cuoiCungDaThay order by id desc limit 20
// ═══ 4. QUÊN INDEX ═══
// Không có index thì mọi tối ưu JPA đều vô nghĩa. Ba chỗ luôn cần:
// - khóa ngoại (JPA KHÔNG tự tạo index cho nó!)
// - cột dùng trong WHERE và ORDER BY thường xuyên
// - cột unique nghiệp vụ (email, mã đơn)
// Kiểm chứng bằng EXPLAIN ANALYZE, đừng đoán.
// ═══ 5. TRANSACTION QUÁ RỘNG ═══
@Transactional
public void xuLy() {
var d = repo.findById(id);
goiApiBenNgoai(); // ❌ giữ kết nối DB suốt lúc chờ mạng!
repo.save(d);
}
// Gọi API ngoài, gửi mail, xử lý file — đưa RA NGOÀI transaction.
// Và đọc thì luôn @Transactional(readOnly = true): Hibernate bỏ dirty checking.Index cho khóa ngoại là chỗ bị quên nhiều nhất — JPA tạo ràng buộc khóa ngoại nhưng không tạo index cho nó.
@Transactional(readOnly = true)cho mọi method chỉ đọc: Hibernate bỏ dirty checking và bớt hẳn một lượt so sánh trạng thái.Đừng gọi API bên ngoài bên trong transaction — bạn đang giữ một kết nối database suốt thời gian chờ mạng của người khác.
6–7. Ghi hàng loạt và chuyện đo đạc
Hai mục cuối khép lại checklist: một về ghi khối lượng lớn, một về thứ quan trọng hơn mọi mẹo tối ưu — nhìn thấy SQL thật. Không đo thì mọi tối ưu chỉ là mê tín.
// ═══ 6. GHI HÀNG LOẠT KHÔNG BATCH ═══
for (var x : motTramNghinDong) repo.save(x); // ❌ 100.000 câu INSERT riêng lẻ
// Bật batch — và nhớ ba dòng này đi CÙNG NHAU, thiếu một là batch không hoạt động:
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true
spring.jpa.properties.hibernate.order_updates: true
// Lưu ý: id kiểu IDENTITY thì Hibernate KHÔNG batch được (phải hỏi DB từng id) —
// dùng SEQUENCE với allocationSize nếu cần ghi khối lượng lớn.
// Còn nhập liệu thật sự lớn thì bỏ hẳn JPA: JdbcClient batchUpdate (Ngày 35).
// ═══ 7. KHÔNG NHÌN THẤY SQL THẬT ═══
// Không đo thì mọi tối ưu chỉ là mê tín. Bật đếm câu lệnh khi phát triển:
spring.jpa.properties.hibernate.generate_statistics: true
logging.level.org.hibernate.SQL: DEBUG
// Tốt hơn nữa: thư viện datasource-proxy hoặc p6spy in ra SQL kèm THAM SỐ và
// thời gian chạy. Và viết test khẳng định "endpoint này chỉ được chạy ≤ 3 câu" —
// đó là cách duy nhất khiến N+1 không lặng lẽ quay lại sau vài lần refactor.
// THỨ TỰ ƯU TIÊN KHI TỐI ƯU:
// 1. Đo trước (bật log, đếm câu) 2. Sửa N+1 3. Thêm index
// 4. Bớt cột bằng projection 5. Batch 6. Cache (Ngày 36–37)
// Cache là bước CUỐI. Cache đặt lên trên một truy vấn tồi chỉ giấu vấn đề đi.Ba dòng cấu hình batch phải đi cùng nhau; và
IDENTITYthì Hibernate không batch được — đây là lý do nhiều người bật batch mà không thấy gì đổi.Viết test đếm số câu truy vấn cho các endpoint quan trọng: đó là cách duy nhất giữ N+1 không quay lại sau vài lần refactor.
Cache là bước cuối cùng, không phải bước đầu. Cache đặt lên một truy vấn tồi chỉ giấu vấn đề, và còn thêm bài toán làm mới dữ liệu.
Bài tập nhỏ
Bật
generate_statisticsrồi mở trang danh sách của bạn — đếm số câu truy vấn thật sự chạy, con số thường gây bất ngờ.Tìm một chỗ N+1 trong dự án, sửa bằng
@EntityGraph, rồi đo lại; sau đó thửdefault_batch_fetch_sizevà so hai cách.Chạy
EXPLAIN ANALYZEcho truy vấn danh sách chậm nhất, thêm index cho khóa ngoại rồi chạy lại.Đổi một truy vấn
OFFSETtrang sâu sang keyset pagination và so thời gian ở trang 1 với trang 5000.Viết test khẳng định một endpoint chỉ chạy tối đa 3 câu truy vấn, rồi cố tình thêm một quan hệ
EAGERđể xem test đỏ.
Kết lại
Bốn ý gói lại hôm nay: N+1 là thủ phạm số một — phòng bằng LAZY, chữa bằng @EntityGraph hoặc default_batch_fetch_size; lấy đúng thứ cần bằng projection, phân trang sâu bằng keyset, và nhớ rằng khóa ngoại không tự có index; transaction phải gọn, đọc thì readOnly, và tuyệt đối không gọi API bên ngoài bên trong nó; còn ghi hàng loạt thì bật batch đủ ba dòng, hoặc bỏ hẳn JPA mà dùng JdbcClient. Trên tất cả: đo trước, sửa sau, cache cuối cùng. Vậy là khép lại mảng dữ liệu của series. Ngày 41 mở một giai đoạn hoàn toàn mới và rất lớn: Spring Security — filter chain, SecurityContext, và phân biệt authentication với authorization. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


