排序规则 (Collation)

字符串比较与排序的规则。为不同语言和文化圈定义各自的排序顺序。

排序规则 (collation) 是比较和排序字符串时使用的一整套规则。同一个字符在不同语言和文化圈中的排列位置并不一样,因此面向国际化的系统必须正确设置排序规则。例如德语里"ö"的位置几乎与"o"相同,而瑞典语把"å""ä""ö"当作独立的字母,排在"z"之后。

数据库可以按表或按列指定排序规则。MySQL 的 utf8mb4_unicode_ci 进行不区分大小写的比较,utf8mb4_bin 进行二进制比较。排序规则选错会造成检索遗漏或排序异常。例如在 utf8mb4_bin 下"A"和"a"是两个不同的字符,用户名检索就可能因为大小写不同而查不到。名称中的 ci 表示不区分大小写,ai 表示不区分变音符号,cs 和 as 则分别表示区分这两者。不过同样带 ci,内部规则也并不相同。utf8mb4_0900_ai_ci 依据 UCA (Unicode 排序算法) 9.0.0 的权重,比依据 UCA 4.0.0 的 utf8mb4_unicode_ci 更新。两者对尾部空格的处理也不同,较新的一方采用 NO PAD,把"a"和"a "判定为不同的值。把既有表的排序规则迁移过去,原本被视为相同的行会变成两个不同的值,唯一约束和检索结果都可能随之改变,这一点需要留意。

日语的排序尤其复杂。平假名与片假名的等同 ("あ"与"ア")、浊音与半浊音的处理 ("は""ば""ぱ"的先后)、汉字如何排列,这些规则相互交织。Unicode 的 CLDR (Common Locale Data Repository) 按区域设置整理了这些规则,各种编程语言都能通过 ICU 库使用。假名种类的差异归第 4 级权重判定,因此在默认设置下平假名和片假名会被排在同一位置。MySQL 面向日语的排序规则也是如此,utf8mb4_ja_0900_as_cs 把片假名和平假名视为等同,想要区分就得选用启用第 4 级权重的 utf8mb4_ja_0900_as_cs_ks。更麻烦的是汉字。即使想让姓名或书名按读音排列,也无法从汉字本身唯一确定读音 ("山崎"既读作"やまざき"也读作"やまさき")。按汉字排列时"山崎"排在"川口"之前,按读音的假名排列则"川口"在前。如果需求就是按读音排序,稳妥的做法是把假名读音作为单独的字段保存,再用这一列排序。

JavaScript 可以用 Intl.Collator 按区域设置比较字符串。这里有个例子值得记住,new Intl.Collator('ja').compare('あ', 'ア') 的结果是 0 (相等)。如前所述,假名的差异由最低一级权重处理,所以有些实现即使提高强度设置也不会区分二者。若要把平假名和片假名区别对待,就得在排序规则之外处理,例如比较之前先统一归一化为其中一种。单纯使用 String.localeCompare() 也能做同样的比较,但要排序大量数据时,创建一个 Intl.Collator 实例反复使用性能更好。传入 { numeric: true } 会变成数值顺序的比较,"file2"排在"file10"之前,对含有连续编号的名称排序很有用。

从字符计数的角度看,排序规则会影响字符的等价性。"は"和"ば"是否视为同一个字符、全角数字"1"和半角数字"1"是否算作相同,都随排序规则的设置而变。JavaScript 的 Intl.Collator 在日语区域设置下判定"1"与"1"相等,而 MySQL 的 utf8mb4_bin 把它们当作不同字符。同一份数据在应用侧和数据库侧算出的重复条数会对不上,原因就在这里。在统计或去重之前先确定要忽略哪些差异,并把决定忽略的差异在保存时归一化,后续的计数方式就不会摇摆。

分享这篇文章