前兩章談的都是單表:一句 SQL 在一張表上怎麼被執行、優化器怎麼估算。這一章開始,所有的估算誤差都會被乘上一個次數。
這就是 JOIN 最重要的性質:它是乘法,不是加法。驅動表多出 100 列,被驅動表就要多被查 100 次。ch02 那個「估 214 列、實際 120 萬列」的誤差,在單表查詢裡是慢五秒,在 JOIN 裡是慢一百倍。
而 JOIN 也是「測試環境完全正常、正式環境爆炸」最常見的來源——因為它的成本結構對資料量與資料分佈極度敏感,而測試環境這兩件事都跟正式環境不一樣。
這一章要拆的是:
join_buffer_size:Hash Join 用它做什麼,不夠用的時候會發生什麼事。IN (SELECT ...) 會被改寫成什麼?什麼時候它不會被改寫(DEPENDENT SUBQUERY)?這一章會大量用到 ch01 的
EXPLAIN讀法(尤其是Extra與EXPLAIN ANALYZE的loops)和 ch02 的估算觀念。JOIN 的問題幾乎都不是「JOIN 本身很慢」,而是前兩章的某個問題在這裡被放大了。
在認識任何一種 JOIN 演算法之前,先把成本公式寫下來。後面所有的討論都只是在改這條式子裡的某一項。
JOIN 總成本 ≈ 掃驅動表的成本
+ 驅動表符合條件的列數 × 查一次被驅動表的成本第二項是重點:它是乘法。這條式子直接推出三件事:
WHERE 之後只剩 3 列,它是很好的驅動表。比起其他資料庫,MySQL 8.0 的選項意外地少——而這反而讓判斷變簡單:
看 EXPLAIN 就能分辨:Extra 出現 Using join buffer (hash join) 就是後者。在 FORMAT=TREE 裡更直接,節點名稱直接寫 Nested loop inner join 或 Inner hash join。
NLJ 的邏輯就是字面意思——兩層迴圈,只是內層走索引:
for (驅動表 o 中每一列 row) { // 外層:掃一次
用 row.user_id 去 users 的主鍵索引查一次; // 內層:走 B+Tree
如果找到就組合輸出;
}EXPLAIN SELECT o.id, u.name FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.merchant_id = 8821;
| id | table | type | key | rows | Extra |
| 1 | o | ref | idx_merchant | 180 | Using where |
| 1 | u | eq_ref | PRIMARY | 1 | NULL |三個要確認的點,缺一個就不健康:
EXPLAIN 表格的第一列就是驅動表(不是你 SQL 裡寫在前面的那張)。type:eq_ref(走唯一索引,每次一列)最好,ref(非唯一索引)可以接受。rows:這就是乘數。180 次索引查詢是小事;180 萬次就不是了。驅動表 180 列 × 被驅動表每次 3 次 I/O(B+Tree 三層)
= 540 次 I/O + 掃驅動表的成本 → 幾毫秒而如果 users 的 id 上沒有索引:
180 × 掃一次 users 全表(假設 65,000 頁)
= 11,700,000 次頁讀取 → 以分鐘計回扣 ch01:loops 才是乘數的實測值。
-> Nested loop inner join (actual time=0.09..42.3 rows=178 loops=1)
-> Index lookup on o using idx_merchant (actual rows=180 loops=1)
-> Single-row index lookup on u using PRIMARY
(actual time=0.02..0.02 rows=1 loops=180) ← 乘數在這裡
loops=180 與估算的 rows=180 一致,代表 ch02 的統計資訊是準的。這兩個數字對不上的時候,問題通常不在 JOIN,在上一章。
當被驅動表的 JOIN 欄位沒有索引,NLJ 就退化成「每一列都掃一次全表」。MySQL 8.0.18 之後改用 Hash Join 來救:
join_buffer_size),以 JOIN 欄位為 key 建成 hash 表。成本從「n × 掃一次 m」變成「掃一次 n + 掃一次 m」——兩張表各掃一次就好。這確實是巨大的改善,但它只適用於等值 JOIN(ON a.x = b.y),範圍條件的 JOIN 用不了。
join_buffer_size 預設只有 256KB。裝不下 hash 表時,MySQL 會走 grace hash join:把兩邊都依 hash 值分割成一批批的 chunk 檔案寫到磁碟,再一組一組載回來比對。
是止血,不是解法,而且它有一個很容易踩的性質:
-- 診斷用:先在 session 層試,不要直接改全域
SET SESSION join_buffer_size = 8 * 1024 * 1024;
-- 確認它有沒有落磁碟
SHOW STATUS LIKE 'Created_tmp_disk_tables';有一種情況 hash join 確實是對的選擇:兩張大表要做大範圍的關聯(例如報表、對帳、批次)。這時候即使被驅動表有索引,逐列走 B+Tree 的隨機 I/O 反而輸給「兩張表各順序掃一次」。
這正好是 ch01 那個推導的 JOIN 版本:當命中比例夠高,順序掃描會贏過大量隨機 I/O。所以判斷準則不是「看到 hash join 就要修」,而是「這是一次幾列的查詢,還是一次幾百萬列的批次?」——線上 API 出現 hash join 幾乎都是缺索引,報表批次則未必。
「小表驅動大表」是流傳最廣的 JOIN 準則,方向對,但照字面理解會出錯。正確的說法是:讓「經過 WHERE 過濾後結果集最小」的那張表當驅動表。
users: 1 萬列(小表)
orders: 2000 萬列(大表)
SELECT ... FROM users u JOIN orders o ON o.user_id = u.id
WHERE u.city = '台北' -- 過濾後 8,000 列
AND o.id = 12345; -- 過濾後 1 列照「小表驅動大表」應該用 users 驅動,那就是 8,000 次索引查詢。但實際上該用 orders 驅動——它雖然是大表,過濾後只剩 1 列,只需要 1 次查詢。
rows × filtered(ch02 那個乘積),不是表的總列數。優化器算的一直都是這個;「小表驅動大表」只是它在「兩邊都沒有其他過濾條件」時的特例。這條準則只在被驅動表有索引時成立。如果被驅動表沒索引、要走 hash join,那成本是「兩邊各掃一次」——誰驅動誰的差別就小得多了,這時候糾結驅動表順序是浪費力氣,該做的是補索引。
-- EXPLAIN 表格的第一列就是驅動表
-- FORMAT=TREE 裡,外層(縮排較淺)的節點是驅動表
-- 強制順序:照 FROM 的書寫順序來(老寫法)
SELECT ... FROM t1 STRAIGHT_JOIN t2 ON ...;
-- 8.0 的 hint(較推薦,粒度更細)
SELECT /*+ JOIN_ORDER(o, u) */ ...; -- 指定完整順序
SELECT /*+ JOIN_PREFIX(o) */ ...; -- 只指定誰先但這些跟 ch02 的結論一樣是止血:優化器選錯驅動表,十次有九次是因為 rows × filtered 估錯了。先去看估算與實測差多少,而不是直接壓順序。
回扣 ch02 提過的搜尋深度:n 張表有 n! 種順序,超過門檻後優化器改用貪婪搜尋。五張表以上的 JOIN,「它沒有選到明顯更好的順序」是有可能的,而且不是 bug。這種場景反而是 hint 少數合理的用途之一。
你寫的子查詢,優化器多半不會照字面執行。知道它會被改寫成什麼,才知道為什麼有些子查詢很快、有些會慢到不可思議。
-- 你寫的
SELECT * FROM users u
WHERE u.id IN (SELECT o.user_id FROM orders o WHERE o.amount > 10000);
-- 優化器實際做的(semi-join):當成 JOIN 處理,但只要「有沒有配對」,不重複輸出MySQL 有五種 semi-join 策略,EXPLAIN 的 Extra 會寫出用了哪一種:FirstMatch(找到一筆就跳下一列)、LooseScan、Materialize(把子查詢結果物化成暫存表)、DuplicateWeedout、以及直接把表 pull-out 併進 JOIN。
IN 子查詢一定慢、要改寫成 JOIN」在 MySQL 8.0 已經不成立了——多數情況它們會被改寫成同一件事。先看 EXPLAIN 再決定要不要手動改寫,不要照舊經驗盲改。真正該警覺的是這個。當子查詢引用了外層的欄位(關聯子查詢),它就無法被獨立物化:
EXPLAIN SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = 9);
| id | select_type | table |
| 1 | PRIMARY | u |
| 2 | DEPENDENT SUBQUERY | o | ← 外層每一列都要跑一次DEPENDENT SUBQUERY 的意思就是外層有幾列,這個子查詢就跑幾次——又是一個乘法。它不一定是問題(如果外層只有幾十列、內層走索引,那很快),但如果外層是幾十萬列,就是災難。判斷方式一樣:看外層的 rows,那就是乘數。
NOT IN (SELECT col ...) 只要子查詢結果裡有一個 NULL,整句就回傳空集合。這是三值邏輯的必然結果(x NOT IN (1, NULL) 永遠不為真),不是 bug——而且它不報錯,只是安靜地回傳零筆。同時,含 NULL 的欄位也讓優化器難以套用 anti-join 改寫,效能一起變差。-- ❌ 語意與效能都有風險
WHERE u.id NOT IN (SELECT o.user_id FROM orders o)
-- ✅ 語意安全,也比較好被改寫成 anti-join
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)這是 ch01 那個隱式轉換的同類問題:不會報錯、只會回傳錯的結果——這一軌反覆出現的第二種危險。
這一節是語意問題,但它會直接變成效能問題,所以放在這裡。
-- ① 條件放 ON
SELECT u.*, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 1;
-- ② 條件放 WHERE
SELECT u.*, o.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 1;o.id 是 NULL。WHERE o.status = 1 會把 o 全為 NULL 的那些列濾掉——LEFT JOIN 退化成 INNER JOIN。LEFT JOIN 已退化成 INNER JOIN,它就解除了驅動表順序的限制(外連接必須由左表驅動,內連接則兩邊都可以),因此可能換一個完全不同的執行計畫。所以 ② 有時候反而更快——但那是回答了另一個問題的快。這種「快了但答案變了」是最糟的組合,因為效能數字會讓人以為改對了。
ON 決定「怎麼配對」,WHERE 決定「配完之後留誰」。ON。WHERE 裡真的需要判斷右表,只能用 IS NULL/IS NOT NULL——那才是「找出沒有訂單的使用者」的正確寫法(anti-join)。-- 找出沒有任何有效訂單的使用者
SELECT u.* FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 1
WHERE o.id IS NULL;在 ORM 裡這個陷阱更常見:JPA 的 @Where、動態條件拼接、Specification 組出來的條件,預設幾乎都會落在 WHERE 而不是 ON。所以 ORM 產生的 LEFT JOIN 要特別檢查(ch08 會再回到這裡)。
訂單查詢功能改版,新增了「顯示商家名稱與優惠券資訊」,於是查詢從兩張表變成三張:
SELECT o.id, o.amount, m.name, c.code
FROM orders o
JOIN merchants m ON m.code = o.merchant_code
LEFT JOIN coupons c ON c.id = o.coupon_id
WHERE o.created_at >= '2026-08-01' AND o.status = 1
ORDER BY o.created_at DESC LIMIT 50;SHOW CREATE TABLE 逐字比對過)。照順序來。先看正式環境的計畫:
| id | table | type | key | rows | Extra |
| 1 | o | range| idx_created | 84200 | Using where |
| 1 | m | ALL | NULL | 12400 | Using where; Using join buffer (hash join) |
| 1 | c | eq_ref| PRIMARY | 1 | NULL |merchants 走了 ALL + hash join——但 merchants.code 上明明有唯一索引。第二張表的 JOIN 欄位有索引卻用不到,回到 ch01 的清單:欄位被什麼東西包住了。
SHOW CREATE TABLE orders\G
merchant_code varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
SHOW CREATE TABLE merchants\G
code varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci ← 不一樣merchants.code 的索引就此失效。這與 ch01 那個 cast() 是同一個機制,只是這次包住欄位的是 collation 轉換。merchants 是三年前建的舊表,當時預設是 utf8mb4_general_ci;orders 去年重建過,用的是 MySQL 8.0 的新預設 utf8mb4_0900_ai_ci。SHOW CREATE TABLE 逐字比對時,大家看的是索引定義那幾行,沒有人去比對欄位的 COLLATE——它不在「索引」這個概念裡。merchants 只有 30 列,hash join 掃一次毫無感覺;正式環境 12,400 列 × 驅動表 84,200 列的比對,就是 40 秒。ALTER TABLE merchants CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;——但這是重建整張表的 DDL,大表要用線上 DDL 工具、要挑離峰、要先確認排序規則變更不會影響既有的比對語意(ch06 主講)。ON m.code = o.merchant_code COLLATE utf8mb4_0900_ai_ci——但要讓被轉換的是驅動表那一邊,否則索引照樣失效。這種寫法很容易寫反,改完一定要用 EXPLAIN 確認 type 回到 eq_ref。把前面幾節收成一個可以照著跑的順序。每一步都是為了回答那條成本公式裡的某一項。
EXPLAIN 第一列(或 FORMAT=TREE 最外層)。它的 rows × filtered 就是乘數。EXPLAIN ANALYZE 看被驅動表節點的 loops,與估算對照。差一個數量級以上 → 回 ch02 處理統計資訊,不要在這一章繼續找。type 應該是 eq_ref 或 ref。出現 ALL 或 Using join buffer → 補索引;索引存在卻沒用到 → 檢查型別與 collation 是否一致(ch01 的清單)。LIMIT 20 限制的是 JOIN 之後的列數,不是主表的列數——結果會少於 20 筆主資料。這是 ORM 分頁最經典的錯誤,ch08 會完整拆解。你可能已經注意到,前面的例子裡反覆出現 ORDER BY ... LIMIT,而我一直沒有處理它。當 JOIN 的結果需要排序、分組、或翻到第五千頁時,成本會用另一種方式爆炸——那是下一章的主題。
這一章給的每個解法都有帳單。第二條主線「代價守恆」在 JOIN 這裡特別明顯。
join_buffer_size → 它是每連線每 JOIN 各一份,不是共用。乘上連線數就是實際記憶體用量,而那塊記憶體是從 Buffer Pool 搶來的(ch07)。SELECT ... FOR UPDATE 的 JOIN 會依序在多張表上加鎖,而加鎖順序不一致正是死鎖的來源(ch05)。| Index Nested Loop | Hash Join | |
|---|---|---|
| 觸發條件 | 被驅動表的 JOIN 欄位有可用索引 | 沒有可用索引,且是等值 JOIN |
| 成本 | 驅動表列數 × 每次索引查詢(約 3 次 I/O) | 兩張表各順序掃一次 |
| EXPLAIN 特徵 | 被驅動表 type=eq_ref / ref | Extra: Using join buffer (hash join) |
| 記憶體 | 幾乎不用額外記憶體 | join_buffer_size(預設 256KB,每連線每 JOIN 一份) |
| 裝不下時 | 不適用 | 分割成 chunk 檔案寫磁碟(grace hash join) |
| 什麼時候是對的 | 線上 API:一次取幾列到幾百列 | 報表/對帳批次:大範圍關聯,順序掃反而快 |
| 支援的條件 | 等值、範圍都可以 | 只支援等值 JOIN |
| EXPLAIN 症狀 | 根因 | 第一順位解法 | 代價 |
|---|---|---|---|
| 被驅動表 type=ALL + hash join | JOIN 欄位沒索引 | 補索引 | 寫入變慢、空間變大 |
| 索引存在卻 type=ALL | 型別或 collation 不一致,欄位被隱式轉換包住 | 統一型別/collation(治本) | CONVERT TO 是重建整張表的 DDL |
| 驅動表 rows 很大 | 驅動表選錯,或 rows×filtered 估錯 | 回 ch02 修統計資訊;必要時 JOIN_ORDER hint | hint 會過期、掩蓋根因 |
| select_type=DEPENDENT SUBQUERY | 關聯子查詢,外層每列跑一次 | 改寫成 JOIN 或 semi-join 形式 | 改寫要確認語意等價(NULL 行為) |
| 寫法 | 優化器通常怎麼處理 | NULL 風險 | 建議 |
|---|---|---|---|
| IN (SELECT ...) | 改寫成 semi-join(FirstMatch/Materialize 等) | 無 | 8.0 已不需要手動改成 JOIN,先看 EXPLAIN |
| EXISTS (相關子查詢) | 多半也能 semi-join;否則 DEPENDENT SUBQUERY | 無 | 安全;但要看外層 rows 這個乘數 |
| NOT IN (SELECT ...) | 難以改寫成 anti-join | 子查詢有一個 NULL → 整句回傳空集合 | 避免;改用 NOT EXISTS |
| NOT EXISTS | 可改寫成 anti-join | 無 | 找「沒有對應資料」的首選 |
| LEFT JOIN ... WHERE 右表 IS NULL | anti-join | 無 | 與 NOT EXISTS 等價,可讀性各有偏好 |
點擊卡片翻面查看答案,共 13 張。