Bỏ qua để đến nội dung
Knowledge Hub

PostgreSQL lưu dữ liệu thế nào: page, heap, tuple và TOAST Cơ bản

Thời gian đọc ước tính: 13 phút

Một bảng documents có thể trông rất đơn giản trong SQL: mỗi row gồm id, title và body. Nhưng một row không phải là một khối dữ liệu được ghi nguyên vẹn vào một chỗ duy nhất trên đĩa.

PostgreSQL chia file quan hệ thành các page; mỗi page có vùng điều khiển, chỗ trống và các tuple. Nếu body quá lớn, PostgreSQL còn có thể nén nó hoặc chuyển phần giá trị đó sang một TOAST table liên kết.

Biết cách chia lớp này giúp trả lời các câu hỏi vận hành thường gặp: vì sao một bảng có vài cột nhưng chiếm nhiều đĩa, ctid đang chỉ điều gì, tại sao pg_column_size(body) khác với tổng kích thước bảng, và vì sao một row có JSON hoặc văn bản lớn vẫn có thể nằm trong bảng thông thường.

Bài này mô tả table access method mặc định heap của PostgreSQL 17. heap ở đây là cách PostgreSQL tổ chức và tìm các phiên bản row trong bảng; nó không có nghĩa row luôn được lưu theo thứ tự id, cũng không phải cấu trúc heap tree thường gặp trong thuật toán.

Hãy hình dung một thư viện lưu hồ sơ chuyến xe. SQL cho ta cách gọi tên dữ liệu: bảng documents, row id = 42, cột body. Khi PostgreSQL lưu dữ liệu vật lý, nó cần các đơn vị nhỏ hơn:

  • Page là khối có kích thước cố định, thường 8 kB, dùng làm đơn vị đọc/ghi cho bảng và index. Kích thước thực tế được quyết định lúc biên dịch PostgreSQL.
  • Heap là table access method mặc định. File của heap relation là dãy page; các page của bảng không được bảo đảm xếp theo giá trị cột hay thứ tự insert.
  • Tuple là phiên bản vật lý của một row trong heap. Tuple gồm header mà PostgreSQL cần để quản lý phiên bản và các giá trị cột; phần header của tuple trong PostgreSQL 17 thường chiếm 23 byte trước bitmap NULL và dữ liệu, chưa tính padding.
  • TOAST là cơ chế xử lý các giá trị lớn của một số kiểu dữ liệu biến độ dài. PostgreSQL có thể nén giá trị hoặc lưu nó thành các chunk trong relation phụ, còn tuple chính giữ một pointer nhỏ.

Mỗi tầng giải quyết một vấn đề khác nhau: page giới hạn đơn vị I/O, heap quản lý các tuple trong relation, tuple mang giá trị và metadata của phiên bản row, còn TOAST tránh buộc một giá trị lớn chiếm nguyên một tuple trong page chính.

Đang tải diagram…

Khi ứng dụng truy vấn SELECT body FROM documents WHERE id = 42, PostgreSQL không mở một file có tên documents rồi tìm chuỗi 42. Catalog mô tả relation và các cột; mỗi relation có storage riêng.

Một bảng heap thông thường có các fork sau:

Fork Vai trò
Main fork File chính, chứa các page có cùng định dạng cơ bản
Free space map (FSM) Giúp tìm page còn chỗ trống
Visibility map (VM) Theo dõi page đã được xác nhận chỉ có tuple nhìn thấy được

Page được lấp từ hai đầu vào giữa: line pointer lớn dần từ đầu, tuple lớn dần từ cuối. Page đầy khi hai phía gặp nhau.

|<------------------------------ 8192 byte ------------------------------>|
+--------+-----+-----+-----+---------------------+-------+-------+-------+
| header | lp1 | lp2 | lp3 | vùng trống | tuple3| tuple2| tuple1|
| 24 B | 4 B | 4 B | 4 B | | | | |
+--------+-----+-----+-----+---------------------+-------+-------+-------+
---> lớn dần <--- lớn dần

Một page heap có các phần chính:

Phần Vai trò
Page header Thông tin page, bao gồm ranh giới giữa vùng quản lý và vùng dữ liệu
Line pointers (ItemIdData) Mảng slot trỏ tới vị trí và kích thước từng tuple; mỗi slot thường chiếm 4 byte
Free space Chỗ chưa cấp phát cho line pointer hay tuple mới
Tuple items Nội dung tuple; được cấp phát ngược từ cuối page về phía line pointer
Special space Vùng riêng cho access method; với heap table thông thường, vùng này rỗng

PostgreSQL 17 dành 24 byte đầu page cho page header. Line pointer tách vị trí nhận diện item khỏi vị trí byte của tuple.

