SQL 標準留下許多沒有規定的地方,而每一家引擎都得替那些地方做一個決定。寬鬆度指的是引擎在一段文字不合標準、或標準沒有規定的時候,選擇收下它並自己補上那個決定——收不收、以及補什麼,各家不同。

查詢的文字之外還有幾方在決定結果:識別字規則決定名字指向誰、collation 決定字串怎麼比、權限決定誰能送出這段 SQL、約束決定資料能長成什麼樣、最佳化器決定要花多久。寬鬆度與那五方的關係不同——它決定的是那五方各自收與不收的界線畫在哪裡,所以同一家引擎的寬鬆度會同時出現在名字、比較、分組等好幾個位置上,本篇把那些位置並置起來比較。

本篇先整理形態學——各家的分割長什麼樣、差異在什麼位置發聲;再談決策——哪幾種差異要在寫的當下就處理掉、什麼時候要重算。形態學是決策的輸入:不知道一個差異會在什麼時候發聲,就算不出提前處理它要付多少。

本篇的量測跑在四家引擎上:SQLite 3.51、DuckDB v0.10.3、PostgreSQL 18、MySQL 8.4。兩張表各兩列,下表的每一個寫法是這個區塊裡的一段查詢:

 1CREATE TABLE 顧客 (顧客編號 INT, 姓名 VARCHAR(20));
 2CREATE TABLE 訂單 (訂單編號 INT, 顧客編號 INT, 金額 INT);
 3INSERT INTO 顧客 VALUES (1, 'Anna'), (2, 'anna');
 4INSERT INTO 訂單 VALUES (101, 1, 700), (102, 1, NULL);
 5
 6-- 未分組欄位:訂單編號不在 GROUP BY 裡,也沒有被聚合
 7SELECT 顧客編號, 訂單編號 FROM 訂單 GROUP BY 顧客編號;
 8-- SQLite:1 101(這一組有兩列,它挑了其中一列)
 9-- DuckDB、PostgreSQL、MySQL:拒絕
10-- MySQL 在同一條連線先 SET SESSION sql_mode = '' 之後:1 101
11
12-- WHERE 用別名:兩倍在 SELECT 那一步才算出來
13SELECT 金額 * 2 AS 兩倍 FROM 訂單 WHERE 兩倍 > 0;
14-- SQLite、DuckDB:1400
15-- PostgreSQL、MySQL:拒絕
16
17-- 同表未取別名
18SELECT count(*) FROM 訂單, 訂單;
19-- SQLite:4;引用欄位時才拒,SELECT 訂單編號 FROM 訂單, 訂單 回 ambiguous column name
20-- DuckDB、PostgreSQL、MySQL:FROM 就拒絕,連 count(*) 都不收
21
22-- 字串比對
23SELECT 姓名 FROM 顧客 WHERE 姓名 = 'anna';
24-- SQLite、DuckDB、PostgreSQL:anna
25-- MySQL:Anna、anna(預設 collation 不分大小寫)
26
27-- 空值排序
28SELECT 訂單編號 FROM 訂單 ORDER BY 金額;
29-- SQLite、MySQL:102、101(空的排最前)
30-- DuckDB、PostgreSQL:101、102(空的排最後)

同一組寫法在各家引擎上收與不收的分割

下面這組寫法擺在 SQLite、DuckDB、PostgreSQL、MySQL 這四家引擎上,每一個寫法把這四家切成兩邊,而切法彼此對不上:

寫法寫出來的樣子SQLiteDuckDBPostgreSQLMySQL
未分組欄位GROUP BY 之外的欄位進 SELECT接受拒絕拒絕拒絕
WHERE 用別名SELECT 的別名用在 WHERE接受接受拒絕拒絕
同表未取別名同一張表出現兩次沒取別名引用該欄位才拒FROM 就拒絕FROM 就拒絕FROM 就拒絕
字串比對WHERE 姓名 = 'anna'回 1 列回 1 列回 1 列回 2 列
空值排序ORDER BY 金額(一列為空)空的排最前空的排最後空的排最後空的排最前

沒有一個寫法的分割能預測下一個寫法的分割。 未分組欄位只有 SQLite 收;WHERE 用別名換成 SQLite 與 DuckDB 一組;字串比對只有 MySQL 給另一個答案;空值排序又切成 SQLite 加 MySQL 對上 DuckDB 與 PostgreSQL。同表未取別名的分割與未分組欄位相同(都是 SQLite 對上其餘三家),這一點不減損上面那句——同一組引擎可以在兩個寫法上一起站在同一邊,而那不預測第三個寫法。

