PostgreSQL 排序规则:文本比较、索引与升级风险

本页目录

操作系统升级后,PostgreSQL 有时会出现这样的告警:

TEXT
WARNING: database "app" has a collation version mismatch

数据库还能连接,表里的数据也都在。提示中又给出了 REFRESH COLLATION VERSION,看起来执行一下就能处理完。

但这里需要检查的是已经落盘的索引。文本索引按照某套规则排列;如果运行环境换了一套比较规则,旧索引的顺序就可能与当前查询的判断不一致。刷新版本号会让告警消失,却不会重新排列索引中的数据。

Andreas Karlsson 的这份材料从不同语言的排序习惯讲起,最后讨论到升级风险。沿着这条线看,Collation 就不只是建库时顺手选择的一个 locale:它参与 SQL 的比较,也影响数据库如何组织和查找数据。

同一组名字,为什么会排出不同顺序

材料中用了几个欧洲城市的名字。换一种语言习惯,Å、Ä、Ö,甚至开头的 Aa,位置都会发生变化。

下面在测试库中建立三种 ICU 排序规则。后续示例使用 PostgreSQL 18、UTF-8 编码,并要求 PostgreSQL 编译时启用 ICU 支持。

SQL
CREATE SCHEMA collation_demo;
CREATE COLLATION collation_demo.de (provider = icu, locale = 'de-DE');
CREATE COLLATION collation_demo.sv (provider = icu, locale = 'sv-SE');
CREATE COLLATION collation_demo.da (provider = icu, locale = 'da-DK');

CREATE TABLE collation_demo.city (name text);
INSERT INTO collation_demo.city VALUES
    ('Aarhus'), ('Essen'), ('Oslo'), ('Valencia'),
    ('Ängelholm'), ('Åre'), ('Öhringen'), ('Östersund');

SELECT name FROM collation_demo.city ORDER BY name COLLATE collation_demo.sv;

将最后一条 SQL 的排序规则分别换成下面几种,得到的顺序是:

排序规则 从前到后的城市名称
pg_c_utf8 Aarhus、Essen、Oslo、Valencia、Ängelholm、Åre、Öhringen、Östersund
德语 collation_demo.de Aarhus、Ängelholm、Åre、Essen、Öhringen、Oslo、Östersund、Valencia
瑞典语 collation_demo.sv Aarhus、Essen、Oslo、Valencia、Åre、Ängelholm、Öhringen、Östersund
丹麦语 collation_demo.da Essen、Oslo、Valencia、Ängelholm、Öhringen、Östersund、Åre、Aarhus

这是原材料里的例子,本次在 PostgreSQL 18.4 中也得到了相同结果。德语排序把这些带符号的字母放在相近的基本字母附近;瑞典语把 Å、Ä、Ö 放在字母表末尾;丹麦语中的 Aa 又有自己的排序习惯。

pg_c_utf8 则按 Unicode 码点排序,不尝试模仿任何一种语言的字典。

这也说明了编码与排序规则的区别。UTF-8 决定字符怎样表示成字节;Collation 决定比较这些字符串时采用什么规则。上面所有数据都使用 UTF-8,排序结果仍然可以不同。

排序规则跟着表达式走

数据库有默认排序规则,列可以单独指定,查询里的表达式也可以使用 COLLATE。

SQL
CREATE TABLE collation_demo.contact (
    name text COLLATE collation_demo.de
);

SELECT name FROM collation_demo.contact ORDER BY name;
SELECT name FROM collation_demo.contact
ORDER BY name COLLATE collation_demo.sv;

第一条查询使用列上的德语规则,第二条明确使用瑞典语规则。显式写出的 COLLATE 会覆盖来自列的隐式规则。

实际应用里比较容易踩到的问题,是两个列分别带着不同的排序规则。把它们拼接起来可能成功,再对拼接结果排序却报错:拼接本身只负责连接字符串,排序必须选定比较规则。如果 PostgreSQL 无法从输入中确定该用哪一种,就需要在表达式上明确指定。

可以用 pg_collation_for(name) 检查表达式继承的排序规则。跨表查询出现 collation 冲突时,应当先确认业务需要的比较语义,再决定在哪个表达式上添加 COLLATE。

不区分大小写,会影响什么

有些系统希望 Alice 和 alice 被当成同一个用户名。只改变字母在排序结果中的位置,并不能实现这个要求。

PostgreSQL 默认创建的是确定性排序规则,即 deterministic = true。即使 ICU 认为两个字符串处于同一比较等级,PostgreSQL 仍会用字节比较分出差别。因此,要让不同字节的字符串真正比较为相等,需要同时选择合适的 ICU 规则,并关闭这一步字节决胜。

