Một trang dữ liệu của PostgreSQL chỉ có 8KB. Vậy làm sao lưu một bài viết dài 50KB, một jsonb khổng lồ, hay một ảnh bytea? Câu trả lời là TOAST (The Oversized-Attribute Storage Technique) — cơ chế âm thầm tự động nén giá trị lớn và đẩy chúng ra một bảng lưu trữ riêng. Hiểu TOAST giải thích nhiều điều tưởng như bí ẩn: vì sao bảng chính nhỏ hơn dữ liệu thật, vì sao SELECT * đắt trên bảng có cột lớn, và vì sao đôi khi một truy vấn tưởng nhẹ lại chậm. Bài này mổ xẻ TOAST bằng số đo thật.
Cơ chế: nén trước, đẩy ra sau
Khi một dòng vượt ngưỡng (~2KB, gọi là TOAST_TUPLE_TARGET), PostgreSQL xử lý các cột lớn theo hai bước:
- Thử nén giá trị lớn. Nếu nén xuống đủ nhỏ để vừa trong dòng, giữ nguyên tại chỗ (inline).
- Nếu sau nén vẫn lớn, đẩy ra bảng TOAST phụ — một bảng ẩn đi kèm mỗi bảng có cột TOAST-able — và để lại trong dòng chính một con trỏ nhỏ.
Điểm mấu chốt: nén xảy ra trước. Dữ liệu nén tốt có thể ở lại bảng chính; dữ liệu không nén được mới bị đẩy ra ngoài.
-- text lặp lại nén cực tốt (6.200 ký tự → 148 byte, ở lại inline)
SELECT pg_column_size(noi_dung) FROM doc WHERE id=1; -- 148
-- md5 ngẫu nhiên không nén được (5.120 byte → 5.120, đẩy ra TOAST)
SELECT pg_column_size(noi_dung) FROM doc2 WHERE id=1; -- 5120

Hình 1: TOAST nén giá trị lớn trước; nén tốt thì giữ inline, không nén được thì đẩy ra bảng TOAST phụ với con trỏ để lại. Mỗi cột có một chiến lược lưu (EXTENDED, MAIN, EXTERNAL, PLAIN).
Đo thật: bảng chính 15 MB, dữ liệu hơn 1 GB
Bảng doc2 200.000 tài liệu, mỗi tài liệu có noi_dung ~5KB ngẫu nhiên (không nén được, bắt buộc ra TOAST):

