DevGuideTK
← Trilha Backend

Migrações de esquema

Como alterar tabelas em produção sem parar o sistema e sem perder dados.

O executor desta página foi rodado nas cinco linguagens contra um PostgreSQL 18, com a tabela de 3 milhões de linhas ocupada por um relatório. As medições de tempo e de travas do nível Conceito vêm do mesmo banco.

Um executor que não forma fila, em cada linguagem

Cada versão abre uma conexão, define lock_timeout = '1s' e tenta aplicar o comando. Se a trava não vier em 1 segundo, o banco devolve o erro 55P03, a fila atrás da migração é liberada, e o executor espera um pouco mais a cada vez antes de tentar de novo.

import pg from "pg";

const espera = (ms: number) => new Promise((r) => setTimeout(r, ms));

// Aplica um comando de migração sem formar fila: desiste rápido e tenta de novo
async function aplicar(sql: string, tentativas = 10): Promise<void> {
  const cliente = new pg.Client({ connectionString: process.env.DATABASE_URL });
  await cliente.connect();
  try {
    await cliente.query("SET lock_timeout = '1s'"); // não espera mais que 1 s pela trava
    for (let tentativa = 1; ; tentativa++) {
      try {
        await cliente.query(sql);
        console.log(`aplicada na tentativa ${tentativa}`);
        return;
      } catch (erro: any) {
        // 55P03: a trava não veio a tempo; quem esperava atrás da migração foi liberado
        if (erro.code !== "55P03" || tentativa === tentativas) throw erro;
        console.log(`tentativa ${tentativa}: tabela ocupada, tentando de novo`);
        await espera(500 * tentativa + Math.random() * 500);
      }
    }
  } finally {
    await cliente.end();
  }
}

await aplicar("ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS canal text");

O resultado, nas cinco linguagens

Em cada rodada, um relatório abriu uma transação que leu a tabela e a manteve aberta por 5 segundos. Ao mesmo tempo, uma sessão fazia leituras simples por chave primária a cada 50 ms, medindo a pior espera. O executor começou logo depois do relatório.

Tentativas recusadasAplicada naPior espera de uma leitura simples
TypeScript23ª tentativa983 ms
Python23ª tentativa960 ms
Go23ª tentativa954 ms
Java23ª tentativa985 ms
C#23ª tentativa976 ms

A pior espera ficou abaixo de 1 segundo em todas, limitada pelo lock_timeout. Sem ele, no teste do nível Conceito, a mesma leitura esperou 5,3 segundos.

Foi nessas rodadas que a leitura de teste, escrita com SELECT * e preparada pelo driver, quebrou com cached plan must not change result type logo depois de a coluna ser criada, nas cinco. O teste separado do nível Conceito confirmou a causa.

O que muda de uma linguagem para outra

Erro de travaOnde o lock_timeout vale
TypeScripterro.code === "55P03"Na conexão do pg.Client
Pythonerrors.LockNotAvailableNa conexão, com autocommit=True
Go*pgconn.PgError com Code == "55P03"Na conexão do pgx.Connect
JavagetSQLState() igual a "55P03"Na conexão JDBC
C#PostgresErrorCodes.LockNotAvailableNa NpgsqlConnection

O SET lock_timeout vale para a conexão inteira. Com um pool, use uma conexão própria para a migração, como nos exemplos, ou SET LOCAL dentro de uma transação, para o limite não ficar na conexão devolvida ao pool.

Nas ferramentas de migração

Os executores acima mostram a ideia. Nas ferramentas, o mesmo resultado vem de configuração e de um cabeçalho em cada arquivo:

-- No começo de cada migração, ou na configuração da ferramenta
SET lock_timeout = '1s';
SET statement_timeout = '15min';

ALTER TABLE pedidos ADD COLUMN canal text;

E para o índice, num arquivo separado e fora de transação (no golang-migrate e no Flyway, por exemplo, isso exige marcar a migração como não transacional):

CREATE INDEX CONCURRENTLY IF NOT EXISTS pedidos_canal_idx ON pedidos (canal);

