Particionento

Particionamento de Tabelas no postgres – Quando?

ParticionamentoJá faz tempo que eu quero falar sobre particionamento de tabelas no PostgreSQL. Quando você lida com bases realmente grandes, o particionamento de tabelas é quase um item obrigatório. Lidar com uma tabela com mais de 500GB é um verdadeiro inferno.

Mas afinal, o que é o particionamento de tabelas em banco de dados. O particionamento consiste em dividir uma tabela grande em pedaços menores, de forma que juntando todos os pedaços você forme a tabela inteira. A aplicação jamais vê as partições individuais de uma tabela, o processo deve sempre ser transparente para a aplicação. O que muda é como os dados são armazenados fisicamente na base. Mesmo como todas seus pedaços físicos, logicamente a tabela não muda. O otimizador do banco de dados percebe quando uma consulta é realizada numa tabela particionada e automaticamente desvia a consulta para a partição específica. Se a consulta precisa manipular registros que estão em mais de uma partição, o banco de dados faz a consulta em todas as partições necessárias para satisfazer o critério da consulta.

Vantagens

Então vejamos os reais motivos que fazem alguém são passar pela empreitada de particionar uma tabela no PostgreSQL:

  • Desempenho em leitura:
    • Se você particionar uma tabela, o tamanho do índice será menor, logo a profundidade da árvore será menor e você terá um acesso mais rápido.
    • Se você tem uma partição mais utilizada que as demais, como no caso em que apenas uma partição concentra as informações mais recentes, a chance do índice caber na memória é maior;
    • Se você tem uma partição mais utilizada que as demais, pode criar um TABLE SPACE que utilize os discos/raid/storage mais rápidos para esta partição deixando as partições menos utilizadas (com dados históricos por exemplo) em discos mais lentos;
  • Desempenho em INSERT e UPDATE: Se você tiver meios físicos de gravação independentes, como dois RAIDs distintos, você pode fazer com que várias gravações concorrentes se dividam em várias partições utilizando diferentes TABLE SPACES em diferentes discos. É um caso bem extremo que depende muito da arquitetura de discos e utiliza um tipo específico de particionamento, conhecido como particionamento por HASH.
  • Expurgo de dados. Fazer DELETE em uma quantidade grande de registros custa muito caro. Muitos sistemas tem rotinas de expurgo realizadas mensalmente, semanalmente e até diariamente. Poder fazer um simples TRUNCATE TABLE, um DROP TABLE ou um ALTER TABLE NO INHERIT, é muito mais simples e não fragmenta a tabela.
  • Rotinas de manutenção. Rodar um VACCUM, um ANALYZE, um REINDEX ou um CLUSTER numa partição, é muito mais rápido do que fazer isso em toda a tabela. Às vezes um REINDEX numa única tabela pode levar varias horas. Se uma tabela é mais utilizada do que outra, você pode rodar suas rotinas de manutenção apenas nesta tabela;

Avaliando o tamanho da tabela

Ok, você acha que está sofrendo na sua base de dados e que algumas tabelas enormes vão se beneficiar do particionamento. Primeiro você deve avaliar se realmente vale à pena. Tabelas pequenas não vão se beneficiar do particionamento. Ao contrário, você está inserindo uma camada a mais de abstração que tem um custo no processamento. A documentação fala que vale à pena particionar tabelas cujo tamanho superam a quantidade de memória física do servidor. Claro, se você tem apenas 2GB de memória no servidor e tem uma tabela com 4GB, talvez seja mais fácil comprar mais memória, que tal 8GB? Gosto de pensar em tabelas que tem pelo menos seus 10GB. Claro, se você prevê que a tabela vai crescer muito no futuro, é bom ir se preparando…

Escolhendo uma chave

