Command Palette

Search for a command to run...

[Advanced Spring Boot] Tối ưu JPA: phát hiện N+1, batch fetching, projection và batch insert

Bài 28 của khoá Basics tìm ra vấn đề N+1 với mười order và mười một câu SELECT, rồi sửa bằng JOIN FETCH@EntityGraph. Đó là phiên bản dễ: một collection, không phân trang, không có tên sản phẩm. Một danh sách order thật phải phân trang qua 200 order, in tên sản phẩm trên từng dòng, và vẫn phải nhanh sau khi người tiếp theo thêm một field vào response. Ở cỡ đó, cách sửa cần ba thứ mà khoá Basics chưa có: một cách đếm statement cho mỗi request để test có thể assert, những chiến lược fetch sống sót được khi phân trang, và một cách thôi load entity khi endpoint chỉ cần năm cột. Việc ghi dữ liệu cũng có phiên bản riêng của vấn đề này, khi bộ sinh khoá lặng lẽ quyết định 10,000 row tốn 10,000 lượt đi về hay không.

Các ví dụ dùng Spring Boot 4.1.1 (đi kèm Hibernate 7) và Java 21, chạy trên PostgreSQL 18. H2 sẽ che đúng những chi phí mà bài này đo, nên không có gì ở đây chạy trên H2. Phần lớn lời khuyên về hiệu năng JPA trên mạng mô tả Hibernate 5, và nhiều kết quả dưới đây nói ngược lại.

Nhiều SQL statement nhỏ gộp lại thành vài statement theo batch

Bài đi từ nhìn thấy query, sang đọc ít row hơn, rồi tới ghi nhiều row.

Bộ dữ liệu và cách đếm statement

Project, database và cấu hình

Bash
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway" -o demo.zip

Database chạy kèm pg_stat_statements, bản ghi của chính PostgreSQL về mọi statement nó đã thực thi và mỗi statement trả về bao nhiêu row. Đây là bộ đếm thứ hai của bài, độc lập với mọi thứ Hibernate báo cáo:

Bash
docker run -d --name sba-a5-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5505:5432 postgres:18 -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
Bash
docker exec sba-a5-pg psql -U demo -d demo -c "create extension pg_stat_statements"
src/main/resources/application.properties
spring.application.name=demo
server.port=8205
spring.datasource.url=jdbc:postgresql://localhost:5505/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=debug

Flyway giữ schema còn Hibernate chỉ validate, như trong khoá Basics. Các bảng là catalogue và order của bài 28, tạm thời dùng khoá identity:

src/main/resources/db/migration/V1__create_schema.sql
create table categories (
    id   bigint generated by default as identity primary key,
    name varchar(80) not null unique
);
 
create table products (
    id          bigint generated by default as identity primary key,
    name        varchar(120)   not null,
    sku         varchar(40)    not null unique,
    price       numeric(10, 2) not null,
    category_id bigint         not null references categories (id)
);
create index products_category_id_idx on products (category_id);
 
create table customers (
    id    bigint generated by default as identity primary key,
    email varchar(120) not null unique,
    name  varchar(120) not null
);
 
create table orders (
    id          bigint generated by default as identity primary key,
    customer_id bigint                   not null references customers (id),
    placed_at   timestamp with time zone not null
);
create index orders_customer_id_idx on orders (customer_id);
 
create table order_lines (
    id         bigint generated by default as identity primary key,
    order_id   bigint         not null references orders (id),
    product_id bigint         not null references products (id),
    quantity   integer        not null,
    unit_price numeric(10, 2) not null
);
create index order_lines_order_id_idx on order_lines (order_id);

Dữ liệu seed là tất định, nên lần chạy nào cũng đọc đúng những row đó:

src/main/resources/db/migration/V2__seed_data.sql
insert into categories (name)
values ('Keyboards'), ('Mice'), ('Monitors'), ('Headsets'), ('Webcams'), ('Cables'), ('Storage'), ('Chairs');
 
-- 100 products, spread over the 8 categories
insert into products (name, sku, price, category_id)
select (array ['Keyboard', 'Mouse', 'Monitor', 'Headset', 'Webcam', 'Cable', 'SSD', 'Chair'])[1 + (g - 1) % 8]
           || ' ' || lpad(((g - 1) / 8 + 1)::text, 2, '0'),
       'SKU-' || lpad(g::text, 4, '0'),
       9.90 + (g * 37) % 400,
       1 + (g - 1) % 8
from generate_series(1, 100) as g;
 
-- 50 customers
insert into customers (email, name)
select 'customer' || lpad(g::text, 2, '0') || '@example.com', 'Customer ' || lpad(g::text, 2, '0')
from generate_series(1, 50) as g;
 
-- 200 orders
insert into orders (customer_id, placed_at)
select 1 + (g - 1) % 50, timestamptz '2026-01-01 08:00:00+00' + g * interval '7 hours'
from generate_series(1, 200) as g;
 
-- 3 to 5 lines per order
insert into order_lines (order_id, product_id, quantity, unit_price)
select o.id, p.id, 1 + (o.id + n) % 4, p.price
from orders o
         cross join lateral generate_series(1, 3 + o.id % 3) as n
         join products p on p.id = 1 + (o.id * 7 + n * 31) % 100
order by o.id, n;

Kết quả là 8 category, 100 product, 50 customer, 200 order và 801 order line. Trang mà mọi phép đo đều đọc là trang 3, cỡ 20 order, sắp theo id: order 61 đến 80, với 81 line trỏ tới 61 product khác nhau.

Entity và endpoint

Mapping giữ nguyên những gì bài 28 của khoá Basics đã chốt: mọi @ManyToOneLAZY với optional = false, và Order.lines là phía inverse của OrderLine.order:

src/main/java/com/example/demo/order/Order.java
@Entity
@Table(name = "orders")
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;
 
    @Column(nullable = false)
    private Instant placedAt;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLine> lines = new ArrayList<>();
 
    // constructor, addLine(OrderLine), total() and getters as in the Basics course
}
src/main/java/com/example/demo/order/OrderLine.java
@Entity
@Table(name = "order_lines")
public class OrderLine {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
 
    // constructor, lineTotal() and getters
}

Productid, name, sku, price và một category lazy. Response mang tên sản phẩm trên từng line, đó là điều làm endpoint giống thật và cũng là điều làm nó đắt:

src/main/java/com/example/demo/order/OrderResponse.java
package com.example.demo.order;
 
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
 
public record OrderResponse(Long id, Instant placedAt, List<LineResponse> lines, BigDecimal total) {
 
    public record LineResponse(String product, int quantity, BigDecimal unitPrice) {
 
        static LineResponse from(OrderLine line) {
            return new LineResponse(line.getProduct().getName(), line.getQuantity(), line.getUnitPrice());
        }
    }
 
    public static OrderResponse from(Order order) {
        return new OrderResponse(order.getId(), order.getPlacedAt(),
                order.getLines().stream().map(LineResponse::from).toList(), order.total());
    }
}

Service map ngay trong transaction của nó, vì open-in-view đang tắt, và trả về record PageResponse của bài 29 khoá Basics:

src/main/java/com/example/demo/order/OrderService.java
@Transactional(readOnly = true)
public PageResponse<OrderResponse> findPage(Pageable pageable) {
    return PageResponse.from(orders.findAll(pageable).map(OrderResponse::from));
}
src/main/java/com/example/demo/order/OrderController.java
@GetMapping
public PageResponse<OrderResponse> findPage(@PageableDefault(size = 20, sort = "id") Pageable pageable) {
    return service.findPage(pageable);
}

Nhìn thấy query: đếm SQL statement cho mỗi request

SQL log cho thấy gì

Bash
curl -s 'http://localhost:8205/api/orders?page=3&size=20'

Những dòng đầu tiên Hibernate log cho request đó, chỉ giữ lại phần message:

Text
select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset ? rows fetch first ? rows only
select count(o1_0.id) from orders o1_0
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?

Đây là N+1 của bài 28 nhân đôi: một SELECT line cho mỗi order, và bên trong mỗi order, một SELECT cho mỗi product chưa có trong persistence context. Cuộn log không phải là cách đếm, nên để PostgreSQL đếm. Chạy select pg_stat_statements_reset() trước request, rồi:

SQL
select calls, rows, query from pg_stat_statements where query not ilike '%pg_stat_statements%' order by query;
Text
 calls | rows |                                                          query
