PostgreSQL Bài 9: Xử lý chuỗi và Văn bản: So sánh VARCHAR, TEXT, CHAR và cách hoạt động của TOAST
Ở Bài 6, chúng ta đã nhắc sơ lược về việc VARCHAR và TEXT có hiệu năng tương đương nhau bên trong PostgreSQL engine. Nhưng điều gì sẽ xảy ra khi bạn lưu một đoạn văn bản dài hàng trăm kilobyte, một tài liệu Markdown, hay một file JSON khổng lồ vào một cột TEXT?
Một trang dữ liệu (Page/Block) tiêu chuẩn của PostgreSQL chỉ có kích thước cố định là 8 KB, và PostgreSQL không bao giờ cho phép một hàng (Row/Tuple) nằm tràn qua hai trang đĩa khác nhau.
Vậy làm thế nào PostgreSQL có thể lưu trữ một trường dữ liệu văn bản lên đến 1 GB? Câu trả lời nằm ở cơ chế TOAST (The Oversized-Attribute Storage Technique). Bài học này sẽ làm sáng tỏ bản chất lưu trữ chuỗi và kỹ thuật TOAST dưới tầng vật lý.
1. Mổ xẻ bản chất bộ ba: CHAR(n), VARCHAR(n), và TEXT
Nhiều database engine khác (như MySQL MyISAM/InnoDB cũ) phân tách rạch ròi giữa chuỗi inline cố định và chuỗi động ngoài bảng. Trong PostgreSQL, cả ba kiểu dữ liệu này đều sử dụng chung một cấu trúc dữ liệu nội bộ gọi là varlena (variable-length structure).
1.1. Cấu trúc nội bộ của kiểu dữ liệu chuỗi (varlena)
Một giá trị chuỗi trong PostgreSQL luôn đi kèm một phần tiêu đề (Header):
-
1-byte Header: Nếu chuỗi ngắn (dưới 127 bytes), header chỉ tốn đúng 1 byte để lưu độ dài chuỗi và cờ đánh dấu. Không có padding khoảng trắng.
-
4-byte Header: Nếu chuỗi dài hơn, header sẽ dùng 4 bytes để lưu độ dài (hỗ trợ dữ liệu lên tới 1 GB) và các cờ nén/TOAST.
Chuỗi ngắn (≤ 126 bytes):
[ 1 Byte Length Header ] [ Raw String Data (Không padding) ]
Chuỗi dài (> 126 bytes):
[ 4 Bytes Flags & Length ] [ Raw String Data / Compressed Data ]
1.2. Bảng phân tích chi tiết
| Kiểu dữ liệu | Kích thước khai báo | Cơ chế đệm (Padding) | Hành vi khi vượt giới hạn | Hiệu năng Query & Index |
|---|---|---|---|---|
| CHAR(n) | Cố định n ký tự |
Luôn đệm thêm dấu cách (' ') cho đủ n ký tự khi lưu |
Ném lỗi nếu vượt quá n |
Chậm nhất. Tốn thêm CPU để loại bỏ dấu cách thừa khi so sánh chuỗi, tốn dung lượng đĩa vô ích. |
| VARCHAR(n) | Tối đa n ký tự |
Không đệm dấu cách, lưu đúng độ dài thực tế | Ném lỗi nếu vượt quá n ký tự |
Tương đương TEXT. Tốn thêm một bước kiểm tra độ dài (length check) khi chèn/sửa. |
TEXT (hoặc VARCHAR không n) |
Tối đa 1 GB | Không đệm dấu cách, lưu đúng độ dài thực tế | Ném lỗi nếu vượt quá 1 GB | Tối ưu nhất. Không cần overhead kiểm tra ràng buộc độ dài cố định. |
Lời khuyên từ chuyên gia PostgreSQL: Hãy ngừng sử dụng
CHAR(n). Với các cột văn bản không có ràng buộc độ dài logic nghiêm ngặt từ quy chuẩn nhà nước hay nghiệp vụ cứng, hãy mặc định chọnTEXT. Nếu cần giới hạn độ dài linh hoạt (ví dụ: mô tả không quá 500 ký tự), bạn có thể dùngTEXTkết hợp vớiCHECK (length(description) <= 500), giúp dễ dàng nới rộng giới hạn sau này bằng lệnhALTER TABLEmà không gây khóa bảng để viết lại kiểu dữ liệu.
2. Giới hạn 8 KB của Data Page và sự ra đời của TOAST
PostgreSQL đọc và ghi dữ liệu theo từng khối (Block) gọi là Page, kích thước mặc định là 8192 bytes (8 KB).
Mỗi Page chứa:
-
Page Header: Chiếm 24 bytes (lưu LSN, checksum, con trỏ).
-
Item Id Data (Linp): Mảng các con trỏ trỏ tới từng tuple.
-
Các Tuple (Hàng dữ liệu thực tế): Nằm từ cuối page xếp ngược lên.
-
Vùng trống (Free Space): Ở giữa.
Vì quy tắc "Một hàng không bao giờ được span qua nhiều page", kích thước tối đa của một tuple (bao gồm toàn bộ các cột trong một hàng) bắt buộc phải nhỏ hơn 8 KB (trừ đi header, tối đa khoảng ~8160 bytes).
Nếu bạn cố nhét một bài viết blog dài 50 KB vào một dòng, PostgreSQL xử lý thế nào? Engine sẽ kích hoạt cơ chế TOAST.
3. Cơ chế hoạt động của TOAST (The Oversized-Attribute Storage Technique)
TOAST là cơ chế tự động "bốc" các giá trị cột có kích thước quá khổ ra khỏi bảng chính (Main Table) và lưu vào một bảng phụ riêng biệt gọi là Bảng TOAST (TOAST Table).
3.1. Ngưỡng kích hoạt (Thresholds)
-
Ngưỡng kích hoạt (
TOAST_TUPLE_THRESHOLD): Mặc định là 2 KB (khoảng 2048 bytes). Khi toàn bộ kích thước của một tuple vượt quá 2 KB, PostgreSQL sẽ bắt đầu can thiệp. -
Ngưỡng mục tiêu (
TOAST_TUPLE_TARGET): Mặc định là 2 KB. Engine sẽ cố gắng thu nhỏ kích thước của tuple xuống dưới ngưỡng này.
3.2. Quy trình 4 bước xử lý TOAST
Khi một tuple vượt quá 2 KB, PostgreSQL sẽ duyệt qua các cột theo thứ tự ưu tiên và thực hiện lần lượt 4 chiến lược:
[ Tuple > 2 KB ]
│
▼
1. NÉN TẠI CHỖ (Inline Compression):
Sử dụng thuật toán pglz hoặc lz4 để nén dữ liệu. Nếu tuple < 2 KB ──► DỪNG (Vẫn nằm trong Main Page)
│ (Vẫn > 2 KB)
▼
2. ĐẨY RA BẢNG TOAST (Out-of-line Storage):
Cắt dữ liệu thành các Chunk (2 KB) và chuyển sang bảng TOAST phụ.
Thay thế giá trị trong bảng chính bằng 1 con trỏ TOAST Pointer (18 bytes).
│ (Vẫn > 2 KB)
▼
3. NÉN RỒI ĐẨY RA NGOÀI (Compress + Out-of-line):
Áp dụng đồng thời vừa nén vừa đẩy ra ngoài cho các cột còn lại.
│ (Vẫn > 2 KB)
▼
4. NÉM LỖI (Row Too Big):
Nếu tất cả các cột đã được TOAST tối đa mà các dữ liệu cố định còn lại vẫn vượt quá 8 KB.
4. Bảng TOAST được tổ chức như thế nào dưới ổ đĩa?
Khi một bảng được tạo có chứa các cột có thể TOAST (như TEXT, JSONB, BYTEA), PostgreSQL sẽ tự động tạo một bảng TOAST tương ứng trong schema ẩn pg_toast.
Ví dụ: Nếu bảng chính có tên là articles với OID là 16400, bảng TOAST ngầm sẽ có tên là pg_toast.pg_toast_16400.
Cấu trúc vật lý của Bảng TOAST:
Bảng TOAST chỉ gồm đúng 3 cột:
-
chunk_id(OID): Mã định danh của giá trị TOAST. -
chunk_seq(INT): Số thứ tự của phần dữ liệu bị cắt nhỏ (0, 1, 2, 3...). -
chunk_data(BYTEA): Dữ liệu nhị phân thực tế của chunk đó (khoảng 2 KB mỗi chunk).
Bảng TOAST luôn có một Unique B-Tree Index trên cặp (chunk_id, chunk_seq).
MAIN TABLE (articles)
┌─────┬───────────────┬────────────────────────────────────────┐
│ id │ title │ content (TOAST Pointer - 18 bytes) │
├─────┼───────────────┼────────────────────────────────────────┤
│ 1 │ 'Postgres 9' │ [Pointer: chunk_id = 99812, size=6KB] │
└─────┴───────────────┴────────────────────────────────────────┘
│
▼ (Trỏ tới bảng TOAST)
BẢNG TOAST (pg_toast.pg_toast_16400)
┌──────────┬───────────┬────────────────────────────────────────┐
│ chunk_id │ chunk_seq │ chunk_data │
├──────────┼───────────┼────────────────────────────────────────┤
│ 99812 │ 0 │ [2 KB nhị phân đầu tiên của bài viết] │
│ 99812 │ 1 │ [2 KB nhị phân tiếp theo] │
│ 99812 │ 2 │ [2 KB nhị phân cuối cùng] │
└──────────┴───────────┴────────────────────────────────────────┘
5. Bốn chiến lược TOAST (Storage Strategies)
Bạn có thể chủ động kiểm soát cách PostgreSQL đối xử với từng cột thông qua 4 chiến lược:
-
EXTENDED(Mặc định choTEXT,JSONB,VARCHAR):- Cho phép cả nén nội bộ (compression) và lưu ngoài bảng (out-of-line storage).
-
MAIN:- Cho phép nén dữ liệu, nhưng ưu tiên giữ dữ liệu nằm trong bảng chính. Chỉ đẩy ra bảng TOAST nếu không còn cách nào khác để nhét vừa trang 8 KB.
-
EXTERNAL:-
Không nén, nhưng cho phép đẩy trực tiếp ra bảng TOAST.
-
Ứng dụng: Phù hợp với các cột lưu dữ liệu đã nén sẵn (ảnh JPG/PNG, file ZIP, video ngắn dưới dạng
BYTEA). Việc ép PostgreSQL nén một file zip chỉ làm lãng phí CPU mà không giảm thêm được byte nào.
-
-
PLAIN:-
Không nén, không đẩy ra bảng TOAST. Dữ liệu bắt buộc phải nằm inline trên bảng chính.
-
Dùng cho các kiểu dữ liệu kích thước cố định như
INTEGER,BOOLEAN,TIMESTAMP.
-
Thay đổi chiến lược lưu trữ bằng SQL:
SQL
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT,
raw_html TEXT,
pdf_attachment BYTEA
);
-- Không nén file đính kèm vì thường đã được nén sẵn:
ALTER TABLE documents ALTER COLUMN pdf_attachment SET STORAGE EXTERNAL;
-- Ưu tiên giữ HTML trong bảng chính nếu nén lại đủ nhỏ:
ALTER TABLE documents ALTER COLUMN raw_html SET STORAGE MAIN;
6. Lợi ích và Tác động hiệu năng thực chiến của TOAST
6.1. Tăng tốc độ đọc toàn bảng (Full Table Scan)
Hãy tưởng tượng bảng documents có 1.000.000 dòng. Mỗi dòng có một cột raw_html dài 50 KB.
-
Nếu không có TOAST, bảng chính sẽ ngốn khoảng 50 GB đĩa. Một câu lệnh:
SQL
SELECT id, title FROM documents WHERE id > 500000;sẽ buộc CPU và ổ đĩa phải đọc toàn bộ 50 GB dữ liệu lên RAM
shared_buffers. -
Nhờ có TOAST: Dữ liệu HTML 50 KB nằm riêng ở bảng TOAST. Cột
raw_htmltrên bảng chính chỉ chiếm 18 bytes (con trỏ pointer). Kích thước bảng chính co lại chỉ còn vài chục Megabytes! -
PostgreSQL có thể quét qua 1.000.000 dòng trên bảng chính với tốc độ cực nhanh mà hoàn toàn không cần chạm vào bảng TOAST. Chỉ khi nào câu lệnh
SELECTcủa bạn yêu cầu lấy cộtraw_html, PostgreSQL mới tìm theo con trỏ sang bảng TOAST để đọc các chunk dữ liệu.
Quy tắc vàng hiệu năng: Tránh dùng
SELECT *trong ứng dụng! Nếu bạn chỉ cần lấyidvàtitleđể hiển thị danh sách, viếtSELECT id, titlesẽ giúp hệ thống không phải tốn I/O giải nén và nạp các khối TOAST khổng lồ từ đĩa.
6.2. Kiểm tra thông tin TOAST bằng SQL thực tế
Để kiểm tra bảng TOAST ngầm của một bảng:
SQL
SELECT
relname AS main_table,
reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE relname = 'documents';
Kiểm tra dung lượng của bảng chính so với bảng TOAST:
SQL
SELECT
pg_size_pretty(pg_relation_size('documents')) AS main_table_size,
pg_size_pretty(pg_total_relation_size(reltoastrelid)) AS toast_size
FROM pg_class
WHERE relname = 'documents';
7. Tóm tắt & Bài tiếp theo
-
CHAR(n)gây lãng phí dung lượng và làm chậm so sánh vì cơ chế đệm khoảng trắng;TEXTvàVARCHAR(n)có cấu trúcvarlenanội bộ tương đương nhau. -
Trang dữ liệu PostgreSQL cố định ở mức 8 KB và không cho phép một hàng bị cắt đôi qua hai trang đĩa.
-
TOAST tự động nén và chuyển các trường dữ liệu lớn (> 2 KB) sang một bảng phụ riêng biệt, thay thế bằng con trỏ 18 bytes ở bảng chính.
-
TOAST giúp các câu truy vấn lọc không chứa cột lớn chạy với tốc độ cao, nhưng việc lạm dụng
SELECT *sẽ triệt tiêu lợi thế này.
Bài 10 xem tiếp: Quản lý dữ liệu bán cấu trúc với
JSONvsJSONB— chúng ta sẽ phân tích lý do PostgreSQL trở thành một trong những cơ sở dữ liệu Document mạnh mẽ nhất hiện nay, sự khác biệt sống còn giữa việc lưu chuỗi Text thuần (JSON) và phân tích cấu trúc nhị phân (JSONB), cùng cơ chế TOAST ảnh hưởng thế nào đến các document JSON lớn.
All Rights Reserved