Bulk update, batch insert và tối ưu hiệu năng JPA

4 câu hỏi~25 phútTừ khóa: hibernate.jdbc.batch_size, IDENTITY, SEQUENCE, allocationSize, saveAll, flush/clear, bulk update, JdbcTemplate.batchUpdate, COPY, reWriteBatchedInserts

Tiếp đi: batch insert và hiệu năng.

Đến giờ ta làm việc với vài entity mỗi request. Bài này xử lý số lượng lớn: tăng giá 100.000 Product, import 10.000 Product từ file CSV vào shop-app.

1. Bài toán

Bạn cần tăng giá 100.000 Product lên 10%. Cách viết đơn giản:

@Transactional
public void increasePrices() {
    List<Product> products = repository.findAll();
    for (Product product : products) {
        product.setPrice(product.getPrice().multiply(new BigDecimal("1.10")));
    }
}

Và import:

productRepository.saveAll(productsFromCsv);   // 10.000 dòng, chạy vài phút

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: Bulk update

@Modifying(clearAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * 1.10")
int increaseAllPrices();
UPDATE products SET price = price * 1.10;   -- 1 câu, không load entity

Lưu ý: bulk JPQL không đồng bộ các managed entity đã load và không kiểm tra/tăng @Version. Muốn optimistic lock vẫn đúng thì tự SET p.version = p.version + 1.

Bước 2: Config batching

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
  datasource:
    url: jdbc:postgresql://localhost:5432/shop?reWriteBatchedInserts=true
    username: ${DB_USER}
    password: ${DB_PASSWORD}

reWriteBatchedInserts=true làm driver PostgreSQL gộp batch thành INSERT ... VALUES (...),(...),..., nhanh hơn thêm đáng kể.

Bước 3: Đổi ID sang SEQUENCE

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;
    ...
}
CREATE SEQUENCE product_seq INCREMENT BY 50;   -- phải khớp allocationSize

Bước 3b: Import với flush/clear

@Service
public class ProductImportService {
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void importProducts(List<Product> products) {
        for (int i = 0; i < products.size(); i++) {
            entityManager.persist(products.get(i));
            if ((i + 1) % 50 == 0) {      // = batch_size
                entityManager.flush();    // gửi 1 batch
                entityManager.clear();    // giải phóng context
            }
        }
    }
}

Bước 3c: JdbcTemplate.batchUpdate khi không cần entity lifecycle:

public void importFast(List<ProductRow> rows) {
    jdbcTemplate.batchUpdate(
        "INSERT INTO products (id, name, price, stock) VALUES (nextval('product_seq'), ?, ?, ?)",
        rows, 1000,
        (ps, r) -> {
            ps.setString(1, r.name());
            ps.setBigDecimal(2, r.price());
            ps.setInt(3, r.stock());
        });
}

Bước 3d: COPY cho hàng triệu dòng (PostgreSQL):

try (Connection con = dataSource.getConnection();
     Reader csv = Files.newBufferedReader(path)) {
    CopyManager copy = con.unwrap(PGConnection.class).getCopyAPI();
    copy.copyIn("COPY products_staging (name, price, stock) FROM STDIN WITH (FORMAT csv, HEADER true)", csv);
}
-- sau đó INSERT INTO products SELECT ... FROM products_staging (có validate)

Bước 4: Test

spring.jpa.properties.hibernate.generate_statistics=true
curl -X POST -F file=@products.csv http://localhost:8080/admin/products/import
# log: "... 200 JDBC batches ..." thay vì "10000 JDBC statements"

5. Cách hoạt động

Không batch:                       batch_size = 50:
persist → INSERT ─► DB (1 RTT)     persist ×50 → (giữ trong action queue)
persist → INSERT ─► DB (1 RTT)     flush → addBatch ×50 → executeBatch ─► DB (1 RTT)
... 10.000 RTT                     ... 200 RTT

Vì sao IDENTITY phá batch:
persist(product)
   └─ cần id NGAY (để đặt vào Persistence Context)
   └─ IDENTITY: id chỉ có SAU khi INSERT chạy
   └─ ⇒ Hibernate phải INSERT ngay, từng câu một

SEQUENCE (allocationSize = 50):
   nextval('product_seq') = 1001 → Hibernate tự cấp 1001..1050 trong RAM
   → id có sẵn, INSERT được hoãn tới flush và gom batch

Bẫy của saveAll:

6. So sánh

