Quay lại bài viết
26 thg 5, 2026
13 min read

Deep Dive: PostgreSQL Storage Internals — Từ Heap File Trên Disk Đến Shared Buffer

Khi bạn chạy EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE id = 42;, PostgreSQL trả về một dòng kiểu như Buffers: shared hit=3 read=1. Con số đó nghĩa là gì? Tại sao cùng một câu UPDATE, lần này chạy 2 ms, lần khác lại 80 ms — dù bảng không hề bị lock? Tại sao sau một câu DELETE thì dung lượng file của bảng vẫn không hề giảm? Và cụ thể thì “shared buffer” mà mọi tài liệu hướng dẫn tune luôn nhắc đến — bên trong nó thực sự chứa gì?

Trả lời được những câu hỏi đó đòi hỏi bạn phải đi xuống một tầng dưới SQL: storage engine layer. Đây là bài deep dive về cách PostgreSQL tổ chức dữ liệu trên đĩa (heap files, pages, tuples) và cách dữ liệu được kéo lên RAM thông qua Shared Buffer Manager. Bài này bổ sung cho Deep Dive về Table Bloat (tập trung vào hậu quả là dead tuples và vacuum) và Deep Dive về PostgreSQL Partitioning (tập trung vào chia bảng lớn) — cả hai bài đó đứng trên những concepts mà bài này mổ xẻ.

1. Tổ chức file của một bảng trên disk

Mọi bảng trong PostgreSQL cuối cùng đều quy về một tập file trên hệ thống tệp. Khi bạn cài đặt PostgreSQL, biến môi trường PGDATA trỏ tới thư mục dữ liệu (ví dụ /var/lib/postgresql/16/main). Bên trong đó, đường dẫn của heap file một bảng có dạng:

$PGDATA/base/<database_oid>/<relfilenode>

Bạn có thể tự kiểm chứng bằng query này:

SELECT pg_relation_filepath('users');

16384 là OID của database, 24576relfilenode của bảng usersrelfilenode là tên file vật lý của 1 relation trong postgresql.

OID và relfilenode là khác nhau. Khi bạn chạy VACUUM FULL, CLUSTER, REINDEX hay TRUNCATE, PostgreSQL viết lại data sang một file mới và đổi relfilenode — nhưng oid (cách bảng được tham chiếu trong system catalog) giữ nguyên. Điều này quan trọng khi monitoring: nếu bạn cache đường dẫn file rồi 30 phút sau dùng lại, có thể nó đã trỏ tới một file đã bị xóa.

Ba forks của một relation

Một bảng không phải chỉ một fork mà tận 3 forks — Fork là một khái niệm mượn từ “file fork” trong filesystem cũ, bạn có thể hiểu đơn giản nó là file.

Khi bảng vượt quá 1 GB, main fork được chia thành nhiều segment files liên tiếp: <relfilenode>, <relfilenode>.1, <relfilenode>.2, …

Lý do lịch sử: nhiều filesystem cũ (HFS, FAT32, ext2 với block size nhỏ) có giới hạn 2 GB hoặc 4 GB cho một file. PostgreSQL giữ giới hạn segment 1 GB cho đến nay vì nó hoạt động tốt và không có lý do để thay đổi.


2. Page anatomy — đơn vị I/O 8 KB

PostgreSQL không đọc/ghi từng byte, từng tuple, hay từng row. Đơn vị I/O cơ bản là page (còn gọi là block), mặc định 8 KB. Mọi thao tác đọc từ disk hay ghi xuống disk đều xảy ra ở granularity này. Đây là lý do bài table-bloat nhấn mạnh chuyện “muốn lấy 1 tuple, phải kéo cả container 8 KB lên RAM”.

Layout của một page, từ offset 0 đến 8191:

  1. PageHeaderData (24 byte) — header chứa metadata của page:

    • pd_lsn (8 B): Log Sequence Number của WAL record gần nhất chạm vào page này. Dùng cho recovery và checkpoint coordination.
    • pd_checksum (2 B): checksum data integrity (nếu bật data_checksums).
    • pd_flags (2 B): flags như PD_HAS_FREE_LINES, PD_PAGE_FULL, PD_ALL_VISIBLE.
    • pd_lower (2 B), pd_upper (2 B): hai con trỏ định nghĩa vùng free space — pd_lower là cuối line pointer array, pd_upper là đỉnh của vùng tuple.
    • pd_special (2 B): offset đến đầu special area (chỉ index dùng).
    • pd_pagesize_version (2 B), pd_prune_xid (4 B).
  2. ItemIdData array — mảng các line pointer 4 byte. Mỗi line pointer = (offset, length, flags) trỏ tới một tuple trong cùng page. Index entries trỏ tới tuple không phải bằng offset trực tiếp mà thông qua line pointer, gọi là CTID = [block_num, line_pointer_index]. Lý do: khi VACUUM xoá dead tuples và defragmentation, các tuple sẽ bị thay đổi vị trí trong page, line pointer giúp tránh việc phải update index trỏ về các vị trí mới.

  3. Free space — khoảng giữa pd_lowerpd_upper. Đây là vùng chưa dùng. INSERT mới sẽ ăn vào vùng này từ cả hai đầu.

  4. Tuples — dữ liệu thật, xếp từ cuối page hướng lên. Mỗi tuple = HeapTupleHeader + (optional) null bitmap + user data.

  5. Special space — dành cho indexes (B-tree, GIN, GiST,…). Heap pages không dùng vùng này.

