Phase 04 — Database Internals(Day 31–40)

100 Day Engineer Challenge

Phase 04 — Database Internals(Day 31–40)

這份逐日教材依循內部規劃文件的範圍與排序展開1。

本檔案由兩個任務接力完成:本段(T-025)涵蓋 Day 31–35:SQL 執行流程總覽 / Page / Buffer / B-Tree;Day 36–40(Index / Query Planner /Transactions)由 T-026 接續寫在本檔案後半段,不另開新檔。

Day 31–35 的順序依 prerequisite chain2:Page 排第一,因為它是「為什麼資料庫不對每筆資料各自做一次 disk I/O」這個問題的答案,是整個 Storage 層存在的物理前提——沒有 Page 的概念,後面的 Buffer、B-Tree 都只是憑空出現的名詞;Buffer 排在 Page 之後,因為 Buffer Pool 的定義建立在「Page 已經是讀寫的最小單位」之上(把哪些 Page 留在記憶體才有意義);B-Tree 排在最後,因為 B-Tree 的 node 大小之所以被設計成貼齊一個 Page,正是為了一次 disk read 就能讀進一整個 node——沒有 Page/Buffer 的前提,B-Tree 只是一種排序樹,講不出本章的核心問題「為什麼 B-Tree 適合 database index」。SQL 執行流程(Parser→Planner→Executor→Storage)放在 Day 31 最前面,作為整個 Phase 04 的地圖:先讓讀者知道「一段 SQL 從打出來到拿到結果,會經過哪幾層」,後面每一天談的 Page/Buffer/B-Tree 都是在講 Storage 這一層內部發生的事,避免讀者只見樹木不見森林。

每個主題依分類標準要求的 5 段式內容撰寫3(Why / Mechanism / Trade-off / Failure mode / Backend 連結),Core Fundamentals 額外附一則陌生題示範。每天的 DoD 依「Database 的特殊驗收」4:Query → EXPLAIN → Hypothesis → Change → Benchmark → Conclusion,每天附一個具體的 PostgreSQL SQL/EXPLAIN 範例,而不是用 Phase 01/02/03 的其他驗收格式——這是 Database 這個 Phase 特有的驗收方式。

Day 31 — SQL 執行流程總覽 + Page:為什麼資料庫需要「頁」這個單位

學習目標

看完今天內容後,能夠:

  1. 講出一段 SQL 從送出到拿到結果,依序經過 Parser / Planner /Executor / Storage 哪四層,每一層各自負責什麼、失敗會是什麼樣子。
  2. 解釋 Page 存在的物理原因(disk 是 block device),並推導出「為什麼不能讓每一筆資料的讀寫都各自觸發一次獨立的 disk operation」。
  3. 給定一個 Page 大小與一筆資料的大小,算出一個 Page 大約能塞幾筆資料,並判斷這對讀寫效率的影響。

教材大綱

1. SQL Execution Flow(Parser → Planner → Executor → Storage)— Awareness Topic(目標 Level 2)

What:一段 SQL 文字進到資料庫後,依序被四層元件處理:Parser把 SQL 文字解析成語法樹(確認語法正確、把 SELECT ... FROM ... WHERE... 拆解成可操作的結構);Planner(也叫 Query Planner/Optimizer)根據 table 的統計資訊(列數、column 分布)決定「用哪一種方式執行這個查詢最省成本」,產出一份執行計畫(例如「先對 users 做 Index Scan 再跟 orders 做 Nested Loop Join」);Executor 照著這份執行計畫實際跑,向下一層要資料、做 filter/join/sort 等運算;Storage 是最底層,負責把資料實際讀進記憶體或寫回磁碟——也就是接下來 Day 31–40 要展開的 Page/Buffer/B-Tree/Index 都活在這一層。

Why

如果沒有這四層分工,每次「換一種查詢方式」都要改寫應用程式碼——而有了 Planner 這一層,同一句 SELECT * FROM orders WHERE user_id = 1,資料庫可以依照目前有沒有 index、orders 有多少筆資料,自己選擇用 Index Scan 或 Sequential Scan,應用層完全不需要知道底層怎麼做,這正是「宣告式」查詢語言(你說要什麼,不用說怎麼做)的核心價值。

Mechanism

用一個具體例子走一遍四層:SELECT name FROM users WHERE id = 42;。Parser 先確認語法合法,產生語法樹(SELECT 目標欄位 name,來源 table users,過濾條件 id = 42)。Planner 檢查users.id 有沒有 index(若 id 是 primary key,通常自動有 B-Tree index),評估「用這個 index 找 1 筆資料」比「掃過整張表找 1 筆資料」成本低很多,於是產出計畫「Index Scan using users_pkey on users」。Executor 依計畫向 Storage 層要「id = 42 這個 key 對應的 page」。Storage 層先查 Buffer Pool 裡有沒有這個 page(Day 32 展開),沒有的話才真的觸發一次 disk I/O 把 page 讀進記憶體,Executor 從讀進來的 page 裡取出 name 欄位的值回傳。

Trade-off

這四層分工的代價是「多一層,就多一次可能出錯或變慢的地方」——Planner 的判斷不是永遠正確:如果 table 的統計資訊過時(例如剛大量刪除資料但沒有更新統計),Planner 可能誤判成本,選出一個實際上更慢的計畫(這也是為什麼 EXPLAIN ANALYZE 要跑「實際執行」而不能只看 Planner 的估計,見 T-026 Query Planner 那幾天)。換來的好處是應用層的查詢邏輯與底層儲存/索引策略完全解耦,可以在不改一行應用程式碼的情況下,換 index、調整統計,讓同一句 SQL 變快。

Failure Modes:最常見的是把「SQL 語法正確」跟「SQL 執行方式正確」混為一談——一句語法完全合法的 SELECT 也可能因為 Planner 選錯計畫(例如該用 Index Scan 卻用了 Sequential Scan)而在大表上跑到逾時;這種錯誤 Parser 抓不出來,因為語法沒有問題,只有 Planner 產出的執行計畫(用 EXPLAIN 才看得到)才會暴露問題,這正是後面幾天要建立「不能只看 SQL 文字本身」這個習慣的原因。

Backend Applications:Backend 工程師平常寫的 ORM(例如 Go 的sqlc/gorm)本質上是「幫你把程式碼組成 SQL 文字」,但 ORM 完全不介入 Parser 之後的流程——一句透過 ORM 產生的 SQL,一樣要經過 Planner 決定計畫、Executor 執行、Storage 層存取。這代表「用了 ORM」不等於「不用管資料庫內部怎麼跑」,效能問題(N+1 query、缺 index)一樣要靠理解這四層才能診斷。

2

Page

Core FundamentalsLv.4

What:Page 是資料庫在磁碟與記憶體之間讀寫的最小固定大小單位,PostgreSQL 預設是 8KB。不管你只想讀 1 個欄位還是整張表,資料庫底層的 I/O 永遠是「以 page 為單位」讀寫——要拿到某一筆資料的某個欄位,至少要把該筆資料所在的整個 page(8KB)讀進記憶體,不會有「只讀 8 bytes」這種操作。

Why

磁碟(不管是傳統硬碟的旋轉盤片還是 SSD 的 flash 晶片)是block device——硬體層面本身就是以固定大小的區塊為單位讀寫,這是物理限制,不是資料庫自己發明的規則。如果資料庫允許「這筆資料佔用 37 bytes,就只讀寫這 37 bytes」,實際上硬體仍然得讀寫它所在的整個 block,資料庫等於是在假裝一個硬體不支援的能力;與其假裝,不如資料庫自己也用固定大小的 Page 對齊硬體的 block,一次 I/O 讀進一整個 Page,把「這個 Page 裡有哪些筆資料」的管理留在記憶體裡做,這樣才能把後續同一個 Page 裡其他筆資料的存取,變成純記憶體操作而不必再觸發一次 I/O。

Mechanism

一個 Page 內部通常包含:page header(記錄這個 page 的 metadata,例如有多少筆資料、空閒空間位置)、多筆 row 資料(依序往下塞)、以及一個從 page 尾端往前長的 item pointer 陣列(記錄每筆 row 在這個 page 裡的位置,讓你可以用「第幾筆」快速定位,不用把整個 page 內容全部解析一遍)。假設一筆 users 資料(id + name + email,共約 100 bytes)要塞進 8KB 的 Page,扣掉約 200 bytes 的 page header/item pointer 開銷,一個 Page 大約能塞 (8192-200)/100 ≈ 79筆資料——這代表讀 1 筆資料進記憶體,其實同時多帶了另外 78 筆的「順路」成本,這正是後面 Buffer(Day 32)要把整個 page 留在記憶體、而不是讀完立刻丟掉的原因之一:既然多帶了 78 筆的成本已經付出,就該把這些「順路」拿到的資料留著備用。

Trade-off

Page 大小是一個固定的權衡——Page 越大,一次 I/O 能「順路」帶到的資料越多,對「循序讀取大量連續資料」(例如全表掃描)越有利,但如果你只需要 page 裡的其中一小筆資料,浪費的頻寬也越多,且 Buffer Pool(Day 32)要用更多記憶體才能快取同樣筆數的資料;Page 越小,浪費的頻寬越少,但管理開銷(page header 佔比、需要更多次 I/O 才能讀完同樣總量的資料)會上升。PostgreSQL 選擇 8KB 是在現代硬碟/SSD 的區塊大小與一般 OLTP 應用「一次操作通常只碰少數幾筆相關資料」之間取得的折衷值,不是理論上唯一正確的數字。

Failure Modes:常見誤解是以為「資料庫可以只讀我要的那幾個 bytes,跳過其他部分」,因此以為「SELECT 少一點欄位就能省 I/O」——實際上只要那筆資料所在的 page 需要被讀,整個 8KB 的 page 都會被讀進記憶體,SELECT id 跟 SELECT *(同一筆 row)的 I/O 成本幾乎相同(差別只在 Executor 從記憶體裡的 page 取出欄位時,處理的資料量不同,但那已經是記憶體操作而不是 I/O 操作);真正能省 I/O 的是「少讀幾個 page」,這才是 Index(T-026)與 Covering Index 存在的意義——用另一棵更小的 B-Tree,只存查詢真正需要的欄位,讓一次查詢碰到的 page 數量下降。

Backend Applications:Backend 工程師設計 table schema 時,「一筆 row 有多寬(多少 bytes)」直接影響「一個 Page 能塞幾筆」,進而影響「掃描同樣筆數需要讀幾個 page」。把一個很少被查詢、但很肥大的欄位(例如一段 JSON blob 或長文字)跟其他常查欄位放在同一張表、同一個 row 裡,會讓每個 page 能塞的「常查欄位」筆數變少,即使那個大欄位從來不在 WHERE/SELECT 裡出現,也會拖慢其他欄位的掃描效率——這是實務上把「冷欄位」拆到另一張表(或用 PostgreSQL 的 TOAST 機制對超大欄位另外存放)的直接理由。

陌生題示範:情境——一張 events 表,每筆 row 平均 200 bytes,目前有 4,000,000 筆資料,做一次不用 index 的全表掃描(SELECT count(*) FROM events WHERE payload LIKE '%error%',payload 沒有 index,非得整表掃過一遍不可)。請推導這次掃描大約要讀幾個 Page。推導:可用 Page 空間約 8192 - 200(header/pointer 開銷) ≈ 7992bytes,每個 Page 能塞 7992 / 200 ≈ 39 筆;總共需要的 Page 數4,000,000 / 39 ≈ 102,564 個 page;若磁碟一次隨機 I/O 約 5ms(機械硬碟量級,SSD 會快很多但仍非 0),且 page 之間若不連續則接近隨機 I/O,理論上界約 102,564 × 5ms ≈ 512.8 秒——這個數字說明了「全表掃描的成本跟資料筆數與 row 大小直接相關,而且是 page 數量而不是 row 數量在決定 I/O 次數」,也解釋了為什麼 Day 33–35 要學的 B-Tree(能把「找到符合條件的資料」從「碰到幾乎所有 page」降到「碰到 log 量級的 page」)對這種查詢有數量級的改善空間。

Day 31 過關標準(DoD)—— Database 特殊驗收格式

``text Query: SELECT * FROM users WHERE id = 42;EXPLAIN: 對這句在你本機(或線上)跑得動的 PostgreSQL 執行 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE id = 42;記錄輸出的第一行計畫節點(例如 "Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=...) (actual time=0.02..0.03 rows=1 loops=1)" 或 "Seq Scan on users ...")。Hypothesis: 依 Day 31 教材,若 id 是 primary key(有 B-Tree index),預期 Planner 選 Index Scan;若 users 表很小(例如 <幾百筆),Planner 可能反而選 Seq Scan(因為表太小,Index Scan 的額外開銷不划算)——寫下你對「這句會走哪種掃描」的預測與理由。Change: 若實際輸出與預測不同,找出原因(通常是表太小,或 users.id 其實沒有 index);若你的環境沒有 users 表,改用任何一張有 primary key 且筆數 > 1000 的表重做一次。Benchmark: 記錄 EXPLAIN ANALYZE 輸出的 "actual time" 數值。Conclusion: 用自己的話寫一段,對照今天教材解釋「這個計畫節點對應 Parser→Planner→Executor→Storage 中的哪一層決策」,以及如果這張表的 row 變寬一倍(例如新增很多欄位),Page 這一層的行為預期會怎麼變(提示:同一個 page 能塞的筆數變少,總 page 數變多)。``

  1. 依 00-master-curriculum.md 第 8 章與 00-knowledge-dependency-graph.md 第 2.4 節
  2. 依 00-knowledge-dependency-graph.md §2.4 的 prerequisite chain 定義
  3. 依 curriculum/content-standard.md 要求的 5 段式內容
  4. 依 00-master-curriculum.md 第 24 節「Database 的特殊驗收」

Day 32 — Buffer:把熱門的 Page 留在記憶體

學習目標

看完今天內容後,能夠:

  1. 講出 Memory / Disk / Buffer Pool / Cache 四者的關係,解釋 Buffer Pool 為什麼建立在「Page 是讀寫最小單位」這個前提之上。
  2. 解釋一次查詢裡「shared hit」與「shared read」的差異,並判斷哪一種代表命中了 Buffer Pool。
  3. 給定一個 Buffer Pool 大小與資料總大小,判斷這個工作集能不能完全放進 Buffer Pool,並推導「放不下」時會發生什麼。

教材大綱

1

Buffer(Memory / Disk / Buffer Pool / Cache)

Core FundamentalsLv.4

What:Disk(或 SSD)是資料真正永久存放的地方,容量大但存取慢(相對 CPU/記憶體速度而言,隨機讀取一個 page 可能要數毫秒);Memory(RAM)容量小但存取快(微秒甚至奈秒級);Buffer Pool是資料庫程序在啟動時向作業系統要來的一塊記憶體區域,專門用來存放「最近被讀過或寫過的 Page」,扮演 Disk 與執行查詢的 Executor 之間的Cache——本質上跟 CPU 的 L1/L2 cache 是同一種設計哲學:把「最近可能還會再用到的資料」留在快速的地方,避免每次都付出慢速媒介的成本。

Why

如果每次查詢都直接讀 Disk,即使是查詢同一筆熱門資料(例如某個熱門商品的詳情頁)一千次,也要付出一千次 disk I/O 的成本;但真實世界的查詢分佈幾乎從不均勻——少數 page(例如熱門商品、最近的訂單)會被反覆存取,多數 page 很少被碰。Buffer Pool 利用這個「局部性」(locality):只要把這些熱門 page 留在記憶體,之後對它們的存取就能完全略過 disk I/O,把系統整體吞吐量拉高一到兩個數量級。

Mechanism