同一家引擎也可以坐在同一個寫法的兩邊。 未分組欄位在 MySQL 上是拒絕,而把 sql_mode 清空之後同一段查詢回 1 101——寬鬆度是「哪一家加上哪一組設定」,不是「哪一家」。SQLite 收下同一段查詢的時候也在補一個決定,只是補得更隱蔽:一組裡有幾列,它就挑其中一列的值交出來,而挑哪一列由執行方式決定、查詢的文字裡沒有說。收下與拒絕之間還有第三種可能——收下並替寫查詢的人選一個答案(多列一組的實例在 SQL.9 分組鍵的選擇:鍵與一組所代表的對象)。

寬鬆度的排名確實存在,而它只回答「收不收」這一個問題。 就收不收而言 SQLite 確實比 PostgreSQL 寬鬆——未分組欄位、WHERE 用別名、同表未取別名都是,字串比對上兩家一樣。問題在於字串比對與空值排序問的不是收不收:這兩個寫法四家全部收下,而答案不同。所以帶著「SQLite 最寬鬆、PostgreSQL 最嚴」去搬家,會準確地預期到「本來能跑的變成報錯」,而錯過完全沒有報錯的字串比對與空值排序。

各個寫法的完整推導住在它們自己的篇裡:未分組欄位在 SQL.2 子句的求值順序:每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法 與 SQL.9 分組鍵的選擇:鍵與一組所代表的對象(SQLite 收下之後自己從那一組挑一列的值),WHERE 用別名在 SQL.11 查詢結果的列序:沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍(WHERE 那一步發生在算出別名之前),同表未取別名在 SQL.7 表的出現與別名:表名的指稱、別名的作用範圍與自連接(同一張表出現兩次為什麼非取名不可),字串比對在 SQL.15 字串比較與 collation:相等、排序、LIKE 與索引可用性(「相等」由哪一條比較規則定義),空值排序在 SQL.11 查詢結果的列序:沒有 ORDER BY 時的來源與 ORDER BY 的規定範圍(空值與任何值都比不出大小,落在哪一端由引擎規定)。表上沒有列出的一個實例形態特別隱蔽:識別字的大小寫。SQLite 與 DuckDB 連引號都不讓名字變敏感,所以在它們上面開發時混用引號的地方一路正常,搬到 PostgreSQL 才整批找不到表——SQL.14 識別字的規則:各家的大小寫摺疊、引號與保留字 寫各家的摺疊規則,以及兩種一致做法之間的中間地帶。

跨引擎差異的分級:當場報錯、等資料才報錯、靜默而輸出不同、靜默且輸出相同

上面那組寫法落在不同的級別上,而分級的依據是這個差異在正常的執行路徑上會不會發出聲音、在哪裡發。比較的單位是組態——不是「這一家與那一家」,是這一家的這個版本配上這一組設定,與另一家的另一個版本配上另一組設定。同一家跨一個大版本、或同一個資料庫換一條連線設定,都會產生下面每一種級別的差異。

當場報錯:剖析或名稱解析就出聲。 未分組欄位與 WHERE 用別名從寬鬆的那一家搬到嚴格的那一家時屬於這一級——第一次跑就報錯,訊息指名了那個欄位。當場報錯的差異最省事:它自己會現身,而且現身得早。

等資料才報錯:執行到某一筆資料才出聲。 約束的差異多半落在這一級——搬過去之後查詢照跑,直到第一筆違反它的資料寫進來。所以等資料才報錯的差異,現身時機由資料決定,而測試資料多半不含觸發它的那一筆。

靜默而輸出不同:不出聲,而輸出有差。 字串比對與空值排序在這一級。四家沒有一家報錯,回來的都是形狀正確的列,而列不一樣。要發現它得把同一段查詢在兩家上各跑一次、比對輸出。而這個比對不會有人自發去做,因為兩邊都沒有報錯——沒有任何東西提示這裡需要被比對。

靜默且輸出相同:不出聲,輸出也沒差。 SQL.18 外鍵與參照完整性:宣告、生效與查詢得到的保證 記的那幾種屬於這一級:同一段 REFERENCES 在一家建得出約束、在另一家被靜默丟掉,而建表語句成功、查詢也成功。靜默而輸出不同的差異至少在輸出上留下痕跡,靜默且輸出相同的差異連痕跡都不在輸出裡——它在系統目錄裡,要主動去查才看得到。

