Compreendendo partições no PostgreSQL 9

O PostgreSQL 10 foi lançado no início de outubro de 2017, quase um ano atrás.

Um dos novos "recursos" mais interessantes é o particionamento incondicionalmente declarativo. Mas e se você não tiver pressa para atualizar para 10k? A Amazon, por exemplo, não tem pressa e introduziu o suporte ao PostgreSQL 10 apenas nos últimos dias de fevereiro de 2018.

Então, o bom e velho particionamento por herança vem em socorro. Como sou arquiteto de software do departamento financeiro de uma empresa de táxi, todos os exemplos serão relacionados a viajar de uma maneira ou de outra (deixaremos os problemas financeiros em outro momento).

Desde que começamos a reescrever nosso sistema financeiro em 2015, quando eu entrei na empresa, não se falava em particionamento declarativo. Até hoje, a técnica descrita abaixo foi usada com sucesso.

O motivo original para escrever este artigo foi que a maioria dos exemplos de particionamento no PostgreSQL que encontrei eram muito básicos. Aqui está uma tabela, aqui está uma coluna que estamos vendo, e talvez até saibamos antecipadamente quais valores estão nela. Parece que tudo é simples. Mas a vida real faz seus próprios ajustes.

No nosso caso, particionamos tabelas em duas colunas, uma das quais contém datas de viagem. É este o caso que consideraremos.

Vamos começar com a aparência da nossa tabela:

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

Para cada inquilino, a tabela contém milhões de linhas por mês. Felizmente, os dados entre os inquilinos nunca se cruzam e as solicitações mais difíceis são feitas no corte de um ou dois meses.

Para aqueles que não se aprofundaram no funcionamento das partições do PostgreSQL (sorte da Oracle, olá!), Descreverei brevemente o processo.

O PostgreSQL conta com dois de seus "recursos" para isso: a capacidade de herdar tabelas, a herança de tabelas e as condições verificadas.

Vamos começar com a herança. Usando a palavra-chave INHERITS, indicamos que a tabela que criamos herda todos os campos da tabela herdada. Isso também cria um relacionamento entre as duas tabelas: fazendo uma consulta do pai, também obtemos todos os dados dos filhos.

As condições verificadas complementam a imagem, garantindo que os dados não se cruzem. Assim, o otimizador do PostgreSQL pode cortar parte das tabelas filho, confiando nos dados da consulta.

A primeira armadilha dessa abordagem parece bastante óbvia: qualquer solicitação deve conter tenant_id. E, no entanto, se você não se lembrar disso constantemente, mais cedo ou mais tarde, você mesmo escreverá um SQL personalizado no qual se esquecerá de especificar esse tenant_id. Como resultado, uma varredura de todas as partições e um banco de dados que não está funcionando.

Mas voltando ao que queremos alcançar. No nível do aplicativo, eu gostaria de transparência - sempre escrevemos na mesma tabela e o banco de dados já escolhe onde exatamente colocar esses dados.

Para fazer isso, usamos o seguinte procedimento armazenado:

 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; 

A primeira coisa que você deve prestar atenção é o uso do TG_TABLE_NAME. Como isso é um gatilho, o PostgreSQL preenche algumas variáveis ​​para nós que podemos acessar. A lista completa pode ser encontrada aqui .

No nosso caso, queremos obter o nome do pai da tabela na qual o gatilho funcionou. No nosso caso, serão passeios. Utilizamos uma abordagem semelhante em vários microsserviços, e essa parte pode ser transferida praticamente sem alterações.

PERFORM é útil se quisermos chamar uma função que não retorna nada. Normalmente, em exemplos, eles tentam colocar toda a lógica em uma função, mas tentamos ter cuidado.

USING NEW indica que nesta consulta usamos os valores da string que tentamos adicionar.

$1.* expandirá todos os valores da nova linha. De fato, isso pode ser traduzido para NEW.* . O que se traduz em NEW.ID, NEW.TENANT_ID, …

O procedimento a seguir, que chamamos de PERFORM , criará uma nova partição, se ela ainda não existir. Isso acontecerá uma vez por período para cada inquilino.

 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; 

