Gửi một lá thư một dòng vẫn tốn nguyên một cái phong bì, một con tem, một dòng địa chỉ. Muốn tính "một bao thư nhét được bao nhiêu lá" mà chỉ chia theo độ dài nội dung thì trật xa — vì mỗi lá thư cõng một lớp bao bì cố định, và với thư ngắn thì bao bì còn nặng hơn cả nội dung. Cơ sở dữ liệu lưu từng hàng đúng kiểu thư có phong bì đó, và bài này là lần tôi nhẩm số hàng vừa một trang bằng phép chia đơn giản, rồi hụt gần năm lần vì quên cái phong bì.
Suốt sê-ri ta đếm "trang" mà chưa mở một trang ra xem bên trong. Bài shared_buffers nói cache giữ các trang; bài B-tree nói mỗi node là một trang. Trang là đơn vị cơ bản của cơ sở dữ liệu — mọi đọc/ghi đều theo trang. Bài này mổ một trang thật bằng pageinspect, xem hàng (tuple) được xếp thế nào, và tính số hàng vừa một trang — con số làm tôi vấp vì quên một chi phí ẩn.
Trang 8KB và bố cục bên trong
PostgreSQL lưu dữ liệu trong các trang (page/block) kích thước cố định 8KB (8192 byte, xem bằng SHOW block_size). Mỗi bảng là một dãy trang liên tiếp trên đĩa; khi đọc một hàng, cơ sở dữ liệu đọc cả trang chứa nó vào bộ đệm. Đây là lý do mọi con số trong sê-ri đều tính theo trang, không theo hàng.
Bên trong một trang có bố cục thông minh: đầu trang là header (24 byte, chứa thông tin quản lý). Ngay sau header là một mảng con trỏ item (line pointer) — mỗi con trỏ 4 byte, trỏ tới vị trí một hàng trong trang. Mảng con trỏ này lớn dần từ đầu trang xuống. Còn các tuple (bản ghi hàng thật) được đặt từ cuối trang mọc ngược lên. Hai bên tiến vào giữa; khi chúng gặp nhau, trang đầy. Cách xếp "hai đầu tiến vào giữa" này cho phép thêm/xóa hàng linh hoạt mà không phải dồn dữ liệu: xóa một hàng chỉ cần đánh dấu con trỏ item của nó là chết, còn thêm hàng thì đặt tuple mới vào khoảng trống ở giữa rồi thêm một con trỏ. Nhờ tách con trỏ (vị trí cố định) khỏi tuple (có thể di chuyển), một chỉ mục trỏ tới hàng qua số con trỏ vẫn đúng kể cả khi tuple bị dời chỗ trong trang.
Đo: 8 byte dữ liệu, nhưng chỉ 226 hàng một trang
Tôi tạo hai bảng, mỗi bảng 100.000 hàng. Bảng hẹp có 2 cột int (8 byte dữ liệu mỗi hàng). Bảng rộng thêm một cột text ~100 byte. Rồi tính số hàng mỗi trang từ pg_class (số trang và số hàng thật):
| Bảng | Hàng/trang |
|---|---|
| hẹp (2 int, 8 byte) | 226 |
| rộng (2 int + text 100) | 58 |
Rồi tôi mổ trang 0 của bảng hẹp bằng pageinspect. page_header cho lower = 928, upper = 960: mảng con trỏ chiếm từ byte 24 tới 928 (đúng 24 + 226 × 4 = 928), còn tuple bắt đầu từ 960 trở về cuối. heap_page_items cho thấy ba tuple đầu ở offset 8160, 8128, 8096 — giảm dần, đúng là mọc ngược từ cuối trang (8192) lên — mỗi tuple lp_len = 32 byte. Và đếm được đúng 226 item trên trang. Mọi con số khớp với bố cục lý thuyết.
Quên cái phong bì mỗi hàng
Trước khi đo, tôi nhẩm nhanh: trang 8192 byte, mỗi hàng 8 byte dữ liệu (2 int), vậy một trang chứa được 8192 / 8 ≈ 1024 hàng. Tôi khá tự tin — phép chia đơn giản mà.
Đo ra chỉ 226 hàng một trang. Lệch 4,5 lần so với con số tôi nhẩm. Hai con số mâu thuẫn xa như vậy, theo kỷ luật, nghĩa là tôi bỏ sót một đại lượng. Và tôi bỏ sót đúng cái quan trọng nhất: một hàng không chỉ là dữ liệu của nó — nó có cả phong bì.
Mỗi tuple có một header 23 byte đứng trước dữ liệu. Header này giữ thông tin phiên bản cho MVCC — xmin (giao dịch tạo hàng), xmax (giao dịch xóa hàng), và cờ trạng thái — thứ cho phép nhiều phiên bản của cùng một hàng cùng tồn tại (sẽ đào sâu ở bài MVCC). Cộng với dữ liệu 8 byte thành 31 byte, căn lề lên 32 byte (PostgreSQL căn tuple theo bội số 8). Rồi mỗi hàng còn cần một con trỏ item 4 byte ở đầu trang. Tổng cộng: 36 byte cho một "hàng 8 byte". Và (8192 − 24) / 36 = 226,9, làm tròn xuống 226 — khớp chính xác con số đo được.
Cái tôi trật là quên chi phí cố định mỗi hàng — cứ tưởng trang chứa toàn dữ liệu thuần. Với hàng hẹp, chi phí đó áp đảo: 23 byte header cho 8 byte dữ liệu nghĩa là gần 78% mỗi hàng là overhead, chứ không phải dữ liệu — cái phong bì nặng hơn lá thư. Khi ước lượng dung lượng hay số hàng, đừng nhân chia trần trên kích thước dữ liệu; phải cộng chi phí cố định mỗi bản ghi (ở đây 23 + 4 + đệm). Và cách chắc chắn để biết là mổ trang thật bằng pageinspect rồi khớp với công thức — như tôi vừa làm, con số 226 nói thẳng công thức đúng.
Về đến code hằng ngày
Điều đầu tiên: hàng hẹp không tiết kiệm dung lượng nhiều như bạn nghĩ, vì overhead cố định. Một bảng chỉ hai cột int tưởng "nhẹ tênh" nhưng mỗi hàng vẫn tốn 36 byte trên đĩa — 8 byte dữ liệu cõng 28 byte overhead. Điều này giải thích vì sao một bảng "nhỏ" với hàng triệu dòng vẫn chiếm hàng chục MB, và vì sao gộp nhiều cột nhỏ vào một hàng thường hiệu quả hơn tách thành nhiều bảng hàng-siêu-hẹp (mỗi hàng lại cõng thêm 23 byte header). Khi ước tính dung lượng bảng, luôn cộng ~24 byte overhead mỗi hàng.
Điều thứ hai: kích thước hàng quyết định số hàng mỗi trang, và số hàng mỗi trang quyết định số trang phải đọc. Một truy vấn quét càng nhiều hàng thì càng nhạy với việc "bao nhiêu hàng vừa một trang". Bảng hẹp 226 hàng/trang cần ít trang hơn hẳn bảng rộng 58 hàng/trang cho cùng số hàng — nên giữ hàng gọn (không nhồi cột text lớn vào bảng hay quét) giúp quét nhanh hơn. Đây cũng là lý do PostgreSQL tách giá trị lớn ra ngoài bằng TOAST (một bài sau), để hàng chính vẫn gọn và nhiều hàng vừa một trang. Ngược lại, một cột text hay jsonb lớn nhồi thẳng vào bảng chính sẽ làm số hàng mỗi trang tụt mạnh, khiến mọi truy vấn quét bảng đó chậm đi dù bạn không hề đụng tới cột lớn ấy.
Và cái bẫy "quên overhead cố định mỗi phần tử" này không riêng gì cơ sở dữ liệu — nó là một trong những nguồn ước lượng sai bộ nhớ phổ biến nhất mọi ngôn ngữ. Một Integer trong Java không phải 4 byte mà ~16 byte (object header + padding); một ArrayList<Integer> một triệu phần tử tốn gấp mấy lần int[] cùng số phần tử, vì mỗi phần tử là một object có phong bì riêng cộng một con trỏ. Một HashMap entry cõng header + hash + hai con trỏ ngoài cặp khóa-giá trị. Ngay cả một chuỗi rỗng "" cũng chiếm vài chục byte. Mẫu số chung đáng mang theo: ở mọi tầng lưu trữ — hàng CSDL, object trên heap, gói tin mạng — mỗi "phần tử" cõng một overhead cố định, và overhead đó áp đảo khi phần tử nhỏ; nên đừng bao giờ ước lượng dung lượng bằng phép chia trên kích thước dữ liệu thuần, hãy cộng chi phí mỗi bản ghi, và với dữ liệu nhỏ-mà-nhiều, chính overhead đó mới là con số quyết định. Gói lại bằng số: PostgreSQL lưu hàng trong trang 8KB, tuple mọc từ cuối trang lên và con trỏ item mọc từ đầu xuống; mỗi hàng cõng 23 byte header MVCC + 4 byte con trỏ + đệm, nên một "hàng 8 byte" thật ra tốn 36 byte và chỉ 226 hàng vừa một trang — không phải 1024.
Muốn tự thấy thì trong PostgreSQL, với một bảng bất kỳ, so kích thước thật với số hàng: SELECT relpages, reltuples, round(reltuples/relpages) AS hang_moi_trang FROM pg_class WHERE relname = 'ten_bang';. Con số hang_moi_trang cho bạn biết bao nhiêu hàng nhét vừa một trang 8KB — và nếu bạn nhân kích thước cột lại rồi chia 8192, bạn sẽ thấy con số thật nhỏ hơn ước lượng, đúng vì overhead 23+ byte mỗi hàng. Muốn nhìn tận mắt, cài pageinspect và chạy SELECT lp, lp_off, lp_len FROM heap_page_items(get_raw_page('ten_bang', 0)) LIMIT 5; — bạn sẽ thấy các offset giảm dần (tuple mọc từ cuối lên) và lp_len là kích thước thật mỗi hàng gồm cả header. Đó là bố cục trang mà bài này đo.