Giới thiệu và kiến trúc Hibernate

Xem thử

Một báo cáo cuối tháng của công ty chạy trong một giao dịch đọc-ghi: nạp 40.000 khách hàng, duyệt từng người để tính tổng. Một lập trình viên thêm vào vòng duyệt một dòng "cho gọn dữ liệu": customer.setEmail(customer.getEmail().toLowerCase()), chỉ để in ra cho đẹp. Báo cáo vẫn đúng. Nhưng sáng hôm sau bộ phận chăm sóc khách hàng thấy mọi email trong hệ thống đã bị chuyển thành chữ thường, thời điểm cập nhật cuối của 40.000 dòng đều là đêm qua, và nhật ký kiểm toán của cơ sở dữ liệu đầy UPDATE. Không ai gọi update, save hay bất kỳ phương thức lưu nào cả.

Đó là Hibernate làm đúng việc của nó: nó theo dõi mọi entity bạn nạp và tự ghi những thay đổi xuống cơ sở dữ liệu. Cơ chế này (persistence context cùng dirty checking) là trái tim của mọi ORM và là nguồn của cả sự tiện lợi lẫn những lỗi khó hiểu nhất. Bài này dựng một dự án Hibernate 7 trên MySQL và nhìn từng câu SQL mà nó sinh ra, để biết chính xác khi nào dữ liệu được đọc, khi nào được ghi, và vì sao.

Sau bài này, bạn có thể

  • Nêu vấn đề mà ORM giải quyết và những thứ nó không giải quyết hộ bạn.
  • Mô tả các đối tượng chính (Configuration, SessionFactory, Session, Transaction, persistence context) và chi phí tương đối của chúng.
  • Dự đoán thời điểm Hibernate gửi SELECT, INSERT, UPDATE: khi nào ngay lập tức, khi nào chờ tới flush.
  • Phân biệt ba trạng thái của entity, biết merge trả về một bản sao, và giải thích dirty checking.
  • Chọn giữa khóa sinh IDENTITY và SEQUENCE trên MySQL và nêu hệ quả với việc gom lô.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, MySQL 8.4.9, Hibernate ORM 7.4.13.Final, Connector/J 26.7.0). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 05-hibernate/01-gioi-thieu-kien-truc, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
MySQL8.4 (image mysql:8.4)
Hibernate ORM7.4.13.Finalkhai báo trong pom.xml
MySQL Connector/J26.7.0khai báo trong pom.xml
Dockerbất kỳ bản gần đâyMySQL cho demo và Testcontainers cho test

Cấu trúc

text
01-gioi-thieu-kien-truc/
  docker-compose.yml           MySQL 8.4, database shop, user app (cổng 3307)
  pom.xml
  src/main/java/dev/canhdominich/hibernate/intro/
    DbConfig.java              đọc URL, user, mật khẩu từ biến môi trường
    Student.java               entity chính, khóa IDENTITY, enum lưu dạng chuỗi
    Ticket.java                entity dùng khóa SEQUENCE (allocationSize 50)
    Note.java                  entity có @DynamicUpdate
    SqlRecorder.java           StatementInspector ghi lại mọi câu SQL
    Factories.java             dựng SessionFactory bằng mã, create-drop, bật thống kê
    PersistenceContextDemo.java  thí nghiệm về Session, persistence context, trạng thái entity
    IdStrategiesDemo.java      IDENTITY, SEQUENCE và @DynamicUpdate

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven Java 17 với hibernate-core 7.4.13.Final (đã kéo theo giao diện Jakarta Persistence), Connector/J, slf4j-simple để đọc log Hibernate gọn, cùng JUnit, Testcontainers và AssertJ cho test. Plugin exec-maven-plugin cho phép chạy từng lớp bằng exec:exec -Dmain.class=....

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>hibernate-gioi-thieu</artifactId>
    <version>1.0.0</version>
    <name>Hibernate - Gioi thieu va kien truc</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.hibernate.intro.PersistenceContextDemo</main.class>
        <main.arg>none</main.arg>
        <jvm.heap>-Xmx512m</jvm.heap>
        <mysql.connector.version>26.7.0</mysql.connector.version>
        <hibernate.version>7.4.13.Final</hibernate.version>
        <junit.version>6.0.3</junit.version>
        <testcontainers.version>2.0.5</testcontainers.version>
        <assertj.version>3.27.7</assertj.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>${mysql.connector.version}</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate.orm</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>${hibernate.version}</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>2.0.17</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-mysql</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.3</version>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>${jvm.heap}</argument>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                        <argument>${main.arg}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Cấu hình Docker Compose

docker-compose.yml

