来源:https://www.pgedge.com/blog/looking-forward-to-postgres-19-split-personality

展望 Postgres 19:分裂的人格

作者: Shaun Thomas
日期: 2026年6月26日

自版本 10 以来,Postgres 就有了对声明式分区的原生支持,并且此后的每个版本都消除了另一个粗糙的边缘。我们获得了分区-wise 连接、默认分区、哈希分区,以及并发附加和分离分区的能力。从任何合理的标准来看,声明式分区都是现代 Postgres 最成功的特性之一。

尽管功能强大,但它一直像是一种单向棘轮。创建或删除分区很容易。但重组现有分区则完全是另一回事。当我们想要将一张表转换为分区集合,或修改现有设计时,没有语法或工具可以提供帮助。相反,这只是一长串手动语句来将表分割成分区,或者可能利用 pg_partman 来完成。

好吧,Postgres 19 终于有了答案。让我们谈谈当前的技术水平,以及 ALTER TABLE 新的 SPLIT PARTITIONMERGE PARTITIONS 语法可能会如何改变现状。

过去的方法

假设我们有一个繁忙的分区,想要将其拆分成更小的部分。当前的过程大致如下:

  1. 创建新分区作为独立表。
  2. 将相关行复制到每个新分区中。
  3. 分离旧分区。
  4. 附加新分区。
  5. 删除空的旧表壳。

这个方法可行,但也繁琐、容易出错,并且会受到各种锁的影响,这些锁可能会延迟过程。Postgres 19 将整个流程缩减为一个语句。

在深入探讨如何实现之前,让我们创建一些真实的东西来玩玩。比如一个包含按时间分区的事件流的分析管道?初始状态将是 2026 年每季度一个分区:

CREATE TABLE event_log (
  id          BIGINT GENERATED ALWAYS AS IDENTITY,
  event_time  TIMESTAMPTZ NOT NULL,
  event_type  TEXT NOT NULL,
  payload     JSONB,
  PRIMARY KEY (id, event_time)
) PARTITION BY RANGE (event_time);

CREATE TABLE event_log_2026_q1 PARTITION OF event_log
  FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE event_log_2026_q2 PARTITION OF event_log
  FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
CREATE TABLE event_log_2026_q3 PARTITION OF event_log
  FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
CREATE TABLE event_log_2026_q4 PARTITION OF event_log
  FOR VALUES FROM ('2026-10-01') TO ('2027-01-01');

现在让我们向一年中的每一天注入一个事件,这样每个分区都有一些数据:

INSERT INTO event_log (event_time, event_type, payload)
SELECT ts, 'click', '{"ok": true}'::jsonb
  FROM generate_series('2026-01-15'::timestamptz, 
                       '2026-12-15'::timestamptz, '1 day') ts;

假设我们在设计时认为按季度分区是合理的,但第一季度的流量比预期的要大。现在,查询三个月的数据来查找一个早上的点击量是很浪费的。我们更希望在那里按月分区。在过去,这意味着要进行迁移的繁琐工作。

繁衍生息

新的语法读起来几乎完全像我们可能会大声说出来的话。“把这个分区,拆分成这些更小的分区”:

ALTER TABLE event_log SPLIT PARTITION event_log_2026_q1 INTO (
  PARTITION event_log_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
  PARTITION event_log_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
  PARTITION event_log_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
);

就这样。原来的 events_2026_q1 分区消失了,取而代之的是三个每月的子分区,并且 Postgres 将每一行都移动到了它现在所属的分区。让我们验证一下:

SELECT tableoid::regclass AS partition, count(*)
  FROM event_log
 WHERE event_time >= '2026-01-01'
   AND event_time < '2026-04-01'
 GROUP BY 1
 ORDER BY 1;

     partition     | count 
-------------------+-------
 event_log_2026_01 |    17
 event_log_2026_02 |    28
 event_log_2026_03 |    31

没有临时表 -> 复制 -> 分离的繁琐过程。相反,数据精确地落入了新边界所指定的位置。一月份的 17 天数据,一个完整的二月,和一个完整的三月。整个操作也是事务性的,因此完全可以在中途中止并保持原始结构不变。

一个值得注意的细节:新分区必须覆盖被拆分分区的整个范围,不能有间隙或重叠。Postgres 会指出这里的错误:

ALTER TABLE event_log SPLIT PARTITION event_log_2026_q2 INTO (
  PARTITION event_log_2026_p1 FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'),
  PARTITION event_log_2026_p2 FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
  -- 注意 2026-05-01 和 2026-06-01 之间的间隙
);

ERROR:  cannot split to partition "event_log_2026_p2"
        together with partition "event_log_2026_p1"
DETAIL:  The lower bound of partition "event_log_2026_p2" is not equal
         to the upper bound of partition "event_log_2026_p1".
HINT:  ALTER TABLE ... SPLIT PARTITION requires the partition bounds to
       be adjacent.

这是一个很好的错误信息;它准确地告诉我们问题出在哪里以及如何修复。

