Tới nội dung chính
caltalys.dev
Thời điểm / Lifecycle

Batch insert không batch, chậm gấp 10 - và tôi đã bật đúng cờ

Bật đủ mọi cờ batch mà Hibernate vẫn insert từng dòng, vì chiến lược sinh id đã quyết định xong trước khi cờ của bạn kịp có ý nghĩa.

Đồ hình trừu tượng của tập 4.4 trong Quyển Một
Caltalys 9 phút đọc

Thằng em nhắn, kèm một cấu hình mà nó tự tin là chuẩn giáo khoa:

thang em Anh, em làm tính năng import - mười nghìn dòng CSV thành mười nghìn entity. Em đọc kỹ rồi: muốn insert nhanh phải batch. Em bật đủ:
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true
@Entity
public class ImportedRow {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
}

@Transactional
public void importAll(List<ImportedRow> rows) {
    rows.forEach(em::persist);   // 10.000 persist, một transaction
}
thang em Theo sách: năm mươi insert gom thành một chuyến. Em chạy, đo: bốn phút rưỡi. Bật log: mười nghìn câu INSERT, từng-dòng-một, ngoan ngoãn xếp hàng. Cái cờ batch của em như chưa từng tồn tại. Không lỗi, không warning - nó chỉ… lờ em đi.

Nó bị lờ thật. Và kẻ lờ nó không phải cái cờ - là hai chữ nằm ngay trong entity: GenerationType.IDENTITY. Đây là một trong những xung đột ngầm đẹp nhất của JPA, vì cả hai bên đều đúng, và bug sinh ra ở chỗ chúng không thể cùng đúng.

Bên thứ nhất: batching. Muốn gom năm mươi INSERT thành một chuyến, Hibernate phải trì hoãn - persist không gửi gì cả, chỉ xếp hàng đợi; đến flush mới đóng gói cả lô giao một thể. Trì hoãn là linh hồn của batch.

Bên thứ hai: cuốn sổ. Bạn còn nhớ lõi của nó chứ - một cái Map, key là (class + id). Mà persist là thủ tục khai sinh: ghi tên vào sổ. Ghi tên vào một cái Map thì phải có… key. Phải có id. Ngay lúc persist. Không có id, không ghi sổ được - đây không phải khó tính, đây là số học của cái Map. Bạn đã thấy chuyện thiếu-danh-tính phá một cái HashSet ra sao rồi; cuốn sổ không cho phép mình rơi vào đúng cái hố đó.

Giờ hỏi: id lấy đâu ra? Tùy chiến lược - và đây là chỗ hai chữ IDENTITY lộ nguyên hình. IDENTITY nghĩa là: cột auto-increment của database tự cấp số. Nghe tiện - DB lo hết. Nhưng đọc kỹ hệ quả: số chỉ tồn tại sau khi dòng đã được INSERT. Không có cách nào hỏi trước; con số sinh ra trong hành động ghi.

Hai bên giáp mặt: cuốn sổ đòi id ngay lúc persist; IDENTITY chỉ giao id sau khi insert. Chỉ có một lối thoát, và Hibernate chọn nó trong im lặng: insert ngay lập tức, từng dòng, tại từng cú persist - để lấy id về mà ghi sổ. Trì hoãn - linh hồn của batch - chết tại đây. Cái cờ batch_size vẫn được đọc, vẫn được tôn trọng… trên một hàng đợi không bao giờ có ai xếp vào. Mười nghìn persist, mười nghìn chuyến xe chở đúng một kiện hàng. Không ai báo bạn, vì không có gì sai - chỉ có một lời hứa không thể giữ trước một ràng buộc mạnh hơn.

Fix nằm ở việc đổi câu trả lời cho “id lấy đâu ra”: SEQUENCE.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "row_seq")
@SequenceGenerator(name = "row_seq", sequenceName = "row_seq", allocationSize = 50)
private Long id;

Sequence là một bộ đếm sống độc lập với bảng - hỏi số mà không cần insert. Hibernate giờ làm được điều IDENTITY cấm: xin id trước, ghi sổ ngay, còn INSERT thì ung dung xếp hàng đợi flush - và batch sống dậy. Tinh tế hơn: với allocationSize = 50, Hibernate không hỏi sequence từng số một (thế thì lại N+1 phiên bản xin-số) - nó xin một lô năm mươi số một lần, cất túi, tự phát dần trong RAM. Một chuyến hỏi, năm mươi lần khai sinh. Thằng em đổi xong, chạy lại: bốn phút rưỡi còn hai mươi giây, log gọn thành hai trăm gói batch. Nó gửi mình đúng một chữ: “ơ.”

Nhưng - và bạn biết series này rồi, luôn có một “nhưng” đến sau ba tuần - lần này nó đến sau hai ngày:

thang em Anh, có chuyện. Sếp em soi bảng, thấy id chạy 1..50, xong nhảy phát lên 101. Rồi 101..134, xong nhảy tiếp 151. Thủng lỗ chỗ. Sếp hỏi có phải mất dữ liệu không, và thật lòng em cũng không chắc để cãi.

