PostgreSQL 11–18 中的 SQL 改进:个人精选-1

来源:https://tapoueh.org/blog/2026/07/sql-improvements-in-postgresql-1118-a-personal-selection/

PostgreSQL 11--18 中的 SQL 改进:个人精选

PostgreSQL 在 2018 年至 2025 年间发布了七个主要版本,每年一个,雷打不动,每个版本的更新日志都包含 150 到 200 项用户可见的变更。每个版本的覆盖范围都很广------性能、复制、管理和安全------但每个版本也都推动了 SQL 层的发展,填补了标准中的空白,添加了缺失的功能,或清理了长期存在的粗糙边缘。在为《The Art of PostgreSQL》的新版本工作时,我不得不将它们全部编目;以下是我在重写示例时反复使用的功能精选。希望它在那个语境之外也能有所帮助------按主题组织,并附上每个功能引入的版本。

本文中的所有查询都针对 Lab 运行,这是一个免费数据集捆绑包,包含 F1 数据库、地理政治数据、音乐数据等------因此你可以亲自尝试每个示例。Lab 只需一个 docker compose up 即可启动:它会启动 PostgreSQL,加载所有数据集,并提供一个在你的浏览器中运行的查询 UI Web 应用,支持 EXPLAIN 计划图,并为地理查询的 PostGIS 结果提供 SVG 渲染。

为了了解社区实际将精力投入何处,Hewlett-Packard Enterprise Japan 的 Noriyoshi Shinoda 从 9.4 版本开始,为每个主要版本发布了一份详尽的"PostgreSQL 新特性及示例"系列------每个版本都用可运行的代码示例编录了所有用户可见的变更。统计他在 PG 11--18 中的分类,可以清晰地看出贡献者的优先事项:

版本 SQL 性能 管理与运维 复制 安全 其他
PG 11 5 6 5 3 4 3
PG 12 4 6 7 3 3 5
PG 13 7 8 10 6 5 6
PG 14 4 7 6 5 3 5
PG 15 2 5 6 5 3 8
PG 16 4 6 8 5 7 8
PG 17 5 7 8 8 4 6
PG 18 4 3 4 3 2 5
总计 35 48 54 38 31 46

各版本按类别划分的新特性数量(Shinoda 统计)

SQL 是其中最小的类别。在整个期间,管理与运维遥遥领先,其次是性能------这反映了谁在为项目提供资金:亚马逊(Aurora)、谷歌(AlloyDB)和微软(Azure)都对 PostgreSQL 易于在云规模下操作、扩展和复制有着强烈的商业兴趣。复制与高可用性显示出最陡峭的增长轨迹,从 PG 11 的 3 个特性攀升至 PG 17 的 8 个,反映了相同的商业优先事项。本文的框架刻意以 SQL 为中心:执行策略、查询语法、数据类型、模式建模和 DML。管理、复制和身份验证方面的改进也是实实在在的;其中一些我觉得特别值得了解的内容收集在文末。

目录

  • 查询执行
    • 窗口帧 GROUPS 模式和 EXCLUDE(PG 11)
  • 聚合与集合操作
    • FETCH FIRST n ROWS WITH TIES(PG 13)
    • ANY_VALUE()(PG 16)
  • WITH 查询与递归
    • CTE 物化提示(PG 12)
    • 递归 CTE 中的 SEARCH 和 CYCLE(PG 14)
  • 数据类型
    • 内置 gen_random_uuid()(PG 13)
    • uuidv7()------时间可排序的 UUID(PG 18)
    • 多范围(Multi-Ranges)和 range_agg()(PG 14)
    • 内置 C.UTF-8 排序规则(PG 17)
    • 增量排序(Incremental Sort)(PG 13)
  • JSON
    • SQL/JSON 路径语言(PG 12)
    • JSONB 下标语法(PG 14)
    • IS JSON 谓词(PG 16)
    • SQL/JSON 构造函数和 JSON_TABLE(PG 16--17)
  • 模式建模
    • 生成存储列(PG 12)
    • 生成虚拟列(PG 18)
    • 唯一约束中的 NULLS NOT DISTINCT(PG 15)
    • 分区改进(PG 12--19)
  • DML
    • MERGE(PG 15),在 PG 17 中扩展
    • RETURNING OLD 和 NEW(PG 18)
  • 数据加载
    • COPY ON_ERROR IGNORE 和 REJECT_LIMIT(PG 17 / PG 18)
  • 扩展
    • pg_trgm:升级到 PG 18 后重建索引
  • 超越 SQL:一些值得了解的运维改进

