Command Palette

Search for a command to run...

[Advanced Java] Kết nối Spring Boot với Database bằng Spring Data JPA

Viết JDBC thuần cho bạn thấy một lời gọi database thực sự tốn những gì: một connection, một prepared statement, một cursor phải duyệt từng row, và phần map từ column sang field phải tự tay viết. Spring Data JPA xóa gần hết đống code đó. Thứ nó không xóa là SQL — nó chỉ ngừng cho bạn thấy SQL, trừ khi bạn hỏi.

Bài này nói về chuyện hỏi. Mọi thứ bên dưới đều lấy từ một application đã được compile và chạy thật, với spring.jpa.show-sql bật lên, nên mỗi statement được trích ở đây là một statement Hibernate thực sự đã gửi đi. Đó là cách nhanh nhất để thôi coi ORM là phép màu, và cũng là cách tìm ra đúng cái bug mà abstraction này che giấu rất giỏi.

Bốn layer xếp chồng — Spring Data JPA trên Hibernate trên JPA trên JDBC — cùng đi xuống một database

Thứ tự bên dưới là có chủ ý: layer trước, rồi mapping, rồi repository, rồi ba thứ hay vỡ trên production — N+1 select, lazy loading ngoài transaction, và ranh giới transaction không nằm ở chỗ bạn tưởng.

Bốn layer, và bạn đang thực sự gọi cái nào

Bốn cái tên hay bị dùng lẫn lộn như thể chúng thay thế được cho nhau. Không hề, và việc lẫn lộn đó là lý do các message lỗi trông như không biết quy cho ai.

LayerNó là gìNó cho bạn cái gì
JDBCMột API gồm các interface của JVM cộng với driver của hãngConnection, PreparedStatement, ResultSet — bạn tự viết SQL và tự map row
JPAMột specification, ở đây là jakarta.persistence 3.2.0@Entity, @Id, EntityManager, JPQL — annotation và interface, tự nó không chạy gì cả
HibernateMột implementation của specification đóPhần code đọc annotation của bạn, sinh SQL, rồi gọi xuống JDBC
Spring Data JPAMột layer repository nằm trên JPAInterface mà bạn không phải viết implementation, derived query, paging, phần plumbing cho @Transactional

Một lời gọi đi xuyên cả bốn. bookRepository.findAll() là một proxy của Spring Data, nó dựng một JPQL query, Hibernate dịch sang SQL, rồi SQL đi ra ngoài qua JDBC trên một connection lấy từ pool. Boot tự cấu hình pool đó là HikariCP mà không cần bạn yêu cầu — log lúc khởi động có dòng HikariPool-1 - Starting... — và connection pooling là chủ đề riêng nên bài này dừng ở đó.

Đây là cùng một query viết theo hai kiểu, trên cùng một database H2, trong cùng một application đang chạy. Trước hết là JDBC thuần, đúng hình dạng của bài về JDBC:

Java
String sql = "select id, title, isbn, price, published_year from books "
           + "where published_year > ? order by title";
List<Book> found = new ArrayList<>();
try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement(sql)) {
    ps.setInt(1, 1980);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            Book b = new Book(rs.getString("title"), rs.getString("isbn"),
                    rs.getBigDecimal("price"), rs.getInt("published_year"));
            found.add(b);
        }
    }
}

Và repository:

Java
List<Book> viaRepo = books.findByPublishedYearGreaterThanOrderByTitleAsc(1980);

Cả hai in ra đúng bốn row giống nhau. Các dòng bắt đầu bằng [demo] là marker do tôi tự thêm vào cho các transcript bên dưới dễ đọc:

Text
[demo] JDBC found 4:
[demo]   Guards! Guards!
[demo]   Parable of the Sower
[demo]   Small Gods
[demo]   The Fifth Season
[demo] repository found 4:
[demo]   Guards! Guards!
[demo]   Parable of the Sower
[demo]   Small Gods
[demo]   The Fifth Season

Hãy sòng phẳng về cái giá của vụ đổi chác này. Bạn xóa được khoảng hai chục dòng code và nhận về một layer tự quyết định thay bạn: gửi bao nhiêu statement và gửi lúc nào. Phần còn lại của bài này là để nhìn thấy những quyết định đó.

Phần setup: một starter, một driver, và một schema bạn xem được lúc nó được tạo

Hai dependency. Starter kéo theo Hibernate, JPA API, Spring Data JPA, phần transaction của Spring và HikariCP; driver H2 chỉ cần ở scope runtime.

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Phần config đáng quan tâm rất ngắn, và hai trong số các dòng này chính là lý do bài viết này kiểm chứng được thay vì phải tin:

Properties
spring.datasource.url=jdbc:h2:mem:bookstore;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
 
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.generate_statistics=true

show-sql in ra mọi statement Hibernate gửi đi, kèm tiền tố Hibernate:. format_sql xuống dòng cho một câu join đọc được. generate_statistics mở ra bộ đếm prepared statement, và đó là cách phần N+1 bên dưới đếm query thay vì bấm giờ.

Phiên bản chính xác của mọi thứ trong bài

Thành phầnPhiên bản
JDKOpenJDK 21.0.6 (arm64)
Spring Boot4.1.1
Spring Framework7.0.9
Spring Data JPA4.1.1
Hibernate ORM7.4.5.Final
Jakarta Persistence API3.2.0
HikariCP7.0.2
H22.4.240, in-memory

Số phiên bản ở đây quan trọng hơn bình thường: vài message được trích bên dưới đã bị viết lại ở Hibernate 7, và nếu bạn tìm theo cách viết cũ thì sẽ ra lời khuyên dành cho một runtime khác.

Mapping một entity: @Entity, @Id, @GeneratedValue và @Column

Entity là một class bình thường, kèm annotation nói cho Hibernate biết nó thuộc table nào và mỗi field trở thành column ra sao.

