大家好!👋

好久沒在這裡發文了。過去這幾天,我重新從基礎開始複習 SQL——不只是解決問題,而是理解為什麼會有不同的 SQL 概念,以及什麼時候該使用它們。

其中讓我(以及許多初學者)感到困惑的一個主題就是關聯子查詢(correlated subqueries)

你可能看過這樣的查詢:

SELECT ...
FROM table1 t1
WHERE EXISTS (
    SELECT *
    FROM table2 t2
    WHERE t2.id = t1.id
);

Enter fullscreen mode Exit fullscreen mode

然後心裡產生疑問:

內層查詢是如何存取外層查詢的欄位?
內層查詢什麼時候執行?
這和一般的子查詢有什麼不同?

在這篇文章中,我們將透過解答 LeetCode 的一道真實 SQL 題目,來回答上述所有問題。

讀完之後,你將理解:

  • ✅ 什麼是關聯子查詢
  • ✅ 它們在內部如何運作
  • ✅ SQL 的邏輯執行順序
  • ✅ 什麼時候該使用它們
  • ✅ 它們與視窗函式的比較

讓我們開始吧!


先備知識

本篇文章假設你已熟悉:

  • 基本的 SELECT 語句
  • WHERE
  • GROUP BY
  • 聚合函式(MINMAXCOUNT 等)

如果你對這些已經感到舒適,那你已經準備好學習關聯子查詢了。


目錄

  1. 為什麼關聯子查詢這麼令人困惑?
  2. 從頭開始認識關聯子查詢
  3. 關聯子查詢 vs 非關聯子查詢
  4. SQL 的邏輯執行順序(以及關聯子查詢的定位)
  5. 以 LeetCode 3421 作為我們的實戰範例
  6. 在撰寫 SQL 之前...
  7. 我的完整註解解答
  8. 使用真實資料一步步走過查詢
  9. 效能探討:關聯子查詢 vs 視窗函式
  10. 結論與練習題

1. 為什麼關聯子查詢這麼令人困惑?

當我第一次學習 SQL 時,關聯子查詢感覺就像魔法。

我腦中不斷浮現這樣的問題:

  • 內層查詢怎麼能存取外層查詢的欄位?
  • 內層查詢什麼時候執行?
  • 它只跑一次還是跑很多次?
  • 為什麼我不能獨立執行它?

如果你也曾問過自己這些問題,別擔心——讀完這篇文章後,你將得到所有答案。


什麼是子查詢?

子查詢就是巢狀在另一個 SQL 語句中的 SELECT 敘述。目前看起來還不算太可怕:

SELECT name, salary
FROM Employees
WHERE salary > (SELECT AVG(salary) FROM Employees);

Enter fullscreen mode Exit fullscreen mode

這裡,內層查詢 (SELECT AVG(salary) FROM Employees) 只執行一次,產生單一數值(例如 55000),而外層查詢就直接使用這個數值。內層查詢不在乎外層查詢目前正在看哪一列。這是非關聯(或稱「簡單」)子查詢——它是完全獨立的,可以獨自執行。

是什麼讓子查詢變成「關聯」的?

💡 核心概念

關聯子查詢依賴外層查詢的值。

因此它無法獨立執行——它會針對外層查詢的每一列重新執行一次,並使用該列的值。

SELECT e.name, e.salary, e.department_id
FROM Employees e
WHERE e.salary > (
    SELECT AVG(salary)
    FROM Employees e2
    WHERE e2.department_id = e.department_id   -- 👈 參照外層列 `e`
);

Enter fullscreen mode Exit fullscreen mode

獨立閱讀內層查詢:

SELECT AVG(salary) FROM Employees e2 WHERE e2.department_id = e.department_id

Enter fullscreen mode Exit fullscreen mode

e.department_id 在這個子查詢自己的 FROM Employees e2 中並不存在——它借用了外層查詢的欄位。這就是它的定義特徵。從心智模型來看,可以把它想像成一個帶有參數的函式

