↑↓ 选择↵ 打开⌫ 切换范围完整搜索

PG.CENTER 连接 PostgreSQL 文档、百科与生态知识。由 Pigsty 维护。

支持中的版本: 当前版本 (18) / 17 / 16 / 15 / 14
开发中的版本: 19 / 20devel
已结束支持的版本: 13 / 12 / 11 / 10 / 9.6 / 9.5 / 9.4 / 9.3 / 9.2 / 9.1 / 9.0 / 8.4 / 8.3 / 8.2 / 8.1
历史版本。 PostgreSQL 13 已结束支持。 2025-11-13. 请参阅 当前版本手册.

5.11. 表分区 #

PostgreSQL 支持基本的表分区。本节介绍为什么以及如何把分区作为数据库设计的一部分来实现。

5.11.1. 概述 #

分区是指把逻辑上的一个大表拆分成一些较小的物理片段。分区可以带来若干好处:

  • 在某些情况下查询性能能够显著提升,特别是当表中频繁访问的行大多集中在一个或少数几个分区时。分区有效地替代了索引树的上层,使得索引中频繁使用的部分更有可能容纳在内存中。

  • 当查询或更新需要访问单个分区中的很大一部分数据时,顺序扫描该分区可以提高性能,优于使用索引并在整个表中执行分散的随机读取。

  • 如果在分区设计中考虑到了这种使用模式,就可以通过添加或移除分区来完成批量加载和删除。使用 DROP TABLE 删除单个分区,或执行 ALTER TABLE DETACH PARTITION 操作,都比批量操作快得多。这些命令还完全避免了批量 DELETE 所导致的 VACUUM 开销。

  • 很少使用的数据可以被迁移到便宜且较慢的存储介质上。

通常,只有表本来会很大时,分区带来的这些好处才值得考虑。一个表在什么情况下会从分区中受益取决于具体应用;一条经验法则是,当表的大小超过数据库服务器的物理内存时,分区通常会带来好处。

PostgreSQL 对下列分区形式提供了内置支持:

范围分区 #

表按照某个键列或一组键列划分为若干“范围”,不同分区的范围彼此不重叠。例如,可以按日期范围进行分区,或者按某个特定业务对象的标识符范围进行分区。每个范围的边界都被理解为左端包含、右端不包含。例如,如果一个分区的范围是从 1 到 10,而下一个分区是从 10 到 20,那么值 10 属于第二个分区,而不是第一个分区。

列表分区 #

通过显式列出每个分区中出现的键值来对表进行分区。

Hash 分区 #

通过为每个分区指定模数和余数来对表进行分区。每个分区保存的都是这样一些行:这些行的分区键 hash 值除以指定模数后,会得到该分区指定的余数。

如果你的应用需要使用上面所列之外的分区形式,可以使用诸如继承和 UNION ALL 视图之类的替代方法。这些方法很灵活,但是却缺少内置声明式分区的一些性能优势。

5.11.2. 声明式分区 #

PostgreSQL 允许你声明将一个表划分为多个分区。被划分的表称为分区表。该声明包括前文所述的分区方法,以及用作分区键的列或表达式列表。

分区表本身是一个“虚拟”表,没有自己的存储空间。相反,存储属于分区,这些分区是与分区表关联的普通表。每个分区存储由其分区边界定义的数据子集。插入到分区表中的所有行都将根据分区键列的值路由到适当的一个分区。如果一行不再满足其原始分区的分区边界,则更新该行的分区键将导致该行移动到另一个分区中。

分区本身可能被定义为分区表,从而导致子分区。尽管所有分区必须具有与其分区父级相同的列,但分区可能具有自己的与其他分区不同的索引、约束以及默认值。创建分区表及分区的更多细节请见 CREATE TABLE。

不可能将常规表转换为分区表,反之亦然。但是,可以把现有的常规表或分区表加入某个分区表作为分区,或者从分区表中移除分区并将其转换为独立表;这可以简化并加快许多维护过程。有关 ATTACH PARTITION 和 DETACH PARTITION 子命令的详细信息,请参见 ALTER TABLE。

分区也可以是外部表,但是需要非常小心,因为这时用户需要确保外部表的内容符合分区规则。还有一些其他限制。更多信息请参见 CREATE FOREIGN TABLE。

5.11.2.1. 示例 #

假定我们正在为一个大型的冰激凌公司构建数据库。该公司每天测量最高温度以及每个区域的冰激凌销售情况。概念上,我们想要一个这样的表:

CREATE TABLE measurement (
    city_id         int not null,
    logdate         date not null,
    peaktemp        int,
    unitsales       int
);

我们知道大部分查询只会访问上周的、上月的或者上季度的数据,因为这个表的主要用途是为管理层准备在线报告。为了减少需要被存放的旧数据量,我们决定只保留最近 3 年的数据。在每个月的开始我们将去除掉最早的那个月的数据。在这种情况下我们可以使用分区技术来帮助我们满足对 measurement 表的所有不同需求。

要在这种情况下使用声明式分区,可采用下面的步骤:

  1. 通过指定 PARTITION BY 子句把 measurement 表创建为分区表,该子句包括分区方法(这个示例中是 RANGE)以及用作分区键的列列表。

    CREATE TABLE measurement (
        city_id         int not null,
        logdate         date not null,
        peaktemp        int,
        unitsales       int
    ) PARTITION BY RANGE (logdate);
    

  2. 创建分区。每个分区的定义必须指定与父表的分区方法和分区键对应的边界。请注意,指定边界如果使得新分区的值与一个或多个现有分区中的值重叠将导致错误。

    分区以普通 PostgreSQL 表(或者可能是外部表)的方式创建。可以为每个分区单独指定表空间和存储参数。

    在我们的示例中,每个分区应该保存一个月的数据,以满足一次删除一个月数据的要求。因此,这些命令可能看起来像:

    CREATE TABLE measurement_y2006m02 PARTITION OF measurement
        FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
    
    CREATE TABLE measurement_y2006m03 PARTITION OF measurement
        FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
    
    ...
    CREATE TABLE measurement_y2007m11 PARTITION OF measurement
        FOR VALUES FROM ('2007-11-01') TO ('2007-12-01');
    
    CREATE TABLE measurement_y2007m12 PARTITION OF measurement
        FOR VALUES FROM ('2007-12-01') TO ('2008-01-01')
        TABLESPACE fasttablespace;
    
    CREATE TABLE measurement_y2008m01 PARTITION OF measurement
        FOR VALUES FROM ('2008-01-01') TO ('2008-02-01')
        WITH (parallel_workers = 4)
        TABLESPACE fasttablespace;
    

    (回想一下,相邻分区可以共享一个边界值,因为范围上限被视为不包含的边界。)

    如果你打算实现子分区,再次在创建分区的命令中指定 PARTITION BY 子句,例如:

    CREATE TABLE measurement_y2006m02 PARTITION OF measurement
        FOR VALUES FROM ('2006-02-01') TO ('2006-03-01')
        PARTITION BY RANGE (peaktemp);
    

    在创建了 measurement_y2006m02 的分区之后,任何插入到 measurement 并被路由到 measurement_y2006m02 的数据(或者直接插入到 measurement_y2006m02、且满足该分区约束的数据)都会再根据 peaktemp 列进一步路由到 measurement_y2006m02 的某个分区。指定的分区键可以与父分区的分区键重叠,不过在指定子分区边界时要注意,它所接受的数据集合必须是该分区自身边界所允许数据集合的一个子集;系统不会尝试检查这一点。

    将没有映射到任何现有分区的数据插入父表将导致错误;必须手动添加适当的分区。

    不需要手动创建描述分区边界条件的表约束。此类约束将自动创建。

  3. 在分区表的键列上创建索引,并创建任何其他需要的索引。(键索引不是严格必需的,但在大多数情况下它是有用的。)会自动在每个分区上创建一个匹配的索引,你稍后创建或附加的任何分区也会有这样的索引。在分区表上声明的索引或唯一约束与分区表一样都是“虚拟的”:实际数据位于各个分区表的子索引中。

    CREATE INDEX ON measurement (logdate);
    

  4. 确保 enable_partition_pruning 配置参数在 postgresql.conf 中没有被禁用。如果被禁用,查询将不会按照想要的方式被优化。

在上面的示例中,我们会每个月创建一个新分区,因此写一个脚本来自动生成所需的 DDL 会更好。

5.11.2.2. 分区维护 #

通常在初始定义分区表时建立的分区并非保持静态不变。移除存放旧数据的分区并且为新数据周期性地增加新分区的需求比比皆是。分区的最大好处之一就是可以通过操纵分区结构来近乎瞬时地执行这类让人头痛的任务,而不是物理地移动大量数据。

移除旧数据最简单的选择是删除掉不再需要的分区:

DROP TABLE measurement_y2006m02;