-------+------+--------------------------------------------------------------------------------------------------------------------------
     1 |    1 | select count(o1_0.id) from orders o1_0
    20 |   81 | select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=$1
     1 |   20 | select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only
    61 |   61 | select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=$1

83 statement và 163 row cho một trang: 1 query trang, 1 count, 20 query line và 61 query product. pg_stat_statements là sự thật gốc trong môi trường lab, nhưng nó tính cho cả server, nên không phân biệt được hai request. Application cũng phải tự đếm.

generate_statistics và log session metrics trong Hibernate 7

Lời khuyên phủ kín kết quả tìm kiếm là spring.jpa.properties.hibernate.generate_statistics=true, kèm lời hứa về một khối Session Metrics cho mỗi session. Với Hibernate 7.4.5, riêng property đó không log gì cho request này. Nó bật statistics API, và kèm logging.level.org.hibernate.statistics=debug thì nó log một dòng cho mỗi query:

Text
DEBUG org.hibernate.statistics : HHH000117: Query: [CRITERIA] select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset ? rows fetch first ? rows only, time: 13ms, rows: 20
DEBUG org.hibernate.statistics : HHH000117: Query: [CRITERIA] select count(o1_0.id) from orders o1_0, time: 0ms, rows: 1

Hai dòng cho 83 statement. 81 lần lazy load không phải là query, nên query statistics không bao giờ thấy chúng: N+1 vô hình đúng ở chỗ người ta tìm nó. Khối theo session đã chuyển sang log category riêng trong Hibernate 7. Công tắc cũ, hibernate.session.events.log, được đánh dấu trong SessionEventSettings là deprecated và "now ignored", và Javadoc của nó chỉ ra thứ thay thế:

src/main/resources/application.properties
logging.level.org.hibernate.session.metrics=debug 

Chỉ với dòng đó, không cần generate_statistics, request log ra:

Text
DEBUG org.hibernate.session.metrics : HHH000401: Logging session metrics:
	29833 ns acquiring 1 JDBC connections
	0 ns releasing 0 JDBC connections
	613495 ns preparing 83 JDBC statements
	24739799 ns executing 83 JDBC statements
	0 ns executing 0 JDBC batches
	0 ns performing 0 second-level cache puts
	0 ns performing 0 second-level cache hits
	0 ns performing 0 second-level cache misses
	0 ns executing 0 flushes (flushing a total of 0 entities and 0 collections)
	72709 ns executing 2 pre-partial-flushes
	71125 ns executing 2 partial-flushes (flushing a total of 0 entities and 0 collections)

83 statement, khớp với PostgreSQL. Khối này tính theo session, được log khi session đóng, và là cách nhanh nhất để đọc chi phí của một request lúc phát triển. Nhưng không assert được nó trong test, và trên một server bận, khối của các request chạy đồng thời sẽ xen vào nhau.

StatementInspector đếm SQL cho mỗi request

Hibernate đưa mọi chuỗi SQL nó prepare qua một StatementInspector trước khi gửi đi. Một inspector ghi các chuỗi đó vào ThreadLocal cho ta số đếm theo thread, và khi open-in-view tắt, mọi statement của một request đều chạy trên thread của request đó:

src/main/java/com/example/demo/common/SqlStatementCounter.java
package com.example.demo.common;
 
import java.util.ArrayList;
import java.util.List;
 
import org.hibernate.resource.jdbc.spi.StatementInspector;
 
public class SqlStatementCounter implements StatementInspector {
 
    private static final ThreadLocal<List<String>> STATEMENTS = new ThreadLocal<>();
 
    @Override
    public String inspect(String sql) {
        List<String> statements = STATEMENTS.get();
        if (statements != null) {
            statements.add(sql);
        }
        return sql;
    }
 
    public static void start() {
        STATEMENTS.set(new ArrayList<>());
    }
 
    public static List<String> stop() {
        List<String> statements = STATEMENTS.get();
        STATEMENTS.remove();
        return statements == null ? List.of() : statements;
    }
}

Spring Boot đưa một instance cho Hibernate qua HibernatePropertiesCustomizer, class nằm trong org.springframework.boot.hibernate.autoconfigure ở Boot 4:

src/main/java/com/example/demo/common/JpaConfig.java
package com.example.demo.common;
 
import org.hibernate.cfg.AvailableSettings;
import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
class JpaConfig {
 
    @Bean
    HibernatePropertiesCustomizer statementCounter() {
        return properties -> properties.put(AvailableSettings.STATEMENT_INSPECTOR, new SqlStatementCounter());
    }
}

Một servlet filter mở và đóng bộ đếm quanh mỗi request, log con số, và để lại danh sách statement trên request cho test đọc:

src/main/java/com/example/demo/common/SqlCountingFilter.java
package com.example.demo.common;
 
import java.io.IOException;
import java.util.List;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
@Component
public class SqlCountingFilter extends OncePerRequestFilter {
 
    public static final String STATEMENTS = SqlCountingFilter.class.getName() + ".statements";
 
    private static final Logger log = LoggerFactory.getLogger(SqlCountingFilter.class);
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        SqlStatementCounter.start();
        try {
            chain.doFilter(request, response);
        } finally {
            List<String> statements = SqlStatementCounter.stop();
            request.setAttribute(STATEMENTS, statements);
            String query = request.getQueryString() == null ? "" : "?" + request.getQueryString();
            log.info("{} {}{} -> {} SQL statements", request.getMethod(), request.getRequestURI(), query,
                    statements.size());
        }
    }
}
Text
INFO c.example.demo.common.SqlCountingFilter  : GET /api/orders?page=3&size=20 -> 83 SQL statements

Inspector thấy đúng 83 statement như session metrics và như PostgreSQL. Nó thấy chuỗi SQL, không thấy driver làm gì với chúng, nên với các INSERT theo batch, phần cuối bài đọc session metrics và pg_stat_statements thay vào đó.

Integration test fail khi có N+1

Bộ đếm chỉ có ích khi nó canh endpoint. Test gọi endpoint qua MockMvc, filter chain của MockMvc có SqlCountingFilter, và assert trên danh sách statement mà filter để lại trên request:

src/test/java/com/example/demo/order/OrderQueryCountTest.java
package com.example.demo.order;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
import java.util.List;
 
import com.example.demo.common.SqlCountingFilter;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
 
@SpringBootTest
@AutoConfigureMockMvc
class OrderQueryCountTest {
 
    @Autowired
    MockMvc mockMvc;
 
    @Test
    void orderPageRunsAtMostThreeStatements() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/orders").param("page", "3").param("size", "20"))
                .andExpect(status().isOk())
                .andReturn();
 
        @SuppressWarnings("unchecked")
        List<String> statements = (List<String>) result.getRequest().getAttribute(SqlCountingFilter.STATEMENTS);
        assertThat(statements)
                .as("SQL statements for GET /api/orders?page=3&size=20")
                .hasSizeLessThanOrEqualTo(3);
    }
}

Test chạy với container PostgreSQL, giống application. Mặc định Gradle chỉ in java.lang.AssertionError at OrderQueryCountTest.java:35 cho một lần fail, nên build được chỉnh để in cả message:

build.gradle
tasks.named('test') {
	useJUnitPlatform()
	testLogging { 
		events 'failed'
		exceptionFormat = 'full'
	} 
}
Bash
./gradlew test --tests 'com.example.demo.order.OrderQueryCountTest'

Với service ở trên, output bị cắt bớt ở giữa:

Text
OrderQueryCountTest > orderPageRunsAtMostThreeStatements() FAILED
    java.lang.AssertionError: [SQL statements for GET /api/orders?page=3&size=20]
    Expecting size of:
      ["select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset ? rows fetch first ? rows only",
        "select count(o1_0.id) from orders o1_0",
        "select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id=?",
        "select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?",
        ...
        "select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=?"]
    to be less than or equal to 3 but was 83
        at com.example.demo.order.OrderQueryCountTest.orderPageRunsAtMostThreeStatements(OrderQueryCountTest.java:35)
 
1 test completed, 1 failed

Lần fail liệt kê luôn SQL, nên nó chỉ ra association nào cần sửa. Với phiên bản hai query của service ở phần sau, chạy lại đúng command đó kết thúc bằng BUILD SUCCESSFUL và filter log 3 statement. Một giới hạn như ≤ 3 là ngân sách cho từng endpoint: một lazy association mới trong response sẽ làm vỡ build thay vì làm chậm production.

