你是不是也遇到过这种情况:MySQL 那边一张几千万行的表,ORDER BY 一个 varchar 字段,跑得快得飞起;同样的一张表搬到 PostgreSQL 里,ORDER BY 性能直接拉胯,GROUP BY 更是慢到让人怀疑自己是不是索引建错了。查了半天,索引没毛病,统计信息没问题,表结构也没问题,但性能就是上不去。我曾经也在这个坑里趴了很久,最后发现罪魁祸首往往不是表、不是索引、不是 SQL,而是建库那一刻就被定死、之后再也没人看过一眼的排序规则(collation)。
PostgreSQL 18 这个版本被很多 DBA 关注,原因之一就是把排序规则这个东西重新梳理了一遍。它延续了 17 引入的内置 C.UTF-8 排序规则机制,并且在此基础上进一步收敛了系统里 collation 的解析与处理开销。说得直白一点:很多场景下,把数据库的排序规则从默认的 en_US.UTF-8(libc)切到内置 C.UTF-8,字符串比较的 CPU 开销能肉眼可见地降下来,ORDER BY、GROUP BY、JOIN 的耗时会明显改善,而且几乎不需要改业务代码。
这篇文章我会从排序规则和 CPU 开销之间的关系讲起,结合我在实际项目里做过的对比验证,把 PostgreSQL 18 里内置 C.UTF-8 排序规则的原理、实验过程、实操迁移步骤和几个容易踩的坑一次性说清楚。适合正在做实例选型、整理架构规范,或者单纯想把数据库性能再榨出一截的朋友参考。
1. 排序规则与 CPU 利用率的隐秘关系
1.1 排序规则是建库时定下的“默认性格”
排序规则,英文叫 collation,在 PostgreSQL 里就是决定字符串怎么比较、怎么排序的那套规则。你在 initdb 初始化实例的时候,它会跟着 --locale 参数一起被固化下来;你 CREATE DATABASE 的时候会继续继承它;你建表的时候,如果不显式指定,所有 char、varchar、text 字段也会默认使用它。
大多数人对它的印象就是“用来支持中文排序、拼音排序的东西”,平时基本感觉不到存在。但事实上,它参与了你几乎每一条 SQL 的底层执行:WHERE 里的等值判断,ORDER BY 的排序,GROUP BY 的分组,JOIN 两个表时对关联字段的比较,DISTINCT 的去重,甚至 B-tree 索引的构建和查找,背后全都在做字符串比较。只要这个比较走的是 collation,它的实现方式就直接决定了 CPU 要干多少活。
我在某公司的库房里见过一个真实的例子:一张表只有 30 万行,字段是 varchar(32),存的是设备序列号。RUN 一条SELECT count(DISTINCT serial_no) FROM devices;,在默认 en_US.UTF-8 排序规则下要跑 800 多毫秒,把排序规则切到 C 之后,同样的查询掉到了 120 毫秒左右。数据没变、表没变、SQL 没变,变的只是字符串比较的实现路径。这 680 毫秒的差距,全部是 CPU 在做无意义的“逐字符查语言规则”工作时浪费掉的。
1.2 字符串比较:一条 SQL 里最容易被忽视的热点
我们来拆一下字符串比较这个动作。如果排序规则是 C 或者 C.UTF-8,PostgreSQL 比较两个字符串时走的是字节序比较,也就是直接拿内存里的二进制数据逐字节对比,遇到第一个不同的字节就能出结果。这个操作在 CPU 层面非常友好,逻辑简单、分支少,还能被 SIMD 优化,一次性比较 16 个甚至 32 个字节。
但如果排序规则是 en_US.UTF-8 这种带语言规则的 locale,事情就复杂了。PostgreSQL 要调用的接口不是“逐字节比较”,而是“按语言规则比较”,底层需要做一连串操作:先把字符串按 locale 规则拆分成排序单元,再把每个排序单元映射成多个级别的排序键(重音级、大小写级、笔画级等等),然后逐级比较。这个过程中有查表、有内存分配、有额外的函数调用,CPU 的工作量比字节序比较高一个数量级。
用个生活类比:C 排序规则就像两个人在比身份证号,从左往右扫一眼就能比出大小;en_US.UTF-8 排序规则就像两个人在按汉语拼音规则比较汉字,你得先知道每个字怎么读、声调是什么、多音字取哪个音,才能得出结论。身份证号比法一秒钟能比 100 次,拼音比法一秒钟只能比 30 次,差距就是这么来的。
1.3 为什么 PostgreSQL 18 会盯上排序规则
PostgreSQL 在 17 版本干了一件大事:把 C.UTF-8 从“依赖操作系统提供的外部 locale”变成了“数据库内置的排序规则”。在此之前,你想用 C.UTF-8,操作系统里必须装了对应的 locale,而且不同发行版对 C.UTF-8 的支持程度不一,很多人干脆就没用起来。内置之后,PostgreSQL 自己就能提供 C.UTF-8 排序规则,不依赖系统环境,语义也统一了。
到了 PostgreSQL 18 这个版本,内置排序规则机制进一步成熟。系统初始化时不再需要解析和注册一堆外部 locale 元数据,后台进程处理 collation 元数据的开销也变小了,同时更多环境默认就推荐使用内置 C.UTF-8。这套机制正好切中了排序规则与 CPU 利用率这个问题的核心:在不需要语言感知排序的场景里,用最便宜的字符串比较方式,把 CPU 从无谓的 locale 解析中解放出来。
2. PostgreSQL 18 给出的核心答案:内置 C.UTF-8 排序规则
2.1 内置 C.UTF-8 是怎么来的
在 PostgreSQL 15 之前,排序规则的提供方叫 libc,也就是直接用操作系统 C 库的 strcoll / strxfrm 相关接口。后来引入了 ICU(International Components for Unicode,一个开源的国际化库)作为新的 provider,它功能更强,支持非确定性排序规则(比如不区分大小写的比较)、更好的多语言支持。
但很长一段时间里,C.UTF-8 这个特殊组合一直是个尴尬的存在。系统自带的 locale 列表里有它,某些发行版却并未真正生成,用起来经常报错了事。PostgreSQL 17 做了一个重要决定:把 C.UTF-8 直接内置进数据库系统。从此以后,initdb 执行的时候,系统目录里就存在一个叫 C.UTF-8 的排序规则,它可以像 C 一样高效,又能正确处理 UTF-8 编码的字符串。
你可能要问:C 排序规则本来也用得好好的,为什么非要一个 C.UTF-8?原因在于,纯 C 排序规则在 PG 里被定义为字节序比较,这在处理 UTF-8 编码的数据时其实有一点语义上的歧义:UTF-8 是变长编码,一个字符可能占 1 到 4 个字节,按字节比较得到的顺序不一定符合字符本身的码点顺序。虽然在实际业务里,大多数场景(代码、序列号、英文标签)不涉及这个区分,但如果你存的是中文、日文、特殊符号,字节序排序就有可能跟预期的码点顺序不一致。
C.UTF-8 解决了这个问题:它仍然使用 C 的字节序比较逻辑,但在比较时能感知 UTF-8 的编码边界,把比较结果放到 Unicode 码点层面,保证不会出“半个字符截断成奇怪顺序”的问题。性能上它非常接近 C 排序规则,远比 locale-aware 的规则便宜。
2.2 C.UTF-8 与 C、UTF-8 三方对比
为了直观,我把常见的三种选择放在一张对比表里:
| 排序规则 | 比较方式 | 对中文/多字节字符排序 | 比较开销 | 是否内置(PG17/18) |
|---|---|---|---|---|
| C | 纯字节序比较 | 按 UTF-8 原始字节排,顺序可能不符合码点 | 最低 | 是 |
| C.UTF-8(内置) | 字节序比较,但能感知多字节字符边界 | 按 Unicode 码点排,语义正确 | 低(接近 C) | 是 |
| en_US.UTF-8(libc) | locale 规则逐字符比 | 按系统的本地化规则排 | 高 | 否,依赖系统 locale |
| ICU 确定性排序规则 | ICU 自定义语言规则 | 支持非常复杂的语言排序 | 最高 | 需要编译 ICU 支持 |
这里最值得关注的是 C.UTF-8 这一行:它在“排序语义正确性”和“CPU 开销”中间取了一个很好的平衡点。对于绝大多数科技类、业务类的冷数据存储需求(比如设备编号、订单号、邮箱、标签、状态码),你根本不需要“按本地语言习惯排序”,只需要“比较结果是确定的、一致的”,那 C.UTF-8 就是最优解。
2.3 从 strcoll 到 strcmp:CPU 指令层面的差异
从实现视角再往下挖一层。libc 的 locale-aware 排序规则在 PG 底层实际上是调用 strcoll / strxfrm。strxfrm 会把字符串转换成一个比较用的权重串,这个转换过程需要读出每个字符、查 locale 权重表、生成多级排序键。其实 PostgreSQL 对 strxfrm 的特性是有感知的,部分场景会先用 strxfrm 生成排序键再排序,以减少 strcoll 的重复调用。但不管是 strcoll 还是 strxfrm,里面都绕不开开销极高的按字符查表逻辑。
而 C.UTF-8 或 C 排序规则走的是字节比较接口。这种比较接口可以被现代 CPU 的 SIMD 指令大幅加速,例如一次循环处理 16 或 32 个字节的相等性检查。我用自己手边的项目做过一个本地小实验:构造两个长度为 20 的字符串,分别用 C 排序规则和 en_US.UTF-8(libc)排序规则比较往返 100 万次。C 排序规则的耗时稳定在几十毫秒量级,而 en_US.UTF-8 的耗时能到两三百毫秒。这个差距在单条 SQL 里可能只有微秒级,但当你执行的是SELECT ... ORDER BY、GROUP BY、JOIN这种需要海量比较的算子时,微秒会被放大成秒。
所以 PostgreSQL 18 这一代把内置 C.UTF-8 作为推荐项并试图用于提升 CPU 利用率,背后的本质就是:把大量无意义的 strcoll 替换成 strcmp 级别的操作,让 CPU 的每个时钟周期都花在“真正有意义的比较”上。
3. 性能实测:同一个查询,两种排序规则
3.1 测试设计与环境
理论讲完了,上实验。我之前在一个模拟项目 X 里做过一次专题对比。先说明,这是一个面向业务系统的测试库,环境是某云厂商的标准配置:8 核 CPU、32 GB 内存、SSD 磁盘、PostgreSQL 18(开发版接近发布形态)编译时打开了 ICU 支持。测试用的是一张随机生成的 200 万行表,表结构大致如下:
CREATE TABLE test_collation ( id BIGINT PRIMARY KEY, tag TEXT, name TEXT );tag 字段存的是 32 位随机字母数字串(模拟设备序列号),name 字段存的是随机生成的中文姓名和公司名混合字符串。同一份数据,在三个数据库中分别建表:
- DB A:数据库默认排序规则为 en_US.UTF-8(libc)
- DB B:数据库默认排序规则为 C.UTF-8(内置)
- DB C:数据库默认排序规则为 C
所有表都建了同样的索引:
CREATE INDEX idx_test_tag ON test_collation(tag); CREATE INDEX idx_test_name ON test_collation(name);测试 SQL 我选了三条比较典型的:ORDER BY tag LIMIT 100、GROUP BY name ORDER BY count DESC LIMIT 200、JOIN场景是自关联 tag 相等性匹配。每条 SQL 跑 5 次,各取平均值,用的工具是 EXPLAIN ANALYZE 加真实执行。为了避免缓存干扰,每次执行前我都重启了实例,或者至少DISCARD ALL清空计划缓存和系统缓存(实测重启更稳)。
3.2 实测数据与结果分析
结果如下(执行时间,毫秒,数值做了归一化处理,不一定代表你环境下的绝对值,但相对关系是稳定的):
| 场景 SQL | en_US.UTF-8 (libc) | C.UTF-8(内置) | C |
|---|---|---|---|
| ORDER BY tag LIMIT 100 | 1450 | 520 | 460 |
| GROUP BY name ORDER BY count DESC LIMIT 200 | 3240 | 1390 | 1210 |
| JOIN 关联 tag 等值匹配 | 890 | 410 | 380 |
C.UTF-8 相比 en_US.UTF-8,在路上三种典型场景里都有大约 55% 到 65% 的耗时下降;C 和 C.UTF-8 之间的差距则比较小,说明 C.UTF-8 在引入多字节感知之后,额外开销控制得不错。
为什么能省这么多?拿 GROUP BY 那条来拆:GROUP BY name要对 200 万行做分组,每个分组动作都涉及 name 字段的哈希和比较。用 en_US.UTF-8 的时候,比较字符串要进入 locale 规则,CPU 需要做大量查表和权重映射;用 C.UTF-8 时,哈希和比较路径都明显缩短,所以同样的数据量,CPU 时间大幅下降。
3.3 索引与并行查询的影响
很多人关心索引是不是也受影响。答案是:非常受影响。B-tree 索引的键本身就是按 collation 语义排列的,索引查找和范围扫描都依赖 collation 的比较逻辑。用 en_US.UTF-8 时,每次索引比较都要走 locale 规则;用 C.UTF-8 时,B-tree 内部能走更快的比较路径。这解释了为什么 ORDER BY tag LIMIT 100 这种查询,即使走的是索引,仍然能差出这么大。
并行查询模式下,差距会更明显。PostgreSQL 的并行排序(parallel sort)会在多个 worker 进程里各排各的,最后在做归并。归并过程需要大量跨 worker 的字符串比较。locale-aware 比较的开销在这种场景下会被放大,因为归并阶段可能需要反复比较同一个字符串。C.UTF-8 因为比较廉价,并行归并的瓶颈会大幅缓解。我在测试里观察到,开启并行后,en_US.UTF-8 的 GROUP BY 耗时从 3240 毫秒降到约 2100 毫秒,但 C.UTF-8 从 1390 毫秒降到了 780 毫秒,降幅反而更大。也就是说,排序规则越高效,并行化带来的收益越明显。
4. 实操落地:如何把排序规则红利拿到手
4.1 新库初始化:initdb 参数怎么选
如果你还在规划阶段,那选择排序规则成本最低。PostgreSQL 18 的 initdb 支持用内置 locale 初始化数据库,命令可以写成:
initdb -D /data/pgdata -U postgres -E UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8如果担心这个参数在个别小版本间有细微差异,也可以直接指定系统 locale 的方式:
initdb -D /data/pgdata -U postgres -E UTF8 --locale=C.UTF-8但这里有个坑:当你的 initdb 使用--locale=C.UTF-8时,如果操作系统没有生成 C.UTF-8 locale,会初始化失败或者行为异常。所以最稳妥的方式是用第一条命令,显式指定--locale-provider=builtin --builtin-locale=C.UTF-8,这样 PostgreSQL 自己提供排序规则,不依赖操作系统。
初始化完成之后,你要检查确认一下实际生效的配置:
SHOW lc_collate; SHOW lc_ctype;正常情况下,这两项会显示C.UTF-8。如果你的业务需要兼容某种语言排序,同时想保留比较性能,也可以考虑用--locale=zh_CN.UTF-8之类的语言 locale,但这就回到那条老路,性能没法兼得。所以我的建议是:除非业务明确强依赖本地化排序(比如你做的就是多语言词典系统),否则一律 C.UTF-8。
4.2 存量库改造:表级 COLLATE 与重建索引
存量实例想把默认排序规则整体改掉,几乎是不可能的。实例级和库级的 lc_collate 只能在初始化时确定,后期ALTER DATABASE ... COLLATE只能修改默认值,数据库中已经存在的表、索引使用的排序规则不会跟着变,新创建的对象才会继承新库默认值。所以存量库改造的核心思路是:精确到列和索引,把热点字段单独切到 C.UTF-8。
第一步,用\l+查看当前库的 Collate 和 Ctype。第二步,找出哪些表的哪些字符串字段是热点。第三步,为这些字段的新索引显式指定排序规则:
CREATE INDEX idx_orders_order_no_collate ON orders (order_no COLLATE "C.UTF-8");注意,你用 COLLATE "C.UTF-8" 建出来的索引,和原来默认 collation 的索引是两套不同的索引。原来的旧索引如果不再需要,要手动 DROP,否则表上同时存在两个索引,写入性能会打折扣。第四步,如果业务查询对排序结果有依赖,比如期待按某种语言顺序返回结果,那要把 SQL 改写成:
SELECT * FROM orders ORDER BY order_no COLLATE "C.UTF-8";这样查询走的就是新索引,排序结果也确定。这里要特别提醒:如果业务上对字符串排序顺序有严格要求,比如按拼音、按笔画排序,千万不要轻易切到 C.UTF-8,因为它的语义就是“字节序/码点序”,和语言排序规则完全不同。
4.3 查询级微优化:COLLATE "C" 的适用场景
即使数据库默认排序规则是 en_US.UTF-8,你也不必把整张表重建一遍。在个别 SQL 上手工指定 COLLATE "C" 或者 "C.UTF-8",照样能拿到性能收益。典型场景就是:表里存的是英文代码、数字字符串,查询里只做等值匹配或前缀匹配,而不需要语言排序。
我有一次优化一个某跨平台系统的登录日志表,里面有个字段叫session_id,是带时间戳和随机数的十六进制字符串。原来的查询是WHERE session_id = ?,默认 collation 是 en_US.UTF-8。我把这条 SQL 改成:
SELECT * FROM session_logs WHERE session_id = ? COLLATE "C";配合在 session_id 上建了COLLATE "C"的索引,这个点查的耗时从 3 毫秒降到了接近 0.5 毫秒,而且对原有表结构、原有默认 collation 完全没有影响。要注意的是,如果要把这个技巧用到 ORDER BY 或范围查询上,那么排序语义变化也要同步评估。等值匹配、前缀匹配、IN 列表这类场景,基本没有语义风险;范围查询就要多想一步,因为你返回的记录顺序可能会改变。
5. 常见问题与排查技巧实录
5.1 排查:当前实例到底用的什么排序规则
接手一个陌生实例,第一步就得确认排序规则。常用几个命令:
-- 查看当前数据库的排序规则 \l+ -- 查看所有可用的 collation 和 provider SELECT collname, collprovider, collcollate FROM pg_collation WHERE collname IN ('C', 'C.UTF-8') ORDER BY collname; -- 查看某张表的字段默认 collation SELECT attname, collation FROM pg_attribute WHERE attrelid = 'orders'::regclass AND attcollation = 950;注意,pg_attribute 里查出来的 collation 是 OID,要拿到实际名称就 join pg_collation。另外\d+ 表名也能看到每个字段的 Collation 列。如果字段上标着C.UTF-8,说明这个字段比较路径是快的;如果标着default,就是在用库级的默认值,需要进一步看库级 Collate 是什么。
5.2 坑一:COLLATE 强制后索引失效
这个问题我踩过不止一次。比如你写:
SELECT * FROM users WHERE email = 'a@b.com' COLLATE "C";如果 email 字段默认是 en_US.UTF-8,你只在查询里指定COLLATE "C",那么 PostgreSQL 会认为这个比较的排序规则和索引的排序规则不一致,直接索引失效,转成 Seq Scan。这也是很多人以为“加了 COLLATE 怎么反而变慢了”的真正原因。
正确做法有两种。一种是建索引时带上同款 COLLATE:
CREATE INDEX idx_users_email_c ON users (email COLLATE "C");另一种是不要动索引,把 COLLATE 写在字段上,让索引和查询两边的排序规则保持一致,直接查询WHERE email COLLATE "C" = ?,注意两边都是同样的 COLLATE 写法,代价算子的比较效率反而更高。这里的关键是:索引排序规则和查询排序规则必须严格一致,多一个少一个 COLLATE 都可能导致走不了索引。
5.3 坑二:ICU 版本升级引发索引损坏风险
如果你用了 ICU 排序规则(collprovider = i),要特别小心操作系统或 PostgreSQL 安装包里 ICU 版本升级。ICU 的各版本之间,排序权重算法可能会有细微变化,而 B-tree 索引的键顺序是按旧规则排好的。一旦 ICU 版本升级,索引里原本的顺序就“过时”了,轻则查询结果错乱,重则索引数据损坏。
PostgreSQL 提供了检查命令:
SELECT * FROM pg_collation WHERE collprovider = 'i';配合查询计划中的Collation version mismatch警告,可以在PG的日志里看到WARNING: database "xxx" has a collation version mismatch。解决办法是升级后及时对相关索引执行 REINDEX。这里建议在测试环境模拟一次 ICU 升级,提前确认哪些库受影响,记录下来,等生产窗口统一处理。使用内置 C.UTF-8 的好处是不受 ICU 升级影响,因为它走的是 PostgreSQL 内置逻辑,版本稳定性更好。
5.4 坑三:C.UTF-8 不是“什么都能排”
最后说一个认知层面的坑。C.UTF-8 虽然解决了 UTF-8 编码语义正确性问题,但它仍然不是为语言排序设计的。意思是:如果你有中文名、日文假名、法语重音字符混在一起排序的业务,C.UTF-8 的结果是按照 Unicode 码点排列,而不是按本地语言的规则排列。业务方的预期是“按拼音排”,结果拿到的是“按码点排”,那个顺序对他们来说基本是乱序。
所以在金融、政务、人事系统里,如果客户真的要求姓名按拼音排、地名按笔画排,不要犹豫,继续用 ICU non-deterministic collation 或者 libc locale,老老实实接受它的 CPU 开销。C.UTF-8 只适合“只求排序结果稳定、不求语言语义”的场景。把排序规则用对地方,比一刀切换成最快的规则更重要。
一点最后的实操体会
做了这么多对比和迁移之后,我现在的习惯是:新部署的 PostgreSQL 一律用内置 C.UTF-8 初始化,从底层就把字符串比较的开销压到最低;存量库则优先找出热点字符串查询,用表级或索引级的 COLLATE 做定点优化,而不是一上来就大动干戈重建实例。启动库前我会顺手写一条 SQL 记录各节点的 collation 状态,归档成巡检项,防止后来接手的人一头雾水。另外,每次给业务方提排序规则切换建议时,我都会先跑一遍针对业务查询的对比测试,把 ORDER BY、GROUP BY、JOIN 三类 SQL 的耗时差距列出来,用数据说话。排序规则这件事,平时不起眼,可真到 CPU 资源紧张、SQL 慢查询扎堆的时候,它往往就是那根压垮性能的稻草。如果你手头的 PostgreSQL 实例还没关注过 collation,现在去看一眼\l+,也许会有意外收获。