你大概遇過這個場景:某支 API 慢到爆,DBA 說「加個索引就好了」,你加了,果然快了。然後過幾個月,另一支 API 也慢,你如法炮製加了索引——這次完全沒有變快。
或者更糟:你在一張兩千萬列的表上加了索引,查詢是快了,但寫入從此變慢,而且慢得莫名其妙。
索引不是「加了就會快」的魔法開關。它是一個有明確結構、明確代價、也有明確失效條件的資料結構。這一章要把它拆開來看:
SELECT * 比 SELECT id 慢,為什麼複合索引 (a, b, c) 單獨查 b 完全用不到。EXPLAIN 卻顯示全表掃描的那幾種寫法。讀完之後,你看到一句慢 SQL 應該要能推理出「它為什麼慢」,而不是憑經驗猜「再加個索引試試」。
這一章假設你知道 SQL 怎麼寫、知道資料庫大概是什麼。如果對「資料庫是什麼、交易是什麼」還不熟,先看 IT 基礎知識 ch07 資料庫基礎。交易與 MVCC 的部分留到下一章,這章專心處理「資料怎麼被找到」。
在談索引之前,要先知道一件事:資料庫慢,慢的幾乎不是「計算」,而是「去磁碟拿東西」。
100 奈秒100 微秒 —— 是記憶體的 1,000 倍10 毫秒 —— 是記憶體的 100,000 倍CPU 比較一百萬次的成本,可能還不如去磁碟拿一次資料。所以優化資料庫查詢,本質上就是想辦法少去磁碟幾次。
InnoDB 不會只讀你要的那一列。它讀寫的最小單位是頁(page),預設 16KB。你只要一個欄位,它也是整頁搬進記憶體。
索引不是為了「比較得比較快」,是為了讓資料庫不必把整張表的頁都搬進記憶體。一張 10GB 的表全表掃描要讀 65 萬頁;如果索引能讓它只讀 3 頁就找到目標,那就是二十萬倍的差距。
後面所有的設計——B+Tree 為什麼長那樣、為什麼要有聚簇索引、為什麼回表很貴——全部都是在回答同一個問題:怎麼少讀幾頁。
資料結構課本會告訴你,有序查找用二元搜尋樹,複雜度 O(log₂ n),很漂亮。但沒有任何一個關聯式資料庫用它。為什麼?
假設一張表有 1.3 億列,用二元搜尋樹:
log₂(130,000,000) ≈ 2727 次比較——聽起來很少。但問題是:樹有 27 層,就代表最壞情況要往下走 27 個節點,而每個節點可能在磁碟的不同位置。
既然每往下一層就是一次 I/O,那就想辦法減少層數。方法是讓每個節點裝更多的 key,分支數變多,樹自然變矮。
一個節點正好就是一頁 16KB。假設 key 是 BIGINT(8 bytes)+子節點指標(6 bytes)= 14 bytes:
16384 bytes ÷ 14 bytes ≈ 1170 個分支樹高就是 I/O 次數。整章後面所有的內容,都是這句話的推論。
B+Tree 之所以贏過 B-Tree,靠的是兩個看起來很小、影響卻很大的設計。
B-Tree 的每一層都可以放資料。B+Tree 不行——資料只存在最底層的葉節點,上面全部只是路標。
好處很直接:一頁 16KB 如果要放資料,一列 1KB 就只能放 16 個;如果只放 key,可以放 1170 個。分支數差了 70 倍,樹高就從 5 層降到 3 層。
所有葉節點之間有指標互相連著,形成一條由小到大的有序鏈。這解決了範圍查詢:
SELECT * FROM orders WHERE id BETWEEN 1000 AND 2000;id = 1000 的葉節點,然後順著鏈結一路往右讀,讀到 2000 為止。因為葉節點本來就是排好序的鏈結,所以:
ORDER BY 索引欄位 幾乎是免費的——資料庫不用排序,順著葉節點走就已經是有序的。反之,ORDER BY 非索引欄位 會觸發 filesort,那才是真的貴。這是最違反直覺、也最重要的一件事。很多人以為表資料存在一個地方,索引是另外一份「目錄」指過去。在 InnoDB 裡不是這樣。
不是指標,不是位址——就是那一列本身。所以主鍵索引這棵樹,同時就是這張表的全部資料。這棵樹叫聚簇索引(Clustered Index)。
InnoDB 一定要有聚簇索引,所以它會依序找:
row_id,你看不到也用不到因為資料是按主鍵順序存放的,主鍵選什麼,就決定了新資料要插在哪裡。
OPTIMIZE TABLE 之後空間掉一大截。AUTO_INCREMENT 保證新資料永遠是最大的,只會往最後一頁追加,不會插進中間、不會頁分裂。這不是什麼慣例,是由聚簇索引的結構直接推導出來的結果。
如果業務上非要用 UUID(例如分散式系統要避免 ID 衝突),常見做法是:主鍵仍用自增,UUID 當作一個有唯一索引的普通欄位。
除了主鍵之外,你自己建的索引都叫二級索引(Secondary Index),也叫輔助索引。它們也是 B+Tree,但葉節點放的東西不一樣。
不是整列資料,也不是磁碟位址,是主鍵。所以一次二級索引查詢,其實走了兩棵樹:
SELECT * FROM users WHERE email = 'andy@example.com';
① 在 email 索引樹裡找 'andy@example.com' → 拿到 id = 42
② 拿著 id = 42 回到主鍵樹(聚簇索引)再查一次 → 拿到整列資料
↑ 這一步就叫「回表」(back to table)如果二級索引直接存資料,那有 5 個索引就有 5 份資料副本,更新一列要改 5 個地方。存主鍵值只佔幾 bytes,而且資料搬動時(例如頁分裂)不用更新每個二級索引——因為主鍵值不會變。這是空間與維護成本的取捨。
回表是隨機 I/O——找到的每一個主鍵值,都要各自到主鍵樹走一趟。如果一個查詢命中 10,000 列,那就是 10,000 次回表。
EXPLAIN SELECT * FROM users WHERE email = ?;Extra: Using index → 沒有回表(用了覆蓋索引,見下一張卡)Using where → 回表了如果你 SELECT 的所有欄位,索引裡通通都有,那就不用回表了——這叫覆蓋索引(Covering Index)。
-- email 索引的葉節點裡有:email(key)+ id(主鍵值)
SELECT id FROM users WHERE email = ?; -- ✅ 覆蓋,不回表
SELECT id, email FROM users WHERE email = ?; -- ✅ 覆蓋,不回表
SELECT * FROM users WHERE email = ?; -- ❌ 要 name、phone…,得回表SELECT *」最實際的理由——不是為了少傳幾個欄位的網路流量,是為了避開回表。建立 INDEX idx (a, b, c) 時,B+Tree 是這樣排的:先按 a 排序;a 相同的,再按 b 排;a 和 b 都相同的,才按 c 排。
因為是這種排法,索引只能從最左邊開始連續使用:
WHERE a = 1WHERE a = 1 AND b = 2WHERE a = 1 AND b = 2 AND c = 3WHERE b = 2 —— 用不到WHERE c = 3 —— 用不到WHERE a = 1 AND c = 3 —— 只有 a 用得到,c 用不到(中間斷了)-- 索引 (a, b, c)
WHERE a = 1 AND b > 5 AND c = 3a 和 b 用得到,但 c 用不到。因為 b 是範圍,符合 b > 5 的那一堆列裡面,c 的順序是亂的(c 只在 b 相同時才有序)。
這是實務上最常遇到的狀況。索引在那裡,但資料庫就是不用。以下是五種最常見的原因,全部都有同一個本質:索引是對「欄位原值」排序的,只要你動了欄位,順序就失效了。
-- ❌ 索引失效
WHERE DATE(created_at) = '2026-08-03'
-- ✅ 改寫成範圍
WHERE created_at >= '2026-08-03'
AND created_at < '2026-08-04'索引是按 created_at 原值排的,不是按 DATE(created_at) 排的。資料庫沒辦法在有序結構裡找一個「被加工過的值」,只能每一列都算一次。
-- phone 欄位是 VARCHAR
-- ❌ 傳了數字
WHERE phone = 13800138000
-- ✅ 傳字串
WHERE phone = '13800138000'型別不一致時 MySQL 會把欄位轉成數字再比較,等於對每一列都套了一個函數——退化成第 1 種情況。SQL 完全合法、結果也正確,只是慢了幾百倍。
WHERE name LIKE 'abc%' -- ✅ 用得到(知道從哪開始找)
WHERE name LIKE '%abc' -- ❌ 用不到
WHERE name LIKE '%abc%' -- ❌ 用不到索引是按開頭排序的。不知道開頭是什麼,就沒有起點可以定位。真的需要全文檢索,該用的是 Elasticsearch 或全文索引,不是 B+Tree。
-- email 有索引,nickname 沒有
WHERE email = ? OR nickname = ? -- ❌ 整條失效因為 nickname 那一半無論如何都要全表掃描,掃都掃了,優化器乾脆整條走全表。
WHERE gender = 'M' -- 全表 50% 都符合命中一半的列,代表要回表幾百萬次。優化器算過之後認為全表掃描(順序 I/O)比回表幾百萬次(隨機 I/O)更快,於是放棄索引。這是對的。
findByCreatedAtBetween、findByPhone…)產生的 SQL 你看不到,型別是由 Entity 欄位決定的。務必打開:spring.jpa.show-sql=trueEXPLAIN 看,不要假設 JPA 幫你產生的 SQL 一定會用到索引。前面都在講索引怎麼讓查詢變快。這一張卡講反面——因為「再加個索引試試」是實務上最常見的誤判。
它要佔空間、要維護、要跟著資料變動而更新。一張大表上的一個索引,實際大小可能是幾百 MB 到幾 GB。
INSERT INTO users VALUES (...);
實際發生的事:
① 主鍵樹(聚簇索引)寫入一次
② email 索引寫入一次
③ phone 索引寫入一次
④ created_at 索引寫入一次
→ 一次 INSERT = 四次 B+Tree 寫入UPDATE 更糟:改到哪個欄位,那個欄位的索引就要刪掉舊值、插入新值(因為位置變了)。
只要插入位置不是遞增的,就有機會撐爆某一頁而觸發分裂——搬資料、改指標,而且分裂後兩頁都只有一半滿,空間利用率直接掉到 50%。
(a, b, c) 就不需要再建 (a) 和 (a, b)——最左前綴已經涵蓋了。這是很常見的重複索引。EXPLAIN 看它現在走什麼路 → 判斷慢在哪 → 再決定要不要加索引、加在哪個欄位。不是先加了再看有沒有變快。有時候正確答案不是加索引,而是改寫查詢(避開失效條件)、改資料模型(拆表、加冗餘欄位),或接受它就是慢(後台報表跑 3 秒沒關係)。索引是工具之一,不是唯一解。
| 結構 | 找單筆 | 範圍查詢 | 為什麼資料庫不選它 |
|---|---|---|---|
| Hash 表 | O(1),最快 | 完全不支援 | 只能等值比對,>、BETWEEN、ORDER BY 全部失效 |
| 二元搜尋樹 BST | 1.3 億筆約 27 層 | 要中序遍歷 | 樹太高 = 磁碟 I/O 次數太多;還可能退化成鏈結串列 |
| B-Tree | 3~4 層 | 要中序遍歷、來回跳父節點 | 非葉節點也放資料 → 每頁能放的 key 變少 → 樹變高 |
| B+Tree(InnoDB 採用) | 3~4 層 | 葉節點串成鏈結,順著走即可 | ✅ 就是它:樹最矮、範圍查詢最快、ORDER BY 幾乎免費 |
| 面向 | 聚簇索引(主鍵) | 二級索引(自建) |
|---|---|---|
| 葉節點存什麼 | 整列資料本身 | 主鍵值(不是資料) |
| 一張表有幾個 | 只能有一個(資料只有一份) | 可以有很多個 |
| 查詢要走幾棵樹 | 一棵,找到就是資料 | 兩棵(回表),除非命中覆蓋索引 |
| 誰決定資料的物理排列 | 它 —— 資料按主鍵順序存放 | 不影響資料排列 |
| 選錯的代價 | UUID 主鍵 → 頁分裂、空間浪費、寫入持續變慢 | 索引開太多 → 寫入放大,一次 INSERT 寫 N 棵樹 |
| 失效的寫法 | 為什麼失效 | 改成 |
|---|---|---|
| WHERE DATE(created_at) = '2026-08-03' | 索引按原值排序,函數加工後順序就不成立 | 改成範圍:>= '2026-08-03' AND < '2026-08-04' |
| WHERE phone = 13800138000(欄位是 VARCHAR) | 隱式轉型 → 等於對每一列套函數,SQL 合法但慢幾百倍 | 傳字串:WHERE phone = '13800138000' |
| WHERE name LIKE '%abc' | 不知道開頭,索引沒有起點可以定位 | 改前綴 LIKE 'abc%';真要全文檢索用 ES 或全文索引 |
| WHERE email = ? OR nickname = ?(nickname 無索引) | OR 的另一半一定要全表掃,掃都掃了乾脆整條走全表 | 拆成兩段 UNION,或替 nickname 也建索引 |
| WHERE gender = 'M' | 選擇性太低,回表成本高於全表掃描——優化器是對的 | 不用改;這種欄位本來就不該單獨建索引 |
| 索引 (a,b,c) 但查 WHERE b = ? | 違反最左前綴,b 在整棵樹裡是散落的 | 調整索引順序,或另建以 b 開頭的索引 |
點擊卡片翻面查看答案,共 13 張。