Java
@Entity
@Table(name = "books")
public class Book {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 200)
    private String title;
 
    @Column(nullable = false, unique = true, length = 13)
    private String isbn;
 
    @Column(nullable = false, precision = 8, scale = 2)
    private BigDecimal price;
 
    private int publishedYear;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;
 
    protected Book() { }
 
    public Book(String title, String isbn, BigDecimal price, int publishedYear) {
        this.title = title;
        this.isbn = isbn;
        this.price = price;
        this.publishedYear = publishedYear;
    }
    // lược bớt getter và setter
}

@Table đặt tên table; không có nó thì Hibernate suy ra tên từ tên class. @Column mang các constraint — nullable, unique, length, precision/scale — và chúng đi vào DDL được sinh ra, chứ không phải đi vào việc validate lúc runtime. publishedYear không có annotation nào mà vẫn thành column published_year, vì naming strategy mặc định của Boot đổi camel case sang snake case.

DDL mà Hibernate sinh ra, nguyên văn

Với ddl-auto=create-drop, Hibernate phát schema ra lúc khởi động và in nó ra vì show-sql đang bật. Đây là output làm sáng tỏ một ORM nhiều nhất:

SQL
create table authors (
    country varchar(2),
    id bigint generated by default as identity,
    name varchar(120) not null,
    primary key (id)
)
 
create table books (
    price numeric(8,2) not null,
    published_year integer not null,
    author_id bigint not null,
    id bigint generated by default as identity,
    isbn varchar(13) not null unique,
    title varchar(200) not null,
    primary key (id)
)
 
alter table if exists books
   add constraint FKfjixh2vym2cvfj3ufxj91jem7
   foreign key (author_id)
   references authors

Từng annotation đều có mặt trong đó. length = 13 thành varchar(13), unique = true thành not null unique, precision = 8, scale = 2 thành numeric(8,2), và @ManyToOne sinh ra cả column author_id lẫn một foreign key với cái tên tự đặt. Lưu ý generated by default as identity là cách viết của H2; PostgreSQL cũng sẽ ra generated by default as identity, còn MySQL ra auto_increment. Dialect mới là thứ quyết định.

@GeneratedValue: IDENTITY và SEQUENCE hành xử rất khác nhau

IDENTITY để database gán khóa. Điều đó kéo theo một hệ quả mà ít ai nối được với cái annotation: Hibernate không thể biết id trước khi row tồn tại, nên save() buộc phải gửi INSERT ngay lập tức chứ không gộp chung với phần còn lại của transaction.

SEQUENCE thì xin một database sequence cấp giá trị tiếp theo trước, nên entity đã có id trước khi có row nào được ghi, và INSERT được phép đợi tới lúc flush. Đây là một entity thứ hai dùng cách đó:

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

Hibernate tạo sequence với allocation size nằm sẵn trong đó:

SQL
create sequence review_seq start with 1 increment by 50

Và khác biệt về hành vi nhìn thấy được trong log. Với SEQUENCE, save() lấy một id rồi trả về; INSERT chỉ xuất hiện khi có gì đó ép flush:

Text
MARK about to call save()
Hibernate:
    select
        next value
    for
        review_seq
MARK save() returned, id = 1 - no INSERT yet
Hibernate:
    insert
    into
        reviews
        (body, stars, id)
    values
        (?, ?, ?)
MARK flush() returned - the INSERT is above this line
MARK method ending, commit happens after this

Với IDENTITY, đúng đoạn code đó cho ra INSERT trước cả khi save() return. Đó không phải bug và cũng không config tắt được — nó là hệ quả tất yếu của việc nhờ database cấp khóa. Nó cũng có nghĩa là JDBC batching không dùng được với insert dùng khóa IDENTITY, và đó là lý do thực tế để chọn SEQUENCE khi bạn insert hàng loạt.

StrategyBiết id trước khi insertHoãn được insertBatch được
IDENTITYkhôngkhôngkhông
SEQUENCE
TABLEcó, nhưng bị tuần tự hóa trên một row
AUTOtùy dialecttùytùy

No-arg constructor, và lỗi xảy ra khi thiếu nó

JPA yêu cầu một constructor không tham số để provider tạo được entity trước khi đổ dữ liệu vào. Để protected là đủ; private thì không.

Xóa protected Book() { } đi và một chuyện bất ngờ xảy ra: application vẫn khởi động ngon lành, schema vẫn được tạo, mọi lệnh insert vẫn thành công. Lỗi chỉ nổ ở lần đọc đầu tiên:

Text
org.springframework.orm.jpa.JpaSystemException: No default constructor for entity 'com.example.demo.Book'
...
Caused by: org.hibernate.InstantiationException: No default constructor for entity 'com.example.demo.Book'
	at org.hibernate.metamodel.internal.EntityInstantiatorPojoStandard.instantiate(EntityInstantiatorPojoStandard.java:94)
	at org.hibernate.persister.entity.AbstractEntityPersister.instantiate(AbstractEntityPersister.java:4442)

Hibernate bọc InstantiationException của chính nó vào trong JpaSystemException của Spring, nên phần đầu stack trace nhắc tới Spring còn phần cause mới nhắc tới Hibernate. Hãy đọc tới dòng Caused by: rồi mới đi tìm message.

Relationship: mappedBy, owning side và cascade

One-to-many là chỗ mà object model và relational model bất đồng về hướng, và hiểu được chỗ bất đồng đó là hiểu được phần lớn vấn đề.

Trong Java, Author giữ một List Book; trong SQL, row books giữ author_id, nên hai mũi tên ngược chiều nhau

Java
@Entity
@Table(name = "authors")
public class Author {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Book> books = new ArrayList<>();
 
    public void addBook(Book b) {
        books.add(b);
        b.setAuthor(this);
    }
}

