Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Criteria API trong Hibernate

Tung Bui Thanh (TungDaDev)12 mins read

Criteria hibernate.webp

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 StringBuilder thì 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?

  1. 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 định hibernate.criteria.value_handling_mode=BIND củ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ào cb.function(...) hay native SQL.
  2. 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ại department_id mà 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): sinh INNER JOIN trong 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ọi office.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): sinh LEFT 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 memory

Nghĩ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:

  1. Dùng Criteria API phân trang để lấy danh sách primary key (ID) của Office (List<Long> ids).
  2. 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ùng ORDER 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ật hibernate.default_batch_fetch_size (hoặc @BatchSize): Hibernate sẽ nạp employee của cả trang bằng vài câu IN (...) thay vì N câu.


# so sánh 4 hướng: Criteria API vs Specification vs QueryDSL vs jOOQ

Tiêu chíJPA Criteria APISpring Data SpecificationQueryDSL (JPA)jOOQ
Bản chấtChuẩn gốc của Jakarta JPALớp bọc hướng domain dựa trên CriteriaThư viện fluent API, sinh code riêngĐi theo hướng database-first / typesafe SQL
Type safetyTrung 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ôngDài dòng, rối, khó đọcRất gọn, dễ dùng lạiNgắn gọn, gần với SQLRất dễ hiểu, giống SQL gần như 100%
Hỗ trợ SQL nâng caoYếu theo chuẩn JPA (Hibernate 6 có mở rộng)Yếu (phụ thuộc JPA ORM)Trung bìnhMạnh nhất (window function, CTE, JSONB, lateral join)
Phù hợp nhất vớiDự án Java EE / Jakarta thuần, không có SpringApp Spring Boot vừa và lớn dùng ORMDự án cần query động phức tạp mà vẫn muốn giữ JPAHệ 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-jpamodelgen và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ỗi SemanticException)? 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.EAGER hoặc fetch join nhiều collection @OneToMany trong 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à null hoặ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 WHERE củ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 😎 👍🏻 🚀 🔥.