Blog

Migração de schema sem downtime.

O deploy é atômico na sua cabeça e não é atômico na realidade.

Durante alguns minutos, a versão antiga e a nova rodam juntas: instâncias sendo substituídas, requisições em voo, worker no meio de um job. Se o banco mudou de forma que a versão antiga não entende, esses minutos são um incidente.

A regra que evita quase tudo: nunca altere, sempre adicione. Remova depois, num deploy separado.

O caso mais comum: renomear coluna

Parece uma linha:

alter table users rename column name to full_name;

O que acontece: a migração roda, a versão antiga ainda no ar procura name, não acha, e todas as requisições dela quebram até a substituição terminar.

O caminho seguro tem quatro deploys.

Deploy 1: adicionar. Coluna nova, aceitando nulo, mais gatilho que mantém as duas sincronizadas.

alter table users add column full_name text;

create function sync_user_name() returns trigger as $$
begin
  new.full_name := coalesce(new.full_name, new.name);
  new.name := coalesce(new.name, new.full_name);
  return new;
end;
$$ language plpgsql;

create trigger user_name_sync before insert or update on users
  for each row execute function sync_user_name();

Backfill. Em lotes, fora do horário de pico, sem travar a tabela.

-- em laço, com pausa entre lotes
update users set full_name = name
where full_name is null and id in (
  select id from users where full_name is null limit 5000
);

Backfill num UPDATE único numa tabela grande gera um lock longo e um WAL enorme. Lote com pausa é mais lento e não derruba nada.

Deploy 2: ler do novo. A aplicação passa a ler full_name e continua escrevendo nos dois. Nenhuma mudança de schema aqui.

Deploy 3: parar de escrever no antigo. Remove o gatilho, a aplicação só usa full_name.

Deploy 4: remover. alter table users drop column name. Semanas depois, quando você tiver certeza de que nada mais lê aquilo.

Quatro deploys pra renomear uma coluna parece exagero. É o preço de não ter janela de manutenção, e depois de fazer duas vezes vira rotina.

Adicionar coluna NOT NULL

-- trava a tabela e quebra a versão antiga, que não sabe preencher
alter table orders add column channel text not null;

A sequência correta: adicionar aceitando nulo com valor padrão, backfill em lotes, aplicação passar a preencher sempre, e só então adicionar a restrição.

No Postgres moderno, adicionar coluna com valor padrão não reescreve mais a tabela inteira, o que ajuda bastante. A restrição de não nulo continua exigindo verificação, e dá pra fazer em duas etapas com NOT VALID seguido de VALIDATE CONSTRAINT, que verifica sem bloqueio longo.

Índice em tabela grande

create index concurrently idx_orders_customer on orders (customer_id, created_at desc);

Sem CONCURRENTLY, a criação bloqueia escrita durante o processo inteiro.

O detalhe que morde: CONCURRENTLY não roda dentro de transação, e sua ferramenta de migração provavelmente envolve tudo em transação. Em TypeORM, marque a migração:

export class AddOrdersIndex implements MigrationInterface {
  transaction = false as const;
  // ...
}

E confira depois. Se falhar no meio, o índice fica em estado inválido, existindo e não sendo usado:

select indexrelid::regclass from pg_index where not indisvalid;

Escrevi mais sobre isso em índices no Postgres.

O lock que ninguém vê

ALTER TABLE pede um lock exclusivo. Ele espera as transações em andamento terminarem, e enquanto espera, ele bloqueia todas as novas.

Uma transação longa segurando a tabela transforma um ALTER de milissegundos numa fila de minutos, e a fila derruba a aplicação.

A proteção:

set lock_timeout = '3s';
alter table orders add column channel text;

Se não conseguir o lock em três segundos, a migração falha em vez de segurar o sistema. Você tenta de novo em outro momento. Falhar rápido é muito melhor que travar devagar.

A regra de ouro

Toda migração precisa ser compatível com a versão anterior da aplicação. Sem exceção.

O teste mental é direto: se eu rodar essa migração agora, com o código atual no ar, alguma coisa quebra?

Se a resposta for sim, ela precisa ser dividida.

Isso também é o que torna o rollback possível. Migração que só funciona com o código novo significa que voltar o código exige voltar o banco, e voltar o banco com dado novo dentro é onde se perde informação.

O que eu automatizaria

Uma verificação no CI que rejeita padrão perigoso na migração: DROP COLUMN, RENAME, ALTER COLUMN TYPE, NOT NULL sem etapa anterior, CREATE INDEX sem CONCURRENTLY em tabela grande.

Não pra proibir, e sim pra obrigar uma justificativa explícita. Migração destrutiva escrita às pressas numa sexta é uma das formas mais rápidas de perder dado, e uma verificação automática é o único obstáculo que não depende de alguém estar prestando atenção.

← Voltar pro blog