Trong Java, Author giữ một list Book. Trong database không có list nào cả — row books mới là chỗ giữ author_id. Phía nào map column foreign key thì phía đó là owning side, ở đây là Book.author cùng với @JoinColumn của nó. mappedBy = "author" đánh dấu Author.booksinverse side: nó nói "column đã có sẵn ở bên kia rồi, trên field tên là author; đừng tạo join table cho tôi".

Hệ quả thực tế là Hibernate chỉ ghi theo những gì owning side nói. Thêm một Book vào author.getBooks() mà quên book.setAuthor(author) thì collection nhìn vẫn đúng trong bộ nhớ còn column author_id vẫn null — và đó chính là lý do addBook ở trên set cả hai đầu. Khi tôi quên làm đúng chuyện này ở bản nháp đầu, lệnh insert hỏng với một message đáng nhớ mặt:

Text
org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value for entity com.example.demo.Book.author

cascade = CascadeType.ALL nghĩa là thao tác trên parent lan xuống các child, nên authors.saveAll(...) đã persist chín cuốn sách mà không cần gọi một repository nào của book. orphanRemoval = true nghĩa là bỏ một Book khỏi list thì row của nó bị xóa. Cả hai đều là tiện lợi có răng: CascadeType.ALL bao gồm cả REMOVE, nên xóa một author là xóa luôn sách của họ.

Repository: JpaRepository cho bạn sẵn những gì

Bạn khai báo một interface. Spring Data dựng implementation lúc khởi động và đăng ký nó thành bean.

Java
public interface BookRepository extends JpaRepository<Book, Long> {
}

Cái interface rỗng đó đã có sẵn save, saveAll, findById, findAll, findAll(Sort), findAll(Pageable), count, existsById, delete, deleteById, deleteAll, flush, saveAndFlushgetReferenceById. Hai type parameter là entity và kiểu của @Id của nó.

Derived query method và bộ grammar đặt tên

Thêm một method mà tên của nó mô tả query, Spring Data sẽ viết query giúp bạn. Cái tên không phải một quy ước được nhận diện nguyên khối — nó là một grammar, được parse từng token một, đối chiếu với các property của entity.

Tên method bị tách thành các chip, mỗi chip được resolve thành property hoặc toán tử, rồi tới SQL mà Hibernate log ra

Java
List<Book> findByTitleContainingIgnoreCase(String fragment);
 
List<Book> findByPublishedYearGreaterThanOrderByTitleAsc(int year);
 
List<Book> findByAuthor_NameAndPriceLessThan(String name, BigDecimal max);
 
long countByPublishedYear(int year);
 
boolean existsByIsbn(String isbn);

Phần subject (find, count, exists, delete) quyết định cái gì được trả về; mọi thứ sau By là phần điều kiện. Tên property được khớp theo kiểu tham lam, và dấu gạch dưới ép chỗ tách khi cái tên có thể hiểu theo hai cách — Author_Namebook.author.name, và đó là lý do SQL sinh ra có một câu join mà không ai viết:

SQL
select
    b1_0.id,
    b1_0.author_id,
    b1_0.isbn,
    b1_0.price,
    b1_0.published_year,
    b1_0.title
from
    books b1_0
join
    authors a1_0
        on a1_0.id=b1_0.author_id
where
    a1_0.name=?
    and b1_0.price<?

ContainingIgnoreCase thành một câu like kèm mệnh đề escape, đáng xem vì nó cho thấy cái keyword đó không miễn phí:

SQL
where
    upper(b1_0.title) like upper(?) escape '\'

existsByIsbn không hề lấy cả row về. Nó select id kèm một giới hạn, mà dialect của H2 viết là fetch first ? rows only:

SQL
select
    b1_0.id
from
    books b1_0
where
    b1_0.isbn=?
fetch
    first ? rows only

Các keyword hay dùng, tất cả đều hoặc đã xuất hiện trong SQL sinh ra ở trên, hoặc là phần được ghi rõ trong cùng bộ grammar đó:

Keyword trong tênSQL nó sinh ra
And, Orand, or
Betweenbetween ? and ?
LessThan, GreaterThan<, >
LessThanEqual, GreaterThanEqual<=, >=
IsNull, IsNotNullis null, is not null
Containing, StartingWith, EndingWithlike với wildcard được đặt sẵn cho bạn
IgnoreCasebọc cả hai vế trong upper(...)
In, NotInin (...), not in (...)
OrderBy...Asc/Descorder by
Top3, First10một giới hạn số row

Một derived method không parse được, và lỗi nó ném ra lúc startup

Đây là điều hay nhất của bộ grammar này: nó được resolve ngay khi context đang được build, chứ không phải lúc method được gọi. Đổi title thành titel ở một method:

Java
List<Book> findByTitel(String title);

Application không khởi động nổi:

Text
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'demoRunner': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'bookRepository' defined in com.example.demo.BookRepository defined in @EnableJpaRepositories declared on DataJpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: No property 'titel' found for type 'Book'; Did you mean 'title'
 
Caused by: org.springframework.data.repository.query.QueryCreationException: Cannot create query for method [BookRepository.findByTitel(java.lang.String)]; No property 'titel' found for type 'Book'; Did you mean 'title'
 
Caused by: org.springframework.data.core.PropertyReferenceException: No property 'titel' found for type 'Book'; Did you mean 'title'

Nó gọi tên method, gọi tên property, và gợi ý luôn cách sửa. Gõ sai tên một repository method là lỗi lúc khởi động, không bao giờ là bất ngờ trên production.

@Query với JPQL và với native SQL

Khi cái tên bắt đầu dài lố bịch thì hãy tự viết query. JPQL được viết theo entity và field, không phải theo table và column:

Java
@Query("select b from Book b where b.price between :lo and :hi order by b.price")
List<Book> inPriceRange(@Param("lo") BigDecimal lo, @Param("hi") BigDecimal hi);
SQL
select
    b1_0.id,
    b1_0.author_id,
    b1_0.isbn,
    b1_0.price,
    b1_0.published_year,
    b1_0.title
