Ở bài trang và tuple ta thấy một hàng phải vừa trong một trang 8KB. Vậy điều gì xảy ra khi bạn lưu một giá trị lớn hơn thế — một bài viết dài, một khối JSON, một ảnh base64 vài chục KB? Nó không thể nhét vừa một trang. PostgreSQL giải quyết bằng một cơ chế tên là TOAST, và cơ chế đó có một hệ quả rất dễ hiểu sai về hiệu năng — tôi đã hiểu sai đúng cái đó trước khi đo.
TOAST: nén, rồi đẩy ra bảng phụ
TOAST (The Oversized-Attribute Storage Technique) là cách PostgreSQL xử lý các giá trị quá lớn để vừa một trang. Khi một hàng vượt ngưỡng (mặc định khi hàng gần 2KB), TOAST xử lý các cột lớn qua hai bước. Bước một: nén giá trị. Nếu sau khi nén nó đủ nhỏ để hàng vừa trang, giá trị nén được giữ inline (ngay trong hàng). Bước hai: nếu vẫn còn lớn, giá trị được cắt thành từng mảnh và lưu ra một bảng phụ riêng — gọi là TOAST table — còn hàng chính chỉ giữ một con trỏ tới đó.
Hệ quả then chốt: bảng chính luôn gọn. Dù cột body của bạn dài 50KB, hàng trong bảng chính chỉ chứa các cột nhỏ (id, tag...) cộng một con trỏ vài chục byte. Giá trị lớn nằm ở nơi khác. Điều này quyết định hoàn toàn cách các truy vấn cư xử — và là chỗ tôi đã đoán sai.
Bảng phụ TOAST là một bảng thật, ẩn đi, có index riêng của nó; giá trị lớn được cắt thành các mảnh khoảng 2KB rồi cất vào đó theo từng dòng. Toàn bộ chuyện này diễn ra tự động: bạn khai một cột text bình thường, PostgreSQL tự quyết định nén hay đẩy ra TOAST tùy kích thước từng giá trị. Bạn không phải bật gì, và cũng thường không thấy nó — cho tới khi đo.
Một lần tôi đo hớ: cột lớn không làm chậm truy vấn không đọc nó
Tôi dựng một bảng doc_rand 50.000 hàng, mỗi hàng có cột body khoảng 5,4KB dữ liệu ngẫu nhiên (nén không được, nên chắc chắn bị đẩy ra TOAST). Niềm tin của tôi vào bài: "cột này to thế, mọi truy vấn trên bảng chắc chắn chậm". Rồi tôi đo một truy vấn không đụng tới body — chỉ tổng hợp cột nhỏ tag:
SELECT sum(tag) FROM doc_rand;
-> đọc 368 trang, ~1,5 ms
Nhanh như bất kỳ bảng nhỏ nào, dù mỗi hàng logic mang theo 5,4KB. Không hề chậm. Lý do: cột body đã bị đẩy ra TOAST, nên bảng chính chỉ còn 2944 kB (id, tag, con trỏ) — 368 trang. Truy vấn sum(tag) quét đúng 368 trang đó, không chạm một byte nào của TOAST. Kích thước khổng lồ của body hoàn toàn vô hình với truy vấn này.
Rồi tôi đo một truy vấn có đụng body:
SELECT sum(length(body)) FROM doc_rand;
-> đọc ~225.000 trang, ~420 ms
Cùng một bảng, cùng số hàng, mà chậm gấp ~280 lần và đọc gấp ~600 lần số trang — vì bây giờ PostgreSQL phải đi theo mỗi con trỏ vào TOAST table (296 MB dữ liệu), đọc từng mảnh và ghép lại. Cái sai của tôi là gán "bảng có cột lớn" thành "bảng chậm". Đúng ra phải hỏi: truy vấn có chạm cột lớn không? Nếu không, nó nhanh như thể cột đó không tồn tại; nếu có, nó trả giá cho việc đọc TOAST. Một cột lớn không làm chậm những gì không đọc tới nó.
Đây cũng là lý do SELECT * trên bảng có cột lớn là một cái bẫy: nó kéo luôn body cho mọi hàng, biến một truy vấn tưởng nhẹ thành 420ms thay vì 1,5ms. Chỉ chọn đúng cột bạn cần.
Nén quyết định dung lượng: 6,8 MB hay 300 MB
Bước nén của TOAST không phải chi tiết phụ — nó quyết định cả việc giá trị có bị đẩy ra bảng phụ hay không, và bảng tốn bao nhiêu đĩa. Tôi so hai bảng cùng "5KB × 50.000 hàng" nhưng khác bản chất dữ liệu.
Bảng thứ nhất, body là văn bản lặp (chuỗi cafe-code- lặp 500 lần):
body lặp: 5000 byte thô -> 79 byte sau nén (63 lần!)
-> nén xong nhỏ tí, GIỮ INLINE, TOAST gần như rỗng (40 kB)
-> tổng bảng: 6,8 MB
Nén 63 lần khiến giá trị "lớn" 5KB co còn 79 byte — nhỏ tới mức nó không cần ra TOAST nữa, nằm gọn trong hàng chính. Bảng thứ hai, body là dữ liệu ngẫu nhiên (base64 của byte ngẫu nhiên, entropy cao):
body ngẫu nhiên: 5406 byte thô -> 5406 byte (nén 0)
-> vẫn lớn, ra TOAST đầy đủ: 296 MB
-> tổng bảng: 300 MB
Cùng một mô tả logic — "5KB text, 50.000 hàng" — mà một bảng tốn 6,8 MB còn bảng kia tốn 300 MB, gấp 44 lần, chỉ vì dữ liệu này nén được còn dữ liệu kia thì không. Dữ liệu ngẫu nhiên hoặc đã nén sẵn (ảnh JPEG, file zip, video) thì nén lần nữa vô ích, nên chúng luôn ra TOAST với đầy đủ kích thước. Như bài kiểu dữ liệu đã thấy, dung lượng thật phụ thuộc nội dung, không chỉ kích thước danh nghĩa.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: lưu giá trị lớn trong PostgreSQL không phải điều cấm kỵ như lời đồn. TOAST xử lý chuyện đó minh bạch — bảng chính vẫn gọn, và các truy vấn không đọc cột lớn không hề bị ảnh hưởng. Nỗi sợ "đừng bao giờ để cột text/json lớn trong bảng" thường bị thổi phồng; cái thật sự quan trọng là đừng đọc cột đó khi không cần. Với dữ liệu nhị phân rất lớn (video, file hàng trăm MB) thì lưu ngoài (object storage) rồi giữ đường dẫn trong CSDL vẫn hợp lý hơn — nhưng ranh giới đó cao hơn nhiều so với con số vài KB mà nhiều người tưởng.
Hệ quả thứ hai: chọn cột, đừng SELECT *. Trên bảng có cột TOAST, SELECT id, tag và SELECT * khác nhau một trời một vực về số trang phải đọc. Nếu bạn chỉ cần metadata, đừng để ORM kéo luôn cả blob. Con số mang theo: TOAST nén rồi đẩy giá trị lớn ra bảng phụ, để bảng chính chỉ giữ con trỏ — nên truy vấn không chạm cột lớn vẫn nhanh (SUM(tag) 1,5ms, 368 trang) còn truy vấn chạm nó phải đọc TOAST (420ms, 225k trang, ~280 lần chậm hơn); và nén quyết định dung lượng: cùng 5KB×50k hàng, text lặp nén 63 lần còn 6,8 MB (nằm inline) trong khi dữ liệu ngẫu nhiên thành 300 MB. Trước khi sợ một cột lớn, hỏi truy vấn của bạn có thật sự đọc nó không.
Thử ba mươi giây
Trong psql, tạo CREATE TABLE t (id int, big text); INSERT INTO t SELECT g, repeat('x', 5000) FROM generate_series(1,10000) g;. Giờ so pg_relation_size('t') (bảng chính) với pg_total_relation_size('t') (gồm cả TOAST) — bạn sẽ thấy bảng chính nhỏ xíu vì big đã ra TOAST. Rồi chạy \d+ t để thấy tên bảng TOAST đi kèm, và thử SELECT pg_column_size(big), octet_length(big) FROM t LIMIT 1; — với chuỗi repeat('x',5000) nén cực tốt, con số pg_column_size sẽ nhỏ hơn 5000 rất nhiều. Đổi repeat('x',5000) thành md5(random()::text) lặp nhiều lần (dữ liệu ngẫu nhiên) rồi đo lại — TOAST sẽ phình lên thấy rõ. Nửa phút đó cho bạn thấy TOAST và nén đang lặng lẽ làm việc dưới mỗi cột lớn.