Blog

Slot de replicação esquecido que enche o disco: WAL retido por um consumidor que morreu

Às 3h42 de um sábado, a primária de um e-commerce parou de aceitar escrita com "PANIC: could not write to file ... No space left on device". O banco tinha 150 GB de dados num volume de 500 GB, e os dados não tinham crescido de forma anormal. Quem ocupava os outros 320 GB era o diretório pg_wal, cheio de segmentos que o Postgres não podia apagar porque um slot de replicação ainda dizia precisar deles. O slot pertencia a um conector Debezium desligado nove dias antes, numa migração do pipeline de analytics. Ninguém lembrou de removê-lo, e o Postgres fez exatamente o que um slot promete: guardar todo o WAL até o consumidor confirmar que leu. Este artigo explica como o slot retém WAL (e o que mais ele segura), como descobrir em poucos minutos quem está enchendo o pg_wal, como remover um slot sem piorar o incidente, quais tetos o Postgres oferece para isso nunca derrubar a primária e como monitorar antes de chegar perto do limite.

2026-11-11 / Arquitetura / 14 min

01

O disco que encheu sem o banco crescer

O time de dados tinha trocado o pipeline de analytics: saiu um conector Debezium que lia a tabela de pedidos por replicação lógica, entrou uma exportação incremental para o data warehouse. O conector foi desligado, o serviço removido do cluster e o ticket fechado. O slot lógico debezium_orders, criado pelo conector na primeira execução, continuou existindo na primária. Um slot não depende de o consumidor estar vivo. Ele é um objeto persistente do servidor, sobrevive a reinícios e não expira por padrão.

Dia 0   pipeline de analytics migrado; conector Debezium antigo desligado
        slot logico "debezium_orders" continua na primaria (active = false)
        restart_lsn congela no ultimo LSN confirmado

Dia 1..8  primaria gera ~40 GB de WAL por dia
          checkpoint quer reciclar segmentos antigos, mas o slot ainda "precisa" deles
          pg_wal: 1 GB -> 40 GB -> 120 GB -> ... -> 320 GB
          volume de 500 GB: dados 150 GB + pg_wal crescendo

Dia 8, 22:10  alerta de disco em 90%: tratado como crescimento normal de dados
Dia 9, 03:42  PANIC: could not write to file "pg_wal/xlogtemp.48121": No space left on device
              primaria fora do ar, API e workers parados

Correcao: pg_drop_replication_slot('debezium_orders') + CHECKPOINT
          pg_wal volta para ~2 GB em menos de 1 minuto

Dois sinais foram ignorados porque pareciam normais. O alerta de disco em 90% disparou na noite anterior e foi lido como crescimento de dados, já que ninguém olhava o tamanho do pg_wal separado do tamanho das tabelas. E o painel de replicação mostrava a réplica física em dia, com atraso de milissegundos, porque o slot problemático não era dela. O incidente só foi entendido quando alguém rodou du no diretório de dados e viu 320 GB dentro do pg_wal.

O primeiro impulso do plantão foi apagar arquivos antigos do pg_wal para liberar espaço. Isso teria sido o pior desfecho possível: os segmentos ali incluem o WAL necessário para a recuperação após a queda, e apagar o arquivo errado transforma um disco cheio em um banco que não sobe ou em dados corrompidos. A correção certa levou três comandos SQL, mas só depois de liberar espaço fora do pg_wal para a primária conseguir iniciar.

02

Como o slot segura o WAL: restart_lsn, confirmed_flush_lsn e catalog_xmin