Tại sao two-direction layout?

Thiết kế “line pointers grow down, tuples grow up” có một mục tiêu cụ thể: cho phép thêm tuple mới mà không phải shift toàn bộ dữ liệu.

Khi INSERT:


3. Tuple anatomy — HeapTupleHeader

Mỗi tuple vật lý là một block byte gồm ba phần: header (23 byte + padding), null bitmap (tùy chọn), và user data (aligned).

Header chứa toàn bộ metadata mà MVCC cần để biết tuple này có visible với một transaction nào đó hay không. Đây là các field quan trọng:

Null bitmap chỉ tồn tại khi HEAP_HASNULL = 1. Mỗi cột chiếm 1 bit; bit = 0 nghĩa cột đó NULL. Cách này tiết kiệm hơn nhiều so với việc lưu riêng một byte/flag cho mỗi cột.

User data — giá trị các cột, được căn chỉnh (aligned) theo typalign của type. int8 align 8 byte, int4 align 4 byte, bool align 1 byte. Hệ quả: thứ tự cột trong CREATE TABLE ảnh hưởng đến storage. Bảng có columns (a bool, b int8, c bool) lớn hơn bảng (b int8, a bool, c bool) vì padding chèn vào để align b. Chi tiết về tối ưu này được nói kỹ ở table-bloat.

Giới hạn kích thước tuple — con số 2 KB đến từ đâu?

Mỗi tuple có giới hạn kích thước là 2KB.

Một câu hỏi thường gặp: “Vì sao lại là 2 KB, có phải hằng số cố định không?”

Câu trả lời là không. Con số đó được PostgreSQL tính ra theo công thức TOAST_TUPLE_THRESHOLD ≈ BLCKSZ / 4, tức cố nhét ít nhất 4 tuple vào mỗi page. Với BLCKSZ = 8 KB mặc định, ngưỡng rơi vào khoảng 2 KB.

Khi tuple vượt ngưỡng này, TOAST (The Oversized-Attribute Storage Technique — cơ chế nén và/hoặc đẩy các cột lớn ra một bảng phụ riêng) kích hoạt: PostgreSQL nén các cột variable-length hoặc tách chúng ra 1 bảng phụ, chỉ để lại một con trỏ inline.

Ngưỡng này có thể chỉnh ở mức bảng bằng storage parameter toast_tuple_target (từ PG 11):

ALTER TABLE my_table SET (toast_tuple_target = 4080);

4. Shared Buffer Manager — cầu nối giữa RAM và disk

Mỗi lần PostgreSQL cần một tuple, đơn vị tối thiểu mà nó đọc từ disk là một page 8 KB. Disk chậm hơn RAM khoảng năm bậc, nên việc truy disk cho mọi câu truy vấn là điều không thể chấp nhận. Lời giải là một buffer pool nằm trong shared memory, được chia thành các slot 8 KB; mỗi slot giữ đúng một page. Tham số shared_buffers (mặc định 128 MB, production thường đặt ~25% RAM) quy định tổng số slot.

Cái khó nằm ở tình huống thực tế: hàng trăm backend cùng đọc–ghi vào pool, có những page nóng được chạm hàng nghìn lần mỗi giây, có những page lạnh nằm im. Buffer manager phải trả lời ba câu hỏi đan vào nhau — tra cứu nhanh “page này đã trong pool chưa?”, chọn ai bị thay khi pool đầy, và ghi dirty page xuống disk mà không chặn transaction. Ba phần dưới đây bóc lần lượt từng câu.

Kiến trúc ba tầng

Để hỗ trợ ba câu hỏi trên, buffer manager được tổ chức thành ba thành phần song hành:

BufferTag là khóa định danh duy nhất một page trên toàn cluster, gồm năm field:

BufferTag = { spcOid, dbOid, relNumber, forkNum, blockNum }

Ví dụ {16821, 16384, 37721, 0, 7} chỉ block 7, fork main (0), của relation 37721 thuộc database 16384 trong tablespace 16821. Vì sao cần đủ năm field? Một PostgreSQL cluster có nhiều database, mỗi database có nhiều tablespace, mỗi relation có vài fork (main, FSM, VM); tag phải gói trọn ngữ cảnh đó mới đảm bảo không trùng.