本文涵盖的七个版本中,每个版本都添加了一项里程碑式的 SQL 功能;下表列出了该功能及其发布日期和官方发布说明中用户可见变更的大致数量。

版本 发布日期 约变更数 SQL 头条
PG 11 2018年10月 180 存储过程(CREATE PROCEDURE / CALL),JIT 编译,覆盖索引(INCLUDE)
PG 12 2019年10月 200 生成列,JSON 路径语言(@? / @@),CTE 内联,REINDEX CONCURRENTLY
PG 13 2020年9月 150 增量排序,B-tree 去重,内置 gen_random_uuid()
PG 14 2021年9月 200 多范围类型,JSONB 下标语法(doc'key'),CTE 中的 SEARCH/CYCLE
PG 15 2022年10月 180 SQL MERGE,NULLS NOT DISTINCT,逻辑复制中的行/列过滤器
PG 16 2023年9月 150 备库上的逻辑复制,pg_stat_io,ANY_VALUE(),IS JSON 谓词
PG 17 2024年9月 180 JSON_TABLE(),增量备份,COPY ON_ERROR IGNORE,内置 C.UTF-8 排序规则
PG 18 2025年9月 150 虚拟生成列,uuidv7(),RETURNING OLD/NEW,异步 I/O

变更计数为近似值(±15%),涵盖每个版本发布说明中的用户可见项目;不包括小版本错误修复。

以下是我从这七个版本中个人精选的 SQL 层面改进------我认为对编写高效 PostgreSQL 查询至关重要的功能,按主题分组。在 AI 时代,了解 PostgreSQL 能做什么比以往任何时候都更重要:如果你不知道窗口函数、范围查询或多范围数据类型的存在,你就无法要求 AI 帮你编写它们。这里有一篇关于 SQL 和 AI 的更详细的文章。


查询执行

窗口帧 GROUPS 模式和 EXCLUDE(PG 11)

ROWS 帧模式按物理行计数;RANGE 按值距离计数。PostgreSQL 11 添加了第三种模式 GROUPS,它按对等组计数------即共享相同 ORDER BY 值的连续行。这是处理排名或平局数据的自然单位。

三种窗口帧规范在 7 行结果集上展示:UNBOUNDED PRECEDING 到 CURRENT ROW(累计总和),1 PRECEDING 到 1 FOLLOWING(滑动窗口),以及 CURRENT ROW 到 UNBOUNDED FOLLOWING(剩余总和)。

PostgreSQL 11 还添加了 EXCLUDE 子句,作为同一 SQL:2011 合规性推动的一部分------EXCLUDE CURRENT ROWEXCLUDE TIESEXCLUDE GROUP------从帧中移除特定行。EXCLUDE TIES 将当前行保留在帧中,但移除共享相同 ORDER BY 值的其他行。单场比赛不适合展示------每个积分位置都有唯一的积分值,因此不会出现平局。2007 赛季的积分榜是合适的数据集:汉密尔顿和阿隆索都以 109 分结束赛季,这是 F1 历史上最接近的总冠军争夺。

sql 复制代码
select surname,
       sum(results.points) as season_pts,
       sum(sum(results.points)) over (
           order by sum(results.points) desc
           rows between unbounded preceding and current row
       ) as running,
       sum(sum(results.points)) over (
           order by sum(results.points) desc
           rows between unbounded preceding and current row
           exclude ties
       ) as running_excl_ties
from results
join drivers using(driverid)
join races   using(raceid)
where extract(year from races.date) = 2007
group by driverid, drivers.surname
order by season_pts desc;


    姓氏    | 赛季积分 | 累计  | 排除平局累计
  --------------+------------+---------+------------------
   Räikkönen    |        110 |     110 |               110
   Hamilton     |        109 |     219 |               219
   Alonso       |        109 |     328 |               219
   Massa        |         94 |     422 |               422
   Heidfeld     |         61 |     483 |               483
   Kubica       |         39 |     522 |               522
   Kovalainen   |         30 |     552 |               552
   Fisichella   |         21 |     573 |               573
   Rosberg      |         20 |     593 |               593
   Coulthard    |         14 |     607 |               607
   Wurz         |         13 |     620 |               620
   Webber       |         10 |     630 |               630
   Trulli       |          8 |     638 |               638
   Button       |          6 |     644 |               644
   Vettel       |          6 |     650 |               644
   R Schumacher |          5 |     655 |               649
   Sato         |          4 |     659 |               653
   Liuzzi       |          3 |     662 |               656
   Davidson     |          2 |     664 |               658
   Sutil        |          1 |     665 |               659
  (20 行)