File này khởi động MySQL 8.4 bằng một lệnh. Nó tạo database shop, tài khoản ứng dụng app (mật khẩu app_pw) và tài khoản quản trị root (mật khẩu root_pw), mở cổng 3307 của máy bạn tới cổng 3306 trong container, và khai báo healthcheck bằng mysqladmin ping để docker compose ps báo healthy khi MySQL đã sẵn sàng nhận kết nối.

yaml
services:
  mysql:
    image: mysql:8.4
    container_name: jdbc-lab-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root_pw
      MYSQL_DATABASE: shop
      MYSQL_USER: app
      MYSQL_PASSWORD: app_pw
    ports:
      - "${MYSQL_PORT:-3307}:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pw"]
      interval: 3s
      timeout: 3s
      retries: 30

Khởi động và kiểm tra MySQL:

bash
docker compose up -d
docker compose ps

Chờ cột STATUS hiện healthy. MySQL lắng nghe ở cổng 3307 của máy bạn; nếu bạn đang chạy một lab khác dùng cùng cổng, chạy docker compose down ở lab đó trước. Biến môi trường mặc định của chương trình: JDBC_URL=jdbc:mysql://localhost:3307/shop, DB_USER=app, DB_PASSWORD=app_pw.

ORM giải quyết điều gì

Cơ sở dữ liệu quan hệ và đối tượng Java nhìn dữ liệu rất khác nhau. Bảng có dòng, cột, khóa ngoại; đối tượng có trường, kiểu, tham chiếu, kế thừa. Với JDBC thuần, bạn viết tay mọi phép chuyển đổi (bài JDBC đã làm: map(ResultSet), INSERT từng cột). ORM (Object-Relational Mapping) khai báo một lần cặp "lớp với bảng, trường với cột" và tự sinh phép chuyển đổi cùng câu SQL. Hibernate là ORM phổ biến nhất của Java và là bản hiện thực chính của đặc tả Jakarta Persistence (JPA).

Cách đọc hình: cột trái là thế giới Java (lớp, trường, enum), cột giữa là thế giới bảng (bảng, cột). Bạn khai báo các cặp tương ứng một lần bằng annotation, và Hibernate sinh INSERT, SELECT, UPDATE, DELETE phù hợp với cơ sở dữ liệu đang dùng.

ORM tiết kiệm công viết mã chuyển đổi, nhưng không làm cho cơ sở dữ liệu biến mất: mọi thứ vẫn là SQL chạy trên bảng, với chi phí, khóa và giới hạn của SQL. Điều bài này nhắm tới là nhìn thấy SQL đó thay vì tin rằng nó "tự lo".

Các đối tượng bạn sẽ gặp

Dự án dựng SessionFactory bằng mã. Lớp DbConfig đọc thông tin kết nối từ biến môi trường như các bài JDBC:

src/main/java/dev/canhdominich/hibernate/intro/DbConfig.java

java
package dev.canhdominich.hibernate.intro;

public record DbConfig(String url, String user, String password) {

    public static DbConfig fromEnvironment() {
        return new DbConfig(
                env("JDBC_URL", "jdbc:mysql://localhost:3307/shop"),
                env("DB_USER", "app"),
                env("DB_PASSWORD", "app_pw"));
    }

    public DbConfig withUrl(String newUrl) {
        return new DbConfig(newUrl, user, password);
    }

    public DbConfig withPassword(String newPassword) {
        return new DbConfig(url, user, newPassword);
    }

    private static String env(String name, String fallback) {
        String value = System.getenv(name);
        return value == null || value.isBlank() ? fallback : value;
    }
}

Entity đầu tiên là Student: một lớp Java thường có vài annotation (@Entity đánh dấu nó, @Id và @GeneratedValue khai báo khóa chính do cơ sở dữ liệu sinh ra bằng IDENTITY, @Column đặt tên và ràng buộc cột, @Enumerated(EnumType.STRING) lưu enum dạng chữ). Phần annotation sẽ nói kỹ ở bài sau:

src/main/java/dev/canhdominich/hibernate/intro/Student.java

java
package dev.canhdominich.hibernate.intro;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "student")
public class Student {

    public enum Level {
        BASIC, ADVANCED
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "full_name", nullable = false, length = 100)
    private String fullName;

    @Column(nullable = false, length = 100)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private Level level = Level.BASIC;

    protected Student() {
    }

