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:

  1. 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).
  2. 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

Ảnh chụp đoạn mã SQL nền tối minh hoạ TOAST PostgreSQL lưu giá trị lớn ra sao, vấn đề một dòng phải vừa một trang 8KB text dài jsonb lớn bytea không thể nhét vừa 8KB TOAST The Oversized-Attribute Storage Technique tự xử lý giá trị lớn hơn khoảng 2KB nén nếu vẫn lớn đẩy ra bảng TOAST phụ, bước 1 thử nén nén được thì giữ inline text lặp lại nén cực tốt 6200 ký tự thành 148 byte SELECT pg_column_size noi_dung FROM doc 148 nén khoảng 42 lần ở lại bảng chính dữ liệu ngẫu nhiên md5 không nén được đẩy ra TOAST SELECT pg_column_size noi_dung FROM doc2 5120 nguyên si ra TOAST, bước 2 vẫn lớn đẩy ra bảng TOAST phụ để lại con trỏ bảng chính doc2 chỉ giữ id tieu_de và con trỏ nhỏ tới TOAST bảng TOAST phụ chứa các khối 5KB nội dung thật bảng chính gọn quét không chạm noi_dung rất nhanh, chiến lược lưu mỗi cột ALTER TABLE SET STORAGE EXTENDED nén cộng đẩy ra TOAST mặc định cho text jsonb bytea MAIN nén nhưng cố giữ inline EXTERNAL đẩy ra TOAST nhưng không nén đọc theo đoạn nhanh hơn PLAIN không bao giờ TOAST kiểu cố định nhỏ int bool

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):

Ảnh chụp bảng kết quả đo thật nền tối TOAST 200 nghìn tài liệu nội dung khoảng 5KB PostgreSQL 16 pg_relation_size pg_column_size EXPLAIN ANALYZE BUFFERS block_size 8KB shared_buffers 128MB, nén tự động nén được thì giữ inline không thì ra TOAST text lặp lại doc 6200 ký tự gốc 148 byte sau nén nén khoảng 42 lần ở lại bảng chính TOAST 0 byte md5 ngẫu nhiên doc2 5120 ký tự gốc 5120 byte sau nén không nén đẩy ra bảng TOAST, kích thước bảng chính vs bảng TOAST phụ doc2 bảng chính doc2 id tieu_de con trỏ 15 MB bảng TOAST phụ nội dung 5KB thật 1116 MB giá trị lớn nằm ở bảng phụ bảng chính chỉ 15 MB dù tổng dữ liệu hơn 1 GB, đọc chạm cột TOAST hay không quyết định tốc độ count tieu_de không chạm noi_dung khoảng 1870 trang chỉ bảng chính 18 mili giây count length noi_dung chạm TOAST khoảng 916000 trang cả bảng TOAST 611 mili giây đọc không chạm cột lớn nhanh hơn khoảng 33 lần đây là lý do SELECT sao trên bảng có cột lớn rất đắt, cốt lõi TOAST tự nén giá trị lớn hơn khoảng 2KB rồi đẩy phần còn lớn ra bảng TOAST phụ giữ bảng chính gọn quét không chạm cột lớn chỉ đọc bảng chính nhanh chạm vào nó phải nạp từ TOAST chậm gấp bội chỉ SELECT cột cần và cân nhắc SET STORAGE EXTERNAL nếu hay đọc từng đoạn

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 doc2 chỉ 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 cho text, 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ề

  1. 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.
  2. Đọ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.
  3. 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ắc EXTERNAL cho 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.