一次讀取的完整路徑——Executor 要某個 page 時,先向 Buffer Pool 查詢這個 page 在不在裡面(依 page 的識別碼,例如(tablespace, relation, block number) 查一個記憶體內的 hash table)。命中(shared hit / cache hit):page 已經在 Buffer Pool,直接從記憶體取用,不觸發任何 disk I/O。未命中(shared read/ cache miss):page 不在 Buffer Pool,必須先發出一次真正的 disk read 把整個 page 讀進 Buffer Pool 的一個 slot,再從記憶體取用;如果 Buffer Pool 已經滿了,要先依某種置換策略(PostgreSQL 用 Clock Sweep,一種近似 LRU 但成本更低的演算法)選一個「最近最少被用到」的 existing page 請出去(若那個 page 有被修改過、還沒寫回磁碟,要先把它寫回磁碟——這個「被修改但還沒寫回磁碟」的 page 稱為 dirty page),空出的 slot 才給新 page 用。

Trade-off

Buffer Pool 越大,能留住的熱門 page 越多,cache hit rate 越高,但這塊記憶體是從作業系統分給資料庫程序的,跟其他程序(例如 application server 若跟資料庫共用同一台機器)搶記憶體;Buffer Pool 太小,會出現「剛換出去的 page 馬上又要用」的thrashing(置換抖動)——每次存取都變成 cache miss,Buffer Pool 不但沒有加速效果,反而多付出了一層查詢/管理 Buffer Pool 本身的開銷。

Failure Modes:一個常見的錯誤心智模型是以為「重開資料庫程序後,第一次查詢就會跟平常一樣快」——實際上重開後 Buffer Pool 是空的(cold cache),前幾次查詢會全部觸發 disk I/O 直到 Buffer Pool 重新被「熱身」(warm up)填滿熱門 page,這段期間查詢延遲會明顯比平常高,這也是為什麼線上資料庫重啟後常常會看到短暫的延遲尖峰,跟程式碼本身有沒有 bug 無關。另一個失效模式是「一次性的大範圍掃描(例如跑一個報表查詢,掃過整張很大的表)會把 Buffer Pool 裡原本的熱門 page 全部擠出去」——PostgreSQL 對大範圍循序掃描有專門的ring buffer 機制緩解這個問題(大掃描只佔用一小圈固定的 buffer slot 循環使用,不會佔滿整個 Buffer Pool),但如果你自己在應用層實作類似快取機制,沒有這種保護,一次背景報表查詢就可能拖垮線上其他查詢的 cache hit rate。

Backend Applications:EXPLAIN (ANALYZE, BUFFERS) 輸出裡的shared hit=X read=Y 就是在報告這次查詢命中 Buffer Pool 幾次(hit)、觸發真正 disk I/O 幾次(read)——這是診斷「這句查詢慢,是因為資料庫邏輯設計得差,還是純粹因為 cold cache」最直接的證據:同一句 SQL 第一次跑 read 很高、第二次馬上重跑變成幾乎全部hit,說明問題只是暫時的 cold cache,而不是查詢本身有結構性問題。Backend 工程師在做效能調查時,看到「同一句查詢時快時慢」,第一個該檢查的就是 Buffer Pool 的命中率,而不是急著改 SQL 邏輯。

陌生題示範:情境——資料庫伺服器有 4GB 記憶體分配給 Buffer Pool,但你的 orders 表加上其索引總大小是 20GB,且查詢分佈幾乎均勻(沒有明顯的熱門資料,每筆 order 被查到的機率差不多)。請推導這種情況下 Buffer Pool 能帶來多少幫助,以及你會建議哪個方向調查。推導:因為查詢均勻分佈、且工作集(20GB)遠大於 Buffer Pool(4GB),理論上任何時刻 Buffer Pool 只能留住整體資料的 4/20 = 20%,且因為沒有「熱門/冷門」之分,被換出去的 page 跟被查詢到的 page 出現機率相同,長期下來 cache hit rate 會趨近 20% 而不是更高——這代表「加大 Buffer Pool」在均勻分佈下的邊際效益是線性的(Buffer Pool 翻倍、hit rate 大致也翻倍,直到 Buffer Pool 大到能裝下整個工作集),不像有明顯熱門資料時「稍微加大就能大幅提升命中率」。建議調查方向:(1) 檢查記憶體是否還有餘裕加大 shared_buffers;(2) 檢查查詢是否真的需要均勻存取全部 20GB,或者可以透過分區/歸檔把很少被查的舊orders 移出主要查詢路徑,讓「實際常查的工作集」縮小到能被 4GB 完整涵蓋。

Day 32 過關標準(DoD)—— Database 特殊驗收格式

``text Query: 任選一句會命中一張有一定資料量(> 1 萬筆)的表的查詢,例如 SELECT * FROM orders WHERE user_id = 1;EXPLAIN: 連續執行兩次 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 1;記錄兩次輸出中 "Buffers: shared hit=X read=Y" 這一行的數值。Hypothesis: 依 Day 32 教材,預期第一次執行 read 值 > 0(cold cache,或至少不確定是否命中),第二次執行應該幾乎全部變成 hit(因為第一次已經把相關 page 讀進 Buffer Pool)。Change: 若第二次 read 依然很高,代表 Buffer Pool 可能太小、或這段期間有其他查詢把相關 page 擠出去了;若你的環境無法重現差異,改用 SELECT pg_prewarm('orders')(若有安裝 pg_prewarm 擴充)或重啟資料庫程序製造一次明確的 cold cache 再對比。Benchmark: 對比兩次 EXPLAIN ANALYZE 的 "actual time" 總耗時。Conclusion: 用自己的話寫一段,說明 hit/read 比例的變化跟耗時變化是否一致,並回答:如果這張表大小超過 Buffer Pool 總容量,重複跑同一句查詢,read 是否還能穩定趨近 0?為什麼(提示:見上方 thrashing 的討論)。``

Day 33 — B-Tree(上):結構與 Search

學習目標

看完今天內容後,能夠:

  1. 畫出 B-Tree 的 Root / Internal Node / Leaf Node 三層結構,並解釋每一層在整棵樹裡的角色。
  2. 手動走一遍 Search 在 B-Tree 上如何從 Root 一路比較、下降到正確的 Leaf,並說出每往下一層對應現實中的什麼成本。
  3. 解釋 B-Tree node 的大小為什麼被設計成貼齊一個 Page(Day 31),並說出這個設計省下了什麼。

教材大綱

1

B-Tree 結構(Root / Internal / Leaf)

Core FundamentalsLv.4

What:B-Tree 是一種自平衡的多叉排序樹:每個 node 不像 Binary Search Tree(Day 18)只存 1 個 key、最多 2 個子節點,而是存多個排序好的 key、對應多個子節點(子節點數量 = key 數量+ 1)。整棵樹分三種角色:Root(最上層,唯一的進入點)、Internal Node(中間層,只存 key 與指向子節點的指標,不存實際資料,作用是「導航」)、Leaf Node(最底層,所有實際資料或指向實際資料的指標都存在這裡,且所有 Leaf 都在同一深度——這正是「自平衡」的意思)。

Why

Binary Search Tree 的問題是「樹高會隨資料量以log₂(n) 成長,但每一層都要付出一次隨機記憶體/磁碟存取」——如果直接把 BST 搬到磁碟上,n = 100萬 筆資料的樹高約 20 層,代表一次 search 要付出 20 次隨機 disk I/O,而磁碟隨機 I/O 的成本遠高於記憶體操作。B-Tree 的解法是「讓每個 node 存很多個 key(多叉),用分支因子(fanout)換掉樹高」——如果每個 node 能存 500 個 key(對應 501 個子節點),同樣 100 萬筆資料,樹高只需要log₅₀₁(1,000,000) ≈ 2.2,無條件進位約 3 層,代表一次 search 只需要 3 次 I/O,而不是 20 次。這個「用分支因子換樹高」的設計,正是 Day 31 Page 存在後才有意義的:一個 node 能存多少個 key,取決於一個 Page(8KB)能塞多少個 key+pointer——這是為什麼 B-Tree 排在 Page 之後才能被真正講清楚的原因。

Mechanism(node 大小與 Page 對齊):B-Tree 的 node 被設計成大小貼齊一個 Page(8KB),這樣「讀進一個 node」剛好對應「讀進一個 Page」,一次 disk read 就能拿到一整個 node 的全部 key 與指標,不用分好幾次 I/O 才湊齊一個 node 的內容。假設每個 key+pointer 需要 16 bytes(8 bytes 的 bigint key + 8 bytes 的指標),扣掉約 100 bytes 的 page header 開銷,一個 8KB 的 Page 大約能存(8192-100)/16 ≈ 505 個 key+pointer——這就是前面 Why 段落「fanout 約 500」這個數字的來源,不是憑空假設,而是直接由 Page 大小、key 大小反推出來的。

Mechanism(Search):從 Root 開始,把要找的 key 跟 Root 裡排序好的 key 陣列做比較(例如二分搜尋),找出「要找的 key 落在哪兩個 existing key 之間」,依此決定要往哪個子節點下降;到了子節點(一個 Internal Node),重複同樣的比較與下降;一路下降到 Leaf Node 後,在 Leaf 裡再做一次比較,確認找到(或確認不存在)。整個過程「每往下一層 = 一次 page read(若該 page 不在 Buffer Pool,見 Day 32)」,樹高即為最壞情況下需要的 I/O 次數上界。

Trade-off

B-Tree 用「fanout 大、樹高低」換來「search 的 I/O 次數少」,代價是每個 node 內部的比較次數變多(在 500 個 key 裡做二分搜尋,比在 2 個 key 裡選一個要多付出幾次 CPU 比較)——但這個 trade-off 幾乎總是划算的,因為 CPU 內比較的成本(奈秒級)遠低於一次 disk I/O 的成本(毫秒級甚至更高),用「多付出一點 CPU 時間」換「少付出很多次 I/O」是資料庫設計裡反覆出現的主題(Buffer Pool 也是同樣邏輯)。

Failure Modes:常見誤解是把 B-Tree 跟 Binary Search Tree 搞混,以為「B-Tree 的 B 是 Binary 的意思」——實際上 B-Tree 的 B 一般被理解為 Balanced(或其發明者 Rudolf Bayer 姓氏的字首,來源有爭議,但確定不是 Binary),核心差異就是多叉 vs. 二叉。另一個失效模式是誤以為「樹越高代表資料越亂」——B-Tree 是自平衡的,樹高只跟資料筆數與 fanout 有關,不會因為插入順序不同而長歪、變高(這點與一般未平衡的 BST 不同,未平衡 BST 若照排序好的順序依序插入,會退化成一條鏈,等同 O(n) 的 linked list)。

Backend Applications:PostgreSQL 的 primary key 與大多數CREATE INDEX 預設就是用 B-Tree(實作稱為 B+Tree,一種 Leaf 之間互相用指標串起來、方便做範圍掃描的 B-Tree 變形);Backend 工程師每次執行 WHERE id = ? 或 WHERE created_at BETWEEN ? AND? 這類條件式查詢,只要對應欄位有 B-Tree index,就是在使用今天學的這套結構做 Search,而不是逐筆比對整張表。

陌生題示範:情境——一個 B-Tree index,fanout(分支因子)為 100,目前存了 8,000,000 筆資料。請推導樹高大約是多少,以及如果資料量成長到 8 億筆(成長 100 倍),樹高會變成多少。推導:樹高h 滿足 100^h ≈ n,即 h ≈ log₁₀₀(n)。n = 8,000,000時,log₁₀₀(8,000,000) = ln(8,000,000)/ln(100) ≈ 15.9/4.6 ≈ 3.46,無條件進位樹高約 4 層;n = 800,000,000 時,log₁₀₀(800,000,000) ≈ 20.5/4.6 ≈ 4.46,無條件進位樹高約 5 層。資料量成長 100 倍,樹高只多了 1 層——這正是 B-Tree「對數成長」的威力:搜尋成本(I/O 次數)幾乎不隨資料量線性成長,這是為什麼即使 table 成長到數十億筆,只要有合適的 index,單筆查詢的延遲仍能維持在毫秒級。

Day 33 過關標準(DoD)—— Database 特殊驗收格式

``text Query: 對一張有 primary key、資料量 > 1 萬筆的表執行 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE id = <某個存在的 id>;EXPLAIN: 記錄輸出計畫節點是否為 "Index Scan using orders_pkey",以及 "actual time" 與 "Buffers" 數值。Hypothesis: 依今天的樹高推導方式,先估算:若這張表 fanout 假設約 200、資料量為 N,樹高大約是 log₂₀₀(N) 層;寫下你預期這句 Index Scan 的 Buffers read/hit 數值量級是否與你估算的樹高相符(量級上應該是個位數,而不是幾百幾千)。Change: 若計畫節點是 Seq Scan 而非 Index Scan,先確認該欄位是否真的有 index(\d orders 或查 pg_indexes),補上CREATE INDEX 後重跑。Benchmark: 記錄補上 index 前後(或對照一張沒有 index 的等大小表)的 actual time 差異。Conclusion: 用自己的話寫一段,說明本次觀察到的 Buffers 數值是否接近今天推導的樹高量級,並回答:如果資料量再成長 10 倍,你預期這個 Buffers 數值會怎麼變(提示:對數成長,不會是 10 倍)。``

Day 34 — B-Tree(下):Insert / Delete / Split

學習目標

看完今天內容後,能夠:

  1. 手動走一遍 Insert 在 B-Tree 上如何找到正確的 Leaf、插入 key,並說出什麼條件會觸發 Split。
  2. 解釋 Split 發生時 key 如何「往上推」給 parent,以及這如何維持 B-Tree 的自平衡特性(所有 Leaf 深度相同)。
  3. 解釋 Delete 在 B-Tree 上除了移除 key,還可能需要處理什麼問題(node 太空、需要跟兄弟 node 合併或借 key)。

教材大綱

1

Insert 與 Split

Core FundamentalsLv.4

What:Insert 一筆新資料到 B-Tree,第一步跟 Search 完全一樣——從 Root 開始比較、逐層下降,找到這個新 key 應該落在哪一個 Leaf Node。找到目標 Leaf 後,把新 key 插入該 Leaf 內部正確的排序位置。如果這個 Leaf 插入後還沒滿,Insert 到此結束;如果插入後滿了(超過 node 能容納的 key 數量上限,這個上限由 Page 大小決定,見 Day 33),就會觸發 Split。

Why

如果 Leaf 滿了還硬塞新 key 進去,這個 node 的大小就會超過一個 Page,之後讀這個 node 就要付出不只一次的 disk I/O——這違背了 B-Tree「node 大小貼齊 Page」的整個設計前提(Day 33)。Split 存在的理由就是:寧可多一個 node(多一次未來可能的 I/O),也不讓任何一個 node 超過一個 Page 的大小,維持「讀一個 node = 一次 I/O」這個不變量。

Mechanism(Split):以一個已滿的 Leaf Node 為例——把這個 Leaf 裡所有 key(含新插入的那個)依序排好後,從正中間切成兩半,變成兩個各自約半滿的 Leaf Node;切開點的那個 key(或其副本,依 B-Tree 實作變形而定)被「往上推」給 parent node,成為 parent 裡新增的一個 key,同時 parent 也多了一個指向新 Leaf 的指標。如果 parent node 也因此被塞滿,Split 會繼續往上傳播,一路傳到 Root——如果連 Root 都滿了,會產生一個全新的 Root(原本的 Root 一分為二成為新 Root 的兩個子節點),這是 B-Tree 唯一會增加樹高的時刻,且因為是從最頂端往上加一層,所有原本的 Leaf 深度會同時增加 1,保證所有 Leaf 深度依然相同(自平衡不被破壞)。

Mechanism(Delete):先跟 Search 一樣定位到 key 所在的 Leaf,移除該 key。如果移除後這個 Leaf 的 key 數量掉到「最低容量」以下(太空),會先嘗試向左右兄弟 node 借一個 key(如果兄弟 node key 數量夠多,可以借出一個而不會自己變太空);如果兩邊兄弟都「借不起」,就會跟其中一個兄弟 node 合併(merge)成一個 node——這是 Split 的反向操作,同樣可能往上傳播(parent 因為少了一個指標與一個 key,也可能因此變太空,需要繼續借/合併)。

Trade-off

Split 與合併都需要額外的 I/O(讀寫牽涉到的 node、可能還要更新 parent),這代表 Insert/Delete 的成本不是均勻的——多數時候是「找到 Leaf、直接插入/刪除」的低成本操作,但偶爾會觸發 Split/Merge 甚至往上傳播多層的高成本操作,這跟 Day 21(Phase 03)學過的攤銷複雜度是同一種模式:長期平均下來 Insert/Delete 仍是 O(log n),但單次操作的實際延遲會有波動。

Failure Modes:一個常見的效能陷阱是大量隨機順序的 Insert(例如用 UUID 而非遞增整數當 primary key)——因為新 key 會均勻散落在整棵樹的各個角落,而不是集中在最右側(遞增 key 的情況下,新 key 永遠插在最後一個 Leaf),這會導致 Split 發生的頻率遠高於遞增 key 的情況,且新產生的 Page 在磁碟上的位置可能跟原本的 Page 不連續(page fragmentation),讓之後的循序掃描效率下降;這是為什麼許多系統設計上偏好「遞增但仍具唯一性」的 ID 生成策略(例如 Day 62 URL Shortener 會用到的 ID generation 設計),而不是直接對 primary key 使用純隨機 UUID。

Backend Applications:了解 Split/Merge 的成本,能解釋為什麼「批次 Insert(例如 COPY 或一次 INSERT 多筆)通常比逐筆 Insert 快很多」——批次操作能讓資料庫更有效率地規劃 Page 配置、減少 Split 傳播到多層的次數;也能解釋為什麼「大量匯入前先移除 index、匯完資料後再重建 index,通常比帶著 index 一邊持續 Insert/Delete、造成大量 Split/Merge 更有效率」,這是資料遷移/批次匯入時常見的操作建議(先停用或最後才建立 index)背後的原理。

陌生題示範:情境——一棵 B-Tree index,每個 node 最多容納 100 個 key(超過就 Split,低於 50 個就會嘗試跟兄弟借/合併)。目前某個 Leaf Node 剛好有 50 個 key(已在最低容量邊界),這時發生一次 Delete,移除其中 1 個 key。請推導接下來會發生什麼、以及這個影響會不會往上傳播到 parent。推導:Delete 後這個 Leaf 只剩 49 個 key,低於最低容量 50,觸發「太空」處理——先檢查左右兄弟 Leaf 是否 key 數量 > 50(借了一個之後仍不低於 50 才能借出):若右兄弟有 70 個 key,可以借 1 個給這個 Leaf(借完後右兄弟剩 69 個,本身變 50 個,兩邊都不低於最低容量),這種情況不會往上傳播,parent 只需要更新一下分隔 key(因為兩個子節點之間的邊界 key 變了);但若左右兄弟都剛好也在 50 個左右(借了就會讓對方也太空),就必須跟其中一個兄弟合併成一個 node(合併後約 99 個 key,未超過 100 的上限,不會立刻再觸發 Split),合併後 parent 少了 1 個指標與 1 個 key,這就會往上傳播——重複同樣的「parent 是否因此太空」判斷,最壞情況一路傳到 Root。這說明了 Delete 的攤銷成本雖然是O(log_m n),但傳播與否高度依賴當下兄弟 node 的 key 數量分佈,無法從單一次 Delete 的操作本身預測,需要動態檢查——這正是 Day 34 開頭 Trade-off 段落強調「操作成本不均勻」的具體來源。

Day 34 過關標準(DoD)—— Database 特殊驗收格式

``text Query: 建立一張測試表並準備兩種 Insert 方式做對照:CREATE TABLE bench_seq (id serial PRIMARY KEY, val text);CREATE TABLE bench_rand (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), val text);分別各批次 INSERT 10 萬筆資料(val 用任意固定字串即可)。EXPLAIN: 對兩張表分別執行 EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM bench_seq;EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM bench_rand;記錄兩者的 Buffers 與 actual time。Hypothesis: 依今天教材,預期 bench_rand(隨機 UUID 主鍵)因為 Insert 時觸發更多次 Split、且 page 較不連續,掃描效能會略遜於 bench_seq(遞增序號主鍵),但差異在單一 count(*) 查詢上不一定巨大——重點在於「插入 10 萬筆」這個動作本身花的時間,而非之後的查詢。Change: 對比兩張表「批次插入 10 萬筆」實際花費的時間(用\timing 或應用層計時),若差異不明顯,改成分批(例如每次 1000 筆、共 100 次)用交錯順序插入 bench_rand 來放大隨機 key 對 Split 頻率的影響。Benchmark: 記錄插入耗時與 pg_relation_size('bench_seq') /pg_relation_size('bench_rand') 的實際磁碟佔用大小對比。Conclusion: 用自己的話寫一段,說明觀察到的耗時/大小差異是否符合今天教材對「隨機 key 導致更多 Split、更多 page fragmentation」的推論;若你的環境差異不明顯,說明可能的原因(例如資料量還不夠大到讓差異顯著、或 PostgreSQL 版本的 fillfactor 設定緩解了問題)。``

Day 35 — B-Tree 高度 / 複雜度:為什麼 B-Tree 適合 Database Index?

學習目標

看完今天內容後,能夠:

  1. 對一組給定的 fanout 與資料筆數,計算出 B-Tree 的高度,並據此算出 Search/Insert/Delete 的 Big-O(以及對應的實際 I/O 次數)。
  2. 完整回答本章核心問題「為什麼 B-Tree 適合 database index?」,並附上具體的數字推導,而不是只背答案。
  3. 走完一次完整的 Query→EXPLAIN→Hypothesis→Change→Benchmark→Conclusion 流程,親手證明加 index 前後的效能差異。

教材大綱

1

Tree Height / Complexity

Core FundamentalsLv.4

What:B-Tree 的高度 h 由資料筆數 n 與分支因子(fanout)m 決定:h ≈ log_m(n)(無條件進位,因為樹高必須是整數層)。Search、Insert、Delete 的時間複雜度都是 O(log_m n)——因為三種操作都需要先從 Root 走到目標 Leaf(Insert/Delete 多出的 Split/Merge 成本,在攤銷分析下也維持在 O(log_m n) 量級,見 Day 34)。空間複雜度是 O(n)(每筆資料在樹中恰好被存放/索引一次,加上 Internal Node 的額外指標開銷,量級仍是 O(n))。

Mechanism(把 Big-O 換算成實際 I/O 次數):O(log_m n) 這個記號本身沒有告訴你「所以到底要幾次 I/O」——要拿到具體數字,必須代入實際的 m(fanout,由 Page 大小與 key 大小決定,見 Day 33)與 n(實際資料筆數)。這正是把 Big-O 從「抽象記號」變成「可以拿來做效能預算」的關鍵一步:O(log n) 對 n = 10 跟 n = 10億在記號上「長得一樣」,但實際 I/O 次數差了好幾倍,工程決策必須看實際數字,不能只看記號。

核心問題推導:為什麼 B-Tree 適合 database index?

把 Day 31–35 學到的三件事串起來做完整推導:

  1. Page(Day 31)決定了 I/O 是以固定大小區塊為單位——讀一個 node 等於讀一個 Page,這是 disk 的物理限制逼出來的設計。
  2. fanout(Day 33)由 Page 大小除以 key+pointer 大小決定——8KB 的 Page、16 bytes 的 key+pointer,fanout 約 505;這代表 B-Tree 的分支因子不是憑空選的大數字,而是「一次 I/O 能帶進來多少個 key」這個物理限制直接算出來的。
  3. 樹高 h ≈ log_m(n),其中 m 是第 2 點算出來的大 fanout——把 m ≈ 500 代入:n = 1,000,000 時 h ≈ log₅₀₀(1,000,000) =ln(1,000,000)/ln(500) ≈ 13.8/6.2 ≈ 2.2,無條件進位約 3 層;n = 1,000,000,000(10 億筆)時h ≈ ln(1,000,000,000)/ln(500) ≈ 20.7/6.2 ≈ 3.3,無條件進位約 4 層。資料量從 100 萬暴增到 10 億(1000 倍),樹高只從 3 層變成 4 層。

對照:若改用 Day 31 算過的「全表掃描」(沒有 index),n = 1,000,000,000 筆、每筆 200 bytes,需要讀的 page 數約1,000,000,000 × 200 / 8192 ≈ 24,414,062 個 page;用 B-Tree index 找同一筆資料只需要約 4 次 page I/O。4 次 I/O 對 2400 萬次 I/O——這就是「為什麼 B-Tree 適合 database index」的具體答案:B-Tree 把「fanout 由 Page 大小決定」這個硬體限制,轉換成「用極少的 I/O 次數(樹高,對數量級)就能定位到任意一筆資料」的能力,而且這個能力隨資料量增長幾乎不衰退(樹高的對數成長遠慢於資料量的線性成長)。如果沒有 Page 這個「一次 I/O 能帶進大量 key」的前提,分支因子就無法做大,B-Tree 也就不會比其他排序結構(例如 Binary Search Tree,Day 33 提過的 log₂ n)在磁碟環境下更有優勢——這正是 Day 31(Page)必須先教、B-Tree 必須排在後面的完整理由。

Trade-off / Failure Modes:見 Day 33(fanout 越大、樹越矮,但單一 node 內比較次數越多)與 Day 34(Split/Merge 的攤銷成本、隨機 key 導致的 fragmentation)——今天不重複,而是強調:這些 trade-off 全部建立在同一個前提上(node 大小 = Page 大小),只要記住這個前提,B-Tree 每一個設計決策都能自己重新推導出來,不需要死背。

Backend Applications:當你在 PostgreSQL 執行 CREATE INDEX idx_orders_user_id ON orders(user_id),資料庫實際上是在背景建立一整棵今天描述的 B-Tree(掃過 orders 現有全部資料、依 user_id排序後,依序插入這棵樹,過程中會觸發今天 Day 34 學過的 Split)。之後每一句 WHERE user_id = ? 的查詢,都是在對這棵已經建好的 B-Tree 做 Day 33 的 Search,I/O 成本是這棵樹的高度,而不是orders 表的總筆數。

Day 35 過關標準(DoD)—— Database 特殊驗收格式(完整實作一次)

```text Query: 先製造一個「沒有 index 導致慢查詢」的情境:CREATE TABLE bench_orders (id serial PRIMARY KEY, user_id int, amount numeric);-- 插入 100 萬筆測試資料,user_id 均勻分布在 1~10000 之間 INSERT INTO bench_orders (user_id, amount)SELECT (random()*9999+1)::int, (random()*1000)::numeric FROM generate_series(1, 1000000);SELECT * FROM bench_orders WHERE user_id = 42;

EXPLAIN: EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM bench_orders WHERE user_id = 42;預期看到 "Seq Scan on bench_orders",Buffers 數值應與 Day 31 推導的「全表掃描 page 數」量級相符(100 萬筆、每筆約數十 bytes,約數千到上萬個 page)。

Hypothesis: 依今天的核心推導,加上 B-Tree index 後,同一句查詢應該從「Seq Scan、Buffers 數千以上」變成「Index Scan、Buffers 個位數到十位數(對應樹高 3 層左右)」,actual time 應有數量級的改善。

Change: CREATE INDEX idx_bench_orders_user_id ON bench_orders(user_id);

Benchmark: 重跑 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM bench_orders WHERE user_id = 42;記錄計畫節點是否變成 "Index Scan using idx_bench_orders_user_id",並對比加 index 前後的 Buffers 與 actual time 數值。

Conclusion: 用自己的話寫一段,回答「為什麼改完真的變快」——需要串起今天完整的推導鏈:index 建成一棵 B-Tree → fanout 由 Page 大小決定 → 樹高只需 log_m(n) 層 → 一次 Search 只需碰樹高這麼多次 page,而不是碰過 Seq Scan 的全部 page。並記錄你實測到的 Buffers 數值是否與樹高的理論值(用你的實際 n 與估算的 fanout 代入 log_m(n))量級相符;若明顯不符,說明可能的原因(例如 PostgreSQL 的 Bitmap Index Scan 在某些情況下會先收集多筆再一次存取 heap,Buffers 計數方式與純 B-Tree Search 略有不同)。```

Day 31–35 到此結束,涵蓋 SQL 執行流程總覽、Page、Buffer、B-Tree 全部子項(結構、Search、Insert、Delete、Split、Tree height、Complexity)與本章核心問題「為什麼 B-Tree 適合 database index」的具體數字推導。Day 36–40(Index 全部類型 / Query Planner / Transactions)由 T-026 接續,會直接建立在今天「B-Tree 的樹高由 fanout 決定、fanout 由 Page 大小決定」這個心智模型之上——Index 的本質正是「另外建一棵今天學的 B-Tree」,Query Planner 選 Index Scan 或 Sequential Scan,本質上就是在比較「今天 Day 35 算出來的樹高 I/O」與「Day 31 算出來的全表掃描 I/O」哪個更便宜。

Day 36 — Index:讓 B-Tree 為你的查詢工作(Single-column / Composite / Covering / Partial)

學習目標

看完今天內容後,能夠:

  1. 解釋 Index 的本質是「另外維護一棵 Day 33–35 學過的 B-Tree」,並說出為什麼建 Index 能把查詢從 O(n) 變成 O(log n)、代價是什麼。
  2. 對一個同時篩多個欄位的查詢,判斷 Composite Index 的 column 順序該怎麼排,並解釋為什麼順序錯了會讓 Index 幾乎沒用。
  3. 判斷一個查詢適合用 Covering Index 還是 Partial Index,並各自寫出對應的 CREATE INDEX 語法。

教材大綱

1

Index 本質與 Single-column Index

Core FundamentalsLv.4

What:Index 是資料庫額外維護的一份資料結構——絕大多數情況下就是 Day 33–35 學過的 B-Tree——把某個(或某幾個)column 的值排序後另外存一份,B-Tree 的每個 Leaf 除了 key 之外還存一個指回原表(PostgreSQL 內部稱為 heap)對應那筆資料實體位置的指標(PostgreSQL 叫CTID,即 (page number, item pointer))。Single-column Index 是最基本的形式:只針對一個 column 建一棵 B-Tree。

Why

沒有任何 Index 時,WHERE 條件唯一能做的是 Day 31 算過的 Sequential Scan——把整張表的每個 Page 都讀一次,逐筆比對條件,成本是O(n);Day 33–35 已經證明了 B-Tree 能把「找到符合條件的資料」壓到O(log_m n)。Index 存在的整個理由,就是「花額外的磁碟空間與寫入成本換一棵隨時可查的 B-Tree,讓讀取從線性掃描變成對數搜尋」。

Mechanism

CREATE INDEX idx_users_email ON users(email); 執行時,資料庫會掃過 users 表現有的每一筆資料,取出 email 欄位的值,依序建成一棵 B-Tree(過程中會觸發 Day 34 學過的 Split),每個 Leaf 存(email 值, 指向該筆資料的 CTID)。之後執行 SELECT * FROM users WHERE email = '[email protected]',Planner 若選擇使用這個 Index(Day 37 展開判斷依據),Executor 會先對這棵 B-Tree 做 Day 33 學過的 Search,找到email = '[email protected]' 對應的 CTID,再依 CTID 去 heap 讀出該筆資料的完整內容(這次額外的讀取稱為 heap fetch)。建立索引之後,往後每一次對這張表的 INSERT/UPDATE/DELETE,資料庫都要同步維護這棵 B-Tree(多寫一次、可能觸發 Split/Merge),不是建完就一勞永逸。

Trade-off

Index 用「額外的磁碟空間 + 每次寫入多一份維護成本」換「讀取從 O(n) 變 O(log n)」。一張寫入頻繁、讀取稀少的表(例如純寫入的 audit log),加太多 Index 反而讓每次 INSERT 都要多維護好幾棵 B-Tree,寫入吞吐量會明顯下降;一張讀取遠多於寫入的表(例如users),Index 幾乎總是划算。

Failure Modes:最常見的失效是「Index 建了但沒被用到」——例如WHERE LOWER(email) = '[email protected]',即使 email 欄位有 Index,B-Tree 裡存的是原始值(不是小寫後的值),Planner 找不到「LOWER(email)的結果」對應到哪個 Index,只能整表掃描(除非另外建CREATE INDEX ... ON users (LOWER(email)) 這種 functional index)。這說明「欄位有沒有 Index」跟「查詢條件能不能用上那個 Index」是兩件事,WHERE 條件裡對欄位做任何函式運算,都可能讓既有 Index 直接失效。

Backend Applications:Backend 工程師在設計 schema 時,判斷該對哪個欄位建 Index 的第一原則是「這個欄位常出現在 WHERE/JOIN/ORDER BY 嗎?」而不是「這個欄位重不重要」——一個從不出現在查詢條件裡的欄位,建 Index 只有維護成本、沒有任何讀取收益。

陌生題示範:情境——orders 表有 500 萬筆資料,目前只有id(primary key)有 Index,應用程式最常見的查詢是SELECT * FROM orders WHERE user_id = ?,這句查詢目前跑一次要 2.3 秒。請推導加上 CREATE INDEX idx_orders_user_id ON orders(user_id); 前後,這句查詢的 I/O 量級大約會怎麼變化,以及為什麼「加這個 Index」比「把整張表搬到更快的 SSD」更該優先做。推導:加 Index 前,這句查詢必須做 Day 31 式的全表掃描,I/O 次數正比於 orders 的總 Page 數(跟資料筆數線性相關,500 萬筆量級可能是數萬到十幾萬個 Page);加上 Index 後,變成先對一棵針對 500 萬筆user_id 建的 B-Tree 做 Search,I/O 次數約為樹高(Day 35 算過,fanout 500 時 500 萬筆的樹高僅約 3 層),再加上少量的 heap fetch——I/O 量級從「跟總筆數線性相關」變成「幾乎常數」。換 SSD 只能讓「每次 I/O」變快幾倍,但 Index 是讓「I/O 次數」從萬級降到個位數級,數量級的改善通常遠大於硬體升級能提供的倍數改善,這正是「先看有沒有用對的 Index,再考慮砸錢升級硬體」這個工程優先順序的具體理由。

2

Composite Index(Column Order 的重要性)

Core FundamentalsLv.4

What:Composite Index(也叫 Multi-column Index)是同時對多個 column 建一棵 B-Tree,key 是這些 column 依宣告順序組成的複合值。例如 CREATE INDEX idx_orders_status_created ON orders(status,created_at); 建出的 B-Tree,其 key 排序方式是「先依 status排序,status 相同的資料再依 created_at 排序」。

Why

一個 WHERE 條件常常同時篩多個欄位(例如 WHERE status ='pending' AND created_at > '2026-01-01')。若只各自對 status、created_at 建 Single-column Index,Planner 最多能用Bitmap Index Scan 分別在兩棵 B-Tree 上各找一次,再取交集——這需要兩次獨立的 Index Search 加上合併結果集的額外成本;而一棵設計正確的 Composite Index 能讓 Search 一次到位,直接定位到同時符合兩個條件的那一段連續 key。

Mechanism(column 順序為什麼重要):這直接沿用 Day 33 教過的 B-Tree Search 機制——比較 key 時從第一個 column 開始比,只有第一個 column 相等時,才會繼續比第二個 column。這代表 composite index (status, created_at):- 能高效支援 WHERE status = ?(只用第一個 column)。- 能高效支援 WHERE status = ? AND created_at > ?(先用 status 定位到一段連續範圍,範圍內再依 created_at 排序,可以直接做範圍掃描)。- 無法高效支援單獨 WHERE created_at > ?(沒有 status 條件)——因為 B-Tree 裡的 key 是先依 status 分組排序,同一個 created_at 值可能散落在依 status 分好的各個區段裡,等於要掃過整棵樹,跟沒建 Index 幾乎一樣貴。

Trade-off

Column 順序沒有「絕對正確」的答案,取決於實際查詢模式——如果 90% 的查詢只帶 status、10% 才多帶 created_at,(status, created_at) 這個順序服務兩種查詢都夠用;但如果查詢模式反過來(90% 只查 created_at 範圍、不管 status),這個 Index 幾乎幫不上忙,該建 (created_at) 或 (created_at, status)。除此之外,column 的 Selectivity(Day 37 展開)也會影響順序選擇:Cardinality 高(distinct 值多、Selectivity 數值低、篩選力強)的欄位放前面,通常能讓每次 Search 更快篩掉不相關的 key。

Failure Modes:常見誤解是「只要幫常查的欄位都建進同一個 composite index 就好,不用管順序」——已經在 Mechanism 段落證明,順序錯了會讓 Index 對某些查詢完全無效,跟沒建一樣,卻仍要付出寫入維護成本,是「兩頭都沒討到好處」的浪費。

Backend Applications:Backend 工程師寫 API 分頁/篩選功能時(例如「依 status 篩選 + 依時間排序」的訂單列表 API),composite index 的 column 順序應該直接對照 API 實際的查詢參數組合來設計,而不是憑直覺把「看起來重要」的欄位排前面。

陌生題示範:情境——一個 API 的查詢模式是:90% 的請求帶WHERE status = ? ORDER BY created_at DESC LIMIT 20(依狀態篩選、依時間排序取最新 20 筆),10% 的請求帶 WHERE status = ? AND user_id = ?(同時篩狀態與使用者)。目前只有一個 composite index(user_id, status)。請判斷這個 Index 對兩種查詢模式各自有沒有幫助,並給出更適合的設計。推導:現有 (user_id, status) 對 90% 的查詢(沒有 user_id 條件)完全用不上,因為 B-Tree 是先依 user_id 分組,沒有 user_id 條件等於要掃過所有分組;對 10% 的查詢(同時有 user_id 與 status)則可以正常使用。應該改成 (status, created_at):90% 的查詢能靠 status 定位到一段連續範圍,範圍內的 key 已經依created_at 排序好,ORDER BY created_at DESC LIMIT 20 甚至可以不用額外排序(Index 本身順序就對)直接取前 20 筆;10% 的查詢(多了 user_id 條件)雖然這個新 Index 幫不上 user_id 這個條件,但可以先靠 status 縮小範圍,再讓 Executor 對縮小後的結果逐筆比對user_id——比原本「完全掃不到」好得多,且服務了佔比更高的 90% 查詢模式,是這個情境下更划算的取捨。

3

Covering Index

Supporting TopicsLv.3

What:Covering Index 是「Index 本身就包含這次查詢需要的所有欄位,不需要再回頭去 heap 讀一次」的 Index。PostgreSQL 用 INCLUDE 語法把非搜尋用(不參與比較排序,只是「順路存起來」)的欄位一併存進 Index 的 Leaf。

Why

Mechanism 段落提過,一般 Index Scan 找到 CTID 後還要多做一次heap fetch 才能拿到查詢實際要的欄位值——如果查詢需要的欄位剛好都已經存在 Index 裡,就能完全跳過這次 heap fetch,省下一次隨機 I/O(heap 上的資料位置通常跟 Index 上的順序不同,heap fetch 幾乎都是隨機讀取,成本比循序讀高)。

Mechanism

CREATE INDEX idx_orders_user_covering ON orders(user_id) INCLUDE (amount, created_at); 之後,SELECT amount, created_at FROM orders WHERE user_id = ? 這句查詢需要的三個欄位(user_id 用來搜尋、amount/created_at 用來回傳)全部都在這個 Index 裡,Planner 會選擇 Index Only Scan(而非一般的 Index Scan),完全不用碰 heap。

Trade-off

把更多欄位塞進 Index,Index 本身的體積變大(等於一部分資料被重複存了兩份:heap 一份、Index 一份),維護成本也提高(heap 上任何一個被 INCLUDE 進去的欄位被更新,Index 也要同步更新),換來的是省下查詢時的 heap fetch。

Failure Modes:即使建了 Covering Index,Index Only Scan 仍可能在某些情況下退化回要查 heap——PostgreSQL 用 Visibility Map 追蹤「這個 Page 裡的資料是否所有 transaction 都看得到(沒有正在進行中的、尚未 vacuum 清理的舊版本)」,若對應 Page 的 visibility map 沒有標記為「all visible」(常見於這個 Page 剛被大量更新、還沒跑過VACUUM),即使 Index 裡有需要的欄位,Executor 仍必須去 heap 確認這筆資料在目前這個 transaction 的 MVCC snapshot(Day 38 展開)下是否可見,等於白白多存了一份欄位卻沒有拿到 Index Only Scan 的好處。

Backend Applications:列表類 API 常只回傳固定的少數幾個欄位(例如訂單列表只要 amount、created_at),而且查詢頻率高,這是 Covering Index 最適合的場景;反過來,如果查詢的欄位組合常常變動,或這張表寫入/更新頻繁(Visibility Map 常失效),INCLUDE 多存的欄位往往換不到 Index Only Scan,只是白白增加 Index 體積與維護成本,所以建完之後要用 EXPLAIN 確認計畫節點真的變成 Index Only Scan,再決定要不要保留。

練習

在自己的 PostgreSQL 環境建一張至少 1 萬筆資料的測試表,先跑EXPLAIN (ANALYZE, BUFFERS) SELECT amount FROM orders WHERE user_id= 42;(此時只有一般 index),記錄計畫節點與 Buffers 數值;接著改成INCLUDE (amount) 的 Covering Index,跑VACUUM orders;(確保 visibility map 更新)後重跑同一句查詢,確認計畫節點變成 Index Only Scan,並對比兩次 Buffers 數值的差異。

4

Partial Index

Supporting TopicsLv.3

What:Partial Index 是「只對符合某個 WHERE 條件的子集資料建 Index」,而不是對整張表全部資料建 Index。

Why

很多表的資料分布並不均勻——例如一張 orders 表裡status = 'completed' 的訂單佔了 95%,但幾乎不會再被單獨查詢(已完成的訂單很少需要再被檢索);真正常被查的是少數status = 'pending' 的訂單。對整張表的 status 欄位建 Index,95% 的 Index 空間跟維護成本都花在幾乎用不到的 completed 資料上。

Mechanism

CREATE INDEX idx_orders_pending ON orders(created_at) WHERE status = 'pending'; 建出的 B-Tree 只包含status = 'pending' 的那些列,體積遠比對整張表建 Index 小很多。Planner 只有在能從查詢的 WHERE 條件邏輯上推導出「這次查詢一定只會碰到 status = 'pending' 的資料」時,才會考慮使用這個 Partial Index(例如查詢本身就帶 WHERE status = 'pending' AND created_at >?)。

Trade-off

Partial Index 換來更小的 Index(更快的 Search、更少的維護成本),代價是它只能服務條件被涵蓋在內的查詢——若查詢條件沒有明確蘊含 Partial Index 的 WHERE 子句,即使邏輯上資料真的只有pending 才符合,Planner 也可能無法安全地使用這個 Index。

Failure Modes:查詢條件寫法跟 Partial Index 的 WHERE子句「邏輯等價但字面不同」時,Planner 未必能推導出兩者等價,因而放棄使用該 Index,改成完整 Seq Scan 甚至報告找不到合適的 Index 可用——這是 Partial Index 比一般 Index 更容易踩到的陷阱,寫查詢時最好讓條件盡量貼近 Index 定義時的字面寫法。

Backend Applications:背景 worker 反覆輪詢「待處理」的資料(例如只撈 status = 'pending' 的訂單或任務)是 Partial Index 的典型用途:輪詢查詢只需要碰一棵很小的 Index,佔絕大多數的已完成歷史資料完全不用付出 Index 空間與寫入維護成本。實作時要記得,經由 ORM 產生的 SQL 也一樣要讓 WHERE 條件的字面寫法貼近 Index 定義,否則 Planner 用不上這個 Index。

練習

在測試表裡讓 status = 'pending' 只佔全部資料的 5% 以下,分別對「整張表建一般 Index」與「只對 pending 建 Partial Index」跑\di+ 或 pg_relation_size() 比較兩個 Index 各自的實際磁碟大小,驗證 Partial Index 明顯更小;再故意用邏輯等價但寫法不同的查詢條件(例如把 status = 'pending' 換成 NOT (status != 'pending')),觀察 Planner 是否仍選用這個 Partial Index。

Day 36 過關標準(DoD)—— Database 特殊驗收格式

```text Query: 建立測試表並模擬 Day 36 陌生題的 90/10 查詢模式:CREATE TABLE bench_orders2 (id serial PRIMARY KEY, user_id int, status text, created_at timestamp);INSERT INTO bench_orders2 (user_id, status, created_at)SELECT (random()*9999+1)::int,(ARRAY['pending','completed'])[floor(random()*2+1)],now() - (random() * interval '365 days')FROM generate_series(1, 500000);-- 90% 查詢模式 SELECT * FROM bench_orders2 WHERE status = 'pending'ORDER BY created_at DESC LIMIT 20;

EXPLAIN: EXPLAIN (ANALYZE, BUFFERS) 對上面這句查詢先在「無任何 index」的狀態下跑一次,記錄計畫節點(預期 Seq Scan + Sort)與 actual time。

Hypothesis: 依今天教材,建立 composite index (status, created_at)後,這句查詢應該能用 Index Scan 直接取得已排序的結果,跳過額外的 Sort 步驟,actual time 應明顯下降。

Change: CREATE INDEX idx_bench_orders2_status_created ON bench_orders2(status, created_at DESC);

Benchmark: 重跑同一句 EXPLAIN (ANALYZE, BUFFERS),記錄計畫節點是否變成 Index Scan(且不再出現額外的 Sort 節點)、Buffers 與 actual time 的變化。

Conclusion: 用自己的話寫一段,說明 composite index 的 column 順序(status 在前、created_at 在後)如何同時滿足「篩選」與「排序」兩個需求;並回答:如果把 column 順序反過來建成 (created_at, status),對這句查詢還會不會一樣有效(提示:見 Mechanism 段落「從第一個 column 開始比」的 B-Tree Search 規則)。```

Day 37 — Selectivity / Cardinality → Index Scan vs Sequential Scan → Query Planner

學習目標

看完今天內容後,能夠:

  1. 給定一個欄位的 distinct values 數量與資料總筆數,算出 Cardinality 與 Selectivity,並判斷這個欄位適不適合建 Index。
  2. 解釋 Planner 決定用 Index Scan 還是 Sequential Scan 的具體成本比較邏輯,而不是背「有 Index 就一定用 Index」這個錯誤直覺。
  3. 讀懂 EXPLAIN 與 EXPLAIN ANALYZE 輸出裡的 cost/rows/actual time/statistics 數字各自代表什麼,並診斷「Planner 選錯計畫」的常見原因。
  4. 解釋 Nested Loop / Hash Join / Merge Join 三種 join 演算法各自的運作機制與成本模型,並給定兩張表的筆數、有無可用 index,推導 Planner 較可能選哪一種。
  5. 解釋 work_mem 如何限制單一 Sort/Hash 操作可用的記憶體、超過時會發生什麼(Disk Spill),並能從 EXPLAIN ANALYZE 輸出判斷一個排序/雜湊操作是否已經溢出到磁碟。
  6. 認得 CTE Materialization 行為變化、max_parallel_workers_per_gather的作用範圍、LEFT JOIN + IS NULL 與 NOT EXISTS 的語意關係這三個查詢優化細節,遇到時看得懂、不會誤判。

教材大綱

1

Selectivity 與 Cardinality

Core FundamentalsLv.4

What:Cardinality 是一個欄位有多少個不同的值(distinct values);Selectivity 是某個查詢條件篩選後,預期剩下的資料佔全體的比例——Selectivity 越低(篩掉的越多),代表這個條件的篩選力越強。在資料分布大致均勻的假設下,Selectivity ≈ 1 / Cardinality(例如一個欄位有 10,000 個 distinct 值且分布均勻,篩「等於某個特定值」的條件,預期只剩 1/10000 的資料)。

Why

Planner 決定要不要使用某個 Index,核心依據就是 Selectivity——如果一個條件的 Selectivity 很差(例如 gender 欄位只有 2 個 distinct 值,篩選後仍剩 50% 的資料),用 Index 找到這 50% 的 CTID 後,還要對每一個都額外做一次 heap fetch(且這些 heap fetch 幾乎都是隨機 I/O,見 Day 36 Covering Index 段落),總成本可能比直接 Seq Scan(循序讀、對磁碟更友善)還要高;反過來,一個 Selectivity 很好的條件(例如篩 email = '特定值',Cardinality 幾乎等於總筆數),用 Index 幾乎總是划算。

