Criteria API trong Hibernate
Tung Bui Thanh (TungDaDev)12 mins read

Trong ứng dụng doanh nghiệp, hiếm khi câu SQL nào cố định. Màn hình tìm đơn hàng của một trang thương mại điện tử, hay màn tra cứu giao dịch tài chính, có khi có tới 20 bộ lọc tuỳ chọn: khoảng ngày, trạng thái, mã chi nhánh, khoảng số tiền, từ khoá... Mà cứ nối chuỗi SQL/JPQL bằng tay với
StringBuilderthì coi như tự mở cửa cho SQL Injection, cú pháp dễ gãy, và code thành một bát mì spaghetti không ai dám sửa.
Để làm dynamic query (truy vấn động), dân Java có khá nhiều lựa chọn: chuẩn JPA Criteria API, lớp trừu tượng Spring Data JPA Specification, thư viện sinh code QueryDSL, hay jOOQ viết SQL type-safe.
Bài này đào vào cách JPA Criteria API & Semantic Query Model (SQM) trong Hibernate 6 chạy bên dưới, rồi đặt nó cạnh mấy lựa chọn kia để bạn dễ chọn cái hợp với hệ thống của mình.
# bên dưới là gì: semantic query model (sqm) trong Hibernate 6
Ở các bản Hibernate cũ (trước 6.0), Criteria API thật ra đi đường vòng: cây Criteria được render ngược thành một chuỗi JPQL, rồi chuỗi đó lại đi qua HQL parser cũ để ra SQL. Hai lần dịch như vậy vừa tốn công, vừa khó debug, vì lỗi báo ra là lỗi của một câu JPQL bạn chưa từng viết.
Từ Hibernate 6.x, phần lõi được viết lại xoay quanh Semantic Query Model (SQM):
Trong Hibernate 6, các object bạn dựng qua CriteriaBuilder chính là node của cây SQM luôn, không còn bước render ra chuỗi. Một chi tiết nhỏ trên sơ đồ: query plan cache chủ yếu có lợi cho chuỗi HQL/JPQL (cùng chuỗi thì khỏi parse lại). Query dựng bằng Criteria mặc định không được cache bản dịch; Hibernate 7 mới thêm tuỳ chọn hibernate.criteria.plan_cache_enabled (mặc định tắt).
# sao Criteria API an toàn hơn nối chuỗi jpql?
- Hết đường SQL/JPQL Injection ở phần giá trị: Giá trị đi qua Criteria API (
builder.equal(root.get("status"), status)) được chuyển thành bind parameter của PreparedStatement, nhờ mặc địnhhibernate.criteria.value_handling_mode=BINDcủa Hibernate 6. Có hai chỗ vẫn phải tự lo: tên field/cột lấy từ input (ví dụ cột sort do client gửi lên,root.get(userInput)) thì nên whitelist; và đừng nhét chuỗi người dùng vàocb.function(...)hay native SQL. - Chung một cây AST: Bạn viết bằng chuỗi JPQL hay dựng object qua Criteria API thì Hibernate 6 cũng đi chung một đường SQM → SQL AST → SQL. Hai cách viết cho ra SQL giống nhau, có chung các tối ưu (ví dụ
root.get(department).get(id)đọc thẳng cột khoá ngoạidepartment_idmà không cần JOIN), và lỗi báo ra cũng cùng một kiểu.
# bẫy magic string & cứu tinh JPA metamodel
Mới dùng Criteria API, nhiều người hay truyền tên field dưới dạng String:
// NGUY HIỂM: Sai lầm Magic String!
Predicate p = builder.equal(root.get("cyti"), "hanoi"); // Gõ sai chính tả 'city' -> Lỗi khi RUNTIME!Một ngày nào đó entity đổi field city thành cityName, compiler Java chẳng báo gì cả. Lỗi chỉ nổ ra trên production, đúng lúc khách bấm lọc dữ liệu!
# cách xử lý: JPA canonical metamodel
Dùng annotation processor (hibernate-jpamodelgen) để tự sinh các class metamodel lúc compile. Cách chắc ăn nhất là khai báo nó trong annotationProcessorPaths của maven-compiler-plugin:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<version>6.5.2.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>Vì sao không khai báo như một dependency provided bình thường? Từ JDK 23, javac không còn tự chạy annotation processor tìm thấy trên classpath nữa, phải chỉ định rõ. Khai báo kiểu cũ thì build vẫn xanh, chỉ có class Office_ là không được sinh ra, và lỗi hiện ra ở chỗ dùng nó. Version của processor nên khớp với version Hibernate đang dùng (trong project Spring Boot có thể ghi ${hibernate.version}). Gradle thì dùng cấu hình annotationProcessor. Từ Hibernate 7, artifact này đổi tên thành hibernate-processor, tên cũ chỉ còn là relocation.
Maven/Gradle sẽ tự sinh class Office_ (rút gọn):
@StaticMetamodel(Office.class)
public abstract class Office_ {
public static volatile SingularAttribute<Office, Long> id;
public static volatile SingularAttribute<Office, String> city;
public static volatile SingularAttribute<Office, Boolean> active;
public static volatile SingularAttribute<Office, Department> department;
}Giờ thì query được kiểm tra kiểu ngay lúc compile (strongly-typed compile-time safety):
// Đổi tên field thì Office_ sinh lại, dòng này lỗi compile ngay
Predicate condition = builder.equal(root.get(Office_.city), "hanoi");# chia tầng cho gọn: Spring data JPA Specification pattern
Criteria API mạnh thật, nhưng cứ phải tạo CriteriaBuilder, CriteriaQuery, Root lặp đi lặp lại ở tầng repository thì code phình ra toàn boilerplate.
Cách hay dùng trên production là kết hợp Criteria API với Specification Pattern (lấy từ Domain-Driven Design) mà Spring Data đã hỗ trợ sẵn:
# viết Specification Builder
Định nghĩa các specification nhỏ, mỗi cái làm một việc và dùng lại được:
public final class OfficeSpecifications {
private OfficeSpecifications() {}
public static Specification<Office> hasCity(String city) {
return (root, query, cb) ->
city == null || city.isBlank()
? cb.conjunction() // 1=1 (Luôn đúng, bỏ qua bộ lọc)
: cb.equal(cb.lower(root.get(Office_.city)), city.toLowerCase());
}
public static Specification<Office> activeOnly() {
return (root, query, cb) -> cb.isTrue(root.get(Office_.active));
}
public static Specification<Office> inDepartment(Long departmentId) {
return (root, query, cb) -> {
if (departmentId == null) return cb.conjunction();
// Điều hướng quan hệ lồng nhau có kiểm tra type
return cb.equal(root.get(Office_.department).get(Department_.id), departmentId);
};
}
}Để ý cb.lower(...) trong hasCity: bọc cột trong hàm thì index thường trên cột city không dùng được nữa. Nếu bảng lớn, hoặc tạo index trên biểu thức lower(city) (PostgreSQL có hỗ trợ), hoặc chuẩn hoá dữ liệu khi ghi để so sánh trực tiếp.
Rồi ghép chúng lại ở tầng service để xử lý DTO tìm kiếm:
@Service
public class OfficeSearchService {
private final OfficeRepository officeRepository;
public OfficeSearchService(OfficeRepository officeRepository) {
this.officeRepository = officeRepository;
}
@Transactional(readOnly = true)
public Page<OfficeResponseDTO> searchOffices(OfficeSearchCriteria criteria, Pageable pageable) {
Specification<Office> spec = OfficeSpecifications.hasCity(criteria.getCity())
.and(OfficeSpecifications.activeOnly())
.and(OfficeSpecifications.inDepartment(criteria.getDepartmentId()));
return officeRepository.findAll(spec, pageable)
.map(OfficeResponseDTO::fromEntity);
}
}Ở đây mình không mở đầu bằng Specification.where(...) như nhiều bài cũ, vì method này bị đánh dấu deprecated trong Spring Data JPA 3.5 (sang 4.0 nó không nhận null nữa). Gọi .and() thẳng trên specification đầu tiên chạy được ở mọi phiên bản; cần một điểm bắt đầu "không lọc gì" thì dùng Specification.unrestricted().
# bẫy hay gặp trên production: Join fetch & phân trang
Khi dùng Criteria API / Specification để query entity có quan hệ @OneToMany hoặc @ManyToOne, có hai cái bẫy hiệu năng rất hay dính:
# phân biệt root.join() và root.fetch()
root.join(Office_.department): sinhINNER JOINtrong mệnh đềFROM(dùng để lọc theo cột của department), nhưng KHÔNG nạp dữ liệu department vào object Office. Lúc bạn duyệt danh sách và gọioffice.getDepartment().getName(), Hibernate sẽ lazy load, mỗi department khác nhau thêm một query → N+1 Problem. Department nào đã nạp rồi thì nằm sẵn trong persistence context nên không query lại; vì vậy số query thêm bằng số department khác nhau, tệ nhất là N.root.fetch(Office_.department, JoinType.LEFT): sinhLEFT JOIN FETCH, bắt Hibernate nạp luôn dữ liệu liên kết trong đúng 1 câu SQL.
Có một bẫy riêng khi đặt fetch() vào Specification rồi gọi findAll(spec, pageable): Spring Data dùng lại chính spec đó để dựng câu count, mà count không select entity nên Hibernate 6 ném SemanticException: Query specified join fetching, but the owner of the fetched association was not present in the select list. Cách quen thuộc là chỉ fetch khi đang ở query lấy dữ liệu:
public static Specification<Office> fetchDepartment() {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) { // count query thì bỏ qua
root.fetch(Office_.department, JoinType.LEFT);
}
return cb.conjunction();
};
}# bẫy fetch Join đi cùng phân trang (pageable)
Nếu bạn fetch() một quan hệ @OneToMany rồi phân trang với PageRequest.of(0, 10): mỗi Office có bao nhiêu employee thì bị lặp lại bấy nhiêu dòng trong kết quả JOIN. LIMIT 10 lúc này cắt theo dòng chứ không theo Office, có thể ra một Office với danh sách employee bị cụt. Nên Hibernate bỏ hẳn LIMIT / OFFSET khỏi SQL và in ra dòng cảnh báo mà ai thấy cũng nên giật mình. Hibernate 5 ghi mã HHH000104, Hibernate 6 đổi thành:
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memoryNghĩa là Hibernate kéo toàn bộ các dòng khớp điều kiện (có khi cả triệu) vào heap của JVM rồi mới cắt lấy 10 bản ghi. Server lăn ra chết là chuyện sớm muộn! Muốn biến cảnh báo này thành lỗi cho khỏi lọt lên production, bật hibernate.query.fail_on_pagination_over_collection_fetch=true.
Cách xử lý: Tách làm 2 bước:
- Dùng Criteria API phân trang để lấy danh sách primary key (ID) của Office (
List<Long> ids).- Chạy query thứ hai:
SELECT o FROM Office o LEFT JOIN FETCH o.employees WHERE o.id IN (:ids)để lấy đủ dữ liệu chi tiết.Query thứ hai không giữ thứ tự của trang, nên nhớ sắp lại kết quả theo đúng thứ tự
ids(hoặc thêm cùngORDER BY). Nếu không muốn viết hai query, cách nhẹ nhàng hơn là đừng fetch collection, để nó lazy và bậthibernate.default_batch_fetch_size(hoặc@BatchSize): Hibernate sẽ nạp employee của cả trang bằng vài câuIN (...)thay vì N câu.
# so sánh 4 hướng: Criteria API vs Specification vs QueryDSL vs jOOQ
| Tiêu chí | JPA Criteria API | Spring Data Specification | QueryDSL (JPA) | jOOQ |
|---|---|---|---|---|
| Bản chất | Chuẩn gốc của Jakarta JPA | Lớp bọc hướng domain dựa trên Criteria | Thư viện fluent API, sinh code riêng | Đi theo hướng database-first / typesafe SQL |
| Type safety | Trung bình (cần metamodel) | Trung bình (cần metamodel) | Rất cao (sinh class QOffice) | Cao nhất (sinh thẳng từ schema DB) |
| Cú pháp dễ đọc không | Dài dòng, rối, khó đọc | Rất gọn, dễ dùng lại | Ngắn gọn, gần với SQL | Rất dễ hiểu, giống SQL gần như 100% |
| Hỗ trợ SQL nâng cao | Yếu theo chuẩn JPA (Hibernate 6 có mở rộng) | Yếu (phụ thuộc JPA ORM) | Trung bình | Mạnh nhất (window function, CTE, JSONB, lateral join) |
| Phù hợp nhất với | Dự án Java EE / Jakarta thuần, không có Spring | App Spring Boot vừa và lớn dùng ORM | Dự án cần query động phức tạp mà vẫn muốn giữ JPA | Hệ thống tải cao, FinTech, data warehouse, báo cáo tài chính |
Hai ghi chú cho bảng trên. Ô "SQL nâng cao" của Criteria API là nói về chuẩn JPA; riêng Hibernate 6 có HibernateCriteriaBuilder mở rộng thêm CTE, window function, set operation, nên dùng Hibernate thì không yếu tới mức đó, chỉ là code không còn portable. Còn với QueryDSL, repo gốc gần như đứng yên mấy năm nay, phần lớn dự án mới dùng bản fork của OpenFeign (io.github.openfeign.querydsl), nên kiểm tra kỹ trước khi chọn.
# checklist trước khi lên production (architect's checklist)
- 1. Bật JPA Metamodel: Đã đưa
hibernate-jpamodelgenvào build pipeline để dẹp hết magic string chưa? - 2. Để ý count query khi phân trang: Khi dùng
findAll(Specification, Pageable), spec cófetch()đã được chặn ở count query chưa (không thì lỗiSemanticException)? Câu đếm có join thêm bảng con không cần thiết làm chậm không? - 3. Tránh cartesian explosion: Đừng bao giờ dùng quá 1
FetchType.EAGERhoặc fetch join nhiều collection@OneToManytrong cùng một Criteria Query. - 4. Fallback bằng conjunction: Predicate động nào cũng nên có nhánh
builder.conjunction()khi tham số lànullhoặc rỗng, để không sinh ra điều kiện sai. - 5. Có index đi kèm: Các cột hay nằm trong
WHEREcủa Criteria (nhưcity,created_at,status) đã có composite index tương ứng trong database chưa?
# lời kết
Criteria API không phải để thay JPQL hay Native SQL ở mọi chỗ. Chỗ nó thật sự đáng dùng là các query động, type-safe và dễ refactor.
Gói Criteria API bên dưới Spring Data Specification, dùng thêm JPA Metamodel, bạn sẽ có một tầng data access gọn gàng mà vẫn chịu được khi yêu cầu nghiệp vụ thay đổi liên tục.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.