    public Student(String fullName, String email) {
        this.fullName = fullName;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getFullName() {
        return fullName;
    }

    public void setFullName(String fullName) {
        this.fullName = fullName;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Level getLevel() {
        return level;
    }

    public void setLevel(Level level) {
        this.level = level;
    }

    @Override
    public String toString() {
        return "Student[" + id + ", " + fullName + ", " + level + "]";
    }
}

Để nhìn thấy SQL một cách chính xác, bài dùng SqlRecorder, một StatementInspector của Hibernate: mỗi câu SQL trước khi gửi xuống JDBC đều đi qua nó, và nó ghi lại để các thí nghiệm in ra:

src/main/java/dev/canhdominich/hibernate/intro/SqlRecorder.java

java
package dev.canhdominich.hibernate.intro;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.hibernate.resource.jdbc.spi.StatementInspector;

public class SqlRecorder implements StatementInspector {

    private static final List<String> STATEMENTS = Collections.synchronizedList(new ArrayList<>());

    @Override
    public String inspect(String sql) {
        STATEMENTS.add(sql.replaceAll("\\s+", " ").trim());
        return sql;
    }

    public static void clear() {
        STATEMENTS.clear();
    }

    public static List<String> statements() {
        synchronized (STATEMENTS) {
            return new ArrayList<>(STATEMENTS);
        }
    }

    public static void print(String title) {
        System.out.println(title);
        List<String> list = statements();
        if (list.isEmpty()) {
            System.out.println("  (no SQL)");
        }
        list.forEach(sql -> System.out.println("  " + sql));
        clear();
    }
}

Factories dựng SessionFactory từ cấu hình viết bằng mã: địa chỉ kết nối, hbm2ddl.auto=create-drop (Hibernate tạo bảng lúc khởi động và xóa lúc đóng, tiện cho thí nghiệm), bật thống kê, và gắn SqlRecorder:

src/main/java/dev/canhdominich/hibernate/intro/Factories.java

java
package dev.canhdominich.hibernate.intro;

import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;

public final class Factories {

    private Factories() {
    }

    public static SessionFactory build(DbConfig config, Class<?>... entities) {
        Configuration configuration = new Configuration()
                .setProperty("hibernate.connection.url", config.url())
                .setProperty("hibernate.connection.username", config.user())
                .setProperty("hibernate.connection.password", config.password())
                .setProperty("hibernate.hbm2ddl.auto", "create-drop")
                .setProperty("hibernate.generate_statistics", "true")
                .setProperty("hibernate.session_factory.statement_inspector", SqlRecorder.class.getName());
        for (Class<?> entity : entities) {
            configuration.addAnnotatedClass(entity);
        }
        return configuration.buildSessionFactory();
    }

    public static SessionFactory student(DbConfig config) {
        return build(config, Student.class);
    }
}

Các đối tượng chính, theo thứ tự xuất hiện trong một chương trình:

Đối tượngVai tròVòng đờiAn toàn đa luồng
ConfigurationGom thuộc tính cấu hình và danh sách entityChỉ lúc khởi độngKhông cần
SessionFactoryNhà máy tạo Session; giữ metadata, kết nối, bộ nhớ đệm cấp haiMột cho cả ứng dụngCó
SessionMột đơn vị công việc; chứa persistence context; giữ một kết nối khi cầnNgắn: mỗi yêu cầu hoặc mỗi giao dịchKhông
Transactionbegin, commit, rollbackMột khối công việcTheo Session
Persistence contextTập các entity đang được Session quản lýSống cùng SessionTheo Session

Chi phí của hai đối tượng đầu rất chênh nhau, và thí nghiệm đo nó: dựng SessionFactory lâu hơn hơn 20 lần mở một Session. Vì vậy SessionFactory tạo một lần và tái dùng, còn Session mở và đóng liên tục.

Cách đọc hình: Configuration tạo SessionFactory một lần; từ đó mỗi đơn vị công việc mở một Session (kèm persistence context của riêng nó) và một Transaction, rồi Transaction mượn kết nối JDBC để nói chuyện với cơ sở dữ liệu.

Hai điều về API cần biết ngay, đều đo được trong dự án. Thứ nhất, Session của Hibernate 7 là một jakarta.persistence.EntityManager, nên bạn dùng các phương thức chuẩn JPA (persist, merge, find, getReference, remove). Thứ hai, các phương thức riêng cũ như save và saveOrUpdate không còn tồn tại trong Session của phiên bản này (một test của dự án in Session has save: false, saveOrUpdate: false, persist: true, merge: true); mã Hibernate cũ dùng chúng phải đổi sang persist và merge.

Persistence context: một đối tượng cho mỗi dòng

Chương trình PersistenceContextDemo làm từng thí nghiệm một trên entity Student và in ra SQL mà SqlRecorder bắt được. Đây là toàn bộ mã; bạn sẽ gặp lại từng phương thức ở các mục dưới:

src/main/java/dev/canhdominich/hibernate/intro/PersistenceContextDemo.java

java
package dev.canhdominich.hibernate.intro;

import org.hibernate.Hibernate;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;

public final class PersistenceContextDemo {

    private PersistenceContextDemo() {
    }

    public static void main(String[] args) {
        long start = System.nanoTime();
        try (SessionFactory factory = Factories.student(DbConfig.fromEnvironment())) {
            long factoryMs = (System.nanoTime() - start) / 1_000_000;
            start = System.nanoTime();
            try (Session session = factory.openSession()) {
                System.out.println("-- building the SessionFactory took more than 20 times longer than opening a Session: " + (factoryMs > 20 * ((System.nanoTime() - start) / 1_000_000 + 1)));
            }

            long id = persistAndCommit(factory);
            firstLevelCache(factory, id);
            dirtyChecking(factory, id);
            entityStates(factory, id);
            autoFlushBeforeQuery(factory, id);
            proxies(factory, id);
        }
    }

    static long persistAndCommit(SessionFactory factory) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = new Student("Nguyễn Văn An", "an@example.com");
            System.out.println("-- persist: state before = transient, id = " + student.getId());
            session.persist(student);
            SqlRecorder.print("-- SQL right after persist(), before commit (IDENTITY must know the id)");
            System.out.println("-- state after persist: managed = " + session.contains(student) + ", id = " + student.getId());
            tx.commit();
            SqlRecorder.print("-- SQL at commit");
            return student.getId();
        }
    }

