Hình dung một thư viện nơi mỗi cuốn sách trên kệ chỉ là một tấm thẻ mỏng ghi tên, tác giả, và một mã số. Nội dung dày cộp của cuốn sách nằm ở kho phía sau; tấm thẻ chỉ giữ cái mã để lấy nó khi cần. Nhờ vậy, ai đó muốn "đếm xem có bao nhiêu sách của tác giả X" chỉ cần lướt qua các tấm thẻ mỏng — nhanh như chớp, chẳng động tới kho. Chỉ khi bạn thật sự muốn đọc một cuốn, người ta mới vào kho lấy nó ra. PostgreSQL lưu các giá trị lớn đúng theo cách này, cơ chế tên là TOAST — và bài này là lần tôi tưởng cái kho phía sau làm chậm cả việc lướt thẻ, rồi đo ra mình sai.

Ở 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 đó (đúng tấm thẻ giữ mã số, còn sách nằm trong kho).

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.

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 — y như lướt các tấm thẻ mỏng mà chẳng vào kho.

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ề đến code hằng ngày

Điều đầ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.

Điều 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. Gói lại bằng số: 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.

Và cái bẫy "đối tượng nặng làm chậm cả những thao tác không đụng tới nó" này là nỗi lo lặp lại ở mọi tầng lập trình — thường cũng bị thổi phồng y hệt. Trong Java, một class có một trường byte[] 5MB không làm chậm việc đọc trường id của nó; cái làm chậm là sao chép hay serialize cả object khi bạn chỉ cần cái id — đúng như SELECT * kéo luôn blob. Một ORM lazy-loading (@Basic(fetch=LAZY) của Hibernate cho cột lớn, hay defer() của Django) chính là bản sao của TOAST ở tầng ứng dụng: nạp con trỏ trước, chỉ chạm dữ liệu nặng khi thật sự đọc tới. Và chuyện "cùng kích thước logic, dung lượng thật khác nhau vì nén" thì lập trình viên gặp mỗi khi chọn gzip cho response API: text nén tuyệt, ảnh đã nén thì gzip thêm chỉ tốn CPU vô ích. Mẫu số chung đáng mang theo: chi phí của một giá trị lớn không nằm ở việc nó tồn tại, mà ở việc bạn có chạm vào toàn bộ nó hay không — nên tối ưu bằng cách chỉ đọc/sao chép/truyền đúng phần bạn cần, chứ không phải bằng cách sợ hãi mà cấm lưu dữ liệu lớn.

Muốn tự thấy thì 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.