8.1 資料庫如何使用磁碟#
概念說明#
資料庫儲存引擎把磁碟空間組織成「表空間(tablespace)→ 段(segment)→ 區(extent)→ 分頁」的層次,所有 I/O 以分頁為單位(InnoDB 預設 16KB),並以緩衝池(buffer pool)為中介:
- 表檔案:每張表一個(或多個)檔案,檔案被切成編號的分頁。
- 主鍵叢集索引:InnoDB 中表本身就是主鍵 B+tree,葉分頁放完整記錄(叢集組織)。
- 二級索引:另一棵 B+tree,葉分頁放「索引鍵 + 主鍵」,查詢後需回表。
- 緩衝池:記憶體中最大的區域,用改進型 LRU 管理(見 2.3 節);所有頁讀寫都經過它。
- 系統表空間 / undo / redo log:支援交易與當機恢復。
讀取一列資料的完整路徑(以主鍵查詢為例):
- SQL 層解析、優化器選擇用主鍵索引。
- B+tree 從根分頁下行:根分頁幾乎一定在緩衝池(命中率接近 100%)。
- 中間與葉分頁:熱資料在緩衝池(0 次 I/O),冷資料 1~2 次隨機 I/O。
- 葉分頁即記錄(叢集索引),直接回傳。
這條路徑解釋了兩個實務現象:熱資料查詢為什麼快如記憶體(緩衝池命中),以及為什麼資料庫伺服器的記憶體那麼重要(緩衝池命中率每降 10%,平均延遲可能翻倍)。
演算法步驟:緩衝池驅動的分頁讀取#
- 計算目標分頁編號(由索引或全表掃描決定)。
- 緩衝池查哈希表:命中 → 直接返回頁。
- 未命中 → 選犧牲頁(LRU/時鐘),髒頁先寫回,讀入新頁。
- 頁內以槽頁結構(2.2 節)定位記錄。
Python 範例:迷你儲存引擎#
import bisect, random
class Page:
def __init__(self, pid):
self.pid, self.records, self.dirty = pid, {}, False
class MiniStorageEngine:
def __init__(self, buf_size=4, disk=None):
self.buf_size = buf_size
self.buffer = {} # pid → Page(LRU 用 OrderedDict 概念)
self.lru = []
self.disk = disk if disk is not None else {}
self.io = {"r": 0, "w": 0}
self.index = [] # 簡化:主鍵有序列表(真實為 B+tree)
self.pages_of = {} # 主鍵 → pid
def _get_page(self, pid):
if pid in self.buffer:
self.lru.remove(pid); self.lru.append(pid)
return self.buffer[pid]
self.io["r"] += 1
page = Page(pid)
page.records = self.disk.get(pid, {})
if len(self.buffer) >= self.buf_size: # 驅逐 LRU
victim = self.lru.pop(0)
vpage = self.buffer.pop(victim)
if vpage.dirty:
self.io["w"] += 1
self.disk[victim] = vpage.records
self.buffer[pid] = page; self.lru.append(pid)
return page
def insert(self, key, val):
if not self.pages_of or key > self.index[-1]:
pid = (max(self.pages_of.values()) + 1) if self.pages_of else 0
else:
pid = self.pages_of[self.index[bisect.bisect_left(self.index, key)]]
page = self._get_page(pid)
page.records[key] = val; page.dirty = True
bisect.insort(self.index, key)
self.pages_of[key] = pid
def point_query(self, key):
if key not in self.pages_of: return None
page = self._get_page(self.pages_of[key])
return page.records.get(key)
eng = MiniStorageEngine(buf_size=3)
for i in range(10):
eng.insert(i, f"val{i}")
random.seed(1)
hot = [3, 4, 5]
for _ in range(20):
eng.point_query(random.choice(hot)) # 熱資料重複查
print("熱資料 20 次查詢 → 磁碟讀:", eng.io["r"], "(緩衝池命中)")
eng.point_query(9) # 冷頁可能需驅逐
print("含冷頁後 I/O:", eng.io)
複雜度與 I/O 成本分析#
- 主鍵點查詢:樹高 h 次 I/O;緩衝池命中上層時,冷葉頁 1 次、熱葉頁 0 次。
- 二級索引查詢:索引樹 h 次 + 回表 h 次,最壞 2h 次 I/O——覆蓋索引可省一半。
- 插入(自增主鍵):只碰最右葉,緩衝池友好;隨機主鍵:每筆可能觸發不同葉頁換入換出。
- 緩衝池命中率 H 時,平均每查詢 I/O ≈ h × (1−H)。
練習#
- 用 buf_size=2 重跑熱資料查詢,觀察命中率下降對 I/O 的影響,畫出「命中率 vs 緩衝池大小」。
- 說明二級索引為何需要「回表」,並解釋覆蓋索引省 I/O 的原理。
- 比較 InnoDB(叢集)與 PostgreSQL(堆檔 + 索引)在主鍵範圍掃描的 I/O 差異。