Vượt qua JOIN FETCH: batch fetching, subselect và mẫu hai query

Các phép đo fetch còn lại đến từ một CommandLineRunner sau profile lab-fetch. Với mỗi biến thể, nó map trang 3 cỡ 20 order sang OrderResponse trong một TransactionTemplate read-only, 20 lần để warm up và 15 lần có bấm giờ, rồi chạy thêm một lần giữa pg_stat_statements_reset() và một lần đọc pg_stat_statements. Thời gian là tốt nhất trong 15 lần với database trên cùng máy, kèm load average 1 phút mà JVM báo; chúng chỉ mang tính tham khảo, còn số statement và số row thì chính xác. Mỗi output mở đầu bằng tên đường code: lazyorders.findAll(pageable) giữ nguyên, chạy với mapping mà phần đó mô tả.

JOIN FETCH kèm Pageable gửi gì trong Hibernate 7.4

Bài 29 của khoá Basics đã phân trang một fetch join và thấy Hibernate 7.4 không áp trang trong bộ nhớ. Làm lại với trang order, fetch cả hai tầng và kèm một count query riêng:

src/main/java/com/example/demo/order/OrderRepository.java
@Query(value = "select o from Order o left join fetch o.lines l left join fetch l.product",
        countQuery = "select count(o) from Order o")
Page<Order> findPageWithLines(Pageable pageable);
Text
=== join-fetch | load 2.98 | statements 2 (pg calls 2) | rows 82 | best 1.11 ms of 15 | orders 20 lines 81 totalElements 200
   calls   1 rows    1  select count(o1_0.id) from orders o1_0
   calls   1 rows   81  select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,l1_0.quantity,l1_0.unit_price,o1_0.placed_at from (select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only) o1_0(id,customer_id,placed_at) left join order_lines l1_0 on o1_0.id=l1_0.order_id left join products p1_0 on p1_0.id=l1_0.product_id order by o1_0.id

Hai statement, và trang được áp lên các order trong một derived table trước khi join line vào. @EntityGraph(attributePaths = {"lines", "lines.product"}) trên một derived query Page<Order> findGraphBy(Pageable pageable) gửi đúng row query đó và select count(*) from orders o1_0, hết 1.55 ms. Mỗi row trong 81 row lặp lại các cột của order cạnh cột của line và của product, đó là cái giá của join; cách sửa tiếp theo chuyển mỗi row đúng một lần.

@BatchSize trên một collection

@BatchSize bảo Hibernate rằng khi khởi tạo một collection lines lazy, hãy khởi tạo luôn tối đa từng ấy collection khác đang chờ trong cùng persistence context, bằng cùng một query:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    @BatchSize(size = 20) 
    private List<OrderLine> lines = new ArrayList<>();

org.hibernate.annotations.BatchSize, service giữ nguyên:

Text
=== lazy | load 2.32 | statements 64 (pg calls 64) | rows 163 | best 8.52 ms of 15 | orders 20 lines 81 totalElements 200
   calls   1 rows    1  select count(o1_0.id) from orders o1_0
   calls   1 rows   81  select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id = any ($1)
   calls   1 rows   20  select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only
   calls  61 rows   61  select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id=$1

20 query line thành một, còn 61 query product vẫn ở đó: annotation trên một collection chỉ batch collection đó và không gì khác. Từ 83 statement xuống 64.

SQL không phải danh sách in (?,?,?,…) mà phần lớn bài viết đưa ra. Trên PostgreSQL, Hibernate 7 gửi một array parameter, và bind log (logging.level.org.hibernate.orm.jdbc.bind=trace) cho thấy nội dung của nó:

Text
select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id = any (?)
binding parameter (1:ARRAY) <- [[61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80]]

hibernate.default_batch_fetch_size cho mọi lazy association

Cấu hình toàn cục áp cùng cơ chế batch cho mọi lazy collection và mọi proxy to-one lazy, không cần annotation:

src/main/resources/application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=50 

Sau khi bỏ @BatchSize đi:

Text
=== lazy | load 2.83 | statements 5 (pg calls 5) | rows 163 | best 4.12 ms of 15 | orders 20 lines 81 totalElements 200
   calls   1 rows    1  select count(o1_0.id) from orders o1_0
   calls   1 rows   81  select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id = any ($1)
   calls   1 rows   20  select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only
   calls   2 rows   61  select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id = any ($1)

Năm statement cho đúng 163 row mà N+1 đọc bằng 83 statement. 61 product tốn hai statement, và bind log cho thấy statement thứ hai:

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id = any (?)
binding parameter (1:ARRAY) <- [[59, 90, 21, 52, 66, 97, 28, 73, 4, 35, 80, 11, 42, 87, 18, 49, 94, 25, 56, 1, 32, 63, 8, 39, 70, 15, 46, 77, 22, 53, 84, 29, 60, 91, 36, 67, 98, 43, 74, 5, 50, 81, 12, 57, 88, 19, 64, 95, 26, 71]]
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id = any (?)
binding parameter (1:ARRAY) <- [[2, 33, 78, 9, 40, 85, 16, 47, 92, 23, 54]]

50 id, rồi 11 id còn lại, trong một array 11 phần tử. Không có padding bằng null hay id lặp, và mọi batch dùng cùng một chuỗi SQL dù cỡ bao nhiêu. Dùng array hay không là tuỳ dialect. Trong source của Hibernate, batch loader dùng nó khi Dialect.useArrayForMultiValuedParameters() trả về true, như với PostgreSQL. H2Dialect override để trả về false, kèm comment "Performance is worse than the in-predicate version", nên cùng mapping đó gửi một danh sách in (…) trên H2. Bài này không chạy trường hợp đó.

default_batch_fetch_size=20 gửi 7 statement cho cùng trang: line trong một statement, rồi product trong ba batch 20 và một where p1_0.id=? cho id cuối, hết 4.17 ms. Annotation trên class là dạng theo từng entity của cùng cơ chế. Với @BatchSize(size = 20) trên Order.lines@BatchSize(size = 50) trên class Product, thay cho property, trang tốn 5 statement và 3.44 ms.

Batch fetching thắng JOIN FETCH khi:

  • Không muốn đổi query. Repository method vẫn là findAll(pageable); mọi đoạn map chạm tới lazy association đều được hưởng, kể cả những đoạn thêm vào sau này.
  • Có nhiều collection. Mỗi batch là một query riêng, nên không có tích Descartes và không có MultipleBagFetchException.
  • Row rộng. Mỗi row order, line và product đi qua mạng một lần thay vì order bị lặp lại trên từng line.

Cái giá là số lượt đi về: 5 statement trong khi fetch join cần 2, mỗi tầng một statement và thêm một cho mỗi 50 entity.

@Fetch(FetchMode.SUBSELECT) và phân trang

SUBSELECT load collection cho mọi owner mà query gốc trả về, bằng cách lặp lại query đó dưới dạng subquery:

src/main/java/com/example/demo/order/Order.java
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    @Fetch(FetchMode.SUBSELECT) 
    private List<OrderLine> lines = new ArrayList<>();

Vẫn giữ @BatchSize(size = 50) trên Product:

Text
=== lazy | load 2.65 | statements 5 (pg calls 5) | rows 922 | best 5.76 ms of 15 | orders 20 lines 81 totalElements 200
   calls   1 rows    1  select count(o1_0.id) from orders o1_0
   calls   1 rows  801  select l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price from order_lines l1_0 where l1_0.order_id in (select o1_0.id from orders o1_0)
   calls   1 rows   20  select o1_0.id,o1_0.customer_id,o1_0.placed_at from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only
   calls   2 rows  100  select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 where p1_0.id = any ($1)

Subquery là select o1_0.id from orders o1_0, không còn offsetfetch first của query trang. Hibernate load line của cả 200 order, 801 row cho một trang chỉ hiện 81, và vì các line đó đặt proxy của mọi product vào persistence context, các batch product cũng load luôn cả 100 product. 922 row so với 163. Trên một query có phân trang, SUBSELECT đọc cả bảng; nó chỉ hợp với query load mọi owner mà nó sẽ dùng.

Mẫu hai query để phân trang parent cùng children

