Cấu hình và CRUD với Hibernate

Xem thử

Nhóm kiểm thử vừa dựng xong môi trường staging bằng cách sao chép tệp cấu hình của máy lập trình, trong đó có dòng hibernate.hbm2ddl.auto=create. Hai tuần sau, trong lúc chuẩn bị buổi demo cho khách hàng, một lập trình viên khởi động lại dịch vụ để nạp bản vá nhỏ. Dữ liệu demo của tuần trước, gồm mấy trăm khách hàng mẫu mà nhóm bán hàng đã nhập tỉ mỉ, biến mất sạch. Hibernate không lỗi gì cả. Nó chỉ làm đúng điều dòng cấu hình đó bảo: mỗi lần khởi động, xóa các bảng của entity rồi tạo lại bảng trống.

Cấu hình Hibernate trông như vài dòng thuộc tính vô hại, nhưng mỗi dòng đổi hành vi thật: nó chọn bộ quản lý kết nối, quyết định có được phép sửa hoặc xóa bảng không, quyết định SQL có được ghi ra hay không. Bài này đo từng lựa chọn thay vì kể lại tài liệu: bốn cách cấu hình cho cùng một kết quả, cái gì quyết định pool kết nối, năm chế độ hbm2ddl.auto trên một bảng thật, rồi CRUD hoàn chỉnh cùng các bẫy giao dịch.

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

  • Cấu hình Hibernate bằng bốn cách (mã, tệp thuộc tính, hibernate.cfg.xml, persistence.xml) và chọn cách phù hợp với bí mật ở ngoài tệp.
  • Biết bộ quản lý kết nối nào đang chạy, vì sao, và cách đặt cỡ pool.
  • Chọn đúng chế độ hbm2ddl.auto cho từng môi trường và nêu hậu quả của sai lầm.
  • Viết CRUD trong một giao dịch có kiểm soát bằng một lớp UnitOfWork, và đọc câu SQL mà từng thao tác sinh ra.
  • Nhận ra bốn bẫy: HQL hàng loạt làm entity bị cũ, entity detached, quên commit, rollback không hoàn tác bộ nhớ.

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 với hibernate-hikaricp, HikariCP 7.1.0). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 05-hibernate/02-cau-hinh-va-crud, 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.Final (kèm hibernate-hikaricp)khai báo trong pom.xml
HikariCP7.1.0khai 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
02-cau-hinh-va-crud/
  docker-compose.yml           MySQL 8.4, database shop, user app (cổng 3307)
  pom.xml
  src/main/resources/
    hibernate.cfg.xml          cấu hình dạng XML
    META-INF/persistence.xml   cấu hình theo chuẩn JPA
    lab/hibernate-lab.properties  cấu hình dạng tệp thuộc tính
  src/main/java/dev/canhdominich/hibernate/crud/
    DbConfig.java              đọc URL, user, mật khẩu từ biến môi trường
    Student.java               entity có cột email duy nhất
    SqlRecorder.java           StatementInspector ghi lại mọi câu SQL
    Configurations.java        bốn cách dựng SessionFactory và hai bộ quản lý kết nối
    UnitOfWork.java            chạy công việc trong một Session và một giao dịch
    ConfigurationDemo.java     so sánh dialect và bộ quản lý kết nối của các cách cấu hình
    SchemaDemo.java            các chế độ hbm2ddl.auto trên bảng thật
    CrudDemo.java              tạo, đọc, sửa, xóa, HQL hàng loạt, dòng không tồn tại
    TransactionPitfallsDemo.java  entity detached, quên commit, ràng buộc, rollback

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

pom.xml

Dự án Maven Java 17 với hibernate-core và hibernate-hikaricp 7.4.13.Final, HikariCP 7.1.0 khai báo tường minh (cần ở thời điểm biên dịch để chương trình hỏi cỡ pool), Connector/J, slf4j-simple, cùng JUnit, Testcontainers và AssertJ cho test.

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-cau-hinh-crud</artifactId>
    <version>1.0.0</version>
    <name>Hibernate - Cau hinh va CRUD</name>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <main.class>dev.canhdominich.hibernate.crud.CrudDemo</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.hibernate.orm</groupId>
            <artifactId>hibernate-hikaricp</artifactId>
            <version>${hibernate.version}</version>
        </dependency>
        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>7.1.0</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.

Cấu hình: bốn cách, một kết quả

Hibernate đọc cấu hình từ một tập thuộc tính (hibernate.connection.url, hibernate.hbm2ddl.auto...) và một danh sách lớp entity. Cách bạn đưa chúng vào có bốn kiểu. Dự án dùng chung ba thứ phụ trợ. DbConfig giữ thông tin kết nối từ biến môi trường (để mật khẩu không nằm trong tệp cấu hình):

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

java
package dev.canhdominich.hibernate.crud;

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

