5.12. 表分区 #
PostgreSQL 支持基本的表分区。本节介绍为什么以及如何把分区作为数据库设计的一部分来实现。
5.12.1. 概述 #
分区是指把逻辑上的一个大表拆分成一些较小的物理片段。分区可以带来若干好处:
在某些情况下查询性能能够显著提升,特别是当表中频繁访问的行大多集中在一个或少数几个分区时。分区有效地替代了索引树的上层,使得索引中频繁使用的部分更有可能容纳在内存中。
当查询或更新需要访问单个分区中的很大一部分数据时,顺序扫描该分区可以提高性能,优于使用索引并在整个表中执行分散的随机读取。
如果在分区设计中考虑到了这种使用模式,就可以通过添加或移除分区来完成批量加载和删除。使用
DROP TABLE删除单个分区,或执行ALTER TABLE DETACH PARTITION操作,都比批量操作快得多。这些命令还完全避免了批量DELETE所导致的VACUUM开销。很少使用的数据可以被迁移到便宜且较慢的存储介质上。
通常,只有表本来会很大时,分区带来的这些好处才值得考虑。一个表在什么情况下会从分区中受益取决于具体应用;一条经验法则是,当表的大小超过数据库服务器的物理内存时,分区通常会带来好处。
PostgreSQL 对下列分区形式提供了内置支持:
如果你的应用需要使用上面所列之外的分区形式,可以使用诸如继承和 UNION ALL 视图之类的替代方法。这些方法很灵活,但是却缺少内置声明式分区的一些性能优势。
5.12.2. 声明式分区 #
PostgreSQL 允许你声明将一个表划分为多个分区。被划分的表称为分区表。该声明包括前文所述的分区方法,以及用作分区键的列或表达式列表。
分区表本身是一个“虚拟”表,没有自己的存储空间。相反,存储属于分区,这些分区是与分区表关联的普通表。每个分区存储由其分区边界定义的数据子集。插入到分区表中的所有行都将根据分区键列的值路由到适当的一个分区。如果一行不再满足其原始分区的分区边界,则更新该行的分区键将导致该行移动到另一个分区中。
分区本身可能被定义为分区表,从而导致子分区。尽管所有分区必须具有与其分区父级相同的列,但分区可能具有自己的与其他分区不同的索引、约束以及默认值。创建分区表及分区的更多细节请见 CREATE TABLE。
不可能将常规表转换为分区表,反之亦然。但是,可以把现有的常规表或分区表加入某个分区表作为分区,或者从分区表中移除分区并将其转换为独立表;这可以简化并加快许多维护过程。有关 ATTACH PARTITION 和 DETACH PARTITION 子命令的详细信息,请参见 ALTER TABLE。
分区也可以是外部表,但是需要非常小心,因为这时用户需要确保外部表的内容符合分区规则。还有一些其他限制。更多信息请参见 CREATE FOREIGN TABLE。
5.12.2.1. 示例 #
假定我们正在为一个大型的冰激凌公司构建数据库。该公司每天测量最高温度以及每个区域的冰激凌销售情况。概念上,我们想要一个这样的表:
CREATE TABLE measurement (
city_id int not null,
logdate date not null,
peaktemp int,
unitsales int
);
我们知道大部分查询只会访问上周的、上月的或者上季度的数据,因为这个表的主要用途是为管理层准备在线报告。为了减少需要被存放的旧数据量,我们决定只保留最近 3 年的数据。在每个月的开始我们将去除掉最早的那个月的数据。在这种情况下我们可以使用分区技术来帮助我们满足对 measurement 表的所有不同需求。
要在这种情况下使用声明式分区,可采用下面的步骤:
通过指定
PARTITION BY子句把measurement表创建为分区表,该子句包括分区方法(这个示例中是RANGE)以及用作分区键的列列表。CREATE TABLE measurement ( city_id int not null, logdate date not null, peaktemp int, unitsales int ) PARTITION BY RANGE (logdate);创建分区。每个分区的定义必须指定与父表的分区方法和分区键对应的边界。请注意,指定边界如果使得新分区的值与一个或多个现有分区中的值重叠将导致错误。
分区以普通 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的某个分区。指定的分区键可以与父分区的分区键重叠,不过在指定子分区边界时要注意,它所接受的数据集合必须是该分区自身边界所允许数据集合的一个子集;系统不会尝试检查这一点。将没有映射到任何现有分区的数据插入父表将导致错误;必须手动添加适当的分区。
不需要手动创建描述分区边界条件的表约束。此类约束将自动创建。
在分区表的键列上创建索引,并创建任何其他需要的索引。(键索引不是严格必需的,但在大多数情况下它是有用的。)会自动在每个分区上创建一个匹配的索引,你稍后创建或附加的任何分区也会有这样的索引。在分区表上声明的索引或唯一约束与分区表一样都是“虚拟的”:实际数据位于各个分区表的子索引中。
CREATE INDEX ON measurement (logdate);
确保 enable_partition_pruning 配置参数在
postgresql.conf中没有被禁用。如果被禁用,查询将不会按照想要的方式被优化。
在上面的示例中,我们会每个月创建一个新分区,因此写一个脚本来自动生成所需的 DDL 会更好。
5.12.2.2. 分区维护 #
通常在初始定义分区表时建立的分区并非保持静态不变。移除存放旧数据的分区并且为新数据周期性地增加新分区的需求比比皆是。分区的最大好处之一就是可以通过操纵分区结构来近乎瞬时地执行这类让人头痛的任务,而不是物理地移动大量数据。
移除旧数据最简单的选择是删除掉不再需要的分区:
DROP TABLE measurement_y2006m02;
这可以非常快速地删除数百万行记录,因为它不需要逐行删除。不过要注意,上面的命令需要在父表上获取 ACCESS EXCLUSIVE 锁。
另一种通常更好的选项是把分区从分区表中移除,但仍可将它作为一个独立的表访问。有两种形式:
ALTER TABLE measurement DETACH PARTITION measurement_y2006m02; ALTER TABLE measurement DETACH PARTITION measurement_y2006m02 CONCURRENTLY;
这允许我们在最终丢弃数据之前,先对其执行进一步操作。例如,这通常是使用 COPY、pg_dump 或类似工具备份数据的好时机;也可能适合先把数据汇总成更小的格式、执行其他数据处理,或运行报表。命令的第一种形式需要在父表上持有 ACCESS EXCLUSIVE 锁。第二种形式额外添加了 CONCURRENTLY 限定符,使分离分区操作只需要在父表上持有 SHARE UPDATE EXCLUSIVE 锁;有关限制的详细信息,请参见 ALTER TABLE ... DETACH PARTITION。
类似地,我们可以添加一个新的分区来处理新数据。我们可以在分区表中创建一个空分区,就像上面创建原始分区一样:
CREATE TABLE measurement_y2008m02 PARTITION OF measurement
FOR VALUES FROM ('2008-02-01') TO ('2008-03-01')
TABLESPACE fasttablespace;
作为一种替代方案,有时更方便的是在分区结构之外创建新表,并稍后将其附加为分区。这允许在新数据出现在分区表中之前加载、检查和转换新数据。此外,ATTACH PARTITION 操作只需要在分区表上持有 SHARE UPDATE EXCLUSIVE 锁,而不像 CREATE TABLE ... PARTITION OF 那样需要 ACCESS EXCLUSIVE 锁,因此对分区表上的并发操作更友好;更多细节请参见 ALTER TABLE ... ATTACH PARTITION。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 命令之前,建议在要附加的表上创建一个匹配预期分区约束的 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.12.2.3. 限制 #
对分区表应用以下限制:
要在分区表上创建唯一或主键约束,分区键不能包含任何表达式或函数调用,约束的列必须包括所有分区键列。存在此限制是因为构成约束的各个索引只能直接在各自的分区内强制唯一性;因此,分区结构本身必须保证不同分区中没有重复数据。
类似地,排他约束也必须包含所有分区键列。此外,该约束必须按相等性比较这些列(而不能例如使用
&&)。同样,这一限制源于无法强制执行跨分区的约束。不过,该约束可以包含不属于分区键的其他列,并且你可以按自己喜欢的方式使用任意操作符来比较这些列。在
INSERT上的BEFORE ROW触发器不能更改哪个分区是新行的最终目的地。在同一分区树中不允许混合临时和永久关系。因此,如果分区表是永久的,那么它的分区也必须是永久的;同样,如果分区表是临时的,那么它的分区也必须是临时的。在使用临时关系时,分区树的所有成员必须来自同一个会话。
各个分区在内部通过继承链接到其分区表。不过,并非所有通用的继承特性都能用于声明式分区表及其分区,如下所述。值得注意的是,一个分区除了其所属的分区表之外不能再有其他父表,也不能同时从分区表和普通表继承。这意味着分区表及其分区永远不会与普通表共享继承层次。
由于由分区表和其分区组成的分区层次仍然是一个继承层次结构,tableoid 和所有继承规则都适用,如在第 5.11 节中描述的那样,但有一些例外:
分区不能具有父级中不存在的列。在使用
CREATE TABLE创建分区时无法指定列,也无法使用ALTER TABLE在事后添加列到分区。只有当表的列与父级完全匹配时,才能使用ALTER TABLE ... ATTACH PARTITION将表添加为分区。分区表的
CHECK和NOT NULL约束始终被所有分区继承。标记为NO INHERIT的CHECK约束不允许在分区表上创建。如果分区的列上存在与父表中相同的NOT NULL约束,则无法删除该约束。只有在没有分区存在时,才支持在分区表上使用
ONLY添加或删除约束。一旦存在分区,使用ONLY将导致除UNIQUE和PRIMARY KEY之外的任何约束都会报错。可以在分区上添加约束,并且(如果这些约束在父表中不存在)可以删除它们。由于分区表本身没有任何数据,因此尝试在分区表上使用
TRUNCATEONLY将始终返回错误。
5.12.3. 使用继承的分区 #
虽然内置的声明式分区适合于大部分常见的用例,但还是有一些场景需要更加灵活的方法。分区可以使用表继承来实现,这能够带来一些声明式分区不支持的特性,例如:
对声明式分区来说,分区必须具有和分区表正好相同的列集合,而在表继承中,子表可以有父表中没有出现过的额外列。
表继承允许多继承。
声明式分区只支持范围、列表和 hash 分区,而表继承允许按用户选择的方式对数据进行分区。(不过注意,如果约束排除无法有效剪枝子表,查询性能可能会很差。)
5.12.3.1. 示例 #
这个示例构建了一个与上面的声明式分区示例等效的分区结构。使用以下步骤:
创建“根”表,所有的“子”表都将从它继承。这个表将不包含数据。不要在这个表上定义任何检查约束,除非想让它们应用到所有的子表上。同样,在这个表上定义索引或者唯一约束也没有意义。对于我们的示例来说,根表是最初定义的
measurement表:CREATE TABLE measurement ( city_id int not null, logdate date not null, peaktemp int, unitsales int );创建若干“子”表,每个都从根表继承。通常,这些表不会在从根表继承的列集合之外增加任何列。和声明式分区一样,这些表就是普通的 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);
为子表增加不重叠的表约束来定义每个分区允许的键值。
典型的示例是:
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);对于每个子表,在键列上创建一个索引,以及任何想要的其他索引。
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);
我们希望我们的应用能够使用
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会引发触发器,因此在使用触发器方法时可以正常使用它。规则方法的另一个缺点是,如果规则集合无法覆盖插入日期,则没有简单的方法能够强制产生错误,数据将会无声无息地进入到根表中。
确认 constraint_exclusion 配置参数在
postgresql.conf中没有被禁用,否则可能会不必要地访问子表。
如我们所见,一个复杂的表层次可能需要大量的 DDL。在上面的示例中,我们可能为每个月创建一个新的子表,因此编写一个脚本来自动生成所需要的 DDL 可能会更好。
5.12.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.12.3.3. 注意事项 #
下面的注意事项适用于使用继承实现的分区:
没有自动的方法验证所有的
CHECK约束之间是否互斥。编写代码来产生子表以及创建和修改相关对象比手写命令要更加安全。索引和外键约束适用于单个表而不是其继承子表,因此它们有一些注意事项需要留意。
这里展示的方案假定行的键列值永不改变,或者至少不会改变到必须把该行移入另一个分区的程度。由于
CHECK约束的存在,试图那样做的UPDATE将会失败。如果需要处理这种情况,可以在子表上放置合适的更新触发器,但这会让整个结构的管理复杂得多。如果手动执行
VACUUM或ANALYZE命令,不要忘记需要在每个子表上分别运行。例如,以下命令:ANALYZE measurement;
只会处理根表。
带有
ON CONFLICT子句的INSERT语句不太可能按照预期工作,因为只有在指定的目标关系而不是其子关系上发生唯一违背时才会采取ON CONFLICT动作。将会需要触发器或者规则将行路由到想要的子表中,除非应用明确地知道分区方案。编写触发器可能会很复杂,并且会比声明式分区在内部执行的元组路由慢很多。
5.12.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 设置禁用分区剪枝。
5.12.5. 分区和约束排除 #
约束排除是一种与分区剪枝类似的查询优化技术。虽然它主要用于采用传统继承方法实现的分区,但也可以用于其他用途,包括声明式分区。
约束排除以非常类似于分区剪枝的方式工作,不过它使用每个表的 CHECK 约束 — 这也是它得名的原因 — 而分区剪枝使用表的分区边界,分区边界仅存在于声明式分区的情况中。另一点不同之处是约束排除仅在规划时应用,在执行时不会尝试移除分区。
约束排除使用 CHECK 约束,这使它比分区剪枝慢,但有时也可以成为一种优势:除了内部分区边界外,声明式分区表也可以定义约束,因此约束排除可能从查询计划中进一步排除分区。
constraint_exclusion 的默认(也是推荐的)设置不是 on 也不是 off,而是一种被称为 partition 的中间设置,这会导致该技术仅被应用于可能工作在继承分区表上的查询。on 设置导致规划器检查所有查询中的 CHECK 约束,甚至是那些不太可能受益的简单查询。
下列提醒适用于约束排除:
约束排除只在查询规划期间应用;不像分区剪枝那样,它不会在查询执行期间应用。
只有查询的
WHERE子句包含常量(或者外部提供的参数)时,约束排除才能有效果。例如,针对一个非不可变函数(如CURRENT_TIMESTAMP)的比较不能被优化,因为规划器不知道该函数的值在运行时会落到哪个子表中。保持分区约束尽量简单,否则规划器可能无法证明哪些子表不需要访问。如前面的示例所示,对列表分区使用简单的等值条件,对范围分区使用简单的范围测试。一条很好的经验法则是:分区约束应只包含分区列与常量之间使用 B-树可索引操作符的比较,因为只有 B-树可索引列才允许出现在分区键中。
约束排除期间会检查父表的所有子表上的所有约束,因此大量的子表很可能明显地增加查询规划时间。因此,传统的基于继承的分区在子表数量不超过大约一百个时可以很好地工作,不要尝试使用成千上万个子表。
5.12.6. 声明式分区最佳实践 #
应当谨慎选择如何对表进行分区,因为糟糕的设计会对查询规划和执行性能产生负面影响。
最重要的设计决策之一是选择对数据进行分区的列或者列的组合。通常最佳选择是按最常出现在分区表上执行的查询的 WHERE 子句中的列或列集合进行分区。与分区边界约束兼容的 WHERE 子句可用于剪枝不需要的分区。但是,你可能会被迫根据 PRIMARY KEY 或 UNIQUE 约束的要求做出其他决策。在规划分区策略时,删除不需要的数据也是需要考虑的一个因素。整个分区可以相当快地分离出去,因此把分区策略设计成让一次需要删除的所有数据都位于单个分区中,往往是有益的。
选择表应划分成多少个分区,也是一个关键决策。没有足够的分区可能意味着索引仍然太大,数据局部性仍然较差,这可能导致缓存命中率很低。但是,把表分成过多分区也会带来问题。在查询规划和执行期间,分区过多可能意味着规划时间更长、内存消耗更高,下面还会进一步说明。在选择如何分区时,也必须考虑将来可能发生的变化。例如,如果你选择为每个客户建立一个分区,而当前只有少量大客户,那么就应考虑几年后可能变成拥有大量小客户的情形。在这种情况下,最好选择按 HASH 分区并且选择合理数量的分区,而不是尝试按 LIST 进行分区,并希望客户数量的增长不会超出按数据分区的实际范围。
子分区有助于进一步拆分那些预计会比其他分区更大的分区。另一种选择是在分区键中使用多列范围分区。这两种做法都很容易导致分区数量过多,因此应当克制使用。
考虑查询规划和执行期间的分区开销也很重要。查询规划器通常能够较好地处理多达几千个分区的层次结构,前提是典型查询能让规划器剪枝掉除少量分区之外的所有分区。规划器执行分区剪枝后保留更多分区时,规划时间会变长,内存消耗会更高。担心拥有大量分区的另一个原因是,服务器的内存消耗可能会随着时间的推移而显著增加,特别是如果许多会话接触大量分区。这是因为每个分区都需要将其元数据加载到接触它的每个会话的本地内存中。
对于数据仓库类型工作负载,使用比 OLTP 类型工作负载更多的分区可能是合理的。通常,在数据仓库中,查询规划时间相对不那么重要,因为大多数处理时间都花在查询执行期间。对于这两种类型的工作负载,尽早做出正确的决策非常重要,因为重新分区大量数据可能会非常缓慢。模拟预期工作负载通常有利于优化分区策略。永远不要只是假设更多的分区比更少的分区更好,反之亦然。
报告文档问题
阅读 上游文档. 通过 PostgreSQL 文档反馈表单.