from
    books b1_0
where
    b1_0.price between ? and ?
order by
    b1_0.price

nativeQuery = true gửi thẳng chuỗi của bạn xuống database mà không dịch, nên nó dùng tên table và tên column, và nó gắn chặt với dialect của database đó:

Java
@Query(value = "select count(*) from books where published_year < ?1", nativeQuery = true)
long countOlderThan(int year);
SQL
select
    count(*)
from
    books
where
    published_year < ?

Để ý khác biệt về tính portable. Bản JPQL chạy nguyên xi trên PostgreSQL; bản native gọi thẳng bookspublished_year, và sẽ vỡ ngay khi naming strategy đổi. Chỉ dùng native SQL khi bạn cần thứ JPQL không diễn đạt được, và biết rằng ở method đó bạn đã bước ra ngoài abstraction.

Paging và sorting với Pageable

Truyền vào một Pageable và Spring Data tự thêm limit, offset cùng phần sắp xếp:

Java
Page<Book> findByPriceGreaterThan(BigDecimal min, Pageable pageable);
Java
Page<Book> page = books.findByPriceGreaterThan(new BigDecimal("12.00"),
        PageRequest.of(0, 3, Sort.by("price").descending()));

Đoạn đó sinh ra hai statement — trang dữ liệu, rồi một câu đếm để getTotalPages() có câu trả lời:

SQL
select
    b1_0.id,
    b1_0.author_id,
    b1_0.isbn,
    b1_0.price,
    b1_0.published_year,
    b1_0.title
from
    books b1_0
where
    b1_0.price>?
order by
    b1_0.price desc
fetch
    first ? rows only
 
select
    count(b1_0.id)
from
    books b1_0
where
    b1_0.price>?
Text
[demo] page 0 of 3, total elements 7
[demo]   17.99  The Fifth Season
[demo]   16.20  Parable of the Sower
[demo]   15.95  Wild Seed

Nếu bạn không cần tổng số, hãy trả về Slice. Tôi đổi đúng một kiểu trả về rồi chạy lại: một statement, không có câu đếm, mà hasNext() vẫn đúng vì Spring Data xin nhiều hơn kích thước trang đúng một row.

Text
[demo] slice size 3, hasNext true

@Modifying cho update và delete

Một @Query có thay đổi row thì cần @Modifying, và cần một transaction:

Java
@Modifying
@Query("update Book b set b.price = b.price + :delta where b.publishedYear < :year")
int raisePriceForBooksBefore(@Param("delta") BigDecimal delta, @Param("year") int year);
SQL
update
    books b1_0
set
    price=(b1_0.price+cast(? as numeric(8, 2)))
where
    b1_0.published_year<?
Text
MARK @Modifying updated 8 rows

Một statement đổi tám row, đúng thứ bạn muốn và cũng đúng thứ persistence context không hề biết. Một bulk update đi thẳng xuống database và đi vòng qua first-level cache, nên entity đã load trong cùng transaction vẫn giữ giá trị cũ. Tôi load một cuốn sách, chạy update, rồi đọc lại nó trong cùng transaction:

Text
MARK price before bulk update: 12.99
MARK price after bulk update, same context: 12.99
MARK same instance = true
MARK price after em.clear() and reload: 14.49

Row dưới database là 14.49 suốt từ đầu; object trong persistence context vẫn cũ cho tới khi context được clear. Đó là công dụng của @Modifying(clearAutomatically = true), và cũng là lý do một bulk update nên nằm trong transaction ngắn của riêng nó thay vì nằm giữa một transaction đang đọc entity.

Vấn đề N+1 select, đếm chứ không bấm giờ

Đây là phần làm nên giá trị của bài viết. Nó là bug hiệu năng phổ biến nhất ở mọi ORM, và nó vô hình nếu bạn không nhìn vào log.

Đoạn code là thứ tự nhiên nhất bạn có thể viết ra: load các author, rồi nhìn vào sách của từng author.

Java
@Transactional(readOnly = true)
public int nPlusOne() {
    List<Author> all = authors.findAll();
    int total = 0;
    for (Author a : all) {
        total += a.getBooks().size();
    }
    return total;
}

Bốn author, chín cuốn sách. Đây là log, chỉ cắt bớt phần select list lặp lại của ba câu query con giống hệt nhau:

Text
Hibernate:
    select
        a1_0.id,
        a1_0.country,
        a1_0.name
    from
        authors a1_0
MARK findAll() returned 4 authors
Hibernate:
    select
        b1_0.author_id,
        b1_0.id,
        b1_0.isbn,
        b1_0.price,
        b1_0.published_year,
        b1_0.title
    from
        books b1_0
    where
        b1_0.author_id=?
Hibernate:
    ... đúng statement đó, thêm ba lần nữa ...
MARK touched every books collection
[demo] N+1: 9 books, JDBC statements prepared = 5

Một query cho các parent, rồi mỗi parent một query. Đó là N+1: 1 + N statement, với N là số row mà query đầu tiên trả về. Bốn author là 5 statement; bốn nghìn author là 4001 statement, vẫn đúng đoạn code đó, không lỗi và không cảnh báo.

Hãy đếm statement, đừng bao giờ bấm giờ. Một con số thời gian đo trên laptop với database in-memory chẳng nói gì về đúng vòng lặp đó khi database nằm sau mạng, và nó còn nói dối rất êm tai về một vòng lặp ổn ở bốn row nhưng thảm họa ở bốn nghìn row. Số lượng statement thì tỉ lệ theo dữ liệu của bạn; con số mili giây thì không sống nổi tới production.

Con số ở trên lấy từ chính getStatistics().getPrepareStatementCount() của Hibernate, đo quanh method đó, và tôi đối chiếu lại với số dòng Hibernate: trong log. Cả hai đều ra 5.