合众为一

拆分只是故事的一半。相反的场景同样合理。我们同样可能有一堆旧分区,没有人再单独查询它们了,并希望清理它们。当 2026 年已经成为过去时,第一季度的三个月度分区就成了纯粹的开销。

让我们把它们合并回一个季度:

ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_01, event_log_2026_02, event_log_2026_03)
 INTO event_log_2026_q1;

三个月度分区合并为一个,所有 76 行数据也一起迁移:

SELECT tableoid::regclass AS partition, count(*)
  FROM event_log
 WHERE event_time < '2026-04-01'
 GROUP BY 1
 ORDER BY 1;

     partition     | count 
-------------------+-------
 event_log_2026_q1 |    76

注意我们重用了名称 events_2026_q1 作为结果。这是允许的,因为为合并提供数据的分区在这个过程中被消耗掉了。同样的技巧也适用于 SPLIT,当拆分默认分区时,这真的非常方便,我们稍后会看到。

合并与拆分具有相同的邻接规则。被合并的分区必须在数轴上相邻,它们之间没有间隙。尝试合并两个中间夹着第三个分区的分区,Postgres 会拒绝:

ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_q1, event_log_2026_q3)
 INTO event_log_2026;

ERROR:  cannot merge partition "event_log_2026_q3" together with
        partition "event_log_2026_q1"
DETAIL:  The lower bound of partition "event_log_2026_q3" is not equal
         to the upper bound of partition "event_log_2026_q1".
HINT:  ALTER TABLE ... MERGE PARTITIONS requires the partition bounds
       to be adjacent.

分配默认分区

这就是这些命令在现实世界中真正发挥作用的地方。想象一个表,数据有时会到达我们尚未分配的时间范围。这就是默认分区的用途:一个捕获所有现有分区未申领数据的容器。

CREATE TABLE log_entry (d date, msg text) PARTITION BY RANGE (d);
CREATE TABLE log_entry_2026_q1 PARTITION OF log_entry
  FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE log_entry_default PARTITION OF log_entry DEFAULT;

INSERT INTO log_entry VALUES ('2026-05-10', 'late'), ('2026-06-20', 'later');

那两行五月和六月的数据无处可去,所以它们落入了 log_entry_default

SELECT tableoid::regclass, count(*) 
  FROM log_entry
 GROUP BY 1
 ORDER BY 1;

     tableoid      | count 
-------------------+-------
 log_entry_default |     2

在过去,将这些零散数据提升到一个合适的 Q2 分区是一件相当麻烦的事情。当默认分区持有匹配的行时,我们不能简单地附加一个与之重叠的新分区。考虑一下:

CREATE TABLE log_entry_2026_q2 PARTITION OF log_entry
  FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');

ERROR:  updated partition constraint for default partition 
        "log_entry_default" would be violated by some row

现在我们不必担心这个了:

ALTER TABLE log_entry SPLIT PARTITION log_entry_default INTO (
  PARTITION log_entry_2026_q2 FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'),
  PARTITION log_entry_default DEFAULT
);

SELECT tableoid::regclass, count(*) 
  FROM log_entry
 GROUP BY 1
 ORDER BY 1;

     tableoid      | count 
-------------------+-------
 log_entry_2026_q2 |     2

五月和六月的行迁移到了全新的分区中,而默认分区则毫发无损。仅此一点就值回票价了。

损失与翻译

但就像任何宜家家具一样,有时我们在组装过程中会丢失一些零件。在拆分或合并过程中,Postgres 会删除直接定义在分区上的任何对象。考虑这样一种情况:我们在一月分区上有一个分区本地索引,在二月分区上有一个 CHECK 约束。

这是合并期间发生的情况:

CREATE INDEX idx_jan_type ON event_log_2026_01 (event_type);

ALTER TABLE event_log_2026_02
  ADD CONSTRAINT chk_feb_payload
CHECK (payload IS NOT NULL);

ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_01, event_log_2026_02, event_log_2026_03) 
 INTO event_log_2026_q1;

\d event_log_2026_q1

SELECT tablename
  FROM pg_indexes
 WHERE indexname = 'idx_jan_type';

 tablename 
-----------
(0 rows)

SELECT conname
  FROM pg_constraint
 WHERE conrelid = 'event_log_2026_q1'::regclass
   AND contype = 'c'; -- c 代表 "check" 约束

 conname 
---------
(0 rows)

约束和索引都消失了,无影无踪。幸运的是,在父表上定义的对象和在单个分区上定义的对象之间有一个重要的区别。

在父表本身上创建的索引是一个分区索引,Postgres 会在每个分区上自动维护一个副本。当合并或拆分产生一个新分区时,该分区会像任何新附加的分区一样,获得父索引的副本。看看当我们添加一个父索引和一个分区本地索引,然后合并时会发生什么:

CREATE INDEX idx_event_type ON event_log (event_type);   -- 在父表上
CREATE INDEX idx_q2_local ON event_log_2026_q2 (event_time); -- 在一个分区上

ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_q2, event_log_2026_q3, event_log_2026_q4)
 INTO event_log_2026_rest;

\d event_log_2026_rest

合并后的分区带回了父表的索引,但没有其他东西:

Partition of: event_log FOR VALUES FROM ('2026-04-01 00:00:00+00')
                                     TO ('2027-01-01 00:00:00+00')
Indexes:
    "event_log_2026_rest_pkey" PRIMARY KEY, btree (id, event_time)
    "event_log_2026_rest_event_type_idx" btree (event_type)

父索引存活了下来,并以生成的名称自动重建。本地的 idx_q2_local 则没有。所以我们可以这样看待它:任何从父表继承的东西都会被重建,而任何只附加到单个分区的东西都会被丢弃。如果分区带有自己定制的索引、约束或触发器,请注意这些,如果需要在合并后重新创建它们。

专属俱乐部

另一个主要的警告是关于争用的。这些命令还没有 CONCURRENTLY 变体。SPLITMERGE 都会获取 ACCESS EXCLUSIVE 锁,并且在操作的整个持续时间内持有它。

这个锁到底有多重?让我们看看:

BEGIN;
ALTER TABLE event_log SPLIT PARTITION event_log_2026_q1 INTO (
  PARTITION event_log_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
  PARTITION event_log_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
  PARTITION event_log_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
);

SELECT c.relname, l.mode, l.granted
  FROM pg_locks l
  JOIN pg_class c ON c.oid = l.relation
 WHERE l.mode = 'AccessExclusiveLock'
   AND c.relname LIKE '%event_log%'
 ORDER BY 1;

             relname              |        mode         | granted 
----------------------------------+---------------------+---------
 event_log                        | AccessExclusiveLock | t
 event_log_2026_01                | AccessExclusiveLock | t
 event_log_2026_01_event_type_idx | AccessExclusiveLock | t
 event_log_2026_01_pkey           | AccessExclusiveLock | t
 event_log_2026_02                | AccessExclusiveLock | t
 event_log_2026_02_event_type_idx | AccessExclusiveLock | t
 event_log_2026_02_pkey           | AccessExclusiveLock | t
 event_log_2026_03                | AccessExclusiveLock | t
 event_log_2026_03_event_type_idx | AccessExclusiveLock | t
 event_log_2026_03_pkey           | AccessExclusiveLock | t

看最上面一行。锁不仅仅在正在被拆分的分区上。它在父表本身上。只要操作运行,整个分区表就被冻结了。所有的读取和写入都将等待,直到 ALTER 完成。这相当不方便。

对于一个小分区来说,这只持续几毫秒,没有人会注意到。包含数亿行数据的分区在拆分或合并期间可能会被锁定几分钟甚至几小时。所以像对待任何其他 ALTER TABLE 语句一样对待这些命令:在流量高峰时期是危险的。在并发变体出现之前,这种重度锁是使用它们必须付出的代价。

哈希分区不适用

Postgres 中的原生表分区已经取得了长足的进步,但合并和拆分功能仍处于早期发展阶段。因此,仍然存在一些遗漏,例如基于哈希的分区。

拆分和合并都适用于 RANGELIST 分区表,但哈希分区表会立即拒绝:

CREATE TABLE h (id INT) PARTITION BY hash (id);
CREATE TABLE h0 PARTITION OF h FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE h1 PARTITION OF h FOR VALUES WITH (MODULUS 4, REMAINDER 1);

ALTER TABLE h MERGE PARTITIONS (h0, h1) INTO h0;
ERROR:  partition of hash-partitioned table cannot be merged

ALTER TABLE h SPLIT PARTITION h1 INTO (
  PARTITION h1_1 FOR VALUES WITH (MODULUS 8, REMAINDER 1),
  PARTITION h1_2 FOR VALUES WITH (MODULUS 8, REMAINDER 2)
);

ERROR:  partition of hash-partitioned table cannot be split

也许这会在未来出现,也许不会。到底如何“合并”一个哈希分区呢?我们必须改变基本的模数,从而影响所有子分区,而不仅仅是一个不同的子集。尽管如此,了解这一点还是有用的。

最后的想法

千里之行,始于足下。声明式 Postgres 分区在版本 10 中就迈出了那一步,现在它通过采用 MERGESPLIT 语法继续其征途。由于它们相对较新,它们缺少一些便利功能,例如并发操作。这是一个不小的遗漏,尤其是考虑到它们会引发访问独占锁。

但基础已经存在。如果 Postgres 分区的历史告诉了我们什么,那就是粗糙的边缘会随着每个版本的发布而被磨平。并发变体感觉几乎是不可避免的。在此之前,维护窗口是拆分或合并分区时的唯一选择。如果可用性至关重要,旧的表交换方法仍然是一个选择。

所以,下载一个 Postgres 19 版本,并尝试一下新的 SPLITMERGE 功能。这是很多用户都要求的功能,现在终于来了!

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