SQL
CREATE COLLATION collation_demo.ignore_case (
    provider = icu,
    locale = 'und-u-ks-level2',
    deterministic = false
);

SELECT 'Alice' = 'alice' COLLATE collation_demo.ignore_case AS same_case,
       'e' = 'é' COLLATE collation_demo.ignore_case AS same_accent;

结果分别是 true 和 false。这里的 und 使用 ICU 的根规则,ks-level2 保留重音差别,但不区分大小写。如果改成 ks-level1,重音差别也会被忽略。

“非确定性”这个名字容易让人误以为每次结果不一样。它在这里指的是允许不同字节表示比较为相等,规则本身仍然有明确含义。

这个选择会进入约束判断:

SQL
CREATE TABLE collation_demo.account (
    name text COLLATE collation_demo.ignore_case UNIQUE
);
INSERT INTO collation_demo.account VALUES ('Alice');

再插入 alice,会触发唯一约束冲突。本次实验同时验证了 DISTINCT 和 GROUP BY:在这套规则下,Alice、alice、ALICE 会被归为同一组。

因此,是否忽略大小写或重音,需要由字段含义决定。人名检索可以接受较宽松的匹配,账号标识、外部系统传来的编码则可能需要严格区分。不能为了搜索方便,就把整个库的比较规则都改掉。

非确定性 Collation 也有使用限制。PostgreSQL 18 已经支持在这类规则下执行 LIKE,但 ILIKE、SIMILAR TO 和 POSIX 正则表达式仍不支持;旧版本还需要另外核对 LIKE 的支持情况。使用非确定性 Collation 的 B-tree 索引也不能进行 deduplication,索引空间和查询成本需要结合实际数据评估。

ICU 的另一项常用能力是按数值比较字符串里的数字。比如 file2 和 file10,普通字节排序会先放 file10;使用 locale = 'und-u-kn-true',则会把 file2 放在前面。这类规则适合产品列表、文件名展示,但仍属于字符串比较,并没有把字段转换成数值类型。

查询改了 Collation,索引未必还能提供顺序

在表达式上加 COLLATE 很方便,不过索引是在固定规则下建立的。

SQL
CREATE TABLE collation_demo.item (name text COLLATE "C");
INSERT INTO collation_demo.item
SELECT md5(i::text) FROM generate_series(1, 10000) AS i;
CREATE INDEX item_name_idx ON collation_demo.item (name);
ANALYZE collation_demo.item;

EXPLAIN (COSTS OFF)
SELECT name FROM collation_demo.item ORDER BY name LIMIT 20;

EXPLAIN (COSTS OFF)
SELECT name FROM collation_demo.item
ORDER BY name COLLATE collation_demo.de LIMIT 20;

在本次实验中,第一条查询通过 Index Only Scan 按索引顺序取数,没有额外排序。第二条查询出现了 Sort:索引按 C 排列,无法直接提供德语 Collation 要求的顺序。

即使这些测试数据碰巧没有体现出两种规则的排序差异,规划器也不能把两个 Collation 当成同一个。它必须依据索引定义判断能否满足查询,不能依赖当前数据碰巧符合某种顺序。

如果业务经常使用另一种排序规则,可以评估建立匹配的索引:

SQL
CREATE INDEX item_name_de_idx
ON collation_demo.item (name COLLATE collation_demo.de);

补上这个索引后,同一条德语排序查询在实验中也去掉了 Sort。生产库是否值得增加索引,还要结合查询频率、写入成本和已有索引判断;这里验证的是排序规则的匹配关系,并非一组性能跑分。

libc、ICU 和 builtin 怎么选

Collation 的 provider 决定规则由谁提供。PostgreSQL 18 中主要有三种选择:

Provider 规则来源 需要关注的地方
libc 操作系统 C 库提供的 locale 可用名称和行为依赖平台;系统更新可能改变规则
icu ICU 库 提供语言相关排序和定制能力;需要关注 ICU 版本变化
builtin PostgreSQL 内置实现 提供 C、C.UTF-8、PG_UNICODE_FAST 等选择,不提供各语言的字典顺序

已有系统使用 libc 并不意味着必须马上迁移。更实际的工作是明确当前依赖、记录运行环境,并把排序规则检查纳入系统升级和数据库迁移流程。对于需要语言排序、大小写或重音定制的新字段,ICU 的表达能力更合适。

builtin 从 PostgreSQL 17 开始提供,pg_c_utf8 对应其 C.UTF-8 locale。PostgreSQL 18 又加入了 pg_unicode_fast,对应 PG_UNICODE_FAST。两者都面向 UTF-8、按码点排序,但字符处理能力有差别:

SQL
SELECT upper('ß' COLLATE pg_c_utf8) AS simple_mapping,
       upper('ß' COLLATE pg_unicode_fast) AS full_mapping;

在 PostgreSQL 18.4 中,结果是 ß 和 SS。前者使用简单大小写映射,在这个例子中保留原字符;后者使用完整映射,可以把一个字符转换成多个字符。传统 C / POSIX 则按字节排序,大小写转换主要面向 ASCII。

对机器编码、标识符这类不需要自然语言排序的数据,稳定的字节序或码点序通常更容易管理。对人名、地址、商品名称,应先确认用户期望的排序与相等规则。

Andreas 在材料中倾向于用 pg_c_utf8 作为默认选择,把语言相关需求交给 ICU。这个思路适合新系统设计时讨论,但不能直接套用到已有业务。尤其不要把 builtin 理解成“今后所有相关对象都不需要检查”:码点排序本身稳定,Unicode 大小写映射和字符属性仍可能随 PostgreSQL 主版本演进。依赖这些函数的表达式索引,也需要在升级时关注对应变化。

版本告警背后,是旧索引与新规则的冲突

B-tree 能快速查找,是因为它假定索引中的键按当前比较规则有序。规则变化后,这个前提可能不再成立:索引扫描可能漏掉符合条件的记录,排序或唯一性判断也可能出问题。数据页的字节没有损坏,页校验和正常,也不能证明这种逻辑关系仍然正确。

常见触发场景包括操作系统更新、替换容器基础镜像、迁移到使用另一套库版本的机器,以及 pg_upgrade 后的新 PostgreSQL 二进制链接了不同版本的 ICU。物理复制两端也需要保持相容的排序环境,不能只看 PostgreSQL 主版本是否一致。

检查依赖时需要区分 provider:libc 规则主要关注操作系统 locale 实现;ICU 规则关注 ICU 的版本和数据。即使 PostgreSQL 的版本号完全没变,替换这些依赖库仍然可能影响文本比较。

排查时,可以先查看数据库默认规则记录的版本与当前版本:

SQL
SELECT datname, datlocprovider,
       datcollversion AS recorded_version,
       pg_database_collation_actual_version(oid) AS actual_version
FROM pg_database
WHERE datallowconn;

再在相关数据库中检查具名 Collation:

SQL
SELECT c.oid::regcollation AS collation,
       c.collprovider,
       c.collversion AS recorded_version,
       pg_collation_actual_version(c.oid) AS actual_version
FROM pg_collation AS c
WHERE c.collversion IS DISTINCT FROM pg_collation_actual_version(c.oid)
ORDER BY 1;

版本差异是需要调查的信号,不能据此断言每个文本索引都已经出现错误。反过来,没有差异也不是完整的安全证明:某些 provider 不提供版本信息;glibc 版本只是排序数据版本的近似标记,发行版还可能回移补丁。查询中的 NULL 需要结合 provider 理解,不能直接读成“检查通过”。

处理顺序应当是:确定最终使用的运行环境,重建受影响对象并验证,最后刷新版本记录。

对受影响索引,可以安排普通 REINDEX 的维护窗口,或评估 REINDEX CONCURRENTLY。后者能减少对写入的阻塞,但仍有锁等待、额外扫描和空间开销;重建唯一索引时,若新规则让原本不同的值比较为相等,还需要先处理冲突数据。具体限制可结合站内的索引维护继续阅读。

也不要只查一遍 pg_depend 就认为对象已经找全。数据库默认规则的使用、表达式里的字符处理,以及依赖比较结果的分区边界,都需要结合实际定义检查。REINDEX 能重建索引,无法代替这些对象各自的核对和修复。

完成上述工作后,才针对对应对象执行 ALTER COLLATION ... REFRESH VERSION;数据库默认规则则使用 ALTER DATABASE ... REFRESH COLLATION VERSION。这两条命令只更新目录中的版本记录,既不会重建对象,也不会检查修复是否完整。

对于新系统,排序规则应当和字段定义一起确定;对于已有系统,则要把它当成升级依赖来管理。需要语言排序的地方明确使用相应规则,不需要的地方保持简单。这样既能解释查询为什么按这个顺序返回,也能在环境变化前知道哪些对象需要检查。

参考资料

讨论与补充

在 GitHub 查看讨论

支持作者

感谢支持 PostgreSQL Howto 的长期维护。

微信赞赏码

读者交流群

二维码若已过期,可通过公众号 PostgreSQL 学徒或邮件联系。

PostgreSQL 学徒读者交流群二维码