Migrações de schema: como atualizar o banco sem downtime em produção

1. Fundamentos: Por que migrações sem downtime são críticas

Em sistemas modernos de alta disponibilidade, o tempo de inatividade não é apenas um inconveniente — é uma ameaça direta ao negócio. SLAs de 99,99% permitem apenas 52 minutos de downtime por ano, e cada minuto extra pode custar milhares de dólares em receita perdida e danos à reputação.

A diferença fundamental entre migração de schema e migração de dados reside na natureza da operação. Migrações de schema alteram a estrutura do banco (colunas, tabelas, índices), enquanto migrações de dados transformam valores existentes. O grande desafio é que ambas precisam ocorrer sem interromper o fluxo de operações.

O princípio central de qualquer migração sem downtime é a compatibilidade retroativa: o schema deve funcionar corretamente tanto com a versão antiga quanto com a nova do código da aplicação. Isso significa que você nunca pode remover algo que o código antigo ainda está usando.

2. Estratégias de deploy paralelo (Expand-Migrate-Contract)

O padrão Expand-Migrate-Contract (também conhecido como parallel change) é a estratégia mais robusta para migrações sem downtime. Ele divide a operação em três fases distintas:

Fase Expand — Adicione novos elementos ao schema sem remover os antigos:

-- Adicionar nova coluna sem remover a antiga
ALTER TABLE usuarios ADD COLUMN email_novo VARCHAR(255) DEFAULT NULL;

-- Criar nova tabela paralela
CREATE TABLE usuarios_v2 (LIKE usuarios INCLUDING ALL);

Fase Migrate — Sincronize dados entre os schemas antigo e novo:

-- Backfill em lotes
UPDATE usuarios SET email_novo = email WHERE email_novo IS NULL LIMIT 1000;

-- Trigger para sincronização contínua
CREATE OR REPLACE FUNCTION sincronizar_email()
RETURNS TRIGGER AS $$
BEGIN
    NEW.email_novo := NEW.email;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_sinc_email
BEFORE INSERT OR UPDATE ON usuarios
FOR EACH ROW EXECUTE FUNCTION sincronizar_email();

Fase Contract — Remova elementos legados após verificação:

-- Remover coluna antiga após confirmar que nenhum código a utiliza
ALTER TABLE usuarios DROP COLUMN email;

3. Técnicas para adicionar colunas e tabelas sem bloqueio

Em bancos como PostgreSQL e MySQL, ALTER TABLE ADD COLUMN com DEFAULT NULL é uma operação quase instantânea que não requer reescrita da tabela. Já adicionar uma coluna com DEFAULT não nulo pode travar a tabela inteira.

Para índices, o PostgreSQL oferece CREATE INDEX CONCURRENTLY:

-- Cria índice sem bloquear escritas
CREATE INDEX CONCURRENTLY idx_usuarios_email_novo ON usuarios(email_novo);

-- Após criação, tornar o índice utilizável
ALTER INDEX idx_usuarios_email_novo SET (fillfactor = 90);

Para novas tabelas, a replicação assíncrona permite criar a estrutura completa sem impactar a produção:

-- Criar tabela réplica em servidor secundário
CREATE TABLE pedidos_novo (LIKE pedidos INCLUDING ALL);

-- Configurar replicação lógica para manter sincronia
-- (exemplo conceitual)
SELECT * FROM pg_create_logical_replication_slot('slot_pedidos', 'pgoutput');

4. Migrações de dados pesadas: backfill e sincronização

Migrações de dados volumosas exigem processamento em lotes com janelas de baixa carga. O backfill deve ser feito em batches pequenos para evitar locks prolongados:

-- Backfill em batches de 1000 registros
DO $$
DECLARE
    v_linhas_afetadas INTEGER;