Sem slots, o Postgres recicla os segmentos de WAL depois de cada checkpoint, mantendo só o necessário para recuperação, para o arquivamento e para wal_keep_size. Uma réplica que fica para trás além disso perde o WAL de que precisa e tem de ser reconstruída. O slot resolve esse problema invertendo a responsabilidade: a primária passa a guardar todo segmento a partir do restart_lsn do slot até que o consumidor avance. Para a réplica, é uma garantia. Para a primária, é uma promessa sem prazo.

  • restart_lsn: o LSN mais antigo que o slot ainda pode precisar. Todo segmento de WAL a partir dele fica no disco. Num slot parado, ele congela, e o WAL retido cresce na mesma velocidade em que a primária escreve.
  • confirmed_flush_lsn (só em slots lógicos): até onde o consumidor confirmou ter processado as mudanças. O restart_lsn avança conforme essa confirmação avança.
  • xmin (slots físicos com hot_standby_feedback): impede o vacuum de remover versões de linha que a réplica ainda pode ler. Um slot físico parado congela esse horizonte e a primária acumula tuplas mortas.
  • catalog_xmin (slots lógicos): impede o vacuum de limpar o catálogo do sistema, porque a decodificação lógica precisa da definição das tabelas no instante de cada mudança. Um slot lógico parado faz pg_class, pg_attribute e afins incharem, o que pesa em toda consulta que consulta o catálogo.

O volume retido é previsível: taxa de geração de WAL vezes o tempo parado. Uma primária que gera 40 GB por dia enche 320 GB em oito dias. Medir essa taxa é simples: anote pg_current_wal_lsn() em dois momentos e calcule pg_wal_lsn_diff entre eles. Com ela, o "quanto tempo até o disco acabar" deixa de ser palpite.

Um detalhe engana bastante gente: um slot ativo também pode reter WAL sem limite. Um conector Debezium conectado a um banco onde as tabelas monitoradas quase não mudam, mas outras tabelas mudam muito, recebe pouca coisa para confirmar e não avança o confirmed_flush_lsn. O slot aparece como active = true, o painel fica verde e o WAL retido sobe do mesmo jeito. A solução no Debezium é heartbeat.interval.ms com heartbeat.action.query gravando numa tabela de heartbeat incluída na publicação, para que sempre haja uma mudança a confirmar.

03

Diagnóstico: quem está segurando o pg_wal

Antes de remover qualquer coisa, três consultas na primária dizem quem é o responsável. A primeira lista os slots ordenados pelo WAL que cada um retém, com o status da retenção. A segunda mede o tamanho real do pg_wal. A terceira descarta a outra causa comum de pg_wal grande: arquivamento falhando, que também impede a reciclagem dos segmentos.

-- Quanto WAL cada slot esta segurando e se alguem esta conectado nele.
-- inactive_since existe a partir do Postgres 17. Em versoes anteriores, remova a coluna.
SELECT slot_name,
       slot_type,
       plugin,
       database,
       active,
       active_pid,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
       wal_status,
       pg_size_pretty(safe_wal_size)                                      AS safe_wal_size,
       inactive_since
  FROM pg_replication_slots
 ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC NULLS LAST;

-- Tamanho real do pg_wal (precisa de superusuario ou do papel pg_monitor).
SELECT count(*) AS wal_files, pg_size_pretty(sum(size)) AS pg_wal_size
  FROM pg_ls_waldir();

-- Arquivamento falhando tambem retem WAL: compare failed_count com archived_count.
SELECT archived_count, failed_count, last_failed_wal, last_failed_time
  FROM pg_stat_archiver;
CausaSinal nas consultasAção
Slot sem consumidoractive = false e retained_wal crescendo a cada horaConfirmar o dono e remover o slot
Consumidor ativo e atrasadoactive = true, retained_wal alto e confirmed_flush_lsn paradoInvestigar o consumidor; no Debezium, configurar heartbeat
Réplica física fora do arSlot físico com active = false, réplica ausente em pg_stat_replicationReligar a réplica ou remover o slot e reconstruí-la depois
archive_command falhandofailed_count subindo e last_failed_wal recente em pg_stat_archiverCorrigir o destino do arquivamento; o WAL sai sozinho depois
wal_keep_size alto demaisNenhum slot retendo muito e pg_wal estável num valor grandeReduzir wal_keep_size e usar slots para as réplicas

