資料庫效能與維運 ch03 JOIN 的執行方式:測試環境 20 毫秒,正式環境 40 秒
下一章→
CH 03 乘法效應

JOIN 的執行方式:測試環境 20 毫秒,正式環境 40 秒

Index Nested LoopHash Join 與 join buffer驅動表怎麼選「小表驅動大表」的真正意思被驅動表沒索引的災難子查詢與 semi-join 改寫collation 不一致

前兩章談的都是單表:一句 SQL 在一張表上怎麼被執行、優化器怎麼估算。這一章開始,所有的估算誤差都會被乘上一個次數。

這就是 JOIN 最重要的性質:它是乘法,不是加法。驅動表多出 100 列,被驅動表就要多被查 100 次。ch02 那個「估 214 列、實際 120 萬列」的誤差,在單表查詢裡是慢五秒,在 JOIN 裡是慢一百倍。

而 JOIN 也是「測試環境完全正常、正式環境爆炸」最常見的來源——因為它的成本結構對資料量與資料分佈極度敏感,而測試環境這兩件事都跟正式環境不一樣。

這一章要拆的是:

  • MySQL 到底怎麼執行 JOIN:Index Nested Loop 與 Hash Join,各自什麼時候被選上。
  • 驅動表:優化器怎麼選、為什麼「小表驅動大表」這句話說得不夠精確。
  • join_buffer_size:Hash Join 用它做什麼,不夠用的時候會發生什麼事。
  • 被驅動表沒索引:一個索引的缺席,怎麼變成上億次的比對。
  • 子查詢:IN (SELECT ...) 會被改寫成什麼?什麼時候它不會被改寫(DEPENDENT SUBQUERY)?
  • 一個真實場景:三張表的 JOIN,測試環境 20 毫秒,正式環境 40 秒——而兩邊的索引一模一樣。

這一章會大量用到 ch01 的 EXPLAIN 讀法(尤其是 Extra 與 EXPLAIN ANALYZE 的 loops)和 ch02 的估算觀念。JOIN 的問題幾乎都不是「JOIN 本身很慢」,而是前兩章的某個問題在這裡被放大了。

在認識任何一種 JOIN 演算法之前,先把成本公式寫下來。後面所有的討論都只是在改這條式子裡的某一項。

JOIN 總成本 ≈ 掃驅動表的成本
            + 驅動表符合條件的列數 × 查一次被驅動表的成本

第二項是重點:它是乘法。這條式子直接推出三件事:

  • 驅動表過濾後剩幾列,比表本身有多大重要得多。一張一億列的表如果 WHERE 之後只剩 3 列,它是很好的驅動表。
  • 「查一次被驅動表」是否走索引,決定了數量級。走索引是幾次 I/O,沒有索引就是掃一次全表——再乘上前面那個次數。
  • ch02 的估算誤差在這裡會被放大。優化器估驅動表剩 214 列、實際剩 120 萬列,那第二項就差了 5,600 倍。
所以 JOIN 慢的原因幾乎永遠是這兩個之一:①驅動表選錯(乘數太大)②被驅動表沒索引(單價太高)。剩下的都是這兩件事的變形。

MySQL 只有兩種 JOIN 演算法

比起其他資料庫,MySQL 8.0 的選項意外地少——而這反而讓判斷變簡單:

  • Index Nested Loop Join(NLJ):拿驅動表的每一列,去被驅動表的索引查一次。被驅動表有可用索引時就走這個,也是絕大多數健康查詢的樣子。
  • Hash Join:把驅動表整批讀進記憶體建成 hash 表,再掃被驅動表逐列比對。被驅動表沒有可用索引時的救援方案(8.0.18 引進,8.0.20 起全面取代舊的 Block Nested Loop)。

看 EXPLAIN 就能分辨:Extra 出現 Using join buffer (hash join) 就是後者。在 FORMAT=TREE 裡更直接,節點名稱直接寫 Nested loop inner join 或 Inner hash join。

一句話的判斷標準

看到 hash join 不要覺得「MySQL 幫我優化了」,要覺得「這裡少了一個索引」。hash join 是在沒有索引的情況下把 O(n×m) 降成 O(n+m) 的補救,而有索引的 NLJ 根本不需要掃第二張表。

NLJ 的邏輯就是字面意思——兩層迴圈,只是內層走索引:

for (驅動表 o 中每一列 row) {          // 外層:掃一次
    用 row.user_id 去 users 的主鍵索引查一次;   // 內層:走 B+Tree
    如果找到就組合輸出;
}

在 EXPLAIN 裡的樣子

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 次頁讀取  →  以分鐘計
同一句 SQL、同樣的資料量,差別只在一個索引在不在。這就是為什麼 JOIN 的第一個檢查點永遠是「被驅動表的 ON 條件欄位有沒有索引」。

用 EXPLAIN ANALYZE 看真正的次數

回扣 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 來救:

1
build 階段:把較小的那一邊整批讀進記憶體(join_buffer_size),以 JOIN 欄位為 key 建成 hash 表。
2
probe 階段:掃描另一邊,每一列算一次 hash、去表裡查有沒有配對。

成本從「n × 掃一次 m」變成「掃一次 n + 掃一次 m」——兩張表各掃一次就好。這確實是巨大的改善,但它只適用於等值 JOIN(ON a.x = b.y),範圍條件的 JOIN 用不了。

記憶體不夠的時候

join_buffer_size 預設只有 256KB。裝不下 hash 表時,MySQL 會走 grace hash join:把兩邊都依 hash 值分割成一批批的 chunk 檔案寫到磁碟,再一組一組載回來比對。

這時候你會看到一句「只是 JOIN 兩張表」的 SQL 產生大量磁碟寫入。而它在測試環境不會發生——因為那裡的資料量剛好塞得進 256KB。這是「測試正常、正式爆炸」的典型成因之一。

調大 join_buffer_size 是解法嗎

是止血,不是解法,而且它有一個很容易踩的性質:

  • 它是每個連線、每個 JOIN 各配一份,不是全域共用一塊。設成 256MB、100 個連線同時各跑一個兩表 JOIN,就是 25GB。
  • 真正的問題是「被驅動表沒索引」,補上索引之後 hash join 根本不會被選用,這塊記憶體也就不需要了。
-- 診斷用:先在 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 少數合理的用途之一。

你寫的子查詢,優化器多半不會照字面執行。知道它會被改寫成什麼,才知道為什麼有些子查詢很快、有些會慢到不可思議。

IN (SELECT ...) 通常會變成 semi-join

-- 你寫的
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 再決定要不要手動改寫,不要照舊經驗盲改。

例外:DEPENDENT SUBQUERY

真正該警覺的是這個。當子查詢引用了外層的欄位(關聯子查詢),它就無法被獨立物化:

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 與 NULL 的陷阱

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;