Không mất gì cả - và đây là cặp bài trùng của hôm nay, đứa em ruột của chính cái fix. Nhớ cái túi năm-mươi-số không? App xin lô 51–100, mới dùng đến 84 thì… deploy lại. Cái túi nằm trong RAM - RAM chết, mười sáu số còn lại chết theo. Sequence dưới DB đâu biết chuyện đó; lần khởi động sau, nó phát tiếp lô 101–150. Mười sáu con số kia không bị xóa - chúng chưa từng được dùng. Cộng thêm nguồn lỗ thứ hai, quen mặt hơn: transaction rollback - số đã xin thì xin rồi, sequence không bao giờ đòi lại số, vì đòi lại nghĩa là các transaction phải xếp hàng chờ nhau quanh bộ đếm, và thế thì vứt hết những gì mùa transaction đã dạy về việc chạy song song.

Cái cần cãi với sếp không phải kỹ thuật, mà là một câu định nghĩa - và nó là câu bạn đã học ở bài HashSet: id là danh tính, không phải số đếm. Việc của nó là khác nhau và bất biến - không phải liền mạch. Liền mạch là thẩm mỹ; danh tính là hợp đồng. Ai cần đếm bản ghi thì count(*) luôn đúng; còn dãy id thủng lỗ là dấu hiệu của một hệ thống đang chạy song song và deploy đều đặn - tức là một hệ thống khỏe. (Nếu nghiệp vụ thật sự đòi số liền mạch - số hóa đơn theo luật chẳng hạn - thì đó là một cột riêng, cấp phát bằng logic riêng có khóa đàng hoàng, và đắt. Đừng bắt cái id gánh việc đó.)

Hai triệu chứng hôm nay soi vào nhau chỉnh tề: batch chết vì id đến quá muộn (sau insert); lỗ hổng sinh ra vì id được phát quá sớm (trước cả khi chắc có dùng). Cùng một câu hỏi xuyên suốt: danh tính được cấp lúc nào trên trục thời gian - và bạn nhận ra đây vẫn là câu hỏi mở màn cả series, chỉ thay @Value bằng cái id. Sớm hay muộn đều có giá; nghề của bạn là biết mình đang trả giá nào.

Muốn tự kiểm chứng, mười phút: giữ IDENTITY, bật hibernate.generate_statistics (bạn có nó từ bài N+1) và đọc dòng JDBC batches - số 0 tròn trĩnh dù cờ batch đang bật. Đổi sang SEQUENCE, chạy lại: batches nhảy lên. Rồi kill app giữa chừng import, khởi động lại, import tiếp - và tự tay tạo một cái lỗ trong dãy id để không bao giờ phải sợ nó nữa.

Một điều kiện của thí nghiệm phải nói trước, kẻo bạn đo xong rồi tưởng bài này nói quá: đọc số lần prepare, đừng đọc đồng hồ. Bốn phút rưỡi ở đầu bài là con số của một database thật, nơi mỗi chuyến đi là một vòng khứ hồi qua mạng - và cái mà batching tiết kiệm chính là số chuyến đi. Chạy thử trên H2 in-memory thì không có chuyến đi nào để tiết kiệm: đo 200 dòng, IDENTITY mất 35ms với 200 lần prepare, SEQUENCE mất 15ms với 6 lần prepare. Khoảng cách thời gian gần như biến mất, còn khoảng cách 200 so với 6 thì nguyên vẹn và vẫn dữ dội y như thế. Cái đồng hồ trên máy bạn đang đo một thứ khác với cái đồng hồ trên production; con số prepare thì đo đúng cái cơ chế bài này nói.

Còn nguyên lý, thứ sống lâu hơn auto-increment: một định danh sinh ra bên trong hành động ghi thì trói mọi thao tác vào nhịp từng-cái-một; muốn gom lô, định danh phải cấp được trước và độc lập với hành động. Đó là lý do hệ phân tán không dùng auto-increment mà dùng UUID, Snowflake ID - những thứ đúc được danh tính mà không cần hỏi ai, không cần chờ ghi. Bài toán “xin số trước hay nhận số sau” của bạn hôm nay là phiên bản một-máy của bài toán mà cả ngành hệ phân tán xây riêng một nhánh để giải.

Thằng em, sau khi tự tạo cái lỗ id đầu tiên của đời mình, nhắn:

Cái gì em không tự tay làm - có người làm hộ, theo lịch của họ, và em phải thuộc cái lịch đó.

thang em Vậy chốt mùa này em có hai câu. Với quan hệ: biết ai cầm bút. Với danh tính: biết nó được cấp lúc nào. Còn nghe anh dặn suốt bốn mùa thì gộp được thành một: cái gì em không tự tay làm - có người làm hộ, theo lịch của họ, và em phải thuộc cái lịch đó.

Đúng rồi đó. Mà em vừa nói trúng luôn đề bài của mùa cuối: mùa sau không học thêm người-làm-hộ nào nữa - mùa sau xem chuyện gì xảy ra khi tất cả bọn họ va vào nhau cùng một lúc. 😃

Tài nguyên tập 17

Cheat-sheet tập 17 Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được. Source demo - tập 4.4JDK 17. mvn spring-boot:run → Episode44Runner in khối [EP 4.4]: batch 50 đã bật, IDENTITY gộp được bao nhiêu, SEQUENCE gộp được bao nhiêu, và dãy id thủng lỗ sau một rollback.

Vị trí trong Lớp Trung Gian Vô Hình

Bài này trả lời

  • Chèn nhiều bản ghi mà không gom lô được
Bản in
Chia sẻ: Facebook X LinkedIn Email
Nhận bài mới qua email
Powered by follow.it