Outra questão importante é olhar com cuidado para a modelagem da tabela que você quer particionar. Você deve escolher um critério de particionamento. Você escolhe em geral uma coluna da tabela e define o critério. Por exemplo, uma tabela com vendas de uma cadeia de lojas. Você pode dividir a tabela por loja física. Pode também dividir a tabela por setor da loja ou por tipo de produto. Pode dividir por N critérios, mas o mais comum que você vai encontrar é dividir por Datas ou épocas, por exemplo:

  • Tabela: pedidos
    • Partição 1: pedidos_2008
    • Partição 2: pedidos_2009
    • Partição 3: pedidos_2010
    • Partição 4: pedidos_2011
    • Partição 5: pedidos_2012
    • Partição 6: pedidos_2013

É importante que cada partição tenha um nome que seja composto pelo nome da tabela original e a chave que você está utilizando para particionar a tabela. Não é obrigatório, mas torna a sua vida mais simples. Você poderia particionar por mês também:

  • Tabela: pedidos
    • Partição 1: pedidos_a2012_m10
    • Partição 2: pedidos_a2012_m11
    • Partição 3: pedidos_a2012_m12
    • Partição 4: pedidos_a2013_m01
    • Partição 5: pedidos_a2013_m02

É importante ter em mente que no PostgreSQL e em outros SGDBs, ter um número muito grande de partições não é saudável, pois o mecanismo que decide em qual partição que a consulta vai ser realizada começa a consumir muito recurso. Digamos que ter até umas 50 partições numa única tabela seja tolerável. Mas também não faz sentido criar partições que sejam muito grandes. Pensar em dividir uma tabela em pelo menos 3 ou 4 partições é algo que faz sentido para mim. Qualquer coisa acima de 20 ou 30 partições deve ser pensado com muito cuidado.

Mas para escolher o seu critério, você deve ter como selecionar ele de forma simples a partir de uma coluna na sua tabela, ex:

  • Por data:
    • data_pedido BETWEEN DATE’2011-01-01′ AND DATE’2011-12-31′
    • data_pedido BETWEEN DATE’2012-01-01′ AND DATE’2012-12-31′
    • data_pedido BETWEEN DATE’2013-01-01′ AND DATE’2013-12-31′
  • Por localização geográfica:
    • uf_pedido = ‘SP’
    • uf_pedido IN (‘AM’, ‘AC’, ‘RO’, ‘PA)
    • uf_pedido IN (‘RS’, ‘PR’, ‘SC’)
  • Por loja:
    • cod_loja = 1
    • cod_loja = 2
    • cod_loja IN (3, 4, 5)
  • Por ano fiscal:
    • cod_ano_fis = 2011
    • cod_ano_fis = 2012
    • cod_ano_fis = 2013

Como você pode ver, você precisa ter um campo como data_pedido, uf_pedido, cod_loja, cod_ano_fis para particionar a sua tabela. Quando o particionamento não é uma opção, é uma necessidade e você não tem um campo bom para usar num critério de particionamento, você acabará tendo de remodelar sua tabela para que o particionamento seja viável. Eu sei, isso não é tão fácil como parece. É por isso que aplicações de grande porte precisam de um planejamento a mais, para se pensar em como serão as coisas quando a aplicação estiver lidando com um grande volume de dados. Uma alternativa é sempre comprar um novo sistema…

Imagine a seginte situação:

Neste caso, para cada pedido, teremos vários detalhes do pedido. Se a tabela ‘pedido_detalhe’ precisar ser particionada por data, você terá um problema, pois a coluna data não está nesta tabela. É o momento onde você precisa de uma licença poética em relação a normalização para tornar o particionamento possível.

Cenário ideal: PK composta

Um único adendo final: sistemas que trabalham com grande volume de registros em geral usam uma PK composta. Pelo menos nas suas tabelas centrais. Imagine por exemplo que cada aluno numa universidade receba um número como “2013-00112″, ou seja, o ano do primeiro ingresso e uma sequência. Assim, o ano pode ser uma boa chave para particionar. Sistemas bancários utilizam um número único para cada dia útil. Assim cada operação bancária tem uma chave composta novamente com o número do dia fiscal e o número da operação. Sistemas grandes como os bancários tem sérios problemas de performance, logo uma modelagem mais cuidadosa como esta traz benefícios imediatos.