A coluna wal_status resume o risco. reserved significa que o WAL retido cabe em max_wal_size. extended significa que passou disso, mas ainda está garantido. unreserved significa que o slot ultrapassou max_slot_wal_keep_size e pode perder WAL no próximo checkpoint. lost significa que já perdeu: o slot foi invalidado e o consumidor não consegue mais continuar de onde parou. safe_wal_size mostra quantos bytes ainda podem ser escritos antes de o slot entrar em unreserved, e é nulo quando não há teto configurado.

04

Remover o slot com segurança, e o que nunca fazer

Com o dono identificado e confirmado que ninguém depende do slot, a remoção é curta. A ordem importa porque não é possível remover um slot com um processo conectado nele, e encerrar o walsender de um consumidor que continua rodando só faz ele reconectar ou recriar o slot.

-- 1. Confirmar o slot, o tipo e se existe processo conectado.
SELECT slot_name, slot_type, database, active, active_pid
  FROM pg_replication_slots
 WHERE slot_name = 'debezium_orders';

-- 2. Se ainda houver um consumidor conectado, pare o servico dele antes. So entao encerre o walsender.
SELECT pg_terminate_backend(active_pid)
  FROM pg_replication_slots
 WHERE slot_name = 'debezium_orders' AND active;

-- 3. Remover o slot. Para slot logico, rode no mesmo banco em que ele foi criado.
SELECT pg_drop_replication_slot('debezium_orders');

-- 4. Forcar um checkpoint para o WAL que ninguem mais precisa ser removido ou reciclado.
CHECKPOINT;
SELECT pg_size_pretty(sum(size)) AS pg_wal_size FROM pg_ls_waldir();
  1. Se a primária já caiu por disco cheio, libere espaço fora do pg_wal: aumente o volume, mova logs do servidor ou apague arquivos temporários que não pertencem ao Postgres. Ela precisa de alguns megabytes para concluir a recuperação e aceitar conexões.
  2. Nunca apague arquivos dentro do pg_wal manualmente nem use pg_resetwal para "limpar". O WAL ali inclui o que é necessário para a recuperação após a queda, e apagar o segmento errado corrompe o banco.
  3. Antes de remover um slot lógico de um consumidor que ainda importa, saiba que ele vai precisar de uma nova carga completa. O WAL entre o último LSN confirmado e agora deixa de existir para ele.
  4. Remover o slot de uma réplica física que vai voltar tem o mesmo efeito: ela não encontra mais o WAL de que precisa e tem de ser reconstruída com pg_basebackup.
  5. Depois do CHECKPOINT, confirme que pg_wal_size caiu e que wal_status dos slots restantes voltou a reserved.

Slots esquecidos costumam nascer de processos que criam o slot automaticamente: conectores de CDC, pg_basebackup com -C para uma réplica que depois é desligada, testes com pg_recvlogical e upgrades por replicação lógica. Neste último caso, um DROP SUBSCRIPTION feito depois de ALTER SUBSCRIPTION ... SET (slot_name = NONE), ou sem conexão com o publicador, deixa o slot para trás no servidor de origem. Toda remoção de consumidor precisa ter "remover o slot" como passo explícito do runbook.

05

Um teto para o slot: max_slot_wal_keep_size e idle_replication_slot_timeout

O valor padrão de max_slot_wal_keep_size é -1, sem limite. Isso significa que, por padrão, qualquer consumidor esquecido pode derrubar a primária. Configurar um teto troca o modo de falha: em vez de o disco encher e o banco parar, o slot que passar do limite é invalidado e só aquele consumidor perde a continuidade. Para um sistema de produção, é quase sempre a troca certa.

# postgresql.conf (primaria / primary)
# Teto de WAL por slot: passou disso, o slot e invalidado em vez de o disco encher.
max_slot_wal_keep_size = 80GB

