Como o GitLab apagaria uma tabela de 4 TB no PostgreSQL sem travar as réplicas
- Leitura
- 8 min
- Tabelas
- 1
- Código
- 2
- Fontes
- 1
Um teste real em PostgreSQL 18.6 mostra o que o DROP TABLE custa em lock, WAL e réplicas, a partir de um caso levado pelo GitLab ao PGConf.EU em Valencia. A resposta certa quase sempre passa por particionar antes de apagar.
Um problema inverso ao de 2017
O GitLab tem uma história pública de ter apagado a coisa errada: em janeiro de 2017, um engenheiro tentando restaurar a replicação removeu um diretório de dados que achava estar no secundário, mas estava no primário. O post-mortem publicado depois segue sendo referência sobre incidentes de banco.
No PGConf.EU em Valencia, pessoas do GitLab levaram ao autor do boletim boringSQL o problema oposto: uma tabela de arquivamento de 4 TB, num primário com dez réplicas, que ninguém escreve e que ninguém deveria mais ler. Eles querem apagá-la de propósito, com o menor impacto possível. O autor do boringSQL testou a resposta óbvia, DROP TABLE, e descobriu que ela depende de três coisas: se alguém ainda lê a tabela, quanto dela o kernel mantém em cache e como os arquivos estão dispostos em disco.
O que o DROP TABLE custa de verdade
Os testes rodaram em PostgreSQL↳PostgreSQL11 conteúdosPostgreSQL via SSL com GolangData · abr 20195 itens legais sobre data types do PostgreSQLData · mar 20195 serviços gratuitos na cloud para bancos de dados PostgresData · fev 2025Ver tudo em Data → 18.6, com checksums de dados ligados e autovacuum desligado, numa tabela de log sintética (build_logs, 28 milhões de linhas, índice secundário por project_id e created_at) replicada entre um laptop, um par de servidores Hetzner e uma máquina GCE com SSDs locais. Num laptop com 9,2 GB de dados, o DROP TABLE levou 67 ms e moveu só 81.737 bytes de WAL, quase todo ele linhas de catálogo saindo de pg_class e pg_attribute, mais seis registros Standby/LOCK, um por objeto com armazenamento próprio.
O dado em si nunca passa pelo WAL na saída: o registro de commit carrega uma lista de relfilenodes, e quem o reaplica, réplica ou o próprio primário após um crash, apaga esses arquivos. O ponto crítico é o lock: DROP TABLE exige AccessExclusiveLock, que conflita com um simples SELECT. Numa fila de espera, o PostgreSQL não deixa requisições posteriores passarem na frente de quem já aguarda um lock exclusivo.
Em resumo: uma transação antiga e esquecida que só lê a tabela segura o DROP na fila, e o DROP segura toda consulta de aplicação que chega depois dele. Uma tabela que "ninguém usa mais" fica indisponível pelo tempo que a transação mais velha ainda estiver aberta sobre ela.
As réplicas pagam duas vezes
Nas réplicas, o mesmo AccessExclusiveLock é aplicado durante o replay do WAL. Se uma query na réplica está lendo a tabela quando o registro chega, o processo de startup, responsável por aplicar o WAL, espera, e nada mais é replicado enquanto isso. No teste do autor do boringSQL, a leitura foi cancelada depois de 30 segundos, o valor padrão de max_standby_streaming_delay, com o log mostrando o conflito de recuperação.
Isso importa mais ainda para quem usa balanceamento de leitura como o do GitLab, que descarta uma réplica quando ela fica mais de max_replication_lag_time (60 segundos por padrão) atrás. Uma réplica travada num lock parece exatamente uma réplica atrasada, e se algo lê a tabela em todas as dez ao mesmo tempo, todas travam no mesmo registro de WAL. Com o timeout padrão de 30 segundos isso ainda fica abaixo do limite de 60, então o segundo custo é maior: o unlink dos arquivos.
No primário, quem apaga os arquivos é o próprio backend↳Back-end49 conteúdosIntegração front-end com backend: 7 decisões que evitam caos entre APIs, BFF e GraphQLDev (Back & Front) · abr 2026Como criar uma FAKE API REST para testes — JSONPlaceholderDev (Back & Front) · set 2025Construindo um aplicativo de bate-papo de IA simples com Spring AI e AngularDev (Back & Front) · jul 2025Ver tudo em Dev (Back & Front) →, depois de liberar os locks, e o restante do sistema segue andando. Na réplica, é o processo de startup quem faz esse trabalho, e o replay espera. Numa tabela de 192 GB no par Hetzner, o DROP levou 15,9 s no primário, mas a réplica ficou 11 segundos sem aplicar nada, com toda mudança concorrente no primário enfileirada atrás dele.
O sistema de arquivos decide a velocidade do unlink
O autor do boringSQL isolou o custo em três termos: por segmento, por gigabyte em cache e por layout físico. Com cache frio, cada segmento de 1 GB custa cerca de 17 ms para apagar, de 256 GB até 1 TB, de forma praticamente linear; cada gigabyte residente no cache de página soma mais 34 ms. Extrapolado para 4 mil segmentos, uma tabela de 4 TB pode travar o replay por quatro a seis minutos, limitado pela RAM disponível.
A comparação entre sistemas de arquivos surpreende:
| Filesystem | Tabela | Extents por segmento | DROP a frio |
|---|---|---|---|
| ext4 | 256 GB | 898, 4.785, 2.845 | 4,38 s |
| XFS | 256 GB | 1, 4, 13 | 39 ms |
| ext4 | 1 TB | 2.577, 4.706, 1.760 | 16,1 s |
| XFS | 1 TB | 6, 14, 2 | 80 ms |
O XFS foi mais de cem vezes mais rápido nas duas tabelas, porque o custo de apagar a frio segue o número de extents a liberar, e gravação paralela (28 sessões simultâneas, no caso) é quase o pior cenário para o alocador do ext4. Quem opera tabelas dessa escala sobre ext4 carrega um custo de infraestrutura que o XFS simplesmente não tem.
Quando a tabela ainda tem tráfego
Se a premissa de que ninguém lê a tabela falha, e ela costuma falhar de formas banais, como um log append-only ainda recebendo inserts, a resposta não muda de família, mas muda de tática:
TRUNCATEtrava só a tabela filha, porque os gatilhos de chave estrangeira ficam na tabela pai; dá relfilenodes novos e vazios, com WAL mínimo, e um código esquecido recebe zero linhas em vez de erro- Renomear a tabela e aguardar um ciclo de release antes do
DROPexpõe qualquer leitura ainda viva de forma barulhenta, com o dado ainda ali para devolver SET lock_timeout = '2s'com um laço de retentativas evita que oDROPfique parado indefinidamente na fila de locks
O guia de migrations do GitLab já recomenda esse laço, encapsulado em with_lock_retries, e vai além: quando a tabela tem chaves estrangeiras, cada uma deve sair numa migration própria, porque apagá-las junto exige AccessExclusiveLock também na tabela referenciada.
É provável que isso trave o tráfego simultâneo.
GitLab, guia de estilo de migrationsis likely to stall concurrent traffic
Nos testes do autor do boringSQL, apagar uma tabela filha de fato tomou o lock exclusivo na tabela pai (uma projects, no exemplo), porque é lá que vivem os gatilhos de validação da chave estrangeira. O problema de fila passa a valer para a tabela mais quente do sistema.
Apagar em lotes custa mais WAL do que parece
Quando as linhas precisam sair de uma tabela que continua em uso, a prática conhecida é deletar em lotes com COMMIT entre eles, via um procedimento como:
CREATE PROCEDURE purge_batched(p_upto bigint, p_batch int) LANGUAGE plpgsql AS $$
DECLARE v_lo bigint := 0;
BEGIN
WHILE v_lo < p_upto LOOP
DELETE FROM build_logs WHERE id > v_lo AND id <= least(v_lo + p_batch, p_upto);
v_lo := v_lo + p_batch;
COMMIT;
END LOOP;
END $$;No laptop, apagar 25,2 milhões de linhas (90% da tabela) gerou 8.179 MB de WAL para remover cerca de 7 GB de dados de heap: o DELETE escreveu mais WAL do que a própria tabela ocupava. A razão é a imagem de página inteira (full page image): a primeira mudança numa página depois de um checkpoint grava a página de 8 KB inteira, e um lote que varre a tabela do começo ao fim toca cada página pela primeira vez.
No par Hetzner, com 540 milhões de linhas apagadas, o lote levou 36 min 37 s e escreveu 229 GB de WAL. O vacuum seguinte rodou por 15 minutos, escreveu mais 36 GB e atrasou a réplica em até 7 GB e 38 segundos, truncando apenas 5.670 páginas, porque as linhas sobreviventes ficam espalhadas por todo o arquivo, e não concentradas no fim. Extrapolado para 4 TB, o delete, a primeira leitura depois dele e o vacuum somam algo perto de 9 TB de WAL, replicado para as dez réplicas e para o arquivo.
A resposta que já estava certa: particionar antes
Se a tabela é particionada por intervalo na coluna que define "antigo" (created_at, no exemplo), aposentar um mês é ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY seguido de um DROP TABLE sobre algo que nada mais referencia:
ALTER TABLE build_logs DETACH PARTITION build_logs_y2024m01 CONCURRENTLY;
DROP TABLE build_logs_y2024m01;Isso reduz o problema inteiro aos poucos quilobytes de WAL e ao custo de unlink medidos lá em cima, mas uma vez por partição, sem tuplas mortas e sem vacuum para limpar depois. Como o estrago do replay escala com o tamanho do que se apaga, um mês é sempre uma fatia pequena de 4 TB, o que limita o teto de impacto em qualquer réplica. Particionamento não resolve, porém, uma tabela que recebe updates em linhas antigas ou que não pode ser reparticionada esta semana.
O que fica em aberto
Recuperar o espaço físico depois de um delete em massa exige reescrever a tabela: VACUUM FULL (lock exclusivo pelo tempo inteiro, inviável numa tabela viva), pg_repack, ou o novo REPACK (CONCURRENTLY), que chega no PostgreSQL 19, ainda em release candidate no momento dos testes. Cada opção reescreve as linhas sobreviventes e reconstrói índices, somando mais WAL ao que o delete e o vacuum já geraram.
Para quem administra um primário com réplicas de leitura e um balanceador que decide por latência de replicação, como o do próprio GitLab, o ponto prático é medir antes de agir: ligar log_recovery_conflict_waits, checar o sistema de arquivos sob as tabelas grandes e decidir, caso a caso, entre TRUNCATE, lote com retentativa ou particionamento. Nenhuma dessas táticas é nova, mas os números do autor do boringSQL dão a elas uma escala concreta, e a diferença entre ext4 e XFS medida ali vale uma auditoria de infraestrutura por si só.
Fonte: Planet PostgreSQL (https://postgr.es/p/9AI)
Fonte: Planet PostgreSQL
Highlights
- Tabela de 4 TB no GitLab causaria 4-6 minutos de travamento de replay em réplicas sem particionamento
- XFS remove segmento de 1 TB em 80 ms; ext4 leva 16,1 segundos; custo escala com número de extents
- DELETE em lotes de 540 milhões de linhas gerou 229 GB de WAL em 36 minutos, atrasando réplicas 38 segundos
- PostgreSQL 19 trará REPACK CONCURRENTLY; até lá, TRUNCATE ou particionamento são as opções viáveis
- DROP TABLE exige AccessExclusiveLock; fila de espera bloqueia todas as queries posteriores indefinidamente
Este artigo foi escrito por Roberto Diniz, colunista de banco de dados. Conteúdo produzido por agente de IA da redação iMasters, sob revisão editorial humana. Saiba como produzimos no expediente.
Repositório RPM do PostgreSQL reduz dependência do EPEL no RHEL
Devrim Gündüz, mantenedor do repositório RPM oficial do PostgreSQL, removeu a maior parte das dependências do EPEL em RHEL 8, 9 e 10. PostGIS, pgBackRest, patroni e dezenas de ferramentas Python agora instalam só com PGDG e os repositórios do próprio RHEL.














