SQL.15 字串比較與 collation:相等、排序、LIKE 與索引可用性
兩個字串相不相等,答案由一條叫 collation 的規則給出——前提是兩邊都是字串型別。型別先發言:PostgreSQL 18 上 'a '::char(2) = 'a' 為真而 'a '::text = 'a'::text 為假,同一條 collation,差別在 char(n) 的等號自己會剝掉尾隨空白。下文談的都是兩邊都是變長字串的情形。collation 是掛在資料庫、表或欄位上的一份比較規則,它規定兩個字串怎麼判相等、以及誰排在前面——大小寫算不算差異、重音符號算不算差異、字元的先後怎麼定。這條規則不在查詢的文字裡,所以同一段 WHERE 姓名 = 'anna' 在不同的地方會回不同的列。
大小寫這件事在 SQL 裡落在兩個彼此獨立的層。SQL.14 識別字的規則:各家的大小寫摺疊、引號與保留字 處理的是名字送進引擎會被怎麼摺疊,這一篇處理的是值被拿去比較時套的是哪一把尺。兩層的規則互不影響:PostgreSQL 把沒加引號的表名摺成小寫,卻不會把 'Anna' 這個值摺成小寫。
本篇把同一條規則作用的四個表面走完:什麼算相等、LIKE 為什麼會與等號分岔、誰排在前面、以及索引走不走得了。四者收在同一個處置上——規則要寫出來,而寫在資料庫、表或欄位哪一層決定它涵蓋到哪裡。
本篇的顧客表有五列:Anna、anna、ANNA、Ánna、佳穎。
等值比較在各家引擎的預設 collation 下命中的列
每一家的預設規則對大小寫與重音的規定不同,所以同一個等值條件在三家上命中的列不是同一批:
1WHERE 姓名 = 'anna'
2
3SQLite 3.51 anna
4PostgreSQL 18 anna
5MySQL 8.4 Anna, anna, ANNA, ÁnnaMySQL 的預設 collation 是 utf8mb4_0900_ai_ci,名字裡的 ai 與 ci 就是它的規定:accent-insensitive 與 case-insensitive——重音與大小寫都不算差異,所以 Ánna 與 anna 在這條規則底下是同一個值。SQLite 與 PostgreSQL 的預設把這兩者都算差異,於是只有逐字相同的那一列命中。
這三個答案都正確,因為「相等」的定義本來就由那條規則給,而查詢的文字沒有選過它。一段在 MySQL 上寫好的登入查詢搬到 PostgreSQL,比對帳號的那一段會突然變成大小寫敏感,而它照樣執行、照樣回一批形狀正確的列。
SQLite 的 LIKE 與等號各自套用的比較規則
LIKE 是另一個做字串比較的運算子,而它套的規則未必與 = 相同:
1 SQLite 3.51 PostgreSQL 18 MySQL 8.4
2= 'anna' anna anna Anna, anna, ANNA, Ánna
3LIKE 'anna' Anna, anna, ANNA anna Anna, anna, ANNA, ÁnnaPostgreSQL 與 MySQL 的兩列一致,SQLite 的兩列分岔:它的 = 逐位元組比較,而它的 LIKE 預設不分大小寫。所以在 SQLite 上把 = 換成 LIKE(例如為了加一個 %)會順帶換掉比較規則,而這件事沒有任何提示。
SQLite 這個不分大小寫的範圍限定在 ASCII 字母:LIKE 'ánna' 在同一批資料上回零列,Ánna 沒有被當成同一個字串。所以那條規則的完整敘述是「A 到 Z 這二十六個字母摺疊,其餘逐位元組」,而不是「不分大小寫」。
要把它調成一致,PRAGMA case_sensitive_like = ON 讓 LIKE 跟著 = 走;反向的做法是在條件上寫 COLLATE NOCASE,讓 = 跟著 LIKE 走。選哪一邊都行,而選了要寫出來——沒寫出來的時候讀查詢的人得先知道它跑在哪一家引擎上,才知道這一句在比什麼。
collation 規定的排序先後
collation 除了規定兩個字串相不相等,也規定誰排在前面。同一批五個名字,三家排出三種順序:
1ORDER BY 姓名
2
3SQLite 3.51 ANNA, Anna, anna, Ánna, 佳穎
4PostgreSQL 18 anna, Anna, ANNA, Ánna, 佳穎
5MySQL 8.4 Anna, anna, ANNA, Ánna, 佳穎SQLite 那一列是位元組的先後——大寫字母的碼位小於小寫,所以 ANNA 整批排在前面。PostgreSQL 這一列取決於資料庫建立時選的 collation,量測用的這個是 en_US.utf8(查法是 SELECT datcollate FROM pg_database),它照人類語言的習慣把大小寫視為同一個字母的變體、再用大小寫決定同分時的先後。同一段排序在 PostgreSQL 上改用 C 這條逐位元組比較的規則,順序就與 SQLite 一致:
1-- PostgreSQL 18,資料庫的 collation 是 en_US.utf8
2SELECT 姓名 FROM 顧客 ORDER BY 姓名;
3-- anna, Anna, ANNA, Ánna, 佳穎
4SELECT 姓名 FROM 顧客 ORDER BY 姓名 COLLATE "C";
5-- ANNA, Anna, anna, Ánna, 佳穎(與 SQLite 的預設順序相同)同一家引擎、同一批資料,換一條規則就換一種順序,而 SQLite 的預設順序就是位元組序這件事也因此驗得出來。
MySQL 那一列要換個方式讀。在 ai_ci 底下四個 A 開頭的名字彼此相等,所以它們之間沒有先後可言——那一列印的是它們進表的順序。同一個排序鍵改由 GROUP_CONCAT 取回,四個名字的順序整個倒過來:
1-- MySQL 8.4,同一張表、同一批資料
2SELECT 姓名 FROM 顧客 ORDER BY 姓名;
3-- Anna, anna, ANNA, Ánna, 佳穎(進表的順序)
4SELECT GROUP_CONCAT(姓名 ORDER BY 姓名) FROM 顧客;
5-- Ánna,ANNA,anna,Anna,佳穎(四個 A 開頭的名字倒過來,佳穎仍在最後)兩次都沒有違反 collation,因為 collation 對這四個之間什麼都沒有規定。這是 collation 與分頁交會的地方:一條把大小寫與重音都忽略的規則,會讓原本以為唯一的排序鍵變成不唯一,而沒有規定的那一段在分頁時會變成同一列出現兩次、另一列一次都不出現——SQL.12 分頁的排序鍵與接續標記 寫那個機制與把排序鍵補到兩兩可分的判斷標準。
索引的比較規則與條件的比較規則
索引把值按某一條規則排好,所以它只服務照同一條規則發問的條件。索引與條件的規則對不上,在小表上感覺不出來——資料長到某個量之後前綴搜尋開始變慢,而查詢與索引都沒有動過。這一節的計畫也要在夠大的表上才看得出來——五列的表無論條件寫成什麼,引擎都直接掃完,所以下面兩組輸出量在二十萬列的同結構表上。SQLite 上的三種問法,索引是同一個:
1CREATE INDEX ixn ON 顧客(姓名); -- 預設規則,逐位元組
2
3WHERE 姓名 = 'anna' SEARCH ... (姓名=?) 查找
4WHERE lower(姓名) = 'anna' SCAN 整段掃過
5WHERE 姓名 LIKE 'ann%' SCAN 整段掃過第二行是欄位被函式包住,索引上排好的是原值而條件問的是摺過的值,兩者對不起來(Sargable)。第三行的成因不同:前綴比對本來可以翻成索引上的一段範圍,而 LIKE 預設不分大小寫,索引卻是逐位元組排的——規則對不上,範圍就算不出來。條件的規則與索引的規則,換掉任何一個都讓計畫回到範圍查找,而兩種換法回的列不同——改條件的規則也改掉了 LIKE 的答案:
1-- 改條件的規則:LIKE 改成分大小寫,回到位元組規則
2PRAGMA case_sensitive_like = ON;
3EXPLAIN QUERY PLAN SELECT * FROM 顧客 WHERE 姓名 LIKE 'ann%';
4-- SEARCH ... (姓名>? AND 姓名<?)
5-- 本篇五列的顧客表上回 anna
6
7-- 改索引的規則:LIKE 維持預設的不分大小寫,另建一個按摺疊規則排的索引;SQLite 的索引一定要取名字
8PRAGMA case_sensitive_like = OFF;
9CREATE INDEX ixn_nocase ON 顧客(姓名 COLLATE NOCASE);
10EXPLAIN QUERY PLAN SELECT * FROM 顧客 WHERE 姓名 LIKE 'ann%';
11-- SEARCH ... (姓名>? AND 姓名<?)
12-- 本篇五列的顧客表上回 Anna, anna, ANNAPostgreSQL 上,索引與條件套的規則對不上,同樣會讓前綴搜尋用不到索引,只是外觀不同:
1WHERE 姓名 = 'anna' Index Cond: (姓名 = 'anna') 查找
2WHERE lower(姓名) = 'anna' Filter: (lower(姓名) = 'anna') 逐列過濾
3WHERE 姓名 LIKE 'ann%' Filter: (姓名 ~~ 'ann%') 逐列過濾en_US.utf8 的排序規則與位元組序不同,所以 ann 這個前綴在索引上不對應一段連續的區間。PostgreSQL 的解法是另建一個按位元組排的索引;lower(姓名) 那一行則由建在運算式上的索引接住:
1-- 前綴搜尋:text_pattern_ops 讓這個索引逐字元比較,不照資料庫的 en_US.utf8
2CREATE INDEX ON 顧客(姓名 text_pattern_ops);
3EXPLAIN SELECT * FROM 顧客 WHERE 姓名 LIKE 'ann%';
4-- Index Cond: (姓名 ~>=~ 'ann' AND 姓名 ~<~ 'ano'):前綴翻成一段範圍
5
6-- 欄位被函式包住:索引直接建在 lower(姓名) 上
7CREATE INDEX ON 顧客(lower(姓名));
8EXPLAIN SELECT * FROM 顧客 WHERE lower(姓名) = 'anna';
9-- Index Cond: (lower(姓名) = 'anna')三種修法的共同形狀是讓索引與條件套同一條規則,而它們把改動放在不同的一邊:改條件、改索引的 collation、或另建一個按別的規則排的索引。比較規則對得上只是索引派得上用場的條件之一:條件的形狀是另一個(lower(姓名) 那一行就是形狀出的問題),而形狀與規則都對了之後,引擎還要看這個條件留下多少列才決定值不值得走索引,留下多少列怎麼估計在 Cardinality 與 Selectivity。
collation 宣告在資料庫、表、欄位或條件上各自的涵蓋範圍
預設值在單一環境底下是隱形的——查詢跑得對,沒有人會去問「相等」現在是怎麼定義的。它在三個時刻現形:換一家引擎、換一個資料庫(同一家引擎的不同資料庫可以有不同的 collation)、以及有人在一欄上單獨指定了規則而其餘欄位沒有。要同時支援好幾家引擎的時候,collation 只是要逐項盤點的預設差異之一,識別字的摺疊是另一項,換引擎才浮現的形態在 SQL.14 識別字的規則:各家的大小寫摺疊、引號與保留字 的「SQLite 與 DuckDB 對識別字大小寫的處理」一節;各家預設差異的完整並置與分級在 SQL.19 引擎寬鬆度與可攜性:各家對同一組寫法的差異、分級與處理時機。
所以規則的住址要選定,而可選的住址比多數人用到的多。開頭那一句說 collation 掛在資料庫、表或欄位上——那是三層,越外層涵蓋越廣而越容易被裡層蓋掉:建庫時選的那一條是所有沒有另外指定的欄位的預設,建表時選的蓋過它,欄位上寫的又蓋過表。來歷不明的建表模板最常把規則留在表層或庫層,所以查一欄的實際規則要從欄位往外找,找到第一個明寫的為止。
寫在欄位上是最貼近資料的一種:姓名 VARCHAR(50) COLLATE utf8mb4_0900_as_cs(as_cs 是 accent-sensitive、case-sensitive)把這一欄固定住,往後每一段查詢、每一個索引都套它。寫在條件上(WHERE 姓名 = 'anna' COLLATE NOCASE)只涵蓋那一句。還有一個住址不在 collation 這條路上:寫入的時候就把值正規化,另存一欄摺過的版本,讀的時候比那一欄。開頭提過的登入查詢——帳號比對在 MySQL 上不分大小寫、搬到 PostgreSQL 突然變成分大小寫——實際的解法多半是這一種:比較規則變成一次寫入時的決定,而讀的那一句與它的索引都回到最單純的等值比較。同一個登入功能裡兩種住址各有位置:帳號欄整欄要不分大小寫,那是欄位的事;而某支後台工具要逐字比對,那是那一句的事。代價是寫在條件上的那一句走不了按預設規則建的索引。
判斷標準與約束那一條同源:一個保證要涵蓋往後的每一次使用,它就得寫在結構上,而不是靠每一句查詢各自記得。差別在於約束擋的是寫入,collation 決定的是讀出來的時候什麼算相等。