    static void firstLevelCache(SessionFactory factory, long id) {
        try (Session session = factory.openSession()) {
            Student first = session.find(Student.class, id);
            Student second = session.find(Student.class, id);
            System.out.println("-- two find() calls in one Session: same object = " + (first == second));
            SqlRecorder.print("-- SQL for two find() calls");
            session.clear();
            session.find(Student.class, id);
            SqlRecorder.print("-- SQL for a find() after clear()");
        }
    }

    static void dirtyChecking(SessionFactory factory, long id) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = session.find(Student.class, id);
            SqlRecorder.clear();
            student.setFullName("Nguyễn Văn An (đã sửa)");
            SqlRecorder.print("-- SQL after setFullName(), before commit");
            tx.commit();
            SqlRecorder.print("-- SQL at commit (no update() call was made)");
        }
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = session.find(Student.class, id);
            SqlRecorder.clear();
            student.setFullName(student.getFullName());
            tx.commit();
            SqlRecorder.print("-- SQL when the value is set to what it already was");
        }
    }

    static void entityStates(SessionFactory factory, long id) {
        Student detached;
        try (Session session = factory.openSession()) {
            detached = session.find(Student.class, id);
        }
        System.out.println("-- after the Session closed: still usable as an object = " + detached.getFullName());
        detached.setEmail("changed-while-detached@example.com");
        SqlRecorder.clear();
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student merged = session.merge(detached);
            System.out.println("-- merge() returns a different object: " + (merged != detached) + ", the argument is managed: " + session.contains(detached) + ", the result is managed: " + session.contains(merged));
            tx.commit();
            SqlRecorder.print("-- SQL for merge() of a detached object");
        }
    }

    static void autoFlushBeforeQuery(SessionFactory factory, long id) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = session.find(Student.class, id);
            SqlRecorder.clear();
            student.setLevel(Student.Level.ADVANCED);
            Long advanced = session.createQuery("select count(s) from Student s where s.level = :level", Long.class).setParameter("level", Student.Level.ADVANCED).getSingleResult();
            System.out.println("-- a query after an in-memory change counts the change: " + advanced);
            SqlRecorder.print("-- SQL: the pending UPDATE is flushed before the query");
            tx.rollback();
        }
    }

    static void proxies(SessionFactory factory, long id) {
        try (Session session = factory.openSession()) {
            SqlRecorder.clear();
            Student reference = session.getReference(Student.class, id);
            System.out.println("-- getReference(): initialized = " + Hibernate.isInitialized(reference) + ", class = " + reference.getClass().getSimpleName().replaceAll("\\$.*", "") + " subclass");
            SqlRecorder.print("-- SQL after getReference()");
            System.out.println("-- reading the id does not load it, reading a field does: id = " + reference.getId());
            SqlRecorder.print("-- SQL after getId()");
            System.out.println("-- name = " + reference.getFullName());
            SqlRecorder.print("-- SQL after getFullName()");
        }
        Student orphan;
        try (Session session = factory.openSession()) {
            orphan = session.getReference(Student.class, id);
        }
        try {
            orphan.getFullName();
        } catch (RuntimeException e) {
            System.out.println("-- proxy used after its Session closed: " + e.getClass().getSimpleName());
        }
    }
}

Chạy MySQL bằng Docker Compose rồi chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.hibernate.intro.PersistenceContextDemo (xem mục Chuẩn bị ở đầu bài để có docker-compose.yml và pom.xml). Hibernate ghi log khởi động ra stderr; phần dưới trích kết quả của chương trình.

persist với khóa IDENTITY: INSERT chạy ngay

text
-- persist: state before = transient, id = null
-- SQL right after persist(), before commit (IDENTITY must know the id)
  insert into student (email,full_name,level) values (?,?,?)
-- state after persist: managed = true, id = 1
-- SQL at commit
  (no SQL)

