• 說明 HDD、SSD、NVMe 的存取特性,以及「循序 vs 隨機 I/O」為何主導資料庫的所有設計決策。

  • 解釋資料庫為何以固定大小的頁面(page)作為 I/O 與緩衝的基本單位,並比較各系統的頁面大小。

  • 畫出 slotted page 的結構,解釋 slot 陣列(line pointer)帶來的間接層有什麼好處。

  • 描述一筆記錄(tuple)在頁面內的佈局:標頭、NULL 位元圖、對齊填補、變長欄位與 TOAST。

  • 說明 heap file 與 free-space map 的運作方式,並完整追蹤「一列資料如何落到磁碟」。

  • 使用 擴充實際觀察 PostgreSQL 的頁面內容。

為什麼要先懂磁碟

資料庫是「以磁碟為導向(disk-oriented)」的系統:資料的主要住所是非揮發性儲存裝置, 記憶體只是暫存。因此所有資料結構的設計目標都是減少磁碟 I/O 次數,而非減少 CPU 指令數。 要理解這一點,先看各層儲存的延遲差距:

層級 典型延遲 相對於 L1 快取
L1 快取 \(\approx\) 1 ns 1\(\times\)
L2 / L3 快取 4–40 ns 10\(\times\)
DRAM \(\approx\) 100 ns 100\(\times\)
NVMe SSD(隨機 4 KB 讀) 10–100 \(\mu\)s \(10^4\)–\(10^5\times\)
SATA SSD(隨機 4 KB 讀) \(\approx\) 100 \(\mu\)s \(10^5\times\)
HDD(隨機讀,含尋道) 5–10 ms \(10^7\times\)
同一資料中心網路往返 \(\approx\) 0.5 ms \(5\times 10^5\times\)

HDD、SSD 與 NVMe

  • HDD:機械臂移動(seek,約 4–10 ms)加上碟片旋轉等待,隨機 I/O 每秒只有約 100–200 次(IOPS), 但循序讀寫可達 100–250 MB/s。「隨機比循序慢兩個數量級」是傳統 B+Tree、WAL、循序掃描設計的根源。

  • SATA SSD:無機械部件,隨機讀 IOPS 達數萬;但 NAND flash 的寫入必須先抹除(以較大的 block 為單位), 造成寫入放大(write amplification)與磨損;FTL(flash translation layer)在內部做了類似 LSM 的日誌式寫入。

  • NVMe SSD:走 PCIe 通道、多達 64K 個佇列,隨機 IOPS 達數十萬到百萬,延遲降到 10 \(\mu\)s 等級。 隨機與循序的差距縮小到數倍,使得 等成本參數需要重新調整(第 6 章)。

SSD 的「隨機」讀取仍需經過 FTL 查表、可能跨越多個 NAND die;而大區塊循序 I/O 可以合併請求、 充分利用內部平行度。此外作業系統的 read-ahead、資料庫的 I/O 合併(PostgreSQL 18 的 ) 都只對循序存取有效。因此「把相關的資料放在一起(locality)」這條原則在 SSD 時代依然成立。

區塊與頁面

作業系統以區塊(block)(通常 4 KB)為單位與儲存裝置溝通;資料庫在其上定義自己的頁面(page):

系統 預設頁面大小 備註
PostgreSQL 8 KB 編譯期常數 ,可改為 1–32 KB
MySQL / InnoDB 16 KB 可設 4–64 KB
SQLite 4 KB 可為 512 B–64 KB 的 2 的冪
Oracle 8 KB 每個 tablespace 可不同
SQL Server 8 KB 8 頁組成 64 KB 的 extent
RocksDB(SST block) 4 KB 壓縮前大小,非固定邊界

頁面是三件事的共同單位:磁碟 I/O 的單位、緩衝池配置的單位(第 4 章)、 鎖定與 WAL 記錄定址的單位(第 8 章)。頁面太小會讓 B+Tree 變高、標頭開銷比例上升; 太大則浪費記憶體與 I/O 頻寬在不需要的資料上,也增加寫入放大。

OS 區塊是 4 KB,PostgreSQL 的頁面是 8 KB;若在寫入 8 KB 頁面的過程中斷電,磁碟上可能只更新了前 4 KB, 形成一個「半新半舊」的損毀頁面,WAL 記錄的差異式更新無法在其上正確重做。 PostgreSQL 的解法是 :每次 checkpoint 後第一次修改某頁面時,把整頁影像寫進 WAL(第 8 章)。 InnoDB 則用 doublewrite buffer 達到同樣目的。

檔案組織:Heap File

資料庫把一張表存成一個或多個檔案,檔案由頁面組成。最常見的組織是 heap file: 頁面之間沒有順序,一筆記錄可以放在任何有空間的頁面。相對的還有依鍵排序的 sorted file (更新代價高,實務上以叢集索引取代)與以雜湊分桶的 hashed file。

