TungDaDev's Blog

Criteria API trong Hibernate

Criteria hibernate.webp
Published on
/10 mins read/

Trong các ứng dụng doanh nghiệp thực tế, hiếm khi câu lệnh SQL là cố định (Static). Một màn hình tìm kiếm đơn hàng trong thương mại điện tử hoặc tra cứu giao dịch tài chính có thể có tới 20 bộ lọc tùy chọn: từ khoảng ngày, trạng thái, mã chi nhánh, dải số tiền đến từ khóa văn bản. Nếu cố gắng nối chuỗi SQL/JPQL thủ công bằng StringBuilder, bạn đang tự mở cửa cho lỗi SQL Injection, cú pháp gãy vỡ và mã nguồn biến thành một 'bát mì spaghetti' không thể bảo trì.

Để giải quyết bài toán Dynamic Queries (Truy vấn động), hệ sinh thái Java đã phát triển nhiều trường phái công nghệ khác nhau: từ tiêu chuẩn chuẩn hóa JPA Criteria API, tầng trừu tượng Spring Data JPA Specification, thư viện sinh mã hướng đối tượng QueryDSL, cho tới công cụ Type-safe SQL jOOQ.

Bài viết này phân tích cơ chế hoạt động tầng sâu của JPA Criteria API & Semantic Query Model (SQM) trong Hibernate 6, đồng thời đặt lên bàn cân so sánh kiến trúc với các giải pháp đối trọng để giúp bạn đưa ra quyết định chuẩn xác nhất cho hệ thống của mình.


# cơ chế tầng sâu: semantic query model (sqm) trong Hibernate 6

Trong các phiên bản Hibernate cũ (trước bản 6.0), Criteria API chuyển đổi trực tiếp các biểu thức sang câu lệnh SQL thông qua cây HQL Parser cũ, dẫn đến việc sinh SQL thiếu tối ưu và rất khó debug.

Kể từ Hibernate 6.x, toàn bộ kiến trúc lõi được xây dựng lại xung quanh Semantic Query Model (SQM):

# tại sao Criteria API lại an toàn hơn string jpql?

  1. Khử hoàn toàn SQL/JPQL Injection: Mọi giá trị truyền qua Criteria API (builder.equal(root.get("status"), status)) đều bắt buộc chuyển thành PreparedStatement Bind Parameters ở mức JDBC Driver.
  2. Thống nhất cây AST: Dù bạn viết bằng chuỗi JPQL hay dựng đối tượng qua Criteria API, Hibernate 6 đều dịch về cùng một biểu diễn SQM, cho phép tối ưu hóa các phép chiếu (projections) và loại bỏ các phép JOIN dư thừa (Join Elimination).

# bẫy chuỗi ký tự (magic strings) & cứu cánh JPA metamodel

Một sai lầm rất phổ biến khi lập trình viên bắt đầu dùng Criteria API là truyền tên trường 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!

Nếu một ngày nào đó Entity đổi tên trường city thành cityName, trình biên dịch Java hoàn toàn bất lực. Lỗi chỉ phát nổ khi ứng dụng chạy trên Production và khách hàng bấm lọc dữ liệu!

# giải pháp kiến trúc: JPA canonical metamodel

Sử dụng công cụ Annotation Processor (hibernate-jpamodelgen) để tự động sinh ra các class Metamodel tương ứng trong quá trình compile:

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
    <version>6.5.2.Final</version>
    <scope>provided</scope>
</dependency>

Maven/Gradle sẽ tự động sinh class Office_:

@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, Department> department;
}

Bây giờ câu truy vấn đạt độ an toàn kiểu tĩnh tuyệt đối (Strongly-Typed Compile-Time Safety):

// An toàn tuyệt đối: Trình biên dịch bắt lỗi ngay lập tức nếu đổi tên field
Predicate condition = builder.equal(root.get(Office_.city), "hanoi");

# kiến trúc phân tầng: Spring data JPA Specification pattern

Mặc dù Criteria API rất mạnh mẽ, nhưng việc khởi tạo CriteriaBuilder, CriteriaQuery, và Root lặp đi lặp lại ở tầng Repository khiến code bị phình to (Boilerplate Code).

Để trừu tượng hóa điều này, kiến trúc chuẩn production kết hợp Criteria API với Specification Pattern (từ Domain-Driven Design) do Spring Data cung cấp:

# triển khai enterprise Specification Builder

Định nghĩa các Atomic Specification có khả năng tái sử dụng độc lập:

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);
        };
    }
}

Kết hợp linh hoạt tại tầng Service để xử lý DTO tìm kiếm động:

@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 = Specification
            .where(OfficeSpecifications.hasCity(criteria.getCity()))
            .and(OfficeSpecifications.activeOnly())
            .and(OfficeSpecifications.inDepartment(criteria.getDepartmentId()));
 
        return officeRepository.findAll(spec, pageable)
            .map(OfficeResponseDTO::fromEntity);
    }
}

# cạm bẫy tử thần trong production: Join fetch & phân trang