Entity mới (new Student) là transient: chưa có id, chưa được theo dõi. persist() đưa nó vào persistence context (trạng thái managed) và, vì khóa sinh bằng IDENTITY (cơ sở dữ liệu cấp id khi chèn dòng), Hibernate phải chạy INSERT ngay lúc persist để biết id. Tới commit không còn câu nào cần gửi.

find hai lần: một câu SELECT

text
-- two find() calls in one Session: same object = true
-- SQL for two find() calls
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
-- SQL for a find() after clear()
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?

Trong một Session, find lần hai không chạy SQL và trả về chính đối tượng của lần đầu (== là true): đó là bộ nhớ đệm cấp một, tức persistence context, luôn bật và không cấu hình được. Sau clear() bộ nhớ đệm trống nên lần find kế tiếp lại chạy SELECT. Persistence context giữ một đối tượng cho mỗi dòng trong phạm vi một Session, nên hai biến cùng trỏ một dòng luôn là một đối tượng.

Cách đọc hình: find lần đầu chạy một SELECT và đặt vào persistence context một đối tượng kèm bản chụp giá trị lúc nạp. find lần hai trong cùng Session trả lại đúng đối tượng đó, không SQL. clear() làm trống persistence context nên find kế tiếp xuống cơ sở dữ liệu lần nữa.

Dirty checking: UPDATE mà không gọi update

text
-- SQL after setFullName(), before commit
  (no SQL)
-- SQL at commit (no update() call was made)
  update student set email=?,full_name=?,level=? where id=?
-- SQL when the value is set to what it already was
  (no SQL)

Entity managed được Session so với bản chụp lúc nạp. Gọi setFullName chỉ đổi đối tượng trong bộ nhớ, không có SQL. Lúc commit, Hibernate flush: so từng entity với bản chụp (dirty checking), và với entity đã đổi, gửi UPDATE mà bạn không hề gọi. Hai chi tiết đáng chú ý. Một: câu UPDATE chứa mọi cột (email, full_name, level) dù chỉ tên đổi, vì Hibernate dùng một câu lệnh dựng sẵn cho entity. Hai: gán lại đúng giá trị cũ không sinh SQL nào, vì so với bản chụp thì không có gì khác. Hoạt cảnh dưới đây diễn lại:

Đang tải Animation…

Đây chính là cơ chế của câu chuyện mở đầu: dòng setEmail(...toLowerCase()) trong vòng duyệt làm 40.000 entity managed đổi giá trị, và commit ghi hết xuống. Cách phòng: không sửa entity managed khi không định lưu (chuyển sang giá trị tạm hoặc DTO để in), và dùng @DynamicUpdate (mục sau) nếu cần giảm số cột ghi.

Ba trạng thái và merge

text
-- after the Session closed: still usable as an object = Nguyễn Văn An (đã sửa)
-- merge() returns a different object: true, the argument is managed: false, the result is managed: true
-- SQL for merge() of a detached object
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
  update student set email=?,full_name=?,level=? where id=?

Sau khi Session đóng, entity thành detached: vẫn là đối tượng Java dùng được nhưng không còn được theo dõi, nên sửa nó không sinh SQL. Muốn ghi thay đổi, đưa nó vào một Session mới bằng merge(). Thí nghiệm cho thấy merge trả về một đối tượng khác đang managed, còn đối tượng bạn truyền vào vẫn detached; bạn phải dùng giá trị trả về. Và merge chạy một SELECT trước (nạp bản hiện tại để so) rồi mới UPDATE: gọi merge với đối tượng detached không miễn phí.

Cách đọc hình: new cho trạng thái transient; persist hoặc find đưa vào trạng thái managed; đóng Session hay clear chuyển sang detached; remove chuyển sang removed (dòng bị xóa ở flush). merge lấy một entity detached và trả về một bản sao managed (nét đứt quay về managed).

Flush: khi nào SQL thật sự đi

text
-- a query after an in-memory change counts the change: 1
-- SQL: the pending UPDATE is flushed before the query
  update student set email=?,full_name=?,level=? where id=?
  select count(s1_0.id) from student s1_0 where s1_0.level=?

Thay đổi chờ ở bộ nhớ cho tới khi có flush, và flush xảy ra ở ít nhất hai thời điểm: commit, và trước một truy vấn có thể bị ảnh hưởng bởi thay đổi chờ (chế độ AUTO mặc định). Thí nghiệm sửa level thành ADVANCED rồi chạy count theo level: Hibernate gửi UPDATE trước, rồi SELECT count, nên truy vấn thấy thay đổi chưa commit của chính giao dịch (kết quả 1). Điều đó giữ cho truy vấn nhất quán với đối tượng trong bộ nhớ, nhưng cũng nghĩa là một truy vấn "đọc" có thể làm phát ra UPDATE.