find_avg_salary_for(department_id):
    return AVG(salary) WHERE department_id = department_id

Enter fullscreen mode Exit fullscreen mode

而 SQL 會針對外層查詢碰到的每一列呼叫這個「函式」一次,帶入該列的 department_id。這就是整個概念。其餘部分只是建立在這個想法之上的裝飾。

一個簡單的心智模型

把外層查詢想像成一個 for 迴圈,而關聯子查詢就像在這個迴圈內部執行的程式碼,並使用迴圈變數:

# 這不是真正的 SQL——只是心智模型
for row in Employees:
    dept_avg = AVG(salary WHERE department_id == row.department_id)
    if row.salary > dept_avg:
        emit(row.name, row.salary, row.department_id)

Enter fullscreen mode Exit fullscreen mode

這個心智模型正是為什麼關聯子查詢可能會變得很昂貴——因為如果資料庫無法將其最佳化,這確實接近它最終的執行方式(更多細節請見效能章節)。

小結

關聯子查詢會針對外層查詢產生的每一列(或群組)執行一次,並使用該列的值作為輸入。


非關聯子查詢 關聯子查詢
參照外層查詢? ❌ 否 ✅ 是
可以獨立執行? ✅ 可以 ❌ 不可以
執行頻率 總共一次 針對外層每一列執行一次(概念上)
典型用途 與全域值比較(例如整體平均) 與「該列所屬群組」的數值比較(例如該部門的平均、該學生的最早日期)

非關聯範例——「薪資高於公司整體平均的員工」:

SELECT name FROM Employees
WHERE salary > (SELECT AVG(salary) FROM Employees);

Enter fullscreen mode Exit fullscreen mode

關聯範例——「薪資高於自己部門平均的員工」:

SELECT e.name FROM Employees e
WHERE e.salary > (
    SELECT AVG(salary) FROM Employees e2 WHERE e2.department_id = e.department_id
);

Enter fullscreen mode Exit fullscreen mode

結構相同,只有一個關鍵差異:WHERE e2.department_id = e.department_id 這一行。這一行就是「全域」與「依列範圍」之間的全部差別。

小結

如果子查詢可以獨自執行並回傳合理的結果,就是非關聯子查詢。如果它需要外層列的值才能有意義,就是關聯子查詢。


4. SQL 的邏輯執行順序(以及子查詢的定位)

這讓很多人困惑,讓我們釐清。雖然 SELECT 語句是從上到下書寫(SELECT → FROM → WHERE → GROUP BY → ...),但它的執行順序並非如此。MySQL(以及大多數 SQL 引擎)實際評估的邏輯順序如下:

① FROM        (以及 JOIN)— 建立工作列集

↓

② WHERE       — 過濾個別列

↓

③ GROUP BY    — 將列分組

↓

④ HAVING      — 過濾群組

↓

⑤ SELECT      — 計算輸出欄位
                (這裡會執行 SELECT 清單中的子查詢)

↓

⑥ ORDER BY    — 排序最終結果

↓

⑦ LIMIT       — 修剪結果

Enter fullscreen mode Exit fullscreen mode

位於 SELECT 清單內的關聯子查詢(例如我在解答中使用的那些)會在步驟 ⑤被評估——針對通過 FROM → WHERE → GROUP BY → HAVING 的每一列/群組執行。這是我們問題的關鍵洞見:

💡 核心概念

SELECT 中的關聯子查詢執行時,MySQL 已經知道哪些 (student_id, subject) 群組存在(感謝 GROUP BY)。接著子查詢針對每個群組被「詢問」一次:「針對這個特定的學生和科目,第一個分數是多少?最新的分數是多少?」

就是這樣。整個查詢實際上就是:1) 依學生+科目分組,2) 針對每個群組,問兩個額外問題。

小結

位於 SELECT 清單中的關聯子查詢會在步驟 ⑤執行——針對已經通過過濾與分組的每個群組執行一次。


5. 以 LeetCode 3421 作為我們的實戰範例:找出有進步的學生

資料表:Scores

欄位名稱 型別
student_id int
subject varchar
score int
exam_date varchar

(student_id, subject, exam_date) 為主鍵。每列代表一位學生在某個科目、某個考試日期的分數。score 介於 0 到 100 之間。

任務:找出有「進步」的學生。若學生在某科目滿足以下兩條件,則視為有進步:

  • 在該科目至少參加過兩次不同日期的考試
  • 該科目最新分數高於第一次分數

回傳 student_id, subject, first_score, latest_score,依 student_idsubject 升冪排序。

範例輸入:

student_id subject score exam_date
101 Math 70 2023-01-15
101 Math 85 2023-02-15
101 Physics 65 2023-01-15
101 Physics 60 2023-02-15
102 Math 80 2023-01-15
102 Math 85 2023-02-15
103 Math 90 2023-01-15
104 Physics 75 2023-01-15
104 Physics 85 2023-02-15

預期輸出:

student_id subject first_score latest_score
101 Math 70 85
102 Math 80 85
104 Physics 75 85

請注意被過濾掉的資料:

  • 101 / Physics — 考了兩次,但分數下降(65 → 60)。不算進步。
  • 103 / Math — 只考了一次。不符合「至少兩次不同日期」。

敘述很簡單,但核心困難在於:針對每一個 (student_id, subject) 組合,你需要回到同一個資料表,找出該群組的最小日期與最大日期,再找出對應的分數。這種「回到同一個資料表,但範圍限定在目前列所屬群組」的操作,正是關聯子查詢擅長的。


6. 在撰寫 SQL 之前...

讓我們暫時忘記 SQL。

假設你用 C++、Java 或 Python 撰寫。

你會怎麼做?大概會像這樣:

for each student

    for each subject

        earliest_exam

        latest_exam

        first_score

        latest_score

        if latest_score > first_score

            print()

Enter fullscreen mode Exit fullscreen mode

SQL 只是另一種表達這個演算法的方式。GROUP BY 對應 for each student / for each subject 部分,而關聯子查詢則對應 earliest_examlatest_examfirst_scorelatest_score 的查詢。請把這個迴圈記在腦中——它能讓下面的查詢變得不那麼抽象。


7. 我的完整註解解答

完整查詢

WITH t AS (
    SELECT
        student_id,
        subject,

        -- 關聯子查詢 #1:取得此群組最早考試日期對應的分數
        (SELECT score
         FROM Scores s
         WHERE s.student_id = t.student_id
           AND s.subject    = t.subject
           AND s.exam_date  = (
                -- 巢狀關聯子查詢:找出此學生+科目的 MIN(exam_date)
                SELECT MIN(exam_date)
                FROM Scores ss
                WHERE ss.student_id = t.student_id
                  AND ss.subject    = t.subject
           )
        ) AS first_score,

        -- 關聯子查詢 #2:取得此群組最晚考試日期對應的分數
        (SELECT score
         FROM Scores s
         WHERE s.student_id = t.student_id
           AND s.subject    = t.subject
           AND s.exam_date  = (
                -- 巢狀關聯子查詢:找出此學生+科目的 MAX(exam_date)
                SELECT MAX(exam_date)
                FROM Scores ss
                WHERE ss.student_id = t.student_id
                  AND ss.subject    = t.subject
           )
        ) AS latest_score

    FROM Scores t
    GROUP BY student_id, subject
)
SELECT *
FROM t
WHERE latest_score > first_score
ORDER BY student_id ASC, subject ASC;

Enter fullscreen mode Exit fullscreen mode

每個部分的作用

  • FROM Scores t GROUP BY student_id, subject — 將原始考試資料列壓縮成每個 (student_id, subject) 組合只有一列。這是我們要評估的「候選集合」。
  • first_score — 一個巢狀在另一個關聯子查詢內的關聯子查詢。內層(MIN(exam_date) 範圍限定 WHERE ss.student_id = t.student_id AND ss.subject = t.subject)找出目前群組 t 所屬的最早日期。外層再取出該確切日期的分數。
  • latest_score — 同樣概念,但使用 MAX(exam_date) 而非 MIN(exam_date)
  • 「至少兩次不同日期」的條件隱含處理的:如果某學生/科目只有一個考試日期,那麼 first_scorelatest_score 會是相同值(同一天 → 同分數),因此 latest_score > first_scorefalse,該列會被外層 WHERE 自然過濾掉。這是邏輯帶來的額外好處——不需要額外的 HAVING COUNT(DISTINCT exam_date) >= 2
  • 最終的 WHERE latest_score > first_score — 真正的「是否有進步」檢查。
  • ORDER BY student_id, subject — 符合題目要求的輸出排序。

建立關聯的那一行

一切都取決於巢狀子查詢內的這對條件:

WHERE
    ss.student_id = t.student_id
AND ss.subject    = t.subject

Enter fullscreen mode Exit fullscreen mode

這兩個條件就是全部的魔法。它們將內層查詢的 MIN/MAX 計算綁定到外層查詢 t 的這一特定列,而不是計算整個資料表。移除它們,子查詢就不再是關聯的——它會變成「所有學生和科目的最早日期」,這完全不是我們想要的。

小結

巢狀的 MIN(exam_date) / MAX(exam_date) 子查詢之所以是關聯的,是因為它們受到外層群組的 student_idsubject 過濾——這將它們的範圍限定在「這個學生、這個科目」,而不是整個資料表。


8. 使用真實資料一步步走過查詢

讓我們針對學生 101、科目 Math 使用範例資料追蹤:

exam_date score
2023-01-15 70
2023-02-15 85

外層查詢

----------------------------------
student_id = 101
subject    = 'Math'
----------------------------------

Enter fullscreen mode Exit fullscreen mode

            │
            ▼

關聯子查詢

----------------------------------
Find MIN(exam_date)
WHERE
    student_id = 101
AND subject    = 'Math'
----------------------------------

Enter fullscreen mode Exit fullscreen mode

            │
            ▼

2023-01-15

            │
            ▼

找出 exam_date = 2023-01-15 對應的 score

            │
            ▼

first_score = 70

latest_score 分支走相同的路徑,只是將 MIN(exam_date) 換成 MAX(exam_date),結果為 2023-02-15score = 85

把所有步驟放在一起:

  1. GROUP BY 產生群組 (101, Math)
  2. first_score 的內層子查詢:針對 (101, Math) 計算 MIN(exam_date)2023-01-15
  3. first_score 的外層子查詢:找出 exam_date = 2023-01-15 且 student=101、subject=Math 的分數 → 70
  4. latest_score 的內層子查詢:針對 (101, Math) 計算 MAX(exam_date)2023-02-15
  5. latest_score 的外層子查詢:找出 exam_date = 2023-02-15 的分數 → 85
  6. 到目前為止的列:(101, Math, 70, 85)
  7. 最終過濾:是 85 > 70 嗎? → 該列通過。

現在追蹤學生 103、科目 Math(只考了一次):

exam_date score
2023-01-15 90
  1. 群組 (103, Math)
  2. MIN(exam_date)2023-01-15first_score = 90
  3. MAX(exam_date)2023-01-15(同一天,只有一列)→ latest_score = 90
  4. 最終過濾:是 90 > 90 嗎? → 該列被刪除。這正是「至少兩次考試日期」規則免費被強制執行的方式。

以及學生 101、科目 Physics(分數從 65 下降到 60):

  1. first_score = 65latest_score = 60
  2. 60 > 65 嗎? → 被刪除。

這就是整個演算法的手動追蹤。

小結

每個群組的 first_scorelatest_score 都是透過「問同樣的兩步驟問題」獨立解析:找出邊界日期,再找出該日期的分數。


9. 效能探討:子查詢 vs 視窗函式

