选择 打开 改范围 完整检索页

pgsql.cc 提供对 postgresql.org 官网内容的中文翻译,由 Pigsty 团队维护。

受支持版本: 当前版本 (18) / 17 / 16 / 15 / 14
测试与开发版本: 19 / devel
不受支持的版本: 13 / 12 / 11 / 10 / 9.6 / 9.5 / 9.4 / 9.3 / 9.2 / 9.1 / 9.0
历史版本PostgreSQL 9.3 已于 2018 年 11 月结束社区维护,本页译文保留供仍在使用旧版本的读者参考。新系统请看当前版本

5.9. 分区 #

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

5.9.1. 概述 #

分区是指将逻辑上的一个大表拆分成较小的物理部分。分区可以带来以下好处:

  • 在某些情况下查询性能能够显著提升,特别是当那些访问压力大的行在一个分区或者少数几个分区时。分区替代了索引的前导列,从而减小索引大小,使索引中被大量使用的部分更有可能容纳在内存中。

  • 当查询或更新访问单个分区的很大一部分时,可以通过使用该分区的顺序扫描来提高性能,而不是使用索引,这将需要分散在整个表中的随机访问读取。

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

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

通常只有表本身非常大时,这些好处才值得考虑。表从多大开始受益于分区,取决于具体应用;不过,一条经验法则是表的大小应超过数据库服务器的物理内存。

目前,PostgreSQL通过表继承来支持分区。每个分区都必须作为单个父表的子表创建。父表本身通常为空;它的存在只是为了表示整个数据集。在尝试设置分区之前,你应当先熟悉继承(见第 5.8 节)。

PostgreSQL中可以实现以下几种分区形式:

范围分区

按照某个键列或一组列定义的范围对表进行分区,不同分区所分配的值范围互不重叠。例如,可以按日期范围,或特定业务对象的标识符范围分区。

列表分区

通过明确列出每个分区包含哪些键值来对表进行分区。

5.9.2. 实现分区 #

要设置一个分区表,可以按以下步骤操作:

  1. 创建所有分区都将继承的表。

    这个表将不包含数据。不要在这个表上定义任何检查约束,除非想让它们等同地应用到所有分区上。在这个表上定义索引或者唯一约束也没有意义。

  2. 创建若干表,每个都从主表继承。通常,这些表不会在从主表继承的列集合之外增加任何列。

    我们将这些子表称为分区,尽管它们在各方面都是普通的PostgreSQL表。

  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 属于哪个分区。

    注意,范围分区和列表分区在语法上没有区别;这些术语只是描述性的。

  4. 对于每个分区,在键列上创建索引,以及其他所需的索引。(键索引不是严格必需的,但在大多数场景中是有帮助的。如果希望键值唯一,则应始终为每个分区创建唯一或主键约束。)

  5. 可以选择定义一个触发器或规则,把插入到主表的数据重定向到适当的分区。

  6. 确认constraint_exclusion配置参数在postgresql.conf中没有被禁用,否则查询将无法按预期得到优化。

例如,假设正在为一家大型冰淇淋公司构建数据库。该公司每天测量最高温度,并统计各个地区的冰淇淋销量。从概念上看,我们需要如下表:

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

我们知道,大多数查询只会访问最近一周、一个月或一个季度的数据,因为该表主要用于为管理层生成在线报表。为减少需要存储的旧数据量,决定只保留最近 3 年的数据,并在每月月初删除最早一个月的数据。

在这种情况下,可以利用分区来帮助满足测量表的各种不同需求。按照上面概述的步骤,可以这样设置分区:

  1. 主表就是measurement表,完全按上面的方式声明。

  2. 然后,为每个活动月份创建一个分区:

    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);
    

    每个分区本身都是完整的表,但它们从measurement表继承其定义。

    这解决了我们的一个问题:删除旧数据。每个月,我们只需对最旧的子表执行DROP TABLE,并为新月份的数据创建一个新的子表。

  3. 我们必须提供互不重叠的表约束。与其像上面那样只创建分区表,表创建脚本其实应该是:

     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 PROCEDURE 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约束完全一致。

    当该函数比单月形式更加复杂时,并不需要频繁地更新它,因为可以在需要的时候提前加入分支。

    注意

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

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

5.9.3. 分区管理 #