这可以非常快速地删除数百万行记录,因为它不需要逐行删除。不过要注意,上面的命令需要在父表上获取 ACCESS EXCLUSIVE 锁。

另一个通常更合适的选择是将分区从分区表中分离,但保留它作为独立表的可访问性:

ALTER TABLE measurement DETACH PARTITION measurement_y2006m02;

这样就可以在删除数据前继续对其执行操作。例如,这时通常适合使用 COPY,pg_dump 或类似工具备份数据,也适合将数据聚合为更紧凑的形式、执行其他数据操作或运行报表。

类似地,可以添加新分区来处理新数据。可以像上面创建初始分区那样,在分区表中创建空分区:

CREATE TABLE measurement_y2008m02 PARTITION OF measurement
    FOR VALUES FROM ('2008-02-01') TO ('2008-03-01')
    TABLESPACE fasttablespace;

另一种有时更方便的做法是先在分区结构之外创建新表,之后再将其变为正式分区。这样,新数据在出现在分区表中之前,就可以先被载入、检查和转换。CREATE TABLE ... LIKE 选项有助于避免繁琐地重复父表定义:

CREATE TABLE measurement_y2008m02
  (LIKE measurement INCLUDING DEFAULTS INCLUDING CONSTRAINTS)
  TABLESPACE fasttablespace;

ALTER TABLE measurement_y2008m02 ADD CONSTRAINT y2008m02
   CHECK ( logdate >= DATE '2008-02-01' AND logdate < DATE '2008-03-01' );

\copy measurement_y2008m02 from 'measurement_y2008m02'
-- possibly some other data preparation work

ALTER TABLE measurement ATTACH PARTITION measurement_y2008m02
    FOR VALUES FROM ('2008-02-01') TO ('2008-03-01' );

ATTACH PARTITION 命令要求在分区表上取得 SHARE UPDATE EXCLUSIVE 锁。

在运行 ATTACH PARTITION 命令之前,建议在要附加的表上创建一个匹配预期分区约束的 CHECK 约束,如上所示。这样,系统就能够跳过验证隐式分区约束所需的扫描。没有 CHECK 约束,表将在持有该分区的 ACCESS EXCLUSIVE 锁的情况下进行扫描以验证分区约束。建议在 ATTACH PARTITION 完成后删除现在多余的 CHECK 约束。如果要附加的表本身是一个分区表,则将递归锁定和扫描其每个子分区,直到遇到合适的 CHECK 约束或到达叶子分区为止。

类似地,如果分区表有一个 DEFAULT 分区,建议创建一个 CHECK 约束,排除要附加分区的约束。如果没有这样做,那么将扫描 DEFAULT 分区,以验证它不包含应该位于要附加分区中的记录。此操作将在持有 ACCESS EXCLUSIVE 锁的情况下执行,该锁位于 DEFAULT 分区上。如果 DEFAULT 分区本身是一个分区表,那么将以与附加表相同的方式递归检查其各个分区,如上所述。

如上所述,可以在分区表上创建索引,使其自动应用于整个层次。这很方便,因为不仅已有分区会建立索引,将来创建的分区也会建立索引。一个限制是,创建这种分区索引时不能使用 CONCURRENTLY 限定词。为避免长时间持锁,可以使用 CREATE INDEX ON ONLY 分区表;这样的索引会被标记为无效,并且不会自动应用到分区。可以在各个分区上使用 CONCURRENTLY 分别创建索引,然后将它们附加到父表索引,所用命令为 ALTER INDEX .. ATTACH PARTITION。当所有分区的索引都附加到父索引后,父索引就会自动标记为有效。例如:

CREATE INDEX measurement_usls_idx ON ONLY measurement (unitsales);

CREATE INDEX CONCURRENTLY measurement_usls_200602_idx
    ON measurement_y2006m02 (unitsales);
ALTER INDEX measurement_usls_idx
    ATTACH PARTITION measurement_usls_200602_idx;
...

此技术也可以用于 UNIQUE 和 PRIMARY KEY 约束;索引会在创建约束时隐式创建。例如:

ALTER TABLE ONLY measurement ADD UNIQUE (city_id, logdate);

ALTER TABLE measurement_y2006m02 ADD UNIQUE (city_id, logdate);
ALTER INDEX measurement_city_id_logdate_key
    ATTACH PARTITION measurement_y2006m02_city_id_logdate_key;
...

5.11.2.3. 限制 #