Como descrito anteriormente, usamos INHERITS para criar uma tabela semelhante ao pai e CHECK para determinar quais dados devem ir para lá.

RAISE NOTICE apenas imprime uma string no console. Se agora rodarmos INSERT partir do psql, podemos ver se a partição foi criada.

Temos um novo problema. INHERITS não herda índices. Para fazer isso, temos duas soluções:

Crie índices usando herança:
Use CREATE TABLE LIKE e ALTER TABLE INHERITS

Ou crie índices proceduralmente:

 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; 

É muito importante não esquecer a indexação de tabelas filho, porque mesmo após o particionamento, cada uma delas terá milhões de linhas. Os índices no pai não são necessários no nosso caso, pois o pai sempre permanecerá vazio.

Por fim, criamos um gatilho que será chamado quando uma nova linha for criada:

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

Há outra sutileza na qual raramente prestamos atenção. O particionamento é melhor em colunas em que os dados nunca são alterados. No nosso caso, isso funciona: a viagem nunca muda tenant_id e created_at. O problema que surgir, se não for o caso, é que o PostreSQL não retornará parte dos dados para nós. Prometemos-lhe, então, VERIFICAR que todos os dados são válidos.

Existem várias soluções (exceto o óbvio - não modifique os dados pelos quais estamos particionando):

Em vez de UPDATE sempre executamos DELETE+INSERT no nível do aplicativo
Adicionamos mais um gatilho em UPDATE que transferirá dados para a partição correta

Outra ressalva que vale a pena considerar é como indexar corretamente as colunas que contêm datas. Se usarmos AT TIME ZONE em consultas, não devemos esquecer que, na verdade, é uma chamada de função. E isso significa que nosso índice deve ser baseado em funções. Eu esqueci Como resultado, a base está morta novamente da carga.

O último aspecto que vale a pena considerar é como as partições interagem com várias estruturas ORM, seja ActiveRecord no Ruby ou GORM in Go.

Partições no PostgreSQL dependem do fato de que a tabela pai sempre estará vazia. Se você não usar o ORM, poderá retornar com segurança ao primeiro procedimento armazenado e alterar RETURN NEW; em RETURN NULL; Em seguida, a linha na tabela pai simplesmente não será adicionada, exatamente o que queremos.

Mas o fato é que a maioria dos ORMs usa a cláusula RETURNING com INSERT. Se retornarmos NULL do nosso gatilho, o ORM entrará em pânico, acreditando que a linha não foi adicionada. É adicionado, mas não para onde o ORM está olhando.

Existem várias maneiras de contornar isso:

  • Não use ORM para INSERTs
  • Patch ORM (que às vezes é recomendado no caso do ActiveRecord)
  • Adicione outro gatilho, que removerá a linha do pai.

A última opção é indesejável, porque para cada operação executaremos três. No entanto, às vezes é inevitável, porque o consideraremos separadamente:

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

A última coisa que precisamos fazer é testar nossa solução. Para fazer isso, geramos um certo número de linhas:

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

E vamos ver como o banco de dados se comporta:

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

Se tudo desse certo, veríamos o seguinte resultado:

  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) 

Apesar de cada inquilino ter centenas de milhares de linhas, apenas selecionamos a fatia de dados desejada. Sucesso!

Espero que este artigo seja interessante para aqueles que ainda não estão familiarizados com o que é o particionamento e como ele é implementado no PostgreSQL. Mas aqueles para quem esse tópico não é mais novo, aprenderam alguns truques interessantes.

UPD:
Como o bigtrot observou corretamente, toda essa mágica de rua não funcionará se a configuração CONSTRAINT_EXCLUSION estiver desativada.

Você pode verificar isso usando o comando
 show CONSTRAINT_EXCLUSION 


A configuração possui três valores: ativado, desativado e particionado

A configuração da partição é mais ideal se você de repente usar o CHECK CONSTRAINTS, não apenas para partições, mas também para normalização de dados.

Source: https://habr.com/ru/post/pt422753/


All Articles