Bulk update, batch insert và tối ưu hiệu năng JPA
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 đề
- Hibernate load 100.000 entity vào memory, giữ snapshot cho dirty checking, rồi bắn 100.000 câu UPDATE riêng lẻ.
- Insert từng câu một: 10.000 round-trip tới PostgreSQL.
- Persistence Context phình to → GC nặng, flush càng lúc càng chậm.
- Entity dùng
GenerationType.IDENTITY→ Hibernate không thể JDBC batch INSERT.
3. Giải pháp
- Bulk update: một câu UPDATE thay cho load-sửa-ghi.
- JDBC batching: gom nhiều statement giống nhau gửi một lần.
- SEQUENCE + pooled optimizer thay IDENTITY để batch được.
- flush/clear định kỳ để Persistence Context không phình.
- Rất lớn: JdbcTemplate.batchUpdate hoặc PostgreSQL COPY.
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:
saveAllchỉ là vòng lặp gọisave; batch có hay không là do config ở trên.savegọiisNew(): nếu entity có id gán sẵn (UUID tự tạo, mã từ file) và không có@Version, Spring Data coi là entity cũ →merge→ bắn SELECT trước mỗi INSERT. Sửa bằng cách implementPersistable<ID>vớiisNew()đúng, hoặc dùngpersist.- Gọi
saveAll10.000 entity trong một transaction không clear → Persistence Context giữ cả 10.000. - Không có
@Transactionalbên ngoài → mỗisavelà một transaction riêng.
6. So sánh
| Cách | Tốc độ (tương đối) | Entity lifecycle / cascade | Khi nào |
|---|---|---|---|
saveAll không batch, IDENTITY | Chậm nhất | Có | Vài chục dòng |
| JPA batch + SEQUENCE + flush/clear | Khá | Có | Nghìn đến chục nghìn, cần logic entity |
| JdbcTemplate.batchUpdate + reWriteBatchedInserts | Nhanh | Không | Chục nghìn đến trăm nghìn |
| COPY | Nhanh nhất | Không | Hàng triệu dòng, import file |
| Bulk JPQL UPDATE/DELETE | 1 câu | Khô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:
- Kiểm tra số lượng SQL thực tế (log SQL, statistics).
- Tránh N+1 queries.
- Chỉ select những field cần thiết (projection).
- Sử dụng pagination.
- Tạo database index phù hợp.
- Cấu hình JDBC batching khi cần; dùng SEQUENCE thay IDENTITY.
- Không giữ transaction quá lâu; job lớn thì chia chunk, mỗi chunk một transaction.
- Dùng
EXPLAIN ANALYZEđể xem query plan. - Theo dõi connection pool và slow queries.
Đừ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.
product_seq với INCREMENT 50. Ai tạo, tạo lúc nào trên dev/staging/prod? → Bài 4.3.12 Flyway & Liquibase.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:
- Entity dùng
IDENTITY(rất hay gặp vớiBIGSERIAL) → Hibernate tắt batch insert cho entity đó. - Log
org.hibernate.SQLluôn in từng câu, kể cả khi đã batch. Hãy xem statistics ("JDBC batches") hoặc dùng datasource-proxy. - Xen kẽ insert nhiều bảng (Order rồi OrderItem rồi Order...) mà chưa bật
order_inserts→ batch bị cắt liên tục. saveAllvới id gán sẵn → merge → SELECT xen giữa các INSERT.
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_sizecùngorder_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ậtreWriteBatchedInserts. Nếu không cần entity lifecycle, em dùngJdbcTemplate.batchUpdate, còn hàng triệu dòng thì dùng COPY.
🇬🇧 English:
I enable
hibernate.jdbc.batch_sizewithorder_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 setreWriteBatchedInserts. If I do not need the entity lifecycle I useJdbcTemplate.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ùngclearAutomatically, 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
@Versionincrement. I useclearAutomatically, bump the version manually if needed, and run it in its own transaction.
Cách nhớ ngắn gọn
- Batch cần 3 thứ:
batch_size, SEQUENCE, flush/clear. - IDENTITY = id sau INSERT = không batch.
saveAllkhông thần kỳ; id gán sẵn → merge → thêm SELECT.- Càng lớn càng xuống thấp: JPA batch → JdbcTemplate → COPY.
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.