SqlRecorder là StatementInspector ghi lại mọi câu SQL, như ở bài trước, để các thí nghiệm in ra được:

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

java
package dev.canhdominich.hibernate.crud;

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

Và entity Student, lần này có cột email duy nhất (unique = true) để bài có thể gây ra vi phạm ràng buộc:

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

java
package dev.canhdominich.hibernate.crud;

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, unique = true, 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 Level getLevel() {
        return level;
    }

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

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

Bốn cách dựng SessionFactory nằm trong lớp Configurations:

src/main/java/dev/canhdominich/hibernate/crud/Configurations.java

java
package dev.canhdominich.hibernate.crud;

import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;

public final class Configurations {

    private Configurations() {
    }

    private static Map<String, String> connection(DbConfig config) {
        Map<String, String> map = new HashMap<>();
        map.put("hibernate.connection.url", config.url());
        map.put("hibernate.connection.username", config.user());
        map.put("hibernate.connection.password", config.password());
        return map;
    }

    public static SessionFactory programmatic(DbConfig config) {
        Configuration configuration = new Configuration()
                .setProperty("hibernate.hbm2ddl.auto", "create-drop")
                .setProperty("hibernate.session_factory.statement_inspector", SqlRecorder.class.getName())
                .addAnnotatedClass(Student.class);
        connection(config).forEach(configuration::setProperty);
        return configuration.buildSessionFactory();
    }

    public static SessionFactory fromPropertiesFile(DbConfig config) throws IOException {
        Properties properties = new Properties();
        try (InputStream in = Configurations.class.getResourceAsStream("/lab/hibernate-lab.properties")) {
            properties.load(in);
        }
        Configuration configuration = new Configuration().addProperties(properties).addAnnotatedClass(Student.class);
        connection(config).forEach(configuration::setProperty);
        return configuration.buildSessionFactory();
    }

    public static SessionFactory fromCfgXml(DbConfig config) {
        Configuration configuration = new Configuration().configure();
        connection(config).forEach(configuration::setProperty);
        return configuration.buildSessionFactory();
    }

    public static EntityManagerFactory fromPersistenceXml(DbConfig config) {
        Map<String, Object> overrides = new HashMap<>(connection(config));
        return Persistence.createEntityManagerFactory("lab", overrides);
    }

    public static SessionFactory withHikari(DbConfig config, int poolSize) {
        Configuration configuration = new Configuration()
                .setProperty("hibernate.hbm2ddl.auto", "create-drop")
                .setProperty("hibernate.connection.provider_class", "org.hibernate.hikaricp.internal.HikariCPConnectionProvider")
                .setProperty("hibernate.hikari.maximumPoolSize", String.valueOf(poolSize))
                .setProperty("hibernate.session_factory.statement_inspector", SqlRecorder.class.getName())
                .addAnnotatedClass(Student.class);
        connection(config).forEach(configuration::setProperty);
        return configuration.buildSessionFactory();
    }

    public static SessionFactory withBuiltInPool(DbConfig config) {
        Configuration configuration = new Configuration()
                .setProperty("hibernate.hbm2ddl.auto", "create-drop")
                .setProperty("hibernate.connection.provider_class", "org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProvider")
                .addAnnotatedClass(Student.class);
        connection(config).forEach(configuration::setProperty);
        return configuration.buildSessionFactory();
    }
}

Ba cách đầu cùng dùng các tệp sau. hibernate.cfg.xml là XML mà configure() đọc từ classpath:

src/main/resources/hibernate.cfg.xml

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-configuration PUBLIC "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
    <session-factory>
        <property name="hibernate.hbm2ddl.auto">create-drop</property>
        <property name="hibernate.session_factory.statement_inspector">dev.canhdominich.hibernate.crud.SqlRecorder</property>
        <mapping class="dev.canhdominich.hibernate.crud.Student"/>
    </session-factory>
</hibernate-configuration>

persistence.xml là cấu hình theo chuẩn JPA, đặt ở META-INF; địa chỉ kết nối được truyền vào lúc gọi createEntityManagerFactory:

src/main/resources/META-INF/persistence.xml

xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.2">
    <persistence-unit name="lab">
        <class>dev.canhdominich.hibernate.crud.Student</class>
        <properties>
            <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
            <property name="hibernate.session_factory.statement_inspector" value="dev.canhdominich.hibernate.crud.SqlRecorder"/>
        </properties>
    </persistence-unit>
</persistence>

Và một tệp thuộc tính để chương trình tự nạp:

src/main/resources/lab/hibernate-lab.properties

properties
hibernate.hbm2ddl.auto=create-drop
hibernate.session_factory.statement_inspector=dev.canhdominich.hibernate.crud.SqlRecorder

Chương trình ConfigurationDemo dựng SessionFactory bằng từng cách rồi hỏi dialect cùng bộ quản lý kết nối đang dùng:

src/main/java/dev/canhdominich/hibernate/crud/ConfigurationDemo.java

java
package dev.canhdominich.hibernate.crud;

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
import org.hibernate.engine.spi.SessionFactoryImplementor;

public final class ConfigurationDemo {

    private ConfigurationDemo() {
    }

    public static void main(String[] args) throws Exception {
        DbConfig config = DbConfig.fromEnvironment();
        describe("programmatic Configuration       ", Configurations.programmatic(config));
        describe("hibernate.properties-style file   ", Configurations.fromPropertiesFile(config));
        describe("hibernate.cfg.xml                 ", Configurations.fromCfgXml(config));
        try (EntityManagerFactory emf = Configurations.fromPersistenceXml(config)) {
            describe("persistence.xml (JPA bootstrap)   ", emf.unwrap(SessionFactory.class));
        }
        describe("hikaricp module, pool size 7       ", Configurations.withHikari(config, 7));
        describe("built-in pool forced explicitly    ", Configurations.withBuiltInPool(config));
    }

    static void describe(String label, SessionFactory factory) {
        try (factory) {
            SessionFactoryImplementor implementor = (SessionFactoryImplementor) factory;
            Dialect dialect = implementor.getJdbcServices().getDialect();
            ConnectionProvider provider = implementor.getServiceRegistry().getService(ConnectionProvider.class);
            String detail = provider.isUnwrappableAs(com.zaxxer.hikari.HikariDataSource.class)
                    ? " maximumPoolSize=" + provider.unwrap(com.zaxxer.hikari.HikariDataSource.class).getMaximumPoolSize() : "";
            System.out.println(label + " dialect=" + dialect.getClass().getSimpleName() + " connectionProvider=" + provider.getClass().getSimpleName() + detail);
        }
    }
}

Chạy MySQL bằng Docker Compose rồi chạy bằng mvn -q compile exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.ConfigurationDemo (xem mục Chuẩn bị ở đầu bài để có docker-compose.yml và pom.xml). Kết quả:

text
programmatic Configuration        dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hibernate.properties-style file   dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hibernate.cfg.xml                 dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
persistence.xml (JPA bootstrap)   dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=10
hikaricp module, pool size 7      dialect=MySQLDialect connectionProvider=HikariCPConnectionProvider maximumPoolSize=7
built-in pool forced explicitly   dialect=MySQLDialect connectionProvider=DriverManagerConnectionProvider
CáchNơi khai báoHợp khi
Bằng mã (Configuration)Trong chương trìnhCấu hình tính toán lúc chạy, hoặc nhúng trong ứng dụng nhỏ
Tệp thuộc tínhTệp khóa=giá trịDễ đổi theo môi trường
hibernate.cfg.xmlXML trên classpathCách native cổ điển, liệt kê cả lớp entity
persistence.xmlXML theo chuẩn JPAMuốn mã không phụ thuộc Hibernate (dùng EntityManagerFactory)

Bốn cách cho cùng kết quả: cùng dialect MySQLDialect (Hibernate tự nhận ra từ kết nối, không cần khai báo hibernate.dialect). Quy tắc thực dụng: cấu trúc (entity, hành vi) ở tệp hoặc mã, còn bí mật và địa chỉ kết nối ở biến môi trường, đúng như Configurations làm bằng connection(config).

Cách đọc hình: bốn ô bên trái là bốn cách khai báo; tất cả đổ vào cùng một SessionFactory với cùng dialect. Ô nét đứt bên phải nhắc rằng địa chỉ và mật khẩu kết nối được thêm từ biến môi trường chứ không nằm trong tệp.

Bộ quản lý kết nối do classpath quyết định

Nhìn lại kết quả ở trên: cả bốn cách đều ra HikariCPConnectionProvider với maximumPoolSize=10, dù không dòng nào chọn HikariCP. Lý do: dự án có hibernate-hikaricp trên classpath, và Hibernate tự chọn nó khi thấy module đó. Hai dòng cuối của kết quả cho thấy hai cách điều khiển: hibernate.hikari.maximumPoolSize=7 đổi cỡ pool (đo qua getMaximumPoolSize() là 7), và hibernate.connection.provider_class buộc dùng bộ có sẵn DriverManagerConnectionProvider. Ở bài trước, không có module HikariCP, Hibernate dùng bộ có sẵn này (log ghi Pool: DriverManagerConnectionProvider, tối đa 20 kết nối).

Bài học thực tế: pool kết nối là phần của hiệu năng, và nó có thể đổi chỉ vì thêm hoặc bớt một jar. Hãy luôn đọc dòng log khởi động về pool, và đặt cỡ pool tường minh (kiến thức từ bài kết nối MySQL: tổng pool của mọi bản sao phải nhỏ hơn max_connections).

Hình: classpath và thuộc tính quyết định bộ quản lý kết nối