Năm statement ở bên trái, một ở bên phải, cho cùng chín cuốn sách

Sửa bằng @EntityGraph

@EntityGraph nói cho Spring Data biết association nào cần fetch sẵn, và chỉ áp dụng cho method đó:

Java
@EntityGraph(attributePaths = "books")
@Query("select a from Author a")
List<Author> findAllWithGraph();

Vẫn vòng lặp đó, không sửa gì:

Text
Hibernate:
    select
        a1_0.id,
        b1_0.author_id,
        b1_0.id,
        b1_0.isbn,
        b1_0.price,
        b1_0.published_year,
        b1_0.title,
        a1_0.country,
        a1_0.name
    from
        authors a1_0
    left join
        books b1_0
            on a1_0.id=b1_0.author_id
MARK findAllWithGraph() returned 4 authors
MARK touched every books collection
[demo] EntityGraph: 9 books, JDBC statements prepared = 1

Năm statement còn một. Vẫn chín cuốn sách đó.

Sửa bằng join fetch

Bản JPQL viết tay tương đương:

Java
@Query("select distinct a from Author a join fetch a.books")
List<Author> findAllJoinFetch();
Text
[demo] join fetch: 9 books, JDBC statements prepared = 1

Cũng một statement. Có hai khác biệt đáng biết, và cả hai đều được kiểm chứng chứ không phải đoán.

join fetch ở đây là inner join, nên một author không có cuốn sách nào sẽ biến mất khỏi kết quả, trong khi @EntityGraph sinh ra left join và vẫn giữ họ lại.

join fetch trên một collection thì không phân trang dưới database. Tôi chạy đúng query đó với một Pageable xin hai author, rồi chạy riêng một lần nữa sau khi bỏ keyword distinct:

Text
MARK no distinct -> 4 author objects
MARK paged join fetch -> 4 author objects

Hai điều rút ra. Kích thước trang không hề xuống tới SQL — Hibernate đọc hết mọi row rồi phân trang trong bộ nhớ — và trên 7.4.5 nó không in ra cảnh báo nào khi làm vậy, nên kiểu hỏng này im lặng hoàn toàn. Và hóa ra distinct là thừa: từ phiên bản 6, Hibernate đã tự khử trùng lặp parent entity trong entity query, nên bỏ nó đi vẫn ra bốn author chứ không phải chín.

Sửa bằng default_batch_fetch_size

Cách thứ ba không đụng gì tới code. Nó nói với Hibernate rằng khi phải khởi tạo một lazy collection thì hãy khởi tạo tối đa N cái cùng lúc:

Properties
spring.jpa.properties.hibernate.default_batch_fetch_size=100

Vẫn method nPlusOne() đó, không sửa gì:

Text
Hibernate:
    select
        b1_0.author_id,
        b1_0.id,
        b1_0.isbn,
        b1_0.price,
        b1_0.published_year,
        b1_0.title
    from
        books b1_0
    where
        b1_0.author_id in (?, ?, ?, ... tổng cộng 100 placeholder ...)
[demo] N+1: 9 books, JDBC statements prepared = 2

Hai statement thay vì năm: các parent, rồi một danh sách in cho toàn bộ child. Danh sách placeholder thật sự bị đệm cho đủ batch size — tôi đã cắt bớt ở trên; dòng log gốc có đúng một trăm dấu hỏi. Đây là cách giảm thiểu toàn cục rẻ nhất, và đáng bật cho mọi application có nhiều hơn dăm ba entity, nhưng nó không thay thế được việc fetch có chủ đích ở chỗ bạn biết mình cần các child.

Cách sửaSố statementPhạm viCần để ý
không làm gì1 + Ntăng theo số row
@EntityGraph1một repository methoddatabase vẫn trả về mỗi parent row một lần cho mỗi child
join fetch1một querymặc định là inner join; paging chạy trong bộ nhớ, và im lặng
default_batch_fetch_size2toàn applicationvẫn hai lượt đi về; danh sách in bị đệm

Lazy loading và cái bẫy entity đã detached

@ManyToOne mặc định là EAGER còn @OneToMany mặc định là LAZY. Một association lazy không được load cùng entity; nó được load ở lần đầu bạn chạm vào, và nó chỉ load được khi persistence context sinh ra entity đó vẫn còn mở.

Load một author bên trong transaction, trả nó ra ngoài, rồi chạm vào collection sau khi transaction đã kết thúc:

Java
public Author loadAuthorDetached(Long id) {
    return authors.findById(id).orElseThrow();  // không có @Transactional
}
Text
[demo] loaded author outside a transaction: Ursula K. Le Guin
[demo] org.hibernate.LazyInitializationException
[demo] Cannot lazily initialize collection of role 'com.example.demo.Author.books' with key '1' (no session)

Bản proxy của cùng lỗi đó cũng đáng nhớ mặt, vì nó trông khác đủ để làm người ta đi tìm nhầm chỗ. getReferenceById trả về một proxy chứ không xuống database, và chạm vào nó ngoài session thì được:

Text
[demo] getReferenceById returned a Book$HibernateProxy
org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.demo.Book#1] - no session

Có bốn cách sửa tử tế, và một cách chỉ trông giống sửa.

Cách làmNó làm gìCái giá
Fetch có chủ đích (@EntityGraph, join fetch)Dữ liệu đã nằm sẵn đó khi bạn rời transactionPhải quyết định cho từng query
Làm hết việc trong method transactionalKhông có gì bị detachedService method phải ôm trọn một use case
Map sang DTO ngay trong transactionChỉ thứ bạn cần mới rời khỏi ranh giớiPhải viết thêm một lớp mapping
FetchType.EAGER trên mappingLuôn được loadMọi query cho entity đó phải trả giá, mãi mãi
spring.jpa.open-in-viewGiữ persistence context mở suốt HTTP requestGiấu vấn đề đi chứ không sửa

Vì sao trả entity thẳng từ controller lại vỡ

