PostgreSQL Bài 21: Cơ chế lưu trữ đĩa: Page, Tuple, Page Header và Fillfactor
Chào mừng bạn bước sang Phần 4: Indexing & Tối ưu hóa truy vấn chuyên sâu.
Ở các phần trước, chúng ta đã tiếp cận PostgreSQL từ góc nhìn cú pháp và mô hình logic. Tuy nhiên, để thực sự hiểu cách hoạt động của B-Tree Index, cơ chế MVCC, VACUUM, cũng như lý do các câu lệnh UPDATE có thể làm suy giảm hiệu năng nghiêm trọng, bạn bắt buộc phải hiểu tầng lưu trữ vật lý (Physical Storage Engine) của PostgreSQL.
Trong bài học này, chúng ta sẽ "giải phẫu" cấu trúc của một Data Page 8 KB, tìm hiểu xem một dòng dữ liệu (Tuple) được đóng gói thế nào dưới ổ đĩa, và cách sử dụng thông số fillfactor để tăng tốc độ ghi trên môi trường sản xuất.
1. Cấu trúc vật lý của Data Page (Block) 8 KB
Khi PostgreSQL lưu một bảng xuống đĩa cứng, nó không ghi thành một file văn bản liên tục mà chia nhỏ file dữ liệu thành các khối có kích thước cố định là 8192 bytes (8 KB), được gọi là Page (hoặc Block).
Bên trong một Page 8 KB, dữ liệu được sắp xếp theo mô hình hai đầu tiến vào giữa (Slotted Page Architecture):
┌────────────────────────────────────────────────────────┐ ◄── Byte 0
│ Page Header (24 bytes) │
├────────────────────────────────────────────────────────┤ ◄── Byte 24
│ ItemId Data / Line Pointers (lp_1, lp_2, lp_3, ...) │
│ (Mỗi con trỏ chiếm đúng 4 bytes) ───────┐ │
├─────────────────────────────────────────┼──────────────┤ ◄── pd_lower (Đỉnh của mảng con trỏ)
│ │ │
│ VÙNG TRỐNG (FREE SPACE) │ │
│ (Chờ chèn thêm dữ liệu mới) │ │
│ │ │
├─────────────────────────────────────────┼──────────────┤ ◄── pd_upper (Đáy của dữ liệu Tuple)
│ ▼ │
│ Tuple 3 (Dòng dữ liệu thực tế số 3) │
├────────────────────────────────────────────────────────┤
│ Tuple 2 │
├────────────────────────────────────────────────────────┤
│ Tuple 1 │
└────────────────────────────────────────────────────────┘ ◄── Byte 8192
1.1. Chi tiết 4 khu vực chính trong Data Page:
-
Page Header (24 bytes): Nằm ở đầu trang, chứa metadata quản lý:
-
pd_lsn(8 bytes): Log Sequence Number ghi nhận bản ghi WAL gần nhất sửa đổi trang này (phục vụ Crash Recovery). -
pd_checksum(2 bytes): Mã kiểm tra toàn vẹn dữ liệu. -
pd_lower(2 bytes): Vị trí byte kết thúc của mảng con trỏItemId. -
pd_upper(2 bytes): Vị trí byte bắt đầu của Tuple gần nhất được ghi từ đáy trang lên. -
pd_special(2 bytes): Dành riêng cho các loại Index đặc biệt (như B-Tree Leaf Link). Với bảng Heap thông thường, giá trị này trỏ thẳng tới cuối trang (8192).
-
-
Item Pointer Array / Line Pointers (
ItemId- 4 bytes mỗi phần tử):-
Là một mảng các con trỏ nhỏ. Mỗi khi có một dòng mới được chèn vào bảng, một
ItemId4-byte sẽ được ghi vào đây. -
Nó lưu: Vị trí offset của Tuple dưới đáy trang + Độ dài (Length) của Tuple đó + Cờ trạng thái (flags: used, unused, redirect, dead).
-
Mảng này phát triển dần từ trên xuống dưới (đẩy
pd_lowertăng dần).
-
-
Vùng dữ liệu Tuple (Heap Tuples):
- Chứa dữ liệu thực tế của các dòng. Dữ liệu này được ghi từ đáy trang (byte 8192) ngược dần lên trên (đẩy
pd_uppergiảm dần).
- Chứa dữ liệu thực tế của các dòng. Dữ liệu này được ghi từ đáy trang (byte 8192) ngược dần lên trên (đẩy
-
Free Space (Khoảng trống khả dụng):
- Khoảng trống nằm giữa
pd_lowervàpd_upper. Khi , trang được coi là đã đầy và PostgreSQL buộc phải cấp phát một Page mới.
- Khoảng trống nằm giữa
2. Giải mã TID (Tuple ID / ItemPointer) và Con trỏ CTID
Trong PostgreSQL, mỗi dòng dữ liệu trên bảng Heap được định danh vật lý bằng một cặp số gọi là TID (Tuple Identifier) hay hiển thị trong SQL dưới tên cột hệ thống ẩn ctid.
-
block_number: Số thứ tự của Data Page trong file (bắt đầu từ 0). -
item_offset: Số thứ tự của Line Pointer bên trong trang đó (bắt đầu từ 1).
Thử nghiệm thực tế:
SQL
CREATE TABLE demo_storage (
id INT,
name TEXT
);
INSERT INTO demo_storage VALUES
(1, 'Alice'),
(2, 'Bob');
-- Truy vấn cột ẩn ctid:
SELECT ctid, id, name FROM demo_storage;
Kết quả:
Plaintext
ctid | id | name
-------+----+-------
(0,1) | 1 | Alice
(0,2) | 2 | Bob
(0,1) có nghĩa là: Dòng này nằm ở Page 0, được trỏ tới bởi Line Pointer số 1.
Tại sao Secondary Index trong PostgreSQL lại trỏ tới
ctid?Cây B-Tree Index không lưu trực tiếp dữ liệu của dòng mà ở tầng lá của nó chỉ lưu các cặp
(Index Key, ctid). Khi bạn query qua Index, engine tìm ractid = (0, 1), sau đó nhảy thẳng vào Page 0, đọc Line Pointer 1 để lấy dữ liệu thực tế.
3. Cấu trúc một Heap Tuple: Cạm bẫy 23 bytes Header
Khi một dòng dữ liệu được ghi xuống đĩa, nó không chỉ gồm các giá trị bạn nhập (id, name) mà luôn bị đính kèm một phần tiêu đề vật lý gọi là HeapTupleHeader có kích thước tối thiểu 23 bytes (làm tròn thành 24 bytes do byte alignment).
CẤU TRÚC 1 DÒNG DỮ LIỆU (HEAP TUPLE):
┌────────────────────────────────────────────────────────┐
│ HEAP TUPLE HEADER (23 bytes) │
│ ├── t_xmin (4 bytes) : Transaction ID tạo ra dòng này │
│ ├── t_xmax (4 bytes) : Transaction ID xóa/sửa dòng này│
│ ├── t_cid (4 bytes) : Command ID trong transaction │
│ ├── t_ctid (6 bytes) : Trỏ tới chính nó (hoặc bản mới)│
│ ├── t_infomask (4 bytes): Các cờ trạng thái MVCC │
│ └── t_hoff (1 byte) : Header offset (vị trí bắt đầu) │
├────────────────────────────────────────────────────────┤
│ NULL BITMAP (Tùy chọn: 1 hoặc nhiều bytes) │
│ (Chỉ xuất hiện nếu bảng có cột chứa giá trị NULL) │
├────────────────────────────────────────────────────────┤
│ USER DATA PADDING (Căn lề bộ nhớ: 2, 4 hoặc 8 bytes) │
├────────────────────────────────────────────────────────┤
│ ACTUAL DATA (Dữ liệu thực tế: int, text, uuid...) │
└────────────────────────────────────────────────────────┘
Ý nghĩa sống còn của Header đối với MVCC:
-
t_xmin: Ghi lại ID của Transaction (XID) đã thực hiện lệnhINSERTdòng này. -
t_xmax: Mặc định là0. Nếu dòng này bịDELETEhoặcUPDATE,t_xmaxsẽ được ghi đè bằng ID của Transaction đã xóa/sửa nó. -
t_ctid: Nếu dòng bịUPDATE,t_ctidcủa dòng cũ sẽ đổi từ trỏ vào chính nó sang trỏ tới(page, offset)của dòng mới vừa sinh ra.
Hệ quả kiến trúc: Bảng có 100 triệu dòng thì riêng phần Tuple Header đã tiêu tốn ~2.3 GB dung lượng đĩa và RAM chỉ để lưu metadata cho MVCC.
4. Công cụ thực hành: Mổ xẻ trang dữ liệu với Extension pageinspect
PostgreSQL tích hợp sẵn extension pageinspect, cho phép bạn đọc trực tiếp từng byte trong Shared Buffers và ổ đĩa như một DBA chuyên nghiệp.
SQL
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 1. Xem Page Header của Page số 0 trong bảng demo_storage:
SELECT * FROM page_header(get_raw_page('demo_storage', 0));
Kết quả:
Plaintext
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
0/1AE5420 | 0 | 0 | 32 | 8112 | 8192 | 8192 | 4 | 0
-
pagesize = 8192(8 KB chuẩn). -
lower = 32: Header (24 bytes) + 2 Line Pointers (mỗi cái 4 bytes) = 32 bytes. -
upper = 8112: Dữ liệu Tuple đang chiếm từ byte 8112 đến byte 8192 (khoảng 80 bytes cho 2 dòng). -
Free spacehiện tại = .
SQL
-- 2. Xem chi tiết các Line Pointers (ItemId) trong Page 0:
SELECT * FROM heap_page_items(get_raw_page('demo_storage', 0));
5. Hiện tượng Update Bloat và Sự ra đời của fillfactor
5.1. Bản chất của lệnh UPDATE trong PostgreSQL
Trong PostgreSQL, không có khái niệm ghi đè dữ liệu tại chỗ (In-place update) đối với bảng quan hệ thông thường.
Khi bạn chạy:
SQL
UPDATE demo_storage SET name = 'Alicia' WHERE id = 1;
PostgreSQL thực chất làm 2 việc:
-
Đánh dấu dòng cũ
(0, 1)là Dead Tuple (bằng cách ghi transaction ID vàot_xmax). -
Chèn một dòng hoàn toàn mới mang giá trị
'Alicia'vào vị trí trống tiếp theo trên đĩa (ví dụ(0, 3)).
5.2. Bi kịch khi Page bị đầy (Page Overflow)
Mặc định, PostgreSQL thiết lập fillfactor = 100 cho các bảng.
-
Điều này có nghĩa là khi chèn dữ liệu (
INSERT), engine sẽ cố gắng nhét dữ liệu lấp đầy 100% không gian 8 KB của Data Page. -
Hậu quả khi
UPDATExảy ra: Khi cần sửa một dòng trong một Page đã đầy 100%, PostgreSQL không còn chỗ trống trong Page hiện tại để ghi Tuple mới. -
Engine buộc phải:
-
Tìm một Page khác còn chỗ (hoặc cấp phát Page mới toanh ở cuối file).
-
Ghi Tuple mới sang Page khác đó (ví dụ từ Page 0 nhảy sang Page 50).
-
Cập nhật lại toàn bộ các B-Tree Index trên bảng để trỏ tới địa chỉ trang mới.
-
Gây ra hiện tượng phân mảnh đĩa, tăng I/O ngẫu nhiên và làm chậm nghiêm trọng cả lệnh ghi lẫn lệnh đọc.
-
6. Kỹ thuật Tinh chỉnh fillfactor và Tối ưu HOT (Heap-Only Tuples)
Để giải quyết vấn đề trên, PostgreSQL hỗ trợ cơ chế tối ưu hóa cực mạnh mang tên HOT (Heap-Only Tuple) kết hợp với thông số fillfactor.
6.1. fillfactor là gì?
fillfactor là một tỷ lệ phần trăm từ 10 đến 100 quy định mức độ lấp đầy tối đa của một Data Page khi chạy lệnh INSERT. Phần trăm còn lại sẽ được cố tình để trống làm "quỹ dự phòng" chỉ dành riêng cho các lệnh UPDATE trong tương lai.
┌────────────────────────────────────────────────────────┐
│ DATA PAGE (8 KB) │
├──────────────────────────┬─────────────────────────────┤
│ Dữ liệu INSERT chiếm │ Dự trữ cho UPDATE tương lai│
│ (70% - 80%) │ (20% - 30%) │
└──────────────────────────┴─────────────────────────────┘
◄─────────────────────── fillfactor ───────────────────►
6.2. Cơ chế tối ưu HOT (Heap-Only Tuples)
Nếu một dòng được UPDATE mà:
-
Giá trị mới vẫn nhét vừa vào chính Data Page đó (nhờ có khoảng trống do
fillfactorchừa lại). -
Cột bị thay đổi không nằm trong bất kỳ Index nào.
Thì PostgreSQL sẽ kích hoạt tính năng HOT:
-
Tuple mới được ghi ngay trong cùng Page.
-
Line Pointer cũ trỏ thẳng sang Line Pointer mới (tạo thành một chuỗi HOT Chain nội bộ trong trang).
-
Lợi ích số 1: Hoàn toàn không cần cập nhật lại B-Tree Index! Index vẫn trỏ tới con trỏ cũ, engine đi vào Page và tự động đi theo HOT Chain đến dòng mới nhất.
-
Lợi ích số 2: Khi một câu truy vấn khác đọc qua trang này, nó có thể tự động dọn dẹp (Prune) các tuple cũ bị chết mà không cần chờ tiến trình
VACUUMtoàn cục can thiệp.
SQL
-- Cấu hình fillfactor khi tạo bảng:
CREATE TABLE user_sessions (
session_id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
last_activity TIMESTAMPTZ NOT NULL, -- Cột này bị UPDATE liên tục mỗi request
payload TEXT
) WITH (fillfactor = 70); -- Dành riêng 30% dung lượng mỗi trang cho UPDATE tại chỗ!
-- Thay đổi fillfactor cho bảng hiện có:
ALTER TABLE user_sessions SET (fillfactor = 80);
-- Lưu ý: Lệnh ALTER TABLE chỉ áp dụng cho các Page ghi mới sau này.
-- Để viết lại toàn bộ bảng cũ theo fillfactor mới, cần chạy:
VACUUM FULL user_sessions; -- (Cần lưu ý lock bảng) hoặc dùng pg_repack
7. Bảng hướng dẫn cấu hình fillfactor thực chiến
| Đặc thù bảng dữ liệu | Tần suất thao tác | Khuyến nghị fillfactor | Lý do kỹ thuật |
|---|---|---|---|
| Bảng Log, Audit, Lịch sử giao dịch (Append-only) | Chỉ INSERT, hầu như không bao giờ UPDATE | 100 (Mặc định) | Tận dụng tối đa 100% dung lượng mỗi block, tiết kiệm đĩa và nạp được nhiều dòng nhất vào RAM cache. |
| Bảng Master Data (Danh mục, Quốc gia, User Profile ít đổi) | Đọc 99%, Ghi/Sửa 1% | 90 - 100 | Không cần tốn RAM đệm dự phòng. |
| Bảng Queue, State Machine, Tracking (Đơn hàng, Phiên làm việc) | UPDATE liên tục các cột trạng thái, mốc thời gian | 70 - 80 | Giúp 90%+ các lệnh UPDATE trở thành HOT Update, triệt tiêu việc ghi đè Index và ngăn ngừa Table Bloat. |
8. Tóm tắt & Bài tiếp theo
-
PostgreSQL lưu trữ dữ liệu dưới dạng các khối cố định 8 KB (Data Page) theo kiến trúc Slotted Page.
-
Con trỏ
ctidgồm(block_number, item_offset)định danh vị trí vật lý của một dòng và là đích đến của B-Tree Index. -
Mỗi Tuple tiêu tốn ít nhất 23 bytes Header để duy trì cơ chế MVCC.
-
UPDATEthực chất làDELETEbản ghi cũ vàINSERTbản ghi mới. -
Cấu hình
fillfactorhợp lý (ví dụ: 70–80) trên các bảng cập nhật nhiều giúp kích hoạt HOT Update, giảm tải I/O đĩa và giữ cho B-Tree Index luôn gọn gàng.
Bài 22 xem tiếp: B-Tree Index: Cấu trúc cân bằng và các quy tắc tối ưu multi-column index — chúng ta sẽ giải phẫu cấu trúc cây B+Tree cân bằng của PostgreSQL, nguyên lý Leaf Pages - High Keys, và quy tắc sống còn về thứ tự cột (Column Ordering) trong Composite Index.
All rights reserved