撰寫的順序,不是執行的順序

令人困惑的 SQL 錯誤,多半源自同一件事:您撰寫子句的順序,並不是資料庫評估它們的順序。 SELECT 寫在最前面,但引擎處理到它的時間點很晚。所以這段會失敗:

SELECT price * quantity AS total
FROM order_items
WHERE total > 1000;          -- 錯誤:找不到 total 這個欄位

而這段可以:

SELECT price * quantity AS total
FROM order_items
ORDER BY total DESC;         -- 正常

別名本身完全沒變,變的是該子句是在 SELECT 之前還是之後執行。

兩種順序並列

撰寫順序邏輯執行順序
1. SELECT1. FROM / JOIN — 組出資料列
2. FROM2. WHERE — 逐列過濾
3. JOIN3. GROUP BY — 把資料列收攏成群組
4. WHERE4. HAVING — 過濾群組
5. GROUP BY5. SELECT — 計算輸出欄位(別名在這裡誕生
6. HAVING6. DISTINCT — 去除重複列
7. ORDER BY7. ORDER BY — 排序結果
8. LIMIT8. LIMIT / OFFSET — 裁切結果

把右欄由上往下讀一遍,SQL 的多數「為什麼」就不再是謎。這是標準定義的邏輯順序;只要結果一致,最佳化器可以自由調整實際的執行方式。

為什麼 WHERE 不能用別名,ORDER BY 可以

SELECT 是第 5 步,WHERE 是第 2 步。WHERE 執行時別名還不存在;ORDER BY 是第 7 步、在 SELECT 之後,那時就存在了。

可攜性最好的做法是把運算式再寫一次:

SELECT price * quantity AS total
FROM order_items
WHERE price * quantity > 1000;

或是塞進子查詢,讓別名對外層查詢而言變成真正的欄位:

SELECT * FROM (
    SELECT price * quantity AS total FROM order_items
) t
WHERE t.total > 1000;

各方言放寬這條規則的程度不同:MySQL 在 GROUP BYHAVING 允許別名,PostgreSQL 在 GROUP BYORDER BY 允許但 HAVING 不行。不過主流資料庫都不允許在 WHERE 使用別名。

WHERE 與 HAVING 的分工

兩者不能互換,原因同樣是順序:WHERE(第 2 步)過濾的是分組之前的資料列HAVING(第 4 步)過濾的是彙總之後的群組

SELECT customer_id, COUNT(*) AS order_count
FROM orders
WHERE status = 'paid'          -- 先過濾資料列:只留已付款的訂單
GROUP BY customer_id
HAVING COUNT(*) >= 3;          -- 再過濾群組:這樣的訂單有 3 筆以上

對調之後意義就完全變了。HAVING status = 'paid' 是在問群組而不是資料列,而 WHERE COUNT(*) >= 3 則直接是錯誤——第 2 步時還沒開始計算任何彙總。

實務判準很單純:條件裡出現彙總函式就放 HAVING,否則放 WHERE 把非彙總條件留在 WHERE,也能讓引擎更早丟掉資料列,通常比較快。

GROUP BY:各家資料庫不同的規則

分組時,SELECT 列出的每個欄位都必須出現在 GROUP BY,或是被彙總函式包起來。否則資料庫無從得知該顯示收攏後的哪一列。

-- 壞掉的寫法:不知道該用哪個 name 搭配計數
SELECT customer_id, name, COUNT(*) FROM orders GROUP BY customer_id;

PostgreSQL 會直接拒絕。MySQL 預設也拒絕——自 5.7.5 起 ONLY_FULL_GROUP_BY 模式預設開啟。舊設定的 MySQL 會接受並回傳任意一列的值,因此常以升級伺服器後既有查詢突然失敗的形式浮現(expression is not in GROUP BY clause and contains nonaggregated column)。正解是把欄位加進 GROUP BY 或改用 MAX(name) 之類的彙總,而不是關掉該模式。

彙總與 NULL

以下兩點會悄悄改變數字,值得記住:

運算式會算進 NULL 嗎
COUNT(*)會(計算列數本身)
COUNT(欄位)不會(該欄位為 NULL 的列會跳過)
COUNT(DISTINCT 欄位)不會(非 NULL 的相異值個數)
SUM / AVG / MAX / MIN不會(忽略 NULL)

AVG(欄位) 是除以非 NULL 值的個數,不是列數。若希望 NULL 視為 0,請明確寫成 AVG(COALESCE(欄位, 0))

影響最大的場合是 LEFT JOIN 之後:沒有配對到的列右側會是 NULL,因此 COUNT(*) 會把沒有訂單的客戶算成 1,而 COUNT(o.id) 才會正確地算成 0。

SELECT c.id, COUNT(o.id) AS order_count      -- 不是 COUNT(*)
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;

LIMIT 最後才跑,而且怕同分

LIMIT 是第 8 步,在排序之後才套用。由此可得兩個結論。

它不會讓彙總變便宜。 對含 GROUP BY 的查詢加上 LIMIT 10,資料庫仍會先分組完再把大部分丟掉。

沒有唯一排序鍵,分頁就不穩定。created_at 有同分,翻頁過程中同一列可能出現兩次,或一次都沒出現。請加上唯一欄位當作決勝條件:

ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

語法有方言差異:PostgreSQL 與 MySQL 都接受 LIMIT n OFFSET m(MySQL 另保留參數順序相反的舊寫法 LIMIT m, n),SQL Server 則是 OFFSET m ROWS FETCH NEXT n ROWS ONLY

JOIN 與 WHERE 的陷阱

這是順序造成的事故中最常見的一種。JOIN 是第 1 步、WHERE 是第 2 步——也就是說,針對右側資料表的 WHERE 條件會在外部連接填入 NULL 之後才執行,而那些 NULL 列無法通過條件,於是消失:

-- 實際上變成了 INNER JOIN
SELECT * FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

-- 沒有已付款訂單的客戶也會保留
SELECT * FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid';

過濾連接的條件寫在 ON,過濾最終結果的條件寫在 WHERE 各種 JOIN 造成的結果列數差異,整理在 SQL JOIN 完整參考

不必背順序也能組裝查詢

知道順序是一回事,每次都把子句寫在正確位置又是另一回事。Visual SQL Builder 讓您從表單挑選資料表、連接條件、過濾、分組與排序,並依正確的撰寫順序輸出子句。由於每加一個元件就能看到產生的 SQL 改變,也很適合用來確認「這個條件到底該放哪裡」。

要讀懂既有查詢,可以用 SQL 格式化工具 縮排,子句的邊界就會變得清楚;SQL 轉 ER 圖 則能把正在連接的資料表關係畫成圖。若還在設計資料表階段,CREATE TABLE 參考手冊 整理了型別與約束。這些工具都在瀏覽器內完成處理,貼上的查詢與結構不會傳送到外部。

總結

  • 執行順序為 FROMWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT
  • 別名誕生於 SELECT,所以 WHERE 看不到、ORDER BY 看得到
  • 不含彙總的條件放 WHERE,含彙總的條件放 HAVING
  • MySQL 自 5.7.5 起 ONLY_FULL_GROUP_BY 為預設值
  • COUNT(*) 算列數,COUNT(欄位) 會跳過 NULL;LEFT JOIN 之後幾乎都該用後者
  • LIMIT 最後才跑,穩定分頁需要在 ORDER BY 加上唯一的決勝欄位
  • 過濾連接用 ON,過濾結果用 WHERE

常見問題

為什麼別名在 ORDER BY 可以用,在 WHERE 卻不行?

因為 WHERESELECT 之前評估,ORDER BY 在之後。別名是由 SELECT 建立的,所以 WHERE 執行時它根本還不存在。請在 WHERE 裡重寫完整的運算式,或用子查詢包起來讓別名成為真正的欄位。

WHERE 和 HAVING 該怎麼分?

WHERE 過濾分組前的個別資料列,HAVING 過濾彙總後的群組。條件若用到 COUNT()SUM() 等彙總函式,就只能放在 HAVING;其餘應留在 WHERE,因為那樣可以更早排除資料列。

MySQL 出現「expression is not in GROUP BY clause」怎麼辦?

這是自 MySQL 5.7.5 起預設開啟的 ONLY_FULL_GROUP_BY 模式。SELECT 中的每個欄位都必須出現在 GROUP BY 或被彙總函式包住。針對舊版 MySQL 撰寫的查詢在升級後失敗多半是這個原因,正解是把欄位加進 GROUP BY 或加以彙總,而不是關閉該模式。

加上 LIMIT 會讓彙總查詢變快嗎?

一般不會。LIMIT 是在分組與排序全部完成之後才套用,資料庫仍然會先建立完整結果再丟棄。想讓它變快,請用 WHERE 提早縮小範圍,或考慮能協助分組的索引。

貼上的 SQL 會傳送到伺服器嗎?

不會。Visual SQL BuilderSQL 格式化工具 都在瀏覽器內完成處理,輸入的查詢與資料表名稱不會傳送到任何地方。