SQL.1 宣告式的紅利與代價:書寫、求值與執行順序各自由誰決定
SQL 寫的是要什麼結果,不是怎麼算出來。這一條是這個語言其餘每個特徵的來源,包括它最容易讓人讀錯的那些。它最直接的一個後果是:同一個問題換兩種寫法送進去回來的結果一模一樣,而引擎在底下做的事可能完全不同——差在哪裡,查詢的文字一個字都沒說。
與逐步執行的程式對照一次,這個性質最清楚。 用 pandas 或任何一種 DataFrame 寫「找出下過單的顧客」,寫下的是一串動作:先載入這張表、再依條件篩選、再與另一張表合併,每一行都指定了下一步做什麼,而執行就照那個順序發生。SQL 寫下的是一段描述——哪些表參與、配對的條件是什麼、要留下哪些列——至於先讀哪一張、用什麼演算法配對、要不要臨時建索引,一個字都沒說。
紅利是描述與實作脫鉤。一段查詢在一萬列與一億列的資料上都不必改寫,換一台機器、換一個索引配置也不必改寫,因為怎麼算從來就不在寫下的查詢文字裡面。代價是紅利的另一面——怎麼算不在寫的東西裡面,所以從查詢的文字看不出它要花多少。
書寫順序、求值順序與執行順序
書寫順序是子句在文字上的先後:SELECT 在最前面,FROM 跟在後面,WHERE、GROUP BY、HAVING、ORDER BY 依序往下。這個順序是給人讀的,它的作用是讓查詢讀起來像一句話。
求值順序是語意模型規定的先後。它與書寫順序不同:先由 FROM 決定要處理哪個資料集合,再用 WHERE 篩掉列,接著 GROUP BY 分組、HAVING 篩掉組,最後才輪到 SELECT 把要輸出的欄位算出來、ORDER BY 排序。寫在最前面的 SELECT 在語意上排在倒數第二步。
執行順序是引擎實際做的事,由 query optimizer 決定,而最佳化器只受一個約束:輸出的列集合要與語意模型算出來的一致。在這個約束之內,它可以任意重排、可以換演算法、可以臨時建資料結構。
書寫順序、求值順序、執行順序這三者不一致,是宣告式這個選擇本身帶來的。讀查詢時的多數誤判來自把其中兩種當成同一種——最常見的是拿書寫順序去猜執行順序,以為「寫在前面的表會先被讀」。
執行計畫的依據:統計資訊與書寫順序
拿書店的兩張表來看這件事。顧客表五十列,訂單表二十萬列,要數的是兩者配得上的訂單有幾筆。同一個問題寫成兩種順序:
1SELECT count(*) FROM 訂單 JOIN 顧客 ON 訂單.顧客編號 = 顧客.顧客編號;
2SELECT count(*) FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;兩者都回 200000。看它們的 query plan 要用 EXPLAIN QUERY PLAN(SQLite 的 EXPLAIN 給的是逐行 bytecode,不是計畫),而計畫取決於引擎有沒有這兩張表的統計資訊。
這一節的兩張表可以這樣造:
1CREATE TABLE 顧客 (顧客編號 INT, 姓名 TEXT);
2CREATE TABLE 訂單 (訂單編號 INT, 顧客編號 INT, 金額 INT);
3
4WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 50)
5INSERT INTO 顧客 SELECT i, '顧客' || i FROM n;
6
7WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 200000)
8INSERT INTO 訂單 SELECT i, i % 50 + 1, i % 900 FROM n;剛建好、還沒統計過的資料庫上,SQLite 給出兩個不同的計畫:
1-- EXPLAIN QUERY PLAN SELECT count(*) FROM 訂單 JOIN 顧客 ON 訂單.顧客編號 = 顧客.顧客編號;
2|--SCAN 訂單
3|--BLOOM FILTER ON 顧客 (顧客編號=?)
4`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)
5
6-- EXPLAIN QUERY PLAN SELECT count(*) FROM 顧客 JOIN 訂單 ON 訂單.顧客編號 = 顧客.顧客編號;
7|--SCAN 顧客
8|--BLOOM FILTER ON 訂單 (顧客編號=?)
9`--SEARCH 訂單 USING AUTOMATIC COVERING INDEX (顧客編號=?)沒有統計就不知道哪張表大,於是它退回去按查詢文字裡兩張表出現的先後來決定計畫:顧客 JOIN 訂單 那種寫法掃五十列的顧客,為了查找而當場替二十萬列的訂單建一個索引。
跑一次 ANALYZE 讓引擎去數過兩張表之後,同樣兩段查詢得到同一個計畫:
1|--SCAN 訂單
2|--BLOOM FILTER ON 顧客 (顧客編號=?)
3`--SEARCH 顧客 USING AUTOMATIC COVERING INDEX (顧客編號=?)兩次都選擇掃二十萬列那張、對五十列那張做查找,寫誰在前面沒有影響。
**書寫順序從來不是引擎的依據,它只是引擎在沒有別的統計資訊時的退路。**真正的依據是統計資訊,而那份資訊不在查詢裡、也不在資料裡,它是引擎另外去數出來存著的資料。
計畫裡還有查詢文字完全沒提到的步驟。AUTOMATIC COVERING INDEX 是 SQLite 當場替顧客表建的索引,BLOOM FILTER 是它加的過濾步驟。查詢裡沒有一個字要求這些。
「先讀的那張表」這個直覺因此對應不到任何固定的一張表——同一段查詢在剛建好、還沒統計過的資料庫上與跑過 ANALYZE 之後,先讀的表不同。文字上的先後只決定語意,不決定執行。它影響語意的位置在外連接:LEFT JOIN 會保留左邊那張表的每一列,配不到對象的也留下、另一側補上空值,所以一張表寫在左邊還是右邊,決定的是哪一張表的列一定會出現在結果裡。多張表相連時「左邊」已經不是一張表——SQL.4 鏈式連接的左運算元 寫那個累積過程,以及在鏈中間放 RIGHT 為什麼會把保護範圍縮到只剩一張表。
求值順序擋下的寫法:聚合函數放進 WHERE
語意模型摸不著,而它會在引擎拒絕的地方現形:把聚合函數寫進 WHERE,SQLite 與 DuckDB 都拒絕,同一件事移到 HAVING 就通過。拿共用資料庫的訂單表找下過一張以上訂單的顧客:
1SELECT 顧客編號 FROM 訂單 WHERE COUNT(*) > 1 GROUP BY 顧客編號;
2-- SQLite:misuse of aggregate: COUNT()
3-- DuckDB:Binder Error: WHERE clause cannot contain aggregates!
4
5SELECT 顧客編號, COUNT(*) AS 張數 FROM 訂單 GROUP BY 顧客編號 HAVING COUNT(*) > 1;
6-- 兩個引擎都回 (1, 2):顧客編號 1 的佳穎,兩張訂單兩則訊息都只說了不准,理由要從求值順序讀出來。 WHERE 那一步分組還沒發生,沒有組就沒有組的計數,聚合函數拿不到它要用的那個值;HAVING 排在 GROUP BY 之後,那時每一組的計數已經算得出來。這條限制由語意模型規定,與寫法好不好看無關。
求值順序與執行順序不同的地方在於它是語意模型規定死的,換引擎不變,所以「每一步能用到哪些欄位與值」是一個有固定答案的問題。SQL.2 子句的求值順序 逐步走過求值順序這一串步驟,並分開模型層擋得住的限制與各家引擎自己放寬的——後者包括一種不報錯而給出錯答案的形態。
代價的驗證:輸出與執行計畫各自回答的問題
宣告式把「怎麼算」交給引擎決定,代價也跟著由引擎決定。查詢的文字裡沒有任何一處表示它會掃全表還是走索引、會不會臨時建一個索引、會不會把中間結果寫到磁碟——上面那段計畫證明了這一點,SCAN、BLOOM FILTER 與那個臨時索引,三者一個都不在查詢裡。
所以驗證分成兩件互相獨立的事。結果對不對看輸出,從查詢本身推得出來。代價可不可以接受看計畫,非得問引擎不可,而且同一段查詢在不同資料量、不同索引、不同統計資訊底下的答案不一樣。
開發用的小資料只驗得到結果對不對這一件:資料量小到任何寫法都夠快,所以查詢在開發環境通過,只代表描述得對;代價可不可以接受,要到正式的資料量上才量得出來。
跑 ANALYZE 前後計畫跟著改變的那一組對照,在真實系統上有它的故障形態:統計資訊過時、或者欄位之間的相關性沒有被統計到,最佳化器就拿著錯的資訊選計畫。PostgreSQL Query Optimization 收了這幾種讓計畫選錯的實際案例,以及讀懂 production 計畫要用的 EXPLAIN、EXPLAIN ANALYZE、auto_explain 三層工具。