A nova tentativa fica por conta do pipeline de deploy: se a migração falhar por trava, o pipeline espera e roda de novo. No teste, CREATE INDEX CONCURRENTLY dentro de uma transação falhou com cannot run inside a transaction block.

As receitas, em SQL

Tornar uma coluna obrigatória sem ler a tabela sob trava (testado: 1 ms, 169 ms sem bloquear e 2,3 ms):

ALTER TABLE pedidos ADD CONSTRAINT obs_nao_nula CHECK (obs IS NOT NULL) NOT VALID;
ALTER TABLE pedidos VALIDATE CONSTRAINT obs_nao_nula;
ALTER TABLE pedidos ALTER COLUMN obs SET NOT NULL;
ALTER TABLE pedidos DROP CONSTRAINT obs_nao_nula; -- opcional: o NOT NULL já garante

Chave estrangeira numa tabela grande (testado):

ALTER TABLE pedidos ADD CONSTRAINT pedidos_cliente_fk
  FOREIGN KEY (cliente_id) REFERENCES clientes (id) NOT VALID;
ALTER TABLE pedidos VALIDATE CONSTRAINT pedidos_cliente_fk;

Preencher uma coluna em lotes (testado com 12 mil linhas: lotes de 5.000, 5.000 e 2.000, e o quarto não alterou nada):

UPDATE pedidos SET canal = 'site'
 WHERE id IN (SELECT id FROM pedidos WHERE canal IS NULL LIMIT 5000);

Lista de revisão

Antes de aplicar

  • O SQL da migração foi lido, inclusive o gerado pelo ORM?
  • A migração funciona com a versão do código que está no ar?
  • Mudanças que reescrevem a tabela (tipo, padrão calculado) foram evitadas ou divididas em etapas?
  • O tempo foi medido numa cópia com o volume de produção?

Travas

  • Há lock_timeout curto, e uma nova tentativa quando ele estoura?
  • Índices usam CREATE INDEX CONCURRENTLY, fora de transação?
  • NOT NULL e chaves estrangeiras em tabelas grandes usam NOT VALID e validação?

Etapas

  • Renomeações, trocas de tipo e remoções seguem expandir e contrair, em deploys separados?
  • Preenchimentos de dados rodam em lotes, retomáveis, fora da migração de esquema?
  • As consultas da aplicação listam as colunas, em vez de SELECT *?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Escrever migrações seguras

    Quero escrever as migrações do meu projeto de forma segura para produção.
    
    Pergunte qual ferramenta de migração e banco eu uso.
    
    Vamos em etapas, com os arquivos de migração completos:
    1. O cabeçalho com lock_timeout e statement_timeout.
    2. Um índice com CREATE INDEX CONCURRENTLY, marcado como não transacional na minha ferramenta.
    3. Uma coluna NOT NULL nova numa tabela com dados, sem bloquear.
    4. Uma chave estrangeira com NOT VALID e validação.
    5. A nova tentativa no pipeline quando a migração falha por trava.
  • Renomear uma coluna sem parar o sistema

    Quero renomear uma coluna numa tabela grande sem janela de manutenção.
    
    Pergunte qual linguagem, ORM, ferramenta de migração e banco eu uso.
    
    Me ajude a escrever, deploy por deploy:
    1. A migração que cria a coluna nova.
    2. A mudança no código para gravar nas duas.
    3. O job que copia os dados em lotes.
    4. A mudança no código para ler só a nova.
    5. A migração que remove a antiga, e como confirmar que nada mais a usa.
  • Revisar uma migração

    Vou colar abaixo uma migração de banco (SQL ou código da ferramenta). Revise para rodar em produção, com o sistema no ar.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. Há lock_timeout?
    2. Algum comando reescreve ou varre a tabela sob trava exclusiva?
    3. Índices usam CONCURRENTLY, fora de transação?
    4. NOT NULL e chaves estrangeiras usam NOT VALID e validação?
    5. A migração funciona com o código que está no ar?
    6. Há atualização de muitas linhas num único comando?
    
    Para cada problema, diga o efeito em produção e mostre a versão segura.
    
    Migração:
    [cole aqui]