汉密尔顿和阿隆索在 running_excl_ties 中都显示为 219------莱科宁的 110 加上他们自己的 109,而平局对手的 109 被排除在帧外。巴顿和维特尔同积 6 分,也出于同样的原因都显示为 644。

GROUPSEXCLUDE 都是标准 SQL,现在在 PostgreSQL 中可用。


聚合与集合操作

FETCH FIRST n ROWS WITH TIES(PG 13)

LIMIT n 截断为恰好 n 行;当截断点落在平局内时,哪些行幸存是任意的。FETCH FIRST n ROWS WITH TIES 将结果扩展,包含与最后保留行平局的每一行:

sql 复制代码
-- 2007 赛季所有并列第二名的车手:
select surname, sum(points) as pts
from results join races using(raceid) join drivers using(driverid)
where extract(year from races.date) = 2007
group by driverid, surname
order by pts desc
fetch first 2 rows with ties;


     姓氏  | pts 
   -----------+-----
   Räikkönen | 110
   Alonso    | 109
   Hamilton  | 109
  (3 行)

莱科宁以 110 分获胜;阿隆索和汉密尔顿都得了 109 分,因此第二名的平局被包含在内,即使它使结果超过了两行。WITH TIES 是 SQL 标准语法,仅适用于 FETCH FIRSTLIMIT n 没有平局扩展。

ANY_VALUE()(PG 16)

当一列不属于 GROUP BY 键时,SQL 要求将其包装在聚合函数中。min()max() 带有查询并不需要的隐式排序。any_value(col) 使意图明确:从组中返回一个任意代表,不保证顺序:

sql 复制代码
select constructors.name                as constructor,
       any_value(drivers.surname)       as a_driver,   -- 代表,非 min/max
       count(distinct driverid)         as num_drivers,
       sum(results.points)              as total_points
from results
join races        using(raceid)
join drivers      using(driverid)
join constructors using(constructorid)
where extract(year from races.date) = 2017
group by constructorid, constructors.name
order by total_points desc
limit 5;


     制造商   |  车手   | 车手数 | 总积分
   ------------+-----------+-------------+--------------
   Mercedes    | Hamilton  |           2 |          503
   Ferrari     | Räikkönen |           2 |          385
   Red Bull    | Ricciardo |           2 |          270
   Force India | Pérez     |           2 |          133
   Williams    | Massa     |           3 |           65
  (5 行)

该功能在 SQL:2023 中标准化,从 PostgreSQL 16 开始可用。


WITH 查询与递归

CTE 物化提示(PG 12)

在 PostgreSQL 12 之前,每个 CTE 都是一个优化屏障:规划器将其物化一次并直接使用结果,即使将其内联为子查询本可以启用谓词下推。PG 12 改变了默认行为:非递归、非易变的 CTE 现在由规划器内联。

两个关键词提供显式控制:

sql 复制代码
-- 强制物化(稳定快照,防止重复求值)
with accidents as materialized (
    select season, count(*) as n from results
    join races using(raceid) join status using(statusid)
    where status = 'Accident'
    group by season
)
...

-- 让规划器内联并下推谓词
with top_drivers as not materialized (
    select driverid, sum(points) as pts
    from results group by driverid
)
select drivers.surname, pts
from top_drivers
join drivers using(driverid)
where pts > 500
order by pts desc
limit 6;


      姓氏   |   pts
   ------------+--------
   Hamilton   |   2528
   Vettel     |   2355
   Alonso     |   1842
   Rosberg    | 1594.5
   Schumacher |   1566
   Räikkönen  |   1498
  (6 行)

这一变更悄无声息地改变了一大批现有查询的性能。当 CTE 有副作用或被多次引用时,MATERIALIZED 是正确的选择。当规划器需要看到 CTE 外部的过滤器时,NOT MATERIALIZED 是正确的选择。