Cách đọc hình: hàng trên: chỉ cần jar hibernate-hikaricp trên classpath là Hibernate dùng HikariCP với cỡ mặc định 10. Hàng giữa: đặt hikari.maximumPoolSize=7 thì pool có 7 kết nối. Hàng dưới: đặt provider_class tường minh để buộc bộ có sẵn.

hbm2ddl.auto: Hibernate có được sửa schema không

Thuộc tính hibernate.hbm2ddl.auto quyết định công cụ schema làm gì với bảng khi khởi động. Chương trình SchemaDemo thử từng chế độ trên bảng student thật: dựng sẵn một bảng cũ thiếu cột level và có thêm cột legacy_notes mà entity không biết, rồi cho Hibernate khởi động với từng chế độ:

src/main/java/dev/canhdominich/hibernate/crud/SchemaDemo.java

java
package dev.canhdominich.hibernate.crud;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;

public final class SchemaDemo {

    private static DbConfig CONFIG;

    private SchemaDemo() {
    }

    public static void main(String[] args) throws SQLException {
        run(DbConfig.fromEnvironment());
    }

    public static void run(DbConfig config) throws SQLException {
        CONFIG = config;
        System.out.println("-- no table at all");
        reset("DROP TABLE IF EXISTS student");
        attempt("none", "none");
        attempt("validate", "validate");
        System.out.println("-- an old table that lacks the column level and has an extra legacy_notes column");
        reset("DROP TABLE IF EXISTS student", "CREATE TABLE student (id BIGINT AUTO_INCREMENT PRIMARY KEY, full_name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, legacy_notes TEXT)");
        attempt("validate", "validate");
        attempt("update", "update");
        attempt("update again", "update");
        System.out.println("-- create and create-drop");
        attempt("create", "create");
        attempt("create-drop", "create-drop");
        reset("DROP TABLE IF EXISTS student");
    }

    static void attempt(String label, String mode) throws SQLException {
        try {
            SessionFactory factory = new Configuration()
                    .setProperty("hibernate.connection.url", CONFIG.url())
                    .setProperty("hibernate.connection.username", CONFIG.user())
                    .setProperty("hibernate.connection.password", CONFIG.password())
                    .setProperty("hibernate.hbm2ddl.auto", mode)
                    .addAnnotatedClass(Student.class)
                    .buildSessionFactory();
            factory.close();
            System.out.println(String.format("%-14s started, columns after: %s", label, columns()));
        } catch (RuntimeException e) {
            Throwable root = e;
            while (root.getCause() != null && root.getCause() != root) {
                root = root.getCause();
            }
            System.out.println(String.format("%-14s FAILED %s: %s", label, root.getClass().getSimpleName(), root.getMessage()));
        }
    }

    static List<String> columns() throws SQLException {
        List<String> names = new ArrayList<>();
        try (Connection connection = open(); Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'student' ORDER BY ordinal_position")) {
            while (rs.next()) {
                names.add(rs.getString(1));
            }
        }
        return names;
    }

    static void reset(String... statements) throws SQLException {
        try (Connection connection = open(); Statement statement = connection.createStatement()) {
            for (String sql : statements) {
                statement.execute(sql);
            }
        }
    }