Chuyện này nên làm thật thay vì mô tả, vì cách nó vỡ phụ thuộc vào một setting mà phần lớn mọi người không biết là đang bật.

Java
@GetMapping("/authors/{id}")
public Author one(@PathVariable Long id) {
    return authors.findById(id).orElseThrow();
}

Với spring.jpa.open-in-view=false, transaction kết thúc ngay bên trong repository và Jackson đi serialize một entity đã detached:

Text
WARN ... DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Cannot lazily initialize collection of role 'com.example.demo.Author.books' with key '1' (no session)]
JSON
{"timestamp":"2026-09-10T08:45:07.609Z","status":500,"error":"Internal Server Error","path":"/authors/1"}

Mặc định của Boot là true, và nó in dòng này lúc khởi động:

Text
WARN ... JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

Để nguyên mặc định, request trả về HTTP 200 — và nội dung là rác, vì Author tham chiếu BookBook lại tham chiếu ngược Author:

JSON
{"name":"Ursula K. Le Guin","country":"US","books":[{"title":"A Wizard of Earthsea","isbn":"9780553383041","price":12.99,"publishedYear":1968,"author":{"name":"Ursula K. Le Guin","country":"US","books":[{"title":"A Wizard of Earthsea", ... }]}}]}

Nó đệ quy tới khi bộ serializer chặn lại, ở mốc 25 KB của một response đã lỡ commit với status 200:

Text
WARN ... DefaultHandlerExceptionResolver : Ignoring exception, response committed already: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500, from `StreamWriteConstraints.getMaxNestingDepth()`)

Nghĩa là open-in-view không sửa được gì — nó biến một lỗi 500 sạch sẽ thành một 200 bị cụt. Đó mới là lập luận thật sự chống lại việc trả entity từ controller: entity là một cấu trúc phục vụ persistence, có kết nối sống với một session và có object graph vòng tròn, còn response body thì không phải cả hai thứ đó. Map sang DTO ngay trong ranh giới transaction là cả nhóm vấn đề này biến mất.

Transaction: @Transactional và ba luật hay làm người ta bất ngờ

@Transactional trên một public method khiến Spring mở một transaction trước method và commit sau đó, hoặc rollback nếu method ném exception. Các method của chính Spring Data đã transactional sẵn, và đó là lý do một save() trần trụi vẫn chạy mà không cần annotation nào cả.

Nó chạy bằng proxy, nên self-invocation không mở transaction nào

Spring hiện thực @Transactional bằng một proxy bọc quanh bean. Lời gọi từ bên ngoài đi qua proxy; lời gọi từ method này sang method kia của cùng bean thì không — nó là một this.method() bình thường trên object đích, và proxy không hề thấy.

Java
public void selfInvocation(Long authorId) {
    System.out.println("MARK outer: transaction active = "
            + TransactionSynchronizationManager.isActualTransactionActive());
    try {
        this.innerTransactional(authorId);   // đường đi này không qua proxy
    } catch (IllegalStateException e) {
        System.out.println("MARK caught " + e);
    }
}
 
@Transactional
public void innerTransactional(Long authorId) {
    System.out.println("MARK inner: transaction active = "
            + TransactionSynchronizationManager.isActualTransactionActive());
    books.save(newBook(authorId, "Self Invoked", "9990000000003", "2.00", 1998));
    throw new IllegalStateException("boom after save");
}

Cả hai đường đi đều lưu một row rồi ném exception. Chỉ một trong hai rollback:

Text
MARK outer: transaction active = false
MARK inner: transaction active = false
MARK caught java.lang.IllegalStateException: boom after save
[demo] rows with isbn 9990000000003 (self-invoked): 1
 
MARK via proxy: transaction active = true
MARK caught java.lang.IllegalStateException: boom after save
[demo] rows with isbn 9990000000006 (via proxy):    0

Đọc dòng giữa của khối đầu: inner: transaction active = false, trong một method có annotation @Transactional. Annotation bị bỏ qua vì lời gọi không đi qua proxy. save() sau đó chạy trong transaction bé tí của riêng nó và commit, nên row sống sót qua exception. Gọi qua proxy thì cũng method đó rollback và không để lại gì.

Cách sửa không phải mẹo mà là chỉnh lại thiết kế: ranh giới transaction thuộc về method được gọi từ bên ngoài. Nếu bạn thật sự cần một method của bean gọi method khác một cách transactional, hãy tách nó sang bean riêng.

Mặc định chỉ runtime exception mới rollback

Spring rollback với RuntimeExceptionError. Một checked exception thì commit.

Java
@Transactional
public void checkedException(Long authorId) throws Exception {
    books.save(newBook(authorId, "Checked Exception", "9990000000004", "3.00", 1997));
    throw new Exception("checked, so no rollback");
}
 
@Transactional(rollbackFor = Exception.class)
public void checkedExceptionWithRollbackFor(Long authorId) throws Exception {
    books.save(newBook(authorId, "Checked With RollbackFor", "9990000000005", "4.00", 1996));
    throw new Exception("checked, but rollbackFor covers it");
}
Text
[demo] caught java.lang.Exception: checked, so no rollback
[demo] rows with isbn 9990000000004: 1
[demo] caught java.lang.Exception: checked, but rollbackFor covers it
[demo] rows with isbn 9990000000005: 0

Cùng code, cùng kiểu exception trong mệnh đề throws, kết cục ngược nhau. Luật này chẳng liên quan gì tới JPA — nó là mặc định của Spring thừa hưởng từ EJB — và nó âm thầm commit phần việc làm dở nếu service của bạn ném checked exception. Hoặc ném unchecked, hoặc đặt rollbackFor.

readOnly

@Transactional(readOnly = true) là một gợi ý với hai tác dụng: Spring chuyển nó xuống JDBC connection để driver có thể tối ưu, và Hibernate đặt flush mode thành manual nên nó không buồn kiểm tra các entity đã load xem có thay đổi gì lúc kết thúc hay không.