Hình 2: Nội dung lặp lại nén 6.200 ký tự xuống 148 byte (ở lại inline); md5 ngẫu nhiên 5.120 byte không nén được (ra TOAST). Bảng chính doc2 chỉ 15 MB, bảng TOAST phụ 1.116 MB. Đọc không chạm noi_dung: 18 ms; chạm vào: 611 ms (~33×).
- Kích thước: bảng chính
doc2chỉ 15 MB (chứa id, tiêu đề, và con trỏ TOAST nhỏ), còn bảng TOAST phụ 1.116 MB (chứa nội dung 5KB thật). Tổng hơn 1 GB, nhưng bảng chính vẫn tí hon — vì mọi giá trị lớn nằm ở bảng phụ.
Vì sao SELECT * đắt: đọc chạm TOAST hay không
Đây là hệ quả hiệu năng quan trọng nhất. So hai truy vấn:
- Không chạm cột TOAST (
count(tieu_de)): chỉ đọc bảng chính ~1.870 trang — 18 ms. - Chạm cột TOAST (
count(length(noi_dung))): phải nạp cả bảng TOAST, ~916.000 trang — 611 ms.
Chậm hơn ~33 lần chỉ vì truy vấn thứ hai đọc cột lớn. Điều này giải thích cạm bẫy SELECT * (đã bàn ở bài trước) ở tầng sâu hơn: khi bảng có cột TOAST, SELECT * kéo mọi giá trị lớn từ bảng TOAST về, dù bạn chỉ cần vài cột nhỏ. Bảng chính gọn giúp các truy vấn không chạm cột lớn cực nhanh — nhưng chỉ khi bạn không vô tình kéo cột lớn ra.
Chiến lược lưu trữ mỗi cột
Mỗi cột có một "chiến lược lưu" (STORAGE) điều khiển hành vi TOAST, đổi được bằng ALTER TABLE ... ALTER COLUMN ... SET STORAGE:
EXTENDED(mặc định chotext,jsonb,bytea): nén và cho phép đẩy ra TOAST.MAIN: nén nhưng cố giữ inline, chỉ đẩy ra TOAST khi buộc phải.EXTERNAL: đẩy ra TOAST nhưng không nén — đọc từng đoạn (substring) của giá trị lớn nhanh hơn vì không phải giải nén cả khối.PLAIN: không bao giờ TOAST (dùng cho kiểu cố định nhỏ nhưint,bool).
Đánh đổi cần cân nhắc
TOAST là bạn, không phải kẻ thù — nhưng cần biết nó tồn tại. Nó cho phép PostgreSQL lưu giá trị lớn tùy ý mà không phá vỡ mô hình trang 8KB, và giữ bảng chính gọn giúp quét nhanh. Bạn không cần cấu hình gì để nó hoạt động. Vấn đề chỉ nảy sinh khi bạn vô tình kéo cột TOAST ra trong truy vấn không cần.
Nén CPU đổi lấy I/O. TOAST nén bằng thuật toán nhẹ (pglz mặc định, hoặc lz4 nếu bật) — tốn chút CPU khi ghi và đọc, đổi lấy ít I/O và dung lượng hơn. Với dữ liệu nén tốt (text, json), đây là lãi ròng. Với dữ liệu đã nén sẵn (ảnh JPEG, file zip trong bytea), nén lại vô ích — cân nhắc SET STORAGE EXTERNAL để bỏ bước nén thừa.
Cột lớn ít truy vấn nên tách bảng riêng. Nếu một cột lớn (nội dung bài, blob) hiếm khi cần cùng các cột khác, tách nó sang bảng riêng (chuẩn hóa) giúp bảng chính còn nhỏ hơn nữa và tránh mọi rủi ro vô tình kéo TOAST. Đây là lúc "tách bảng" thắng rõ, khác với bài chuẩn hóa vừa rồi.
Ba ý mang về
- TOAST tự nén giá trị lớn rồi đẩy phần còn lớn ra bảng phụ: nén xảy ra trước (text lặp 6.200 ký tự nén còn 148 byte, ở lại inline; md5 5.120 byte không nén được, ra TOAST), giữ bảng chính gọn — đo thật bảng chính chỉ 15 MB dù tổng dữ liệu hơn 1 GB.
- Đọc chạm cột TOAST chậm hơn nhiều lần: đo thật, quét không chạm cột lớn 18 ms (chỉ đọc bảng chính) so với 611 ms khi chạm cột TOAST (~33×) — đây là lý do sâu xa
SELECT *đắt trên bảng có cột lớn; chỉ SELECT cột cần. - Mỗi cột có chiến lược lưu điều khiển TOAST:
EXTENDED(mặc định, nén + đẩy ra),MAIN(cố giữ inline),EXTERNAL(đẩy ra không nén, đọc đoạn nhanh),PLAIN(không TOAST) — cân nhắcEXTERNALcho dữ liệu đã nén sẵn, hoặc tách cột lớn sang bảng riêng nếu ít dùng.
Phần sau ta quay lại chủ đề planner với một góc thiết kế: Phần sau đo cách ràng buộc (NOT NULL, CHECK, UNIQUE, khóa ngoại) không chỉ bảo vệ dữ liệu mà còn giúp planner sinh kế hoạch tốt hơn — thông tin ràng buộc cho phép nó bỏ bước thừa.