Bài 29 của khoá Basics đã kiểm tra lời cảnh báo quen thuộc rằng fetch join có phân trang bị áp trong bộ nhớ, và Hibernate 7.4.5 không log nó. Việc viết lại thành derived table thay cho cách đó mới xuất hiện gần đây: class làm việc đó, CollectionFetchPaginationQueryTransformer, có trong source của Hibernate ORM 7.4.0 và không có trong 7.3.13. Hibernate 7.4 vẫn quay về phân trang trong bộ nhớ khi việc viết lại không an toàn. Sắp theo một cột của line trước order là một trường hợp như vậy:

Java
@Query(value = "select o from Order o left join fetch o.lines l order by l.unitPrice desc, o.id",
        countQuery = "select count(o) from Order o")
Page<Order> findPageOrderedByLinePrice(Pageable pageable);
Text
WARN org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.placed_at from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id order by l1_0.unit_price desc,o1_0.id

Không offset, không fetch first: PostgreSQL trả về 801 row để Hibernate giữ lại năm order. Với spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true, cùng lời gọi đó fail thay vì cảnh báo:

Text
org.springframework.orm.jpa.JpaSystemException: setFirstResult() or setMaxResults() specified with collection fetch join (in-memory pagination was about to be applied, but 'hibernate.query.fail_on_pagination_over_collection_fetch' is enabled)

Lọc theo line là trường hợp còn lại, và nó hỏng theo cả hai cách. Các order có chứa bàn phím, viết thành fetch join có phân trang, kết thúc bằng ERROR: missing FROM-clause entry for table "c2_0" của PostgreSQL, đúng lỗi bài 29 gặp với tag. Không có Pageable thì query chạy được và trả về dữ liệu sai:

Java
@Query("select o from Order o left join fetch o.lines l left join fetch l.product p where p.category.name = :category order by o.id")
List<Order> findWithKeyboardLines(String category);
Text
    order 1 lines 1
    order 7 lines 1
    order 8 lines 1
    order 9 lines 1
    order 10 lines 1
    db: order 1 lines 4
    db: order 7 lines 4
    db: order 8 lines 5
    db: order 9 lines 3
    db: order 10 lines 4

where đã lọc chính collection được fetch, nên mỗi order về tay chỉ còn line bàn phím của nó, dưới dạng entity được quản lý trông như đầy đủ.

Mẫu hai query tránh được cả ba trường hợp và không cần tới việc viết lại của 7.4. Query thứ nhất phân trang các id, với điều kiện lọc và thứ tự sắp mà trang cần; query thứ hai load các order đó cùng children, không phân trang:

src/main/java/com/example/demo/order/OrderRepository.java
@Query(value = "select o.id from Order o", countQuery = "select count(o) from Order o")
Page<Long> findIds(Pageable pageable);
 
@Query("select o from Order o left join fetch o.lines l left join fetch l.product where o.id in :ids")
List<Order> findWithLinesByIdIn(Collection<Long> ids);
src/main/java/com/example/demo/order/OrderService.java
@Transactional(readOnly = true)
public PageResponse<OrderResponse> findPage(Pageable pageable) {
    return PageResponse.from(orders.findAll(pageable).map(OrderResponse::from)); 
    Page<Long> ids = orders.findIds(pageable); 
    Map<Long, Order> byId = orders.findWithLinesByIdIn(ids.getContent()).stream() 
            .collect(Collectors.toMap(Order::getId, Function.identity())); 
    return PageResponse.from(ids.map(id -> OrderResponse.from(byId.get(id)))); 
}

ids.map giữ nguyên thứ tự và metadata của trang, nên query thứ hai không cần order by:

Text
=== two-query | load 2.98 | statements 3 (pg calls 3) | rows 102 | best 1.53 ms of 15 | orders 20 lines 81 totalElements 200
   calls   1 rows    1  select count(o1_0.id) from orders o1_0
   calls   1 rows   20  select o1_0.id from orders o1_0 order by o1_0.id offset $1 rows fetch first $2 rows only
   calls   1 rows   81  select o1_0.id,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,l1_0.quantity,l1_0.unit_price,o1_0.placed_at from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id left join products p1_0 on p1_0.id=l1_0.product_id where o1_0.id in ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)

Điều kiện lọc nằm trong query id, nơi nó không đụng được tới collection:

src/main/java/com/example/demo/order/OrderRepository.java
@Query(value = "select o.id from Order o where exists (select 1 from OrderLine l where l.order = o and l.product.category.name = :category)",
        countQuery = "select count(o) from Order o where exists (select 1 from OrderLine l where l.order = o and l.product.category.name = :category)")
Page<Long> findIdsContainingCategory(String category, Pageable pageable);

Trang 0 cỡ 5, rồi findWithLinesByIdIn, trong 3 statement:

Text
    order 1 lines 4
    order 7 lines 4
    order 8 lines 5
    order 9 lines 3
    order 10 lines 4
    totalElements 99 totalPages 20

Order đầy đủ, và count đếm order chứ không đếm row sau join. Với service này, OrderQueryCountTest pass.

So sánh các chiến lược fetch

Năm hàng ô vuông, mỗi ô là một SQL statement cho cùng một trang 20 order: lazy loading 83 statement và 163 row, JOIN FETCH kèm Pageable 2 statement và 82 row, default_batch_fetch_size 50 là 5 statement và 163 row, FetchMode.SUBSELECT 5 statement và 922 row vì subquery bỏ mất phân trang, mẫu hai query 3 statement và 102 row

Trang 3 cỡ 20 order map sang OrderResponse, PostgreSQL 18.6, tốt nhất trong 15 lần:

Kỹ thuậtStatementRow truyền vềAn toàn khi phân trangThời gian tốt nhấtLoad
Lazy loading, chưa sửa83163Có, phân trang bằng SQL11.29 ms2.98
JOIN FETCH + countQuery282Có trên 7.4 nhờ derived table; trong bộ nhớ khi sắp theo cột của line; lỗi khi lọc theo cột đó1.11 ms2.98
@EntityGraph trên derived query282Như JOIN FETCH1.55 ms2.98
@BatchSize(20) chỉ trên Order.lines641638.52 ms2.32
default_batch_fetch_size=2071634.17 ms2.82
default_batch_fetch_size=5051634.12 ms2.83
@BatchSize trên lines và trên Product51633.44 ms2.19
@Fetch(SUBSELECT) trên lines5922Không: subquery bỏ mất phân trang5.76 ms2.65
Mẫu hai query3102Có; điều kiện lọc và sắp xếp nằm ở query id1.53 ms2.98

Một chính sách dùng được cho application:

  • Đặt default_batch_fetch_size toàn cục, ở đây là 50. Nó giới hạn thiệt hại của mọi lazy association không ai lên kế hoạch ở mức một statement cho mỗi 50 entity.
  • Cho mỗi endpoint danh sách một kế hoạch fetch rõ ràng: mẫu hai query cho trang parent kèm children, hoặc fetch join khi query không sắp xếp hay lọc theo children.
  • Đặt ngân sách statement trong test cho mọi endpoint trả về danh sách.
  • Không bao giờ kết hợp SUBSELECT với phân trang.

Projection: chỉ select những gì response cần

Mọi cách sửa ở trên vẫn dựng entity, và entity tốn hơn row của nó. Mỗi entity được đăng ký trong persistence context và, trong transaction read-write, bị kiểm tra thay đổi lúc flush. Một endpoint hiển thị danh sách product không cần cả hai việc đó. Spring Data JPA dựng projection từ return type của repository method, và loại projection quyết định Hibernate gửi SQL gì và cái gì ở lại trong persistence context.

Profile lab-projections gọi từng method dưới đây cho 13 product thuộc Keyboards, trong một TransactionTemplate read-write, in kết quả và số entity được quản lý lấy từ persistence context của Hibernate, rồi log session metrics lúc commit:

Java
entityManager.unwrap(SessionImplementor.class).getPersistenceContextInternal().getNumberOfManagedEntities()

Mốc so sánh, List<Product> findByCategoryNameOrderByPrice(String category):

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price
    13 results, first: Product[65, Keyboard 09, 14.90]
    managed entities in the persistence context: 13
	2453625 ns executing 1 flushes (flushing a total of 13 entities and 0 collections)

Mọi cột, 13 entity được quản lý, và 13 entity bị dirty checking lúc commit dù không có gì thay đổi.

Closed interface projection

Một interface mà mọi getter đều khớp với property của entity là closed projection:

src/main/java/com/example/demo/product/ProductSummary.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public interface ProductSummary {
 
    Long getId();
 
    String getName();
 
    BigDecimal getPrice();
}
src/main/java/com/example/demo/product/ProductRepository.java
List<ProductSummary> findSummariesByCategoryNameOrderByPrice(String category);
Text
select p1_0.id,p1_0.name,p1_0.price from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price
    13 results, first: ProductSummary[65, Keyboard 09, 14.90]
    result class: jdk.proxy2.$Proxy131
    managed entities in the persistence context: 0
	10875 ns executing 1 flushes (flushing a total of 0 entities and 0 collections)

Ba cột, không entity nào, không có gì để dirty checking. Các object kết quả là JDK proxy.

Open projection với SpEL load cả entity

Một getter có @Value tính giá trị từ target, tức là entity:

src/main/java/com/example/demo/product/ProductLabel.java
package com.example.demo.product;
 
import org.springframework.beans.factory.annotation.Value;
 
public interface ProductLabel {
 
    @Value("#{target.name + ' (' + target.sku + ')'}")
    String getLabel();
}

Trả về từ List<ProductLabel> findLabelsByCategoryNameOrderByPrice(String category):

Text
select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price
    13 results, first: ProductLabel[Keyboard 09 (SKU-0065)]
    result class: jdk.proxy2.$Proxy132
    managed entities in the persistence context: 13
	200833 ns executing 1 flushes (flushing a total of 13 entities and 0 collections)

Spring Data không biết một expression đọc những property nào, nên nó load entity: đủ năm cột, 13 entity được quản lý và 13 entity bị dirty checking lúc commit, cùng chi phí với trả về Product. Open projection là một góc nhìn lên entity, không phải cách để đọc ít hơn. Một default method tính ra cùng nhãn đó mà vẫn giữ interface ở dạng closed, ở đây trả về từ List<ProductLabelView> findLabelViewsByCategoryNameOrderByPrice(String category):

src/main/java/com/example/demo/product/ProductLabelView.java
package com.example.demo.product;
 
public interface ProductLabelView {
 
    String getName();
 
    String getSku();
 
    default String getLabel() {
        return getName() + " (" + getSku() + ")";
    }
}
Text
select p1_0.name,p1_0.sku from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price
    13 results, first: ProductLabelView[Keyboard 09 (SKU-0065)]
    managed entities in the persistence context: 0

Hai cột, đúng những cột mà các getter abstract nêu tên, và không entity nào.

Record DTO: derived query và select new

Record dùng được làm return type của derived query. Spring Data đọc các parameter trong constructor của nó:

src/main/java/com/example/demo/product/ProductRow.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductRow(Long id, String name, BigDecimal price) {
}
src/main/java/com/example/demo/product/ProductRepository.java
List<ProductRow> findRowsByCategoryNameOrderByPrice(String category);
 
@Query("select new com.example.demo.product.ProductRow(p.id, p.name, p.price) from Product p where p.category.name = :category order by p.price")
List<ProductRow> findRowsJpql(String category);
 
@Query("select p.id, p.name, p.price from Product p where p.category.name = :category order by p.price")
List<ProductRow> findRowsJpqlWithoutNew(String category);

Cả ba đều gửi cùng một statement:

Text
select p1_0.id,p1_0.name,p1_0.price from products p1_0 join categories c1_0 on c1_0.id=p1_0.category_id where c1_0.name=? order by p1_0.price
    13 results, first: ProductRow[id=65, name=Keyboard 09, price=14.90]
    result class: com.example.demo.product.ProductRow
    managed entities in the persistence context: 0

Method thứ ba không có new: Spring Data JPA viết lại danh sách property thành constructor expression cho record. Class làm việc đó, DtoProjectionTransformerDelegate, mang @since 3.5, cho biết tính năng có từ bản nào. select new với tên class đầy đủ là JPQL chuẩn và không cần tới việc viết lại đó.

Dynamic projection

Một method có thể trả về bất kỳ dạng nào ở trên khi caller truyền type vào:

src/main/java/com/example/demo/product/ProductRepository.java
<T> List<T> findByCategoryName(String category, Class<T> type);
Lời gọiCột trong SQLKết quảEntity được quản lý
findByCategoryName("Keyboards", ProductRow.class)p1_0.id,p1_0.name,p1_0.priceProductRow0
findByCategoryName("Keyboards", ProductSummary.class)p1_0.id,p1_0.name,p1_0.priceJDK proxy0
findByCategoryName("Keyboards", Product.class)đủ năm cộtProduct13

Type quyết định SQL ngay lúc gọi, nên một service có thể đưa ra danh sách rẻ và entity đầy đủ từ cùng một repository method.

Native query map sang interface

Native query có thể trả về một interface mà getter khớp với alias của cột. Một báo cáo theo category:

src/main/java/com/example/demo/product/CategoryStats.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public interface CategoryStats {
 
    String getCategory();
 
    long getProducts();
 
    BigDecimal getAveragePrice();
}
src/main/java/com/example/demo/product/ProductRepository.java
@NativeQuery("""
        select c.name as category, count(*) as products, round(avg(p.price), 2) as averagePrice
        from products p join categories c on c.id = p.category_id
        group by c.name
        order by c.name""")
List<CategoryStats> categoryStats();
Text
    8 results, first: CategoryStats[Cables, 12, 226.57]
    result class: jdk.proxy2.$Proxy135
    managed entities in the persistence context: 0
	3249 ns executing 2 flushes (flushing a total of 0 entities and 0 collections)

Getter có tên thay cho các row Object[] mà bài 27 của khoá Basics đọc theo vị trí, và không có entity. Session chạy hai lần flush, nhiều hơn các method JPQL một lần: Hibernate không biết native query đọc những bảng nào, nên nó flush persistence context trước khi chạy.

Projection và persistence context

Năm cột của products là id, category_id, name, price và sku, mỗi loại projection một hàng tô sáng các cột mà SQL của nó select: entity và open projection SpEL select đủ năm cột và để lại 13 entity được quản lý, bị dirty checking; closed interface và record DTO select id, name và price, closed interface kèm default method select name và sku, và cả ba không để lại entity nào

Return typeSố cột được selectĐược quản lý sau lời gọiEntity bị dirty checking lúc commit
Entity Product5 trên 51313
Closed interface ProductSummary300
Closed interface có default method200
Open interface có @Value5 trên 51313
Record, derived query300
Record, select new300
Native query sang interfacecác alias của query00

Còn một dạng nằm giữa hai nhóm. Closed interface có interface lồng bên trong, ProductWithCategory với CategoryName getCategory(), select p1_0.name,c1_0.id,c1_0.name và để lại 1 entity được quản lý: projection lồng được dựng từ một entity Category thật, và entity đó bị dirty checking lúc commit. Chỉ closed interface phẳng và DTO mới đứng ngoài persistence context.

Projection là bản sao giá trị, không phải object được quản lý. Không có gì theo dõi nó, nên không có gì được ghi ngược lại khi nó thay đổi, đúng thứ một endpoint đọc cần và hoàn toàn sai cho code định cập nhật dữ liệu.

Endpoint read model dùng record projection

Danh sách order cũng không cần entity. Bản tóm tắt cho mỗi order là một JPQL query với constructor expression:

src/main/java/com/example/demo/order/OrderSummary.java
package com.example.demo.order;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public record OrderSummary(Long id, Instant placedAt, String customerEmail, long lines, BigDecimal total) {
}
src/main/java/com/example/demo/order/OrderRepository.java
@Query(value = """
        select new com.example.demo.order.OrderSummary(o.id, o.placedAt, c.email, count(l), sum(l.unitPrice * l.quantity))
        from Order o join o.customer c join o.lines l
        group by o.id, o.placedAt, c.email""",
        countQuery = "select count(o) from Order o")
Page<OrderSummary> findSummaries(Pageable pageable);
src/main/java/com/example/demo/order/OrderController.java
@GetMapping("/summaries")
public PageResponse<OrderSummary> findSummaries(@PageableDefault(size = 20, sort = "id") Pageable pageable) {
    return service.findSummaries(pageable);
}

service.findSummaries là một transaction read-only trả về PageResponse.from(orders.findSummaries(pageable)). join o.lines dạng inner bỏ qua order không có line, bộ dữ liệu này không có order nào như vậy; left join sẽ giữ chúng lại.