Tác dụng thứ hai quan sát được. Cũng method đó, dưới transaction bình thường thì sinh UPDATE, còn dưới readOnly thì không sinh gì:

Text
MARK readOnly: loaded, about to setTitle
MARK readOnly: method about to end
[demo] title now = A Wizard of Earthsea

Title đã bị gán lại trong bộ nhớ và không bao giờ được ghi xuống. Hãy coi readOnly là tài liệu nói rằng method này không ghi, chứ không phải một bảo đảm — nó không chặn được một @Modifying query hay một câu insert native.

Persistence context: first-level cache, dirty checking, flush và commit

Persistence context là cái map các entity đang được quản lý của EntityManager trong transaction hiện tại. Ba hành vi của nó giải thích phần lớn những thứ trông như phép màu.

Nó là một first-level cache. Hai lời gọi findById cùng một id trong một transaction chỉ ra một query, và trả về đúng cùng một object:

Text
Hibernate:
    select
        b1_0.id,
        b1_0.author_id,
        b1_0.isbn,
        b1_0.price,
        b1_0.published_year,
        b1_0.title
    from
        books b1_0
    where
        b1_0.id=?
MARK first findById done
MARK second findById done
MARK same instance = true

same instance = true là so sánh reference chứ không phải equals. Trong một transaction, một entity id ứng với đúng một object.

Dirty checking: một UPDATE mà không hề gọi save()

Vì context giữ trạng thái lúc load, nó so được với trạng thái hiện tại lúc flush và ghi xuống phần khác biệt. Không có save() nào tham gia:

Java
@Transactional
public void dirtyChecking(Long id) {
    Book b = books.findById(id).orElseThrow();
    b.setTitle("Dirty Checked Title");
}
Text
MARK loaded, about to setTitle with no save() call
MARK setTitle returned, method about to end
Hibernate:
    update
        books
    set
        author_id=?,
        isbn=?,
        price=?,
        published_year=?,
        title=?
    where
        id=?
[demo] title now = Dirty Checked Title

Hai điều đáng để ý. Câu UPDATE chạy sau khi thân method kết thúc, lúc commit. Và nó set mọi column chứ không riêng title — đó là mặc định của Hibernate, và là lý do có @DynamicUpdate cho những entity mà ghi hết column là đắt hoặc hay tranh chấp.

Mặt trái là cái bẫy: mọi setter trên một managed entity đều là một lần ghi xuống database. Một thao tác chuẩn hóa dữ liệu "vô hại" nằm trong một đường đọc cũng sẽ được persist.

flush khác commit

flush() gửi phần SQL đang chờ xuống database. commit() kết thúc transaction và làm nó bền vững. Flush không phải commit — một thay đổi đã flush mà chưa commit thì vẫn vô hình với connection khác và vẫn rollback được.

Java
@Transactional
public void flushSequenceEntity() {
    Review r = new Review("Sequence-generated id, so the INSERT can wait", 5);
    reviews.save(r);   // xin sequence một id; chưa có INSERT nào
    em.flush();        // INSERT đi ra ở đây
}                      // commit xảy ra ở đây

Đó chính là method có log nằm ở phần @GeneratedValue phía trên. Chạy đúng hình dạng đó với Book, vốn dùng khóa IDENTITY, thì INSERT rời đi ngay tại save() — chiến lược sinh id mới quyết định thời điểm, chứ không phải lời gọi flush().

Hibernate tự flush trước một query có thể bị ảnh hưởng bởi các thay đổi đang chờ, và flush lúc commit. Bạn gọi flush() bằng tay vì hai lý do: để database gán các giá trị sinh tự động ngay bây giờ, hoặc để một lỗi constraint nổi lên ở đúng chỗ trong code mà bạn còn xử lý được.

Những giới hạn cần nói thẳng

Ba thứ mà setup này không phải, nói thẳng ra.

H2 in-memory không phải PostgreSQL. Nó là chỗ học rất tốt và chỗ chạy test nhanh rất tốt, và nó sẽ vui vẻ chấp nhận thứ SQL mà database production của bạn từ chối — cũng như từ chối thứ SQL mà database kia chấp nhận. Cái fetch first ? rows only trong output paging ở trên là thứ H2Dialect của Hibernate phát ra; dialect khác phát ra limit. Cách phân biệt hoa thường của identifier, kiểu ngày giờ, hành vi sequence, ngữ nghĩa isolation level và văn bản chính xác của message vi phạm constraint đều khác nhau. Nếu production là PostgreSQL thì hãy chạy test trên PostgreSQL.

ddl-auto: update là tiện lợi khi dev và là hiểm họa trên production. Nó thêm column và table; nó không đổi tên, không xóa, không đổi kiểu một cách an toàn, không sắp xếp thay đổi theo tiến trình deploy, và không có khái niệm rollback. Nó còn âm thầm để lại một schema có hình dạng mà không tài liệu nào mô tả. Dùng create-drop cho test, validate trên production để lệch mapping là fail ngay lúc khởi động, và dùng một migration tool thật — Flyway hoặc Liquibase — cho các thay đổi.

ORM không thay thế được việc biết SQL. Mọi thứ đáng biết trong bài này đều nhìn thấy trong log SQL, và không thứ nào trong đó suy ra được từ riêng code Java. Phần N+1 là bằng chứng: code Java đọc lên hoàn hảo còn database thì thấy năm statement. Bạn vẫn phải đọc execution plan, vẫn phải biết index làm gì, và vẫn phải quyết định khi nào một query nên viết tay. ORM xóa đi phần code map row. Nó không xóa đi cái database.

FAQ

JPA, Hibernate và Spring Data JPA khác nhau thế nào?

