Nhập 5000 bản ghi. Bài này đo bốn cách, và khoảng cách giữa cách chậm nhất với cách nhanh nhất là 38 lần.

Không cấu hình gì

  save() từng cái                1121 ms |  5101 lệnh JDBC
  saveAll()                      1109 ms |  5100 lệnh JDBC
  persist + flush/clear mỗi 50    981 ms |  5100 lệnh JDBC
  JdbcTemplate.batchUpdate          29 ms |     1 lệnh JDBC

Dòng thứ hai là điều tôi muốn nói trước.

saveAll() không gộp lô gì cả. Nó nhanh hơn vòng lặp save() đúng 12 mili giây trên 5000 bản ghi — tức là nhiễu đo. Số lệnh JDBC gần như y hệt: 5100 so với 5101.

Nhìn mã nguồn thì rõ: saveAll là một vòng for gọi save. Nó tồn tại cho tiện, không cho nhanh.

Dòng thứ ba cũng đáng nói: flush/clear định kỳ là lời khuyên phổ biến cho chèn hàng loạt. Nó tác dụng — nhưng chỉ 14%, và tác dụng đó là về bộ nhớ, không phải tốc độ. Không có nó, persistence context giữ cả 5000 entity và bạn có nguy cơ hết heap.

Một dòng cấu hình

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
  save() từng cái                 140 ms |  102 lệnh JDBC     (1121 -> 140)
  saveAll()                       112 ms |  101 lệnh JDBC
  persist + flush/clear mỗi 50    107 ms |  201 lệnh JDBC
  JdbcTemplate.batchUpdate         37 ms |    1 lệnh JDBC

Nhanh gấp tám lần, không đổi một dòng mã nào.

5101 lệnh JDBC xuống 102 — đúng 5000/50 cộng phần lấy giá trị sequence. Hibernate gom 50 câu INSERT vào một lượt gửi.

Hibernate không bật gộp lô theo mặc định. Mọi ứng dụng chưa khai batch_size đang gửi một lượt đi về cho mỗi bản ghi. Với CSDL ở xa, đó là nhân độ trễ mạng với số bản ghi.

order_inserts gom các câu chèn cùng bảng lại với nhau — cần thiết vì Hibernate chỉ gộp được những câu liên tiếp giống nhau. Chèn xen kẽ hai loại entity mà không có nó thì mỗi lô chỉ có một câu.

Cái bẫy: IDENTITY tắt hẳn gộp lô

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)   // batch KHÔNG hoạt động

Với IDENTITY, CSDL sinh id lúc chèn, và Hibernate cần id đó ngay để đưa entity vào persistence context. Nên nó phải chạy từng câu một và đọc id trả về. Batch bị vô hiệu hoá hoàn toàn — im lặng, không cảnh báo.

Phép đo của tôi dùng:

@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "g")
@SequenceGenerator(name = "g", sequenceName = "ghi_seq", allocationSize = 50)

allocationSize = 50 là phần thứ hai: Hibernate lấy một khoảng 50 giá trị mỗi lần hỏi sequence, thay vì hỏi từng cái. Để mặc định allocationSize = 1 là thêm 5000 lượt đi về nữa.

Với PostgreSQL và Oracle, dùng SEQUENCE. Với MySQL cũ chỉ có AUTO_INCREMENT thì không gộp lô được qua JPA — đó là lúc dùng JDBC thẳng.

JdbcTemplate.batchUpdate: 29 ms

jdbcTemplate.batchUpdate(
    "insert into ghi(id, noi_dung) values (nextval('ghi_seq'), ?)",
    danhSachThamSo);

Một lệnh JDBC, 29 mili giây. Vẫn nhanh hơn JPA đã tối ưu ba tới bốn lần.

Lý do: không có persistence context, không dirty checking, không sự kiện vòng đời, không bản chụp entity. Chỉ là gửi dữ liệu.

Với PostgreSQL, thêm reWriteBatchedInserts=true vào chuỗi JDBC còn nhanh hơn nữa — driver viết lại thành một câu INSERT ... VALUES (...), (...), (...) duy nhất.

Và với nhập dữ liệu thật lớn, COPY của PostgreSQL nhanh hơn mọi thứ ở trên một bậc nữa.

Chọn cách nào

Số bản ghi Cách
Dưới 100 JPA, không cần nghĩ
100 – 10.000 JPA + batch_size
10.000 – 1 triệu JdbcTemplate.batchUpdate
Trên đó COPY hoặc công cụ nhập của CSDL

Và luôn bật batch_size ngay từ đầu dự án — nó không có nhược điểm nào, chỉ có lợi ích chờ tới lúc dữ liệu lớn lên.

Cập nhật và xoá hàng loạt

Cùng nguyên tắc, nhưng có thêm lựa chọn tốt hơn:

@Modifying
@Query("update Don d set d.trangThai = :moi where d.trangThai = :cu")
int doiTrangThai(@Param("cu") String cu, @Param("moi") String moi);

Một câu lệnh cho mọi bản ghi khớp, không tải entity nào lên. Nhanh hơn bất kỳ vòng lặp nào.

Đánh đổi phải biết: nó không cập nhật persistence context, không kích hoạt sự kiện vòng đời, không chạy cascade, và không tăng @Version — nên nó phá khoá lạc quan ở bài 28. Chọn có ý thức.

Và nhắc lại từ bài 25: đừng dùng clearAutomatically = true khi câu lệnh chạy giữa lúc render trang.

Bộ nhớ

Với 5000 bản ghi thì heap không phải vấn đề. Với 500.000 thì có.

Ba việc:

flush() rồi clear() định kỳ — phép đo cho thấy nó không nhanh hơn nhiều, nhưng nó giữ persistence context nhỏ.

Đọc theo luồng thay vì tải hết:

@Query("select d from Don d")
Stream<Don> docTheoLuong();      // dùng trong try-with-resources, trong giao dịch

Chia lô ở tầng ứng dụng — xử lý 1000 bản ghi một giao dịch. Chậm hơn một chút nhưng bộ nhớ ổn định, và một lô hỏng không mất tất cả.

Thử ba mươi giây

grep -rn "batch_size" src/main/resources/

Không có kết quả nghĩa là mọi thao tác ghi hàng loạt của bạn đang gửi một lượt đi về cho mỗi bản ghi. Thêm ba dòng vào application.yml và đo lại — bảng trên cho thấy khoảng cách là tám lần.

Ngày mai: migration lược đồ CSDL — và vì sao ddl-auto: update không phải là chiến lược.