Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

Gostaria de compartilhar com vocês minha primeira experiência bem-sucedida na restauração completa da funcionalidade da base de dados Postgres. Conheci o SGBD Postgres há seis meses, e antes disso não tinha nenhuma experiência em administração de bases de dados.

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

Trabalho como engenheiro semi-DevOps em uma grande empresa de TI. Nossa empresa se dedica ao desenvolvimento de software para serviços de alta carga, e eu sou responsável pela funcionalidade, manutenção e deploy. Fui incumbido de uma tarefa padrão: atualizar um aplicativo em um servidor. O aplicativo foi escrito em Django, e durante a atualização, são executadas migrações (mudanças na estrutura da base de dados), e antes desse processo, fazemos um dump completo da base de dados usando o programa padrão pg_dump, por precaução.

Durante a criação do dump, ocorreu um erro inesperado (versão do Postgres – 9.5):

pg_dump: A captura do conteúdo da tabela “ws_log_smevlog” falhou: PQgetResult() falhou.
pg_dump: Mensagem de erro do servidor: ERRO: página inválida no bloco 4123007 do relacionamento base/16490/21396989
pg_dump: O comando foi: COPY public.ws_log_smevlog [...]
pg_dump: [arquivamento paralelo] um processo de trabalho falhou inesperadamente

Erro «Página inválida no bloco» indica problemas no nível do sistema de arquivos, o que não é bom. Em vários fóruns, sugeriram fazer FULL VACUUM com a opção zero_damaged_pages para resolver esse problema. Bem, vamos tentar…

Preparação para a restauração

ATENÇÃO! Certifique-se de fazer um backup do Postgres antes de qualquer tentativa de restaurar a base de dados. Se você tiver uma máquina virtual, pare a base de dados e faça um snapshot. Se não for possível fazer um snapshot, pare a base e copie o conteúdo do diretório do Postgres (incluindo arquivos wal) para um local seguro. O mais importante no nosso trabalho é não piorar a situação. Leia isso.

Uma vez que, no geral, a base de dados estava funcionando, limitei-me a um dump regular da base de dados, mas excluí a tabela com os dados corrompidos (opção -T, —exclude-table=TABLE no pg_dump).

O servidor era físico, não foi possível fazer um snapshot. O backup foi feito, vamos em frente.

Verificação do sistema de arquivos

Antes de tentar restaurar a base de dados, é necessário garantir que tudo esteja em ordem com o próprio sistema de arquivos. E, caso haja erros, corrigi-los, pois, caso contrário, pode-se apenas piorar.

No meu caso, o sistema de arquivos com a base de dados estava montado em «/srv» e o tipo era ext4.

Parando a base de dados: systemctl stop postgresql@9.5-main.service e verificamos se o sistema de arquivos não está em uso e pode ser desmontado com o comando lsof:
lsof +D /srv

Eu também precisei parar o banco de dados redis, pois ele também estava em uso «/srv». Em seguida, eu o desmontei /srv (umount).

A verificação do sistema de arquivos foi realizada com a ferramenta e2fsck com a opção -f (Forçar verificação mesmo que o sistema de arquivos esteja marcado como limpo):

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

Em seguida, com a ferramenta dumpe2fs (sudo dumpe2fs /dev/mapper/gu2—sys-srv | grep checked) podemos confirmar que a verificação realmente foi realizada:

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

e2fsck diz que não foram encontrados problemas no nível do sistema de arquivos ext4, o que significa que podemos continuar tentando recuperar o banco de dados, ou melhor, voltar para vacuum full (naturalmente, devemos montar o sistema de arquivos novamente e iniciar o banco de dados).

Se você tiver um servidor físico, certifique-se de verificar o estado dos discos (através de smartctl -a /dev/XXX) ou do controlador RAID, para garantir que o problema não está em nível de hardware. No meu caso, o RAID era 'hardware', então pedi ao administrador local que verificasse o estado do RAID (o servidor estava a várias centenas de quilômetros de distância de mim). Ele disse que não havia erros, o que significa que podemos certamente iniciar a recuperação.

Tentativa 1: zero_damaged_pages

Conectamos ao banco de dados através do psql com uma conta que possui privilégios de superusuário. Precisamos exatamente de um superusuário, pois a opção zero_damaged_pages só pode ser alterada por ele. No meu caso é postgres:

psql -h 127.0.0.1 -U postgres -s [database_name]

Opção zero_damaged_pages é necessária para ignorar erros de leitura (do site postgrespro):

Ao detectar um cabeçalho de página corrompido, o Postgres Pro geralmente relata um erro e interrompe a transação atual. Se a opção zero_damaged_pages estiver ativada, em vez disso, o sistema gera um aviso, zera a página corrompida na memória e continua o processamento. Esse comportamento corrompe os dados, ou seja, todas as linhas na página corrompida.

Ativamos a opção e tentamos fazer um full vacuum na tabela:

VACUUM FULL VERBOSE

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)
Infelizmente, falhou.

Enfrentamos um erro semelhante:

INFO: vacuuming "“public.ws_log_smevlog”
WARNING: página inválida no bloco 4123007 da relação base/16400/21396989; zerando a página
ERROR: número de chunk inesperado 573 (esperado 565) para valor toast 21648541 em pg_toast_106070

pg_toast – mecanismo de armazenamento de 'dados longos' no Postgres, se não couberem em uma página (por padrão 8kb).

Tentativa 2: reindex

O primeiro conselho do Google não ajudou. Após alguns minutos de pesquisa, encontrei o segundo conselho – fazer um reindex tabela danificada. Este conselho encontrei em muitos lugares, mas não inspirava confiança. Vamos fazer reindex:

reindexar a tabela ws_log_smevlog

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

reindex terminou sem problemas.

No entanto, isso não ajudou, VACUUM FULL terminou de forma semelhante com um erro. Como estou acostumado a falhas, comecei a procurar conselhos na internet e encontrei algo bastante interessante artigo.

Tentativa 3: SELECT, LIMIT, OFFSET

No artigo acima, foi sugerido olhar a tabela linha por linha e remover dados problemáticos. Para começar, era necessário revisar todas as linhas:

for ((i=0; i/dev/null || echo $i; done

no meu caso, a tabela continha 1 628 991 linhas! O ideal seria cuidar do particionamento de dados, mas esse é um tema para outra discussão. Era sábado, eu executei este comando no tmux e fui dormir:

for ((i=0; i/dev/null || echo $i; done

Pela manhã, decidi verificar como as coisas estavam. Para minha surpresa, descobri que em 20 horas apenas 2% dos dados foram escaneados! Eu não queria esperar 50 dias. Mais uma falha completa.

Mas eu não desisti. Fiquei curioso para saber por que a varredura estava tão lenta. Na documentação (novamente no postgrespro), descobri:

OFFSET indica pular o número especificado de linhas antes de começar a retornar as linhas.
Se OFFSET e LIMIT forem especificados, o sistema primeiro pula as linhas do OFFSET e depois começa a contar as linhas para o limite do LIMIT.

Ao aplicar LIMIT, é importante também usar a cláusula ORDER BY, para que as linhas do resultado sejam retornadas em uma ordem específica. Caso contrário, subconjuntos imprevisíveis de linhas serão retornados.

É óbvio que o comando acima estava incorreto: em primeiro lugar, não havia order by, o resultado poderia estar incorreto. Em segundo lugar, o Postgres precisava primeiro escanear e pular as linhas do OFFSET, e à medida que aumentava OFFSET o desempenho diminuiria ainda mais.

Tentativa 4: fazer um dump em formato texto

Em seguida, me ocorreu, aparentemente, uma ideia genial: fazer um dump em formato texto e analisar a última linha gravada.

Mas antes, vamos conhecer a estrutura da tabela ws_log_smevlog:

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

No nosso caso, temos uma coluna "id", que continha um identificador único (contador) da linha. O plano era o seguinte:

  1. Começar a fazer um dump em formato texto (na forma de comandos sql)
  2. Em um determinado momento, a exportação de dump poderia ser interrompida devido a um erro, mas o arquivo de texto ainda assim seria salvo no disco.
  3. Vamos olhar o final do arquivo de texto, assim encontramos o identificador (id) da última linha que foi exportada com sucesso.

Comecei a exportar o dump em formato de texto:

pg_dump -U my_user -d my_database -F p -t ws_log_smevlog -f .\/my_dump.dump

A exportação do dump, como esperado, foi interrompida com o mesmo erro:

pg_dump: Mensagem de erro do servidor: ERRO: página inválida no bloco 4123007 da base de dados/16490/21396989

Depois, através de tail eu verifiquei o final do dump (tail -5 .\/my_dump.dump) e descobri que a exportação foi interrompida na linha com id 186 525. "Significa que o problema está na linha com id 186 526, ela está corrompida, é essa que precisa ser removida!" – pensei. Mas, ao consultar o banco de dados:
«select * from ws_log_smevlog where id=186529não havia problema com essa linha... As linhas com índices de 186 530 a 186 540 também funcionaram sem problemas. Mais uma "ideia genial" falhou. Depois percebi por que isso aconteceu: ao deletar ou modificar dados em uma tabela, eles não são removidos fisicamente, mas marcados como "tuplas mortas"; posteriormente, um processo autovacuum as marca como deletadas e permite que essas linhas sejam reutilizadas. Para entender, se os dados em uma tabela mudam e o autovacuum está ligado, eles não são armazenados sequencialmente.

Tentativa 5: SELECT, FROM, WHERE id=

Os fracassos nos tornam mais fortes. Nunca devemos desistir, devemos ir até o fim e acreditar em nós mesmos e em nossas capacidades. Portanto, decidi tentar mais uma opção: simplesmente revisar todos os registros no banco de dados um por um. Sabendo a estrutura da minha tabela (veja acima), temos um campo id, que é único (chave primária). Na tabela, temos 1.628.991 linhas e id elas estão em ordem, o que significa que podemos simplesmente percorrê-las uma a uma:

for ((i=1; i<1628991; i=$((i+1)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id=$i" >\/dev\/null || echo $i; done

Se alguém não entende, o comando funciona da seguinte maneira: percorre a tabela linha por linha e envia o stdout para /dev/null, mas se o comando SELECT falha, então um texto de erro é mostrado (stderr é enviado para o console) e a linha que contém o erro é exibida (graças ao ||, que indica que houve problemas no select (código de retorno do comando não é 0)).

Tive sorte, pois criei índices sobre o campo id:

Minha primeira experiência na recuperação de um banco de dados Postgres após uma falha (página inválida no bloco 4123007 da base relatton/16490)

E isso significa que encontrar a linha com o id necessário não deve levar muito tempo. Na teoria, isso deve funcionar. Vamos lá, executando o comando. , demônio e vamos dormir.

Pela manhã, descobri que cerca de 90.000 registros foram visualizados, o que representa um pouco mais de 5%. Um ótimo resultado, se comparado ao método anterior (2%)! Mas não queria esperar 20 dias...

Tentativa 6: SELECT, FROM, WHERE id >= e id <

O cliente tinha um ótimo servidor dedicado para o banco de dados: dual-core Intel Xeon E5-2697 v2, tínhamos 48 threads disponíveis! A carga no servidor era média, conseguíamos facilmente processar cerca de 20 threads. A memória RAM também era suficiente: incríveis 384 gigabytes!

Portanto, a equipe precisava ser paralelizada:

for ((i=1; i<1628991; i=$((i+1)) )); do psql -U my_user -d my_database  -c "SELECT * FROM ws_log_smevlog where id=$i" >\/dev\/null || echo $i; done

Aqui poderia ser escrito um script bonito e elegante, mas escolhi a maneira mais rápida de paralelização: dividir manualmente o intervalo de 0-1628991 em intervalos de 100.000 registros e executar separadamente 16 comandos do tipo:

for ((i=N; i/dev/null || echo $i; done

Mas isso não é tudo. Em teoria, conectar-se ao banco de dados também leva algum tempo e recursos do sistema. Conectar 1.628.991 não era muito razoável, concorda? Portanto, vamos extrair 1000 linhas por conexão em vez de uma. No final, o comando se transformou nisso:

for ((i=N; i=$i and id/dev/null || echo $i; done

Abrimos 16 janelas na sessão tmux e executamos os comandos:

1) for ((i=0; i=$i and id/dev/null || echo $i; done
2) for ((i=100000; i=$i and id/dev/null || echo $i; done
…
15) for ((i=1400000; i=$i and id/dev/null || echo $i; done
16) for ((i=1500000; i=$i and id/dev/null || echo $i; done

Depois de um dia, recebi os primeiros resultados! A saber (os valores XXX e ZZZ já não foram salvos):

ERROR: missing chunk number 0 for toast value 37837571 in pg_toast_106070
829000
ERROR: missing chunk number 0 for toast value XXX in pg_toast_106070
829000
ERROR: missing chunk number 0 for toast value ZZZ in pg_toast_106070
146000

Isso significa que temos três linhas com erro. O id do primeiro e do segundo registro problemático estavam entre 829.000 e 830.000, enquanto o id do terceiro estava entre 146.000 e 147.000. A seguir, precisávamos apenas encontrar o valor exato do id dos registros problemáticos. Para isso, vamos percorrer nosso intervalo de registros problemáticos com um passo de 1 e identificar os ids:

for ((i=829000; i/dev/null || echo $i; done
829417
ERRO: número de chunk inesperado 2 (esperado 0) para o valor toast 37837843 em pg_toast_106070
829449
for ((i=146000; i/dev/null || echo $i; done
829417
ERRO: número de chunk inesperado ZZZ (esperado 0) para o valor toast XXX em pg_toast_106070
146911

Final Feliz

Encontramos linhas problemáticas. Conectamos ao banco via psql e tentamos removê-las:

my_database=# delete from ws_log_smevlog where id=829417;
DELETE 1
my_database=# delete from ws_log_smevlog where id=829449;
DELETE 1
my_database=# delete from ws_log_smevlog where id=146911;
DELETE 1

Para minha surpresa, as entradas foram removidas sem quaisquer problemas, mesmo sem a opção zero_damaged_pages.

Em seguida, conectei ao banco, fiz VACUUM FULL (acho que não era necessário), e, finalmente, consegui fazer um backup com pg_dump. O dump foi criado sem nenhum erro! O problema foi resolvido dessa forma simples. A alegria foi imensa, após tantas falhas finalmente encontrei uma solução!

Agradecimentos e Conclusão

Esta foi minha primeira experiência em restaurar um banco de dados real do Postgres. Essa experiência eu vou lembrar por muito tempo.

E, por último, gostaria de agradecer à empresa PostgresPro pela documentação traduzida para o russo e por cursos online totalmente gratuitos, que ajudaram muito na análise do problema.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster