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 bancoFase 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 transactionMigrations 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 automaticamenteRollback 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 == 0Feature 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 NULLsem 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.