Hình dung mỗi câu INSERT gửi riêng tới CSDL như một chuyến đi chợ: bạn lái xe ra cửa hàng, mua đúng một món, lái về — rồi lặp lại 5000 lần cho 5000 món. Cái đắt không phải món hàng (câu lệnh SQL rất nhẹ), mà là quãng đường đi về — một lượt qua lại mạng giữa ứng dụng và CSDL. Gộp lô là đi một chuyến duy nhất với danh sách 50 món trong tay. Cả bài này là về việc biến 5000 chuyến thành 100 chuyến bằng một dòng cấu hình, và về một cái bẫy khiến bạn tưởng đã gộp mà thật ra vẫn đang chạy từng chuyến. Nhập 5000 bản ghi, đ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. Nghe như "đi chợ một lần" nhưng mở ra vẫn là 5000 chuyến xe.

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ó có 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: một chuyến xe chở 50 món.

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.

Và đáng nhấn mạnh: 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.

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ề — đúng như cửa hàng bắt bạn chờ in hoá đơn cho từng món ngay tại quầy, không cho gom giỏ. 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ả.

Muốn biết dự án mình có đang chạy 5000 chuyến xe không, kiểm một dòng:

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 đ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, chỉ từ một dòng cấu hình.

Mẫu số chung

Kẻ thù thật trong bài này không phải công việc, mà là lượt đi về — và "độ trễ nhân với số lần" là căn bệnh xuất hiện mỗi khi bạn vượt một ranh giới (CSDL, mạng, RPC) một lần cho mỗi phần tử. Mọi tầng đều mắc, và mọi tầng đều có một lối thoát kiểu gộp lô.

  • Vấn đề N+1 của ORM là đúng căn bệnh này ở phía đọc: tải một danh sách rồi lặp qua, mỗi phần tử một truy vấn con. Hibernate, ActiveRecord (Rails), ORM của Django, Entity Framework — tất cả đều dính, và thuốc chữa luôn là gộp: JOIN FETCH, select_related, eager loading.
  • Lối thoát gộp lô có ở mọi lớp: Redis có pipelining và MGET; HTTP có endpoint nhận lô hoặc GraphQL gộp truy vấn; gRPC có streaming; CSDL nào cũng có đường nhập khối (COPY của Postgres, LOAD DATA của MySQL) bỏ qua giao thức từng-dòng.
  • Và cái bẫy "phương thức gộp cho tiện không thật sự gộp" cũng lặp lại: saveAll của Spring là một ví dụ; AddRange của Entity Framework trong một thời gian dài cũng không gộp; create trong vòng lặp của ActiveRecord thua xa insert_all.

Sợi chỉ chung đáng mang theo: ORM và thư viện giấu đi các lượt đi về, nên bệnh N+1 lúc đọc và không-gộp lúc ghi đều vô hình cho tới khi dữ liệu lớn lên — và cách chữa ở mọi ngôn ngữ giống nhau: làm cho số lần vượt ranh giới hiện ra rồi bóp nó lại. Đếm số lệnh JDBC, số truy vấn, số lời gọi HTTP; thấy con số bằng số bản ghi là biết mình đang chạy 5000 chuyến xe. Và đừng tin cái tên "bulk" hay "all" — đo mới biết nó có thật sự chở chung một chuyến hay không.

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