文本排序是查询契约的一部分。按字节顺序排列的列表,可能不同于按语言习惯排列的列表;忽略大小写的比较,也可能改变唯一约束眼中的“重复名字”。PostgreSQL 不仅有数据库默认规则,还允许在列和表达式上指定排序规则。涉及业务标识、去重或翻页时,应把这些语义写清楚,而不是等不同环境返回不同结果后再猜原因。

坏掉的样子

选了一个语言区域,并不自动意味着不同字节的字符串会相等。确定性排序规则在语言比较相同时,还会使用字节来区分;非确定性规则则允许提供方把不同字节视为等价。对于 ICU,使用 und-u-ks-level2 并设置 deterministic=false,会忽略大小写但仍然区分重音。把这个组合描述为“大小写和重音都不区分”,会直接误导唯一性设计。

比较规则改变后,原先不同的值可能发生碰撞。因此,以新规则创建唯一索引,可能因为已有数据而失败。如果多个名字在新规则下相等,排序还需要明确的第二排序键。翻页时只按不区分大小写的名字排序,不能定义这些等价行之间谁在前谁在后。这里影响的是模式与查询行为,不只是页面上显示哪种字母风格。

快速验证

准备支持 ICU 的 PostgreSQL 18、一个 UTF-8 编码的可丢弃数据库,以及全新的 psql -X -v ON_ERROR_STOP=1 会话。创建排序规则需要对应数据库权限。如果服务器没有 ICU,初始化应明确失败,不要偷偷换成别的区域规则后继续把结果当作同一个实验。先创建忽略大小写的规则,再插入五个名字:

CREATE SCHEMA collation_demo;
CREATE COLLATION collation_demo.ci
  (provider=icu,locale='und-u-ks-level2',deterministic=false);
CREATE TABLE collation_demo.names (id integer PRIMARY KEY,name text COLLATE "C");
INSERT INTO collation_demo.names VALUES (1,'a'),(2,'A'),(3,'á'),(4,'b'),(5,'B');
SELECT 'a' COLLATE "C" = 'A' AS c_equal,
       'a' COLLATE collation_demo.ci = 'A' AS case_equal,
       'a' COLLATE collation_demo.ci = 'á' AS accent_equal;

三个比较依次返回假、真、假。字节规则区分大小写;新的语言规则把小写与大写 A 视为相等,但仍区分没有重音和带重音的字母。非确定性开关决定是否使用字节打破比较平局,ICU 的区域与强度决定哪些语言差异会参与比较。删除这个开关,或者改变强度,都已经是在测试另一份契约。

接着比较两种排序,并执行一次忽略大小写的等值查询:

SELECT string_agg(name,',' ORDER BY name COLLATE "C",id) AS byte_order,
       string_agg(name,',' ORDER BY name COLLATE collation_demo.ci,id) AS ci_order
FROM collation_demo.names;
SELECT id,name FROM collation_demo.names
WHERE name COLLATE collation_demo.ci='A' ORDER BY id;

字节顺序为 A,B,a,b,á,新的排序结果为 a,A,á,b,B。编号在名字相等时决定先后,因此实验结果不依赖执行计划碰巧先返回哪一行。查询大写 A 时,返回编号一和二。数据库没有把原文本改写成小写,保存的仍然是两个不同的字符串;变化的是比较时如何判定它们匹配。

在这些等价名字仍然并存时,尝试建立采用新规则的唯一索引:

\set ON_ERROR_STOP off
CREATE UNIQUE INDEX unique_ci ON collation_demo.names(name COLLATE collation_demo.ci);
\echo existing_duplicates :SQLSTATE
\set ON_ERROR_STOP on

两段错误实验暂时关闭了自动停止,必须分别检查输出的 SQLSTATE;其他错误码或 00000 都表示实验失败,即使后续命令还能执行。此处预期出现 23505,因为现有数据不满足准备施加的唯一规则,索引不能成功建立。这个结果是迁移前需要处理的数据证据,不是稍后重试就会自然消失的瞬态错误。在可丢弃实验里,明确删除两条大写重复记录,再创建索引,并尝试插入另一个冲突名字:

DELETE FROM collation_demo.names WHERE id IN (2,5);
CREATE UNIQUE INDEX unique_ci ON collation_demo.names(name COLLATE collation_demo.ci);
\set ON_ERROR_STOP off
INSERT INTO collation_demo.names VALUES (6,'A');
\echo duplicate_case :SQLSTATE
\set ON_ERROR_STOP on
SELECT id,name FROM collation_demo.names ORDER BY id;

插入同样返回唯一约束错误。剩下编号一、三、四,对应普通小写字母、带重音字母和小写 b;它们在本次保留重音差别的规则下可以共存。真实迁移必须有适合业务的冲突合并或改名规则,不能把实验中的删除动作照搬为生产数据清理方案。最后核对完整保留值,并删除实验模式:

DO $$ BEGIN
  IF (SELECT array_agg(name ORDER BY id) FROM collation_demo.names)
     IS DISTINCT FROM ARRAY['a','á','b'] THEN
    RAISE EXCEPTION 'collation uniqueness result mismatch';
  END IF;
END $$;
DROP SCHEMA collation_demo CASCADE;

让查询与索引使用同一套语义

索引按照自己定义的排序规则组织键。查询显式选择另一种比较规则时,不能直接假设原索引顺序仍能满足排序要求。需要索引查找或有序访问的路径,应保持表达式语义一致,再用实际计划验证。在展示名字后面加唯一编号,可以明确等价名字之间的输出顺序,却不会改变唯一索引对这些名字是否相等的判断。

数据库默认规则在创建时选定,但它不是唯一可用的比较方式。表达式可以使用 COLLATE,列也可以声明自己的规则,因此不能宣称每次改变某个查询的排序都必须重建整个数据库。另一方面,提供方的库版本升级,可能确实改变已建立索引所依赖的排序行为,这类变化也不能只当成配置文字更新。

升级时应比较记录中的规则版本与当前实际版本,识别需要重建的对象,完成重建后再刷新版本记录。仅刷新元数据,不会自动修复已有索引次序。把版本、比较规则、重复值政策和稳定翻页顺序一起审阅,才能避免“看起来只是换了语言环境”,实际上却改变查找结果或数据约束的迁移问题。

来源