CáchTốc độ (tương đối)Entity lifecycle / cascadeKhi nào
saveAll không batch, IDENTITYChậm nhấtCóVài chục dòng
JPA batch + SEQUENCE + flush/clearKháCóNghìn đến chục nghìn, cần logic entity
JdbcTemplate.batchUpdate + reWriteBatchedInsertsNhanhKhôngChục nghìn đến trăm nghìn
COPYNhanh nhấtKhôngHàng triệu dòng, import file
Bulk JPQL UPDATE/DELETE1 câuKhông (bỏ qua context)Sửa hàng loạt cùng công thức

7. Khi nào nên dùng / không nên dùng

Checklist tối ưu JPA:

Đừng mặc định rằng JPA chậm. Nhiều vấn đề hiệu năng xuất phát từ cách dùng ORM hoặc thiết kế SQL/database chưa phù hợp.

Bật batch_size rồi mà log vẫn thấy từng câu INSERT, sao vậy?

Bạn gần như chắc chắn gặp một trong các nguyên nhân sau:

allocationSize = 50 thì restart app có bị nhảy id không?

Có, và không sao. Mỗi instance lấy một khối 50 id; restart thì các id chưa dùng trong khối bị bỏ, nên id có "lỗ". Id chỉ cần duy nhất, không cần liên tục. Điều quan trọng là INCREMENT BY của sequence phải bằng allocationSize, lệch là Hibernate 6 báo lỗi lúc khởi động hoặc sinh id trùng ở bản cũ.

Phỏng vấn hỏi batch insert, hiệu năng JPA thì trả lời sao?

Q1. Làm sao insert 100.000 bản ghi nhanh với JPA?

🇻🇳 Tiếng Việt:

Em bật hibernate.jdbc.batch_size cùng order_inserts, dùng ID kiểu SEQUENCE với allocationSize khớp sequence vì IDENTITY làm mất batching, và flush/clear EntityManager mỗi batch để Persistence Context không phình. Với PostgreSQL em bật reWriteBatchedInserts. Nếu không cần entity lifecycle, em dùng JdbcTemplate.batchUpdate, còn hàng triệu dòng thì dùng COPY.

🇬🇧 English:

I enable hibernate.jdbc.batch_size with order_inserts, use SEQUENCE ids with an allocationSize matching the sequence, because IDENTITY disables insert batching, and flush and clear the EntityManager every batch so the persistence context does not grow. On PostgreSQL I set reWriteBatchedInserts. If I do not need the entity lifecycle I use JdbcTemplate.batchUpdate, and for millions of rows I use COPY.

Q2. Vì sao IDENTITY không batch được?

🇻🇳 Tiếng Việt:

Khi persist, Hibernate cần id ngay để quản lý entity trong Persistence Context. Với IDENTITY, id chỉ có sau khi câu INSERT chạy, nên Hibernate phải INSERT ngay từng câu, không hoãn tới flush để gom batch. SEQUENCE cho phép lấy trước một khối id, nên INSERT được hoãn và gom batch.

🇬🇧 English:

On persist, Hibernate needs the id immediately to manage the entity in the persistence context. With IDENTITY the id only exists after the INSERT runs, so Hibernate must execute each INSERT right away instead of deferring it to flush and batching. A SEQUENCE lets Hibernate pre-allocate a block of ids, so inserts are deferred and batched.

Q3. Bulk update bằng @Modifying có rủi ro gì?

🇻🇳 Tiếng Việt:

Nó chạy thẳng xuống database, bỏ qua Persistence Context: entity đã load bị stale, không có dirty checking, không có callback, không tăng @Version. Em dùng clearAutomatically, tự tăng version nếu cần, và chạy nó trong transaction riêng.

🇬🇧 English:

It goes straight to the database and bypasses the persistence context: loaded entities become stale, and there is no dirty checking, no lifecycle callbacks and no @Version increment. I use clearAutomatically, bump the version manually if needed, and run it in its own transaction.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao vòng lặp setPrice trên 100.000 entity chậm?

Load toàn bộ vào memory, giữ snapshot dirty checking, sinh 100.000 UPDATE riêng; dùng bulk UPDATE một câu.

allocationSize phải khớp với gì?

INCREMENT BY của sequence trong database.

Tại sao cần entityManager.clear() khi import?

Giải phóng entity đã flush khỏi Persistence Context để tránh tốn RAM và flush chậm dần.

saveAll với id gán sẵn sinh thêm query gì?

Một SELECT trước mỗi INSERT vì Spring Data coi là entity cũ và gọi merge.

Khi nào chọn COPY thay JPA?

Import hàng triệu dòng từ file, không cần entity lifecycle.