Buffer Pool 與頁面管理#

InnoDB 的所有資料與索引最終都落在磁碟上,但每次查詢都讀磁碟是不可接受的。Buffer Pool(緩衝池) 是 InnoDB 在記憶體中為磁碟頁建立的快取,是決定資料庫吞吐量的最關鍵記憶體結構。本章聚焦於「頁如何進出記憶體、髒頁何時刷盤、為什麼這樣設計」,與 03 事務與鎖 中的 MVCC、04 日誌系統 的 redo/undo 互相銜接。

記憶體階層:資料離 CPU 有多遠#

一次資料存取要穿過數層快取,每往下一層延遲就高出數個量級:

層級典型延遲容量量級由誰管理
CPU L1/L2/L3 快取~1–20 nsMB硬體
主記憶體(DRAM)~100 nsGB作業系統 + 行程
Buffer Pool~100 ns(命中)GBInnoDB 自行管理
OS Page Cache~100 ns(命中)GB作業系統檔案系統
SSD 隨機讀~100 µsTB硬體
機械硬碟隨機讀~10 msTB硬體

記憶體與磁碟的延遲差距約為 $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_DIRECTinnodb_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 的規避。所有調參——大小、執行個體數、刷盤節奏、防污染——本質都在回答同一個問題:如何讓最該留在記憶體的頁留得更久。