Blog

Backfill de milhões de linhas sem derrubar a produção.

Você adicionou uma coluna e precisa preencher os registros antigos. São 40 milhões de linhas.

update orders set channel = 'web' where channel is null;

Uma linha. E ela pode derrubar o sistema por quatro motivos ao mesmo tempo.

O que dá errado

Lock. O UPDATE pega lock em cada linha afetada, e as segura até o commit. Quarenta milhões de linhas travadas por vinte minutos significa que toda escrita concorrente naquelas linhas espera.

WAL. Cada linha modificada gera registro no log de transação. Um backfill grande produz dezenas de gigabytes de WAL de uma vez, o que enche disco, atrasa réplica e pode travar o arquivamento.

Inchaço. No Postgres, UPDATE não altera a linha: cria uma versão nova e marca a antiga como morta. A tabela dobra de tamanho. O espaço só volta depois de um VACUUM que também vai demorar.

Replicação. A réplica precisa aplicar tudo isso. O atraso sobe, e se a aplicação lê de réplica, ela passa a ler dado velho. Esse sintoma costuma ser o primeiro que alguém percebe, e o mais difícil de associar à causa.

A forma correta: lotes

-- em laço, com pausa entre iterações
with lote as (
  select id from orders
  where channel is null
  order by id
  limit 5000
  for update skip locked
)
update orders o
set channel = 'web'
from lote
where o.id = lote.id;

Três detalhes que fazem diferença.

FOR UPDATE SKIP LOCKED pula linhas que outra transação está usando. Sem isso, o backfill espera pela transação da aplicação, e essa espera é onde a fila se forma.

ORDER BY id com LIMIT usa o índice e mantém o progresso previsível.

E o lote precisa ser pequeno. Cinco mil é um bom começo. A transação de cada lote dura milissegundos, então nenhuma escrita fica presa por muito tempo.

O laço, com freio

Script fora do banco, com controle:

let processed = 0;

while (true) {
  const started = Date.now();
  const { rowCount } = await db.query(BATCH_SQL);
  if (rowCount === 0) break;

  processed += rowCount;
  const elapsed = Date.now() - started;

  // pausa proporcional: quanto mais o banco sofre, mais devagar a gente vai
  await sleep(Math.max(100, elapsed * 2));

  // freio pela saúde da replicação
  const lag = await replicationLagSeconds();
  if (lag > 5) {
    logger.warn(`Replicação atrasada em ${lag}s, pausando`);
    await sleep(30_000);
  }

  if (processed % 100_000 === 0) logger.info(`${processed} linhas`);
}

A pausa proporcional é o truque que mais importa. Se o banco está tranquilo, o lote roda em 50ms e a pausa é curta. Se o banco está sob carga, o lote demora e a pausa cresce junto. O backfill se adapta sem ninguém monitorando.

O freio pela replicação é o segundo. Atraso de réplica é o indicador mais confiável de que você está indo rápido demais.

Retomável, sempre

Backfill de milhões de linhas vai ser interrompido: deploy, queda de conexão, alguém que cancelou.

Se o critério for “onde a coluna é nula”, ele é naturalmente retomável, o que é uma vantagem grande.

Quando não for, guarde o progresso:

create table backfill_progress (
  job_name   text primary key,
  last_id    bigint not null,
  updated_at timestamptz not null default now()
);

E jamais confie em contar de novo do começo. Backfill que reprocessa do zero depois de uma interrupção é como se duplica efeito colateral, quando existe efeito colateral.

Quando tem efeito colateral

Se o backfill dispara evento, envia e-mail ou chama API externa, a conversa muda completamente.

Aí ele precisa ser idempotente por registro, com marcação de processado na mesma transação do efeito. Um backfill que reenvia dez mil e-mails porque alguém reiniciou o script é um incidente com clientes.

A regra: efeito externo e marcação de processado na mesma transação, ou nenhum dos dois. Escrevi sobre isso em outbox pattern.

O horário

Rodar de madrugada parece óbvio e nem sempre é o certo.

De madrugada tem menos tráfego e também tem backup, VACUUM automático, job de relatório e a janela de manutenção do provedor. Competir com o backup por disco costuma ser pior que competir com o tráfego por CPU.

Com a pausa adaptativa bem calibrada, dá pra rodar em horário comercial com segurança. Isso tem uma vantagem grande: tem gente acordada pra perceber se der problema.

O que monitorar durante

Quatro números, e o segundo é o mais importante:

Progresso, pra saber se vai terminar hoje ou em três semanas. Atraso de replicação, que é o primeiro a reagir. Tempo de resposta da aplicação, que é o que o usuário sente. E espaço em disco, por causa do WAL e do inchaço.

Se o atraso de replicação subir de forma consistente, pare. Não diminua o ritmo: pare, deixe alcançar, e recomece mais devagar.

O plano de parada

Antes de começar, tenha a resposta pronta pra duas perguntas.

Como eu paro isso agora? Um sinal, uma flag em tabela, um pkill. Precisa ser algo que funcione com você nervoso.

E o que acontece com o estado parcial? Se metade das linhas está preenchida e a outra não, a aplicação aguenta? Se a resposta for não, o backfill precisa vir antes de qualquer código que dependa dele, e o código precisa tratar o campo como opcional até terminar.

Essa última pergunta é a que separa um backfill que dá pra abortar em paz de um que precisa terminar de qualquer jeito às três da manhã.

← Voltar pro blog