什么是排序规则,为什么我的数据损坏了?

11 阅读16分钟

什么是排序规则,为什么我的数据损坏了?

摘要: 本文探讨了 GNU C 库(glibc)2.28 版本在 2018 年升级时如何通过更改排序规则导致全球 PostgreSQL 数据库静默损坏,并解释了 PostgreSQL 社区为防止此类事件再次发生而开发的解决方案。

原文链接


GNU C 库(glibc)2.28 版本于 2018 年 8 月 1 日发布,自此 PostgreSQL 发生了变化。它包含许多更改,其中最重要的是对区域排序规则数据的大规模更新,使其与 ISO 14651 标准 2016 版第 4 次修订版和 Unicode 9.0.0 保持一致。这并不是一次细微的调整。这是大约 18 年累积区域修改的结晶,全部在一次发布中合并。

没人会为此举办庆祝会。

随后发生了 PostgreSQL 历史上最严重、最隐秘的数据完整性问题之一。索引静默损坏,查询结果意外更改,唯一约束不再可信。最糟糕的是?你必须知道要去检查它。PostgreSQL 没有报错。操作系统也没有报错。一切看起来都很正常,直到不再正常。

这就是一个库升级如何 quietly 损坏全球数据库的故事、PostgreSQL 社区采取了什么措施,以及如何确保它不会再次发生在你身上。

到底什么是排序规则?

在我们理解出了什么问题之前,我们需要理解排序规则实际上做什么。本质上,排序规则定义了文本的比较和排序方式。这听起来很简单,但在英语字母之外,排序规则变得更加复杂。

考虑一下德语字母 ß。它是否与"ss"排序相同?通常是的。带重音符号的字符如 é 和 è 呢?为了排序目的,它们是否应该被视为与"e"等效,还是应该有自己的独立位置?瑞典字母表中,ä 和 o 位于 z 之后,而不是被视为 a 和 o 的变体,这个问题呢?

每种语言对这些问题都有自己的答案,排序规则将这些答案编码为数据库遵循的规则集。当 PostgreSQL 需要对文本列进行排序、实施唯一约束或构建 B 树索引时,它会向排序规则询问:"这两个字符串中哪个排在前面?"排序规则的答案决定了从查询结果到索引查找是否能找到数据的任何内容。

从历史上看,PostgreSQL 将这个问题委托给了操作系统的 C 库。PostgreSQL 没有自己的美式英语排序规则实现,因此使用 en_US.UTF-8 区域创建的数据库依赖于外部库。更具体地说,PostgreSQL 过去只是简单调用 glibc(在 Linux 系统上)的 strcoll(),并信任返回的任何答案。

这种信任多年来运作良好。直到有一天不再有效。

世界改变的那一天

那么 glibc 2.28 中究竟改变了什么?考虑一个使用 en_US.UTF-8 区域的简单示例。更新之前,包含特殊字符的字符串排序如下:

a, $a, a$, A, b, $b, b$, B

在 glibc 2.28 之后,这些相同的字符串排序如下:

$a, $b, a, A, a$, b, B, b$

这不是一次轻微的调整。包含标点符号、混合大小写和特殊字符的字符串的相对位置发生了显著变化。另一个有据可查的例子:'a-a''a+a' 的排序顺序在新旧版本之间简单地交换了。包含连字符、下划线或货币符号的字符串现在经历了看似不一致的排序规则。

这并不是 glibc 中的错误。glibc 开发人员正在纠正多年来与 Unicode 标准的累积偏差。新的排序规则可以说更正确。但对于建立在错误假设之上的索引来说,正确性是一种冷冰冰的安慰。

静默灾难的解剖

为什么这会造成如此多的麻烦?让我们看看 PostgreSQL B 树索引如何处理文本。当 PostgreSQL 在文本列上构建索引时,它根据活动的排序规则对值进行排序,并按该顺序存储它们。PostgreSQL 根据这些结果构建实际的磁盘树结构。之后,PostgreSQL 通过将搜索词与存储的键进行比较来遍历 B 树,根据哪个字符串"排在前面"(按照排序规则)向左或向右走。

想象一下底层的库改变了它对哪个字符串排在前面的看法。索引的物理布局反映了旧的排序顺序,但每次新比较都使用新的排序顺序。PostgreSQL 应该向左走时向右走,应该向右走时向左走,以前有效的数据变得不可见。该行仍在表中,顺序扫描仍然可以找到它,但索引查找完全错过了它。

