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");import os
import random
import time
import psycopg
from psycopg import errors
def aplicar(sql: str, tentativas: int = 10) -> None:
"""Aplica um comando de migração sem formar fila: desiste rápido e tenta de novo."""
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as conexao:
conexao.execute("SET lock_timeout = '1s'") # não espera mais que 1 s pela trava
for tentativa in range(1, tentativas + 1):
try:
conexao.execute(sql)
print(f"aplicada na tentativa {tentativa}")
return
except errors.LockNotAvailable:
# 55P03: a trava não veio a tempo; quem esperava atrás da migração foi liberado
if tentativa == tentativas:
raise
print(f"tentativa {tentativa}: tabela ocupada, tentando de novo")
time.sleep(0.5 * tentativa + random.random() * 0.5)
aplicar("ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS canal text")package main
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"os"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// aplicar executa um comando de migração sem formar fila: desiste rápido e tenta de novo
func aplicar(ctx context.Context, sql string, tentativas int) error {
conexao, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer conexao.Close(ctx)
// Não espera mais que 1 s pela trava
if _, err := conexao.Exec(ctx, "SET lock_timeout = '1s'"); err != nil {
return err
}
for tentativa := 1; ; tentativa++ {
_, err := conexao.Exec(ctx, sql)
if err == nil {
fmt.Println("aplicada na tentativa", tentativa)
return nil
}
var pgErr *pgconn.PgError
// 55P03: a trava não veio a tempo; quem esperava atrás da migração foi liberado
if !errors.As(err, &pgErr) || pgErr.Code != "55P03" || tentativa == tentativas {
return err
}
fmt.Printf("tentativa %d: tabela ocupada, tentando de novo\n", tentativa)
pausa := time.Duration(500*tentativa+rand.IntN(500)) * time.Millisecond
time.Sleep(pausa)
}
}
func main() {
err := aplicar(context.Background(), "ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS canal text", 10)
if err != nil {
panic(err)
}
}import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.concurrent.ThreadLocalRandom;
public class Migrar {
/** Aplica um comando de migração sem formar fila: desiste rápido e tenta de novo. */
static void aplicar(String sql, int tentativas) throws Exception {
try (var conexao = DriverManager.getConnection(System.getenv("JDBC_URL"));
var comando = conexao.createStatement()) {
comando.execute("SET lock_timeout = '1s'"); // não espera mais que 1 s pela trava
for (int tentativa = 1; ; tentativa++) {
try {
comando.execute(sql);
System.out.println("aplicada na tentativa " + tentativa);
return;
} catch (SQLException erro) {
// 55P03: a trava não veio a tempo; quem esperava atrás foi liberado
if (!"55P03".equals(erro.getSQLState()) || tentativa == tentativas) throw erro;
System.out.println("tentativa " + tentativa + ": tabela ocupada");
Thread.sleep(500L * tentativa + ThreadLocalRandom.current().nextInt(500));
}
}
}
}
public static void main(String[] args) throws Exception {
aplicar("ALTER TABLE pedidos ADD COLUMN IF NOT EXISTS canal text", 10);
}
}using Npgsql;
// Aplica um comando de migração sem formar fila: desiste rápido e tenta de novo
async Task Aplicar(string sql, int tentativas = 10)
{
var url = Environment.GetEnvironmentVariable("DATABASE_URL");
await using var conexao = new NpgsqlConnection(url);
await conexao.OpenAsync();
await using (var limite = new NpgsqlCommand("SET lock_timeout = '1s'", conexao))
await limite.ExecuteNonQueryAsync(); // não espera mais que 1 s pela trava
for (var tentativa = 1; ; tentativa++)
{
try
{
await using var comando = new NpgsqlCommand(sql, conexao);
await comando.ExecuteNonQueryAsync();
Console.WriteLine($"aplicada na tentativa {tentativa}");
return;
}
// 55P03: a trava não veio a tempo; quem esperava atrás da migração foi liberado
catch (PostgresException erro) when (
erro.SqlState == PostgresErrorCodes.LockNotAvailable && tentativa < tentativas)
{
Console.WriteLine($"tentativa {tentativa}: tabela ocupada, tentando de novo");
await Task.Delay(500 * tentativa + Random.Shared.Next(500));
}
}
}
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 recusadas | Aplicada na | Pior espera de uma leitura simples | |
|---|---|---|---|
| TypeScript | 2 | 3ª tentativa | 983 ms |
| Python | 2 | 3ª tentativa | 960 ms |
| Go | 2 | 3ª tentativa | 954 ms |
| Java | 2 | 3ª tentativa | 985 ms |
| C# | 2 | 3ª tentativa | 976 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 trava | Onde o lock_timeout vale | |
|---|---|---|
| TypeScript | erro.code === "55P03" | Na conexão do pg.Client |
| Python | errors.LockNotAvailable | Na conexão, com autocommit=True |
| Go | *pgconn.PgError com Code == "55P03" | Na conexão do pgx.Connect |
| Java | getSQLState() igual a "55P03" | Na conexão JDBC |
| C# | PostgresErrorCodes.LockNotAvailable | Na 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á garanteChave 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_timeoutcurto, e uma nova tentativa quando ele estoura? - Índices usam
CREATE INDEX CONCURRENTLY, fora de transação? NOT NULLe chaves estrangeiras em tabelas grandes usamNOT VALIDe 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]