BEGIN
    LOOP
        UPDATE usuarios
        SET email_novo = email
        WHERE email_novo IS NULL
        LIMIT 1000;

        GET DIAGNOSTICS v_linhas_afetadas = ROW_COUNT;
        EXIT WHEN v_linhas_afetadas = 0;

        COMMIT;
        PERFORM pg_sleep(0.1); -- Pausa para reduzir contenção
    END LOOP;
END;
$$;

Para sincronização em tempo real, o sistema LISTEN/NOTIFY do PostgreSQL permite notificar mudanças:

-- Notificar mudanças
CREATE OR REPLACE FUNCTION notificar_mudanca_email()
RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify('canal_migracao', 
                      json_build_object('id', NEW.id, 'email', NEW.email)::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Aplicação ouve as notificações e atualiza o schema novo
-- (exemplo em Python/psycopg2)

5. Renomeação e remoção segura de colunas e tabelas

O padrão "rename-and-deprecate" é essencial para remoções seguras:

-- Fase 1: Renomear coluna antiga
ALTER TABLE usuarios RENAME COLUMN email TO email_legado;

-- Fase 2: Criar nova coluna com nome original
ALTER TABLE usuarios ADD COLUMN email VARCHAR(255);

-- Fase 3: Copiar dados (após confirmar que app usa novo nome)
UPDATE usuarios SET email = email_legado;

-- Fase 4: Remover coluna legada (após feature flag desabilitar acesso)
ALTER TABLE usuarios DROP COLUMN email_legado;

Feature flags são cruciais para coordenar a remoção com o deploy do código:

-- Exemplo de verificação de feature flag no código
-- if feature_flags.is_active('novo_email_column'):
--     return usuario.email_novo
-- else:
--     return usuario.email_legado

6. Monitoramento e validação durante a migração

Métricas essenciais a serem monitoradas:

-- Monitorar locks ativos
SELECT pid, locktype, mode, granted
FROM pg_locks
WHERE NOT granted;

-- Verificar latência de queries
SELECT query, mean_time, calls
FROM pg_stat_statements
ORDER BY mean_time DESC LIMIT 10;

-- Comparar checksums entre schemas
SELECT COUNT(*) AS total_registros,
       COUNT(*) FILTER (WHERE email IS DISTINCT FROM email_novo) AS divergencias
FROM usuarios;

Estratégias de canary release: aplique a migração primeiro em uma réplica de leitura, valide os resultados e só então promova para o primário.

7. Casos complexos: migrações que alteram chaves e constraints

Migração de chaves primárias requer múltiplos passos:

-- Fase 1: Adicionar nova PK como UNIQUE
ALTER TABLE pedidos ADD COLUMN id_novo BIGSERIAL UNIQUE;

-- Fase 2: Repopular dados e criar nova PK
ALTER TABLE pedidos ADD PRIMARY KEY (id_novo);

-- Fase 3: Remover PK antiga (após verificar dependências)
ALTER TABLE pedidos DROP CONSTRAINT pedidos_pkey;
ALTER TABLE pedidos DROP COLUMN id_antigo;

Para foreign keys, use NOT VALID para evitar locks:

-- Adicionar FK sem validar dados existentes
ALTER TABLE itens_pedido
ADD CONSTRAINT fk_pedido_novo
FOREIGN KEY (pedido_id_novo) REFERENCES pedidos(id_novo)
NOT VALID;

-- Validar posteriormente
ALTER TABLE itens_pedido VALIDATE CONSTRAINT fk_pedido_novo;

Migrações de tipo de coluna seguem o padrão de coluna auxiliar:

-- Criar coluna com novo tipo
ALTER TABLE usuarios ADD COLUMN bio_text TEXT;

-- Copiar dados com conversão
UPDATE usuarios SET bio_text = bio::TEXT WHERE bio_text IS NULL;

-- Trocar nomes
ALTER TABLE usuarios RENAME COLUMN bio TO bio_legado;
ALTER TABLE usuarios RENAME COLUMN bio_text TO bio;

-- Remover coluna legada
ALTER TABLE usuarios DROP COLUMN bio_legado;

Referências