后果从那里级联蔓延:

  • 不可见的行。 使用索引扫描的查询可能会静默跳过现有行。当实际有 1,000 行匹配谓词时,SELECT 可能会返回 999 行。

  • 幽灵唯一性违规。 由 B 树索引支持的唯一约束可能无法检测到实际重复,因为索引遍历找不到现有条目。或者,它们可能会错误地拒绝有效条目,因为遍历到达了错误的节点。

  • 错误的查询结果。 任何使用文本列的 ORDER BY 的查询都会在升级前后产生不同的结果。合并连接(依赖于两个输入排序方式相同)可能产生静默错误的结果。

  • 复制发散。 如果主库运行一个 glibc 版本而副本运行另一个版本,针对相同数据的相同查询会产生不同的结果。2014 年 TripAdvisor 事件在流式副本中演示了这种情况,尽管当时很少有人理解其全部含义。

所有这些都是静默发生的。没有人知道(也无法知道)出了什么问题,直到为时已晚。

多米诺骨牌效应

glibc 2.28 版本并非同时影响所有 Linux 发行版。相反,它在大约一年的时间里随着每个发行版按照自己的时间表采用它而逐渐蔓延:

  • 2018 年 10 月: Fedora 29 和 Ubuntu 18.10 随 glibc 2.28 一起发布。

  • 2019 年 3 月: Debian PostgreSQL 维护者 Christoph Berg 在 debian-glibc 邮件列表 上发出警报,称情况严峻。他建议为拥有 PostgreSQL 集群的 Debian 用户提供自动警告。

  • 2019 年 5 月: RHEL 8 和 CentOS 8 随 glibc 2.28 一起发布,从 RHEL 7 的 glibc 2.17 跃升。这是一个升级周期中的 11 个版本跳跃。

  • 2019 年 7 月: Debian 10(Buster)紧随其后。

RHEL 的跳跃尤其残酷。许多企业运行在 CentOS 或 RHEL 上,从版本 7 到 8 的操作系统升级只是常见的事件。没有人想到一次常规的发行版升级会静默损坏他们的数据库索引。Arch Linux 用户总是运行在最新的软件上,是第一批牺牲品。

Daniel Verite 于 2018 年 8 月发布了"警惕你的下一次 glibc 升级",这是最早的公开警告之一。PostgreSQL Wiki 创建了专门页面来跟踪区域数据变化排序规则的演变情况。来自 Crunchy DataCitus DataCYBERTEC 的博客文章都强调了一个令人不安的真相:如果你升级了 glibc 但没有重建索引,你的数据可能已经损坏了。

查找损坏

任何受影响系统的第一步是识别有风险的索引。任何在文本、varchar、char 或 citext 列上使用区域相关排序规则(除了 CPOSIX 之外的任何东西)的 B 树索引都可能已损坏。PostgreSQL 社区确定了一个诊断查询,如下所示:

SELECT indrelid::regclass AS table_name,
       indexrelid::regclass AS index_name,
       collname AS column_name,
       pg_get_indexdef(indexrelid) AS index_definition
FROM (SELECT indexrelid, indrelid,
             indcollation[i] AS coll
        FROM pg_index,
             generate_subscripts(indcollation, 1) g(i)
     ) s
JOIN pg_collation c ON coll = c.oid
WHERE collprovider IN ('d', 'c')
  AND collname NOT IN ('C', 'POSIX');

该查询返回的每个索引都需要重建。对于 PostgreSQL 12 及更高版本,这意味着:

REINDEX INDEX CONCURRENTLY index_name;

CONCURRENTLY 选项被证明是生产系统的救命稻草,因为它允许重建发生而无需在整个重建过程中锁定表。对于那些仍在使用 PostgreSQL 11 或更早版本的人,解决方案更丑陋:并发创建替换索引,删除旧索引,然后重命名新索引。主键索引和唯一约束使这尤其痛苦。

问题的规模是惊人的。一个拥有数百个表和数千个文本索引的数据库需要重建每一个。而且这不是一次性的修复。任何未来更改排序规则数据的 glibc 升级都需要重复这一过程。

似曾相识

具有讽刺意味的是,glibc 2.28 事件甚至不是 glibc 第一次导致 PostgreSQL 索引损坏。2015 年的几个 glibc 版本附带了一个有缺陷的 strcoll() 实现,其结果与 strxfrm() 不一致,违反了 ISO C90 和 POSIX 标准。

