全面解读 PostgreSQL 18 新功能
全面盘点 PostgreSQL 18 的新特性、性能优化、运维与可观测性改进,以及面向开发者的新工具。
上周,Postgres 18 正式发布稳定版。其亮点包括:异步 I/O 基础设施和 OAuth 2.0 支持等基础特性,B-树跳过扫描等性能优化,以及原生 UUIDv7 支持等备受期待的功能。
但本次发布涉及超过 3000 次提交,除核心亮点外还有诸多值得关注的变更。我们力求全面梳理变更内容,最终在这篇长文(近5000字)中涵盖了约30项功能。
若您无暇通读全文,我完全理解——以下是简要提要:
- 异步IO是极具前景的根本性变革,但目前仅适用于特定场景,因此您的使用场景可能尚未体现其全部优势。
- 部分默认行为有所调整(如生成列的VIRTUAL属性),但整体升级过程应较为顺畅,对应用程序代码的影响微乎其微。
- 以下各领域均有重大改进:
-
- 开发便利性(uuidv7、RETURNING old/new、虚拟生成列、时态数据库)。
- 常规操作(更快的升级流程、oauth认证、vacuum优化、扩展管理)。
- 特定场景的性能优化(B树索引、规划器改进)。
- 可观测性/监控(进程级统计、EXPLAIN改进)。
不过我建议有时间的话还是通读全文,其中包含许多有趣的细节。我们还附上了代码示例和提交信息的片段。
异步I/O
在此版本之前,Postgres采用同步I/O(类似read()和write()系统调用),依赖操作系统隐藏同步I/O的开销。它确实使用了posix_fadvise系统调用,该调用向操作系统“提示”应用程序计划如何访问文件。通过引入异步IO(AIO),Postgres获得了两大核心优势,我将其总结自 AIO读我文件总结如下:
- 更直接地控制并行发送IO请求的时机,最大限度减少IO等待
- 支持直接I/O(Direct IO),可通过DMA在存储设备与Postgres缓冲池间进行数据复制,从而避免占用CPU资源
这些改进最终将带来更优性能和更低资源消耗。对于支持高度并行性的网络附加存储设备,其效益尤为显著。在Xata,我们采用光纤NVMe技术——这正是AIO的理想应用场景,因此对此功能格外期待。
虽然AIO框架已引入,但尚未全面应用,因此当前需合理管理预期。托马斯·冯德拉的这篇博客文章提供了我所见过的最佳性能概述。
简而言之:
- 顺序扫描性能提升2-3倍!
- 索引扫描未见改善。
- 位图扫描虽有提升,但在
io_workers值较低时会出现性能退化
此外,AIO应已降低后台维护任务的开销。
OAuth 2.0
OAuth支持意味着现已存在避免共享长期密码的标准化解决方案。您可通过单点登录提供商获得交互式Postgres访问权限,应用程序也能通过OAuth工作流与Postgres进行身份验证。
实际应用中,交互式登录的Postgres连接字符串可能如下所示:
$ psql 'host=example.org oauth_issuer=https://... oauth_client_id=...'
Visit <https://oauth.example.org/login> and enter the code: FPQ2-M4BG
应用程序可通过如下连接字符串使用令牌:
host=example.com oauth_issuer=https://... oauth_client_id=... oauth_token=...
我们计划近期在 Xata 中采用此方案替代静态密码。
开发者优化
若您以开发者身份使用 PostgreSQL,本节内容将最符合您的需求。
UUID v7
如何生成主键是开发者最热衷的讨论话题之一,如今终于有了简便的解决方案。在Postgres 18之前,开发者可使用原生UUID类型中的UUID v4,但这会带来性能代价——由于UUIDv4不具备单调性,既影响索引局部性,又难以压缩。而UUIDv7完美解决了这些问题:它支持排序,并在开头嵌入了当前时间戳。
由于UUIDv7规范允许实现细节存在灵活性,以下是Postgres的具体实现方式(摘自提交信息):
在我们的实现中,12位亚毫秒级时间戳小数部分紧接在时间戳之后存储,位于RFC中称为“rand_a”的空间。这确保了毫秒级内的额外单调性。rand_ a位同时充当计数器。我们选择亚毫秒级时间戳,是为了确保在同一后端内生成的UUID具有单调递增特性——即使系统时钟倒转或以极高频率生成UUID时亦然。因此同一后端内生成的UUID具有单调性。
基本用法如下所示:
select uuidv7();
uuidv7
--------------------------------------
01997f39-9e31-7988-a86a-622879771a69
(1 row)
该函数支持间隔类型参数,可用于获取带有过去或未来时间戳的UUIDv7。例如,以下代码将生成带有昨日时间戳的UUIDv7:
select uuidv7(INTERVAL '-1 day');
uuidv7
--------------------------------------
01997a17-44c8-7bb3-a447-0d224f3de52f
(1 row)
您可以通过提取嵌入的时间戳进行验证,如下所示:
SELECT uuid_extract_timestamp('01997a17-44c8-7bb3-a447-0d224f3de52f');
uuid_extract_timestamp
----------------------------
2025-09-24 04:59:29.352+00
(1 row)
RETURNING 子句现可指定 OLD/NEW
作为开发者,这可能是我最喜爱的功能,因为它能大幅减少代码量和复杂度。该特性使RETURNING子句可通过特殊别名old和new显式返回旧值和/或新值。除非查询中已定义这些别名,否则它们会自动添加到查询中。
以下是一个简短的UPDATE示例:
UPDATE foo SET name=upper(name)
RETURNING old.name as old_name, new.name as new_name;
old_name | new_name
----------+----------
foo | FOO
(1 row)
虽然上述UPDATE是最典型的应用场景,但该功能同样适用于INSERT ... ON CONFLICT ... DO UPDATE等操作。
虚拟生成列:读取时计算
Postgres 18新增了一种生成列变体:VIRTUAL。这类列在读取时计算(类似视图),与存储生成列(在写入时计算,类似物化视图)不同。
以下是一个简短示例:
CREATE TABLE users (
id serial PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
full_name text
GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL
);
需注意:VIRTUAL 现已成为默认行为,因此若从旧版 Postgres 恢复模式,生成列将从存储型自动切换为虚拟型。将VIRTUAL设为默认值的动机详见提交信息:
拟视图默认采用VIRTUAL而非STORED模式,以兼容其他SQL产品。(SQL标准对此未作规定,且该标准本身也不认识VIRTUAL或STORED概念。)(此外,虚拟视图默认启用,而非物化视图。)
另需注意提交信息中的说明:这些视图在读取时进行计算,但存储成本并非完全为零:
虚拟生成列以空值形式存储于元组中。(早期补丁曾试图完全不存储这些列,但若元组中某列完全缺失,将导致大量功能失效或混乱。此方案虽属折中,仍比强制使用存储生成列更节省空间。若未来找到优化方案,可通过pg_upgrade的巧妙设计实现向新版方案的升级。)
逻辑复制中包含生成列
关于生成列,Postgres 18新增了将其纳入逻辑复制流的功能。此前默认假设从节点可重新生成这些列,但如今逻辑复制的应用场景已远超Postgres内部复制。
这对pgstream和Debezium等CDC工具是重大利好,它们现在能获取生成列数据。
时态数据库增强
首先,让我们快速定义时序数据库:这类数据库专门追踪随时间变化的数据。与仅存储最新信息状态不同,时序数据库不仅记录数据的当前值,还完整保存数据在其生命周期中的历史演变轨迹。
Postgres 18 增强 通过支持主键和唯一约束的 WITHOUT OVERLAPS 子句,增强了时序数据库的应用场景。此举通过强制执行关键时间规则——相同业务键对应的有效时间段不得重叠——使 Postgres 更接近时间数据库能力。
我们正在撰写关于将 PostgreSQL 用作时间数据库的深度博客文章,现提供简要示例:
-- loading this extension is required for the index to work
CREATE EXTENSION btree_gist;
CREATE TABLE bookings (
room_id int NOT NULL,
during tstzrange NOT NULL,
-- Temporal PK: last column uses WITHOUT OVERLAPS
PRIMARY KEY (room_id, during WITHOUT OVERLAPS)
);
上述主键确保同一房间在任何时间点均不会被重复预订。
此外,外键约束现可通过PERIOD关键字引用时间段。此功能支持范围型和多范围型数据类型。时间外键采用范围包含性而非相等性进行校验。
创建带NOT VALID约束的NOT NULL限制
此变更将NOT NULL约束添加到可作为NOT VALID添加的约束列表中。这意味着什么?假设您有一个当前包含NULL值的列。您不能直接添加NOT NULL约束,因为这会导致约束无效(且Postgres验证所有值期间会锁定表)。若先回填数据清除NULL值,则存在新插入操作可能再次添加NULL值的风险。
而NOT VALID约束允许以下操作:
- 以
NOT VALID形式添加约束。此操作快速高效,因其不检查现有行。但此后插入操作必须为该列指定非空值。 - 批量导入数据以清除所有NULL值。
- 验证约束。此操作无需锁定表进行读写(技术上仍存在锁,但不阻塞读写操作)。
以下为操作示例:
CREATE TABLE foo(id int PRIMARY KEY, name text);
INSERT INTO foo(id) VALUES (1);
-- there is one row with name = NULL
ALTER TABLE foo ADD CONSTRAINT name_not_null NOT NULL name NOT VALID;
-- the following will fail
INSERT INTO foo(id) VALUES (2);
UPDATE foo SET name='';
ALTER TABLE foo VALIDATE CONSTRAINT name_not_null;
分区表上的 NOT VALID 外键约束
模式变更操作再获胜绩,其优势与上述类似:分区表的外键约束可声明 NOT VALID。例如,若events为分区表且数据引用accounts表时,以下操作可行:
ALTER TABLE events
ADD CONSTRAINT events_account_fk
FOREIGN KEY (account_id) REFERENCES accounts(id)
NOT VALID;
验证可按分区逐个检查,这有助于进一步减少锁定。
新协议版本
Postgres 18 自2003年以来首次提升传输协议版本!这也是首次增加次要版本号。新版本号为3.2,其说明阐述了版本升级的原因:
查询取消机制中使用的密钥长度从4字节扩展为可变长度字段。为适应此变更,BackendKeyData消息结构已调整,CancelRequest消息亦重新定义为可变长度有效负载。
若您好奇为何版本号为3.2而非3.1,答案就在上述链接页面中:
保留版本号。尽管PostgreSQL从未使用过3.1版本,但因流行应用pgbouncer的旧版本存在协议协商漏洞——该漏洞导致其错误声称支持3.1版本——故跳过此版本号。
libpq客户端库目前仍默认使用3.0版本,直至上层组件(如驱动程序、连接池管理器、代理)添加对新协议版本的支持。鉴于此情况以及新版本的破坏性变更较小,我们不应因新版本引发兼容性问题。
操作改进
重大版本升级加速
重大版本升级是Xata团队持续投入的另一重点领域。Postgres 18在此方面带来多项显著改进。
首先,pg_upgrade整体运行速度将提升,尤其在同一集群中存在大量数据库,或更普遍地存在海量对象(表、视图、序列等)时。这得益于其新增的并行多任务框架,以及更智能地规避冗余操作和fsync的机制。
其次,pg_upgrade现可迁移旧版本的统计数据,这意味着升级后规划器能立即获取关键信息以实现最优执行,从而降低升级后的性能风险。
最令人称道的是,统计数据转移实际由pg_dump实现——该工具新增了--no-statistics和--statistics-only选项。这使其在其他场景同样适用,例如通过逻辑复制实现蓝绿部署。
更便捷的K8s环境扩展管理
新增了一个名为extension_control_ path的配置选项,用于控制Postgres搜索扩展程序的路径。该功能由CloudNativePG项目提出,旨在为Kubernetes运维人员实现更便捷的声明式扩展管理。
此前由于镜像必须不可变,唯一可行的方案是构建包含所需扩展子集的定制镜像。现在则可使用精简镜像,通过挂载包含其他扩展的镜像卷实现扩展管理。
Xata将充分利用此功能,最终实现更强的安全性与更灵活的新扩展添加能力。
VACUUM优化
Postgres 18 带来了多项与 vacuum 相关的改进。
此项改进 降低了激进 vacuum 的执行成本。当Postgres检测到事务回绕风险时,便需要执行激进真空操作——此时系统会加速冻结旧元组(行数据)。
为摊销激进真空操作的成本,Postgres 18 在常规真空过程中会主动扫描部分可见但未冻结的页面。这意味着常规真空时工作量增加,但能显著避免最坏情况的发生。
Postgres 18 还更改 插入阈值的计算方式,使其不再包含冻结页,这意味着在插入密集型表中通常会更频繁地执行该操作。
真空操作的可观察性也得到提升,新增名为track_cost_ delay_timing的配置项,用于收集基于成本的真空延迟计时统计数据。请注意该参数默认关闭,因为它会反复查询操作系统获取当前时间,在某些平台上可能造成显著开销。所幸Postgres自带便捷工具pg_ test_timing,可帮助您判断在当前架构上启用该功能是否合理。
可观测性/监控改进
EXPLAIN 语句优化
Postgres 18 对 EXPLAIN 语句进行了多项细微改进。
值得注意的是,在执行 EXPLAIN ANALYZE 时,BUFFERS 现已成为默认值。提交 消息(双关语)阐述了更改默认值的理由:
过去几年中,多次出现关于在ANALYZE选项下启用EXPLAIN的BUFFERS选项的讨论。从多方面来看,此举似乎是个好主意,因为它能更直观地向用户说明特定查询为何运行速度低于预期。 此外,根据我(David)的个人经验,曾见过用户在邮件列表中贴出两个完全相同的执行计划——一个运行缓慢,一个运行迅速——并询问为何查询有时会变慢。多数情况下,这源于额外的读取操作。默认启用BUFFERS或许能减少此类疑问,即便不能,也能让用户在发帖前更清晰地意识到问题根源,避免因额外I/O操作导致的延迟而徒增邮件往来。
除上述改进外,EXPLAIN还提供了更丰富详尽的信息:包含Material节点的内存/磁盘使用情况、索引搜索次数、禁用节点数量等。
pg stat statements中的更多语句
另一项有助于可观测性的改进:CREATE TABLE AS 和 DECLARE CURSOR 等语句现在会为其创建的内部查询分配查询 ID。此举的益处在于,这些查询将出现在 pg _stat_ statements 等视图中——因为该视图需要查询 ID 作为标识。
锁获取失败日志记录
此变更引入了新配置参数:log_lock_ failure。启用该参数(默认关闭)后,锁获取失败时将生成详细日志信息。目前仅支持记录由SELECT ... NOWAIT引发的锁获取失败。
日志包含所有持有或等待未获取锁的进程信息,有助于用户分析诊断锁获取失败的原因。
进程级统计功能
本次变更优化了统计基础设施,支持在进程生命周期内记录进程级统计数据。提交信息说明了其工作原理:
这在pgstats中新增了一种可变数量的统计类型,其统计条目的对象ID键基于后端的进程编号。该机制为同时存在的统计条目数量设定了上限。条目在后端通过身份验证启动时创建,并在后端退出时删除,因此统计条目仅在对应后端运行期间存在。
该新架构的首个用户是一个新函数:pg _stat_ get _backend_ io(),用于收集特定后端/进程的I/O统计数据。使用示例:
SELECT *
FROM pg_stat_get_backend_io( pg_backend_pid() )
WHERE backend_type = 'client backend'
AND object = 'relation'
AND context = 'normal';
-[ RECORD 1 ]--+---------------
backend_type | client backend
object | relation
context | normal
reads | 122
read_time | 0
writes | 0
write_time | 0
writebacks | 0
writeback_time | 0
extends | 49
extend_time | 0
op_bytes | 8192
hits | 11049
evictions | 0
reuses |
fsyncs | 0
fsync_time | 0
stats_reset |
追踪连接建立时长
Postgres 18新增日志记录功能,可追踪从建立连接、配置后端直至连接准备就绪执行首条查询的耗时。日志包含三项时长:
- 总配置时长(从postmaster接受连接请求起,至连接可执行查询结束)
- 新后端进程分叉耗时
- 用户身份验证耗时
启用该功能需在log_connections配置参数中添加setup_ durations选项。
性能提升与优化
索引优化:B-树跳跃扫描
假设你有一个多列索引,例如 (col1, col2, col3)。在 Postgres 18 之前,只有当条件中包含最左侧的列时,此类索引才能高效使用。因此以下查询都会使用索引:
SELECT * FROM foo WHERE col1 = '...';
SELECT * FROM foo WHERE col1 = '...' AND col2 = '...';
SELECT * FROM foo WHERE col1 = '...' AND col2 = '...' AND col3 = '...';
但以下查询通常不会使用索引:
SELECT * FROM foo WHERE col2 = '...';
SELECT * FROM foo WHERE col2 = '...' AND col3 = '...';
这是因为多列索引按元组 (col1, col2, col3) 的顺序存储键值,因此可利用其任意前缀进行检索。
Postgres 18 能够在后两种示例中高效利用索引。其工作原理是跳转至 col1 的值,并读取索引每个“分区”的相关部分。
当 col1 的基数较低时,这种方法效果更佳,因为此时可跳过更多数据。因此在定义多列索引时,将基数较低的列置于首位更为合理。
以下是相关文档提交记录中的关键段落:
教导nbtree多列索引扫描在遇到查询时,若该查询对一个或多个前缀索引列未设置“=”条件,则可选择性跳过索引中无关的部分。当nbtree接收源自谓词“WHERE b = 5”的输入扫描键时,新的nbtree预处理步骤会输出“WHERE a = ANY(<所有可能的’a’值>) AND b = 5”的扫描键。即预处理为被忽略的前缀列“a”生成“跳过数组”(及输出扫描键),从而可安全地将“b”列扫描键标记为继续扫描所需。扫描过程因此能通过同时应用“a”和“b”键反复重新定位。[…] 测试表明,当索引包含基数较低的跳过前缀列时,跳过扫描的速度可比等效的全索引扫描(或顺序扫描)快多个数量级。通常,扫描跳过列的基数决定了可跳过的叶页数量上限。
SQL语言函数使用计划缓存
这有助于更好地内联SQL函数中的查询。摘自提交信息:
在历史版本的 SQL 函数实现中(若未被内联),我们会在外部查询首次调用时为所有嵌套查询构建执行计划,随后在外部查询执行期间复用这些计划,最后彻底清除所有计划。这种方式并不理想,尤其因为计划无法根据函数参数的具体值进行定制。
自连接消除
自连接消除(SJE)功能可在查询树中移除普通表与其自身的内部连接,前提是该连接可被全表扫描替代且不影响查询结果。
此优化通过减少冗余形式,本质上能提升规划器的估算精度,并减轻后续层级的工作负荷。分区表尤其受益于此,因为它能更早识别分区修剪的潜在机会。
利用唯一索引检测冗余的GROUP BY列
当用户对多列唯一索引执行GROUP BY操作时,此规划器优化将生效。在此特殊情况下,Postgres 仅需使用其中一列即可,因为唯一索引确保了分组结果的等效性。
以下示例展示了该优化的效果:
CREATE TABLE employees (
emp_id serial PRIMARY KEY,
dept_id int NOT NULL,
email text NOT NULL,
UNIQUE (dept_id, email)
);
SELECT dept_id, email
FROM employees
GROUP BY dept_id, email;
Postgres此前已对主键实施此优化,现将其扩展至所有多列唯一键。需注意:唯一索引中的列必须标记为NOT NULL,或索引必须采用NULLS NOT DISTINCT模式。
重排DISTINCT值以减少排序操作
当在多个列上使用DISTINCT时,这些列在DISTINCT子句中的顺序并不重要,因此优化器可以重新排序 进行重新排序,以最符合其需求的方式排列。以下示例说明此行为:
CREATE TABLE sales (
store_id int,
sale_date date,
amount numeric
);
-- Note: index orders rows by (store_id, sale_date)
CREATE INDEX ON sales (store_id, sale_date);
-- Query: DISTINCT keys appear as (sale_date, store_id) in this order
-- Semantically it’s the same set of pairs either way.
SELECT DISTINCT sale_date, store_id FROM sales;
此行为现为默认设置,但可通过新配置项禁用:enable_distinct_ reordering。
尽可能将’x IN (VALUES …)’转换为’x = ANY …’
此优化的核心在于简化查询树结构,消除冗余连接的出现。以下是典型场景:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT o.*
FROM orders o
WHERE o.id IN (VALUES (101), (205), (99999), (123456));
QUERY PLAN
---------------------------------------------------------------------------------------
Index Scan using orders_pkey on orders o (actual time=0.010..0.010 rows=0.00 loops=1)
Index Cond: (id = ANY ('{101,205,99999,123456}'::integer[]))
Index Searches: 1
Buffers: shared hit=8
Planning:
Buffers: shared hit=26 read=1
I/O Timings: shared read=0.019
Planning Time: 0.178 ms
Execution Time: 0.027 ms
注意执行计划中提及的ANY条件。因此等效SQL为:
SELECT o.*
FROM orders o
WHERE o.id = ANY('{101,205,99999,123456}'::integer[]);
提交信息解释了加速原理:
由于VALUES描述的是关系表,且此类列表的值为表行,优化器可能因无法通过MCV统计量估算基数而面临低估问题。基数评估机制可配合数组包含检查操作运行。若数组足够小(<100个元素),则会逐元素执行统计评估。
字符大小写转换
Postgres 18 新增了 casefold() 函数,该函数与 lower() 类似,但能避免无区分大小写匹配中的边缘案例问题。对于支持该功能的排序规则,casefold() 可处理具有超过两种大小写变体或多字符大小写变体的字符。
以下示例(摘自邮件列表)展示了大小写折叠比转换为小写更优的边界情况:
- 某些字符存在超过两种大小写形式,例如“Σ”(U+03A3)可转换为小写“σ”(U+03C3)或“ς”(U+03C2)。
casefold()函数将该字符的所有大小写形式统一转换为“σ”。 - 字符“İ”(U+0130,带点的首字母I)会被转换为小写“i”,这在不支持此转换的区域设置中可能引发问题。
- 若Unicode新增小写字符,
lower()的处理结果可能随之改变。
更高效的lower()与upper()
与上述内容相关,Postgres 18对lower()和upper()的实现也进行了加速优化。优化主要体现在映射表的生成方式,具体优势如下(摘自邮件列表):
- 移除了所有映射表中存储Unicode码点(无符号整数)的功能。
- 主表记录数从3003条精简至1575条(已消除重复项)。
- 在主表中将指针(本质上是 uint64_t)替换为 uin8_ t。
- 缩短了在表中查找记录的时间。
- 缩小最终对象文件的体积。
提交信息中记载了其他备选方案:
曾考虑过其他方案,例如将这些区间表示为另一种结构(而非生成函数中的分支),或采用基数树、完美哈希等不同方法。作者实现并测试了这些替代方案后,最终选择了生成分支方案。
加速范围型 GiST 索引构建
GiST 支持“排序构建”模式:当输入元组已排序时,可显著提升树结构构建速度并优化压缩率。但要高效排序范围数据,规划器/执行器需要专用的排序支持函数。此功能通过该提交实现。
数组便捷函数
Postgres 18 引入了两个实用的数组便捷函数:array _reverse() 和 array_ sort()。
快速示例:
SELECT array_reverse(ARRAY[1,2,3]);
array_reverse
---------------
{3,2,1}
(1 row)
SELECT array_sort(ARRAY[3,1,2]);
array_sort
------------
{1,2,3}
(1 row)
json_strip_ nulls() 可移除数组中的空值元素
JSON函数json_strip_ nulls()新增了一个参数:strip _in_ arrays。其默认值为false。当该参数为 true 时,不仅会移除数组中的 null 元素,还会移除对象字段中的 null 值。仅包含单个 null 的 JSON 数据不受影响。
添加获取数据库对象访问控制列表的函数
Postgres 18 引入新函数 pg_get_ acl(),用于检索和检查数据库对象关联的权限。示例如下:
postgres=# CREATE TABLE foo (id INT);
CREATE TABLE
postgres=# CREATE ROLE bar;
CREATE ROLE
postgres=# GRANT SELECT ON foo TO bar;
GRANT
postgres=# CREATE ROLE baz;
CREATE ROLE
postgres=# GRANT UPDATE ON foo TO baz;
GRANT
postgres=# SELECT unnest(pg_get_acl('pg_class'::regclass, 'foo'::regclass, 0));
unnest
----------------------------
postgres=arwdDxtm/postgres
bar=r/postgres
baz=w/postgres
(3 rows)
从上例可见,bar角色获得读取权限,而baz角色获得写入权限。