Hình: khi nào Hibernate gửi SQL: ngay, hoặc ở flush

Cách đọc hình: persist với khóa IDENTITY gửi INSERT ngay (hàng trên); persist với SEQUENCE và các thay đổi trên entity managed đều chờ tới flush (hàng giữa và dưới); flush xảy ra ở commit hoặc trước một truy vấn HQL hay Criteria.

Proxy của getReference

text
-- getReference(): initialized = false, class = Student subclass
-- SQL after getReference()
  (no SQL)
-- reading the id does not load it, reading a field does: id = 1
-- SQL after getId()
  (no SQL)
-- name = Nguyễn Văn An (đã sửa)
-- SQL after getFullName()
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
-- proxy used after its Session closed: LazyInitializationException

getReference trả về một proxy (một lớp con do Hibernate sinh ra) chỉ mang id, không có SQL. Đọc id vẫn không có SQL; đọc một trường khác làm proxy nạp dữ liệu bằng một SELECT. Proxy chỉ nạp được khi Session còn mở: dùng sau khi Session đóng thì ném LazyInitializationException. Điều này quay lại ở bài sau với quan hệ giữa các entity, nơi proxy xuất hiện khắp nơi.

Cách đọc hình: proxy chỉ biết id, nên getId() được trả lời ngay. Lần đầu đọc một trường khác (getFullName()), proxy chạy SELECT. Nếu Session đã đóng ở thời điểm đó, nó ném LazyInitializationException.

Chiến lược sinh khóa và @DynamicUpdate

Hai thứ còn lại nằm trong IdStrategiesDemo. Entity Ticket dùng khóa SEQUENCE với allocationSize = 50 (mỗi lần xin cơ sở dữ liệu một dải 50 số), và Note dùng @DynamicUpdate:

src/main/java/dev/canhdominich/hibernate/intro/Ticket.java

java
package dev.canhdominich.hibernate.intro;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;

@Entity
public class Ticket {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "ticket_seq")
    @SequenceGenerator(name = "ticket_seq", sequenceName = "ticket_seq", allocationSize = 50)
    private Long id;

    private String subject;

    protected Ticket() {
    }

    public Ticket(String subject) {
        this.subject = subject;
    }

    public Long getId() {
        return id;
    }
}

src/main/java/dev/canhdominich/hibernate/intro/Note.java

java
package dev.canhdominich.hibernate.intro;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.hibernate.annotations.DynamicUpdate;

@Entity
@DynamicUpdate
public class Note {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String body;

    protected Note() {
    }

    public Note(String title, String body) {
        this.title = title;
        this.body = body;
    }

    public Long getId() {
        return id;
    }

    public void setTitle(String title) {
        this.title = title;
    }
}

src/main/java/dev/canhdominich/hibernate/intro/IdStrategiesDemo.java

java
package dev.canhdominich.hibernate.intro;

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;

public final class IdStrategiesDemo {

    private IdStrategiesDemo() {
    }

