EP.02資料庫與 SQL

索引(Index):為什麼你的查詢這麼慢?
B-Tree 原理、複合索引與 EXPLAIN 實戰

同樣的 SQL,在 100 筆資料時跑 1ms,在 1000 萬筆時跑 30 秒。 索引是讓資料庫「秒查」的關鍵,但錯誤的索引策略反而會讓系統更慢。

Joseph Chen 2026 12 min read Index · B-Tree · EXPLAIN · PostgreSQL

「沒有索引的資料庫查詢,就像在一本沒有目錄的書裡找一個章節—— 只能從第一頁翻到最後一頁。」

索引是資料庫效能優化最直接、最有效的工具。但「加了索引就快」是個危險的迷思—— 索引有維護成本,加錯地方反而拖慢寫入。這篇帶你從原理出發,學會判斷「什麼時候該加索引、加在哪裡」。

索引是什麼?Full Table Scan 的代價

沒有索引時,資料庫找資料的方式是全表掃描(Full Table Scan): 從第一列讀到最後一列,逐一比對條件。資料量小時無感,資料量大時慘不忍睹。

無索引:全表掃描

第 1 列:email = alice@? ❌
第 2 列:email = bob@? ❌
...(掃描 100 萬列)...
第 999,999 列:✅ 找到了!

複雜度:O(N),N=資料筆數

有索引:B-Tree 查詢

根節點:m < joseph < z → 往右
中間層:j < joseph < k → 往右
葉節點:joseph ✅ 找到位置
直接讀取該列

複雜度:O(log N),百萬筆只需 ~20 步


B-Tree 索引的運作原理

PostgreSQL 和 MySQL 預設的索引結構都是 B-Tree(Balanced Tree)。 它是一棵「平衡」的多叉樹,確保從根節點到任何葉節點的距離都相同—— 這保證了查詢的時間複雜度穩定在 O(log N)。

B-Tree 結構示意(以 email 欄位建索引)

根節點:joseph
alice · bob
kevin · mary
╱ ╲╱ ╲

alice → row 2

bob → row 7

joseph → row 1

kevin → row 5

mary → row 9

zoe → row 3

葉節點儲存索引值 + 指向實際資料列的指標(row pointer)

B-Tree 擅長的查詢

  • ✅ 等值查詢:WHERE email = 'x@x.com'
  • ✅ 範圍查詢:WHERE age BETWEEN 20 AND 30
  • ✅ 前綴查詢:WHERE name LIKE 'Jo%'
  • ✅ 排序:ORDER BY email(可避免額外排序步驟)

B-Tree 無法優化的查詢

  • ❌ 後綴查詢:WHERE name LIKE '%Chen'
  • ❌ 函數計算:WHERE YEAR(created_at) = 2026
  • ❌ 否定:WHERE status != 'active'(回傳大量資料時)
  • ❌ OR 跨欄位(需要多個索引)

建立與管理索引

索引操作語法(PostgreSQL)
-- 建立單欄索引
CREATE INDEX idx_users_email ON users(email);

-- 建立唯一索引(同時確保值不重複)
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);

-- 建立複合索引(欄位順序很重要!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- 建立部分索引(只對符合條件的列建索引,節省空間)
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';   -- 只對待處理訂單建索引

-- 查看某張表的所有索引
SELECT indexname, indexdef FROM pg_indexes
WHERE tablename = 'orders';

-- 刪除索引
DROP INDEX idx_users_email;

-- 重建索引(修復索引膨脹,在離峰時間執行)
REINDEX INDEX idx_users_email;

⚠️ 生產環境建索引:一定要加 CONCURRENTLY

普通的 CREATE INDEX 會鎖住整張表,期間所有寫入都被阻塞。 百萬列的表可能鎖幾分鐘——生產環境中這等同於服務中斷。 加上 CONCURRENTLY 讓索引在背景建立,不阻塞讀寫。

-- ❌ 生產環境禁用(會鎖表)

CREATE INDEX idx_users_email ON users(email);

-- ✅ 生產環境必用(背景建立,不阻塞)

CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

-- 注意:CONCURRENTLY 不能在交易(BEGIN...COMMIT)內使用

-- 若中途失敗,需手動清除殘留的 INVALID 索引

💡 主鍵(PRIMARY KEY)自動建索引

定義 PRIMARY KEY 時,PostgreSQL 會自動建立一個唯一索引。 不需要、也不應該再手動對 id 欄位建索引。 FOREIGN KEY 欄位(如 user_id)則不會自動建索引,需要手動加——這是常見遺漏。


複合索引的欄位順序:最重要的細節

複合索引(Composite Index)可以同時索引多個欄位,但欄位的順序決定了索引的效用。 這是最多人搞錯的地方。

規則:最左前綴原則(Leftmost Prefix Rule)

索引 (A, B, C) 可以服務以下查詢,但只能從左邊開始連續使用:

WHERE A = 1✅ 使用索引(A)
WHERE A = 1 AND B = 2✅ 使用索引(A, B)
WHERE A = 1 AND B = 2 AND C = 3✅ 使用索引(A, B, C)
WHERE B = 2❌ 無法使用索引(跳過了 A)
WHERE A = 1 AND C = 3⚠️ 只能使用 A 部分,C 無法走索引
複合索引設計實例
-- 場景:電商訂單查詢,最常見的查詢模式是:
-- 1. 查某個使用者的特定狀態訂單(最常見)
-- 2. 查某個使用者的所有訂單
-- 3. 查特定狀態的所有訂單(較少)

-- ❌ 錯誤設計(status 在前)
CREATE INDEX idx_bad ON orders(status, user_id);
-- WHERE user_id = 1 → 無法使用此索引!

-- ✅ 正確設計(高選擇性欄位在前)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- WHERE user_id = 1            → 使用 user_id 部分
-- WHERE user_id = 1 AND status = 'pending' → 使用完整索引

-- 設計原則:
-- 1. 等值查詢的欄位放前面(= 而非 BETWEEN / LIKE)
-- 2. 高選擇性的欄位放前面(user_id 比 status 更有辨別力)
-- 3. ORDER BY 的欄位放最後(可利用索引排序,避免額外 Sort 步驟)

EXPLAIN:看穿查詢的執行計畫

EXPLAIN 是診斷慢查詢最重要的工具。它顯示資料庫「打算怎麼執行你的 SQL」—— 有沒有走索引、掃了多少列、花了多少成本。

EXPLAIN ANALYZE 使用範例
-- EXPLAIN:只看執行計畫(不實際執行)
EXPLAIN SELECT * FROM orders WHERE user_id = 1;

-- EXPLAIN ANALYZE:實際執行並回報真實時間(推薦)
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1 AND status = 'pending';

-- 典型輸出(有索引):
-- Index Scan using idx_orders_user_status on orders
--   (cost=0.43..8.45 rows=3 width=72) (actual time=0.05..0.07 rows=2 loops=1)
--   Index Cond: ((user_id = 1) AND (status = 'pending'))

-- 典型輸出(無索引,全表掃描):
-- Seq Scan on orders
--   (cost=0.00..21458.00 rows=1000000 width=72) (actual time=0.05..312.4 rows=2 loops=1)
--   Filter: ((user_id = 1) AND (status = 'pending'))
--   Rows Removed by Filter: 999998

看懂 EXPLAIN 輸出的關鍵欄位

Seq Scan全表掃描,通常是問題所在
Index Scan走索引,好的跡象
Index Only Scan只讀索引不讀資料列,最快
cost=X..YX=取第一列的成本,Y=總成本(越小越好)
rows=N預估回傳的列數(與 actual 差很多表示統計過期)
actual time=X..Y實際執行時間(ms),ANALYZE 才有

⚠️ 為什麼索引有時不被使用?

  • 資料量太少:幾百列時全表掃描比走索引更快(有 overhead)
  • 選擇性太低:欄位值重複率高(如性別),全掃比索引跳轉更划算
  • 函數包裹欄位:WHERE LOWER(email) = 'x',索引在 email 上,LOWER() 使其失效
  • 隱式型別轉換:欄位是 VARCHAR,查詢傳 INTEGER,型別不符導致索引失效

索引的代價:不是加越多越好

每個索引都有維護成本

💾

儲存空間

每個索引都需要額外的磁碟空間。一張表索引多了,可能比資料本身還大。

✏️

寫入效能

INSERT / UPDATE / DELETE 時,所有相關索引都需要同步更新。寫多讀少的表慎加索引。

🔒

鎖定範圍

索引更新需要獲取鎖,高並發寫入時多餘的索引會加劇鎖競爭。

✅ 索引最佳實踐

  • WHERE、JOIN ON、ORDER BY 的欄位是索引的第一候選
  • • FK 欄位(如 user_id)幾乎都該加索引——外鍵查詢非常頻繁
  • • 選擇性高的欄位優先(email 勝過 gender)
  • • 先用 EXPLAIN ANALYZE 確認問題,再加索引——不要猜測
  • • 定期用 pg_stat_user_indexes 檢查索引使用率,刪除從未被使用的索引

本篇重點回顧

🔍無索引 = Full Table Scan = O(N)。B-Tree 索引讓查詢降到 O(log N),百萬筆資料只需約 20 步。
🌲B-Tree 擅長等值、範圍、前綴查詢;不適合後綴(LIKE '%x')和函數包裹的欄位。
📐複合索引遵循最左前綴原則:(A,B,C) 的索引,查詢必須從 A 開始才有效。高選擇性、等值查詢的欄位放前面。
🔬EXPLAIN ANALYZE 是診斷工具:看 Seq Scan 還是 Index Scan、actual time、rows 的估計準不準。
⚖️索引有代價:每次寫入都需要更新索引。不要「猜測」要加什麼索引,先量測再決定。

IndexB-TreePostgreSQLEXPLAINPerformanceComposite IndexDatabaseEP.02