Khi PostgreSQL cần gom vùng trống trong page, tuple có thể được dời bên trong page mà line pointer vẫn giữ nguyên slot. Đây là lý do ctid có thể tiếp tục trỏ tới cùng slot sau một số thao tác dọn page.

Dù vậy, ứng dụng không nên dùng ctid như khoá bền vững: update có thể tạo phiên bản tuple mới, và các thao tác bảo trì có thể làm vị trí vật lý thay đổi.

Tuple không chỉ là các giá trị SQL. Theo thứ tự, một tuple gồm:

  1. Header: transaction ID tạo phiên bản (xmin), transaction ID xoá hoặc thay thế phiên bản (xmax), ctid, cờ trạng thái và số cột.
  2. Bitmap NULL (nếu có).
  3. Padding để căn chỉnh dữ liệu.
  4. Các giá trị cột.

Chi tiết đặt byte phụ thuộc kiểu dữ liệu và quy tắc alignment; vì vậy không thể cộng đơn giản độ dài từng chuỗi để suy ra kích thước chính xác của tuple.

ctid có dạng (block number, item number): số block xác định page trong relation, còn item number xác định line pointer trên page. Nó trả lời “phiên bản row này hiện ở đâu trong relation?”, không trả lời “đây là định danh lâu dài của row nào?”. Nếu cần định danh nghiệp vụ, dùng primary key.

Phần dữ liệu biến độ dài dùng định dạng varlena. Kiểu text, bytea và các kiểu như jsonb có thể chứa giá trị kích thước thay đổi, nhưng không phải mọi kiểu đều hỗ trợ TOAST.

Khi row chứa các trường có thể TOAST, PostgreSQL giữ giá trị nhỏ trực tiếp trong tuple nếu phù hợp. Việc có relation TOAST gắn với bảng chưa có nghĩa mọi giá trị đã được đưa ra ngoài.

UPDATE không sửa tại chỗ: nó tạo tuple mới và đánh dấu tuple cũ bằng xmax. Tuple cũ vẫn chiếm chỗ trong page tới khi VACUUM thu hồi. Đây là nguồn gốc của bloat; xem MVCC và VACUUM, autovacuum và bloat.

Tuple không được trải qua nhiều page. Khi một row rộng hơn TOAST_TUPLE_THRESHOLD (thường 2 kB), PostgreSQL nén và/hoặc đẩy các giá trị lớn ra ngoài cho tới khi row ngắn hơn TOAST_TUPLE_TARGET (cũng thường 2 kB, chỉnh được theo bảng bằng toast_tuple_target). Ngưỡng tính trên cả row, không phải từng cột.

Bốn chiến lược lưu trữ, đặt bằng ALTER TABLE ... ALTER COLUMN ... SET STORAGE:

Chiến lược Nén Out-of-line Dùng khi
PLAIN Không Không Kiểu không TOAST được (mặc định của int, v.v.)
EXTENDED Có Có Mặc định cho text, jsonb, bytea: nén trước, nếu vẫn lớn mới đẩy ra ngoài
EXTERNAL Không Có Cần substring nhanh trên text/bytea lớn: chỉ đọc phần cần
MAIN Có Chỉ khi bất đắc dĩ Muốn giữ trong tuple chính nếu còn cách

Một giá trị TOAST-able tối đa 1 GB. Thuật toán nén chọn theo cột bằng COMPRESSION hoặc default_toast_compression.

Khi dữ liệu được lưu out-of-line trên đĩa, PostgreSQL chia giá trị sau khi nén (nếu có) thành các chunk trong TOAST table riêng:

  • Mỗi chunk có mã giá trị, số thứ tự chunk và dữ liệu chunk.
  • Index trên các trường nhận diện giúp ghép đúng thứ tự khi đọc lại.
  • Tuple ở bảng chính giữ pointer chứa thông tin cần thiết để truy xuất giá trị, thay vì mang toàn bộ payload.

Luồng đọc vẫn trong suốt ở mức SQL: SELECT body trả về giá trị đầy đủ. Khác biệt vật lý là backend có thể cần đọc các chunk TOAST và giải nén trước khi đưa giá trị cho executor hoặc client. Chi phí đó phụ thuộc query:

Thao tác Chi phí TOAST
Query chỉ cần id, title Không đọc chunk của body
UPDATE một cột khác của row có body đã out-of-line Không tốn, vì giá trị không đổi được giữ nguyên
SELECT * trên bảng nhiều cột TOAST Kéo và giải nén tất cả

Ví dụ dưới đây tạo một bảng tạm, thêm một chuỗi ngắn và một payload lớn gần như không lặp, rồi quan sát kích thước datum, TOAST pointer và kích thước relation. Bảng tạm chỉ tồn tại trong session psql hiện tại. Chạy từ thư mục examples/ sau khi PostgreSQL 17 trong Compose đã sẵn sàng:

Terminal window
docker compose up -d postgres
docker compose exec -T postgres psql -U hub -d hub < src/postgres-storage/storage-layout.sql

Hoặc chép nội dung tệp examples/src/postgres-storage/storage-layout.sql trong repo vào một session PostgreSQL 17. Các hàm được dùng:

Hàm Cho biết
pg_column_size Kích thước lưu của datum
pg_column_compression Giá trị có được nén không
pg_column_toast_chunk_id Giá trị có pointer tới chunk TOAST trên đĩa hay không
pg_class.reltoastrelid Nối bảng với TOAST relation, để so sánh kích thước main fork với tổng storage

Kết quả cần đọc theo quan hệ, không học thuộc con số byte: row short nằm inline và không có TOAST chunk; row large có TOAST pointer. Kích thước chính xác thay đổi theo page size, header, alignment, thuật toán nén và phiên bản.

pg_total_relation_size gồm cả TOAST và index của bảng; pg_relation_size mặc định chỉ đo main fork của relation được truyền vào.

Trong lần chạy với image postgres:17-alpine của Compose, phần quan sát datum trả về:

label characters pg_column_size compression Có TOAST pointer?
short 40 51 byte NULL không
large 16.000 16.000 byte NULL có

Vì sao chuỗi short 40 ký tự lại là 51 byte? Chuỗi demo là tiếng Việt (Nội dung ngắn nằm trực tiếp trong tuple.), các ký tự có dấu chiếm 2-3 byte UTF-8, nên 40 ký tự thành 50 byte, cộng 1 byte header của giá trị ngắn. characters đếm ký tự, pg_column_size đếm byte lưu.

Ở cùng lần chạy:

Thành phần Kích thước
Main fork của heap 8192 bytes
TOAST table cùng index 64 kB
Tổng relation 88 kB

Đây là số đo của một lần chạy cụ thể; con số phụ thuộc phiên bản patch, page size và layout relation.

pg_column_size của giá trị out-of-line là kích thước đã lưu trong TOAST table, không phải kích thước pointer trong tuple chính. Giá trị large báo 16.000 byte vì payload gần như không nén được (compression là NULL), nên kích thước lưu bằng kích thước gốc. Với dữ liệu nén được, con số sẽ nhỏ hơn số ký tự.

Ưu điểm

  • Page có kích thước cố định nên PostgreSQL quản lý I/O và vùng trống theo đơn vị nhất quán.
  • Line pointer cho phép dời tuple trong page mà không đổi slot nhận diện item ngay lập tức.
  • TOAST giúp row có giá trị lớn mà vẫn giữ tuple chính đủ gọn cho nhiều truy vấn không cần payload.

Nhược điểm

  • Đọc hoặc cập nhật cột TOAST có thể phát sinh đọc chunk, giải nén và thêm I/O.
  • ctid là địa chỉ vật lý của một phiên bản tuple, không phải định danh ứng dụng ổn định.
  • Kích thước relation gồm page header, line pointer, vùng trống, phiên bản tuple cũ, index và TOAST; một con số đơn lẻ không giải thích được nguyên nhân phình.

Một cách nhớ nhanh: page là khung chứa, line pointer là slot, tuple là phiên bản row, TOAST là nơi phụ cho giá trị lớn. Đánh đổi là query chỉ lấy các cột nhỏ có thể nhẹ hơn, còn query cần payload lớn vẫn phải trả chi phí lấy và xử lý payload đó.

  1. Query danh sách chỉ chọn cột nhỏ cần dùng; không SELECT * trên bảng có cột TOAST, vì nó kéo và giải nén tất cả.
  2. Dùng primary key làm định danh ứng dụng; không lưu ctid sang hệ thống khác.
  3. Khi bảng “phình”, tách các lớp kích thước: pg_relation_size (main fork) so với pg_total_relation_size (gồm TOAST và index).
  4. Dùng pg_column_size và pg_column_toast_chunk_id để biết một giá trị nằm inline hay out-of-line, thay vì cộng độ dài chuỗi.
  5. Chỉ đổi SET STORAGE EXTERNAL khi thật sự cần substring nhanh trên text/bytea lớn; mặc định EXTENDED đã nén trước.
  6. Chỉ tách cột lớn sang bảng riêng khi nó có vòng đời hay chính sách lưu trữ khác; TOAST đã tách payload khỏi tuple chính.

Ba câu tự kiểm tra:

  1. Vì sao pg_total_relation_size('documents') có thể lớn hơn pg_relation_size('documents')?
  2. Nếu một bảng có TOAST table, điều đó có chứng minh mọi giá trị text đang được lưu ngoài heap không?
  3. Vì sao không nên lưu ctid của một row vào hệ thống khác làm định danh lâu dài?