    public static void main(String[] args) {
        try (SessionFactory factory = Factories.build(DbConfig.fromEnvironment(), Student.class, Ticket.class, Note.class)) {
            SqlRecorder.clear();
            try (Session session = factory.openSession()) {
                Transaction tx = session.beginTransaction();
                for (int i = 1; i <= 3; i++) {
                    session.persist(new Student("Student " + i, "s" + i + "@example.com"));
                }
                SqlRecorder.print("-- IDENTITY: three persist() calls, nothing committed yet");
                tx.commit();
            }
            try (Session session = factory.openSession()) {
                Transaction tx = session.beginTransaction();
                long before = factory.getStatistics().getPrepareStatementCount();
                for (int i = 1; i <= 3; i++) {
                    session.persist(new Ticket("Ticket " + i));
                }
                SqlRecorder.print("-- SEQUENCE (allocationSize 50): three persist() calls, nothing committed yet");
                System.out.println("-- JDBC statements prepared during those three persist() calls: " + (factory.getStatistics().getPrepareStatementCount() - before) + " (reading the id generator)");
                System.out.println("-- MySQL has no sequences, Hibernate keeps the counter in a table: " + session.createNativeQuery("select table_name from information_schema.tables where table_schema = database() and table_name = 'ticket_seq'", String.class).getResultList());
                SqlRecorder.clear();
                tx.commit();
                SqlRecorder.print("-- SEQUENCE: SQL at commit");
            }
            try (Session session = factory.openSession()) {
                Transaction tx = session.beginTransaction();
                Note note = new Note("first title", "a long body that did not change");
                session.persist(note);
                tx.commit();
                SqlRecorder.clear();
                tx = session.beginTransaction();
                session.find(Note.class, note.getId()).setTitle("second title");
                tx.commit();
                SqlRecorder.print("-- @DynamicUpdate: only the changed column is in the UPDATE");
            }
        }
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.intro.IdStrategiesDemo:

text
-- IDENTITY: three persist() calls, nothing committed yet
  insert into student (email,full_name,level) values (?,?,?)
  insert into student (email,full_name,level) values (?,?,?)
  insert into student (email,full_name,level) values (?,?,?)
-- SEQUENCE (allocationSize 50): three persist() calls, nothing committed yet
  (no SQL)
-- JDBC statements prepared during those three persist() calls: 4 (reading the id generator)
-- MySQL has no sequences, Hibernate keeps the counter in a table: [ticket_seq]
-- SEQUENCE: SQL at commit
  insert into Ticket (subject,id) values (?,?)
  insert into Ticket (subject,id) values (?,?)
  insert into Ticket (subject,id) values (?,?)
-- @DynamicUpdate: only the changed column is in the UPDATE
  update Note set title=? where id=?
Chiến lượcKhi nào INSERT chạyGom lô được khôngGhi chú trên MySQL
IDENTITYNgay lúc persist, mỗi entity một lầnKhông: mỗi lần cần id từ chính dòng vừa chènCột AUTO_INCREMENT tự nhiên của MySQL
SEQUENCEỞ flush, sau khi id đã được cấp từ bộ cấp phátCó (bài hiệu năng sẽ đo)MySQL không có sequence, Hibernate dùng một bảng (ticket_seq) làm bộ đếm và đọc nó khi cần id, nên có vài câu JDBC ẩn

Dòng JDBC statements prepared ... : 4 cho thấy "không SQL" của SEQUENCE chỉ đúng với SqlRecorder: bốn lệnh JDBC đã chạy ở phía sau để đọc và cập nhật bảng đếm. Cuối cùng @DynamicUpdate cho UPDATE chỉ chứa cột title (chỉ cột thật sự đổi), đổi lấy việc Hibernate phải dựng câu lệnh mỗi lần.

Cách đọc hình: mặc định UPDATE liệt kê cả ba cột dù chỉ một trường đổi (hàng trên). Với @DynamicUpdate chỉ cột đổi có mặt (hàng giữa). Khi gán lại giá trị cũ không có UPDATE (hàng dưới).

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Tạo SessionFactoryMột lần lúc khởi động, tái dùngTốn vài giây khởi động, đổi lấy Session rẻ
Phạm vi SessionNgắn: một yêu cầu hoặc một giao dịchEntity thành detached sau đó; cần chiến lược nạp dữ liệu
Sửa entity managedChỉ khi định lưuCần kỷ luật: mọi thay đổi trên entity managed sẽ được ghi
Khóa sinhSEQUENCE khi cần gom lô; IDENTITY khi đơn giảnSEQUENCE trên MySQL dùng bảng đếm; IDENTITY chặn gom lô
Cập nhậtMặc định (mọi cột) hoặc @DynamicUpdateDynamic: ít cột nhưng dựng câu lệnh mỗi lần
merge cho detachedDùng khi thật sự cầnTốn một SELECT; cần dùng giá trị trả về

Playbook: gặp sự cố ở tầng Hibernate cơ bản thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Dữ liệu tự đổi dù không gọi lưuCó sửa entity managed trong giao dịch khôngDirty checkingKhông sửa entity managed khi chỉ đọc; dùng giá trị tạm hoặc DTO
Không có gì được ghiCó commit khôngQuên commit, hoặc rollbackKiểm tra giao dịch được commit
LazyInitializationExceptionProxy hoặc quan hệ được dùng ở đâuDùng sau khi Session đóngNạp dữ liệu cần dùng trước khi đóng (bài sau)
UPDATE chứa mọi cột, tốn kémCột lớn trong entityMặc định ghi mọi cột@DynamicUpdate hoặc tách cột lớn
INSERT hàng loạt chậmChiến lược sinh khóaIDENTITY chặn gom lôSEQUENCE và cấu hình gom lô
Truy vấn "đọc" làm phát UPDATECó thay đổi chờ flushAuto flush trước truy vấnTách đọc và ghi, hoặc đặt chế độ flush
Mã cũ gọi save hoặc saveOrUpdate không biên dịchPhiên bản HibernateHai phương thức đã bị xóaDùng persist và merge

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Bind for 0.0.0.0:3307 failed: port is already allocatedCổng 3307 đã bị chiếm (ví dụ bởi lab trước chưa tắt)docker compose down ở lab trước, hoặc MYSQL_PORT=3308 docker compose up -d rồi export JDBC_URL=jdbc:mysql://localhost:3308/shop
Communications link failure khi chạy chương trìnhMySQL chưa sẵn sàng hoặc sai cổngdocker compose ps chờ healthy, kiểm tra JDBC_URL
Could not find a valid Docker environment khi mvn testDocker chưa chạyBật Docker rồi chạy lại

Bài thực hành

Làm trong dự án của bài (thư mục 05-hibernate/01-gioi-thieu-kien-truc). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. docker compose up -d, chờ healthy, chạy PersistenceContextDemo. Tiêu chí: thấy INSERT ngay sau persist và (no SQL) ở commit.
  2. Đếm SELECT. Thêm một lần find thứ ba trong cùng Session ở firstLevelCache. Tiêu chí: vẫn chỉ một SELECT; thêm session.evict(first) trước đó và thấy SELECT thứ hai.
  3. Gây UPDATE ngoài ý muốn. Trong dirtyChecking, thêm dòng student.setEmail(student.getEmail().toUpperCase()) để "in cho đẹp" rồi commit. Tiêu chí: thấy câu UPDATE xuất hiện dù không gọi lưu; sau đó thay bằng một biến tạm để in và thấy UPDATE biến mất.
  4. So khóa sinh. Chạy IdStrategiesDemo. Tiêu chí: ba INSERT ngay với IDENTITY, ba INSERT ở commit với SEQUENCE; đếm lệnh JDBC ẩn.
  5. Thử @DynamicUpdate. Thêm @DynamicUpdate cho Student và chạy lại dirtyChecking. Tiêu chí: UPDATE chỉ còn cột full_name.
  6. Gọi merge sai cách. Trong entityStates, bỏ giá trị trả về của merge và tiếp tục sửa detached. Tiêu chí: giải thích vì sao thay đổi sau đó không được ghi.
  7. Chạy bộ test (tùy chọn). Bộ test tự động của dự án nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 9, Failures: 0, Errors: 0. Dọn dẹp bằng docker compose down -v.

Hiểu lầm và giới hạn

  • "Phải gọi update hoặc save để lưu thay đổi." Với entity managed, commit tự ghi; hai phương thức đó còn bị xóa khỏi Session.
  • "persist luôn gửi INSERT ngay." Chỉ với IDENTITY; với SEQUENCE nó chờ flush.
  • "Cache cấp một có thể tắt hoặc cấu hình." Nó là một phần của Session, luôn bật; clear và evict để làm trống.
  • "merge giống persist." merge làm việc với entity detached và trả về bản sao; persist chỉ cho entity transient.
  • Số đo thuộc về Hibernate 7.4.13 với MySQL 8.4.9 và dialect MySQLDialect; dạng câu SQL, tên bí danh (s1_0) và số lệnh ẩn có thể khác ở phiên bản khác. Bài chưa nói về quan hệ giữa các entity, truy vấn HQL, bộ nhớ đệm cấp hai và khóa; các bài sau trình bày.

Tự kiểm tra

  1. Một chương trình mở một Session cho mỗi yêu cầu và tạo SessionFactory mới cho mỗi yêu cầu. Vấn đề là gì?
  2. Vì sao persist với khóa IDENTITY gửi INSERT ngay, còn với SEQUENCE thì không?
  3. Bạn nạp một entity, đổi hai trường rồi commit nhưng không gọi lưu. Điều gì xảy ra, và câu UPDATE chứa cột nào?
  4. merge(detached) trả về copy. Bạn tiếp tục sửa detached. Có được ghi không?
  5. Một truy vấn đếm chạy sau khi bạn sửa một entity managed. Thứ tự câu SQL gửi đi là gì, và vì sao?

Gợi ý đáp án. (1) SessionFactory đắt (đo lâu hơn hơn 20 lần Session) nên chỉ tạo một lần. (2) IDENTITY lấy id từ chính dòng vừa chèn nên phải chèn ngay; SEQUENCE xin id từ bộ cấp phát nên chờ flush. (3) Dirty checking phát hiện thay đổi và gửi một UPDATE ở commit, mặc định chứa mọi cột. (4) Không: detached không được theo dõi; chỉ copy đang managed. (5) UPDATE trước rồi SELECT count, vì auto flush trước truy vấn để kết quả nhất quán.

Điều cần nhớ

  • SessionFactory đắt và dùng chung; Session rẻ, ngắn và không an toàn luồng; Session là một EntityManager.
  • Persistence context giữ một đối tượng cho mỗi dòng cùng một bản chụp; find lần hai trong cùng Session không có SQL.
  • Entity managed được tự ghi ở flush (dirty checking), mặc định mọi cột; sửa nhầm entity managed là ghi nhầm.
  • Flush xảy ra ở commit và trước truy vấn; IDENTITY chèn ngay, SEQUENCE chờ flush.
  • merge trả về bản sao managed; proxy chỉ nạp khi Session còn mở.

Đọc thêm

Bài tiếp theo đi vào cấu hình và CRUD: các cách cấu hình Hibernate, vòng đời của một thao tác tạo, đọc, sửa, xóa, và những bẫy của giao dịch.