IT Blog #12 | Xây dựng H2 Table Engine giúp giảm 10 lần mức sử dụng bộ nhớ

1761282294001

Giới thiệu

  • H2 (h2database.com) là một cơ sở dữ liệu rất phổ biến trong phát triển Java back-end nhờ đặc tính embeddedin-memory. Để nhanh chóng test API trên môi trường local với database mà không cần các bước thiết lập phức tạp và không cần thiết, bạn chỉ cần thêm dependency của H2 vào pom.xml cùng một vài cấu hình database đơn giản. Bạn thậm chí còn có thể yêu cầu H2 “giả lập” một số hành vi của các database nghiêm túc như PostgreSQL, MySQL hoặc thậm chí là Oracle.
  • Mặc dù use-case chính của H2 là làm database cho mục đích test, tại Home Credit Việt Nam, chúng tôi nhận thấy H2 phù hợp một cách bất ngờ với nhu cầu xử lý các batch calculation lớn và phức tạp, nhờ hiệu năng ấn tượng của engine in-memory. Các phép tính của chúng tôi thường liên quan đến các bảng có hàng triệu bản ghi, thông thường khoảng 50 triệu bản ghi. Trước đây, chúng tôi sử dụng stored procedure để xử lý và mọi thứ vẫn ổn, nhưng cách này gây tải rất lớn lên MAIN database cluster.
  • Để giảm tải cho MAIN DB cluster nhằm phục vụ các tác vụ quan trọng khác, chúng tôi off-load phần tính toán sang một K8S cluster, sử dụng H2 để thực hiện toàn bộ phép tính trong bộ nhớ. Đúng là trong quá trình tính toán, chúng tôi cần sao chép dữ liệu liên quan từ database nguồn sang H2, nhưng sự đánh đổi này rõ ràng là có lợi. Việc truyền dữ liệu (thông qua một số câu SELECT đơn giản) chỉ tiêu tốn một phần nhỏ tài nguyên so với việc thực hiện một phép tính đầy đủ với hàng trăm stored procedure, bao gồm các JOIN cực kỳ phức tạp và logic nghiệp vụ tinh vi.
  • Chúng tôi biết rằng sẽ gặp một vấn đề có thể dự đoán trước: bộ nhớ. Tuy nhiên, H2 dường như sử dụng nhiều bộ nhớ hơn rất nhiều so với dự đoán của chúng tôi: lên đến 10 lần kích thước dữ liệu gốc. Một bảng 1.5GB có thể dễ dàng “phình” lên thành 16GB heap. Việc xử lý cả chục bảng với kích thước như vậy sẽ đòi hỏi một server lên tới 256GB RAM. Mặc dù chúng tôi có các server vật lý với vài TB RAM, nhưng việc cấp phát một VM riêng với 256GB RAM chỉ để chạy vài batch mỗi ngày không phải là một ý tưởng hay.

Vấn đề: H2 dùng quá nhiều bộ nhớ

  • Trong lúc chờ Platform team (hay còn gọi là DevOps) tìm ra một chiến lược hợp lý cho việc cấp phát VM theo nhu cầu, chúng tôi cần phải tự tìm giải pháp để tiết kiệm bộ nhớ, bởi các bảng của chúng tôi chắc chắn sẽ ngày càng lớn hơn. Một trong những ý tưởng hiển nhiên là chuyển H2 sang sử dụng file, nhưng việc test cho thấy cách này sẽ làm chậm quá trình tính toán 5–10 lần – điều này đơn giản là không thể chấp nhận được. Một ý tưởng khác là kết hợp cả memory và file, nhưng điều đó sẽ làm pipeline hiện tại trở nên phức tạp hơn.
  • Ý tưởng táo bạo cuối cùng là thử giảm lượng bộ nhớ mà H2 sử dụng cho engine in-memory. Ban đầu, ý tưởng này nghe có vẻ khá vô lý, nhưng khi đi sâu tìm hiểu thì nó lại không hẳn là điên rồ. H2 là một engine mã nguồn mở hoàn toàn, chúng tôi có thể đào sâu vào source code để xem H2 sử dụng bộ nhớ như thế nào và hy vọng tìm ra cách nén dữ liệu trong bộ nhớ
  • Sau khi nghiên cứu source code của H2 và với sự hỗ trợ của ChatGPT, chúng tôi hiểu được nguyên nhân: H2 sử dụng Java object để lưu trữ giá trị trong các bảng in-memory. Ví dụ, để lưu một giá trị double, H2 sử dụng một object ValueDouble bao bọc một primitive double. Chi phí bộ nhớ cho một giá trị double là 24 bytes, trong khi bản thân primitive double chỉ chiếm 8 bytes (overhead của object là 16 bytes). Hãy hình dung: chỉ riêng một cột double trong bảng 10 triệu dòng đã lãng phí 160MB RAM. Mọi thứ còn tệ hơn nữa khi các bảng của chúng tôi thường lưu các giá trị nhỏ, và rất nhiều giá trị là 0, 1, chuỗi rỗng hoặc NULL, do tính chất optional của nhiều cột.
  • May mắn là H2 cho phép chúng tôi mở rộng class TableEngine để lưu trữ bảng. Tính năng này có lẽ được thiết kế cho các mở rộng storage engine trong tương lai của H2. Nhưng khoan đã, chẳng phải việc tự viết TableEngine là quá lớn sao? Suy cho cùng, lưu trữ dữ liệu chính là trái tim của bất kỳ hệ thống database nào. 
  • Điều này đúng, nhưng trong trường hợp của chúng tôi, do tính chất của bài toán tính toán, chúng tôi chỉ cần một phần rất nhỏ các tính năng tiêu chuẩn. Chúng tôi sử dụng H2 không phải như một transactional database mà chỉ để crunching numbers. Chúng tôi không cần UPDATE, INSERT dữ liệu hay quan tâm đến concurrency vì quá trình tính toán chỉ chạy single thread. Chúng tôi chỉ cần SELECT dữ liệu vào các bảng tạm. Vì vậy, chúng tôi không cần các cấu trúc dữ liệu phức tạp như B-Tree; một ArrayList<byte[]> là đủ để lưu danh sách các dòng của bảng. Mỗi dòng được nén thành một mảng byte, và đây chính là điểm mấu chốt của bài blog này.

