執行計畫是 optimizer 給你的「我打算怎麼拿這份資料」的自白書。看懂它,你才知道索引到底有沒有被用、慢在哪一步——而不是盲目加索引碰運氣。
🧠 Intuition
TIP
讀執行計畫先看兩件事:哪一步碰最多列(最寬的那條),以及估計列數 vs 實際列數差多少。前者是熱點,後者告訴你 optimizer 是不是被統計騙了。
- EXPLAIN vs EXPLAIN ANALYZE:
EXPLAIN只給 optimizer 的估計(不執行);EXPLAIN ANALYZE(PG)/ 實際執行計畫(SQL Server)真的跑一遍,給你實際列數與時間。估計與實際差很多 = 統計問題。 - 由下往上、由內往外讀:計畫是一棵樹,葉節點(掃描)先執行,結果往上餵給 join / sort / aggregate。縮排最深的先跑。
- 掃描方式分三種:seq scan(全表)、index scan(走索引再回表)、index-only scan(只走索引、不回表)——成本依序遞減。
- 重點不是「有沒有用索引」,而是「用對掃描方式」:撈整表 80% 的列時,seq scan 反而比逐筆回表的 index scan 便宜,optimizer 選 seq scan 是對的。
⚖️ Tradeoffs
Seq Scan vs Index Scan vs Index-Only Scan
- Seq scan:順序讀整張表。撈大比例的列或小表時最快(順序 I/O > 大量隨機回表)。
- Index scan:走 B-Tree 找到符合的列,再回表取其他欄位。選擇度高(只撈少數列)時最划算。
- Index-only scan / covering index:查詢需要的欄位全在索引裡,免回表。PG 還需 visibility map 夠新才能省掉 heap 存取。
- tipping point:命中列數一多,optimizer 會判定回表太貴而從 index scan 切回 seq scan——這通常是對的。
Nested Loop vs Hash Join vs Merge Join
- Nested loop:外表每一列去內表查一次。外表小 + 內表 join 欄有索引時最優;外表一大就是災難(O(n×m))。
- Hash join:先把小表建 hash table,再掃大表探查。大表等值 join、無有用索引時的主力;需記憶體,不夠就 spill 到磁碟。
- Merge join:兩邊各自排序後像拉鍊般合併。雙方已排序(或有可用索引序)時最優,否則排序成本可能不划算。
- 選錯的根因幾乎都是 cardinality 估計:以為內表小選 nested loop,實際很大就慢到爆。
為何索引沒被用 (Index Not Used)
- 型別轉換 (implicit conversion):
WHERE varchar_col = 123,欄位被隱式轉型,索引失效;或WHERE id = '5'兩邊型別不符。 - 函式包住欄位 (non-sargable):
WHERE YEAR(created_at) = 2026、WHERE UPPER(name) = 'X'、WHERE col + 1 > 10——欄位被運算包住,B-Tree 無法定位,得改寫成範圍條件或建 function-based / computed index。 - 低選擇度 (low selectivity):條件命中表的大部分列(如
status='active'佔 90%),optimizer 算出回表比全表掃描還貴,故意不用索引——這是正確決定。 - 前綴不符:複合索引
(a, b)卻只查b,用不到(leftmost prefix rule)。
N+1 問題 — ORM 的隱形殺手
- 抓 N 筆主資料(1 條查詢),再為每一筆各發一條查詢抓關聯(N 條),總共 N+1 條往返。
- 單看每條查詢計畫都漂亮(走索引、毫秒級),但網路往返與固定開銷 × N 會把整體拖垮——計畫工具看不出來,要看查詢「條數」。
- 解法:JOIN fetch /
JOIN FETCH(JPA)、batch fetching(IN (...)一次撈)、DataLoader 模式。不是索引問題,是查詢數量問題。
🔑 Takeaways
- 讀計畫先抓「碰最多列的那步」和「估計 vs 實際列數的落差」;用
EXPLAIN ANALYZE看實際數字,別只信估計。 - 掃描三型(seq / index / index-only)成本遞減,但撈大比例列時 seq scan 才對;join 三型(nested loop / hash / merge)選錯幾乎都源自 cardinality 估錯。
- 索引沒被用的四大主因:型別轉換、函式包欄位 (non-sargable)、低選擇度、複合索引前綴不符——前兩個是你的鍋,第三個是 optimizer 正確。
- N+1 是查詢「條數」問題不是索引問題,單條計畫再漂亮也救不了,用 join fetch / batch fetch 收斂往返次數。
2026-07-01
- Q: 我查詢明明有建索引,EXPLAIN 卻顯示 Seq Scan,是不是索引壞了?
- A: 大概率沒壞,是 optimizer 判斷不用比較划算,常見三類原因。一是低選擇度:你的條件命中了表裡一大半的列,逐筆走索引回表的隨機 I/O 比直接順序掃整表還貴,這時 seq scan 是正解。二是寫法讓索引失效(non-sargable):例如
WHERE YEAR(created_at)=2026或欄位型別被隱式轉換,B-Tree 無法用來定位,得把運算從欄位上拿掉(改成created_at >= '2026-01-01' AND < '2027-01-01')。三是統計過期讓 optimizer 估錯選擇度。先用EXPLAIN ANALYZE對照估計列數與實際列數:差很多就是統計或寫法問題;數字接近、就是它真的算出來該掃整表。先確認是哪一類,再決定要改寫、更新統計、還是接受它。