Melhor maneira de preencher uma nova coluna em uma tabela grande?

33

Temos uma tabela de 2,2 GB no Postgres com 7.801.611 linhas. Estamos adicionando uma coluna uuid / guid a ela e estou me perguntando qual é a melhor maneira de preencher essa coluna (pois queremos adicionar uma NOT NULLrestrição a ela).

Se eu entendi o Postgres corretamente, uma atualização é tecnicamente uma exclusão e inserção, portanto isso basicamente está reconstruindo toda a tabela de 2,2 gb. Também temos um escravo em funcionamento, para que não queremos que isso fique para trás.

Existe alguma maneira melhor do que escrever um script que o preencha lentamente ao longo do tempo?

Collin Peters
fonte
2
Você já executou uma ALTER TABLE .. ADD COLUMN ...parte ou deve ser respondida também?
ypercubeᵀᴹ
Ainda não executou nenhuma modificação na tabela, apenas no estágio de planejamento. Eu fiz isso antes adicionando a coluna, preenchendo-a e adicionando a restrição ou índice. No entanto, esta tabela é significativamente maior e eu estou preocupado com a carga, fecho, replicação, etc ...
Collin Peters

Respostas:

45

Depende muito dos detalhes de suas necessidades.

Se você tiver espaço livre suficiente (pelo menos 110% de pg_size_pretty((pg_total_relation_size(tbl))) no disco e puder permitir um bloqueio de compartilhamento por algum tempo e um bloqueio exclusivo por um período muito curto , crie uma nova tabela incluindo a uuidcoluna usando CREATE TABLE AS. Por quê?

O código abaixo usa uma função do uuid-ossmódulo adicional .

  • Bloqueie a tabela contra alterações simultâneas no SHAREmodo (ainda permitindo leituras simultâneas). Tentativas de gravar na tabela aguardarão e eventualmente falharão. Ver abaixo.

  • Copie a tabela inteira enquanto preenche a nova coluna rapidamente - possivelmente solicitando linhas de maneira favorável enquanto estiver nela.
    Se você deseja reordenar linhas, defina o valor work_memmais alto possível (apenas para a sua sessão, e não globalmente).

  • Em seguida, adicione restrições, chaves estrangeiras, índices, gatilhos etc. à nova tabela. Ao atualizar grandes partes de uma tabela, é muito mais rápido criar índices do zero do que adicionar linhas iterativamente.

  • Quando a nova tabela estiver pronta, descarte a antiga e renomeie a nova para substituí-la. Somente esta última etapa adquire um bloqueio exclusivo na tabela antiga para o restante da transação - que deve ser muito curta agora.
    Também requer que você exclua qualquer objeto, dependendo do tipo de tabela (visualizações, funções usando o tipo de tabela na assinatura, ...) e os recrie posteriormente.

  • Faça tudo em uma transação para evitar estados incompletos.

BEGIN;
LOCK TABLE tbl IN SHARE MODE;

SET LOCAL work_mem = '???? MB';  -- just for this transaction

CREATE TABLE tbl_new AS 
SELECT uuid_generate_v1() AS tbl_uuid, <list of all columns in order>
FROM   tbl
ORDER  BY ??;  -- optionally order rows favorably while being at it.

ALTER TABLE tbl_new
   ALTER COLUMN tbl_uuid SET NOT NULL
 , ALTER COLUMN tbl_uuid SET DEFAULT uuid_generate_v1()
 , ADD CONSTRAINT tbl_uuid_uni UNIQUE(tbl_uuid);

-- more constraints, indices, triggers?

DROP TABLE tbl;
ALTER TABLE tbl_new RENAME tbl;

-- recreate views etc. if any
COMMIT;

Isso deve ser mais rápido. Qualquer outro método de atualização em vigor também deve reescrever a tabela inteira, apenas de uma maneira mais cara. Você só seguiria essa rota se não tiver espaço livre suficiente no disco ou não puder bloquear a tabela inteira ou gerar erros para tentativas simultâneas de gravação.

O que acontece com gravações simultâneas?

Outras transações (em outras sessões) que tentam INSERT/ UPDATE/ DELETEna mesma tabela após a transação ter SHAREbloqueado o bloqueio aguardarão até que o bloqueio seja liberado ou o tempo limite comece, o que ocorrer primeiro. Eles falharão de qualquer maneira, uma vez que a tabela na qual eles estavam tentando gravar foi excluída.

A nova tabela possui um novo OID da tabela, mas as transações simultâneas já resolveram o nome da tabela para o OID da tabela anterior . Quando a trava é finalmente liberada, eles tentam trancar a mesa antes de escrever nela e descobrem que ela se foi. O Postgres responderá:

ERROR: could not open relation with OID 123456

Onde 123456está o OID da tabela antiga. Você precisa capturar essa exceção e tentar novamente as consultas no código do seu aplicativo para evitá-lo.

Se você não pode permitir que isso aconteça, você deve manter sua tabela original.

Duas alternativas mantendo a tabela existente

  1. Atualização no local (possivelmente executando a atualização em pequenos segmentos por vez) antes de adicionar a NOT NULLrestrição. Adicionar uma nova coluna com valores NULL e sem NOT NULLrestrição é barato.
    Desde o Postgres 9.2, você também pode criar uma CHECKrestrição comNOT VALID :

    A restrição ainda será aplicada a inserções ou atualizações subsequentes

    Isso permite que você atualize linhas ponto a ponto - em várias transações separadas . Isso evita manter os bloqueios de linha por muito tempo e também permite a reutilização de linhas mortas. (Você precisará executar VACUUMmanualmente se não houver tempo suficiente para o vácuo automático entrar.) Finalmente, adicione a NOT NULLrestrição e remova a NOT VALID CHECKrestrição:

    ALTER TABLE tbl ADD CONSTRAINT tbl_no_null CHECK (tbl_uuid IS NOT NULL) NOT VALID;
    
    -- update rows in multiple batches in separate transactions
    -- possibly run VACUUM between transactions
    
    ALTER TABLE tbl ALTER COLUMN tbl_uuid SET NOT NULL;
    ALTER TABLE tbl ALTER DROP CONSTRAINT tbl_no_null;

    Resposta relacionada discutindo NOT VALIDem mais detalhes:

  2. Prepare o novo estado em uma tabela temporária , TRUNCATEo original e refil a partir da tabela temporária. Tudo em uma transação . Você ainda precisa SHAREbloquear antes de preparar a nova tabela para evitar a perda de gravações simultâneas.

    Detalhes nestas respostas relacionadas ao SO:

Erwin Brandstetter
fonte
Resposta fantástica! Exatamente a informação que eu estava procurando. Duas perguntas 1. Você tem alguma idéia de uma maneira fácil de testar quanto tempo uma ação como essa levaria? 2. Se demorar 5 minutos, o que acontece com as ações que tentam atualizar uma linha nessa tabela durante esses 5 minutos?
Collin Peters
@CollinPeters: 1. A maior parte do tempo seria usada para copiar a grande mesa - e, possivelmente, recriar índices e restrições (isso depende). Descartar e renomear é barato. Para testar, você pode executar o script SQL preparado sem o e LOCKaté o DROP. Eu só podia expressar palpites selvagens e inúteis. Quanto ao 2., considere o adendo à minha resposta.
Erwin Brandstetter
@ErwinBrandstetter Continue em recriar visualizações, por isso, se eu tenho uma dúzia de visualizações que ainda usam a tabela antiga (oid) após renomear a tabela. Existe alguma maneira de executar uma substituição profunda em vez de executar novamente a atualização / criação da exibição inteira?
CodeFarmer
@ CodeFarmer: Se você apenas renomear uma tabela, as exibições continuarão trabalhando com a tabela renomeada. Para que as visualizações usem a nova tabela, é necessário recriá-las com base na nova tabela. (Também para permitir que a tabela antiga seja excluída.) Não há maneira prática de contorná-la.
Erwin Brandstetter
14

Não tenho uma resposta "melhor", mas tenho uma resposta "menos ruim" que pode permitir que você faça as coisas razoavelmente rápido.

Minha tabela tinha linhas de 2MM e o desempenho da atualização foi ruim quando tentei adicionar uma coluna de registro de data e hora secundária que padronizou a primeira.

ALTER TABLE mytable ADD new_timestamp TIMESTAMP ;
UPDATE mytable SET new_timestamp = old_timestamp ;
ALTER TABLE mytable ALTER new_timestamp SET NOT NULL ;

Depois de 40 minutos de espera, tentei fazer isso em um pequeno lote para ter uma idéia de quanto tempo isso levaria - a previsão era de 8 horas.

A resposta aceita é definitivamente melhor - mas esta tabela é muito usada no meu banco de dados. Existem algumas dezenas de mesas que FKEY nele; Eu queria evitar trocar CHAVES ESTRANGEIRAS em tantas tabelas. E depois há pontos de vista.

Um pouco de pesquisa de documentos, estudos de caso e StackOverflow, e eu tive o "A-Ha!" momento. O dreno não estava no UPDATE principal, mas em todas as operações do INDEX. Minha tabela tinha 12 índices - alguns para restrições exclusivas, outros para acelerar o planejador de consultas e alguns para pesquisa de texto completo.

Cada linha que foi ATUALIZADA não estava apenas trabalhando em um DELETE / INSERT, mas também na sobrecarga de alterar cada índice e verificar restrições.

Minha solução foi eliminar todos os índices e restrições, atualizar a tabela e adicionar todos os índices / restrições novamente.

Demorou cerca de 3 minutos para escrever uma transação SQL que fez o seguinte:

  • INÍCIO;
  • caiu índices / constaints
  • tabela de atualização
  • adicionar novamente índices / restrições
  • COMMIT;

O script levou 7 minutos para ser executado.

A resposta aceita é definitivamente melhor e mais adequada ... e praticamente elimina a necessidade de tempo de inatividade. No entanto, no meu caso, seria necessário muito mais trabalho de "Desenvolvedor" para usar essa solução e tínhamos uma janela de 30 minutos de tempo de inatividade programada em que ela poderia ser realizada. Nossa solução abordou isso em 10.

Jonathan Vanasco
fonte