Variable-length value-based row encoding

Our design targets are:

  • (1) Chỉ sử dụng 01 byte cho các giá trị đặc biệt như: 0, 1, NULL, chuỗi rỗng
  • (2) Với cột số, số càng nhỏ thì kiểu dữ liệu sử dụng càng nhỏ. Ví dụ: lưu giá trị 10 chỉ cần 1 byte, lưu 1000 cần dùng short (2 bytes).
  • (3) Với chuỗi, kích thước tối đa của cột chuỗi là 4000, chúng tôi chia thành chuỗi ngắn (≤ 32 bytes) và chuỗi dài (tối đa 8000 bytes).
  • (4) Với chuỗi dài (> 32 bytes), chúng tôi sử dụng byte thứ hai của value header để lưu độ dài chuỗi. Với cách này, chúng tôi có tổng cộng 8 + 5 = 13 bits, đủ để biểu diễn độ dài chuỗi lên tới 8000 bytes.
  • Cấu trúc của một dòng đơn giản là một chuỗi value header theo sau bởi các byte thực tế dùng để lưu giá trị. Value header gồm 1 byte hoặc 2 bytes (trong trường hợp chuỗi dài) để lưu thông tin giúp giải mã giá trị. Phần lưu trữ giá trị (khi cần) được tối ưu để nhỏ nhất có thể.

Cấu trúc của một dòng đơn giản là một chuỗi value header theo sau bởi các byte thực tế dùng để lưu giá trị. Value header gồm một hoặc hai byte (với chuỗi dài) để lưu thông tin của giá trị nhằm hỗ trợ quá trình decode. Phần value storage được lưu với kích thước nhỏ nhất có thể.

(1)

Chúng tôi sử dụng 3 bit thấp của value header để mã hóa kiểu giá trị, 5 bit còn lại để lưu độ dài chuỗi ngắn (2⁵ = 32, do đó độ dài chuỗi ngắn tối đa là 32). Dưới đây là ý nghĩa của 3 bit thấp này.

(2)

Pros & Cons

  • Pros: Ưu điểm rõ ràng nhất của cách tiếp cận này là chúng tôi có thể tiết kiệm rất nhiều bộ nhớ trong các trường hợp sử dụng của mình. Với giá trị 0 hoặc chuỗi rỗng, thay vì tốn 24 bytes cho mỗi giá trị, chúng tôi có thể giảm xuống chỉ còn 01 byte.
  • Cons: Nhược điểm tự nhiên của cách làm này là nếu muốn truy cập một cột theo cách random access, chúng tôi phải tính toán offset bắt đầu của cột đó bằng cách “nhảy” qua value header của các cột trước. Ví dụ, nếu muốn lấy giá trị của cột thứ 4, chúng tôi phải đọc value header của cột 1 để xác định vị trí bắt đầu của cột 2, sau đó tiếp tục đọc header của cột 2 để xác định cột 3, rồi lặp lại cho đến cột 4. Bảng càng có nhiều cột thì việc truy cập cột ở vị trí cao càng chậm. May mắn là H2 dường như không đọc từng cột một cách ngẫu nhiên. Khi sử dụng SELECT với danh sách cột không theo thứ tự vật lý, H2 luôn yêu cầu toàn bộ dòng, và nó sẽ tự xử lý việc xuất các cột. Chúng tôi chỉ cần triển khai hàm encode/decode một dòng cho H2.