Mechanism

PostgreSQL 用 ANALYZE(可手動執行,也會被 autovacuum 背景自動觸發)掃描一部分資料,把統計結果存進系統目錄pg_stats,關鍵欄位包括 n_distinct(估計的 distinct 值數量,Cardinality 的來源)、most_common_vals/most_common_freqs(最常出現的幾個值與各自出現頻率,用來處理資料分布不均勻、不能只用簡單的 1/n_distinct 估算的情況)、histogram_bounds(把值域切成多個桶,估算範圍查詢如 WHERE created_at > ? 會剩多少資料)。Planner 每次規劃查詢時,都會讀這些統計資訊來估算每個候選計畫的cost,而不是重新掃一次真實資料去算。這些統計的抽樣精細度預設由default_statistics_target(預設 100)決定,若某個欄位的分布特別複雜、預設抽樣精細度不足以估準(例如上面提到的 most_common_vals/histogram_bounds 對這個欄位算出來的結果常常偏差很大),可以只對這一欄單獨調高,不用動全域設定:ALTER TABLE table_name ALTER COLUMN column_name SET STATISTICS n;(n 可設到 10000,數字越大下次 ANALYZE 對這欄抽樣的筆數越多、統計越準,但 ANALYZE 本身也會花更多時間)。

Trade-off / Failure Modes:這裡的核心風險是統計過時——資料在大量 INSERT/UPDATE/DELETE 後,pg_stats 裡的數字若沒有重新 ANALYZE,會跟實際資料分布脫節。例如一個欄位原本值很分散(n_distinct 很大),但業務邏輯改變後大量新資料都集中在少數幾個值上,若統計沒更新,Planner 仍依照舊的(過度樂觀的)Selectivity 估算去選 Index Scan,實際執行時卻要對大量結果逐一做 heap fetch,造成「EXPLAIN 估計很快、實際跑起來很慢」的落差——這正是為什麼EXPLAIN ANALYZE(會真的執行一次並記錄實際 rows/time)比純EXPLAIN(只給估計值)更能診斷這類問題。

Backend Applications:Backend 工程師在大量批次匯入/刪除資料後,應該主動執行 ANALYZE table_name;(而不是被動等 autovacuum 排程觸發),確保接下來的查詢 Planner 拿到的是新鮮的統計資訊,尤其是資料分布劇烈改變(例如批次清空重灌)的場景。

陌生題示範:情境——products 表有 200 萬筆資料,category_id欄位 pg_stats 顯示 n_distinct = 50(只有 50 種分類),sku(商品編號)欄位 n_distinct 接近 2,000,000(幾乎每筆都不同)。請判斷 WHERE category_id = ? 與 WHERE sku = ? 這兩種查詢,Planner 各自比較可能選哪種 scan,並解釋為什麼即使兩個欄位都建了 Index,判斷結果會不一樣。推導:category_id 的 Selectivity 約1/50 = 2%,篩選後仍剩約 2,000,000 × 2% = 40,000 筆——這代表要做 4 萬次 heap fetch(大部分是隨機 I/O),成本可能高於直接 Seq Scan 一次循序讀完整張表;sku 的 Selectivity 約 1/2,000,000,篩選後幾乎只剩 1 筆,Index Scan 只需要 Day 35 算過的樹高次數 I/O 再加 1 次 heap fetch,遠比 Seq Scan 便宜。同一張表、都建了 Index,Planner 卻可能對 category_id 選 Seq Scan、對 sku 選 Index Scan——這說明「有沒有 Index」跟「這次查詢實際會不會用到 Index」是兩個獨立的問題,後者完全取決於 Selectivity。

2

Index Scan vs Sequential Scan:Planner 的成本比較

Core FundamentalsLv.4

What:Index Scan 是沿著 Day 33 學過的 B-Tree Search 找到符合條件的 CTID 再逐一 heap fetch;Sequential Scan 是 Day 31 學過的「把整張表的每個 Page 循序讀一遍」。Planner 對同一句查詢,會分別估算兩種(甚至更多種,例如 Bitmap Index Scan)計畫各自的 cost,選 cost 最低的那個執行。

Why

如果沒有這一層成本比較,資料庫要嘛「永遠用 Index」(Day 37 前段已證明這在 Selectivity 差的情況下反而更慢),要嘛「永遠不用 Index」(讓 Index 形同虛設)——都是不合理的極端。Planner 存在的價值正是「依實際資料分布動態選擇」。

Mechanism

PostgreSQL 的 cost 模型用兩個關鍵設定值換算「I/O 次數」與「抽象 cost 單位」:seq_page_cost(循序讀一個 Page 的成本,預設 1.0)與 random_page_cost(隨機讀一個 Page 的成本,預設 4.0——因為隨機 I/O 通常比循序 I/O 貴,這個預設值反映傳統機械硬碟的特性,在 SSD 為主的環境常會被調低,例如設成 1.1)。Seq Scan 的 cost 大致是「總 Page 數 × seq_page_cost」;Index Scan 的 cost 大致是「樹高(Day 35)× random_page_cost(走 B-Tree 通常是隨機 I/O)+ 預期符合條件的筆數 × random_page_cost(heap fetch,也是隨機 I/O)」。Selectivity 越差,Index Scan 公式裡「預期符合條件的筆數」就越大,cost 就越接近甚至超過 Seq Scan。

Trade-off

random_page_cost 這個設定值本身就是一種取捨——調低它(更接近 SSD 的真實隨機讀取成本)會讓 Planner 更傾向選 Index Scan;調得太低(低估真實隨機 I/O 成本)則可能讓 Planner 對 Selectivity 沒那麼好的查詢也選了 Index Scan,反而變慢。這代表 Planner 的判斷品質,一部分依賴於這些成本參數是否貼近實際硬體特性。

Failure Modes:常見誤解是「EXPLAIN 顯示 Seq Scan 就代表 Index 沒生效/壞掉」——多數時候這是 Planner 依照 Selectivity 與 cost 模型做出的正確選擇(例如篩選力很差的條件,或整張表本來就很小、Seq Scan 幾頁就讀完,Index 反而多一層開銷)。診斷時應該先確認 Selectivity 與資料量,而不是預設「沒用到 Index 就是壞掉」。

Backend Applications:Backend 工程師若在同一台機器上把資料庫從機械硬碟遷移到 SSD,應該同時檢查並調整 random_page_cost(例如從預設 4.0 調到 1.1),否則 Planner 仍照舊的成本模型運作,可能在新硬體上做出並非最優的計畫選擇。

陌生題示範:情境——一張 100 萬筆資料的表,WHERE status = ?的 Selectivity 是 30%(篩選後剩 30 萬筆),已建 Index。請判斷 Planner 比較可能選 Seq Scan 還是 Index Scan,並說明如果把random_page_cost 從 4.0 調到 1.1(模擬換成 SSD),判斷會不會改變。推導:Selectivity 30% 代表要對 30 萬筆做 heap fetch,用random_page_cost = 4.0 計算,Index Scan 的 cost 大約是300,000 × 4.0 = 1,200,000(樹高部分相對很小可忽略),而 Seq Scan 掃完整張表(假設約 10,000 個 page)的 cost 大約是10,000 × 1.0 = 10,000——Index Scan 貴了 100 倍以上,Planner 會選 Seq Scan;把 random_page_cost 調到 1.1,Index Scan 的 cost 變成 300,000 × 1.1 = 330,000,仍遠高於 Seq Scan 的 10,000,判斷不會改變——因為問題根源是 Selectivity 太差(30% 太高),不是硬體隨機讀取成本,這說明「換 SSD」對 Selectivity 差的查詢幫助有限,真正該做的是重新設計查詢或索引(例如改用更能篩選的複合條件),而不是只調整成本參數。

3

Query Planner:EXPLAIN / EXPLAIN ANALYZE / Cost / Statistics / Planner Decision

Core FundamentalsLv.4

What:EXPLAIN 顯示 Planner 估計的執行計畫(不會真的執行查詢),輸出每個計畫節點的 cost=startup_cost..total_cost(開始輸出第一筆結果的估計成本、輸出全部結果的估計成本)、rows(估計會輸出幾筆)、width(估計每筆平均多寬)。EXPLAIN ANALYZE 會真的執行這句查詢,額外印出 actual time=startup..total(實際耗時)與 actual rows(實際輸出筆數),讓你能對照「Planner 猜的」跟「實際發生的」差多少。加上 BUFFERS選項(EXPLAIN (ANALYZE, BUFFERS))還會顯示 Buffers: shared hit=X read=Y(Day 32 學過的 Buffer Pool 命中/未命中次數)。

Why

一句 SQL 語法正確不代表執行方式正確(Day 31 已說明 Parser 抓不出計畫選錯的問題),而執行計畫是唯一能看見資料庫「打算怎麼做」的地方;沒有 EXPLAIN 這類工具,Planner 的選擇就是黑盒子,查詢變慢時只能憑猜測改 SQL 或亂加 Index。EXPLAIN 讓 Planner 的決定可以被觀察,EXPLAIN ANALYZE 再把「估計」與「實際」並排,讓你能分辨問題是計畫選錯、統計不準,還是資料量本身就大。

Mechanism(Planner Decision 的完整流程):Planner 針對一句查詢,會先列出所有邏輯上可行的計畫(例如「Seq Scan」「Index Scan using idx_a」「Index Scan using idx_b」「Bitmap Index Scan 合併 idx_a 與 idx_b」……),依 pg_stats 的統計資訊(Selectivity/Cardinality)與 seq_page_cost/random_page_cost 等成本參數,分別估算每個計畫的 total_cost,選 total_cost 最低的那一個執行——這是一個窮舉+比價的過程,不是規則式的「有 Index 就用」。

Trade-off

EXPLAIN ANALYZE 因為要真的執行查詢,若這句查詢本身是一句很慢的 UPDATE/DELETE,EXPLAIN ANALYZE 會真的把資料改掉(不像純 EXPLAIN 只是估算不執行)——診斷寫入類查詢時,若不想真的執行,應該包在一個 transaction 裡跑完EXPLAIN ANALYZE 後 ROLLBACK,而不是直接對線上資料庫跑。

Failure Modes:rows(估計)與 actual rows(實際)差距過大,是「統計過時」或「Planner 對複雜條件的關聯性估算不準」最直接的證據——例如 WHERE a = 1 AND b = 2,若 Planner 假設 a 與 b互相獨立各自估算 Selectivity 再相乘,但實際上 a 與 b 高度相關(例如 a 決定了 b 的值),估計出來的筆數會遠低於實際筆數,導致 Planner 選錯計畫。PostgreSQL 較新版本支援CREATE STATISTICS 手動告訴 Planner「這兩個欄位有關聯,不要假設獨立」,緩解這類問題。

Backend Applications:Backend 工程師在做慢查詢診斷時,標準第一步永遠是 EXPLAIN (ANALYZE, BUFFERS),比對 rows 與actual rows、Buffers: hit 與 read 的比例,先確認問題出在「Planner 估計錯誤」還是「cold cache(Day 32)」還是「Selectivity 本來就差、無法用 Index 解決」,再決定該加 Index、改寫查詢、還是重新設計 schema。

陌生題示範:情境——EXPLAIN ANALYZE 顯示某句查詢的計畫節點估計 rows=50,但 actual rows=48000,執行時間比 Planner 估計的多了 20 倍。請推導最可能的原因與該做的下一步。推導:估計筆數與實際筆數相差近 1000 倍,代表 Planner 對這個條件的 Selectivity 估算嚴重失準——最可能的原因是相關欄位的統計過時(大量資料變動後沒重跑 ANALYZE),或者查詢用了多個彼此相關但被當獨立估算的條件(前述 Failure Modes 的關聯性問題)。下一步應先執行ANALYZE table_name; 重新收集統計後再跑一次同樣的EXPLAIN ANALYZE,若估計值大幅貼近實際值,證實是統計過時;若仍差很多,則進一步用 CREATE STATISTICS 告訴 Planner 欄位間的關聯性,而不是直接調整成本參數(那治標不治本)。

4

JOIN 演算法:Nested Loop / Hash Join / Merge Join

Core FundamentalsLv.4

What:當一句查詢需要合併兩張表(或兩個中繼結果)的資料時,Planner 在 Nested Loop Join、Hash Join、Merge Join三種演算法之間選擇一種來實際執行 join。三者的核心差異在於「怎麼找出兩邊符合 join 條件的配對」:Nested Loop 對外層(outer)的每一筆資料,逐一去內層(inner)找配對;Hash Join 先把其中一邊(通常是較小的一邊)在記憶體裡建成一個以 join key 為索引的雜湊表,再用另一邊逐筆探測(probe);Merge Join 要求兩邊都已經依 join key 排序,像合併兩份排好序的清單一樣同步往前走,一次配對完成。

Why

如果只有一種 join 演算法,資料庫要嘛在小表/有索引的場景效率低(例如永遠用 Hash Join,即使 outer 只有幾筆也要為 inner 整個建雜湊表),要嘛在大表且無索引場景效率極差(例如永遠用 Nested Loop,等同於雙層迴圈全表比對,複雜度 O(n×m))。三種演算法在「outer/inner 筆數」「有沒有可用 index」「資料是否已排序」等不同條件下的成本落差很大,Planner 需要像本天前兩段選 Index Scan/Seq Scan 一樣,比較三者的估計 cost 再挑最低的。

Mechanism

  • Nested Loop Join:對 outer 端的每一列,掃一次 inner 端找符合 join 條件的列。若 inner 端在 join key 上有可用 index(Index Scan),每次 inner 查找的成本接近 Day 35 算過的 B-Tree 樹高 +heap fetch,總成本大致是 outer_rows × (單次 inner index lookup 成本);若 inner 端沒有可用 index,每次 inner 查找都要整個 Seq Scan 一次 inner 表(除非 Planner 插入一個 Materialize 節點,把 inner 端結果快取起來避免對同一份資料重複做實體 I/O),成本趨近outer_rows × inner_table_size,outer 稍大就會非常昂貴。
  • Hash Join:分兩階段。Build 階段:把兩邊裡 Planner 依統計判斷較小的一邊完整讀一次,依 join key 算雜湊值建成記憶體內的雜湊表。Probe 階段:讀另一邊(較大的一邊)每一列,算同樣的雜湊值去雜湊表裡查有沒有配對。兩階段各自只需要把對應的 relation 完整讀過一次,總成本大致是 build_rows + probe_rows(線性量級),不會因為 outer 筆數變多而重複觸發大量個別查找。Hash Join 只能用在 join 條件是等值比較(=)的情況,因為雜湊表只能做等值查找。
  • Merge Join:前提是兩邊資料都已經按 join key 排序好——可能是本來就用 Index Scan 依序讀出(B-Tree index 本身依 key 排序),或者 Planner 額外插入一個 Sort 節點先排序。排序完成後,兩個指標各自從頭往後走:比較目前兩邊指標的 key,相等就輸出配對並視情況移動指標、不相等就移動 key 較小的那一邊的指標,像合併排序(merge sort)的合併步驟一樣,整個過程只需要各自掃過一次(若本來就排序好),成本同樣是線性量級;但如果任一邊需要額外 Sort,Sort 本身的成本是 O(n log n)(大量資料排序若超出可用記憶體,會退化成外部排序,牽涉暫存磁碟 I/O,這部分成本可能抵銷掉 Merge Join 本身的優勢)。
Trade-off

三者是典型的「不同前提下各自最省」而非「有一種絕對最好」——Nested Loop 在 outer 小、inner 有索引時幾乎零額外開銷,但 outer 一旦變大且 inner 缺索引就會急遽變貴;Hash Join 對大表等值 join 通常穩定(線性量級),但代價是需要足夠的記憶體放下 build 端的雜湊表,記憶體不夠時會退化成分批(batch)處理,多次讀寫暫存磁碟;Merge Join 在兩邊本來就排序好(例如兩邊都依同一個 key 建了 index)時幾乎免費,但如果兩邊都要額外排序,那筆 Sort 成本可能比直接做 Hash Join 更貴。