# Postgres 18+: invalida slots sem conexao ha mais de 1 dia (verificado no checkpoint).
idle_replication_slot_timeout = 1d

# Arquivos de WAL extras mantidos para replicas sem slot. Mantenha pequeno.
wal_keep_size = 1GB
ConfiguraçãoO que acontece com o slot esquecidoQuem paga
Padrão (sem teto)Retém WAL até o disco acabarA primária inteira, que para de aceitar escrita
max_slot_wal_keep_size = 80GBInvalidado ao passar de 80 GB de WAL retidoSó o consumidor, que precisa ser recriado ou recarregado
idle_replication_slot_timeout = 1d (Postgres 18+)Invalidado após 1 dia sem conexão, mesmo com pouco WAL retidoSó o consumidor parado, e o catalog_xmin deixa de segurar o vacuum
Monitoramento com alertaAlguém é avisado antes de qualquer limiteNinguém, se o alerta tiver dono e runbook

O teto precisa caber no disco com folga e ser maior que a pior parada legítima. Se uma réplica pode ficar até 6 horas em manutenção e a primária gera 2 GB de WAL por hora, ela precisa de pelo menos 12 GB, e 80 GB num disco com 300 GB livres deixa margem para picos de escrita e para o próprio max_wal_size. O idle_replication_slot_timeout complementa o teto de volume: ele pega o slot lógico parado num banco de pouca escrita, que nunca chegaria aos 80 GB mas segura o catalog_xmin por semanas. Os dois são verificados no checkpoint, então a invalidação não é instantânea.

06

Monitorar antes de encher: alerta por slot, não só por disco

O alerta de disco chegou tarde e sem contexto. O que faltava era um alerta que nomeasse o slot. O monitor abaixo roda a cada minuto, lê pg_replication_slots e avisa em dois casos: slot parado há mais de 15 minutos ou slot retendo mais de 20 GB, mesmo ativo. O usuário de banco dele precisa apenas do papel pg_monitor.

// slot-watchdog.js
import pg from 'pg';

const RETAINED_LIMIT_BYTES = 20 * 1024 ** 3;
const INACTIVE_LIMIT_MS = 15 * 60 * 1000;
const CHECK_EVERY_MS = 60 * 1000;

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });

// Estado observado de cada slot entre uma verificacao e outra.
const inactiveSince = new Map();

const SLOTS_SQL = `
  SELECT slot_name,
         slot_type,
         active,
         wal_status,
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)::bigint AS retained_bytes
    FROM pg_replication_slots`;

async function sendAlert(text) {
  console.error(text);
  if (!process.env.ALERT_WEBHOOK_URL) return;
  await fetch(process.env.ALERT_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content: text }),
  });
}

export async function checkSlots(now = Date.now()) {
  const { rows } = await pool.query(SLOTS_SQL);
  const alerts = [];
  const seen = new Set();

  for (const slot of rows) {
    seen.add(slot.slot_name);
    const gib = (Number(slot.retained_bytes ?? 0) / 1024 ** 3).toFixed(1);

    // restart_lsn nulo significa slot invalidado: o consumidor precisa ser recriado.
    if (slot.retained_bytes === null || slot.wal_status === 'lost') {
      alerts.push(`slot ${slot.slot_name} invalidated (wal_status=${slot.wal_status})`);
      continue;
    }

    // Sem inactive_since (antes do 17), o proprio monitor registra desde quando o slot esta parado.
    if (slot.active) {
      inactiveSince.delete(slot.slot_name);
    } else if (!inactiveSince.has(slot.slot_name)) {
      inactiveSince.set(slot.slot_name, now);
    }

    const idleMs = slot.active ? 0 : now - inactiveSince.get(slot.slot_name);
    if (idleMs > INACTIVE_LIMIT_MS) {
      alerts.push(`slot ${slot.slot_name} (${slot.slot_type}) idle for ${Math.round(idleMs / 60000)} min, holding ${gib} GiB`);
    } else if (Number(slot.retained_bytes) > RETAINED_LIMIT_BYTES) {
      // Ativo mas atrasado tambem e problema: o consumidor existe e nao confirma o LSN.
      alerts.push(`slot ${slot.slot_name} active=${slot.active} holding ${gib} GiB (wal_status=${slot.wal_status})`);
    }
  }

  for (const name of inactiveSince.keys()) {
    if (!seen.has(name)) inactiveSince.delete(name);
  }

  if (alerts.length > 0) await sendAlert(alerts.join('\n'));
  return alerts;
}