递归 CTE 按照 PostgreSQL 工作表产生的任何内部顺序访问行。会出现两个问题:规划器按什么顺序访问每行?当图中有循环时会发生什么?

SEARCH 控制遍历顺序。SEARCH DEPTH FIRST BY col SET order_col 产生前序深度优先遍历;SEARCH BREADTH FIRST BY col SET order_col 产生逐层广度优先遍历。在 PG 14 之前,实现同样的效果需要在递归项中手动添加一个整数计数器。

CYCLE 检测并停止在已访问的行上。CYCLE col SET is_cycle USING path 添加一个 is_cycle 布尔值和一个 path 列,跟踪迄今为止看到的值序列。当递归项会重新访问一个值时,is_cycle 被设为真,该行不会反馈到下一轮。

sql 复制代码
with recursive borders(isocode, name, depth) as (
    select isocode, name, 0 from geoname.country where isocode = 250
    union all
    select c.isocode, c.name, b.depth + 1
    from geoname.neighbour n
    join geoname.country c on c.isocode = n.neighbour
    join borders b          on b.isocode = n.isocode
    where b.depth < 4
)
cycle isocode set is_cycle using path
select depth, isocode, name from borders
where not is_cycle
order by depth, name;


     depth | isocode |     name
    -------+---------+---------------
         0 |     250 | France
         1 |      20 | Andorra
         1 |      56 | Belgium
         1 |     276 | Germany
         1 |     380 | Italy
         1 |     442 | Luxembourg
         1 |     492 | Monaco
         1 |     724 | Spain
         1 |     756 | Switzerland
    ...
  (36 行)

将同一个 CTE 包装在汇总中,显示每跳可到达的不同国家数量:

sql 复制代码
select depth, count(distinct isocode) as countries
from borders
where not is_cycle
group by depth
order by depth;


     depth | countries
    -------+-----------
         0 |         1
         1 |         8
         2 |        19
         3 |        26
         4 |        43
  (5 行)

从法国出发 1-4 个边境跳转可到达的国家,按深度着色。使用 PostGIS 从 geoname 数据集生成的地图。

在 PG 14 之前,等价实现需要 WHERE NOT id = ANY(path) 并手动构造数组------在相同节点对之间有多条边的图上容易出错且脆弱。


数据类型

内置 gen_random_uuid()(PG 13)

从 PostgreSQL 13 开始,gen_random_uuid() 是一个内置函数,无需安装 uuid-ossp 扩展即可生成加密随机的 UUID v4:

sql 复制代码
select gen_random_uuid();
-- fbb850cc-dd26-4904-96ef-15ad8dfaff07

uuidv7()------时间可排序的 UUID(PG 18)

PostgreSQL 18 添加了 uuidv7(),用于生成时间可排序的 UUID v7。前 48 位编码当前毫秒时间戳,因此按顺序插入的行在 UUID 列上也按字典序排序------这对 B-tree 索引局部性和集群写入有显著优势:

sql 复制代码
select uuidv7() as v7, gen_random_uuid() as v4;

对于使用 UUID 作为主键的新模式,如果预期有大量插入负载或范围扫描,uuidv7() 是更好的选择。

多范围(Multi-Ranges)和 range_agg()(PG 14)

数轴上的三种范围类型运算符:&&(重叠)、@>(包含)和 -|-(相邻)。

每种范围类型都获得了对应的多范围类型:int4multirangedatemultirangetstzmultirange 等等------存储在一个列值中的有序、不重叠的子范围集合。

其核心问题是带间隙的成员资格。John Frusciante 曾三次担任红辣椒乐队的吉他手。他于 1988 年加入,1992 年离开,1998 年回归,2009 年再次离开,并于 2019 年重新加入。一个普通的 UNIQUE (band_id, composer_id) 约束会禁止第二段任期。而一个带有 GiST 排除约束的 tstzrange 列正好可以处理这种情况:

sql 复制代码
create table composer.bandmember (
  id          serial    primary key,
  band_id     int       not null references composer.band(id),
  composer_id int       not null references composer.composer(id),
  active      tstzrange not null,
  exclude using gist (band_id with =, composer_id with =, active with &&)
);

EXCLUDE USING GIST 约束表明:没有两行具有相同的 (band_id, composer_id) 可以拥有重叠的 active 范围。Frusciante 的三行不重叠记录共存;任何与三者之一重叠的第四行将在写入时被拒绝,无需触发器。