Failure Modes:常見誤解是「join 慢就是缺 index」,但如果 Planner 選的是 Hash Join,效能瓶頸可能出在可用記憶體太小導致雜湊表放不下、被迫多輪 batch(EXPLAIN ANALYZE 的 Hash 節點會顯示Batches 數字,大於 1 就代表發生了這種退化,需要更多磁碟 I/O 才能完成);另一個常見錯誤是誤以為 Nested Loop 一定是最差的演算法而人為避免它——但當 outer 端經過前面的 WHERE 條件篩選後只剩極少筆、且 inner 端有可用 index 時,Nested Loop 反而是三者裡最便宜的,強迫 Planner 改用其他演算法在這種場景下反而更慢。

Backend Applications:Backend 工程師在診斷慢查詢時,看到EXPLAIN ANALYZE 顯示 Hash Join 且 actual time 遠高於預期,第一件事該檢查 Hash 節點的 Batches 是否 >1;看到 Nested Loop 但 outer 端 actual rows 意外地大(原本預期篩選後應該很少筆,但統計過時或條件寫錯導致篩不掉),代表 Planner 是基於錯誤的(過小的)rows 估計選了 Nested Loop,實際執行卻要做大量個別 inner 查找——這也呼應本天前段反覆強調的重點:join 演算法的選擇,跟 Index Scan vs Seq Scan 一樣,完全依賴 Selectivity/Cardinality 統計是否新鮮。

陌生題示範:情境——orders 表 500 萬筆,order_items 表 3,000 萬筆,兩表分別在 orders.id(primary key)與order_items.order_id 上有 B-Tree Index。查詢:

``sql SELECT * FROM orders o JOIN order_items oi ON o.id = oi.order_id WHERE o.status = 'pending';``

status = 'pending' 的 Selectivity 使篩選後 orders 只剩 200 筆,order_items 沒有其他過濾條件。請推導 Planner 較可能選哪種 join 演算法,並說明如果拿掉 WHERE o.status = 'pending'(篩選後的orders 變成全部 500 萬筆都要 join),Planner 的選擇會不會改變、為什麼。

推導:有 filter 時,outer 端(篩選後的 orders)只剩 200 筆,這正是 Nested Loop 的理想場景——對這 200 筆各自用 order_items.order_id的 index 做一次 Index Scan 查找配對列,總成本大致是200 × (樹高 + 少量 heap fetch),遠低於把 3,000 萬筆的order_items 完整讀一次(Hash Join 至少要對較大的一邊做一次 Seq Scan);Planner 會選 Nested Loop Join(inner 走 Index Scan)。拿掉 filter 後,outer 端變成全部 500 萬筆都要 join,若仍用 Nested Loop,代表要對 order_items 的 index 做 500 萬次個別 lookup——依本天前段的 Selectivity/cost 邏輯,這麼多次個別隨機 I/O 的總成本會遠超過「把兩表各自完整讀一次」;這時 Hash Join 更划算:用 orders(500 萬筆)當 build 端建雜湊表、order_items(3,000 萬筆)當 probe 端各自只需被完整讀取一次,總成本量級是O(5,000,000 + 30,000,000),遠比 500 萬次個別 index lookup 便宜。因此拿掉 filter 後,Planner 的選擇會從 Nested Loop 換成 Hash Join——outer 端筆數越大,Nested Loop 的個別 index lookup 次數就越多,超過某個交叉點後,「兩邊各自完整讀一次再雜湊比對」的 Hash Join 反而更便宜,這跟 Selectivity 越差、Index Scan 越不划算是同一套「筆數推高單一策略的重複成本」的邏輯。

5

work_mem / Disk Spill

Supporting TopicsLv.3

What:work_mem 是一個 PostgreSQL 設定值,限制單一Sort(例如 ORDER BY、DISTINCT、Merge Join 前置排序)或 Hash(例如 Hash Join 的 build 端、Hash Aggregate)操作可以使用的記憶體上限——注意是「每一個運算節點」各自的上限,不是整個查詢、更不是整個連線的總量。

Why

如果 Sort/Hash 可以無限制使用記憶體,單一句複雜查詢(可能同時有好幾個 Sort/Hash 節點)在高並發下就可能把伺服器整台記憶體耗盡;work_mem 讓每個運算節點的記憶體用量有一個可預期的上限,超過時改用磁碟繼續完成運算而不是直接失敗或拖垮整台機器。

Mechanism

當一個 Sort 節點需要處理的資料量超過 work_mem,PostgreSQL 不會報錯,而是改用外部合併排序(external merge sort)——先把資料切成多個能放進 work_mem 的批次,各自在記憶體內排序後寫成暫存檔,再合併這些已排序的暫存檔產生最終結果;Hash 操作超過 work_mem 時則是把雜湊表切成多個批次(batches),分批寫入與讀取暫存磁碟檔案。EXPLAIN ANALYZE 能直接看出有沒有發生這種情況:沒溢出時 Sort 節點顯示 Sort Method: quicksort Memory: XXXkB;溢出時顯示 Sort Method: external merge Disk: XXXkB(Hash 節點則是看Batches 數字是否大於 1)。

Trade-off

調高 work_mem 讓更多操作能留在記憶體內完成(通常快很多),但這個上限是每個操作各自的配額——一個查詢若有 3 個 Sort/Hash 節點、同時有 50 個連線都在跑類似查詢,尖峰時的記憶體用量可能逼近 work_mem × 3 × 50,而不是 work_mem × 50。全域調高work_mem(postgresql.conf)看似讓查詢變快,但沒考慮到這個乘數效應,流量尖峰時反而可能把伺服器記憶體榨乾;調太低則會讓資料量稍大的排序/雜湊頻繁溢出到磁碟,即使伺服器本身記憶體充足也一樣變慢。

Failure Modes:測試環境資料量小、Sort/Hash 都留在記憶體內、效能正常;上線後資料量成長,同一句查詢開始溢出到磁碟,actual time 明顯變慢但 Planner 選的計畫形狀(Seq Scan/Index Scan、Sort 節點)完全沒變——這種「計畫沒變、但突然變慢」的情況,work_mem不足導致 Disk Spill 是常被忽略的排查方向之一。另一個常見誤區是只看伺服器整體記憶體用量很低就排除 work_mem 問題,卻沒發現真正會被記憶體榨乾的情況只發生在流量尖峰、多個連線同時跑重查詢的瞬間,平常監控看到的是低估的平均值。

Backend Applications:Backend 工程師遇到單一句已知很吃記憶體的查詢(例如夜間報表的大排序),比起冒風險全域調高 work_mem,更安全的做法是只針對這個 session/這句查詢用SET work_mem = '256MB'; 提高上限,執行完這個連線結束後設定就失效,不影響其他一般查詢的記憶體配額。

可執行練習:沿用 Day 36 建立的 bench_orders2(50 萬筆),對一個沒有 index 可用、必須真的排序的欄位(user_id)跑排序,比較預設work_mem 與調高後的差異:

```sql SHOW work_mem; -- 確認目前設定(未特別調整過通常是預設 4MB)

EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM bench_orders2 ORDER BY user_id;-- user_id 沒建 index,Planner 必須先取出全部 50 萬筆再排序,-- 預期在預設 work_mem(4MB)下這筆資料量會超過上限,Sort 節點顯示-- Sort Method: external merge Disk: XXXkB

SET work_mem = '64MB'; -- 只在本 session 生效,調高到足以放下整批排序資料

EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM bench_orders2 ORDER BY user_id;-- 預期 Sort 節點改顯示-- Sort Method: quicksort Memory: XXXkB-- 對照兩次的 actual time 與 Buffers read 數字差異```

6

查詢優化 Awareness 補充:CTE Materialization / Parallel Workers / JOIN 語意改寫

Awareness TopicsLv.2
一句話理解

三個容易在讀 EXPLAIN 輸出或寫查詢時漏看、但先聽過看得懂就能少踩一次坑的細節,要求是「看到時認得、解釋得出來」,不要求推導 trade-off 或另外準備陌生題。

  1. CTE Materialization(PostgreSQL 12+ 的行為改變):12 之前,WITH 子句(CTE)永遠是一道最佳化屏障——Planner 一定先把 CTE 算完、暫存起來,外層查詢即使只用得到其中一小部分,也無法把外層的篩選條件推進 CTE 內部一起最佳化。12 起,非遞迴 CTE 預設改成像一般子查詢一樣可以被 inline(外層條件可以推進去),如果仍想保留舊行為(例如刻意只想讓一段昂貴運算只跑一次,不被展開重算),要明確加 MATERIALIZED 關鍵字:WITH cte AS MATERIALIZED (...)。踩坑例子:一段在 PostgreSQL 11 上用 CTE 包裝、只想算一次的昂貴子查詢,升級到 12+ 後可能被 Planner 展開成多次重算(預設不再自動 materialize),效能反而變差,要補上 MATERIALIZED 才能拿回原本的行為。
  1. Parallel Workers(max_parallel_workers_per_gather):對夠大的 Seq Scan/聚合等操作,Planner 可能選擇平行計畫,用多個背景 worker process 同時處理不同資料區塊,最後由 Gather(或Gather Merge)節點收攏結果;max_parallel_workers_per_gather決定單一查詢節點最多能配到幾個 worker。踩坑例子:調高這個值不保證查詢變快——資料量太小的表 Planner 根本不會選平行計畫(平行化本身有 worker 啟動與結果彙整的固定開銷,小資料量划不來),而且整個系統實際可動用的 worker 數還受 max_worker_processes/max_parallel_workers 這兩層更上面的全域上限節制,單一查詢的上限調再高也拿不到超過全域剩餘額度的 worker;設成 0 則是常見的除錯手法,用來確認某次效能差異是不是平行化造成的。
  1. JOIN 語意改寫(LEFT JOIN + IS NULL vs NOT EXISTS → Anti Join):「找出 A 表裡沒有出現在 B 表的資料」常見的兩種寫法——LEFT JOIN B ON ... WHERE B.id IS NULL 與WHERE NOT EXISTS (SELECT 1 FROM B WHERE ...)——邏輯上等價,PostgreSQL Planner 通常都能把兩者最佳化成同一種底層節點(Anti Join:outer 端每一列只要在 inner 端找到至少一個符合的列就整列排除,不需要真的列出所有符合的 inner 列)。踩坑例子:當 B 表的 join 欄位本身可能是 NULL 時,LEFT JOIN ... WHERE B.id IS NULL 在某些條件組合下會因為三值邏輯(NULL 比較結果既非真也非假)算出跟預期不同的結果;NOT EXISTS 對這種情況通常更直覺、不容易寫錯,這也是多數風格指南建議優先用 NOT EXISTS的原因,即使兩者常被 Planner 最佳化成同一種執行計畫。

Day 37 過關標準(DoD)—— Database 特殊驗收格式

```text Query: 沿用 Day 36 的 bench_orders2 表,構造一個 Selectivity 差的查詢與一個 Selectivity 好的查詢做對照:SELECT * FROM bench_orders2 WHERE status = 'pending'; -- 差 SELECT * FROM bench_orders2 WHERE id = 12345; -- 好

EXPLAIN: 分別執行 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM bench_orders2 WHERE status = 'pending';EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM bench_orders2 WHERE id = 12345;記錄兩者的計畫節點(Seq Scan 或 Index Scan)、rows 估計值、actual rows、actual time。

Hypothesis: 依今天教材,status 條件 Selectivity 差(只有 2 個值,篩選後仍剩約 50%),預期 Planner 選 Seq Scan;id 是 primary key,Selectivity 極好(篩選後只剩 1 筆),預期 Planner 選 Index Scan。

Change: 若實際結果與預測不同,用 SELECT n_distinct, most_common_vals FROM pg_stats WHERE tablename='bench_orders2' AND attname='status';核對統計資訊是否與預期相符;若 status 分布不是均勻的 pending/completed 各半,重新設計陌生題的推導。

Benchmark: 對比兩句查詢的 actual time 與 Buffers 數值差異。

Conclusion: 用自己的話寫一段,解釋為什麼同一張表、同樣都有 Index 可用,Planner 對兩句查詢做出不同選擇,並回答:如果把 random_page_cost 調低(SET random_page_cost = 1.1;,僅在當前 session 生效)重跑 status 那句查詢,計畫節點會不會改變、為什麼(提示:見 Selectivity 30% 那道陌生題的推導)。```

Day 38 — Transactions 基礎 + MVCC:多個操作同時發生時,資料庫怎麼保持資料一致

學習目標

看完今天內容後,能夠:

  1. 不依賴任何 Phase 05 的詞彙,獨立說出「兩個 transaction 同時對同一筆資料操作」會出現什麼現象,以及 Transaction 這個抽象存在的理由。
  2. 解釋 MVCC 如何用 Version/Snapshot 讓每個 transaction 看到「一致的某個時間點快照」,而不用整個資料庫上一把大鎖擋住所有人。
  3. 對一組具體的 xmin/xmax 值與 transaction ID,判斷某一筆資料對某個 transaction 而言是否可見(Visibility 判斷)。

教材大綱

0. 獨立的併發存取情境定義(不依賴 Phase 05 概念)

在展開 MVCC 之前,先建立一個完全自足、不需要 Phase 05(Concurrency,Day 41–50)任何詞彙的情境:想像兩個完全獨立的應用程式請求,幾乎同時對同一張 accounts 表的同一筆資料(id = 1,balance = 100)發出操作——請求 A 要執行「查詢目前餘額,扣款 30」,請求 B 要執行「查詢目前餘額,扣款 50」。如果資料庫沒有任何機制保護,可能發生:A 讀到 balance = 100,還沒寫回,B 也讀到 balance = 100(因為 A 還沒寫回),B 算出 100 - 50 = 50 並寫回;接著 A 算出100 - 30 = 70 並寫回——最終 balance 變成 70,而不是正確的100 - 30 - 50 = 20。B 的扣款憑空消失了。Transaction(交易)是資料庫用來防止這類問題的抽象邊界:把「讀取餘額」到「寫回新餘額」包成一個不可分割的單位,資料庫保證同一時刻只有一個 transaction 真正「看得見」某個中間狀態,或至少提供明確、可調整的規則(Day 39 的 Isolation Level)去限制這種交錯可能造成的後果。這個「多個操作同時讀寫同一筆資料」的現象,本身不需要任何程式語言層級的執行緒/goroutine 知識就能理解——它是資料庫這個共享資源天生就會遇到的問題,跟 Phase 05 之後要學的「同一個程式內部多個 goroutine 互相搶記憶體」是同一個抽象問題在不同層級的重現,但這裡先只在資料庫這一層把它講清楚。

1

MVCC(Multi-Version Concurrency Control):Version / Snapshot / Visibility

Core FundamentalsLv.4

What:MVCC 是 PostgreSQL(以及多數現代資料庫)用來處理上述併發問題的核心機制:不是靠鎖住整筆資料擋住其他人,而是「每次UPDATE 不覆寫原本的資料,而是新增一個帶版本資訊的新版本(Version)」,每個 transaction 開始時拿到一份快照(Snapshot),只能看到快照當下「已經確定提交」的版本,藉由這種「各自看各自的版本」設計,讓讀取完全不需要等待寫入(也不會被寫入擋住),寫入也不需要等待讀取。

Why

如果沒有 MVCC、只靠鎖來處理上述情境,最直接的做法是讓讀取與寫入互相等待(寫的時候擋住所有讀、讀的時候擋住寫)——一個跑很久的報表查詢就能讓整張表的更新全部排隊,並行度極差。MVCC 的出發點是:既然問題來自「大家共用同一份唯一的資料」,那就讓每次修改產生新版本、每個 transaction 各自看自己該看的版本,讀寫因此不必互相等待;代價與細節見下面的 Mechanism 與 Trade-off。

Mechanism(Version):PostgreSQL 裡每一筆資料的實體列(heap tuple)都帶有兩個隱藏欄位:xmin(建立這個版本的 transaction ID)與 xmax(讓這個版本失效的 transaction ID,尚未被任何後續操作取代時為空/0)。UPDATE 在 PostgreSQL 的實作裡,實際上是「把舊版本的 xmax 設成目前這個 transaction 的 ID(標記舊版本失效),同時插入一個新版本,新版本的 xmin 是目前這個 transaction 的 ID」——舊版本並沒有被立刻刪除,只是被標記「對之後的 transaction 不再可見」,之後由 VACUUM(Day 36 Covering Index 段落提過的 visibility map 維護機制之一)背景清理真正回收空間。