要在 heap file 中快速找到「哪一頁還有空間」,有兩種作法:

  1. 頁面鏈結串列:標頭頁維護「有空間的頁面」與「已滿頁面」兩條串列,找空間可能需要走訪多頁。

  2. 頁面目錄(page directory):專門的目錄頁記錄每個資料頁的可用空間——PostgreSQL 的 Free Space Map 即屬此類。

每個表或索引對應資料目錄下 的檔案,超過 1 GB 時切成 、 等區段(segment)。 每個關聯還有多個分支(fork):

  • main:資料頁面本身。

  • fsm():Free Space Map,以 1 byte 記錄每頁的可用空間(精度 \(1/256\) 頁), 組織成一棵二元樹,讓「找一頁至少有 \(n\) bytes 空間」只需 \(O(\log)\) 次頁面存取。

  • vm():Visibility Map,每頁 2 bits(all-visible、all-frozen),供 VACUUM 與 index-only scan 使用(第 9 章)。

  • init:unlogged table 的初始化分支。

SELECT pg_relation_filepath('employees');          -- base/16384/16399
SELECT pg_relation_size('employees', 'main'),
       pg_relation_size('employees', 'fsm'),
       pg_relation_size('employees', 'vm');

頁面佈局:Slotted Page

資料庫頁面幾乎都採用 slotted page(又稱 slot array 或 line pointer array)佈局:

  • 標頭記錄頁面的元資料:可用空間的起訖位移、校驗和、最後修改此頁的 WAL 位置(LSN)等。

  • slot 陣列從頁首向後成長,每個 slot 存放一筆記錄的(位移、長度、狀態)。

  • 記錄從頁尾向前成長。兩者中間就是可用空間,當 追上 頁面就滿了。

外部對記錄的參照(例如索引項)使用 TID = (頁號, slot 號),而不是頁內位元組位移。 這樣當記錄被更新變長、或頁面做內部整理(compaction)而移動時,只要更新 slot 的位移, 索引完全不必改動。刪除記錄只需把 slot 標記為 unused/dead;空間可在稍後的整理中回收。 PostgreSQL 的 HOT 更新(第 9 章)更進一步把 slot 設為「重導向(redirect)」指向同頁的新版本。

PostgreSQL 的頁面標頭固定 24 bytes():

llP7.2cm 欄位 & 大小 & 意義
& 8 B & 最後一筆修改此頁的 WAL 記錄結尾 LSN(WAL 規則與復原的關鍵,第 8 章)
& 2 B & 頁面校驗和( 或 啟用)
& 2 B & 旗標:有可用 slot、全頁可見(all-visible)等
& 2 B & slot 陣列結尾位移(可用空間起點)
& 2 B & 可用空間結尾位移(tuple 區起點)
& 2 B & 特殊區起點(索引頁用來放兄弟指標等;heap 頁為頁尾)
& 2 B & 頁面大小與版本(目前為 4)
& 4 B & 頁內最舊未被修剪的 XMAX,提示是否值得做 HOT 修剪

每個 slot()只有 4 bytes:15 bits 位移 、2 bits 狀態 ( / / / )、15 bits 長度 。 索引頁的 special space 則放 :左右兄弟頁號、層級、旗標。

記錄佈局

一筆記錄在頁面內的典型結構為:記錄標頭 \(\to\) NULL 位元圖 \(\to\) 對齊填補 \(\to\) 欄位資料。

  • 定長欄位(、、)直接依型別長度存放。

  • 變長欄位(、、、)以長度前綴(PostgreSQL 稱 varlena 標頭,1 或 4 bytes)帶出實際內容。

  • NULL 不佔資料空間,只在 NULL 位元圖中設一個 bit;若整列沒有 NULL,位元圖可以省略。

  • 對齊:為了讓 CPU 直接讀取,8-byte 型別通常要對齊到 8 的倍數(PostgreSQL 的 ), 因此欄位順序會影響填補量—— 比 多浪費 14 bytes。

typedef struct HeapTupleFields {
    TransactionId t_xmin;        /* 插入此版本的交易 ID */
    TransactionId t_xmax;        /* 刪除或鎖定此版本的交易 ID(0 表示仍有效) */
    union {
        CommandId     t_cid;     /* 交易內的命令序號 */
        TransactionId t_xvac;    /* 舊式 VACUUM FULL 使用 */
    } t_field3;
} HeapTupleFields;

struct HeapTupleHeaderData {
    union { HeapTupleFields t_heap; DatumTupleFields t_datum; } t_choice;
    ItemPointerData t_ctid;      /* 此版本或更新版本的 TID (block, offset) */
    uint16  t_infomask2;         /* 欄位數 + 旗標 */
    uint16  t_infomask;          /* 旗標:有 NULL、有變長欄、xmin/xmax 已提交提示位元… */
    uint8   t_hoff;              /* 使用者資料起始位移(含 NULL 位元圖與對齊) */
    /* ^ 以上固定 23 bytes ^ */
    bits8   t_bits[FLEXIBLE_ARRAY_MEMBER];   /* NULL 位元圖(變長) */
    /* 之後接欄位資料 */
};