級別量的是發聲的位置,不是嚴重性、也不是偵測要花多少工夫。 修起來多麻煩與級別無關(當場報錯的差異可能要重寫整批查詢,靜默且輸出相同的差異可能只要加一行設定);連偵測成本都不跟著級別排序——靜默且輸出相同的差異查一次系統目錄就有答案,而靜默而輸出不同的差異要持續做跨引擎的輸出比對,成本反而更高。分級真正決定的是什麼時候會知道有這件事:當場報錯的差異不必準備,它自己會現身;靜默且輸出相同的差異在往後任何一個時刻都不會自己浮現,所以知道它存在的唯一途徑落在搬家之前那一次盤點裡。盤點之外,搬家本身的流程——分階段、雙寫、切換——在 大規模 DB 遷移實戰。

可攜性的操作對象:引擎代為決定的位置與把決定寫出來的宣告

一段 SQL 單獨拿出來問「它可不可攜」是答不出來的。上面字串比對那個寫法在四家上有兩種答案,而把比較規則寫進欄位宣告之後,四家的答案就統一了(SQL.15 字串比較與 collation:相等、排序、LIKE 與索引可用性 的〈collation 宣告在資料庫、表、欄位或條件上各自的涵蓋範圍〉一節);統一到分大小寫或不分大小寫都做得到,兩個方向在四家上都量過。統一的是答案,宣告的文字本身仍然要逐家寫。下面是 顧客 表 姓名 那一欄的宣告,放進各家的 CREATE TABLE 裡:

 1-- 分大小寫:WHERE 姓名 = 'anna' 在四家都回 1 列
 2姓名 TEXT COLLATE BINARY                      -- SQLite
 3姓名 TEXT COLLATE C                           -- DuckDB;寫 COLLATE BINARY 是語法錯誤
 4姓名 TEXT COLLATE "C"                         -- PostgreSQL
 5姓名 VARCHAR(20) COLLATE utf8mb4_0900_as_cs   -- MySQL
 6
 7-- 不分大小寫:同一句在四家都回 2 列
 8姓名 TEXT COLLATE NOCASE                      -- SQLite
 9姓名 TEXT COLLATE NOCASE                      -- DuckDB
10姓名 TEXT COLLATE ci                          -- PostgreSQL,ci 要先建成一條非確定性的規則:
11    -- CREATE COLLATION ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
12姓名 VARCHAR(20) COLLATE utf8mb4_0900_ai_ci   -- MySQL

空值排序那個寫法的決定是空值排在哪一端,把它寫進查詢就收回了引擎自己補的那一項:

1SELECT 訂單編號 FROM 訂單 ORDER BY (金額 IS NULL), 金額;
2-- 四家都回 101、102:金額 IS NULL 對空的那一列是真,真排在假後面
3SELECT 訂單編號 FROM 訂單 ORDER BY 金額 NULLS LAST;
4-- SQLite、DuckDB、PostgreSQL:101、102
5-- MySQL 8.4:ERROR 1064 語法錯誤

所以可攜性的操作對象多半是那些原本讓引擎代為決定的位置,而不是查詢本身。判斷標準是一句話:這段文字裡有沒有一個決定是它沒說、而由收下它的那一方補上的。 有,那個位置就是可攜性的斷點,不論它現在跑得多正常。這個判斷標準只涵蓋「有人補上的決定」;另一層是沒有任何一家引擎會代為決定的——查詢問對了沒有。那一層與寬鬆度無關,任何一家引擎的設定都管不到它,SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法 寫它的判斷標準為什麼只能來自查詢之外。

「有沒有一個決定由收下它的那一方補上」這個判斷標準也解釋了為什麼可攜性在單一一組設定上測不出來——同一段查詢跑一萬次,那個被補上的決定每一次都一樣。「單一引擎」還不夠:同一個 MySQL 實例上,一條連線 SET SESSION sql_mode = ''、另一條用預設,同一段帶未分組欄位的查詢一邊回結果、一邊回 ERROR 1055。可攜性是查詢與那一組組態之間的性質,不是查詢的性質。

寫的當下要處理哪幾級差異:按目標組態數分的做法

「一律照最嚴格的標準寫」這條路有價錢:每一個原本可以讓引擎決定的位置都要自己寫出來,查詢變長,寫的人要知道每一條預設是什麼。這筆錢值不值得花,主要落在一個可以現在就數出來的量上——這段 SQL 現在跑在幾種組態上。「組態」指一家引擎的某個版本配上一組設定:換一家算一種,同一家換一個大版本或換一組連線設定也算一種,所以升級一次 MySQL 與換去 PostgreSQL 在組態數上是同一類事。

數出來是一的時候,還要問第二句:這個一會不會被別人改成二。數出來已經大於一的時候這一句不必問,答案在成本上沒有分別。「會不會被別人改成二」這個變數比「現在跑在幾種組態上」難察覺,因為它不在系統裡:資料庫由自己選的系統,與資料庫由客戶指定的系統,現在可能都只跑在一種組態上,而它們對同一段 SQL 的要求完全不同——資料庫由自己選的系統可以把整筆帳推遲,資料庫由客戶指定的系統推遲不了。