    private static Connection open() throws SQLException {
        return DriverManager.getConnection(CONFIG.url(), CONFIG.user(), CONFIG.password());
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.SchemaDemo. Kết quả:

text
-- no table at all
none           started, columns after: []
validate       FAILED SchemaManagementException: Schema validation: missing table [student]
-- an old table that lacks the column level and has an extra legacy_notes column
validate       FAILED SchemaManagementException: Schema validation: missing column [level] in table [student]
update         started, columns after: [id, full_name, email, legacy_notes, level]
update again   started, columns after: [id, full_name, email, legacy_notes, level]
-- create and create-drop
create         started, columns after: [id, email, full_name, level]
create-drop    started, columns after: []
Chế độHành vi đo đượcDùng ở đâu
noneKhông làm gì: khởi động êm dù chưa có bảng, lỗi chỉ đến ở câu truy vấn đầu tiênMôi trường thật khi schema do công cụ khác quản lý
validateChỉ kiểm tra: ném SchemaManagementException với thông điệp cụ thể (missing table [student], missing column [level] in table [student]) và ứng dụng không khởi độngMôi trường thật: lệch schema bị phát hiện ngay
updateThêm cái còn thiếu (đã thêm level) nhưng không bao giờ xóa (legacy_notes vẫn còn), chạy lại không đổi gìTiện lúc phát triển; tránh ở môi trường thật
createXóa bảng rồi tạo lại: dữ liệu cũ mất, legacy_notes biến mất, thứ tự cột đổiChỉ thí nghiệm
create-dropNhư create, và xóa bảng khi đóng SessionFactory (cột sau đó là [])Thí nghiệm và test

Hoạt cảnh dưới đây diễn lại validate và update trên bảng lệch:

Đang tải Animation…

Đây là lý do create trong tệp cấu hình dùng chung xóa dữ liệu staging ở câu chuyện mở đầu. Quy tắc: create, create-drop chỉ cho test và thí nghiệm; môi trường thật dùng validate (hoặc none) và đổi schema bằng một công cụ migration như Flyway (khóa học sẽ dùng ở dự án Capstone), nơi mỗi thay đổi là một tệp SQL có phiên bản, được xem xét và chạy đúng một lần. update nghe tiện nhưng che sự lệch: nó vá bằng cách thêm, không bao giờ bớt, nên cột thừa tích tụ mà không ai hay.

Cách đọc hình: none không làm gì và lỗi đến muộn; validate ném lỗi cụ thể và dừng ứng dụng; update thêm cột thiếu nhưng giữ mọi thứ thừa; create và create-drop tạo lại bảng và xóa dữ liệu.

CRUD trong một giao dịch có kiểm soát

Mọi thao tác ghi cần một giao dịch. Lớp UnitOfWork đóng gói khuôn: mở Session, bắt đầu giao dịch, chạy công việc, commit, và rollback nếu có ngoại lệ (rồi ném lại), luôn đóng Session:

src/main/java/dev/canhdominich/hibernate/crud/UnitOfWork.java

java
package dev.canhdominich.hibernate.crud;

import java.util.function.Consumer;
import java.util.function.Function;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;

public final class UnitOfWork {

    private UnitOfWork() {
    }

    public static <T> T inTransaction(SessionFactory factory, Function<Session, T> work) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            try {
                T result = work.apply(session);
                tx.commit();
                return result;
            } catch (RuntimeException e) {
                if (tx.isActive()) {
                    tx.rollback();
                }
                throw e;
            }
        }
    }

    public static void run(SessionFactory factory, Consumer<Session> work) {
        inTransaction(factory, session -> {
            work.accept(session);
            return null;
        });
    }
}

Chương trình CrudDemo dùng nó để làm đủ bốn thao tác và vài trường hợp biên, in SQL của từng bước:

src/main/java/dev/canhdominich/hibernate/crud/CrudDemo.java

java
package dev.canhdominich.hibernate.crud;

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

public final class CrudDemo {

    private CrudDemo() {
    }

    public static void main(String[] args) {
        try (SessionFactory factory = Configurations.programmatic(DbConfig.fromEnvironment())) {
            long id = create(factory);
            read(factory, id);
            update(factory, id);
            bulkUpdateIsInvisibleToManagedEntities(factory, id);
            delete(factory, id);
            missingRows(factory, id);
        }
    }

    static long create(SessionFactory factory) {
        SqlRecorder.clear();
        long id = UnitOfWork.inTransaction(factory, session -> {
            Student student = new Student("Nguyễn Văn An", "an@example.com");
            session.persist(student);
            return student.getId();
        });
        SqlRecorder.print("-- create: persist inside one transaction");
        return id;
    }

    static void read(SessionFactory factory, long id) {
        UnitOfWork.run(factory, session -> System.out.println("-- read: " + session.find(Student.class, id)));
        SqlRecorder.print("-- SQL of the read");
    }

    static void update(SessionFactory factory, long id) {
        UnitOfWork.run(factory, session -> session.find(Student.class, id).setFullName("Nguyễn Văn An (cập nhật)"));
        SqlRecorder.print("-- update: load, change, commit");
    }

    static void bulkUpdateIsInvisibleToManagedEntities(SessionFactory factory, long id) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = session.find(Student.class, id);
            int rows = session.createMutationQuery("update Student s set s.level = :level where s.id = :id").setParameter("level", Student.Level.ADVANCED).setParameter("id", id).executeUpdate();
            System.out.println("-- HQL bulk update changed " + rows + " row; the managed object still says " + student.getLevel());
            session.refresh(student);
            System.out.println("-- after refresh() the object says " + student.getLevel());
            tx.commit();
        }
        SqlRecorder.print("-- SQL of the bulk update scenario");
    }

    static void delete(SessionFactory factory, long id) {
        UnitOfWork.run(factory, session -> session.remove(session.find(Student.class, id)));
        SqlRecorder.print("-- delete: load then remove");
    }

    static void missingRows(SessionFactory factory, long id) {
        UnitOfWork.run(factory, session -> {
            System.out.println("-- find() of a missing row returns " + session.find(Student.class, id));
            Student reference = session.getReference(Student.class, id);
            try {
                reference.getFullName();
            } catch (RuntimeException e) {
                System.out.println("-- getReference() of a missing row fails only when used: " + e.getClass().getSimpleName());
            }
        });
    }
}

Chạy bằng mvn -q exec:exec -Dmain.class=dev.canhdominich.hibernate.crud.CrudDemo. Kết quả (phần CRUD):