分区表有以下限制:

  • 要在分区表上创建唯一或主键约束,分区键不能包含任何表达式或函数调用,约束的列必须包括所有分区键列。存在此限制是因为构成约束的各个索引只能直接在各自的分区内强制唯一性;因此,分区结构本身必须保证不同分区中没有重复数据。

  • 无法创建跨越整个分区表的排他约束,只能在每个叶子分区上分别设置这种约束。同样,这一限制源于无法强制执行跨分区的约束。

  • 在 INSERT 上的 BEFORE ROW 触发器不能更改哪个分区是新行的最终目的地。

  • 在同一分区树中不允许混合临时和永久关系。因此,如果分区表是永久的,那么它的分区也必须是永久的;同样,如果分区表是临时的,那么它的分区也必须是临时的。在使用临时关系时,分区树的所有成员必须来自同一个会话。

各个分区在内部通过继承链接到其分区表。不过,并非所有通用的继承特性都能用于声明式分区表及其分区,如下所述。值得注意的是,一个分区除了其所属的分区表之外不能再有其他父表,也不能同时从分区表和普通表继承。这意味着分区表及其分区永远不会与普通表共享继承层次。

由于分区表及其分区所组成的分区层次仍然是继承层次,tableoid 以及所有普通继承规则仍然适用,具体见第 5.10 节,但有以下例外:

  • 分区不能具有父级中不存在的列。在使用 CREATE TABLE 创建分区时无法指定列,也无法使用 ALTER TABLE 在事后添加列到分区。只有当表的列与父级完全匹配时,才能使用 ALTER TABLE ... ATTACH PARTITION 将表添加为分区。

  • 分区表的 CHECK 和 NOT NULL 约束始终被所有分区继承。标记为 NO INHERIT 的 CHECK 约束不允许在分区表上创建。如果分区的列上存在与父表中相同的 NOT NULL 约束,则无法删除该约束。

  • 只有在没有分区存在时,才支持在分区表上使用 ONLY 添加或删除约束。一旦存在分区,使用 ONLY 将导致除 UNIQUE 和 PRIMARY KEY 之外的任何约束都会报错。可以在分区上添加约束,并且(如果这些约束在父表中不存在)可以删除它们。

  • 由于分区表本身没有任何数据,因此尝试在分区表上使用 TRUNCATE ONLY 将始终返回错误。

5.11.3. 使用继承的分区 #

虽然内置的声明式分区适用于大多数常见场景,但有时更灵活的方法可能更有用。可以使用表继承来实现分区,它允许使用声明式分区所不支持的一些功能,例如:

  • 对声明式分区来说,分区必须具有和分区表正好相同的列集合,而在表继承中,子表可以有父表中没有出现过的额外列。

  • 表继承允许多继承。

  • 声明式分区只支持范围、列表和 hash 分区,而表继承允许按用户选择的方式对数据进行分区。(不过注意,如果约束排除无法有效剪枝子表,查询性能可能会很差。)

  • 某些操作在使用声明式分区时,比使用表继承时需要更强的锁。例如,从分区表中移除分区需要在父表上取得 ACCESS EXCLUSIVE 锁,而对于普通继承,SHARE UPDATE EXCLUSIVE 锁就足够了。

5.11.3.1. 示例 #