現在只有一種,而換不換由自己決定的時候,寬鬆度是引擎給的便利。代價全部推遲到換引擎的那一天,而那一天的工作量由靜默而輸出不同、靜默且輸出相同這兩級差異的數量決定——當場報錯的那些第一次執行就會出現。這條路的風險不在「會不會換引擎」,在換的時候有沒有辦法知道要查哪些位置:推遲的不只是工作,還有那份清單。

現在只有一種,而換不換由別人決定的時候(客戶指定的資料庫、或遲早要跟上的版本升級),那一天會來而日期不在自己手上,所以成本前置到寫的當下——但只前置靜默而輸出不同、靜默且輸出相同這兩級差異:把引擎自己補的決定寫出來(collation、空值的位置、識別字的引號)。當場報錯的差異留著不動,因為它搬家時會當場現身。這條路把「什麼時候知道」從搬家那一天提前到編譯那一刻,而付的是幾個子句的長度。

現在就不只一種的時候(產品裝在客戶各自的資料庫上、或測試與正式本來就不同家——測試與正式不同家是自己選的,裝在客戶的資料庫上不是,而在組態數大於一這個條件下兩者的成本相同),答案要完全一致,而完全一致無法只靠寫出那些決定——還要一份跨引擎的對照,同一批查詢在目標引擎上各跑一次、比對輸出。這一步的成本是持續的(每次改查詢都要重跑),換到的是靜默而輸出不同的差異不再靠人記得去查。而它有一個取得性的問題正好落在最需要它的那個情形上:產品裝在客戶的資料庫上時,客戶那一組版本加設定多半拿不到也不見得授權得起,這時退而求其次的是把自己手上那幾種組態的對照跑起來,並把「我們驗過哪幾種組態」寫成一份可以交出去的清單。跨引擎的對照無法由閱讀代替,理由與 SQL.20 關鍵字的宣告與引擎的行為那一層相同:宣稱的真值住在文字之外。

還有一條路不看組態數:用查詢建構器或物件關聯對映把 SQL 包起來。按組態數分的那三種做法是自己決定哪幾種差異要提前處理,這一條是把整個決定交出去——那一層自己要對每一家引擎做同樣的決定,而它做了哪些決定通常沒有寫在讀得到的地方。所以它換掉的是「誰來查這些引擎代為決定的位置」,不是「有沒有這些位置」。

按組態數做的選擇成立的前提:組態清單、引擎設定與測試環境

按組態數做的選擇綁在幾個前提上,前提換了就要重算。

目標組態的清單會變長。 維持一致的成本隨組態數量成長,而不是隨查詢數量成長,所以清單變長那一刻的重算幅度比想像中大。讓清單變長的事件多半來自外部——客戶要求裝在自己的資料庫上、併購進來一套用別家引擎的系統,或者一次跨大版本的升級。升級這一種特別容易被漏掉,因為它在直覺上不算「換引擎」,而上面那組寫法裡至少有一個(未分組欄位)收不收由 sql_mode 決定,這個變數的預設值跟著版本走,換了版本,收不收就可能跟著換。清單變長時,各家引擎對同一個寫法收不收的差異只是遷移要處理的其中一項:換引擎或把併購進來的系統搬過來,另外還有資料的搬運、雙寫與切換;每一步要留下什麼證明,Schema Migration Rollout 證據 用一次同庫內的欄位拆分示範證據長什麼樣。

引擎的設定會變。 未分組欄位在 MySQL 上收不收由 sql_mode 決定,而那個變數可以在連線層設定——同一個資料庫、同一段查詢,換一個連線設定就換一邊。這一類的差異不在查詢裡也不在 schema 裡,所以讀程式碼看不到它。

測試環境與正式環境要用同一家引擎。 測試跑 SQLite、正式跑 PostgreSQL 是最常見的一種不同家的配對,而靜默而輸出不同、靜默且輸出相同這兩級差異在這種配對下會延到正式環境才第一次浮現——那時它們看起來像資料問題。

跨引擎的一致之外:文字與引擎行為的一致

本篇談的一致是跨引擎的:同一段文字送進另一組組態會做什麼。同一段文字還有另一個讀者——下一個讀它的人,他會照著文字說的去理解資料,而文字說的與引擎做的可以不一樣。文字與引擎的一致在 SQL.20 關鍵字的宣告與引擎的行為:宣告落空的形態與查證,它處理文字說的與引擎做的分岔時,下一個讀者會被帶到哪裡。