Text
select o1_0.id,o1_0.placed_at,c1_0.email,count(l1_0.id),sum((l1_0.unit_price*l1_0.quantity)) from orders o1_0 join customers c1_0 on c1_0.id=o1_0.customer_id join order_lines l1_0 on o1_0.id=l1_0.order_id group by o1_0.id,o1_0.placed_at,c1_0.email order by o1_0.id offset ? rows fetch first ? rows only
select count(o1_0.id) from orders o1_0
INFO c.example.demo.common.SqlCountingFilter  : GET /api/orders/summaries?page=3&size=20 -> 2 SQL statements
Text
{"content":[{"id":61,"placedAt":"2026-01-19T03:00:00Z","customerEmail":"customer11@example.com","lines":4,"total":2193.00},{"id":62,"placedAt":"2026-01-19T10:00:00Z","customerEmail":"customer12@example.com","lines":5,"total":2236.60},
GET …?page=3&size=20StatementRowByte của responseTốt nhất trong 20 lần qua HTTP
/api/orders, entity theo mẫu hai query31025,9793.95 ms
/api/orders/summaries, record projection2212,3012.98 ms

Đo bằng curl sau 30 request warm-up, load average 2.75. Database làm phép cộng và phép đếm; application nhận 20 row. Với một danh sách đọc nhiều, record projection là điểm xuất phát mặc định đáng chọn; entity dành cho màn hình sửa order.

Batch insert trên PostgreSQL

Bài import và cách đo

Bài import gồm 2,000 order, mỗi order 4 line, tổng 10,000 row trên hai bảng, lưu qua cascade của bài 28:

Java
tx.execute(status -> {
    List<Order> batch = new ArrayList<>(2000);
    for (int i = 0; i < 2000; i++) {
        Order order = new Order(entityManager.getReference(Customer.class, 1L + i % 50),
                Instant.parse("2026-09-01T00:00:00Z").plus(i, ChronoUnit.MINUTES));
        for (int n = 1; n <= 4; n++) {
            long productId = 1 + (i * 7L + n * 31L) % 100;
            order.addLine(new OrderLine(entityManager.getReference(Product.class, productId),
                    1 + (i + n) % 4, prices.get(productId)));
        }
        batch.add(order);
    }
    return orderRepository.saveAll(batch);
});

getReference cho một proxy của mỗi customer và product mà không cần SELECT, còn prices được đọc một lần trước khi bấm giờ. Một runner lab-import xoá các row của lần chạy trước, chạy vacuum analyze, reset pg_stat_statements, và bấm giờ cả transaction lẫn commit của nó: một lần warm-up, rồi năm lần có bấm giờ. Load average 1 phút nằm trong khoảng 2.7 đến 6.1 suốt các lần chạy này; mỗi hàng của bảng bên dưới ghi load của riêng nó.

IDENTITY: mỗi row một INSERT

Với khoá identity của V1, Hibernate log một INSERT cho mỗi entity, và PostgreSQL nhận được:

Text
   calls   8000 rows   8000  insert into order_lines (order_id,product_id,quantity,unit_price) values ($1,$2,$3,$4) RETURNING *
   calls   2000 rows   2000  insert into orders (customer_id,placed_at) values ($1,$2) RETURNING *

RETURNING * không có trong SQL log của Hibernate. Hibernate xin driver trả về khoá được sinh ra, và driver PostgreSQL gắn thêm nó vào. Khoá chỉ tồn tại khi row đã tồn tại, nên Hibernate thực thi INSERT ngay trong persist, đúng lúc nó cần id, và không thể giữ row lại để gửi chung. Đặt hibernate.jdbc.batch_size=50hibernate.order_inserts=true không đổi gì trong session metrics:

Text
	1537914 ns preparing 10000 JDBC statements
	1331657082 ns executing 10000 JDBC statements
	0 ns executing 0 JDBC batches

10,000 statement, 0 batch. Tốt nhất trong 5 lần: 1,372.2 ms khi không có cấu hình đó và 1,418.7 ms khi có.

SEQUENCE với pooled optimizer

Sequence phát khoá trước khi INSERT, nên Hibernate có thể xếp hàng các row tới lúc flush. Với allocationSize = 50, một lần nextval phủ 50 id khi sequence tăng mỗi bước 50. Migration thay các cột identity của hai bảng nhận dữ liệu import:

src/main/resources/db/migration/V3__sequences_for_orders.sql
alter table orders alter column id drop identity;
alter table order_lines alter column id drop identity;
 
create sequence orders_seq increment by 50;
create sequence order_lines_seq increment by 50;
 
select setval('orders_seq', (select max(id) from orders));
select setval('order_lines_seq', (select max(id) from order_lines));

Bỏ identity là việc quan trọng: nếu để cả hai, một câu insert thô dựa vào default của identity có thể đụng id mà Hibernate đã lấy từ sequence. Không có default, mọi câu insert phải tự đưa id.

src/main/java/com/example/demo/order/Order.java
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) 
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "orders_seq") 
    @SequenceGenerator(name = "orders_seq", sequenceName = "orders_seq", allocationSize = 50) 
    private Long id;

OrderLine được đổi y hệt với order_lines_seq, và ddl-auto=validate chấp nhận cả hai sequence. Chưa có cấu hình batch:

Text
   calls   8000 rows   8000  insert into order_lines (order_id,product_id,quantity,unit_price,id) values ($1,$2,$3,$4,$5)
   calls   2000 rows   2000  insert into orders (customer_id,placed_at,id) values ($1,$2,$3)
   calls    200 rows    200  select nextval('orders_seq')

Id giờ là một parameter được bind và không còn RETURNING. 200 lần nextval gồm 40 cho orders_seq và 160 cho order_lines_seq: SQL log đếm được 40 và 160, và hai sequence tăng thêm 2,000 và 8,000. pg_stat_statements gộp cả hai vào một mục duy nhất như trên. Vẫn là 10,000 INSERT từng cái một: 1,196.5 ms.

hibernate.jdbc.batch_size và hibernate.order_inserts

src/main/resources/application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50 
Text
	535605 ns preparing 4200 JDBC statements
	23395792 ns executing 200 JDBC statements
	695271525 ns executing 4000 JDBC batches

4,000 batch cho 10,000 row, 755.8 ms. Cascade xếp hàng các row theo thứ tự chúng được persist, một order rồi bốn line của nó, và Hibernate đóng batch hiện tại mỗi khi statement tiếp theo nhắm tới bảng khác: batch 1 order, rồi batch 4 line, mỗi loại 2,000 lần. Sắp hàng đợi theo bảng sẽ sửa được việc đó:

src/main/resources/application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true 
Text
	72250 ns preparing 202 JDBC statements
	21294954 ns executing 200 JDBC statements
	110352048 ns executing 200 JDBC batches

200 batch đầy, mỗi batch 50, gồm 40 cho order và 160 cho line, hết 162.6 ms. pg_stat_statements vẫn đếm 10,000 INSERT một row: batching đổi cách statement đi từ driver sang, không đổi những gì PostgreSQL thực thi.

reWriteBatchedInserts=true

Driver PostgreSQL có thể viết lại một batch thành các INSERT statement nhiều row. Đây là một property của connection:

src/main/resources/application.properties
spring.datasource.url=jdbc:postgresql://localhost:5505/demo 
spring.datasource.url=jdbc:postgresql://localhost:5505/demo?reWriteBatchedInserts=true 

Phía Hibernate không đổi, vẫn 200 batch, nhưng PostgreSQL nhận được các statement khác. Những câu dài được rút gọn ở đây:

Text
   calls    200 rows    200  select nextval('orders_seq')
   calls    160 rows   2560  insert into order_lines (order_id,product_id,quantity,unit_price,id) values ($1,$2,$3,$4,$5),($6,$7,$8,$9,$10), ... ,($76,$77,$78,$79,$80)
   calls    160 rows   5120  insert into order_lines (order_id,product_id,quantity,unit_price,id) values ($1,$2,$3,$4,$5),($6,$7,$8,$9,$10), ... ,($156,$157,$158,$159,$160)
   calls    160 rows    320  insert into order_lines (order_id,product_id,quantity,unit_price,id) values ($1,$2,$3,$4,$5),($6,$7,$8,$9,$10)
   calls     40 rows    640  insert into orders (customer_id,placed_at,id) values ($1,$2,$3),($4,$5,$6), ... ,($46,$47,$48)
   calls     40 rows   1280  insert into orders (customer_id,placed_at,id) values ($1,$2,$3),($4,$5,$6), ... ,($94,$95,$96)
   calls     40 rows     80  insert into orders (customer_id,placed_at,id) values ($1,$2,$3),($4,$5,$6)