本示例构建与上述声明式分区示例等价的分区结构。请按以下步骤操作:

  1. 创建“根”表,所有“子”表都从它继承。该表不包含数据。除非希望检查约束同样应用于所有子表,否则不要在此表上定义任何检查约束。在它上面定义索引或唯一约束也没有意义。在本示例中,根表就是最初定义的 measurement 表:

    CREATE TABLE measurement (
        city_id         int not null,
        logdate         date not null,
        peaktemp        int,
        unitsales       int
    );
    

  2. 创建若干“子”表,每个都从根表继承。通常,这些表不会在从根表继承的列集合之外增加任何列。和声明式分区一样,这些表就是普通的 PostgreSQL 表(或者外部表)。

    CREATE TABLE measurement_y2006m02 () INHERITS (measurement);
    CREATE TABLE measurement_y2006m03 () INHERITS (measurement);
    ...
    CREATE TABLE measurement_y2007m11 () INHERITS (measurement);
    CREATE TABLE measurement_y2007m12 () INHERITS (measurement);
    CREATE TABLE measurement_y2008m01 () INHERITS (measurement);
    

  3. 为子表增加不重叠的表约束来定义每个分区允许的键值。

    典型示例如下:

    CHECK ( x = 1 )
    CHECK ( county IN ( 'Oxfordshire', 'Buckinghamshire', 'Warwickshire' ))
    CHECK ( outletID >= 100 AND outletID < 200 )
    

    确保约束能够保证不同子表允许的键值之间没有重叠。一个常见的错误是设置如下范围约束:

    CHECK ( outletID BETWEEN 100 AND 200 )
    CHECK ( outletID BETWEEN 200 AND 300 )
    

    这是错误的,因为无法确定键值 200 属于哪个子表。范围应当采用以下方式定义:

    CREATE TABLE measurement_y2006m02 (
        CHECK ( logdate >= DATE '2006-02-01' AND logdate < DATE '2006-03-01' )
    ) INHERITS (measurement);
    
    CREATE TABLE measurement_y2006m03 (
        CHECK ( logdate >= DATE '2006-03-01' AND logdate < DATE '2006-04-01' )
    ) INHERITS (measurement);
    
    ...
    CREATE TABLE measurement_y2007m11 (
        CHECK ( logdate >= DATE '2007-11-01' AND logdate < DATE '2007-12-01' )
    ) INHERITS (measurement);
    
    CREATE TABLE measurement_y2007m12 (
        CHECK ( logdate >= DATE '2007-12-01' AND logdate < DATE '2008-01-01' )
    ) INHERITS (measurement);
    
    CREATE TABLE measurement_y2008m01 (
        CHECK ( logdate >= DATE '2008-01-01' AND logdate < DATE '2008-02-01' )
    ) INHERITS (measurement);
    

  4. 对于每个子表,在键列上创建一个索引,以及任何想要的其他索引。

    CREATE INDEX measurement_y2006m02_logdate ON measurement_y2006m02 (logdate);
    CREATE INDEX measurement_y2006m03_logdate ON measurement_y2006m03 (logdate);
    CREATE INDEX measurement_y2007m11_logdate ON measurement_y2007m11 (logdate);
    CREATE INDEX measurement_y2007m12_logdate ON measurement_y2007m12 (logdate);
    CREATE INDEX measurement_y2008m01_logdate ON measurement_y2008m01 (logdate);
    

  5. 我们希望应用程序能够执行 INSERT INTO measurement ...,并使数据被重定向到适当的子表。可以在根表上附加合适的触发器函数来实现这一点。如果数据只添加到最新的子表,可以使用非常简单的触发器函数:

    CREATE OR REPLACE FUNCTION measurement_insert_trigger()
    RETURNS TRIGGER AS $$
    BEGIN
        INSERT INTO measurement_y2008m01 VALUES (NEW.*);
        RETURN NULL;
    END;
    $$
    LANGUAGE plpgsql;
    

    创建函数后,创建一个调用该触发器函数的触发器:

    CREATE TRIGGER insert_measurement_trigger
        BEFORE INSERT ON measurement
        FOR EACH ROW EXECUTE FUNCTION measurement_insert_trigger();
    

    我们必须每月重新定义触发器函数,使它总是向当前子表插入。不过,触发器定义不需要更新。

    我们可能希望在插入数据时,由服务器自动确定应将该行添加到哪个子表。可以使用更复杂的触发器函数来实现,例如:

    CREATE OR REPLACE FUNCTION measurement_insert_trigger()
    RETURNS TRIGGER AS $$
    BEGIN
        IF ( NEW.logdate >= DATE '2006-02-01' AND
             NEW.logdate < DATE '2006-03-01' ) THEN
            INSERT INTO measurement_y2006m02 VALUES (NEW.*);
        ELSIF ( NEW.logdate >= DATE '2006-03-01' AND
                NEW.logdate < DATE '2006-04-01' ) THEN
            INSERT INTO measurement_y2006m03 VALUES (NEW.*);
        ...
        ELSIF ( NEW.logdate >= DATE '2008-01-01' AND
                NEW.logdate < DATE '2008-02-01' ) THEN
            INSERT INTO measurement_y2008m01 VALUES (NEW.*);
        ELSE
            RAISE EXCEPTION 'Date out of range.  Fix the measurement_insert_trigger() function!';
        END IF;
        RETURN NULL;
    END;
    $$
    LANGUAGE plpgsql;
    

    触发器定义与之前相同。注意,每个 IF 测试必须与其子表的 CHECK 约束完全匹配。

    虽然该函数比单月形式更复杂,但并不需要同样频繁地更新,因为可以在需要某个分支之前就把它加入。

    注意

    在实践中,如果大部分插入都会进入最新的子表,最好先检查它。为了简洁,我们为触发器的检查采用了和本例中其他部分一致的顺序。

    将插入重定向到适当子表的另一种方法,是在根表上设置规则来代替触发器。例如:

    CREATE RULE measurement_insert_y2006m02 AS
    ON INSERT TO measurement WHERE
        ( logdate >= DATE '2006-02-01' AND logdate < DATE '2006-03-01' )
    DO INSTEAD
        INSERT INTO measurement_y2006m02 VALUES (NEW.*);
    ...
    CREATE RULE measurement_insert_y2008m01 AS
    ON INSERT TO measurement WHERE
        ( logdate >= DATE '2008-01-01' AND logdate < DATE '2008-02-01' )
    DO INSTEAD
        INSERT INTO measurement_y2008m01 VALUES (NEW.*);
    

    规则的开销明显高于触发器,但这种开销每个查询只支付一次,而不是每行一次,因此在批量插入时,这种方法可能有优势。不过,在大多数情况下,触发器方法的性能更好。

    注意 COPY 会忽略规则。如果想要使用 COPY 插入数据,则需要拷贝到正确的子表而不是直接放在根表中。COPY 会引发触发器,因此在使用触发器方法时可以正常使用它。

    规则方法的另一个缺点是,如果规则集合无法覆盖插入日期,则没有简单的方法能够强制产生错误,数据将会无声无息地进入到根表中。

  6. 确认 constraint_exclusion 配置参数在 postgresql.conf 中没有被禁用,否则可能会不必要地访问子表。

