了解PostgreSQL 9中的分区

PostgreSQL 10于大约一年前于2017年10月上旬发布。

最有趣的新“功能”之一是无条件声明式分区。 但是,如果您不急于升级到10k怎么办? 例如,亚马逊并不着急,仅在2018年2月的最后几天才引入了PostgreSQL 10 支持

然后,可以通过继承对旧的良好分区进行救援。 我是出租车公司财务部门的软件架构师,因此所有示例都将以一种或另一种方式与旅行有关(我们将把钱问题再留一遍)。

自2015年我们刚开始重写财务系统以来,当我刚加入公司时,就没有关于声明式分区的讨论了。 因此,迄今为止,已经成功地使用了以下所述的技术。

撰写本文的最初原因是,我遇到的PostgreSQL中的大多数分区示例都是非常基本的。 这是一张表,这是我们正在查看的一列,甚至可能预先知道其中的值。 似乎一切都很简单。 但是现实生活会自行调整。

在本例中,我们将表分为两列,其中一列包含旅行日期。 我们将考虑这种情况。

让我们从表的外观开始:

create table rides ( id bigserial not null primary key, tenant_id varchar(20) not null, ride_id varchar(36) not null, created_at timestamp with time zone not null, metadata jsonb -- Probably more columns and indexes coming here ); 

对于每个租户,该表每月包含数百万行。 幸运的是,租户之间的数据永远不会相交,最困难的请求是在一两个月的时间里提出的。

对于那些尚未研究PostgreSQL分区如何工作的人(幸运的是,Oracle,您好!),我将简要描述该过程。

PostgreSQL为此依赖两个“功能”:继承表,表继承和检查条件的能力。

让我们从继承开始。 使用INHERITS关键字,表明创建的表继承了继承表的所有字段。 这也会在两个表之间建立关系:从父表进行查询,我们还从子表中获取所有数据。

检查条件通过确保数据不相交来补充图片。 因此,PostgreSQL优化器可以依靠查询中的数据来切断部分子表。

这种方法的第一个陷阱似乎很明显:任何请求都必须包含tenant_id。 但是,如果您不时提醒自己,那么您自己迟早会编写自定义SQL,而您忘记在其中指定此tenant_id。 结果,将扫描所有分区和无法正常运行的数据库。

但是回到我们想要实现的目标。 在应用程序级别,我想要透明化-我们始终写入同一张表,并且数据库已经选择了将数据放置在何处。

为此,我们使用以下存储过程:

 CREATE OR REPLACE FUNCTION insert_row() RETURNS TRIGGER AS $BODY$ DECLARE partition_env TEXT; partition_date TIMESTAMP; partition_name TEXT; sql TEXT; BEGIN -- construct partition name partition_env := lower(NEW.tenant_id); partition_date := date_trunc('month', NEW.created_at AT TIME ZONE 'UTC'); partition_name := format('%s_%s_%s', TG_TABLE_NAME, partition_env, to_char(partition_date, 'YYYY_MM')); -- create partition, if necessary IF NOT EXISTS(SELECT relname FROM pg_class WHERE relname = partition_name) THEN PERFORM create_new_partition(TG_TABLE_NAME, NEW.tenant_id, partition_date, partition_name); END IF; select format('INSERT INTO %s values ($1.*)', partition_name) into sql; -- Propagate insert EXECUTE sql USING NEW; RETURN NEW; -- RETURN NULL; if no ORM END; $BODY$ LANGUAGE plpgsql; 

您首先要注意的是TG_TABLE_NAME的使用。 由于这是一个触发器,PostgreSQL为我们填充了很多我们可以访问的变量。 完整列表可以在这里找到

在我们的例子中,我们想要获取触发器在其上工作的表的父级的名称。 在我们的情况下,它将是游乐设施。 我们在几种微服务中使用了类似的方法,并且可以在不做任何更改的情况下实际转移这一部分。

如果我们要调用不返回任何内容的函数,则PERFORM很有用。 通常,在示例中,他们尝试将所有逻辑放在一个函数中,但是我们尝试要小心。

USING NEW表示在此查询中,我们使用尝试添加的字符串中的值。

$1.*将扩展所有换行值。 实际上,这可以翻译成NEW.* 。 转换为NEW.ID, NEW.TENANT_ID, …

以下过程(用PERFORM调用)将创建一个新分区(如果尚不存在)。 每个租户每个周期都会发生一次。

 CREATE OR REPLACE FUNCTION create_new_partition(parent_table_name text, env text, partition_date timestamp, partition_name text) RETURNS VOID AS $BODY$ DECLARE sql text; BEGIN -- Notifying RAISE NOTICE 'A new % partition will be created: %', parent_table_name, partition_name; select format('CREATE TABLE IF NOT EXISTS %s (CHECK ( tenant_id = ''%s'' AND created_at AT TIME ZONE ''UTC'' > ''%s'' AND created_at AT TIME ZONE ''UTC'' <= ''%s'')) INHERITS (%I)', partition_name, env, partition_date, partition_date + interval '1 month', parent_table_name) into sql; -- New table, inherited from a master one EXECUTE sql; PERFORM index_partition(partition_name); END; $BODY$ LANGUAGE plpgsql; 

如前所述,我们使用INHERITS创建类似于父表的表,并使用CHECK确定应将哪些数据INHERITS该表。

RAISE NOTICE仅将字符串输出到控制台。 如果现在从psql运行INSERT ,则可以查看是否已创建分区。

我们有一个新问题。 INHERITS不继承索引。 为此,我们有两种解决方案:

使用继承创建索引:
使用CREATE TABLE LIKE ,然后使用ALTER TABLE INHERITS