How to implement a custom H2 TableEngine?

  • ChatGPT tỏ ra tốt hơn Gemini trong nhiệm vụ này. Dĩ nhiên ChatGPT không thể giúp chúng tôi triển khai toàn bộ framework, nhưng nó cung cấp những ý tưởng cốt lõi về cách thực hiện. Chúng tôi cũng phải tự tìm hiểu thêm nhiều chi tiết ẩn khác. Ngoài ra, bạn cần luôn nhắc ChatGPT rằng chúng tôi đang sử dụng H2 2.x, không phải 1.x, vì kiến trúc của 2.x khác khá nhiều so với 1.x.

Đây là điểm vào của custom table engine. Chúng tôi cần implement interface TableEngine của H2 để cung cấp implementation của riêng mình. Interface này chỉ có một hàm cần implement, như pseudo-code bên dưới:

(4)

Khi tạo bảng bằng SQL, chúng tôi có thể chỉ định engine bằng cú pháp sau:

(4)

com.example.MyTableEnginefully qualified class name của class MyTableEngine, bạn có thể dễ dàng lấy bằng cách gọi MyTableEngine.class.getName().

MyTable

Bước tiếp theo phức tạp hơn một chút. Chúng tôi cần extend class TableBase của H2. Class này có khá nhiều method cần override. Ở mức tối thiểu, chúng tôi cần override 2 method: addRowgetRow. Ngoài ra, chúng tôi cần cung cấp một ScanIndex để H2 có thể duyệt các dòng trong bảng theo thứ tự.

(5)

Đối tượng Row của H2 đại diện cho một dòng trong bảng. Một cách trực quan, nó bao gồm một danh sách các Value (org.h2.value.Value). Dưới đây là cách trích xuất các value object từ một Row trong addRow. Lưu ý rằng các giá trị NULL được biểu diễn bằng Value.NULL, không phải Java null.

(3) (6)

Để tạo một Row cho H2, chúng tôi chỉ cần tạo một mảng các Value và gọi method tĩnh Row.get().

(6)

ScanIndex and find function

Bước cuối cùng để có một custom TableEngine tối thiểu hoạt động được là cung cấp scan index để H2 quét bảng khi tìm kiếm dòng. Điều này được thực hiện bằng cách extend class Index mặc định của H2 (org.h2.index.Index) và implement method find

(8)

Method find() được gọi khi H2 cần tìm một danh sách các dòng. Điều kiện tìm kiếm được truyền vào thông qua tham số firstlast. Một SearchRow – tương tự như Row – chứa các giá trị tìm kiếm tương ứng với từng cột của Index. Một giá trị Java null biểu thị “match all” hoặc “don’t care”. firstlast lần lượt là cận dướicận trên của điều kiện.

Ví dụ, với câu lệnh:

SELECT * FROM MY_TABLE WHERE 5 <= AMOUNT AND AMOUNT <= 10

Nếu MY_TABLE có 3 cột ID, AMOUNT và NAME, thì tham số firstlast sẽ có dạng như sau.

(9)

Nếu cả firstlast đều là null, điều đó có nghĩa là một câu SELECT không có WHERE.

Cursor

Công việc cuối cùng của chúng tôi là trả về cho H2 một danh sách các dòng là kết quả của find. Chúng tôi thực hiện điều này bằng cách trả về một object implement interface Cursor (org.h2.index.Cursor) để H2 có thể duyệt qua các dòng tìm được. Interface Cursor khá đơn giản như sau.

(10)

Với ScanIndex, cả get()getSearchRow() đều trả về toàn bộ dòng tại vị trí con trỏ hiện tại. Nếu bạn implement một index thực sự, getSearchRow() nên trả về các cột được index để tối ưu, nhưng điều này không bắt buộc.

Method next() sẽ di chuyển con trỏ sang vị trí tiếp theo và trả về false nếu không còn dòng nào để lấy.

Conclusion

Sau khi triển khai đầy đủ custom TableEngine, mức sử dụng bộ nhớ của H2 đã giảm hơn 10 lần so với engine in-memory mặc định. Giờ đây, chúng tôi có thể xử lý các bảng 30 triệu dòng một cách thoải mái với hạ tầng hiện tại. Chúng tôi không ghi nhận bất kỳ sự suy giảm hiệu năng nào trong các phép tính hiện có.

Thú vị hơn, chúng tôi còn tăng tốc quá trình tính toán bằng cách insert dữ liệu trực tiếp vào các bảng tạm thay vì sử dụng câu lệnh INSERT.

Tác giải: Anh Dũng Đinh - IT Division at Home Credit Vietnam