如我们所见,一个复杂的表层次可能需要大量的 DDL。在上面的示例中,我们可能为每个月创建一个新的子表,因此编写一个脚本来自动生成所需要的 DDL 可能会更好。

5.11.3.2. 继承分区的维护 #

要快速移除旧数据,只需要简单地去掉不再需要的子表:

DROP TABLE measurement_y2006m02;

要从继承层次表中去掉子表,但还是把它当做一个表保留:

ALTER TABLE measurement_y2006m02 NO INHERIT measurement;

要增加一个新子表来处理新数据,可以像上面创建的原始子表那样创建一个空的子表:

CREATE TABLE measurement_y2008m02 (
    CHECK ( logdate >= DATE '2008-02-01' AND logdate < DATE '2008-03-01' )
) INHERITS (measurement);

或者,用户可能想要创建新子表并且在将它加入到表层次之前填充它。这允许在数据对父表上的查询可见之前,先对其进行装载、检查和转换。

CREATE TABLE measurement_y2008m02
  (LIKE measurement INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
ALTER TABLE measurement_y2008m02 ADD CONSTRAINT y2008m02
   CHECK ( logdate >= DATE '2008-02-01' AND logdate < DATE '2008-03-01' );
\copy measurement_y2008m02 from 'measurement_y2008m02'
-- possibly some other data preparation work
ALTER TABLE measurement_y2008m02 INHERIT measurement;

5.11.3.3. 注意事项 #

使用继承实现分区时,有以下注意事项:

  • 没有自动的方法验证所有的 CHECK 约束之间是否互斥。编写代码来产生子表以及创建和修改相关对象比手写命令要更加安全。

  • 索引和外键约束适用于单个表而不是其继承子表,因此它们有一些注意事项需要留意。

  • 这里展示的方案假定行的键列值永不改变,或者至少不会改变到必须把该行移入另一个分区的程度。由于 CHECK 约束的存在,试图那样做的 UPDATE 将会失败。如果需要处理这种情况,可以在子表上放置合适的更新触发器,但这会让整个结构的管理复杂得多。

  • 如果手动执行 VACUUM 或 ANALYZE 命令,不要忘记需要在每个子表上分别运行。例如,以下命令:

    ANALYZE measurement;
    

    只会处理根表。

  • 带有 ON CONFLICT 子句的 INSERT 语句不太可能按照预期工作,因为只有在指定的目标关系而不是其子关系上发生唯一违背时才会采取 ON CONFLICT 动作。

  • 将会需要触发器或者规则将行路由到想要的子表中,除非应用明确地知道分区方案。编写触发器可能会很复杂,并且会比声明式分区在内部执行的元组路由慢很多。

5.11.4. 分区剪枝 #

分区剪枝是一种提升声明式分区表性能的查询优化技术。例如:

SET enable_partition_pruning = on;                 -- the default
SELECT count(*) FROM measurement WHERE logdate >= DATE '2008-01-01';

如果没有分区剪枝,上面的查询将会扫描 measurement 表的每一个分区。如果启用了分区剪枝,规划器会检查每个分区的定义,并证明某个分区不需要扫描,因为它不可能包含满足查询 WHERE 子句的任何行。当规划器能够证明这一点时,就会把该分区从查询计划中排除(剪枝)。

通过使用 EXPLAIN 命令和 enable_partition_pruning 配置参数,可以显示进行了分区剪枝的计划与未剪枝计划的差异。对于这种表结构,典型的未优化计划如下:

SET enable_partition_pruning = off;
EXPLAIN SELECT count(*) FROM measurement WHERE logdate >= DATE '2008-01-01';
                                    QUERY PLAN
-------------------------------------------------------------------​----------------
 Aggregate  (cost=188.76..188.77 rows=1 width=8)
   ->  Append  (cost=0.00..181.05 rows=3085 width=0)
         ->  Seq Scan on measurement_y2006m02  (cost=0.00..33.12 rows=617 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2006m03  (cost=0.00..33.12 rows=617 width=0)
               Filter: (logdate >= '2008-01-01'::date)
...
         ->  Seq Scan on measurement_y2007m11  (cost=0.00..33.12 rows=617 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2007m12  (cost=0.00..33.12 rows=617 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2008m01  (cost=0.00..33.12 rows=617 width=0)
               Filter: (logdate >= '2008-01-01'::date)

某些或全部分区可能使用索引扫描,而不是全表顺序扫描,但这里的重点是,为回答该查询,根本不需要扫描较旧的分区。启用分区剪枝后,可以得到代价明显更低、结果却相同的计划:

SET enable_partition_pruning = on;
EXPLAIN SELECT count(*) FROM measurement WHERE logdate >= DATE '2008-01-01';
                                    QUERY PLAN
-------------------------------------------------------------------​----------------
 Aggregate  (cost=37.75..37.76 rows=1 width=8)
   ->  Seq Scan on measurement_y2008m01  (cost=0.00..33.12 rows=617 width=0)
         Filter: (logdate >= '2008-01-01'::date)

注意,分区剪枝仅由分区键隐式定义的约束所驱动,而不是由索引的存在驱动。因此,没有必要在键列上定义索引。是否需要为一个给定分区创建索引取决于预期的查询扫描该分区时会扫描大部分还是小部分。索引有助于后一种情况,对前一种情况则没有帮助。

分区剪枝不仅可以在给定查询的规划期间执行,也可以在执行期间执行。这一点很有用,因为当子句中包含查询规划时其值尚未知晓的表达式时,就可以剪掉更多分区;例如 PREPARE 语句中的参数、从子查询取得的值,或参数化嵌套循环连接内侧使用的参数值。执行期间的分区剪枝可能在下列任何时刻执行:

  • 在查询计划的初始化阶段。对于在执行初始化阶段就已知的参数值,可以在这里执行分区剪枝。这个阶段被剪掉的分区不会出现在查询的 EXPLAIN 或 EXPLAIN ANALYZE 结果中。通过观察 EXPLAIN 输出中的“Subplans Removed”属性,可以确定这个阶段被移除的分区数。需要注意的是,在此阶段被分区剪枝移除的任何分区,仍会在执行开始时被锁定。

  • 在查询计划的实际执行期间。这里可以使用只有在实际查询执行时才能知道的值执行分区剪枝。这包括来自子查询的值以及来自执行时参数的值(例如来自于参数化嵌套循环连接的参数)。由于在查询执行期间这些参数的值可能会改变多次,所以只要分区剪枝使用到的执行参数发生改变,就会执行一次分区剪枝。要判断分区是否在这个阶段被剪枝,需要仔细地观察 EXPLAIN ANALYZE 输出中的 loops 属性。对应于不同分区的子计划可以具有不同的值,这取决于在执行期间每个分区被剪枝的次数。如果每次都被剪枝,有些分区可能会显示为(never executed)。

可以使用 enable_partition_pruning 设置禁用分区剪枝。

注意

执行时分区剪枝目前只会发生在 Append 和 MergeAppend 节点类型上。ModifyTable 节点类型尚未实现此功能,但这很可能在 PostgreSQL 的未来版本中改变。

5.11.5. 分区和约束排除 #

约束排除是一种与分区剪枝类似的查询优化技术。虽然它主要用于采用传统继承方法实现的分区,但也可以用于其他用途,包括声明式分区。

约束排除以非常类似于分区剪枝的方式工作,不过它使用每个表的 CHECK 约束 — 这也是它得名的原因 — 而分区剪枝使用表的分区边界,分区边界仅存在于声明式分区的情况中。另一点不同之处是约束排除仅在规划时应用,在执行时不会尝试移除分区。

约束排除使用 CHECK 约束,这使它比分区剪枝慢,但有时也可以成为一种优势:除了内部分区边界外,声明式分区表也可以定义约束,因此约束排除可能从查询计划中进一步排除分区。

constraint_exclusion 的默认(也是推荐的)设置不是 on 也不是 off,而是一种被称为 partition 的中间设置,这会导致该技术仅被应用于可能工作在继承分区表上的查询。on 设置导致规划器检查所有查询中的 CHECK 约束,甚至是那些不太可能受益的简单查询。

下列提醒适用于约束排除:

  • 约束排除只在查询规划期间应用;不像分区剪枝那样,它不会在查询执行期间应用。

  • 只有查询的 WHERE 子句包含常量(或者外部提供的参数)时,约束排除才能有效果。例如,针对一个非不可变函数(如 CURRENT_TIMESTAMP)的比较不能被优化,因为规划器不知道该函数的值在运行时会落到哪个子表中。

  • 保持分区约束尽量简单,否则规划器可能无法证明哪些子表不需要访问。如前面的示例所示,对列表分区使用简单的等值条件,对范围分区使用简单的范围测试。一条很好的经验法则是:分区约束应只包含分区列与常量之间使用 B-树可索引操作符的比较,因为只有 B-树可索引列才允许出现在分区键中。

  • 约束排除期间会检查父表的所有子表上的所有约束,因此大量的子表很可能明显地增加查询规划时间。因此,传统的基于继承的分区在子表数量不超过大约一百个时可以很好地工作,不要尝试使用成千上万个子表。

5.11.6. 声明式分区最佳实践 #

应当谨慎选择如何对表进行分区,因为糟糕的设计会对查询规划和执行性能产生负面影响。

最重要的设计决策之一是选择对数据进行分区的列或者列的组合。通常最佳选择是按最常出现在分区表上执行的查询的 WHERE 子句中的列或列集合进行分区。与分区边界约束兼容的 WHERE 子句可用于剪枝不需要的分区。但是,你可能会被迫根据 PRIMARY KEY 或 UNIQUE 约束的要求做出其他决策。在规划分区策略时,删除不需要的数据也是需要考虑的一个因素。整个分区可以相当快地分离出去,因此把分区策略设计成让一次需要删除的所有数据都位于单个分区中,往往是有益的。

选择表应划分成多少个分区,也是一个关键决策。没有足够的分区可能意味着索引仍然太大,数据局部性仍然较差,这可能导致缓存命中率很低。但是,把表分成过多分区也会带来问题。在查询规划和执行期间,分区过多可能意味着规划时间更长、内存消耗更高,下面还会进一步说明。在选择如何分区时,也必须考虑将来可能发生的变化。例如,如果你选择为每个客户建立一个分区,而当前只有少量大客户,那么就应考虑几年后可能变成拥有大量小客户的情形。在这种情况下,最好选择按 HASH 分区并且选择合理数量的分区,而不是尝试按 LIST 进行分区,并希望客户数量的增长不会超出按数据分区的实际范围。

子分区有助于进一步拆分那些预计会比其他分区更大的分区。另一种选择是在分区键中使用多列范围分区。这两种做法都很容易导致分区数量过多,因此应当克制使用。

考虑查询规划和执行期间的分区开销也很重要。查询规划器通常能够较好地处理多达几千个分区的层次结构,前提是典型查询能让规划器剪枝掉除少量分区之外的所有分区。规划器执行分区剪枝后保留更多分区时,规划时间会变长,内存消耗会更高。对于 UPDATE 和 DELETE 命令尤其如此。担心拥有大量分区的另一个原因是,服务器的内存消耗可能会随着时间的推移而显著增加,特别是如果许多会话接触大量分区。这是因为每个分区都需要将其元数据加载到接触它的每个会话的本地内存中。

对于数据仓库类型工作负载,使用比 OLTP 类型工作负载更多的分区可能是合理的。通常,在数据仓库中,查询规划时间相对不那么重要,因为大多数处理时间都花在查询执行期间。对于这两种类型的工作负载,尽早做出正确的决策非常重要,因为重新分区大量数据可能会非常缓慢。模拟预期工作负载通常有利于优化分区策略。永远不要只是假设更多的分区比更少的分区更好,反之亦然。

报告文档问题

阅读 上游文档. 反馈更正前请先核对 当前版本手册.