PostgreSQL 17: 分区表上的标识列

John Doe 七月 4, 2025

你需要同时使用到分区表和标识列吗?

非洲肯尼亚草原上的一头大象

特性提交日志

支持分区表中的标识列。

此前,分区表中不允许使用标识列。主要原因是没人花时间去研究细节以实现这一功能。现在这一功能已可正常使用。

关于其行为的一些细节:

  • 新创建的分区继承标识属性 分区表的分区是分区表不可分割的一部分。分区会从分区表继承标识列。分区的标识列与分区表对应的列共享标识空间。换句话说,分区表所有分区中的同一标识列,共享相同的标识空间。这是通过共享相同的底层序列实现的。

    当直接向分区插入数据时,会使用与最上层分区表相关联的序列来计算相应标识列的值。

    在常规继承中,子表中的标识列及其属性独立于父表中的标识列及其属性。子表不会自动从父表继承标识列或其属性。这一点未发生改变。

  • 附加的分区继承标识列 被附加为分区的表会从分区表继承标识属性。这应该是可行的,因为我们期望分区表的列与分区表中相应列的类型相同。如果被附加的表是分区表,标识属性会向下传播到其分区层次结构中。

    分区表中的标识列也会被标记为 NOT NULL。为了使附加操作成功,分区中相应的列需要被标记为 NOT NULL。

  • 分离分区时删除标识属性 分区的标识列与分区表相应列共享标识空间(即底层序列)。如果一个分区被分离,它就不能再像以前那样共享标识空间了。因此,被分离的分区的标识列会失去其标识属性。

    当删除常规表中某一列的标识时,该列会保留随标识属性而来的 NOT NULL 约束。同样,被分离的分区的列也会保留随标识属性而来的 NOT NULL 约束,即使标识属性本身已丢失。

    与标识属性相关联的序列链接到分区表(而非被分离的分区)。该序列不会作为分离操作的一部分被删除。

  • 不允许分区拥有自己的标识列。

  • 分区表支持常见的ALTER操作(添加标识列、为现有列添加标识属性、修改标识列的属性、删除标识属性)。不允许仅在分区表或某个分区中修改列;修改需要应用于整个分区层次结构。

讨论:https://www.postgresql.org/message-id/flat/CAExHW5uOykuTC+C6R1yDSp=o8Q83jr8xJdZxgPkxfZ1Ue5RRGg@mail.gmail.com

示例

提交日志的描述已经相当详细了,让我们来看看实际操作。

CREATE TABLE some_data (
    id             int8 generated always as identity NOT NULL,
    created_at     timestamptz NOT NULL,
    payload        TEXT
) PARTITION BY RANGE ( created_at ) ;

CREATE TABLE some_data_old PARTITION OF some_data FOR VALUES FROM (MINVALUE) TO ('2022-01-01');
CREATE TABLE some_data_y2022 PARTITION OF some_data FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');
CREATE TABLE some_data_y2023 PARTITION OF some_data FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
CREATE TABLE some_data_y2024 PARTITION OF some_data FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE some_data_y2025 PARTITION OF some_data FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

现在,我们可以向所有这些表中插入数据:

insert into some_data (created_at, payload)
  select now() - '5 years'::interval * random(), 'Row #' || i
    from generate_series(1,10) i;

select tableoid::regclass, * from some_data order by created_at;
    tableoid     | id |          created_at           | payload
-----------------+----+-------------------------------+---------
 some_data_old   |  8 | 2020-12-14 08:40:15.272099+00 | Row #8
 some_data_old   |  7 | 2021-01-27 12:28:57.147299+00 | Row #7
 some_data_old   |  4 | 2021-03-27 15:24:49.524899+00 | Row #4
 some_data_old   |  2 | 2021-10-14 01:33:39.531299+00 | Row #2
 some_data_y2022 | 10 | 2022-10-04 06:04:15.905699+00 | Row #10
 some_data_y2023 |  6 | 2023-01-02 05:19:54.267299+00 | Row #6
 some_data_y2023 |  9 | 2023-03-14 09:37:02.196899+00 | Row #9
 some_data_y2023 |  1 | 2023-04-09 14:36:47.566499+00 | Row #1
 some_data_y2023 |  3 | 2023-10-28 22:47:40.980899+00 | Row #3
 some_data_y2023 |  5 | 2023-11-14 14:18:36.593699+00 | Row #5
(10 rows)

如你所见,id 是按照插入顺序分配的,无论数据最终进入哪个分区。

直接向分区插入数据也能正常工作:

insert into some_data_y2024 (created_at, payload) values (now(), 'manual #1') returning *;
 id |          created_at          |  payload
----+------------------------------+-----------
 11 | 2024-02-07 15:54:46.95793+00 | manual #1
(1 row)

insert into some_data_y2023 (created_at, payload) values (now() - '1 year'::interval, 'manual #2') returning *;
 id |          created_at           |  payload
----+-------------------------------+-----------
 12 | 2023-02-07 15:54:55.463043+00 | manual #2
(1 row)

有一个问题是,我们不能在 id 列上创建主键或唯一约束:

alter table some_data add primary key (id);
ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL:  PRIMARY KEY constraint on table "some_data" lacks column "created_at" which is part of the partition key.

alter table some_data add unique (id);
ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL:  UNIQUE constraint on table "some_data" lacks column "created_at" which is part of the partition key.

这并不理想,但我们总能直接在分区上添加唯一约束:

ALTER TABLE some_data_old   ADD UNIQUE (id);
ALTER TABLE some_data_y2022 ADD UNIQUE (id);
ALTER TABLE some_data_y2023 ADD UNIQUE (id);
ALTER TABLE some_data_y2024 ADD UNIQUE (id);
ALTER TABLE some_data_y2025 ADD UNIQUE (id);

当然,PostgreSQL 无法知道哪个分区包含具有特定 id 的行,因此它必须扫描所有分区:

explain select * from some_data where id = 7;
                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 Append  (cost=0.00..12.35 rows=5 width=48)
   ->  Seq Scan on some_data_old some_data_1  (cost=0.00..1.05 rows=1 width=48)
         Filter: (id = 7)
   ->  Seq Scan on some_data_y2022 some_data_2  (cost=0.00..1.01 rows=1 width=48)
         Filter: (id = 7)
   ->  Seq Scan on some_data_y2023 some_data_3  (cost=0.00..1.07 rows=1 width=48)
         Filter: (id = 7)
   ->  Seq Scan on some_data_y2024 some_data_4  (cost=0.00..1.01 rows=1 width=48)
         Filter: (id = 7)
   ->  Index Scan using some_data_y2025_id_key1 on some_data_y2025 some_data_5  (cost=0.15..8.17 rows=1 width=48)
         Index Cond: (id = 7)
(11 rows)

但如果我们在 id 上使用索引(一旦有足够多的数据),查询应该会很快。

另一方面,如果你的分区方案是以标识列为基础进行分区的,那么它运行起来会表现不错,并且可以在标识列上创建主键:

CREATE TABLE some_data (
    id       int8 generated always as identity NOT NULL,
    payload  TEXT,
    PRIMARY KEY (id)
) PARTITION BY RANGE ( id ) ;

CREATE TABLE some_data_old PARTITION OF some_data FOR VALUES FROM (MINVALUE) TO (100);
CREATE TABLE some_data_1xx PARTITION OF some_data FOR VALUES FROM (100) TO (200);
CREATE TABLE some_data_2xx PARTITION OF some_data FOR VALUES FROM (200) TO (300);
CREATE TABLE some_data_3xx PARTITION OF some_data FOR VALUES FROM (300) TO (400);
CREATE TABLE some_data_4xx PARTITION OF some_data FOR VALUES FROM (400) TO (500);

然后:

postgres=> \d some_data_2xx
                      Table "public.some_data_2xx"
 Column  |  Type  | Collation | Nullable |           Default
---------+--------+-----------+----------+------------------------------
 id      | bigint |           | not null | generated always as identity
 payload | text   |           |          |
Partition of: some_data FOR VALUES FROM ('200') TO ('300')
Indexes:
    "some_data_2xx_pkey" PRIMARY KEY, btree (id)

需要注意的是,这样的结构会阻止直接向分区插入数据:

insert into some_data_2xx (payload) values ('a');
ERROR:  new row for relation "some_data_2xx" violates partition constraint
DETAIL:  Failing row contains (1, a).

当然,这是因为标识是共享的,新分配的 id 对于 some_data_2xx 来说“太旧”了,应该放入 some_data_old 中。

确实相当不错的特性,以后我们就能够在分区上创建主键了,即使它们与分区键不一致。

感谢社区的所有相关人员。

参考

提交日志:https://git.postgresql.org/pg/commitdiff/699586315704a8268808e3bdba4cb5924a038c49