Buffer Pool 與頁面管理#
InnoDB 的所有資料與索引最終都落在磁碟上,但每次查詢都讀磁碟是不可接受的。Buffer Pool(緩衝池) 是 InnoDB 在記憶體中為磁碟頁建立的快取,是決定資料庫吞吐量的最關鍵記憶體結構。本章聚焦於「頁如何進出記憶體、髒頁何時刷盤、為什麼這樣設計」,與 03 事務與鎖 中的 MVCC、04 日誌系統 的 redo/undo 互相銜接。
記憶體階層:資料離 CPU 有多遠#
一次資料存取要穿過數層快取,每往下一層延遲就高出數個量級:
| 層級 | 典型延遲 | 容量量級 | 由誰管理 |
|---|---|---|---|
| CPU L1/L2/L3 快取 | ~1–20 ns | MB | 硬體 |
| 主記憶體(DRAM) | ~100 ns | GB | 作業系統 + 行程 |
| Buffer Pool | ~100 ns(命中) | GB | InnoDB 自行管理 |
| OS Page Cache | ~100 ns(命中) | GB | 作業系統檔案系統 |
| SSD 隨機讀 | ~100 µs | TB | 硬體 |
| 機械硬碟隨機讀 | ~10 ms | TB | 硬體 |
記憶體與磁碟的延遲差距約為 $10^5$ 倍。若一個查詢需要讀 100 個頁,全部命中 Buffer Pool 約需 $100 \times 100,\text{ns} = 10,\mu s$;全部 miss 落到機械硬碟則是 $100 \times 10,\text{ms} = 1,\text{s}$。命中率每掉一個百分點,尾延遲就被磁碟主宰。
Buffer Pool 與 OS Page Cache 是兩層獨立快取。InnoDB 用
O_DIRECT(innodb_flush_method=O_DIRECT)繞過 Page Cache,避免「同一份資料在記憶體被快取兩次」的雙重緩衝(double buffering)浪費。因此實務上應把記憶體預算優先給 Buffer Pool,而非寄望 Page Cache。
16KB 頁:InnoDB 的 IO 與快取單位#
InnoDB 不以「列」而以頁(page,預設 16KB) 作為磁碟 IO 與記憶體管理的最小單位:
- 讀一列資料時,InnoDB 會把該列所在的整個 16KB 頁讀進 Buffer Pool。
- 同一頁內的相鄰列因此「免費」被快取,這正是 02 索引 中 B+ 樹「順序掃描友善」特性的物理基礎。
- 頁大小由
innodb_page_size決定(4K/8K/16K/32K/64K),初始化後不可更改。
頁太大則一次 IO 浪費頻寬、Buffer Pool 容納的頁變少;頁太小則樹高增加、每次查詢要讀更多頁。16KB 是讀寫放大與樹高之間的折衷。
LRU 改良:young / old 雙鏈表#
最直覺的快取淘汰策略是 LRU(Least Recently Used),但樸素 LRU 在資料庫場景會被全表掃描污染:一次 SELECT * FROM big_table 把整張冷表的頁灌進來,會把真正的熱點頁全部擠出記憶體。
InnoDB 把 LRU 鏈表切成兩段來防污染:
flowchart LR
subgraph LRU["Buffer Pool LRU 鏈表"]
direction LR
subgraph Young["young 區 (約 5/8)"]
Y1["熱點頁<br/>(最常存取)"]
end
subgraph Old["old 區 (約 3/8)"]
O1["新讀入的頁<br/>(插入點)"]
end
Young --> Old
end
Disk[(磁碟)] -->|讀入| O1
O1 -.->|停留 > 1s 且再次存取.-> Y1
Old -->|尾端淘汰| Evict([淘汰])
style Young fill:#c8e6c9
style Old fill:#fff3e0
style Evict fill:#ffcdd2關鍵設計:
- 新讀入的頁不是插在鏈表頭,而是插在 old 區的頭部(整條鏈約 3/8 處,由
innodb_old_blocks_pct控制,預設 37)。 - 頁要從 old 區「升級」到 young 區,必須滿足:首次讀入後超過
innodb_old_blocks_time(預設 1000ms)才再次被存取。 - 全表掃描時,一頁通常在 1 秒內被讀完就不再碰,永遠停在 old 區,很快從尾端淘汰,碰不到 young 區的熱點頁。
innodb_old_blocks_time的時間窗是防污染的精髓:全表掃描對單一頁的多次存取都發生在毫秒級的連續掃描內,落在時間窗內因此不觸發升級。把它設為 0 會退化回樸素 LRU。
髒頁刷盤:何時、刷多少#
當頁在記憶體中被修改(且尚未寫回磁碟),它就是髒頁(dirty page)。修改先寫 redo log 保證持久性(見 04 日誌系統),髒頁則在背景非同步刷回磁碟。刷盤(flush)由以下時機觸發:
| 觸發時機 | 說明 | 對前台影響 |
|---|---|---|
| redo log 空間將滿 | 推進 checkpoint,必須刷掉對應髒頁,否則 redo 無法循環覆寫 | 嚴重,會抖動 |
| 髒頁比例過高 | 超過 innodb_max_dirty_pages_pct(預設 90%)開始加速刷盤 | 中等 |
| Buffer Pool 空間不足 | 淘汰的剛好是髒頁,必須先刷盤才能釋放 | 中等 |
| 系統空閒 / 正常關機 | 背景趁機刷,或關機前全部刷 | 無 |
刷盤速度由 innodb_io_capacity 設定(應貼近磁碟真實 IOPS,SSD 可設數千)。InnoDB 用兩個維度決定當下刷多快:
$$ \text{刷盤速度} = \text{innodb_io_capacity} \times \max\left(F(\text{髒頁比例}),; G(\text{redo log 寫入速度})\right) $$
把
innodb_io_capacity設得遠低於磁碟實際能力(例如 SSD 上仍用預設 200)是常見的隱性效能殺手:InnoDB 誤以為磁碟很慢而刷得太保守,髒頁越積越多,最終被迫在 redo 滿時集中刷盤,造成查詢週期性卡頓。
關聯參數 innodb_flush_neighbors:機械硬碟時設 1,刷某髒頁時順帶刷相鄰髒頁,把隨機寫合併成順序寫;SSD 無尋道成本,應設 0。
多執行個體:拆鎖以提升並行#
整個 Buffer Pool 由一把互斥鎖保護其鏈表結構,在高並行下會成為瓶頸。innodb_buffer_pool_instances 把 Buffer Pool 切成多個獨立執行個體,每個有自己的 LRU、flush list 與鎖:
- 每個頁依其位置雜湊到固定執行個體,鎖競爭被分散。
- 僅當
innodb_buffer_pool_size≥ 1GB 時才有意義;每個執行個體不應小於 1GB,否則管理開銷反而得不償失。 - 例如 16GB 緩衝池配 8 個執行個體,每個 2GB。
預熱:重啟後的冷啟動問題#
資料庫重啟後 Buffer Pool 是空的,所有查詢都 miss 落磁碟,命中率要數十分鐘才爬回來——這段期間服務形同半癱。InnoDB 提供 dump/load 機制把「熱點頁清單」持久化:
-- 開啟關機自動 dump、啟動自動 load(建議生產環境常開)
SET GLOBAL innodb_buffer_pool_dump_at_shutdown = ON;
SET GLOBAL innodb_buffer_pool_load_at_startup = ON;
-- 只 dump 最熱的 N%,避免 dump 過大
SET GLOBAL innodb_buffer_pool_dump_pct = 25;
-- 手動立即 dump / load(例如切換主庫前先預熱備庫)
SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;dump 出的是頁的 ID 清單(space_id + page_no)而非頁內容,檔案很小(
ib_buffer_pool)。load 時 InnoDB 依清單把這些頁重新從磁碟讀入,因此預熱仍需付出一次磁碟讀取,但變成可預測的順序預載,遠優於上線後被真實流量隨機打散。
監控指標#
核心是命中率與髒頁狀態。命中率定義:
$$ \text{命中率} = 1 - \frac{\text{Innodb_buffer_pool_reads}}{\text{Innodb_buffer_pool_read_requests}} $$
其中 read_requests 是邏輯讀(向 Buffer Pool 要頁的次數),reads 是實體讀(不得不去磁碟讀的次數)。穩態下命中率應 ≥ 99%。
-- 命中率與實體讀
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 詳細狀態:總頁數、空閒頁、髒頁、young/old 命中等
SELECT POOL_ID, POOL_SIZE, FREE_BUFFERS, DATABASE_PAGES,
MODIFIED_DATABASE_PAGES AS dirty_pages,
HIT_RATE, PAGES_MADE_YOUNG, PAGES_NOT_MADE_YOUNG
FROM information_schema.INNODB_BUFFER_POOL_STATS;如何判讀這些指標
- 命中率持續 < 99%:Buffer Pool 太小,或有大量全表掃描污染。先查慢查詢(見 06 效能調優),再考慮擴大
innodb_buffer_pool_size。 dirty_pages / DATABASE_PAGES長期貼近innodb_max_dirty_pages_pct:寫入壓力超過刷盤能力,檢查innodb_io_capacity是否被低估、磁碟是否到頂。PAGES_MADE_YOUNG異常高:可能是 young 區太小或熱點集中,調innodb_old_blocks_pct。Innodb_buffer_pool_wait_free> 0:曾經因為沒有乾淨頁可用而等待刷盤,是緩衝池過小或刷盤過慢的紅旗。
本章小結#
| 主題 | 要點 |
|---|---|
| 記憶體階層 | 記憶體比磁碟快約 $10^5$ 倍,命中率主宰尾延遲 |
| 16KB 頁 | IO 與快取的最小單位,平衡讀寫放大與樹高 |
| young/old | 雙鏈表 + 時間窗防止全表掃描污染熱點 |
| 髒頁刷盤 | 由 redo 空間、髒頁比例、空間不足觸發;io_capacity 是關鍵 |
| 多執行個體 | 拆鎖提升並行,≥1GB 才啟用 |
| 預熱 | dump/load 頁清單,避免重啟後冷啟動半癱 |
| 監控 | 命中率 ≥ 99%、盯髒頁比例與 wait_free |
Buffer Pool 的設計哲學貫穿一條主線:用可控的記憶體換取對隨機磁碟 IO 的規避。所有調參——大小、執行個體數、刷盤節奏、防污染——本質都在回答同一個問題:如何讓最該留在記憶體的頁留得更久。