Já sistemas modelados olhando pelo prisma de um ORM com zilhões de chaves artificiais… bom, estes tem sérios problemas.  É praticamente impossível particionar uma base que utiliza chaves artificiais nas suas maiores tabelas. É claro que se você tiver um sistema com grande volume de dados modelado assim, o particionamento será o menor dos seus problemas.

O problema das FKs

Bom, fora isso, você terá uma nova preocupação que são as chaves estrangeiras (vamos aqui abreviar como FK, de foreign key). O problema é como o PostgreSQL implementa o recurso de particionamento de tabelas. Ele utiliza a herança de tabelas, onde a tabela PAI é a tabela original, sem nenhum registro nela e as tabelas FILHAS são as partições contendo os registros. Então o que ocorre é que quando você faz uma consulta na tabela mãe… o PostgreSQL olha para a cláusula WHERE da sua consulta e decide em qual partição a consulta será feita.

Imagine que particionamos a tabela PEDIDOS citada aqui. Temos a tabela mãe PEDIDO e as tabelas filhas PEDIDO_2011, PEDIDO_2012 e PEDIDO_2013. Agora imagine que não particionamos a tabela PEDIDO_DETALHE. Inserimos um registro na tabela PEDIDO e depois vamos inserir um na tabela DETALHE_PEDIDO:

Como você pode ver, fizemos um INSERT na tabela PEDIDO, mas na verdade o INSERT foi realizado na tabela PEDIDO_2011, uma vez que a DATA_PEDIDO é do ano de 2011. Note o detalhe do retorno do INSERT: ”

Detalhe importante. Isto significa que você pediu um INSERT na tabela PEDIDO, mas não houve retorno de nenhum registro nela, por isso o valor zero exibido como retorno. Isso vai nos causar problemas com algumas ferramentas de ORM como o Hibernate que espera um retorno diferente. Isso é facilmente contornável desativando a checagem dos códigos de retorno, mas é mais um ponto de atenção.

Enfim o erro:

Aqui vemos que mesmo inserindo um registro que sabemos que respeita a FK, pois existe o registro com o ID_PEDIDO = 1, a mensagem de erro teima em aparecer. O problema é que a FK aponta para a tabela PEDIDO e não para PEDIDO_2011 onde o registro se encontra. Desta forma, não é possível utilizar a FK da forma que estamos acostumados.

Desta forma temos uma limitação clara aqui: uma tabela não particionada não pode ter uma FK apontando para uma tabela particionada. Isto não costuma ser um enorme problema, pois você verá que as tabelas mais volumosas que são escolhidas para particionamento são em geral tabelas que estão na ponta da modelagem, portanto não é comum ver uma FK apontando para ela. No nosso caso em particular, uma alternativa seria particionar não apenas a tabela PEDIDO, mas também a tabela PEDIDO_DETALHE. Se as partições das duas tabelas utilizarem a mesma chave, ou seja a coluna DATA_PEDIDO, então cada partição de PEDIDO_DETALHE pode ter uma FK apontando para a partição PEDIDO correspondente. Claro, teremos de ter o campo DATA_PEDIDO em ambas as tabelas para que isso funcione…

Ajustando seus SELECTs

Aqui, para separar as partições utilizamos algumas constraints:

Vamos carregar aqui alguns dados antes de mais nada:

Detalhe importante. Note que aqui rodamos um ANALYZE para que o otimizador tenha as informações mais atualizadas sobre os registros que acabamos de carregar. Note que rodamos o comando individualmente em cada partição. Se você rodasse apenas um ‘ANALYZE pedido;’ isto não provocaria efeito algum, pois não há registros nesta tabela. Então, tarefas de manutenção como ANALYZE, VACUUM e CLUSTER devem ser executados diretamente sobre as partições ou tabelas filhas.