JPA là một specification — annotation và interface trong jakarta.persistence, không có hành vi. Hibernate là implementation mà Spring Boot mang theo, và nó mới là thứ thực sự sinh ra SQL. Spring Data JPA là layer nằm trên JPA, biến các repository interface thành bean. Bạn dùng JPA mà không dùng Spring Data cũng được, dùng Hibernate mà không qua JPA cũng được, nhưng trên Boot thì thường có đủ cả ba.

Vì sao query của tôi chạy N+1 lần?

Vì một lazy collection được khởi tạo ở lần đầu bạn chạm vào, mỗi parent entity một lần. Bật spring.jpa.show-sql, đếm số dòng Hibernate:, và nếu bạn thấy mỗi row của kết quả trước đó lại kèm một query thì đúng là nó rồi. Sửa bằng @EntityGraph, bằng một query join fetch, hoặc bằng hibernate.default_batch_fetch_size.

Sửa LazyInitializationException thế nào?

Load sẵn thứ bạn cần khi transaction còn mở — bằng @EntityGraph hoặc join fetch — hoặc map sang DTO ngay trong method transactional. Đừng sửa bằng cách đổi association sang EAGER, và đừng dựa vào open-in-view: cách đầu làm mọi query chậm đi vĩnh viễn, cách sau giữ một database connection gắn với HTTP request.

Vì sao method @Transactional của tôi không rollback?

Hai nguyên nhân thường gặp. Hoặc method được gọi từ một method khác của cùng bean, nên proxy bị đi vòng qua và không transaction nào được mở; hoặc nó ném một checked exception, mà mặc định Spring sẽ commit. Hãy log TransactionSynchronizationManager.isActualTransactionActive() bên trong method để phân biệt hai trường hợp.

Có cần gọi save() sau khi sửa một entity đã load không?

Không. Trong một transaction, entity load qua persistence context là managed, và dirty checking ghi thay đổi xuống lúc flush. Gọi save() thì vô hại nhưng thừa. Ngoài transaction thì entity đã detached và chẳng có gì được ghi cả, và đó chính là trường hợp khiến người ta thêm save() rồi kết luận là phải có nó.

IDENTITY và SEQUENCE khác nhau ở đâu?

IDENTITY để database gán khóa, nên INSERT buộc phải chạy ngay và không batch được. SEQUENCE lấy id trước, nên insert hoãn được tới lúc flush và batch được. Nếu bạn insert hàng loạt thì dùng SEQUENCE.

Có nên trả entity từ REST controller không?

Không. Một entity có thể chứa lazy proxy và sẽ serialize hỏng ngay khi transaction kết thúc, còn một quan hệ hai chiều thì serialize thành vòng lặp vô tận. Cả hai đều đã tái hiện ở trên. Hãy map sang DTO ngay trong ranh giới transaction.

ddl-auto dùng trên production có an toàn không?

validate thì an toàn — nó đối chiếu schema với mapping của bạn và fail lúc khởi động nếu lệch, đúng thứ bạn muốn. update thì không: nó không đổi tên, không xóa, không sắp xếp lại được gì, và không có lịch sử migration. Hãy dùng Flyway hoặc Liquibase.

Kết luận

Spring Data JPA sâu bốn layer, và mỗi layer đều đang làm một việc mà bạn nhìn thấy được. Bật show-sql, đọc DDL nó sinh ra, đếm số statement một vòng lặp tạo ra, và abstraction này thôi là phép màu mà thành một công cụ bạn lập luận được về nó. Ba lỗi đáng thuộc lòng là ba lỗi bài này tái hiện thay vì mô tả: N+1 select tăng theo dữ liệu của bạn, LazyInitializationException chỉ xuất hiện khi transaction đã kết thúc, và method @Transactional lặng lẽ không làm gì vì nó bị gọi từ trong chính bean của nó.

Đó là hết Phần 7. Phần 8 đem cả khóa học ra dùng: xây một project REST API hoàn chỉnh từ đầu tới cuối — entity, repository, service, controller, validation, xử lý lỗi và test — trên nền tảng mà bảy phần vừa rồi đã dựng.

Bài viết liên quan

[Advanced Java] Xây dựng REST API với Spring Boot

REST API trên Spring Boot 4.1.1 và Java 21: DispatcherServlet route request ra sao, các annotation mapping, ResponseEntity và status code, jakarta.validation với @Valid, error body ProblemDetail theo RFC 9457, content negotiation và một lần chạy @WebMvcTest — mọi response đều lấy từ ứng dụng đang chạy.

[Advanced Java] Connection Pool với HikariCP: tối ưu kết nối database

Connection pool trong Java với HikariCP 5.1.0 trên OpenJDK 21: vì sao close() trả connection về pool chứ không đóng nó, proxy và unwrap, maximumPoolSize, minimumIdle, connectionTimeout, idleTimeout, maxLifetime và leakDetectionThreshold, một SQLTransientConnectionException thật khi hết connection, log leak thật kèm stack trace, metrics từ HikariPoolMXBean và cách chọn cỡ pool một cách trung thực.

[Advanced Java] Unit Testing trong Java với JUnit 5

Unit testing trong Java với JUnit 5.11.3 trên OpenJDK 21: kiến trúc Platform, Jupiter và Vintage, các callback lifecycle, mỗi test method một instance mới, message báo lỗi thật của từng assertion, assertThrows và assertAll, DisplayName, Nested, Disabled và Tag, parameterized test với đủ các argument source, assumption so với assertion, và những thói quen khiến một test trở nên vô dụng.

[Advanced Java] ExecutorService và Thread Pool trong Java

ExecutorService và thread pool trên OpenJDK 21: bảy argument của ThreadPoolExecutor, vì sao queue không giới hạn khiến maximumPoolSize vô dụng, default bị giấu sau mỗi factory của Executors, submit khác execute và exception mà submit nuốt mất, Callable, Future, cancel, invokeAll và invokeAny, trình tự shutdown đúng, và cách chọn số thread một cách trung thực.