0

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:

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

  2. 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 ItemId 4-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_lower tăng dần).

  3. 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_upper giảm dần).
  4. Free Space (Khoảng trống khả dụng):

    • Khoảng trống nằm giữa pd_lower và pd_upper. Khi pd_lower≥pd_upper\text{pd\_lower} \ge \text{pd\_upper}, trang được coi là đã đầy và PostgreSQL buộc phải cấp phát một Page mới.

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.

ctid=(block_number,item_offset)\text{ctid} = (\text{block\_number}, \text{item\_offset})

  • 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 ra ctid = (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ệnh INSERT dòng này.

  • t_xmax: Mặc định là 0. Nếu dòng này bị DELETE hoặc UPDATE, t_xmax sẽ được ghi đè bằng ID của Transaction đã xóa/sửa nó.

  • t_ctid: Nếu dòng bị UPDATE, t_ctid củ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 space hiện tại = 8112−32=8080 bytes8112 - 32 = 8080\text{ bytes}.

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:

  1. Đánh dấu dòng cũ (0, 1) là Dead Tuple (bằng cách ghi transaction ID vào t_xmax).

  2. 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 UPDATE xả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:

    1. Tìm một Page khác còn chỗ (hoặc cấp phát Page mới toanh ở cuối file).

    2. Ghi Tuple mới sang Page khác đó (ví dụ từ Page 0 nhảy sang Page 50).

    3. 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.

    4. 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à:

  1. Giá trị mới vẫn nhét vừa vào chính Data Page đó (nhờ có khoảng trống do fillfactor chừa lại).

  2. 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 VACUUM toà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ỏ ctid gồ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.

  • UPDATE thực chất là DELETE bản ghi cũ và INSERT bản ghi mới.

  • Cấu hình fillfactor hợ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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí