說明資料庫為何要自己管理頁面快取,而不是直接依賴作業系統的 page cache 或 。
畫出 buffer pool 的結構:frame、page table、dirty bit、pin count、置換用的元資料。
比較 LRU、Clock、LRU-K、2Q、ARC 等置換策略,並解釋「循序掃描洪水(sequential flooding)」問題與對策。
說明 dirty page 的寫回時機、WAL 規則(先寫日誌再寫頁面)與 steal/no-force 政策。
解釋 pin(釘住)與 latch 的角色,以及預讀(read-ahead)如何把隨機 I/O 變成循序 I/O。
完整回答:「執行 時,資料庫如何從 SSD 取得資料?」
為什麼需要 Buffer Pool
資料庫不可能每次存取都讀磁碟。Buffer pool(又稱 buffer cache、buffer manager)是記憶體中一塊由資料庫 自己管理的頁面快取,位於執行引擎與磁碟之間:
作業系統本來就有 page cache,為什麼資料庫還要自己做一份?因為資料庫比作業系統更了解自己的存取模式,而且有 OS 無法提供的正確性需求:
寫回順序必須受控:WAL 規則要求「日誌先落盤,資料頁才能落盤」(第 8 章)。OS page cache 會在任意時間把髒頁寫出, 資料庫無法阻止;而 的頁面被核心寫出時,資料庫甚至不知道。
置換策略需要語意:資料庫知道「這是一次性的全表掃描,別把熱頁面趕走」、「這是 B+Tree 根頁,永遠留著」;OS 只看到匿名的 4 KB 區塊。
預讀需要知識:資料庫知道 B+Tree 葉頁鏈結的下一頁是哪一頁、bitmap scan 接下來要讀哪些 heap 頁;OS 的 read-ahead 只能猜「下一個連續區塊」。
錯誤處理與可觀測性: 的 I/O 錯誤以 出現,無法乾淨地回報給交易;pin 計數、命中率等統計也只有自管快取才做得到。
Crotty、Leis 與 Pavlo 在 CIDR 2022 的這篇論文系統性地整理了 的問題:交易安全(無法控制寫回)、 I/O 停頓(page fault 不可預測)、錯誤處理、效能(TLB shootdown、單執行緒的 page eviction)。 MongoDB(MMAPv1)、LMDB、早期 InfluxDB 都踩過這些坑;多數成熟系統最終都回到自管的 buffer pool。 論文與影片:https://db.cs.cmu.edu/mmap-cidr2022/
Buffer Pool 的結構
Frame:固定大小的記憶體槽,一次放一頁。PostgreSQL 的 就是一個 frame 陣列。
Page table:從 page id 對應到 frame id 的雜湊表。PostgreSQL 把它切成 128 個分區 (),各自上鎖以降低爭用。
每個 frame 的元資料(PostgreSQL 的 ):頁面標籤(哪個關聯的哪一頁)、dirty 位元、 pin count(refcount)、usage count、I/O 進行中旗標、內容 latch。PostgreSQL 把 refcount(18 bits)、 usage count(4 bits)、旗標(12 bits) 塞進一個 64-bit 原子變數,以 CAS 操作避免加鎖。
核心操作
fetch_page(pid):
if pid in page_table: # 命中(hit)
frame = page_table[pid]; frame.pin++; replacer.record_access(frame)
return frame
frame = free_list.pop() or replacer.evict() # 未命中:找空 frame 或淘汰 victim
if frame is None: raise "no unpinned buffers available"
if frame.dirty:
wal.flush_up_to(frame.page.lsn) # WAL 規則:先刷日誌,才能寫髒頁
disk.write(frame.page_id, frame.data)
page_table.remove(frame.page_id)
disk.read(pid, frame.data) # 真正的 I/O
page_table[pid] = frame; frame.pin = 1; frame.dirty = False
return frame
unpin_page(pid, is_dirty): frame.pin--; frame.dirty |= is_dirty
flush_page(pid): wal.flush_up_to(lsn); disk.write(...); frame.dirty = False
new_page(): pid = disk.allocate(); 取一個 frame 清零並登記
Pin(釘住)告訴置換器「這頁正在被使用,不准淘汰」;它不保護內容。 Latch(PostgreSQL 稱 buffer content lock,讀寫鎖)保護頁面內容不被同時修改。 一個典型的讀取流程:pin \(\to\) 取讀 latch \(\to\) 讀取 \(\to\) 放讀 latch \(\to\) unpin。 Pin 可以持有較久(例如游標停在某頁),latch 必須極短。忘記 unpin 是 buffer pool 實作最常見的 bug——最終導致「no unpinned buffers available」。
置換策略
當沒有空 frame 時,必須選一個 victim 淘汰。理想策略(Bélády 的 OPT:淘汰最久以後才會用到的頁)需要預知未來,實務上以歷史近似:
lP6.0cmP5.2cm 策略 & 做法 & 問題 / 採用者
LRU & 維護依最近使用時間排序的串列,淘汰最久未用者 & 每次存取都要搬動串列節點(需鎖);對一次性掃描完全沒有抵抗力
Clock(second chance) & frame 排成環,每個有一個 reference bit;指針掃過時把 1 改 0、遇到 0 就淘汰 & LRU 的廉價近似;PostgreSQL 的 clock-sweep(usage count 上限 5)
LRU-K & 記錄最近 \(K\) 次存取時間,以倒數第 \(K\) 次的距離排序 & 只被存取一次的頁(掃描)永遠排在最後;BusTub 早期版本採 LRU-K
2Q / midpoint LRU & 新頁先進「試用區」,再次被存取才升入「主區」 & InnoDB:LRU 分 young/old 兩段,新頁插在 \(5/8\) 處, 防掃描污染
ARC & 同時維護「最近用過」與「頻繁用過」兩個 LRU 加上 ghost list,自適應調整兩者比例 & Megiddo & Modha (FAST 2003);CMU 15-445 Fall 2025 Project #1 採用 ARC;ZFS 使用
LFU & 依存取頻率淘汰 & 老舊熱頁難以被淘汰(需要衰減)
Clock 演算法
循序掃描洪水與對策
一次全表掃描會把整張表的頁面依序讀進來,每頁只用一次。在純 LRU 下,這會把所有真正熱門的頁面(索引根頁、常用的小表) 全部趕出去——這就是 sequential flooding。對策:
MRU:對已知的掃描改用「淘汰最近用的」。
ring buffer(環形緩衝):掃描只在一小塊私有的 frame 子集內循環重用。PostgreSQL 對大於 /4 的表的循序掃描使用 256 KB 的 ring(),/ 用 16 MB(),VACUUM 用 2 MB(可由 調整)。
優先級提示:LRU-K、2Q、ARC 天生把「只被碰過一次」的頁排在淘汰隊伍前端。
的 :先看 strategy ring 有沒有可重用的 frame, 再嘗試 free list,最後進入 clock sweep 迴圈:以原子操作讀取 ,若 frame 被 pin 就跳過, 若 就減一繼續,否則以 CAS 把它 pin 住並回傳。 定為 5—— 註解寫得很清楚:值越大越接近 LRU,但最壞要掃 6 圈才找得到 victim,所以不能太大。
Dirty Page 的寫回
被修改的頁面標記為 dirty,不會立刻寫回磁碟(write-back 而非 write-through)——這是效能的關鍵: 一頁可能被連續修改幾百次,只需寫回一次。髒頁在三種時機落盤:
被選為 victim 時(前景寫回,會拖慢查詢)。
背景寫入器(PostgreSQL 的 、InnoDB 的 page cleaner)定期掃描並寫出即將被淘汰的髒頁,讓前景少做事。
Checkpoint:把某個時間點之前的所有髒頁全部寫出,以縮短當機復原時間(第 8、11 章)。PostgreSQL 的 checkpointer 會把寫入 分散在 (預設 0.9)的時間內,避免 I/O 尖峰。
寫回任何髒頁之前,必須先確保「描述該頁修改的 WAL 記錄」已經落盤(頁面的 \(\le\) 已刷出的 WAL 位置)。 在復原理論的術語中(第 11 章):
Steal:允許把未提交交易修改過的髒頁寫回磁碟(因為 victim 可能屬於進行中的交易)\(\Rightarrow\) 需要 undo 資訊。
No-force:提交時不強制把資料頁寫回,只要 WAL 落盤即可 \(\Rightarrow\) 需要 redo 資訊。
幾乎所有高效能資料庫都是 steal + no-force,這正是 WAL 同時需要 redo 與 undo 能力的原因(PostgreSQL 以 MVCC 舊版本取代 undo,第 9 章)。
預讀與非同步 I/O
循序預讀:循序掃描時提前發出後續頁面的讀取。PostgreSQL 18 之前主要靠 OS read-ahead; 18 起引入 read stream 介面與真正的非同步 I/O(), 由資料庫自己決定「往前看幾頁」(依最近的命中/未命中歷史動態調整),並把相鄰頁面合併成最大 的單一 I/O。
索引驅動的預讀:bitmap heap scan 知道接下來要讀哪些 heap 頁, 控制同時發出的預讀數量—— 在 SSD 上可以設到 100 以上。
AIO 的另一個收穫:避免 double buffering(同一頁同時佔據 OS page cache 與 shared_buffers)。 搭配 direct I/O 時資料由 DMA 直接進 buffer pool,CPU 不必複製。InnoDB 長期以 加大 buffer pool(常設為 RAM 的 70–80%); PostgreSQL 傳統建議 約 RAM 的 25%、其餘留給 OS cache,並以 告訴最佳化器總快取量。
完整走一遍:SELECT * FROM users WHERE id = 123
解析器把 SQL 轉成查詢樹;最佳化器看到主鍵等值條件,估算 index scan 成本遠低於 seq scan(第 6 章),產生 。
執行器啟動計畫節點,呼叫 B-tree 存取方法搜尋鍵 123。
B-tree 從 metapage 取得 root 頁號,向 buffer pool 要該頁:page table 命中(root 永遠是熱頁),pin、取讀 latch、二分搜尋找到子頁號。
內部頁同樣幾乎必定命中。
葉頁可能未命中:buffer pool 以 clock sweep 選出 victim(若為 dirty 先刷 WAL 再寫回),然後 從 SSD 讀 8 KB(約 100 \(\mu\)s)。 在葉頁中找到鍵 123 的索引項,取得 TID,例如 (block 1592, slot 3)。
執行器拿 TID 向 buffer pool 要 heap 頁 1592()。這是直接定址,不需要再搜尋。
從 slot 3 取得 tuple,檢查 / 與目前快照決定可見性(第 9 章);若不可見且 指向新版本,就沿鏈追蹤。
把需要的欄位解出(可能要去 TOAST 表),unpin 兩頁,回傳結果。
總計:最佳情況 0 次 I/O,典型情況 1–2 次隨機讀,加上幾次 page table 查找與 latch 操作。 全表掃描則是 次循序讀——這個對比就是第 6 章成本模型的核心。
動手觀察:pg_buffercache
SELECT c.relname, count(*) AS buffers, sum(CASE WHEN isdirty THEN 1 ELSE 0 END) AS dirty,
round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name='shared_buffers'), 2) AS pct
FROM pg_buffercache b JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname ORDER BY buffers DESC LIMIT 10;
SELECT usagecount, count(*) FROM pg_buffercache GROUP BY 1 ORDER BY 1; -- clock-sweep 的計數分佈
-- 緩衝池命中率(整個資料庫)
SELECT blks_hit::float / NULLIF(blks_hit + blks_read, 0) AS hit_ratio FROM pg_stat_database WHERE datname = current_database();
v3 在 v1/v2 之間插入 :, 搭配可替換的 介面(先做 Clock,再做 LRU-K 或 ARC)。把 v2 的 B+Tree 改成「只透過 buffer pool 存取頁面」, 並用一個「隨機點查詢 + 一次全表掃描」的混合工作負載比較 LRU 與 Clock/LRU-K 的命中率——你會親眼看到 sequential flooding。
3 個 frame、存取序列 :分別以 LRU、Clock、LRU-2 計算未命中次數並列出每步的 frame 內容。
一張 100 萬頁的表、buffer pool 1 萬頁,熱門小表佔 500 頁。說明純 LRU 下一次全表掃描後熱門小表的命中率會發生什麼事;ring buffer 如何解決。
為什麼淘汰 dirty victim 前必須先刷 WAL?若違反這條規則,當機後會出現什麼不一致?
解釋 pin count 與 latch 的差別。如果某個 bug 在 fetch 之後忘了 unpin,系統會逐漸出現什麼症狀?
把 設為 RAM 的 90% 在 PostgreSQL 上通常不是好主意,原因為何?同樣設定在 InnoDB 上為何常見?
(動手)執行 對同一個點查詢連跑兩次,比較 與 ; 再對一張大表做 ,用 驗證它只佔用了約 256 KB 的 ring。
CMU 15-445 Fall 2025, Lecture #04 Memory Management;Project #1 Buffer Pool Manager(ARC replacer、disk scheduler、BPM): https://15445.courses.cs.cmu.edu/fall2025/project1/
A. Crotty, V. Leis, A. Pavlo, , CIDR 2022: https://db.cs.cmu.edu/mmap-cidr2022/
Hironobu Suzuki, The Internals of PostgreSQL, Ch. 8 Buffer Manager:https://www.interdb.jp/pg/pgsql08.html
PostgreSQL 原始碼 、(clock sweep、ring)、; (PostgreSQL 18 非同步 I/O 設計)。
PostgreSQL 文件 §19.4 Resource Consumption(、、) 與 F. :https://www.postgresql.org/docs/current/pgbuffercache.html
N. Megiddo & D. Modha, , FAST 2003; E. O’Neil et al., , SIGMOD 1993。
MySQL 參考手冊 Buffer Pool(midpoint insertion、young/old sublist): https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool.html