Vòng đời của một page access

Bây giờ ta đi qua đúng chuỗi thao tác khi backend cần đọc một page, theo ba kịch bản tăng dần độ phức tạp. Cùng một bộ thuật ngữ — buffer_table, descriptor, slot — được dùng xuyên suốt.

Cache hit (page đã trong pool):

  1. Backend process xác định page cần đọc và tính BufferTag.
  2. Buffer manager tra buffer_id trong Buffer table với BufferTag.
  3. Buffer manager cập nhật metadata trong Buffer descriptor để đánh dấu slot này có truy cập: refcount += 1, usage_count += 1.
  4. Backend process truy cập slot thứ buffer_id trong Buffer pool thông qua Buffer manager, đọc hoặc ghi tuple.
  5. Sau khi xong, Buffer manager cập nhật lại metadata trong Buffer descriptor: refcount -= 1. Nếu vừa ghi, set BM_DIRTY = 1.

Đây là đường nhanh — mọi thao tác đều vận hành trên RAM.

Cache miss, còn slot trống:

  1. Backend process xác định page cần đọc và tính BufferTag.
  2. Buffer manager không tìm thấy buffer_id trong Buffer table.
  3. Buffer manager xác định slot còn trống trong freelist.
  4. Buffer manager insert entry (buffer_tag, buffer_id) vào buffer table.
  5. Set BM_IO_IN_PROGRESS = 1, đọc 8 KB từ disk vào slot.
  6. Set BM_IO_IN_PROGRESS = 0, BM_VALID = 1.

Sau khi tìm và nạp thành công page từ disk lên Buffer pool. Process tiếp tục được xử lý như tình huống Cache hit.

Cache miss, pool đã đầy (page replacement):

Khi tất cả các slot trong Buffer pool đã bị chiếm giữ, và page cần tìm chưa được nạp vào pool. Buffer manager cần phải quyết định page nào sẽ bị thay thế. Khi này, một thuật toán thay thế sẽ được thực thi, và page được chọn gọi là victim page.

  1. Buffer manager xác định page cần tìm chưa được nap vào Buffer pool, và không còn slot trống.
  2. Buffer manager tìm victim page thông qua thuật toán clock-sweep (mục kế tiếp).
  3. Nếu victim page đang dirty: phải flush page xuống storage trước khi tái sử dụng slot, nếu không nội dung đã modify sẽ mất.
  4. Hoán đổi entry trong buffer table: gỡ entry cũ (trỏ tới tag victim), insert entry mới (trỏ tới tag được yêu cầu) — cả hai trỏ tới cùng buffer_id.
  5. Load page mới từ disk vào slot, reset BM_DIRTY = 0, BM_VALID = 1.

Clock-sweep — chọn victim không cần global LRU lock

LRU thuần đòi hỏi mỗi truy cập phải promote slot lên đầu danh sách, mà danh sách thì chỉ có một — với hàng trăm backend cùng truy cập, lock toàn cục trên LRU list trở thành điểm nghẽn. PostgreSQL chấp nhận một xấp xỉ LRU rẻ hơn nhiều: clock-sweep.

Một con trỏ nextVictimBuffer chạy vòng tròn qua descriptor array. Mỗi vòng, nó kiểm tra descriptor hiện tại theo logic này:

loop: d = descriptors[nextVictimBuffer] if d.refcount == 0: if d.usage_count == 0: return d else: d.usage_count -= 1 nextVictimBuffer = (nextVictimBuffer + 1) mod N

Phối hợp với việc backend tăng usage_count mỗi lần chạm vào buffer, thuật toán này tạo ra hành vi mong muốn:

Chi phí trên hot path là O(1) thao tác trên một descriptor đơn lẻ, không cần global lock.

Flushing dirty pages — bgwriter, checkpointer, và backend

Khi backend ghi vào buffer, nó chỉ set BM_DIRTY rồi đi tiếp — việc đưa nội dung xuống disk là chuyện của ai khác. Có ba “ai khác”, phân vai rõ ràng:

Tóm lại, bgwriter “dọn vệ sinh” theo cấp số nhỏ và đoán trước eviction; checkpointer định kỳ “tổng vệ sinh”; backend là phương án cuối khi cả hai không kịp.


5. Kết luận

PostgreSQL storage engine có thể được học từ trên xuống (SQL → query plan → I/O) hoặc từ dưới lên (byte trong file → page → buffer → tuple → MVCC → SQL). Cách thứ hai khó hơn nhưng cho hiểu biết bền vững hơn — bạn không còn lệ thuộc vào “best practice từng nghe nói” mà có thể đọc bất cứ tài liệu nào và suy ra trade-off bằng những nguyên lý đã nắm vững.

Liên quan