Khi sử dụng Criteria API / Specification để truy vấn các entity có quan hệ @OneToMany hoặc @ManyToOne, hai thảm họa hiệu năng thường trực rình rập:

# phân biệt root.Join() và root.fetch()

  • root.join(Office_.department): Sinh lệnh SQL INNER JOIN trong mệnh đề FROM, nhưng KHÔNG nạp dữ liệu của department vào đối tượng Office. Khi bạn duyệt qua danh sách và gọi office.getDepartment().getName(), Hibernate sẽ kích hoạt Lazy Loading và bắn thêm N câu queries phụ → N+1 Problem.
  • root.fetch(Office_.department, JoinType.LEFT): Sinh lệnh LEFT JOIN FETCH, ép Hibernate nạp ngay lập tức dữ liệu liên kết trong 1 câu SQL duy nhất.

# cạm bẫy fetch Join kết hợp phân trang (pageable)

Nếu bạn dùng fetch() trên quan hệ @OneToMany kết hợp với PageRequest.of(0, 10): PostgreSQL/MySQL sẽ trả về các dòng bị nhân bản (Cartesian Product). Do không thể áp dụng LIMIT / OFFSET chính xác trên mức SQL, Hibernate sẽ in ra dòng cảnh báo kinh hoàng:

WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Hibernate sẽ kéo toàn bộ 1 triệu bản ghi vào Heap Memory của JVM rồi mới cắt lấy 10 bản ghi, đánh sập máy chủ ngay lập tức!

Giải pháp Kiến trúc: Tách làm 2 bước:

  1. Dùng Criteria API để phân trang lấy về danh sách Primary Keys (IDs) của Office (List<Long> ids).
  2. Chạy câu query thứ hai: SELECT o FROM Office o LEFT JOIN FETCH o.employees WHERE o.id IN (:ids) để lấy toàn bộ dữ liệu chi tiết mà không gây nhân bản Cartesian!

# ma trận đánh đổi 4 chiều: Criteria API vs Specification vs QueryDSL vs jOOQ

Tiêu chuẩn Kiến trúcJPA Criteria APISpring Data SpecificationQueryDSL (JPA)jOOQ
Bản chất triết lýChuẩn Jakarta JPA NativeWrapper hướng Domain trên nền CriteriaThư viện Fluent API tạo mã ngoàiHướng tiếp cận Database-First / Typesafe SQL
Độ an toàn kiểu (Type Safety)Trung bình (Cần Metamodel)Trung bình (Cần Metamodel)Rất cao (Sinh class QOffice)Tuyệt đối (Sinh từ trực tiếp Schema DB)
Tính biểu cảm cú phápDài dòng, phức tạp, khó đọcRất thanh thoát, dễ tái sử dụngNgắn gọn, gần gũi với SQLCực kỳ trực quan, giống 100% cú pháp SQL
Hỗ trợ tính năng SQL nâng caoYếu (Hạn chế Window functions, CTE, JSONB)Yếu (Phụ thuộc vào JPA ORM)Trung bìnhVô địch (Window Functions, CTE, JSONB, Lateral Joins)
Phù hợp nhất choDự án thuần Java EE / Jakarta không dùng SpringCác ứng dụng Spring Boot vừa và lớn theo chuẩn ORMDự án cần query động phức tạp nhưng vẫn muốn giữ JPAHệ thống tải cao, FinTech, Data Warehouse, Báo cáo tài chính

# bảng kiểm tra sẵn sàng vận hành (architect's checklist)

  • 1. Kích hoạt JPA Metamodel: Đã tích hợp hibernate-jpamodelgen vào build pipeline để triệt tiêu toàn bộ magic strings chưa?
  • 2. Kiểm soát Count Query trong Phân trang: Khi dùng findAll(Specification, Pageable), đã kiểm tra xem câu query đếm (count()) có bị Join Fetch các bảng con không cần thiết làm chậm tốc độ không?
  • 3. Tránh Cartesian Explosion: Tuyệt đối không dùng nhiều hơn 1 FetchType.EAGER hoặc Fetch Join nhiều Collection @OneToMany trong cùng một Criteria Query.
  • 4. Conjunction Fallback: Mọi predicate động đều có nhánh fallback builder.conjunction() khi tham số truyền vào là null hoặc rỗng để tránh sinh điều kiện sai.
  • 5. Index Hỗ Trợ: Các cột được đưa vào Criteria WHERE (như city, created_at, status) đã được đánh Composite Index tương ứng trong cơ sở dữ liệu chưa?

# lời kết

Criteria API không sinh ra để thay thế JPQL hay Native SQL trong mọi tình huống. Sức mạnh đích thực của nó nằm ở khả năng xây dựng các câu truy vấn động, an toàn kiểu dữ liệu và có thể tái cấu trúc (Refactorable).

Bằng cách đóng gói Criteria API bên dưới lớp trừu tượng Spring Data Specification và kết hợp cùng JPA Metamodel, bạn sẽ xây dựng được một tầng Data Access vừa thanh lịch, vừa vững chãi trước những biến đổi phức tạp của yêu cầu nghiệp vụ thực tế.


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 😎 👍🏻 🚀 🔥.