或按程序创建索引:

 CREATE OR REPLACE FUNCTION index_partition(partition_name text) RETURNS VOID AS $BODY$ BEGIN -- Ensure we have all the necessary indices in this partition; EXECUTE 'CREATE INDEX IF NOT EXISTS ' || partition_name || '_tenant_timezone_idx ON ' || partition_name || ' (tenant_id, timezone(''UTC''::text, created_at))'; -- More indexes here... END; $BODY$ LANGUAGE plpgsql; 

重要的是不要忘记为子表建立索引,因为即使在分区之后,每个子表也将具有数百万行。 在本例中,不需要在父级上的索引,因为父级将始终为空。

最后,我们创建一个触发器,该触发器将在创建新行时被调用:

 CREATE TRIGGER before_insert_row_trigger BEFORE INSERT ON rides FOR EACH ROW EXECUTE PROCEDURE insert_row(); 

还有一个很少注意的微妙之处。 分区最好在数据永不更改的列中。 在我们的例子中,这行得通:行程永远不会更改tenant_id和created_at。 如果不是这样,则会出现问题,因为PostreSQL不会将部分数据返回给我们。 然后,我们向他保证所有数据均有效。

有几种解决方案(除了显而易见的解决方案-请勿更改要分区的数据):

我们始终在应用程序级别执行DELETE+INSERT ,而不是UPDATE
我们在UPDATE上添加了另一个触发器,该触发器会将数据传输到正确的分区

另一个值得考虑的警告是如何正确索引包含日期的列。 如果我们在查询中使用AT TIME ZONE,则一定不要忘记这实际上是一个函数调用。 这意味着我们的索引应该基于函数。 我忘了 结果,基座再次因负载而死。

值得考虑的最后一个方面是分区如何与各种ORM框架进行交互,无论是Ruby中的ActiveRecord还是Go中的GORM。

PostgreSQL中的分区依赖于父表将始终为空的事实。 如果不使用ORM,则可以安全地返回第一个存储过程,并更改RETURN NEW; 在RETURN NULL;。 这样,父表中的行就不会被添加,这正是我们想要的。

但是事实是,大多数ORM都将RETURNING子句与INSERT一起使用。 如果我们从触发器返回NULL,则ORM会恐慌,认为尚未添加该行。 它已添加,但未在ORM查找的位置添加。

有几种解决方法:

  • 不要将ORM用于INSERT
  • 修补ORM(有时建议在ActiveRecord的情况下使用)
  • 添加另一个触发器,这将从父级删除该行。

最后一个选项是不可取的,因为对于每个操作,我们将执行三个操作。 但是,有时这是不可避免的,因为我们将单独考虑:

 CREATE OR REPLACE FUNCTION delete_parent_row() RETURNS TRIGGER AS $BODY$ DECLARE BEGIN delete from only rides where id = NEW.ID; RETURN null; END; $BODY$ LANGUAGE plpgsql; 

 CREATE TRIGGER after_insert_row_trigger AFTER INSERT ON rides FOR EACH ROW EXECUTE PROCEDURE delete_parent_row(); 

我们需要做的最后一件事是测试我们的解决方案。 为此,我们生成一定数量的行:

 DO $script$ DECLARE year_start_epoch bigint := extract(epoch from '20170101'::timestamptz at time zone 'UTC'); delta bigint := extract(epoch from '20171231 23:59:59'::timestamptz at time zone 'UTC') - year_start_epoch; tenant varchar; tenants varchar[] := array['tenant_a', 'tenant_b', 'tenant_c', 'tenant_d']; BEGIN FOREACH tenant IN ARRAY tenants LOOP FOR i IN 1..100000 LOOP insert into rides (tenant_id, created_at, ride_id) values (tenant, to_timestamp(random() * delta + year_start_epoch) at time zone 'UTC', i); END LOOP; END LOOP; END $script$; 

让我们看看数据库的行为:

 explain select * from rides where tenant_id = 'tenant_a' and created_at AT TIME ZONE 'UTC' > '20171102' and created_at AT TIME ZONE 'UTC' <= '20171103'; 

如果一切顺利,我们应该看到以下结果:

  Append (cost=0.00..4803.76 rows=4 width=196) -> Seq Scan on rides (cost=0.00..4795.46 rows=3 width=196) Filter: (((created_at)::timestamp without time zone > '2017-11-02 00:00:00'::timestamp without time zone) AND ((created_at)::timestamp without time zone <= '2017-11-03 00:00:00'::timestamp without time zone) AND ((tenant_id)::text = 'tenant_a'::text)) -> Index Scan using rides_tenant_a_2017_11_tenant_timezone_idx on rides_tenant_a_2017_11 (cost=0.28..8.30 rows=1 width=196) Index Cond: (((tenant_id)::text = 'tenant_a'::text) AND ((created_at)::timestamp without time zone > '2017-11-02 00:00:00'::timestamp without time zone) AND ((created_at)::timestamp without time zone <= '2017-11-03 00:00:00'::timestamp without time zone)) (5 rows) 

尽管每个租户都有成千上万的行,但我们仅从所需的数据切片中进行选择。 成功!

我希望这篇文章对于那些还不了解分区及其在PostgreSQL中如何实现的人来说是有趣的。 但是,对于那些不再是该主题的人来说,他们学到了一些有趣的技巧。

UPD:
正如bigtrot正确观察到的那样,如果关闭了CONSTRAINT_EXCLUSION设置,那么所有这些街头魔术都将不起作用。

您可以使用以下命令进行验证
 show CONSTRAINT_EXCLUSION 


该设置具有三个值:on,off和partition

如果您突然想将CHECK CONSTRAINTS不仅用于分区,还用于数据规范化,则分区配置将是最佳选择。

Source: https://habr.com/ru/post/zh-CN422753/


All Articles