Bom, vamos agora rodar algumas consultas. O que desejamos é consultar dados de uma partição específica, sem ter que dizer em qual partição está a informação. Ou seja, fazemos uma consulta na tabela PEDIDO e o PostgreSQL deverá buscar os registros apenas nas partições certas. Então vamos buscar informações apenas de 2011. Para saber o que o PostgreSQL está fazendo internamente, vamos sempre usar o EXPLAIN:

Note que houve um ‘Seq Scan’ na tabela PEDIDO_2011 e outro na tabela PEDIDO. A passagem na tabela PEDIDO, praticamente não consome tempo, veja que o custo é zero aqui. As partições PEDIDO_2012 e PEDIDO_2013 não fazem parte do plano de execução. No entanto se mudarmos um pouco a consulta pegando novamente os registros de 2011, isso não vai mais funcionar:

Note que a consulta agora passa por todas as partições o custo final subiu consideravelmente. Isto significa que devemos usar a mesma função que utilizamos no CHECK CONSTRAINT que criamos. Vejamos agora o que ocorre se fazemos a mesma consulta retornando apenas um único registro de 2011:

Bom, e agora… um custo tão alto para trazer uma única linha. Bom, deveríamos criar um índice para as nossas partições na coluna DATA_PEDIDO:

Melhorou muito, de 14878,01 para 24,89. .. Mas continuamos consultado todas as partições. Temos de fazer isso funcionar direito então faremos assim:

Agora sim, um “Index Scan” na PEDIDO_2011 e nada das demais partições. O custo agora caiu para algo razoável, 8,3. A consulta ficou meio estranha, mas é a forma de garantir que o mecanismo de ‘constraint exclusion’ funcione.

Migração de partições

Se você alterar o conteúdo de uma linha de uma tabela particionada, deve ter um cuidado adicional. O valor não pode obrigar a linha a mudar de partição. No nosso exemplo, um registro não pode ter a data mudando de ano. Se isso ocorrer o registro ficará perdido, pois o PostgreSQL não irá retirar a linha de uma partição e inserir ela na partição correta. Se este casso ocorrer, mas for raro, você pode criar um gatilho de UPDATE na tabela particionada para fazer esta migração de partições, mas é um trabalho a mais a ser feito.

Resumindo

Particionar é algo realmente importante. Em bases com tabelas muito grandes é um recurso indispensável. Mas é um recurso que não sai barato. A implementação do PostgreSQL tem restrições consideráveis:

  • O custo do particionamento não se justifica para tabelas pequenas;
  • A tabela que será particionada precisa ter um campo que possa ser a chave do particionamento;
  • Nenhuma tabela não particionada pode ter uma FK apontada para uma tabela particionada;
  • O número de partições de uma tabela não deve ser muito grande. Em geral um número de até 50 partições é algo tolerável. Aumentar este número aumenta o custo das operações sobre a tabela particionada;
  • Ao realizar operações de INSERT numa tabela particionada, o código de retorno não reflete o número correto de linhas inseridas, retorna sempre zero;
  • Ao realizar operações de UPDATE numa tabela particionada, o valor alterado não deve alterar a partição em que o registro deve pertencer. Se isto for inevitável, então um gatilho deverá fazer esta migração.
  • Ao realizar um SELECT você deve utilizar na cláusula WHERE uma restrição idêntica a utilizada na restrição que divide as partições. Esta restrição também não pode estar parametrizada ou ser enviada como parâmetro de um PREPARED STATEMENT.

A evolução