Mechanism(Snapshot 與 Visibility):每個 transaction 開始時(依 Isolation Level 不同,時機略有差異,Day 39 展開),會記下「目前哪些 transaction 已經提交、哪些還在進行中」這份資訊,成為它的 Snapshot。之後這個 transaction 讀任何一筆資料的任何一個版本時,都要做 Visibility 判斷:這個版本的 xmin 對應的 transaction,在我的 Snapshot 裡是否已經提交?如果還沒提交(或xmin 就是我自己這個 transaction 但操作發生在目前這個查詢之後),這個版本對我不可見;如果這個版本的 xmax 已經被設定且對應的 transaction 已提交,代表這個版本已經被取代,同樣不可見。只有「xmin 對應的 transaction 已提交、且 xmax 為空或對應的 transaction 尚未提交」的版本,才是這個 Snapshot 看得到的正確版本。

Trade-off

MVCC 用「保留多個版本、讓讀寫互不阻擋」換來極佳的讀寫並行能力,代價是(1)需要額外的儲存空間存放舊版本,直到VACUUM 回收;(2)每次讀取都要做 Visibility 判斷,比「資料庫裡永遠只有一份最新版本、直接讀」多一點 CPU 開銷;(3)長時間不提交的 transaction 會讓大量舊版本無法被 VACUUM 回收(因為理論上那個還沒結束的 transaction 可能仍需要看到某個舊版本),造成表膨脹(bloat)。

Failure Modes:最常見的營運問題是長時間開著不提交的 transaction(例如應用程式忘記 commit、或一個互動式 session 開了 transaction 後掛著不動)——因為 MVCC 必須保留「這個 transaction 的 Snapshot 可能還需要看到」的所有舊版本,這類長 transaction 會讓VACUUM 無法清理大量本該回收的舊版本,久了造成表與 Index 大幅膨脹、查詢變慢;診斷時可以查pg_stat_activity 裡 xact_start 很久以前、state 仍是idle in transaction 的連線,找出並終止它們。

Backend Applications:Backend 工程師寫應用程式碼時,若使用連線池(connection pool)搭配 ORM 的 transaction 管理,最容易犯的錯誤就是「開了 transaction 之後在裡面做一個很慢的外部 API 呼叫」——外部呼叫拖多久,這個 transaction 就開多久,直接影響上面提到的表膨脹問題;正確做法是把「跟資料庫無關的慢操作」移到 transaction 之外執行。

陌生題示範:情境——Transaction T1(ID=100)在時間點 A 讀取accounts 表 id=1 這筆資料,此時看到的版本是 xmin=90,xmax=NULL, balance=100。在 T1 讀取之後、還沒提交之前,另一個 Transaction T2(ID=101)執行 UPDATE accounts SET balance = 50 WHERE id = 1; 並且已經提交。請推導:如果 T1 在提交前,於同一個 transaction 內再讀一次同一筆資料,會看到 balance是 100 還是 50?(提示:這正是 Day 39 要展開的 Isolation Level 差異來源)推導:這個答案取決於 T1 使用的 Isolation Level——若是 Repeatable Read(或更嚴格的 Serializable),T1 的 Snapshot 是在 transaction 一開始就固定的,即使 T2 之後提交了新版本(xmin=101 的新版本 balance=50),這個新版本對 T1 的 Snapshot 而言「xmin 對應的 transaction(101)在我的 Snapshot 建立時還沒提交」,仍然不可見,T1 兩次都會讀到 balance=100;若是 Read Committed(PostgreSQL 預設),T1 的 Snapshot 是每一句語句(而非整個 transaction)開始時才重新建立,第二次讀取會拿到一個新的 Snapshot,這時 T2 已提交,T1 第二次讀取會看到balance=50——同一個 transaction 內兩次讀到不同結果,這正是 Day 39 要學的 Non-repeatable Read 現象,MVCC 的 Version/Snapshot/Visibility 機制正是讓「該不該看到別人已提交的新版本」這件事,可以透過切換 Isolation Level 精確控制,而不是寫死的行為。

Day 38 過關標準(DoD)—— Database 特殊驗收格式

```text Query: 開兩個獨立的 psql 連線(session A、session B),對同一張測試表製造一次可觀察的 MVCC 行為:CREATE TABLE mvcc_demo (id int PRIMARY KEY, balance int);INSERT INTO mvcc_demo VALUES (1, 100);

-- Session A:BEGIN;SELECT * FROM mvcc_demo WHERE id = 1; -- 記錄看到的 balance

-- Session B(在 A 尚未 COMMIT 前執行並 COMMIT):UPDATE mvcc_demo SET balance = 50 WHERE id = 1;

-- Session A(B 已提交後,仍在同一個未提交的 transaction 內):SELECT * FROM mvcc_demo WHERE id = 1; -- 再次記錄看到的 balance COMMIT;

EXPLAIN: 用 SELECT xmin, xmax, * FROM mvcc_demo;(在兩次 SELECT 前後都跑一次,可在 session A 或另開第三個 session 執行)觀察 xmin/xmax 的實際數值變化,對照今天教材的 Version 機制。

Hypothesis: 依今天陌生題推導,先寫下你的 PostgreSQL 預設 Isolation Level(SHOW transaction_isolation;,預設是 Read Committed)下,你預期 session A 的兩次 SELECT 會不會看到不同的 balance 值。

Change: 若想觀察兩種行為的對照,把 session A 的 BEGIN 改成BEGIN ISOLATION LEVEL REPEATABLE READ; 重跑一次整個流程。

Benchmark: 記錄 Read Committed 與 Repeatable Read 兩種模式下,session A 兩次 SELECT 各自看到的 balance 值。

Conclusion: 用自己的話寫一段,說明你觀察到的結果是否與 Hypothesis 一致,並回答:MVCC 的哪一個機制(Version/Snapshot/Visibility)分別對應「B 的新版本被建立」「A 決定要不要看到這個新版本」「A 實際判斷某個版本可不可見」這三件事各自發生在哪裡。```

Day 39 — Isolation Levels 與 Concurrency Problems:四個等級、四種異常

學習目標

看完今天內容後,能夠:

  1. 完整列出四種 Isolation Level(Read Uncommitted/Read Committed/Repeatable Read/Serializable)與四種 Concurrency Problem(Dirty Read/Non-repeatable Read/Phantom Read/Lost Update),並畫出兩者的對應關係表。
  2. 對一組給定的兩個 transaction 交錯時序,判斷在指定 Isolation Level 下會不會出現指定的 Concurrency Problem。
  3. 指出 PostgreSQL 的實際行為在哪些地方跟 ANSI SQL 標準的定義不完全一致,並解釋為什麼。

教材大綱

1

Isolation Levels

Core FundamentalsLv.4

What:Isolation Level 是資料庫提供的一組「可以選擇要多嚴格」的規則,決定一個 transaction 執行時能不能看到其他同時進行的 transaction 造成的中間狀態。ANSI SQL 標準定義四個等級,嚴格程度遞增:Read Uncommitted(最寬鬆,理論上能看到別人尚未提交的資料)→ Read Committed(只能看到已提交的資料,但同一個 transaction 內每句語句可能看到不同的已提交狀態,即 Day 38 陌生題展示的行為)→ Repeatable Read(整個 transaction 期間看到的是同一份固定的 Snapshot,同一筆資料兩次讀到的結果保證一致)→Serializable(最嚴格,保證多個 transaction 同時執行的最終結果,等同於它們依某種順序依序執行的結果,不會出現任何交錯導致的異常)。

Why

越嚴格的 Isolation Level 提供越強的正確性保證,但代價是越低的並行度(更容易讓 transaction 互相等待或失敗需要重試)——資料庫把這個選擇權交給應用程式,是因為不同業務場景對「正確性」與「吞吐量」的取捨不同:一個單純的商品瀏覽紀錄可以接受 Read Committed 帶來的些微不一致,但銀行轉帳這類牽涉多筆資料一致性的操作,可能需要 Serializable 才能避免 Day 38 陌生題那種扣款消失的情境。

Mechanism(PostgreSQL 實際行為,與 ANSI 標準的落差):PostgreSQL 只實作了三種行為(雖然 SQL 語法上可以宣告READ UNCOMMITTED,但 PostgreSQL 內部會把它當成READ COMMITTED 處理——PostgreSQL 的 MVCC 設計本質上就不可能讀到未提交的資料,因為 Visibility 判斷(Day 38)明確要求xmin 對應的 transaction 必須已提交):Read Committed(預設)、Repeatable Read、Serializable(PostgreSQL 用一種稱為Serializable Snapshot Isolation, SSI 的技術實作,用「偵測到可能違反 Serializable 保證的交錯」時讓其中一個 transaction 失敗、要求應用程式重試,而不是真的把所有 transaction 排成一個佇列依序執行)。

Trade-off

Serializable 提供最強的保證,但應用程式必須自己處理「transaction 可能因為 serialization failure 而失敗」的情況(捕捉特定的錯誤碼、自動重試),這對應用層的複雜度有直接要求;Repeatable Read 雖然保護範圍比 Serializable 窄(只擋得住同一列的直接寫入衝突,見 Day 39 後段展開的 Lost Update),但一旦真的撞上這類衝突,一樣會讓後到的 transaction 因 serialization failure 失敗、要求應用層重試——不是「不需要處理重試」,只是觸發重試的情境比 Serializable 少見(只有兩個 transaction 真的修改同一列才會撞上,不像 Serializable 連跨列的異常都可能觸發)。

Failure Modes:常見誤解是「Isolation Level 設得越高,效能一定越差」——這不完全準確:Repeatable Read/Serializable 在低衝突的工作負載下(大部分 transaction 存取的資料範圍不重疊),額外成本很小;真正的效能代價集中在高衝突場景(大量 transaction 搶同一小批資料),這時 Serializable 的重試率會明顯上升。診斷效能問題時應該先確認實際衝突率,而不是先入為主認定「調低 Isolation Level 一定變快」。

Backend Applications:Backend 工程師選擇 Isolation Level 時,應該先問「這個操作牽涉幾筆資料、彼此有沒有關聯性、錯誤的中間狀態會造成多嚴重的業務後果」——單筆、無關聯的操作用預設的Read Committed 通常足夠;牽涉多筆資料且彼此有業務邏輯關聯(例如轉帳的兩個帳戶餘額)才需要考慮 Repeatable Read 或Serializable,並準備好處理重試邏輯。

陌生題示範:情境——Transaction T1 執行SELECT count(*) FROM orders WHERE status = 'pending';,得到 100 筆。在 T1 尚未提交前,Transaction T2 INSERT 了一筆新的 pending 訂單並提交。T1 在同一個 transaction 內再執行一次同樣的count(*) 查詢。請分別判斷在 Read Committed 與Repeatable Read 下,T1 第二次會不會看到 101 筆,並說出這對應哪一種 Concurrency Problem。推導:Read Committed 下,T1 的 Snapshot 每句語句都重新建立,第二次查詢會看到 T2 已提交的新增資料,得到 101 筆——這正是下一段要定義的 Phantom Read(同一個條件的查詢,兩次得到的筆數不同,多出/少了符合條件的整列,而不是同一筆資料的值改變);Repeatable Read 下,T1 的 Snapshot 在 transaction 一開始就固定,即使 T2 已提交新資料,對 T1 而言那筆新資料的 xmin 對應的 transaction 在它的 Snapshot 建立時尚未提交,不可見,第二次查詢仍是 100 筆——PostgreSQL 的 Repeatable Read 藉由這個機制,實際上已經防止了 Phantom Read(這點跟 ANSI SQL 標準「Repeatable Read 允許 Phantom Read 發生」的定義不同,是 PostgreSQL 實作比標準更嚴格的一個具體例子)。

2

Concurrency Problems:Dirty Read / Non-repeatable Read / Phantom Read / Lost Update

Core FundamentalsLv.4

What:四種標準定義的異常現象——Dirty Read:讀到另一個 transaction 尚未提交、之後可能被 rollback 的資料(讀到「根本不算真的發生過」的資料)。Non-repeatable Read:同一個 transaction 內,對同一筆資料讀兩次,兩次的值不同(Day 38 陌生題展示過的現象)。Phantom Read:同一個 transaction 內,用同一個條件查詢兩次,兩次符合條件的筆數/整列不同(Day 39 前段陌生題展示過的現象)。Lost Update:兩個 transaction 各自基於同一份舊資料計算新值並寫回,其中一個的寫入結果被另一個覆蓋、憑空消失(Day 38 §0 併發存取情境定義展示的扣款消失問題正是這一種)。

Why

這四種異常之所以被獨立命名、獨立定義,是因為它們代表「多個 transaction 交錯執行」可能造成的四種不同機制的錯誤,需要不同的防禦手段——理解每一種異常「具體是怎麼發生的」,才能判斷某個 Isolation Level 或某種鎖策略是否真的防得住它,而不是含糊地說「加了 transaction 應該就安全了」。

Mechanism(各異常與 Isolation Level 的對應關係):

| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Lost Update ||---|---|---|---|---|| Read Uncommitted(PostgreSQL 視為 Read Committed) | 標準允許,PostgreSQL 不會發生 | 允許 | 允許 | 允許 || Read Committed | 不會發生 | 允許(Day 38 陌生題) | 允許 | 允許 || Repeatable Read | 不會發生 | 不會發生 | 標準允許,PostgreSQL 實作上不會發生(Day 39 前段陌生題) | 不會發生(衝突時拋出 serialization error 要求重試,見下方 Mechanism 展開) || Serializable | 不會發生 | 不會發生 | 不會發生 | 不會發生 |

Repeatable Read 如何用 serialization error 擋下 Lost Update:T1、T2 各自的 Snapshot 都固定、互相看不到對方未提交的變更,兩者仍可能各自基於自己 Snapshot 裡的舊值算出新值,分別執行UPDATE ... SET balance = <算出來的新值>;但 PostgreSQL 在 Repeatable Read 下,若偵測到「我要更新的這一列,在我開始 transaction 之後已經被別人更新並提交」,並不會讓後到的那個 transaction 靜默地基於自己 Snapshot 裡的舊值覆蓋——而是先等前一個 transaction 完成,若它真的提交了對這一列的更動,就讓後到的 transaction 以ERROR: could not serialize access due to concurrent update 失敗,要求應用層抓住這個錯誤、重跑整個 transaction(重跑會用新的 Snapshot 讀到最新值,不會再基於過時資料計算)。這代表 Repeatable Read 實際上已經擋住了本節定義的 Lost Update(兩個 transaction 各自基於同一份舊值覆蓋同一列);但這一層保護只到「同一列的直接寫入衝突」——如果換成跨多個列、彼此有業務邏輯關聯的 write skew(例如兩個 transaction 各自更新不同的列,兩者合起來卻違反了一個跨列的業務約束),Repeatable Read 不保證擋得住,需要真正的 Serializable(SSI)才有這一層更廣的保護。應用層自己用 SELECT ... FOR UPDATE顯式鎖定該列(見 Day 40),則是另一種完全不同的解法:讓後到的 transaction 等待前一個完成再讀取最新值,而不是走「失敗後重試」這條路。

Trade-off

光靠拉高 Isolation Level 到 Serializable 能防住全部四種異常,但要付出重試邏輯的開發成本與潛在的重試率;另一種更輕量的替代方案是針對「明確知道有寫入衝突風險」的特定操作(例如帳戶餘額扣款),只對那幾句查詢加 SELECT ... FOR UPDATE(Day 40 的 Row Lock)顯式鎖住,其餘操作仍用預設的 Read Committed,取得「只在真正需要的地方付出並行度代價」的折衷。

