Saltar para o conteúdo
Migrations sem Downtime: Estratégias para Bancos em Produção
Banco de Dados

Migrations sem Downtime: Estratégias para Bancos em Produção

20 de maio de 2026·Paulo Pereira

O Problema com Migrations em Produção

Uma migration simples como ALTER TABLE usuarios ADD COLUMN telefone VARCHAR(20) NOT NULL pode bloquear uma tabela com milhões de linhas por minutos — derrubando a produção.

O segredo está no padrão Expand-Contract (também chamado de Blue-Green Migration).

Padrão Expand-Contract

Fase 1: Expand (Adicionar, não remover)

Adicione a mudança de forma compatível com o código atual:

-- Adicione como NULL primeiro (sem bloqueio longo)
ALTER TABLE usuarios ADD COLUMN telefone VARCHAR(20);

-- Para DEFAULT + NOT NULL em tabelas grandes, use esta abordagem:
ALTER TABLE usuarios ADD COLUMN telefone VARCHAR(20) DEFAULT NULL;
-- Depois preencha em lotes:
UPDATE usuarios SET telefone = '' WHERE telefone IS NULL LIMIT 10000;
-- Repita até completar, então:
ALTER TABLE usuarios ALTER COLUMN telefone SET NOT NULL;

Fase 2: Migrar o Código

Deploy do novo código que:

  • Lê o campo novo E o antigo
  • Escreve em AMBOS os campos

Fase 3: Backfill

def backfill_telefone(lote: int = 5000):
    while True:
        atualizados = db.execute("""
            UPDATE usuarios
            SET telefone = dados_legados->>'telefone'
            WHERE telefone IS NULL
            LIMIT %s
        """, (lote,)).rowcount

        if atualizados < lote:
            break
        time.sleep(0.1)  # evita sobrecarregar o banco

Fase 4: Contract (Limpar o antigo)

Depois que novo e antigo forem equivalentes, remova o legado.

Renomear uma Coluna Sem Downtime

Renomear uma coluna é uma das operações mais perigosas. O jeito seguro:

-- 1. Adiciona nova coluna
ALTER TABLE pedidos ADD COLUMN valor_total NUMERIC(10,2);

-- 2. Trigger para manter sincronia durante a transição
CREATE OR REPLACE FUNCTION sync_valor()
RETURNS TRIGGER AS $$
BEGIN
  NEW.valor_total := NEW.preco_total;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_valor_trigger
BEFORE INSERT OR UPDATE ON pedidos
FOR EACH ROW EXECUTE FUNCTION sync_valor();

-- 3. Backfill
UPDATE pedidos SET valor_total = preco_total WHERE valor_total IS NULL;

-- 4. Deploy do código usando novo nome
-- 5. Remover trigger e coluna antiga
DROP TRIGGER sync_valor_trigger ON pedidos;
ALTER TABLE pedidos DROP COLUMN preco_total;

Adicionar Índice sem Bloquear

-- NUNCA em produção:
CREATE INDEX idx_pedidos_usuario ON pedidos(usuario_id);
-- Bloqueia writes durante toda a criação

-- CORRETO:
CREATE INDEX CONCURRENTLY idx_pedidos_usuario ON pedidos(usuario_id);
-- Lento, mas não bloqueia
-- Atenção: não pode ser executado dentro de uma transaction

Migrations com Alembic (Python)

# alembic/versions/001_add_telefone.py
from alembic import op
import sqlalchemy as sa

def upgrade():
    # Expand: adiciona nullable
    op.add_column('usuarios',
        sa.Column('telefone', sa.String(20), nullable=True)
    )

def downgrade():
    op.drop_column('usuarios', 'telefone')
# alembic/versions/002_backfill_telefone.py
from alembic import op

def upgrade():
    # Backfill em lotes para tabelas grandes
    op.execute("""
        DO $$
        DECLARE
            processados INT;
        BEGIN
            LOOP
                UPDATE usuarios
                SET telefone = dados_legados->>'telefone'
                WHERE telefone IS NULL
                  AND dados_legados IS NOT NULL
                LIMIT 5000;

                GET DIAGNOSTICS processados = ROW_COUNT;
                EXIT WHEN processados < 5000;
                PERFORM pg_sleep(0.05);
            END LOOP;
        END $$;
    """)

def downgrade():
    pass  # dados não podem ser desfeitos automaticamente

Rollback Seguro

# Sempre verifique antes de remover
def pode_remover_coluna_antiga():
    # Certifique-se que:
    # 1. Nova coluna tem todos os dados
    # 2. Novo código está em produção há pelo menos 1 ciclo de deployment
    # 3. Não há queries antigas referenciando a coluna (logs de slow queries)

    resultado = db.execute("""
        SELECT COUNT(*) FROM pedidos
        WHERE valor_total IS NULL
          AND preco_total IS NOT NULL
    """).scalar()

    return resultado == 0

Feature Flags para Migrations Graduais

from functools import lru_cache

@lru_cache(maxsize=1)
def usar_nova_coluna() -> bool:
    return os.getenv('FEATURE_NOVA_COLUNA', 'false') == 'true'

def get_valor_pedido(pedido_id: int) -> Decimal:
    if usar_nova_coluna():
        return db.execute(
            "SELECT valor_total FROM pedidos WHERE id = %s", (pedido_id,)
        ).scalar()
    else:
        return db.execute(
            "SELECT preco_total FROM pedidos WHERE id = %s", (pedido_id,)
        ).scalar()

Checklist de Migration Segura

  • Testada em staging com volume de dados similar à produção
  • Migration é reversível (downgrade funcional)
  • Não usa ADD COLUMN NOT NULL sem default em tabelas grandes
  • Índices criados com CONCURRENTLY
  • Backfill feito em lotes com sleep
  • Código backward compatible por pelo menos 1 deploy
  • Monitoramento de lock time durante a execução

Conclusão

Migrations sem downtime exigem mais passos, mas são a diferença entre uma mudança tranquila e um incidente às 2h da manhã. O padrão Expand-Contract é simples e funciona para 90% dos casos.