text
-- create: persist inside one transaction
  insert into student (email,full_name,level) values (?,?,?)
-- read: Student[1, Nguyễn Văn An, BASIC]
-- SQL of the read
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
-- update: load, change, commit
  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=?
-- delete: load then remove
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
  delete from student where id=?
-- find() of a missing row returns null
-- getReference() of a missing row fails only when used: ObjectNotFoundException

Từng thao tác trong thực tế:

  • Create: persist (với khóa IDENTITY, INSERT chạy ngay).
  • Read: find trả null nếu không có dòng; getReference không báo lỗi lúc gọi mà chỉ ném ObjectNotFoundException khi bạn dùng proxy (thông tin hay bị bỏ sót khi kiểm tra tồn tại).
  • Update: nạp, sửa, commit; không có lời gọi cập nhật.
  • Delete: muốn xóa, phải có entity: remove(find(...)) chạy một SELECT rồi DELETE. Muốn xóa không cần nạp, dùng HQL (mục sau), với cái giá riêng.
Hình: bốn thao tác CRUD và các câu SQL tương ứng

Cách đọc hình: mỗi hàng là một thao tác và câu SQL sinh ra: persist chèn ngay, find một SELECT, cập nhật nạp rồi UPDATE ở commit, xóa nạp rồi DELETE.

HQL hàng loạt không cập nhật entity trong bộ nhớ

Câu HQL update/delete chạy thẳng trong cơ sở dữ liệu và không đi qua persistence context. CrudDemo nạp một sinh viên, chạy update Student s set s.level = :level trên chính dòng đó, rồi đọc lại đối tượng:

text
-- HQL bulk update changed 1 row; the managed object still says BASIC
-- after refresh() the object says ADVANCED
-- SQL of the bulk update scenario
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?
  update student s1_0 set level=? where s1_0.id=?
  select s1_0.id,s1_0.email,s1_0.full_name,s1_0.level from student s1_0 where s1_0.id=?

Cơ sở dữ liệu đã đổi nhưng đối tượng trong bộ nhớ vẫn nói BASIC (cũ) cho tới khi refresh() chạy thêm một SELECT nữa. Bài học: sau một câu HQL hàng loạt, dùng refresh hoặc clear cho các entity có thể bị ảnh hưởng, nếu không phần code sau đọc dữ liệu cũ.

Cách đọc hình: sinh viên được nạp với level là BASIC; câu HQL hàng loạt đổi dòng trong cơ sở dữ liệu nhưng entity vẫn đọc ra BASIC; sau refresh nó đọc lại từ cơ sở dữ liệu và thành ADVANCED.

Bốn bẫy giao dịch

Chương trình TransactionPitfallsDemo tái hiện bốn bẫy, mỗi bẫy kèm kết quả đo:

src/main/java/dev/canhdominich/hibernate/crud/TransactionPitfallsDemo.java

java
package dev.canhdominich.hibernate.crud;

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

public final class TransactionPitfallsDemo {

    private TransactionPitfallsDemo() {
    }

    public static void main(String[] args) {
        try (SessionFactory factory = Configurations.programmatic(DbConfig.fromEnvironment())) {
            reset(factory);
            detachedObjects(factory);
            reset(factory);
            forgottenCommit(factory);
            constraintViolation(factory);
            rollbackKeepsMemoryState(factory);
        }
    }

    static void reset(SessionFactory factory) {
        UnitOfWork.run(factory, session -> {
            session.createMutationQuery("delete from Student").executeUpdate();
            session.persist(new Student("An", "an@example.com"));
        });
    }

    static long rows(SessionFactory factory) {
        try (Session session = factory.openSession()) {
            return session.createQuery("select count(s) from Student s", Long.class).getSingleResult();
        }
    }