兩者的結果不一樣

  • ①:所有 users 都會出現。沒有符合條件訂單的人,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;
  • 測試環境:20 毫秒,計畫漂亮,Code Review 通過,上線。
  • 正式環境:40 秒,而且 CPU 直接被打滿。
  • 兩邊的索引定義完全一致(有拿 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   ← 不一樣
兩張表的 collation 不同。JOIN 時 MySQL 必須把其中一邊轉換成另一邊的定序才能比較——於是欄位被隱式轉換包住,merchants.code 的索引就此失效。這與 ch01 那個 cast() 是同一個機制,只是這次包住欄位的是 collation 轉換。

為什麼測試環境不會出現

1
merchants 是三年前建的舊表,當時預設是 utf8mb4_general_ci;orders 去年重建過,用的是 MySQL 8.0 的新預設 utf8mb4_0900_ai_ci。
2
測試環境是用 schema 腳本一次建起來的,所有表都吃同一個當前預設值——兩邊 collation 一致,索引正常,所以 20 毫秒。
3
SHOW CREATE TABLE 逐字比對時,大家看的是索引定義那幾行,沒有人去比對欄位的 COLLATE——它不在「索引」這個概念裡。
4
再加上資料量差距:測試環境 merchants 只有 30 列,hash join 掃一次毫無感覺;正式環境 12,400 列 × 驅動表 84,200 列的比對,就是 40 秒。

修法

  • 治本:統一 collation。ALTER TABLE merchants CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;——但這是重建整張表的 DDL,大表要用線上 DDL 工具、要挑離峰、要先確認排序規則變更不會影響既有的比對語意(ch06 主講)。
  • 止血:在 JOIN 條件上明確指定定序 ON m.code = o.merchant_code COLLATE utf8mb4_0900_ai_ci——但要讓被轉換的是驅動表那一邊,否則索引照樣失效。這種寫法很容易寫反,改完一定要用 EXPLAIN 確認 type 回到 eq_ref。
  • 防再犯:建表模板固定 charset/collation;上線前的 schema diff 檢查納入欄位屬性,不要只比對索引與欄位名。

這個案例真正的教訓

「測試環境正常」不能證明計畫正確,它只證明了「在那份資料量與那份 schema 下正常」。JOIN 對這兩者都極度敏感——資料量決定乘數,schema 細節決定被驅動表能不能走索引。要驗證 JOIN,就得在接近正式的資料量與 schema 上驗證。

把前面幾節收成一個可以照著跑的順序。每一步都是為了回答那條成本公式裡的某一項。

1
誰是驅動表?看 EXPLAIN 第一列(或 FORMAT=TREE 最外層)。它的 rows × filtered 就是乘數。
2
乘數估得準嗎?EXPLAIN ANALYZE 看被驅動表節點的 loops,與估算對照。差一個數量級以上 → 回 ch02 處理統計資訊,不要在這一章繼續找。
3
被驅動表走索引了嗎?type 應該是 eq_ref 或 ref。出現 ALL 或 Using join buffer → 補索引;索引存在卻沒用到 → 檢查型別與 collation 是否一致(ch01 的清單)。
4
能不能少 JOIN 一張表?只為了取一個名稱而 JOIN 一張幾乎不變的字典表,用應用層快取或反範式冗餘一個欄位,往往比優化這個 JOIN 划算得多(ch06 談反範式的代價)。
5
是不是根本不該用 JOIN?見下面。

什麼時候不要用 JOIN

  • 資料在不同的資料庫或服務裡——那就沒得選,只能在應用層組裝。此時要注意的是不要組成 N+1(ch08)。
  • JOIN 出來的結果會大量重複:一對多的 JOIN 會把「一」那邊的欄位重複很多次。主表有幾個大欄位時,網路傳輸與 ORM 映射的成本可能超過查兩次資料庫。
  • 分頁 + 一對多 JOIN:LIMIT 20 限制的是 JOIN 之後的列數,不是主表的列數——結果會少於 20 筆主資料。這是 ORM 分頁最經典的錯誤,ch08 會完整拆解。
反過來說,也不要因為「JOIN 很貴」就一律拆成多次查詢。兩張表走索引的 NLJ,成本可能比多一次網路往返還低。判斷依據永遠是那條成本公式和實測,不是原則。

帶到下一章

你可能已經注意到,前面的例子裡反覆出現 ORDER BY ... LIMIT,而我一直沒有處理它。當 JOIN 的結果需要排序、分組、或翻到第五千頁時,成本會用另一種方式爆炸——那是下一章的主題。

這一章給的每個解法都有帳單。第二條主線「代價守恆」在 JOIN 這裡特別明顯。

四筆帳

  • 補索引讓 NLJ 走得動 → 寫入變慢、空間變大。被驅動表如果是寫入頻繁的表,這筆帳不小(ch06 會算)。
  • 調大 join_buffer_size → 它是每連線每 JOIN 各一份,不是共用。乘上連線數就是實際記憶體用量,而那塊記憶體是從 Buffer Pool 搶來的(ch07)。
  • 用 hint 壓驅動表順序 → 與 ch02 同一筆帳:資料分佈變了它不會跟著變,而且會掩蓋統計資訊失準這個根因。
  • 反範式冗餘欄位以避開 JOIN → 讀變快,但從此要維護一致性:來源改了誰負責同步?同步失敗怎麼辦?這是把「查詢複雜度」換成「寫入複雜度 + 資料一致性風險」(ch06 主講)。

JOIN 診斷看不到的東西

  • 它不會告訴你這句 JOIN 被呼叫了幾次。一個計畫完美的兩表 JOIN,在迴圈裡跑 200 次,一樣會拖垮系統(ch08)。
  • 它不會告訴你 JOIN 期間持有了什麼鎖。SELECT ... FOR UPDATE 的 JOIN 會依序在多張表上加鎖,而加鎖順序不一致正是死鎖的來源(ch05)。
  • 它不會告訴你結果集有多大。一對多 JOIN 回傳 50 萬列時,資料庫端可能只花 200 毫秒,應用端 ORM 映射花掉 10 秒。

把這一章收成三句

① JOIN 是乘法:驅動表過濾後的列數是乘數,被驅動表單次查詢成本是單價,慢一定出在其中一項。
② 看到 hash join 不是「被優化了」,是「這裡少一個索引」——除非它是大範圍的批次查詢。
③ 測試環境正常不能證明 JOIN 正確,因為乘數來自資料量、單價來自 schema 細節,而測試環境這兩者都跟正式不同。
兩種 JOIN 演算法:什麼時候用哪個
Index Nested LoopHash Join
觸發條件被驅動表的 JOIN 欄位有可用索引沒有可用索引,且是等值 JOIN
成本驅動表列數 × 每次索引查詢(約 3 次 I/O)兩張表各順序掃一次
EXPLAIN 特徵被驅動表 type=eq_ref / refExtra: Using join buffer (hash join)
記憶體幾乎不用額外記憶體join_buffer_size(預設 256KB,每連線每 JOIN 一份)
裝不下時不適用分割成 chunk 檔案寫磁碟(grace hash join)
什麼時候是對的線上 API:一次取幾列到幾百列報表/對帳批次:大範圍關聯,順序掃反而快
支援的條件等值、範圍都可以只支援等值 JOIN
JOIN 慢的四種成因與對策
EXPLAIN 症狀根因第一順位解法代價
被驅動表 type=ALL + hash joinJOIN 欄位沒索引補索引寫入變慢、空間變大
索引存在卻 type=ALL型別或 collation 不一致,欄位被隱式轉換包住統一型別/collation(治本)CONVERT TO 是重建整張表的 DDL
驅動表 rows 很大驅動表選錯,或 rows×filtered 估錯回 ch02 修統計資訊;必要時 JOIN_ORDER hinthint 會過期、掩蓋根因
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 NULLanti-join無與 NOT EXISTS 等價,可讀性各有偏好

練習題 點選選項查看解析

0 / 10
01 / 10
JOIN 的成本公式中,決定「乘數」的是什麼?
A 被驅動表的總列數
B 驅動表經過 WHERE 過濾後的列數(rows × filtered)
C 兩張表列數的乘積
D JOIN 條件的欄位數量
解析
成本 ≈ 掃驅動表 + 驅動表過濾後列數 × 查一次被驅動表的成本。第二項是乘法,而乘數是驅動表過濾後剩幾列,不是表本身多大。這也是為什麼 ch02 的估算誤差在 JOIN 裡會被放大數千倍。
02 / 10
EXPLAIN 的 Extra 出現 Using join buffer (hash join),正確的解讀是什麼?
A MySQL 選用了更快的演算法,是好消息
B 被驅動表的 JOIN 欄位沒有可用索引——線上 API 出現它幾乎都代表缺索引
C join_buffer_size 設定過小
D 查詢用到了暫存表
解析
hash join 是「沒有索引時」把 O(n×m) 降成 O(n+m) 的救援方案,有索引的 NLJ 根本不需要掃第二張表。例外是大範圍的報表批次查詢——那種情況下兩張表各順序掃一次,確實可能贏過大量隨機 I/O 的索引查詢。
03 / 10
「小表驅動大表」這句話不夠精確,正確的說法是?
A 永遠讓列數最少的表當驅動表
B 讓經過 WHERE 過濾後結果集最小的表當驅動表,而且這個準則只在被驅動表有索引時才重要
C 永遠讓有主鍵的表當驅動表
D 讓寫在 FROM 後面第一個的表當驅動表
解析
一億列的表若 WHERE 後只剩 1 列,它是很好的驅動表。決定乘數的是 rows × filtered 而非總列數。另外若被驅動表沒索引、走 hash join,成本是兩邊各掃一次,驅動表順序的影響就小得多——該做的是補索引。
04 / 10
join_buffer_size 有一個容易踩的性質,是什麼?
A 它是全域共用一塊記憶體
B 它是每個連線、每個 JOIN 各配一份,乘上連線數才是實際用量
C 它只在啟動時能設定
D 它只影響 ORDER BY
解析
設成 256MB、100 個連線各跑一個兩表 JOIN 就是 25GB,而這塊記憶體是從 Buffer Pool 搶來的。調大它是止血;補上索引之後 hash join 根本不會被選用,這塊記憶體也就不需要了。
05 / 10
測試環境 20ms、正式環境 40 秒,兩邊索引定義逐字比對過完全一致。最值得懷疑的是什麼?
A 正式環境的硬體比較差
B 資料量造成乘數不同,以及欄位的型別/collation 不一致讓被驅動表索引失效
C 正式環境的 MySQL 版本較舊
D 測試環境有查詢快取
解析
JOIN 對資料量(決定乘數)與 schema 細節(決定被驅動表能否走索引)都極度敏感。collation 不一致特別隱蔽:比對 SHOW CREATE TABLE 時大家看的是索引那幾行,沒人比對欄位的 COLLATE,而測試環境用同一份腳本一次建起來時兩邊必然一致。
06 / 10
為什麼 collation 不一致會讓 JOIN 的被驅動表索引失效?
A 因為 MySQL 不支援跨 collation 的比較
B 因為必須先把一邊轉換成另一邊的定序才能比較,欄位被隱式轉換包住,B+Tree 的順序就用不上了
C 因為索引會自動被停用
D 因為 collation 不同的欄位不能建索引
解析
這與 ch01 那個 cast() 是同一個機制——只要索引欄位被任何東西包住(函數、型別轉換、定序轉換),索引裡儲存的順序就失去意義。止血手法是在 JOIN 條件上指定 COLLATE,但要讓被轉換的是驅動表那一邊,改完必須用 EXPLAIN 確認 type 回到 eq_ref。
07 / 10
關於 NOT IN (SELECT col FROM ...),下列哪一點最需要注意?
A 它比 NOT EXISTS 快
B 只要子查詢結果中有一個 NULL,整句就回傳空集合,而且不會報錯
C 它不能用在有索引的欄位上
D MySQL 8.0 已不支援這個語法
解析
三值邏輯的必然結果:x NOT IN (1, NULL) 永遠不為真。它安靜地回傳零筆,不拋錯、不進錯誤日誌——與 ch01 的隱式轉換同屬「不報錯只給錯答案」這一類。同時含 NULL 也讓優化器難以套用 anti-join 改寫。建議一律改用 NOT EXISTS。
08 / 10
LEFT JOIN 時,把被驅動表的過濾條件寫在 WHERE 而不是 ON,會發生什麼?
A 沒有差別,兩者等價
B 右表全為 NULL 的列會被濾掉,LEFT JOIN 退化成 INNER JOIN,而且優化器因此解除驅動表順序限制、可能換一個計畫
C 會導致語法錯誤
D 只會影響效能,不影響結果
解析
ON 決定「怎麼配對」,WHERE 決定「配完之後留誰」。退化成 INNER JOIN 之後,因為外連接必須由左表驅動的限制解除了,優化器可能選出完全不同的計畫——於是查詢變快了,但它回答的是另一個問題。這種「快了但答案變了」最危險。
09 / 10
EXPLAIN 顯示 select_type = DEPENDENT SUBQUERY,代表什麼?
A 子查詢會被物化成暫存表,只執行一次
B 子查詢引用了外層欄位,外層有幾列它就要跑幾次——又是一個乘法
C 子查詢有語法錯誤
D 子查詢會被優化器自動改寫成 JOIN
解析
關聯子查詢無法被獨立物化。它不一定是問題——外層只有幾十列、內層走索引就很快;但外層是幾十萬列時就是災難。判斷方法一樣:看外層的 rows,那就是乘數。
10 / 10
JOIN 慢的排查中,發現 EXPLAIN ANALYZE 的 loops 與估算的 rows 差了 5000 倍。下一步該做什麼?
A 調大 join_buffer_size
B 回到 ch02 處理統計資訊——乘數估錯的根因在上一層,不該在 JOIN 這一層繼續找
C 加 JOIN_ORDER hint 強制驅動表順序
D 把 JOIN 拆成多次查詢在應用層組裝
解析
JOIN 慢的問題幾乎都不是「JOIN 本身慢」,而是前兩章的某個問題在這裡被放大。估算與實測差一個數量級以上,代表優化器是在錯誤前提下選的驅動表與演算法,先修輸入(ANALYZE TABLE、histogram)再回頭看計畫。

點擊卡片翻面查看答案,共 13 張。

QUESTION
JOIN 的成本公式是什麼?為什麼說它是乘法?
點擊翻面
ANSWER
成本 ≈ 掃驅動表 + 驅動表過濾後列數 × 查一次被驅動表的成本。第二項是乘法,所以 JOIN 慢一定出在兩者之一:乘數太大(驅動表選錯/估錯)或單價太高(被驅動表沒索引)。
點擊翻回
QUESTION
MySQL 8.0 有哪兩種 JOIN 演算法?各自何時被選用?
點擊翻面
ANSWER
Index Nested Loop(被驅動表有可用索引時)與 Hash Join(沒有索引、且是等值 JOIN;8.0.18 引進,8.0.20 起取代舊的 Block Nested Loop)。
點擊翻回
QUESTION
看到 Using join buffer (hash join) 該怎麼解讀?
點擊翻面
ANSWER
不是「被優化了」,是「這裡少一個索引」。例外:大範圍的報表批次查詢,兩張表各順序掃一次確實可能贏過大量隨機 I/O。判準是「一次取幾列」還是「一次取幾百萬列」。
點擊翻回
QUESTION
join_buffer_size 的預設值與最容易踩的性質是?
點擊翻面
ANSWER
預設 256KB;它是每個連線、每個 JOIN 各配一份,不是全域共用。乘上連線數才是實際記憶體用量,而那塊記憶體是從 Buffer Pool 搶來的。
點擊翻回
QUESTION
hash join 的記憶體不夠時會發生什麼?
點擊翻面
ANSWER
走 grace hash join:把兩邊依 hash 值分割成 chunk 檔案寫到磁碟再分批載回。於是「只是 JOIN 兩張表」的 SQL 產生大量磁碟寫入——而測試環境資料量小塞得下,不會出現。
點擊翻回
QUESTION
「小表驅動大表」正確的說法是什麼?
點擊翻面
ANSWER
讓「經過 WHERE 過濾後結果集最小」的表當驅動表——決定乘數的是 rows × filtered 而非總列數。而且這個準則只在被驅動表有索引時才重要。
點擊翻回
QUESTION
怎麼從 EXPLAIN 看出誰是驅動表?
點擊翻面
ANSWER
表格式的第一列就是驅動表(不是 SQL 裡寫在前面的那張);FORMAT=TREE 裡則是縮排較淺的外層節點。
點擊翻回
QUESTION
collation 不一致為什麼會讓 JOIN 的索引失效?
點擊翻面
ANSWER
比較前必須把一邊轉換成另一邊的定序,欄位因此被隱式轉換包住,B+Tree 的順序失去意義。與 ch01 的 cast() 同一個機制,只是包住欄位的東西不同。
點擊翻回
QUESTION
為什麼「測試環境 20ms、正式環境 40 秒」在 JOIN 特別常見?
點擊翻面
ANSWER
JOIN 的乘數來自資料量、單價來自 schema 細節,而測試環境這兩者都與正式不同:資料量小到 hash join 無感,schema 用同一份腳本建立所以 collation 必然一致。
點擊翻回
QUESTION
NOT IN (SELECT ...) 的陷阱是什麼?該用什麼取代?
點擊翻面
ANSWER
子查詢結果只要有一個 NULL,整句就回傳空集合(三值邏輯),而且不報錯。同時難以被改寫成 anti-join。改用 NOT EXISTS。
點擊翻回
QUESTION
LEFT JOIN 的條件放 ON 與放 WHERE 差在哪?
點擊翻面
ANSWER
ON 決定怎麼配對、WHERE 決定配完留誰。放 WHERE 會濾掉右表全 NULL 的列,使 LEFT JOIN 退化成 INNER JOIN,優化器也因此解除驅動表順序限制、可能換計畫——快了,但答案變了。
點擊翻回
QUESTION
select_type = DEPENDENT SUBQUERY 代表什麼?危險在哪?
點擊翻面
ANSWER
關聯子查詢,引用了外層欄位所以無法獨立物化,外層有幾列就跑幾次。危險程度取決於外層的 rows——那就是乘數。
點擊翻回
QUESTION
JOIN 慢的排查順序是什麼?
點擊翻面
ANSWER
①誰是驅動表、乘數多大 ②乘數估得準嗎(loops vs rows,差太多回 ch02)③被驅動表走索引了嗎(type 應為 eq_ref/ref)④能不能少 JOIN 一張表 ⑤是不是根本不該用 JOIN。
點擊翻回