setInterval(() => {
  checkSlots().catch((error) => console.error('slot check failed', error));
}, CHECK_EVERY_MS);
  • Separe o tamanho do pg_wal do tamanho dos dados nos painéis. Disco subindo com tabelas estáveis é quase sempre WAL retido.
  • Alerte sobre wal_status diferente de reserved. extended é aceitável por pouco tempo; unreserved e lost exigem ação imediata.
  • Inclua na revisão mensal a lista de slots com o dono de cada um. Um slot sem dono conhecido é candidato a remoção.
  • Em serviços gerenciados, a métrica de "uso de disco por logs de transação" do provedor mostra o mesmo sintoma. Os tetos acima funcionam também ali, a partir do Postgres 13.

FAQ

Perguntas frequentes

Se eu configurar max_slot_wal_keep_size, posso perder dados?

A primária não perde nada: o teto só decide até quando ela guarda WAL para um consumidor atrasado. Quem perde é o consumidor do slot invalidado, que não consegue mais continuar do ponto onde parou. Uma réplica física precisa ser reconstruída com pg_basebackup, e um consumidor lógico, como um conector de CDC, precisa de uma nova carga completa para não ficar com lacuna. É uma perda de continuidade de um consumidor, trocada pela disponibilidade da primária inteira.

Por que o slot aparece ativo e mesmo assim o WAL retido não para de subir?

Porque estar conectado não é o mesmo que confirmar. O restart_lsn só avança quando o consumidor confirma ter processado as mudanças, e alguns consumidores só confirmam quando recebem algo relevante. No Debezium, um banco com muita escrita em tabelas fora da publicação e pouca nas tabelas monitoradas produz exatamente isso. Configurar heartbeat.action.query, gravando numa tabela incluída na publicação, garante uma mudança periódica para confirmar. Em outros consumidores, verifique se a confirmação de LSN acontece depois do processamento e se não está travada por erro silencioso.

Réplicas devem usar slot físico ou wal_keep_size?

Slot, com teto. wal_keep_size guarda uma quantidade fixa de WAL para todas as réplicas, ocupa esse espaço o tempo todo e ainda assim falha se a réplica atrasar mais do que o valor. O slot guarda exatamente o necessário para cada réplica e, com max_slot_wal_keep_size, deixa de ser um risco para o disco. A combinação prática é slot físico por réplica, teto dimensionado pela pior manutenção esperada e wal_keep_size pequeno ou zero.

Todo slot precisa de dono, teto e alerta

Um slot de replicação é uma promessa sem prazo: guardar todo o WAL até o consumidor voltar. Quando o consumidor morre ou é desligado sem que alguém remova o slot, a primária cumpre a promessa até o disco acabar, e ainda segura o vacuum pelo xmin ou pelo catalog_xmin no caminho. A prevenção é barata: medir o WAL retido por slot, configurar max_slot_wal_keep_size e, no Postgres 18, idle_replication_slot_timeout, alertar pelo nome do slot e não só pelo disco, e incluir a remoção do slot em todo runbook de desligamento de consumidor. Com isso, um conector esquecido vira um aviso no canal de alertas, não uma primária fora do ar às 3h da manhã.