    static void detachedObjects(SessionFactory factory) {
        Student detached;
        try (Session session = factory.openSession()) {
            detached = session.createQuery("from Student", Student.class).getSingleResult();
        }
        try {
            UnitOfWork.run(factory, session -> session.persist(detached));
        } catch (RuntimeException e) {
            System.out.println("-- persist(detached): " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
        try {
            UnitOfWork.run(factory, session -> session.remove(detached));
            System.out.println("-- remove(detached): no exception, rows left = " + rows(factory));
        } catch (RuntimeException e) {
            System.out.println("-- remove(detached): " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }

    static void forgottenCommit(SessionFactory factory) {
        try (Session session = factory.openSession()) {
            session.beginTransaction();
            session.persist(new Student("Forgotten", "forgotten@example.com"));
        }
        try (Session session = factory.openSession()) {
            Long count = session.createQuery("select count(s) from Student s where s.email = 'forgotten@example.com'", Long.class).getSingleResult();
            System.out.println("-- a Session closed without commit: rows stored = " + count);
        }
    }

    static void constraintViolation(SessionFactory factory) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            try {
                session.persist(new Student("Duplicate", "an@example.com"));
                tx.commit();
            } catch (RuntimeException e) {
                System.out.println("-- duplicate email, raised by persist() because IDENTITY inserts at once: " + e.getClass().getSimpleName() + ", cause " + e.getCause().getClass().getSimpleName());
                System.out.println("-- transaction status after the failure: " + tx.getStatus() + ", active = " + tx.isActive());
            }
        }
    }

    static void rollbackKeepsMemoryState(SessionFactory factory) {
        try (Session session = factory.openSession()) {
            Transaction tx = session.beginTransaction();
            Student student = session.createQuery("from Student s where s.email = 'an@example.com'", Student.class).getSingleResult();
            student.setFullName("Changed then rolled back");
            tx.rollback();
            System.out.println("-- after rollback the object in memory still says: " + student.getFullName());
        }
        try (Session session = factory.openSession()) {
            System.out.println("-- and the database says: " + session.createQuery("from Student s where s.email = 'an@example.com'", Student.class).getSingleResult().getFullName());
        }
    }
}

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

text
-- persist(detached): EntityExistsException: Detached entity passed to persist: dev.canhdominich.hibernate.crud.Student
-- remove(detached): no exception, rows left = 0
-- a Session closed without commit: rows stored = 0
-- duplicate email, raised by persist() because IDENTITY inserts at once: ConstraintViolationException, cause SQLIntegrityConstraintViolationException
-- transaction status after the failure: MARKED_ROLLBACK, active = true
-- after rollback the object in memory still says: Changed then rolled back
-- and the database says: An
  • Entity detached: persist một entity đã có id (detached) ném EntityExistsException. Ngược lại remove một entity detached không ném lỗi ở phiên bản này mà xóa dòng thật (rows left = 0); tài liệu và mã Hibernate cũ có thể nói khác, nên đừng dựa vào ngoại lệ để phát hiện lỗi.
  • Quên commit: Session đóng mà không commit thì không có gì được lưu (rows stored = 0). Không lỗi, không cảnh báo: dữ liệu biến mất lặng lẽ.
  • Vi phạm ràng buộc: chèn email trùng ném ConstraintViolationException (nguyên nhân gốc là lỗi của JDBC) ngay ở persist, không phải ở commit, vì khóa IDENTITY buộc INSERT chạy ngay; sau lỗi giao dịch ở trạng thái MARKED_ROLLBACK, chỉ còn rollback.
  • Rollback không hoàn tác bộ nhớ: sau rollback() cơ sở dữ liệu giữ nguyên (An) nhưng đối tượng Java vẫn mang giá trị đã sửa (Changed then rolled back). Sau lỗi hoặc rollback hãy bỏ Session và các entity của nó, đừng tiếp tục dùng.
Hình: bốn bẫy giao dịch đã đo

Cách đọc hình: hai ô trên là hai hành vi với entity detached (persist ném lỗi, remove lại xóa thật). Ô thứ ba là Session đóng không commit nên không lưu gì. Hai hàng dưới: vi phạm ràng buộc đưa giao dịch về MARKED_ROLLBACK, và rollback khôi phục cơ sở dữ liệu nhưng không khôi phục đối tượng trong bộ nhớ.

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

Quyết địnhLựa chọnĐánh đổi
Cách cấu hìnhCấu trúc ở tệp hoặc mã, bí mật ở biến môi trườngHai nơi để tra khi tìm một giá trị
Pool kết nốiChọn tường minh, đặt cỡ, đọc log khởi độngPhải theo dõi khi thêm hoặc bớt module
hbm2ddl.autovalidate ở môi trường thật, create-drop ở testCần công cụ migration để đổi schema
Giao dịchMột UnitOfWork bao mọi thao tác ghiGiao dịch quá dài giữ khóa lâu
Cập nhật hàng loạtHQL kèm refresh hoặc clearBỏ qua persistence context nên phải tự đồng bộ
Sau lỗiBỏ Session và entity của nóPhải mở Session mới và nạp lại

Playbook: gặp sự cố cấu hình và giao dịch 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 biến mất sau khởi động lạiGiá trị hbm2ddl.autocreate hoặc create-drop ở môi trường có dữ liệuĐổi sang validate ngay, khôi phục từ bản sao lưu
Ứng dụng không khởi động với Schema validationThông điệp nêu bảng hoặc cột nàoSchema lệch so với entityViết migration, không để Hibernate tự sửa
Số kết nối khác mong đợiLog về ConnectionProviderThêm hoặc bớt hibernate-hikaricpChọn tường minh và đặt cỡ pool
Không thấy dữ liệu đã "lưu"Có commit khôngQuên commit hoặc rollbackDùng UnitOfWork, kiểm tra giao dịch
Dữ liệu đọc ra cũ sau cập nhật hàng loạtCó dùng HQL update khôngPersistence context không biếtrefresh hoặc clear
EntityExistsException khi lưuEntity có id chưaTruyền entity detached cho persistDùng merge, hoặc nạp lại
Tiếp tục dùng Session sau lỗi cho kết quả lạTrạng thái giao dịchMARKED_ROLLBACK, đối tượng đã sửaBỏ Session, mở cái mới

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
SchemaManagementException khi chạy CrudDemoBảng student còn sót từ SchemaDemo với cấu trúc cũChạy SchemaDemo đến hết (nó dọn bảng ở cuối) hoặc DROP TABLE student trong database shop
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/02-cau-hinh-va-crud). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. So bốn cách cấu hình. docker compose up -d, chạy ConfigurationDemo. Tiêu chí: cả bốn cho MySQLDialect và HikariCPConnectionProvider.
  2. Đổi cỡ pool. Đặt hikari.maximumPoolSize là 3 trong withHikari. Tiêu chí: dòng tương ứng báo maximumPoolSize=3.
  3. Thử các chế độ schema. Chạy SchemaDemo. Tiêu chí: validate lỗi đúng thông điệp, update giữ legacy_notes, create làm nó biến mất.
  4. Chạy CRUD. Chạy CrudDemo. Tiêu chí: giải thích vì sao update và delete đều có một SELECT đi trước.
  5. Làm entity bị cũ. Trong bulkUpdateIsInvisibleToManagedEntities, thay refresh bằng session.clear() rồi find lại. Tiêu chí: giá trị đúng sau find; giải thích khác biệt với refresh.
  6. Gây từng bẫy. Chạy TransactionPitfallsDemo. Tiêu chí: bảy dòng giống bảng trong bài.
  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: 5, Failures: 0, Errors: 0. Dọn dẹp bằng docker compose down -v.

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