sql 复制代码
select b.name                                   as band,
       c.name                                   as member,
       lower(bm.active)::date                   as joined,
       case when upper(bm.active) = 'infinity' then null
            else upper(bm.active)::date end     as departed,
       (upper(bm.active) = 'infinity')          as current
  from composer.band          b
       join composer.bandmember bm on bm.band_id = b.id
       join composer.composer   c  on c.id = bm.composer_id
 where b.name = 'Red Hot Chili Peppers'
 order by lower(bm.active);
复制代码
         乐队          │      成员      │   加入日期   │  离开日期  │ 当前
═══════════════════════╪══════════════════╪════════════╪════════════╪═════════
 Red Hot Chili Peppers │ Anthony Kiedis   │ 1983-01-01 │            │ t
 Red Hot Chili Peppers │ Flea             │ 1983-01-01 │            │ t
 Red Hot Chili Peppers │ Chad Smith       │ 1988-01-01 │            │ t
 Red Hot Chili Peppers │ John Frusciante  │ 1988-01-01 │ 1992-05-07 │ f
 Red Hot Chili Peppers │ Dave Navarro     │ 1993-09-01 │ 1998-04-01 │ f
 Red Hot Chili Peppers │ John Frusciante  │ 1998-04-01 │ 2009-12-31 │ f
 Red Hot Chili Peppers │ John Frusciante  │ 2019-12-15 │            │ t

(7 行)

@>(包含)运算符询问:这个范围是否包含这个点?一个运算符就能回答"这个人在这个时间点是成员吗?":

sql 复制代码
select b.name as band,
       c.name as member
  from composer.band          b
       join composer.bandmember bm on bm.band_id = b.id
       join composer.composer   c  on c.id = bm.composer_id
 where bm.active @> '1985-07-13 12:00:00+00'::timestamptz
 order by b.name, c.name;
复制代码
         乐队            │      成员
═════════════════════════╪══════════════════
 King Diamond            │ King Diamond
 King Diamond            │ Mikkey Dee
 Metallica               │ Cliff Burton
 Metallica               │ James Hetfield
 Metallica               │ Kirk Hammett
 Metallica               │ Lars Ulrich
 Motörhead               │ Lemmy Kilmister
 Motörhead               │ Phil Campbell
 Motörhead               │ Pete Gill
 Motörhead               │ Würzel
 Queen                   │ Brian May
 Queen                   │ Freddie Mercury
 Queen                   │ John Deacon
 Queen                   │ Roger Taylor
 Red Hot Chili Peppers   │ Anthony Kiedis
 Red Hot Chili Peppers   │ Flea
 Scorpions               │ Klaus Meine
 Scorpions               │ Matthias Jabs
 Scorpions               │ Rudolf Schenker

(19 行)

日期是 1985 年 7 月 13 日------Live Aid 演唱会。John Frusciante 还未加入红辣椒;他在 1988 年 1 月才加入。Phil Taylor 也不在 Motörhead:他的第一段任期于 1984 年 5 月结束,第二段直到 1987 年 6 月才开始。

相关推荐
逃跑的浣熊1 小时前
MySQL 性能分析报告:Page Cache 与 fsync 对读写性能的影响
数据库
路由侠内网穿透.1 小时前
本地部署开源日志收集系统 Log Bull 并实现外部访问
运维·服务器·网络·数据库·开源
sevenll072 小时前
SqlKit - 覆盖 50+ 数据库的 AI 智能体 SQL 桌面客户端
数据库·人工智能·sql·智能体
数智化管理手记5 小时前
手工统计指标误差大、效率低?指标管理系统如何告别人工算数痛点?
大数据·运维·数据库·人工智能·云计算
ShiXZ21311 小时前
Redis 常用指令全集:redis-cli 实战速查手册
数据库·redis·缓存
晓子文集12 小时前
Tushare接口文档:期货日线行情(fut_daily)
大数据·数据库·金融数据·量化投资·tushare
WA内核拾荒者12 小时前
WhatsApp 账号异常检测的自动化告警系统设计
数据库·python·自动化
龙仔72516 小时前
人大金仓OS_Core数据库自动备份实施笔记(银河麒麟Linux)
linux·数据库·笔记·备份·人大金仓
sunxr.22716 小时前
Mysql-----最后一次作业
数据库·mysql