Failure Modes:常見誤解是把 Non-repeatable Read 跟 Phantom Read 搞混——差異在於前者是「同一筆資料的值變了」,後者是「符合條件的整批資料筆數變了」;另一個常見錯誤反過來是誤以為「用了 Repeatable Read,程式完全不用額外處理 Lost Update」——上面 Mechanism 段落說明的是 PostgreSQL 靠讓後到的 transaction 失敗+要求重試來擋下衝突,如果應用層沒有實作「捕捉 serialization error 並重跑整個 transaction」的邏輯(很多框架的預設行為是讓例外直接往上拋、請求就此失敗),使用者會看到一個原本該成功的操作報錯,只是從「資料悄悄算錯」換成「操作明確失敗」,仍然沒有真正處理好;另外,這層保護只到同一列的直接衝突,遇到跨列的 write skew 依然不保證安全,唯有 Serializable 才涵蓋這個更廣的情境(見上方 Mechanism 段落展開)。

Backend Applications:Backend 工程師實作「查詢餘額 → 計算 →寫回」這類讀取後再寫入(read-modify-write)的邏輯時,正確做法通常不是拉高 Isolation Level,而是用單一 SQL 語句完成整個操作(例如 UPDATE accounts SET balance = balance - 30 WHERE id = 1;,讓資料庫在單一原子操作內完成讀取現值與寫入,不給任何交錯空間),或明確用 SELECT ... FOR UPDATE 鎖住要更新的那一列。

陌生題示範:情境——電商系統的庫存扣減邏輯目前寫成:``sql-- Transaction 內 SELECT stock FROM products WHERE id = 1; -- 假設讀到 stock = 5-- 應用程式判斷 stock > 0,於是:UPDATE products SET stock = 4 WHERE id = 1; -- 寫回讀到的值減 1`在 Read Committed(PostgreSQL 預設)下,兩個並發請求幾乎同時執行這段邏輯,請推導可能發生的錯誤結果,並給出正確的修法。推導:兩個 transaction T1、T2 都各自讀到 stock = 5,都判斷「庫存足夠」,都各自算出「扣 1 之後應該是 4」並寫回 stock = 4——實際上應該賣出 2 件、stock 應為 3,但因為兩者都是基於同一份舊值計算,其中一個的扣減「憑空消失」,最終 stock 仍是 4,這正是 Lost Update,且會導致庫存被超賣(賣出 2 件但庫存只少了 1)。正確修法是把「讀取+判斷+寫回」壓縮成一句原子操作:UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0 RETURNING stock;——資料庫在單一語句內完成「檢查 stock > 0」與「扣減」,兩個並發的 UPDATE 會被資料庫序列化執行(後到的那個看到的是前一個已經寫回的新值),不會有兩者都基於同一份舊值計算的空間,若 RETURNING 沒有回傳任何列,代表 stock` 已經不足、扣減未發生,應用層可依此判斷並回報「庫存不足」。

Day 39 過關標準(DoD)—— Database 特殊驗收格式

```text Query: 用兩個 psql session 重現陌生題的 Lost Update 情境:CREATE TABLE stock_demo (id int PRIMARY KEY, stock int);INSERT INTO stock_demo VALUES (1, 5);

-- Session A:BEGIN;SELECT stock FROM stock_demo WHERE id = 1; -- 讀到 5

-- Session B(在 A 尚未寫回前執行並完整提交):BEGIN;SELECT stock FROM stock_demo WHERE id = 1; -- 也讀到 5 UPDATE stock_demo SET stock = 4 WHERE id = 1;COMMIT;

-- Session A(接續,基於自己讀到的舊值 5 計算):UPDATE stock_demo SET stock = 4 WHERE id = 1;COMMIT;

EXPLAIN: 不適用 EXPLAIN(本次驗收重點是交易時序而非查詢計畫),改為記錄每一步 session A/B 的 SQL 輸出與最終 SELECT stock FROM stock_demo WHERE id = 1; 的結果。

Hypothesis: 依今天陌生題推導,預期最終 stock 會停在 4(Lost Update 發生:兩個 -1 只生效了一次)。

Change: 把兩處 UPDATE stock_demo SET stock = 4 WHERE id = 1;改成 UPDATE stock_demo SET stock = stock - 1 WHERE id = 1;,重新跑一次同樣的交錯時序(B 完整提交後 A 才執行)。

Benchmark: 記錄修正前後,最終 stock 的數值分別是多少。

Conclusion: 用自己的話寫一段,說明修正前後的差異來自哪裡(提示:stock = 4 是基於各自讀到的舊值寫死結果;stock = stock - 1是基於資料庫當下的最新值做相對運算),並回答:如果把 session A 的 BEGIN 改成 Serializable,原本寫死結果的版本會發生什麼(提示:其中一個 transaction 會因 serialization failure 而失敗,而不是靜默地弄丟一次扣減)。```

Day 40 — Lock(Row / Table / Advisory / Deadlock)+ Phase 04 收尾 Capstone

學習目標

看完今天內容後,能夠:

  1. 分辨 Row Lock / Table Lock / Advisory Lock 三種鎖各自鎖定的範圍與典型使用場景。
  2. 不依賴 Phase 05 的定義,獨立說出 Deadlock(死結)是什麼、為什麼會發生、資料庫怎麼偵測並處理它。
  3. 完整走一次 Query → EXPLAIN → Identify bottleneck → Index/Query/Schema decision → Measure again 的調查流程,診斷一段慢 SQL 並解釋為什麼修正後真的變快——整合 Day 36–40 學到的 Index/Planner 知識。

教材大綱

1

Lock:Row Lock / Table Lock / Advisory Lock / Deadlock

Core FundamentalsLv.4

What:Lock 是資料庫用來限制「多個 transaction 能否同時存取同一個資源」的機制,補足 MVCC(Day 38)處理不了的情況——MVCC 讓讀取不阻擋寫入、寫入不阻擋讀取,但兩個寫入互相衝突時(例如 Day 39 陌生題的庫存扣減),仍需要某種形式的互斥。Row Lock鎖定單一列,最常見的形式是 SELECT ... FOR UPDATE——明確告訴資料庫「我接下來要更新這一列,其他想對同一列做 FOR UPDATE 或UPDATE 的 transaction 請先等我」。Table Lock 鎖定整張表,範圍粗得多,通常用於 schema 變更(例如 ALTER TABLE)這類需要確保沒有其他 transaction 同時在讀寫整張表的操作。Advisory Lock 是 PostgreSQL 提供的一種「跟任何實際資料表無關、由應用程式自己定義語意」的鎖(例如 SELECT pg_advisory_lock(12345);),常用於「同一時間只允許一個背景排程/一個 worker 執行某個邏輯任務」這類跟特定資料列無關的互斥需求。

Why

Day 39 已經說明,Read Committed(PostgreSQL 預設)下光靠 Isolation Level 完全防不住 Lost Update;拉高到 Repeatable Read(或 Serializable)雖然會在真的撞上衝突時讓後到的 transaction 失敗、逼你重跑整個 transaction,但那是用「交易失敗+重試」解決問題,且一旦拉高 Isolation Level,同一個 transaction 裡其他不相關的查詢也會被迫套用同一個更嚴格、更新更少的 Snapshot。Row Lock 提供一個更局部的解法(只鎖需要的那幾列,不影響其他不相關的資料、也不用把整個 transaction 拉到 Repeatable Read):讓後到的 transaction 直接等待前一個完成,而不是走「失敗再重試」這條路,也不是各自基於舊值計算再互相覆蓋。

Mechanism(Deadlock,不依賴 Phase 05 定義的獨立說明):Deadlock(死結)發生在兩個(或多個)transaction 互相等待對方已經持有的鎖、誰都無法繼續往下執行的情況。具體例子:Transaction T1 先鎖住 row A(例如對 A 執行 SELECT ... FOR UPDATE),接著想鎖 row B;同時 Transaction T2 先鎖住 row B,接著想鎖 row A——T1 在等 T2 釋放 B,T2 在等 T1 釋放 A,兩者永遠等不到對方釋放,形成循環等待。PostgreSQL 會定期執行 Deadlock Detection(檢查「誰在等誰持有的鎖」是否形成一個循環),一旦偵測到,會選擇其中一個 transaction(通常是造成循環的較晚那個)強制回滾(拋出deadlock detected 錯誤),讓另一個得以繼續執行,打破循環——這個定義完全建立在「鎖」與「等待」這兩個本節剛定義過的概念上,不需要引用 Phase 05 才會教的 Goroutine/OS thread 排程知識;後續 Phase 05 教到 Deadlock 時,會回頭引用這裡的 transaction 鎖案例作為「你已經看過一個 deadlock 實例」的複習起點1。

Trade-off

鎖的粒度越細(Row Lock 優於 Table Lock),並行度越高,但管理成本(要追蹤誰鎖了哪一列)也越高;鎖的持有時間越長(例如在一個 transaction 裡鎖住一列後接著做很慢的外部呼叫),其他想動同一列的 transaction 等待時間就越長,甚至可能提高 Deadlock 發生機率(等待時間越長,越可能跟另一個 transaction 的鎖需求交錯形成循環)。

Failure Modes:常見錯誤是「鎖的順序不一致」——例如某段程式碼永遠先鎖 A 再鎖 B,另一段程式碼卻先鎖 B 再鎖 A,即使兩段程式碼各自看起來都合理,只要同時執行就可能形成上面 Mechanism 描述的循環等待。修正 Deadlock 問題最根本的方法通常是統一鎖的取得順序(例如永遠依 primary key 由小到大依序鎖),而不是單純依賴資料庫的 Deadlock Detection 事後補救(那只是避免系統整個卡死,transaction 仍然會失敗,需要應用層重試)。

Backend Applications:Backend 工程師實作「轉帳」這類需要同時鎖多筆資料的邏輯時,應該明確規定鎖的取得順序(例如永遠先鎖id 較小的帳戶),並且讓應用層在捕捉到 deadlock detected錯誤時自動重試(因為 Deadlock 是資料庫主動回滾其中一方造成的,重試通常能成功);用 Advisory Lock 實作排程互斥時,也要確保所有會用到同一個 Advisory Lock key 的地方都遵循同樣的取得順序。

陌生題示範:情境——轉帳邏輯:transfer(from_id, to_id,amount) 依序執行 SELECT ... FOR UPDATE WHERE id = from_id再 SELECT ... FOR UPDATE WHERE id = to_id。應用程式同時收到兩個轉帳請求:請求 X 要「帳戶 1 轉給帳戶 2」,請求 Y 要「帳戶 2 轉給帳戶 1」,幾乎同時執行。請推導是否會發生 Deadlock,以及如何修正。推導:請求 X 先鎖帳戶 1、再嘗試鎖帳戶 2;請求 Y 先鎖帳戶 2、再嘗試鎖帳戶 1——這正是 Mechanism 段落定義的循環等待:X 等 Y 釋放帳戶 2,Y 等 X 釋放帳戶 1,形成 Deadlock,PostgreSQL 會偵測到並讓其中一個失敗。修正方法:不管轉帳方向是 1→2 還是 2→1,都先鎖 id 較小的帳戶——這樣請求 X 與請求 Y 都會先嘗試鎖帳戶 1,其中一個會先成功並繼續鎖帳戶 2,另一個會在鎖帳戶 1 這一步就排隊等待(而不是各自鎖住一個又去等另一個),不會形成循環等待,Deadlock 不會發生,只是其中一個請求會多等一下、依序完成。

Day 40 過關標準(DoD)—— Database 特殊驗收格式(Phase 04 收尾 Capstone,完整實作一次)

```text Query: 先製造一個結合本週 Index/Planner 知識才能診斷的慢查詢情境:CREATE TABLE bench_events (id serial PRIMARY KEY,event_type text,payload jsonb,created_at timestamp DEFAULT now());INSERT INTO bench_events (event_type, payload, created_at)SELECT(ARRAY['login','purchase','logout','error'])[floor(random()*4+1)],'{"note": "seed data"}'::jsonb,now() - (random() * interval '90 days')FROM generate_series(1, 1000000);

-- 應用程式實際常跑的查詢:找出最近 7 天內的 purchase 事件,-- 依時間新到舊排序,取前 50 筆:SELECT * FROM bench_events WHERE event_type = 'purchase' AND created_at > now() - interval '7 days'ORDER BY created_at DESC LIMIT 50;

EXPLAIN: EXPLAIN (ANALYZE, BUFFERS) 對上面這句查詢在「完全沒有 Index(只有 primary key)」的狀態下先跑一次,記錄計畫節點(預期 Seq Scan + Sort + Limit)、rows 估計 vs actual rows、actual time、Buffers。

Hypothesis: 依 Day 36–37 教材,這句查詢同時有「篩選條件」(event_type、created_at 範圍)與「排序+取前 N 筆」需求,最適合的解法是 composite index (event_type, created_at DESC)——理由:event_type 是等式篩選、created_at 是範圍篩選+排序方向一致,依 Day 36 的 B-Tree 比較規則,這個順序能讓 Planner 先用 event_type 定位到一段連續範圍,範圍內已依 created_at 排序好,Limit 50 幾乎可以立刻拿到結果,不需要排序整個結果集。先寫下你預期加上這個 Index 後,計畫節點與 actual time 會怎麼變。

Change: CREATE INDEX idx_bench_events_type_created ON bench_events(event_type, created_at DESC);ANALYZE bench_events;(確保統計資訊更新,依 Day 37 教材避免 Planner 用過時統計誤判)

Benchmark: 重跑同一句 EXPLAIN (ANALYZE, BUFFERS),記錄計畫節點是否變成 Index Scan(且沒有額外的 Sort 節點)、rows 估計是否貼近 actual rows、Buffers 與 actual time 相較修正前的具體倍數差異。

Conclusion: 用自己的話寫一段,串起本週完整的推導鏈解釋「為什麼改完真的變快」:沒有 Index 時,event_type 的 Selectivity 差(篩選後仍剩 1/4,Day 37),加上 created_at 範圍條件,仍必須先掃過大部分 Page 才能收集到符合條件的列,再額外排序整個結果集才能取 Limit 50;composite index 用 Day 36 的 B-Tree column 順序規則,把「篩選」與「排序」用同一次 Index Search 解決,I/O 量級從「正比於全表 Page 數」降到「正比於符合條件的筆數 + 樹高」。並回答:如果應用程式的查詢模式反過來變成「只依 created_at 範圍查詢、不篩 event_type」,這個 Index 還會不會一樣有效(提示:見 Day 36 composite index column 順序陌生題的推導)。若你的環境數字與預期不完全相符,說明可能的原因(例如資料量不夠大、或 autovacuum 提前跑過 ANALYZE 讓統計已經是新的)。```

Day 36–40 到此結束,涵蓋 Index 全部類型(Single-column/Composite/Covering/Partial/Selectivity/Cardinality/Index Scan/Sequential Scan)、Query Planner 全部子項(EXPLAIN/EXPLAIN ANALYZE/Cost/Statistics/Planner Decision)、Transactions 全部子項(MVCC 的 Version/Snapshot/Visibility、四種 Isolation Level、四種 Concurrency Problem、四種 Lock),並在 Day 38 依規劃文件的要求2,用完全獨立於 Phase 05 的併發存取情境定義展開 MVCC,Day 40 的 Deadlock 說明同樣不依賴 Phase 05 定義。Day 40 收尾的 Capstone 完整走了一次 Query→EXPLAIN→Identify bottleneck→Index/Query/Schema decision→Measure again 流程,串起 Day 31–40 全部 10 天的知識(Page→Buffer→B-Tree→Index→Selectivity→Planner Decision)診斷並解釋一段真實的慢查詢為什麼變快。Phase 04(Database Internals)到此全部完成(Day 31–40),T-027(Phase 05 Concurrency 上半,Day 41–45)depends_on 只有 T-006(已滿足),與本 Phase 無直接依賴,可獨立開始;T-027 撰寫 Concurrency 相關內容時,可回頭引用本檔案 Day 38§0 的併發存取情境與 Day 40 的 Deadlock 案例作為「你已經在資料庫情境看過這個現象」的銜接點(見 dependency graph §3.1 第 3 點)。

  1. 依 00-knowledge-dependency-graph.md §3.1 第 3 點
  2. 依 00-knowledge-dependency-graph.md §3.1 的要求