  • "update là chế độ an toàn vì không xóa gì." Nó không xóa nhưng che sự lệch và tích tụ cột thừa.
  • "remove trên entity detached luôn bị từ chối." Ở Hibernate 7.4.13 nó xóa dòng thật.
  • "Lỗi ràng buộc luôn đến lúc commit." Với khóa IDENTITY nó đến ngay ở persist.
  • "Rollback đưa mọi thứ về như cũ." Chỉ cơ sở dữ liệu; đối tượng Java giữ giá trị đã sửa.
  • Số đo thuộc về Hibernate 7.4.13, MySQL 8.4.9 và hibernate-hikaricp trên classpath. Bài chưa thử các tham số pool khác (thời gian chờ, kiểm tra rò rỉ), tích hợp giao dịch với Spring, mức cô lập giao dịch của Hibernate, và công cụ migration; phần sau của khóa trình bày.

Tự kiểm tra

  1. Bạn không khai báo pool nào mà Hibernate vẫn dùng HikariCP. Vì sao, và làm sao biết?
  2. Vì sao hbm2ddl.auto=update không phù hợp với môi trường thật dù không xóa dữ liệu?
  3. getReference của một id không tồn tại có ném lỗi ngay không?
  4. Sau một câu HQL update hàng loạt, vì sao entity trong bộ nhớ vẫn mang giá trị cũ?
  5. Một persist vi phạm ràng buộc duy nhất ném lỗi ở đâu với khóa IDENTITY, và nên làm gì với Session sau đó?

Gợi ý đáp án. (1) Jar hibernate-hikaricp trên classpath làm Hibernate tự chọn nó; đọc log khởi động hoặc hỏi ConnectionProvider. (2) Nó vá bằng cách thêm, không bao giờ bớt, nên che sự lệch và tích tụ cột thừa; dùng validate kèm công cụ migration. (3) Không: chỉ ném ObjectNotFoundException khi dùng proxy. (4) HQL hàng loạt chạy thẳng trong cơ sở dữ liệu và bỏ qua persistence context; cần refresh hoặc clear. (5) Ngay ở persist (vì INSERT chạy ngay); giao dịch ở trạng thái MARKED_ROLLBACK, nên rollback và bỏ Session.

Điều cần nhớ

  • Bốn cách cấu hình cho cùng một kết quả; bí mật ở biến môi trường.
  • Bộ quản lý kết nối do classpath và thuộc tính quyết định; đặt tường minh và đọc log khởi động.
  • validate cho môi trường thật, create-drop cho test; update chỉ thêm và che sự lệch; đổi schema bằng công cụ migration.
  • Ghi trong một UnitOfWork; xóa và cập nhật nạp entity trước; HQL hàng loạt bỏ qua persistence context.
  • Quên commit mất dữ liệu lặng lẽ; rollback không hoàn tác đối tượng trong bộ nhớ.

Đọc thêm

Bài tiếp theo đi vào ánh xạ: annotation cho entity, các loại quan hệ, cascade và HQL, cùng lỗi LazyInitializationException và vấn đề N+1 đầu tiên.