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 và tuple

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.

Một lần tôi đo hớ: quên chi phí 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ó.

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 đo hớ 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. Bài học đo lường: 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.

Hệ quả thứ ba là bài học đo lường. Con số mang theo: 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. Đừng ước lượng dung lượng bằng phép chia trên kích thước dữ liệu; chi phí cố định mỗi bản ghi là biến ẩn luôn có mặt, và mổ trang thật bằng pageinspect là cách đo chắc chắn nhất.

Thử ba mươi giây

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.