Veja que o particionamento ainda tem grandes problemas, mas já foi pior. A partir da versão 8.1 onde foi lançado todas as versões posteriores incluíram algum tipo de melhoria no particionamento.

  • 9.2: 
    • Permite que restrições do tipo CHECK sejam marcadas como NO INHERIT, fazendo com que estas restrições só se apliquem a tabela pai e não sejam herdadas nas tabelas filhas. Útil para bloquear INSERT na tabela pai.
    • Permite que uma restrição do tipo CHECK seja marcada como NOT VALID;
    • Corrige o CREATE TABLE (LIKE … ) para evitar conflito de nomes em comentários de índices;
  • 9.1:
    • Melhoria na otimização de consultas de tabelas particionadas que utilizam ORDER BY, LIMIT e MIM/MAX;
  • 9.0
    • Melhoria na velocidade de seleção de tabelas filhas;
    • ANALYZE automático em tabelas filhas;
    • Quando uma consulta é realizada numa tabela mãe, não checa novamente as permissões nas tabelas filhas, conforme padrão SQL;
    • Permite herdar comentários e parâmetros de storage da tabela pai ao rodar um CREATE TABLE pedido_2013 (LIKE pedido INCLUDING INDEXES INCLUDING STORAGE). Cria também uma atalho para incluir todos os tipos de atributos: comentários, índices, restrições, parâmetros de storage e valores padrão: CREATE TABLE pedido_2013 (LIKE pedido INCLUIDNG ALL)
  • 8.4
    • Força as tabelas filhas a herdarem as restrições CHECK das tabelas mães, conforme padrão SQL;
    • Criação valor ‘partition’ para o parâmetro CONTRAINT_EXCLUSION. Assim a busca em tabelas filhas se torna padrão em tabelas particionadas e em consultas UNION ALL;
  • 8.3
    • Corrige a herança de restrições NULL;
    • Permite herdar índices da tabela pai ao rodar um CREATE TABLE pedido_2013 (LIKE pedido INCLUDING INDEXES)
    • Melhoria no desempenho de consultas com exclusão de muitas partições;
  • 8.2
    • Criação da cláusula NO INHERIT no comando ALTER TABLE;
    • Permite herdar restrições da tabela pai ao rodar um CREATE TABLE pedido_2013 (LIKE pedido INCLUDING CONSTRAINTS)
    • Permite a exclusão de partições em operações de UPDATE e DELETE;
  • 8.1
    • Criação do parâmetro CONSTRAINT_EXCLUSION e a possibilidade de particionamento a partir da herança entre tabelas.

Alternativas

Alternativas ao particionamento de tabelas:

  • Índices parciais. Quem usa não esquece mais. Resolve problemas difíceis de forma elegante. Nada mais é do que a possibilidade de se colocar uma cláusula WHERE na definição de um índice. Você pode achar que não é a coisa mais elegante do mundo, mas funciona muito bem e é rápido e simples de implementar. Se você precisa de índices mais seletivos e enxutos, essa é uma boa opção.
  • Visões materializadas. Criar tabelas resumo com informações condensadas é a melhor forma de lidar com relatórios monstruosos. Você atualiza uma vez por dia a tabela resumo e simplifica uma série de consultas complexas em consultas bem mais simples e rápidas. Claro que isso envolve em trabalhar com informações mais desatualizadas. O mundo não é fácil e em muitos casos é preciso negociar os requisitos, particularmente de relatórios mais pesados. Infelizmente o PostgreSQL também não tem um mecanismo pronto para criar visões materializadas. Mas com um pouco de PL/pgSQL você pode criar algo que funciona muito bem. Senão, pode buscar algum módulo pronto no pgFoundry ou no PGXN.
  • Trocar DELETEs por INSERTs. Se você tem que excluir um grande volume de registros, mas não pode criar uma partição para simplesmente trunca-la, então uma alternativa ao desastroso DELETE é o INSERT.

Na sequência

3 comentários sobre “Particionamento de Tabelas no postgres – Quando?

  1. Fábio, parabéns pelo artigo, me esclareceu muitas dúvidas sobre particionamento e será muito útil para minha implementação. Curti sua palestra hoje no FISL. Parabéns, Abraço.

Dúvidas, sugestões, críticas, comentários e cervejas são bem vindos!!!