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.