通常,最初定义表时建立的分区集合并不会保持不变。常见的需求是删除旧数据分区,并定期为新数据添加新分区。分区最重要的优势之一,恰恰在于它允许通过操纵分区结构,而不是物理地移动大量数据,来近乎瞬时地完成这种原本非常痛苦的任务。

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

DROP TABLE measurement_y2006m02;

由于不必逐条删除每条记录,这可以非常快速地删除数百万条记录。

另一种通常更可取的选择,是将该分区从分区表中移除,但保留它作为独立表的可访问性:

ALTER TABLE measurement_y2006m02 NO INHERIT measurement;

这样在删除数据之前,还可以对其执行进一步的操作。例如,这常常是使用COPYpg_dump或类似工具备份数据的好时机。也可能是把数据聚合成更小格式、执行其他数据处理或运行报告的好时机。

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

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.9.4. 分区和约束排除 #

约束排除是一种查询优化技术,可提高按上述方式定义的分区表的性能。例如:

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

如果没有约束排除,上述查询会扫描measurement表的每个分区。启用约束排除后,规划器会检查每个分区的约束,并尝试证明该分区不需要扫描,因为它不可能包含满足查询WHERE子句的行。如果规划器能够证明这一点,就会将该分区排除在查询计划之外。

可以使用EXPLAIN命令,显示启用constraint_exclusion时的计划与禁用时的计划之间的差异。对于这种表结构,典型的未优化计划如下:

SET constraint_exclusion = off;
EXPLAIN SELECT count(*) FROM measurement WHERE logdate >= DATE '2008-01-01';

                                          QUERY PLAN
-----------------------------------------------------------------------------------------------
 Aggregate  (cost=158.66..158.68 rows=1 width=0)
   ->  Append  (cost=0.00..151.88 rows=2715 width=0)
         ->  Seq Scan on measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2006m02 measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2006m03 measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)
...
         ->  Seq Scan on measurement_y2007m12 measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2008m01 measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)

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

SET constraint_exclusion = on;
EXPLAIN SELECT count(*) FROM measurement WHERE logdate >= DATE '2008-01-01';
                                          QUERY PLAN
-----------------------------------------------------------------------------------------------
 Aggregate  (cost=63.47..63.48 rows=1 width=0)
   ->  Append  (cost=0.00..60.75 rows=1086 width=0)
         ->  Seq Scan on measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)
         ->  Seq Scan on measurement_y2008m01 measurement  (cost=0.00..30.38 rows=543 width=0)
               Filter: (logdate >= '2008-01-01'::date)

注意,约束排除只由CHECK约束驱动,与是否存在索引无关。因此,不必在键列上定义索引。是否需要为某个分区创建索引,取决于预期查询扫描该分区时通常会扫描其中的大部分,还是仅扫描一小部分。后一种情况下索引会有帮助,前一种情况下则不会。

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

5.9.5. 备选分区方法 #

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

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会引发触发器,因此在使用触发器方法时可以正常使用它。

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

也可以使用UNION ALL视图来安排分区,而不是使用表继承。例如:

CREATE VIEW measurement AS
          SELECT * FROM measurement_y2006m02
UNION ALL SELECT * FROM measurement_y2006m03
...
UNION ALL SELECT * FROM measurement_y2007m11
UNION ALL SELECT * FROM measurement_y2007m12
UNION ALL SELECT * FROM measurement_y2008m01;

但是,重新创建视图的需要给数据集分区的添加和删除增加了一个额外步骤。实践中,与使用继承相比,这种方法没有什么可取之处。

5.9.6. 注意事项 #

分区表有以下注意事项:

  • 没有自动方式验证所有CHECK约束是否互斥。与手工逐个编写相比,通过代码生成分区并创建和/或修改相关对象更安全。

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

  • 如果手动执行VACUUMANALYZE命令,不要忘记需要在每个分区上分别运行。例如,以下命令:

    ANALYZE measurement;
    

    只会处理根表。

约束排除有以下注意事项:

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

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

  • 约束排除期间会检查根表的所有分区上的全部约束,因此大量分区很可能显著增加查询规划时间。使用这些技术进行分区,大概在不超过一百个分区时效果较好;不要尝试使用成千上万个分区。

提交更正

译文有误、术语不当或页面显示问题,请到译文仓库 pgsty/pgdoc 报告译文问题。 英文原文本身的问题,请在当前版本的对应页面向上游反馈;上游不再修订已结束维护的版本。