標頭中的 、、 正是 MVCC(第 9 章)的基礎——PostgreSQL 把版本資訊直接放在每一列裡, 這是它與 InnoDB(版本放在 undo log)最根本的差異之一。

一筆 tuple 不能跨頁,而 8 KB 的頁面至少要放得下 4 筆( 約 2 KB)。 超過時 PostgreSQL 會依欄位的 storage 策略( / / / ): 先嘗試以 pglz 或 LZ4 壓縮,仍太大就切成約 2 KB 的 chunk 存到關聯的 TOAST 表 (),原欄位只留下 18 bytes 的指標。這就是為什麼 比 貴得多——大欄位需要額外去 TOAST 表取回並解壓。

可用空間管理與插入流程

當執行 時,儲存引擎需要回答「這筆記錄該放哪一頁?」:

PostgreSQL 的 ()流程:

  1. 先試上次插入的那一頁(有空間的機率高,且維持局部性)。

  2. 否則查 FSM 找一頁可用空間 \(\geq\) tuple 長度(加上 預留的空間)。

  3. 找不到就延伸檔案一頁(並在 FSM 登記)。

  4. 透過緩衝池取得頁面並加排他鎖,呼叫 寫入 slot 與資料,更新 /。

  5. 把頁面標為 dirty,寫出 WAL 記錄(),把頁面的 設為該記錄的 LSN。

  6. 此時資料頁還在記憶體;它會在 checkpoint、background writer 或緩衝池淘汰時才真正寫回磁碟(第 4、8 章)。

讓 INSERT 只填到頁面的 70%,剩下 30% 保留給同頁的 UPDATE 新版本, 提高 HOT 更新的機率(第 9 章)、減少索引膨脹。對更新頻繁的表非常有效;對只插入不更新的表則純屬浪費。

其他儲存模型:NSM、DSM 與 PAX

本章描述的「一列的所有欄位連續存放」稱為 NSM(N-ary Storage Model,列式)。 另外兩種模型會在第 13 章深入:

  • DSM(Decomposition Storage Model,行式):每個欄位獨立成檔,OLAP 掃描只讀需要的欄位。

  • PAX(Partition Attributes Across,Ailamaki et al. 2001):頁面層級仍是列式(一頁內含整列), 但頁內依欄位分組存放(mini-page),兼顧 I/O 局部性與 CPU 快取效率。Parquet、ORC 的 row group 正是這種思路。

動手觀察:pageinspect

CREATE TABLE t (id int, name text);
INSERT INTO t SELECT g, 'name_' || g FROM generate_series(1, 5) g;

-- 頁面標頭
SELECT lsn, checksum, flags, lower, upper, special, pagesize, version, prune_xid
FROM   page_header(get_raw_page('t', 0));
--   lsn    | lower | upper | special | pagesize
-- 0/1A3F2B8|   44  |  8032 |  8192   |  8192      -- 24 B 標頭 + 5 個 4 B slot = 44

-- 每個 slot 與 tuple 標頭
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid, t_infomask2, t_hoff
FROM   heap_page_items(get_raw_page('t', 0));
--  lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid | t_hoff
--   1 |   8160 |     32 |   7421 |      0 | (0,1)  |   24
--   2 |   8128 |     32 |   7421 |      0 | (0,2)  |   24

-- 每一列的實體位置 (block, slot)
SELECT ctid, * FROM t;

觀察重點:tuple 從 8160 往前排( 遞減);=24 表示 23 bytes 標頭對齊到 24; 指向自己,表示這是最新版本。執行一次 再查, 會看到舊版本的 被填上、 指向新版本的 slot——這就是第 9 章的起點。

MiniDB v1 需要三個類別:(以 / 讀寫第 \(n\) 個 4 KB 或 8 KB 頁面)、 (slotted page:標頭 + slot 陣列 + 從尾端成長的 tuple 區)、 (走訪所有頁面、以簡單的「最後一頁優先」策略插入)。 記錄序列化採「定長欄位直接複製、變長欄位以 4-byte 長度前綴」即可。第 19 章給出介面與測試建議。

  1. 一個 8 KB 的 PostgreSQL 頁面最多能放多少筆「只有一個 欄位」的 tuple? (提示:標頭 23 bytes 對齊到 24,資料 4 bytes 對齊到 8,每筆加一個 4 bytes slot,頁標頭 24 bytes。)

  2. 設計表 ,計算以此順序與以最佳順序存放時每列的填補 bytes。

  3. 解釋為何索引項存 TID 而非頁內位移;如果沒有 slot 間接層, 一筆變長記錄會引發什麼連鎖更新?

  4. 比較「頁面鏈結串列」與「頁面目錄」兩種 heap file 空間管理策略在 (a) 插入 (b) 掃描 (c) 並行寫入下的優劣。

  5. (動手)建立含 欄位的表,插入一筆 100 KB 的字串,用 與 找到 TOAST 表並查看 chunk 數量。

  6. (動手)以 與預設值各建一張表,執行相同的 UPDATE 工作負載後,比較 的 與表大小。