Mỗi batch 50 thành ba statement 32, 16 và 2 row, các khúc có cỡ là luỹ thừa của 2: 600 INSERT statement thay vì 10,000. Tốt nhất trong 5 lần: 156.9 ms, so với 162.6 ms khi không viết lại. Trên máy này, việc viết lại đổi phần việc của database, 600 lần thực thi so với 10,000, nhiều hơn hẳn so với thời gian.

Mười nghìn row, đặt cạnh nhau

Ba làn cho cùng 10,000 row: IDENTITY gửi mỗi INSERT kèm RETURNING * ngay trong persist, 10,000 statement và 0 batch hết 1,372 ms; SEQUENCE với batch_size 50 đóng batch mỗi khi INSERT tiếp theo nhắm sang bảng kia, 4,000 batch hết 756 ms; thêm order_inserts và reWriteBatchedInserts cho 200 batch 50 row, mỗi batch gửi thành các statement 32, 16 và 2 row, tổng 600, hết 157 ms; 200 lần nextval phủ đủ 10,000 id

Khoá và cấu hìnhStatement PostgreSQL thực thiJDBC batchTốt nhất trong 5 lầnLoad
IDENTITY10,000 INSERT01,372.2 ms5.06–5.29
IDENTITY, batch_size=50, order_inserts10,000 INSERT01,418.7 ms5.19–5.74
SEQUENCE, allocationSize=5010,000 INSERT + 200 nextval01,196.5 ms4.29–4.42
+ batch_size=5010,000 INSERT + 200 nextval4,000755.8 ms5.82–6.07
+ order_inserts=true10,000 INSERT + 200 nextval200162.6 ms5.82
+ reWriteBatchedInserts=true600 INSERT + 200 nextval200156.9 ms5.52

Các cấu hình chỉ có tác dụng khi đi cùng nhau. batch_size bị bỏ qua với khoá identity, và thiếu order_inserts thì batch của một cascade parent-child vẫn bé tí.

Flush và clear cho import rất lớn

saveAll giữ mọi entity nó đã lưu trong persistence context cho tới lúc commit. Với một bài import không biết trước cỡ, vòng lặp flush và clear sau mỗi vài batch:

Java
// newOrder(i, prices) builds one order and its four lines, as in the loop above
for (int i = 0; i < orderCount; i++) {
    entityManager.persist(newOrder(i, prices));
    if ((i + 1) % 50 == 0) {
        entityManager.flush();
        entityManager.clear();
    }
}

flush gửi các INSERT đang xếp hàng, còn clear detach mọi thứ, nên persistence context không bao giờ giữ quá 50 order cùng line của chúng. 20,000 order, 100,000 row, bật cả ba cấu hình batch, tốt nhất trong 3 lần:

100,000 rowĐược quản lý lúc commitHeap đang dùng trước commit, sau System.gc()Số lần flushTốt nhất trong 3 lần
saveAll100,00070.0 MB11,456.5 ms
flush và clear sau mỗi 50 order026.2 MB40119,858.4 ms

Bộ nhớ giảm đúng như mong đợi, còn thời gian tăng mười ba lần. Thời gian đó nằm ở PostgreSQL, không phải Hibernate. Mỗi INSERT vào order_lines chạy phép kiểm tra foreign key SELECT 1 FROM ONLY "public"."orders" x WHERE "id" OPERATOR(pg_catalog.=) $1 FOR KEY SHARE OF x. Nạp auto_explain vào session qua connection-init-sql của Hikari, cho một lần chạy vòng lặp với 300 order, log ra phép kiểm tra đó 1,200 lần, lần nào cũng là Seq Scan on orders x. Lần flush đầu tiên của vòng lặp chạy nó khi orders có 250 row, lúc đó sequential scan rất rẻ. saveAll flush một lần, và order_inserts đặt cả 20,000 order lên trước line của chúng. Plan cache giải thích sự khác biệt: với set plan_cache_mode = force_custom_plan làm connection init SQL, khiến PostgreSQL lập plan cho từng lần thực thi thay vì dùng lại một generic plan đã cache, vòng lặp hết 2,552.5 ms và saveAll hết 2,474.5 ms, còn 5,000 order qua vòng lặp đi từ 1,814.6 ms xuống 692.6 ms.

Vậy flush và clear giới hạn bộ nhớ chứ không làm gì nhanh hơn. Đó là nhịp đúng cho những bài import đủ lớn để đe doạ heap. Khi một bài import như vậy vào một bảng parent nhỏ chậm dần theo thời gian, hãy đọc plan của các phép kiểm tra foreign key bằng auto_explain trước khi đổ lỗi cho Hibernate.

Khi nào nên bỏ qua JPA

Với những lần nạp dữ liệu lớn mà không ai đọc lại dưới dạng entity, COPY của PostgreSQL là đường vào nhanh nhất. CopyManager của driver stream CSV vào bảng qua chính connection của transaction, còn id lấy từ chính các sequence đó:

Java
List<Long> orderIds = jdbc.sql("select nextval('orders_seq') from generate_series(1, :n)")
        .param("n", 2000).query(Long.class).list();
// order_lines_seq the same way, then build the CSV text for both tables
CopyManager copy = DataSourceUtils.getConnection(dataSource).unwrap(PGConnection.class).getCopyAPI();
copy.copyIn("copy orders (id, customer_id, placed_at) from stdin (format csv)", new StringReader(orderRows));
copy.copyIn("copy order_lines (id, order_id, product_id, quantity, unit_price) from stdin (format csv)", new StringReader(lineRows));

Cùng 2,000 order và 8,000 line đó hết 63.7 ms, tốt nhất trong 5 lần với load 2.75, so với 156.9 ms của cấu hình JPA tốt nhất. PGConnection là class của driver, nên dependency chuyển từ runtimeOnly sang implementation. Nó bỏ qua mọi thứ JPA làm: không entity callback, không auditing, không cascade và không event. Chấp nhận được cho một lần nạp bảng staging hằng đêm, và là lý do giữ JPA cho mọi thứ có quy tắc nghiệp vụ đi kèm.

Transaction read-only cho lần đọc lớn

Đọc 80,801 order line dưới dạng entity, mỗi chế độ ba lần, không cho thấy khác biệt đáng tin nào về heap. Heap giữ lại sau System.gc() là 34.2 đến 34.5 MB trong transaction read-write, 31.7 đến 36.6 MB với @Transactional(readOnly = true), và 36.6 đến 36.7 MB với query hint read-only của Hibernate, @QueryHints(@QueryHint(name = HibernateHints.HINT_READ_ONLY, value = "true")). Phần bộ nhớ mà chế độ read-only tiết kiệm được, nếu có, nhỏ hơn độ dao động giữa các lần chạy ở đây. Commit thì khác. Trong transaction read-write, commit mất 66.4 đến 79.8 ms sau warm-up, gần như toàn bộ là lần flush dirty checking 80,801 entity. Với readOnly = true commit mất 6.0 đến 10.9 ms và session metrics ghi 0 lần flush. Chỉ với hint, flush vẫn đi qua cả 80,801 entity, hết 6.0 đến 6.7 ms sau warm-up. readOnly đổi những gì trong Spring và trong Hibernate, và khi nào nên dùng, là một phần của bài tiếp theo.

Công cụ nào cho vấn đề hiệu năng JPA nào

Triệu chứngCông cụĐo được ở bài này
Endpoint danh sách chậm mà không ai biết vì saoorg.hibernate.session.metrics=debug, StatementInspector cho mỗi request83 statement cho một trang
N+1 quay lại sau mỗi lần sửa codeNgân sách statement trong integration testFail ở 83, pass ở 3
Lazy association không ai lên kế hoạchhibernate.default_batch_fetch_size83 → 5 statement
Một trang parent kèm childrenMẫu hai query, hoặc JOIN FETCH khi query không sắp xếp hay lọc theo children3 hoặc 2 statement
Một danh sách chỉ đọcClosed interface hoặc record projection21 row, 0 entity được quản lý
Một bài import lớnSEQUENCE với allocationSize, batch_size, order_inserts, reWriteBatchedInserts1,372 → 157 ms
Bài import vượt quá heapflushclear sau mỗi vài batch70.0 → 26.2 MB
Nạp dữ liệu lớn không có quy tắc nghiệp vụCOPY63.7 ms

FAQ