PostgreSQL 9.5 引入了"缩写键"来加速文本索引构建,而 glibc 错误导致这些键产生损坏的索引。PostgreSQL 9.5.2 中的修复是为非 C 区域完全禁用缩写键,这是一个持续到今天的基于 libc 的排序规则的性能回归。用户当时也必须 REINDEX

三年内发生了两起重大事件,都是由同一个根本问题引起的:PostgreSQL 将关键操作委托给了一个外部库,而该库的行为既不稳定也无保证。警告信号已经出现。

远离 glibc 的漫漫征程

PostgreSQL 社区对这些事件的反应是审慎但坚定的,经过近十年的渐进式进展才显现出来。

Postgres 10(2017) 引入了对 ICU(Unicode 国际组件)作为替代排序规则提供程序的支持。Peter Eisentraut 的这项工作具有先见之明,在 glibc 2.28 发布整整一年前就完成了。首次可以创建由 ICU 而不是 libc 支持的排序规则:

CREATE COLLATION german (provider = icu, locale = 'de-DE');

ICU 维护独立于操作系统的自己的排序规则数据。ICU 通过严格的版本控制系统更新其规则,这意味着 PostgreSQL 可以检测到更改并发出警告。

Postgres 13(2020) 添加了 glibc 排序规则版本跟踪。PostgreSQL 开始在索引创建期间记录排序规则版本,并在底层版本更改时发出警告。这是第一个真正的"早期预警系统"。它无法防止损坏,但至少日志能说明全部情况。

Postgres 15(2022) 是许多人期待的事件:ICU 现在可以作为整个数据库集群的默认排序规则提供程序使用:

initdb --locale-provider=icu --icu-locale=en-US

在此之前,ICU 仅对单个排序规则对象可用,这既不方便也容易出错。

Postgres 17(2024) 交付了许多人认为是真正的解决方案:内置排序规则提供程序。这个提供程序将排序规则逻辑直接编译到 PostgreSQL 本身中,不依赖外部 glibc 或 ICU。该提供程序附带两个主要排序规则:

  • pg_c_utf8:Unicode 代码点排序,具有 POSIX 兼容的模式匹配和简单的大小写映射。

  • pg_unicode_fast:Unicode 代码点排序,具有完整的 Unicode 大小写映射和标准模式匹配行为。

两者在一个主要 PostgreSQL 版本中都被保证是不可变的。整类"操作系统更改了我的排序顺序"错误根本不可能使用这些排序规则发生。

初始化集群的正确方式(现在)

尽管有这些历史,glibc 仍然是默认的排序规则提供程序,直到 PostgreScript 18。新初始化的集群将使用 glibc 库,除非另行指定。这意味着每个新数据库都可能面临与 glibc 2.28 导致的问题相同的脆弱性,只是等待下一次主要库升级来触发它。

因此,我建议在创建新集群时始终指定内置提供程序:

initdb --locale-provider=builtin --locale=C.UTF-8

两个标志消除了整类数据损坏风险。C.UTF-8 区域提供正确的 UTF-8 字符处理,同时按 Unicode 代码点顺序排序,不会有任何意外。

对于在现有集群中创建数据库,相同的原则适用:

CREATE DATABASE mydb
  LOCALE_PROVIDER = builtin
  BUILTIN_LOCALE = 'C.UTF-8'
  TEMPLATE = template0;

在这样做时有必要将 template0 指定为模板数据库,因为通常不可能使用与源数据库不同的排序规则。

直到 PostgreSQL 项目更改默认值(关于这一点正在进行讨论),每个 DBA 都需要为每个新集群或数据库做出这个有意识的选择。

从来没有免费的午餐

如果新的内部提供的排序规则这么棒,为什么不是每个人都在使用它们?

第一个原因是很少有人停下来考虑这个话题。他们可能相信这个问题会被 future 版本处理并解决,就像魔法一样发生。没有人愿意面对数据迁移的丑陋现实。也许他们只是错过了这场风波的新用户。

另一个原因更微妙。pg_c_utf8pg_unicode_fast 都按 Unicode 代码点值排序。这本质上是 UTF-8 编码文本的字节序排序。它是确定的、快速的,对索引来说是完美的。但它不符合大多数语言的人类语言学排序预期。

考虑一下德语名称:

SELECT name FROM (VALUES
        ('Müller'), ('Muller'), ('Ötzi'), ('Ozzy'), ('Über'), ('Upper')
      ) AS t(name)
ORDER BY name COLLATE "pg_c_utf8";

 name  
--------
 Muller
 Müller
 Ozzy
 Upper
 Ötzi
 然而 native 德语使用者会期望这样:
```pgsql
SELECT name FROM (VALUES
        ('Müller'), ('Muller'), ('Ötzi'), ('Ozzy'), ('Über'), ('Upper')
      ) AS t(name)
ORDER BY name COLLATE "de-x-icu";

 name  
--------
 Muller
 Müller
 Ötzi
 Ozzy
 Upper

对于大多数应用程序工作负载来说,这实际上并不重要。API 返回 JSON,前端在客户端排序数据,搜索操作关心的是匹配而非排序。但是语言排序顺序仍然与直接向用户显示排序列表的应用程序相关,例如目录、目录、报告等等。

应用语言排序

这就是 ICU 发挥其作用的地方。集群可以运行内置提供程序来确保安全和性能,然后精确地在需要的地方应用 ICU 排序规则。有两种方法。

列级排序规则是当特定列始终需要语言排序时的理想选择:

CREATE TABLE customers (
    id serial PRIMARY KEY,
    name text COLLATE "de-x-icu",
    email text
);

现在 name 列始终按照德语语言规则排序,而数据库中的每个其他文本列都使用安全的、确定性的内置排序规则。name 列上的索引将使用 ICU 排序规则,因此依赖于该特定索引的 ICU 版本控制。ICU 在版本管理方面往往比 glibc 更严格,所以这风险不大。

表达式级排序规则更适合偶尔的语言排序:

SELECT name
  FROM customers
 ORDER BY name COLLATE "de-x-icu";

这仅针对这个特定的排序操作应用德语 ICU 排序规则。底层列及其索引仍保持在内置排序规则上。这导致语言排序而无需更改存储或索引行为。因此,这是最安全的方法,尽管由于额外的语法确实更不方便。

过去集群的幽灵

新集群很容易。在初始化时指定 builtin 提供程序,然后继续你的生活。但是已经运行在 libc 排序规则上的数百万个现有 PostgreSQL 集群呢?它们不会凭空消失,而且不可能在创建后更改数据库的默认排序规则。尽管如此,还是有几个选项:

  • 迁移到新集群。 使用 pg_dumppg_restore 或逻辑复制将数据移动到使用 builtin 提供程序初始化的新集群中。这是最干净的方法,但需要规划和潜在的停机时间。

  • 手动迁移到 ICU 或内置排序规则。 在基于 libc 的集群中,各列可以使用不同的排序规则。通过将受影响的表或列迁移到更安全的排序规则来分阶段推出,并始终使用该排序规则创建新列。未来的集群迁移仍然是永久性修复的必要条件,但这个过程提供了一条安全路径。

  • 监视排序规则版本。 Postgres 13+ 会在排序规则的底层版本更改时记录警告。注意这些警告;它们在尖叫着 REINDEX 是必要的。查找这些消息:

    WARNING: collation "xx-x-icu" has version mismatch

    DETAIL: The collation in the database was created using version 1.2.3.4, but the operating system provides version 2.3.4.5.

  • 在每次主要操作系统升级后 REINDEX。 对于基于 glibc 的集群,最安全的方法是在主要发行版升级后重建每个文本索引。没有例外。使用之前的诊断查询来识别受影响的索引,并对识别的候选索引使用 REINDEX INDEX CONCURRENTLY

继续前行

glibc 2.28 事件改变了整个 PostgreSQL 社区对外部依赖的思考方式。在 2018 年之前,操作系统库更新导致数据库损坏的概念只有少数人担心。它静默地这样做——让损坏持续数周、数月,甚至数年——只是在伤口上撒盐。

PostgreSQL 社区以其典型的英雄气概做出了回应。排序规则版本跟踪、ICU 提供程序支持,最终是 builtin 排序规则,表明了 PostgreSQL 开发人员为解决问题愿意走多远。这并不是对信任操作系统提供库的完全反叛,但考虑到具体情况,远离外部排序规则资源仍然是一种审慎的反应。

然而,我们不应该安于现状;glibc 仍然是默认值,这表明教训还没有完全被每个人吸取。每次运行 initdb 而不带 --locale-provider=builtin 都会带来与几乎给许多人带来灾难的相同风险的另一个集群。我个人在 2025 年最近一次遇到了这种类型的损坏,距离一切出问题已经整整七年了。为什么还要传播那个错误?

所以下次你启动 PostgreSQL 集群时,帮自己一个忙,使用内置语言提供程序。 那个刚刚升级到最新 Ubuntu LTS 而没有多加考虑的人,会感谢你的。