MySQL 8.0+ 提供了視窗函式,能以更有效率且更易讀的方式表達「每個群組的第一個與最後一個值」:

視窗函式版本

WITH ranked AS (
    SELECT
        student_id,
        subject,
        score,
        exam_date,
        FIRST_VALUE(score) OVER (
            PARTITION BY student_id, subject ORDER BY exam_date ASC
        ) AS first_score,
        FIRST_VALUE(score) OVER (
            PARTITION BY student_id, subject ORDER BY exam_date DESC
        ) AS latest_score,
        COUNT(*) OVER (PARTITION BY student_id, subject) AS exam_count
    FROM Scores
)
SELECT DISTINCT student_id, subject, first_score, latest_score
FROM ranked
WHERE exam_count >= 2 AND latest_score > first_score
ORDER BY student_id, subject;

Enter fullscreen mode Exit fullscreen mode

💡 核心概念

視窗函式通常只需要引擎對資料進行一次排序/分區,然後在單次遍歷中計算每個分區的所有值。相比之下,我的關聯子查詢版本會針對每個群組重新執行 MIN/MAX/查詢子查詢——在最壞情況下(或沒有良好索引時),這些內層查詢可能退化為針對每個群組重新掃描相關列,而不是單一統一的遍歷。

不過——對我原始解答的公平說明:由於有 GROUP BY,關聯子查詢只會針對每個相異的 (student_id, subject) 組合執行一次,而不是針對每一列原始資料執行。有了適當的複合索引 (student_id, subject, exam_date),MySQL 幾乎可以立即滿足每個 MIN/MAX 查詢與分數的點查詢,因此在實際資料集大小下,這個查詢的表現相當不錯——它並非人們有時假設的關聯子查詢必然是 O(n²) 的最壞情況。視窗函式版本仍然是表達這種邏輯的更「現代 SQL」方式,而且擴展性更可預測,但一旦建立索引,兩者之間的差異通常比人們預期的要小。

經驗法則:

  • 當你需要表達「針對每一列/群組,針對相關範圍提出針對性問題」時,請選擇關聯子查詢——它們在這種情況下極為易讀。
  • 當你要在整個分區資料集中計算 first/last/rank/running-total 這類邏輯時,請選擇視窗函式——它們通常擴展性更好,且避免每個群組重複執行。
  • 在假設哪一種是「最快的」之前,請務必在你的實際資料上檢查 EXPLAIN——資料大小、索引和 MySQL 版本都比教科書的複雜度故事更重要。

小結

關聯子查詢擅長易讀性與針對性的每群組查詢;視窗函式擅長規模,因為它們避免重複執行相同的計算。


10. 結論與後續練習

從這整篇文章中要帶走的核心想法可以用一句話概括:

關聯子查詢只是一個小型查詢,它一次從外層列取得一個值,並回答針對該值範圍的問題。

一旦這個概念被理解,大多數「困難」的關聯子查詢問題就不再困難——它們只是變成「我需要針對每一列/群組問什麼小問題,以及它需要外層查詢的什麼值?」

如果你想進一步鍛鍊這個技能,以下是一些直接依賴相同技能的題目:

  • Second Highest Salary(經典的關聯子查詢熱身)
  • Department Top Three Salaries
  • Rank Scores
  • Consecutive Numbers
  • Employees Earning More Than Their Managers

請嘗試用我這裡展示的方式解決每一題兩次:一次用關聯子查詢(或視窗函式),一次用紙筆像迴圈一樣手動追蹤。迴圈追蹤才是真正建立直觀理解的方法——SQL 語法只是之後對這種直觀理解的編碼。

最後的想法

關聯子查詢通常被認為是 SQL 最棘手的概念之一——不是因為語法困難,而是因為很容易迷失哪個查詢正在執行,以及正在參照哪一列

一旦你意識到內層查詢只是從外層查詢的目前列「借用」值,這個概念就會變得更加直觀。

希望這篇文章能讓這個心智模型更清楚一些。

祝學習愉快!🚀