Làm sao phát hiện N+1 query trong test Spring Boot?

Đăng ký một StatementInspector của Hibernate qua HibernatePropertiesCustomizer, gom SQL theo thread, và assert số lượng trong một test MockMvc. Test ở đây fail với to be less than or equal to 3 but was 83 và liệt kê từng statement. generate_statistics là không đủ: query log của nó chỉ cho thấy 2 trong 83 statement, vì lazy load không phải là query.

Tại sao generate_statistics không còn in Session Metrics?

Vì Hibernate 7 chuyển khối theo session sang log category org.hibernate.session.metrics ở mức DEBUG. Riêng generate_statistics=true không in gì cho mỗi session với Hibernate 7.4.5, và cấu hình cũ hibernate.session.events.log đã deprecated và bị bỏ qua. logging.level.org.hibernate.session.metrics=debug in ra HHH000401 kèm số statement, không cần generate_statistics.

@BatchSize và hibernate.default_batch_fetch_size khác nhau thế nào?

Ở phạm vi. @BatchSize trên một collection chỉ batch collection đó: trên trang order, line đi từ 20 statement xuống 1 còn 61 statement product vẫn nguyên, tổng 64. Đặt trên một class entity, nó batch các proxy của entity đó. default_batch_fetch_size=50 batch mọi lazy association cùng lúc: 5 statement. Trên PostgreSQL cả hai đều gửi = any (?) với một array parameter, cỡ đúng bằng số id thực sự cần.

JOIN FETCH kèm Pageable có load hết vào bộ nhớ trong Hibernate 7 không?

Trong trường hợp thường gặp trên Hibernate 7.4 thì không: nó phân trang parent trong một derived table rồi join children vào trang đó, ở đây là 2 statement và 82 row. Nó vẫn quay về bộ nhớ, kèm cảnh báo HHH90003004, khi query sắp xếp theo một cột của children trước; query đó chuyển về 801 row để hiện năm order. Fetch join có phân trang mà lọc theo children thì lỗi trên PostgreSQL với missing FROM-clause entry. Việc viết lại thành derived table có từ Hibernate ORM 7.4.0.

FetchMode.SUBSELECT có an toàn khi phân trang không?

Không. Subquery mà Hibernate 7.4.5 sinh ra, select o1_0.id from orders o1_0, bỏ mất offset và limit của trang, nên một trang 20 order load line của cả 200 order: 801 row thay vì 81, và 922 row cho cả request thay vì 163.

Tại sao Hibernate tắt JDBC batching với cột IDENTITY?

Vì id chỉ có sau khi row được insert, mà Hibernate cần nó ngay khi persist trả về. Nó gửi mỗi INSERT ngay lập tức và đọc khoá về, việc mà driver PostgreSQL làm bằng cách gắn thêm RETURNING *. Với batch_size=50, session metrics vẫn ghi 10,000 statement và 0 batch. Một SEQUENCE với allocationSize = 50 cần 200 lần nextval cho 10,000 row và cho phép 200 batch.

Interface projection có chỉ select các cột cần thiết không?

Closed projection thì có: ProductSummary với ba getter sinh ra select p1_0.id,p1_0.name,p1_0.price và không để lại entity nào trong persistence context. Open projection với @Value("#{target…}") select mọi cột và để lại 13 entity được quản lý, vì expression cần entity.

Kết luận

Một vấn đề hiệu năng JPA là một con số đếm trước khi là một con số thời gian. Trang order gửi 83 statement, và cùng con số đó hiện ra trong pg_stat_statements của PostgreSQL, trong khối org.hibernate.session.metrics mà Hibernate 7 log thay cho generate_statistics, và trong một StatementInspector mà test có thể assert. Test đó là công cụ giữ cho các cách sửa đứng yên. Trong các cách sửa, default_batch_fetch_size đưa trang xuống 5 statement mà không đụng tới query nào, dùng một array parameter trên PostgreSQL. SUBSELECT đọc cả bảng cho một trang 20 order. Hibernate 7.4 phân trang fetch join bằng SQL trừ khi query sắp xếp hay lọc theo children, và mẫu hai query xử lý những trường hợp đó trong 3 statement.

Đọc ít hơn thắng fetch khéo hơn. Closed interface hay record projection chỉ select ba cột và không để lại gì trong persistence context, open projection SpEL thì không làm được việc nào trong hai việc đó, còn read model dạng record trả lời danh sách order bằng 21 row. Khi ghi, bộ sinh khoá quyết định: IDENTITY nghĩa là 10,000 INSERT lẻ bất kể cấu hình, còn SEQUENCE pooled cùng batch_size, order_insertsreWriteBatchedInserts nghĩa là 600 INSERT nhiều row trong chưa tới một phần tám thời gian. Flush và clear giữ heap trong giới hạn với 100,000 row, và tiện thể phơi ra một plan của PostgreSQL.

Bài tiếp theo đi sâu vào transaction: propagation, isolation level, readOnly thực sự thay đổi những gì, và các quy tắc rollback.

Bài viết liên quan

[Advanced Spring Boot] NoSQL với Spring Data: MongoDB và Redis

Spring Data MongoDB và Spring Data Redis trên Spring Boot 4.1.1: @Document, id là String hay ObjectId, field _class, embedded hay @DocumentReference cùng các query mỗi cách gửi đi, MongoRepository và MongoTemplate kèm command đã log, $push/$inc so với load-modify-save dưới 50 thread, @Version, Boot có tạo index từ @Indexed không, COLLSCAN và IXSCAN trên 300.000 document, một aggregation pipeline, transaction trên replica set, một field bị đổi tên, serializer của RedisTemplate, INCR, sorted set, hash, TTL, key của @RedisHash và phantom key, pipelining và Lettuce.

[Advanced Spring Boot] Multi-tenancy và soft delete với Spring Boot và Hibernate

Multi-tenancy và soft delete trên Spring Boot 4.1.1 với Hibernate và PostgreSQL: filter đọc X-Tenant-Id với ThreadLocal bị rò rỉ trên thread Tomcat được tái sử dụng và bị mất trên @Async, discriminator column với @TenantId và CurrentTenantIdentifierResolver (predicate tenant trên find, JPQL, derived query, Specification và bulk update, không có trên native SQL hay JdbcClient), schema per tenant với MultiTenantConnectionProvider, bẫy tái sử dụng connection giữa setSchema và SET search_path trên HikariCP, Flyway cho từng tenant schema và TenantSchemaMapper của Hibernate, database per tenant với 100 connection cho mười tenant, row-level security của PostgreSQL với set_config và FORCE, các strategy của @SoftDelete so với @SQLDelete và @SQLRestriction, lỗi to-one LAZY, partial unique index cho SKU đã soft delete, và khôi phục các dòng đã xóa.

[Advanced Spring Boot] Spring Events: ApplicationEventPublisher, @EventListener và @TransactionalEventListener

Spring event trên Spring Boot 4.1.1: publish một record qua ApplicationEventPublisher và lớp bọc PayloadApplicationEvent, bằng chứng @EventListener chạy trên thread và transaction của caller, @Order cùng condition SpEL, listener trả về event khác, vì sao listener cho generic OrderEvent<Product> không bao giờ nhận được gì nếu thiếu ResolvableTypeProvider, listener throw exception khi sync và khi @Async, đủ bốn phase của @TransactionalEventListener với log của từng lần chạy, fallbackExecution, cái bẫy ghi database trong AFTER_COMMIT làm mất row và REQUIRES_NEW cứu nó, @Async so với applicationEventMulticaster async, và chỗ event in-process hết là một cơ chế đáng tin.

[Advanced Spring Boot] Transaction chuyên sâu trong Spring: propagation, isolation level và rollback rules

Propagation, isolation level và rollback rules của Spring trên Spring Boot 4.1.1 với PostgreSQL: đủ bảy propagation với log JpaTransactionManager và backend pid, deadlock connection pool do REQUIRES_NEW kèm số đo HikariCP, vì sao NESTED lỗi với JpaTransactionManager nhưng chạy bằng savepoint với JdbcTransactionManager, non-repeatable read, lost update và write skew ở từng isolation level, SQLSTATE 40001 thành CannotAcquireLockException, retry đúng chỗ quanh transaction, readOnly ở tầng JDBC, PostgreSQL và Hibernate, validateExistingTransaction, rollbackOn ALL_EXCEPTIONS và thứ thực sự áp dụng @Transactional(timeout).