Uma migração de schema é uma alteração controlada e versionada à estrutura
da base de dados. O problema central é que o schema e o código da aplicação
evoluem juntos — um ALTER TABLE executado manualmente num
servidor de produção não pode ser reproduzido noutro ambiente, não fica
registado em Git e não tem rollback definido. Ferramentas como Flyway e
Liquibase resolvem isto tratando o schema como código:
cada alteração é um ficheiro versionado, aplicado exactamente uma vez,
em ordem, em todos os ambientes. O desafio mais difícil não é a ferramenta
— é escrever migrações que funcionam em produção sem downtime,
enquanto a aplicação antiga ainda está a correr.
-- Sem migrações versionadas, o estado do schema diverge entre ambientes:
--
-- dev (máquina local) staging produção
-- ───────────────────── ───────────────────── ─────────────────────
-- utilizador utilizador utilizador
-- id BIGSERIAL id BIGSERIAL id BIGSERIAL
-- email VARCHAR(255) email VARCHAR(255) email VARCHAR(255)
-- nome VARCHAR(100) nome VARCHAR(100) nome VARCHAR(100)
-- telefone VARCHAR(30) ← telefone VARCHAR(30) ← falta em produção!
-- ultimo_login TIMESTAMPTZ ← ← falta em staging!
--
-- Resultado:
-- - A aplicação funciona em dev mas crasha em produção
-- - Ninguém sabe exactamente o que está em produção
-- - "ALTER TABLE manual" em produção esquecido de documentar
--
-- Solução: tratar o schema como código
-- - Cada alteração = um ficheiro SQL numerado e imutável
-- - A ferramenta de migrações garante que cada ficheiro é executado
-- exactamente uma vez, na ordem correcta, em todos os ambientes
-- - O estado actual do schema é sempre derivável a partir do Git
-- Convenção de nomes Flyway (a mais comum):
-- V{versão}__{descrição}.sql
-- R{descrição}.sql ← repeatable migrations (views, stored procedures)
--
-- db/migration/
-- ├── V1__create_utilizador.sql
-- ├── V2__create_produto_categoria.sql
-- ├── V3__create_encomenda.sql
-- ├── V4__add_telefone_utilizador.sql
-- ├── V5__add_indices_performance.sql
-- ├── V6__create_auditoria_log.sql
-- └── R__create_views.sql ← re-executada quando o checksum muda
-- Regra fundamental: ficheiros de migração são IMUTÁVEIS após serem aplicados.
-- Flyway/Liquibase guardam o checksum (MD5) de cada ficheiro.
-- Se o ficheiro for alterado após ser aplicado, a ferramenta lança erro:
-- "ERROR: Found non-empty schema with no schema history table"
-- ou
-- "ERROR: Migration checksum mismatch for migration version 4"
--
-- Para corrigir uma migração já aplicada → criar uma nova migração (V7, V8, ...)
-- Nunca editar um ficheiro já commitado e aplicado em qualquer ambiente
-- ── Tabela de controlo (criada automaticamente pelo Flyway) ──────────────────
-- flyway_schema_history
-- ┌─────────────┬──────────────────────────────┬─────────┬────────────┬─────────┐
-- │ installed_rank│ version │ description │ checksum│ success │ │
-- ├─────────────┼──────────────────────────────┼─────────┼────────────┼─────────┤
-- │ 1 │ 1 │ create utilizador │ 8f3a... │ true │ │
-- │ 2 │ 2 │ create produto... │ 2c1d... │ true │ │
-- │ 3 │ 3 │ create encomenda │ 9b7e... │ true │ │
-- │ 4 │ 4 │ add telefone... │ 4f2a... │ true │ │
-- └─────────────┴──────────────────────────────┴─────────┴────────────┴─────────┘
-- ── V1__create_utilizador.sql ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS utilizador (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
nome VARCHAR(100) NOT NULL,
password_hash CHAR(60) NOT NULL,
activo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_utilizador_email UNIQUE (email)
);
CREATE INDEX idx_utilizador_email ON utilizador (email);
-- ── V4__add_telefone_utilizador.sql ──────────────────────────────────────────
-- Cada migração deve ser idempotente quando possível (IF NOT EXISTS, IF EXISTS)
ALTER TABLE utilizador
ADD COLUMN IF NOT EXISTS telefone VARCHAR(30);
COMMENT ON COLUMN utilizador.telefone
IS 'Número de telefone opcional. Formato livre — validação feita na aplicação.';
O problema mais difícil em migrações de produção: a aplicação antiga continua a correr enquanto a migração é aplicada. O schema tem de ser compatível com a versão antiga E a versão nova ao mesmo tempo durante o período de deployment. Isto requer o padrão expand-contract (também chamado parallel change).
-- ═══════════════════════════════════════════════════════════════════════════
-- CENÁRIO: renomear a coluna "nome" para "nome_completo" em utilizador
-- ═══════════════════════════════════════════════════════════════════════════
-- ── ❌ ABORDAGEM PERIGOSA — migração directa ──────────────────────────────────
-- V10__rename_nome_to_nome_completo.sql
ALTER TABLE utilizador RENAME COLUMN nome TO nome_completo;
-- PROBLEMA: a aplicação antiga ainda usa "nome" → crasha imediatamente
-- O rename e o deploy da nova aplicação não são atómicos
-- ── ✅ PADRÃO EXPAND-CONTRACT — 3 fases ────────────────────────────────────────
-- FASE 1: EXPAND — adicionar a nova coluna, manter a antiga (V10)
-- Deploy desta migração enquanto a aplicação antiga ainda está a correr:
-- V10__add_nome_completo.sql
ALTER TABLE utilizador ADD COLUMN nome_completo VARCHAR(100);
-- Sincronizar a nova coluna com a antiga (trigger para manter em sync):
CREATE OR REPLACE FUNCTION sync_nome_completo()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.nome IS NOT NULL AND NEW.nome_completo IS NULL THEN
NEW.nome_completo := NEW.nome;
END IF;
IF NEW.nome_completo IS NOT NULL AND NEW.nome IS NULL THEN
NEW.nome := NEW.nome_completo;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_nome
BEFORE INSERT OR UPDATE ON utilizador
FOR EACH ROW EXECUTE FUNCTION sync_nome_completo();
-- Preencher linhas existentes:
UPDATE utilizador SET nome_completo = nome WHERE nome_completo IS NULL;
-- Após esta migração: ambas as colunas existem e estão em sync
-- A aplicação antiga usa "nome" → continua a funcionar
-- A nova aplicação pode começar a usar "nome_completo"
-- FASE 2: MIGRATE — deploy da nova versão da aplicação
-- A nova aplicação usa "nome_completo" em vez de "nome"
-- O trigger mantém as duas colunas em sync durante a transição
-- (possível ter múltiplas instâncias a correr em simultâneo durante o deploy)
-- FASE 3: CONTRACT — remover a coluna antiga (V11, numa sprint/deploy futura)
-- V11__remove_nome_coluna.sql
-- Só executar quando TODAS as instâncias da aplicação usam nome_completo:
DROP TRIGGER IF EXISTS trg_sync_nome ON utilizador;
DROP FUNCTION IF EXISTS sync_nome_completo();
ALTER TABLE utilizador DROP COLUMN nome;
ALTER TABLE utilizador ALTER COLUMN nome_completo SET NOT NULL;
-- ═══════════════════════════════════════════════════════════════════════════
-- CENÁRIO: adicionar coluna NOT NULL sem default dinâmico
-- ═══════════════════════════════════════════════════════════════════════════
-- ❌ Perigoso em tabelas grandes (reescreve a tabela inteira + bloqueia):
ALTER TABLE produto ADD COLUMN descricao TEXT NOT NULL DEFAULT '';
-- ✅ Padrão seguro em 3 passos:
-- V12__add_descricao_nullable.sql
ALTER TABLE produto ADD COLUMN IF NOT EXISTS descricao TEXT;
-- Passo 1: adicionar como nullable — instantâneo, sem bloqueio
-- V13__backfill_descricao.sql
-- Preencher em batches para não bloquear a tabela (em produção com milhões de linhas):
DO $$
DECLARE
batch_size INT := 1000;
last_id BIGINT := 0;
max_id BIGINT;
BEGIN
SELECT MAX(id) INTO max_id FROM produto;
WHILE last_id < max_id LOOP
UPDATE produto
SET descricao = ''
WHERE id > last_id
AND id <= last_id + batch_size
AND descricao IS NULL;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.01); -- 10ms de pausa entre batches — reduz contention
END LOOP;
END;
$$;
-- V14__set_descricao_not_null.sql
-- Passo 3: adicionar a constraint NOT VALID primeiro (não verifica linhas existentes):
ALTER TABLE produto
ADD CONSTRAINT chk_descricao_not_null
CHECK (descricao IS NOT NULL) NOT VALID;
-- Validar em background (sem bloquear escritas):
ALTER TABLE produto VALIDATE CONSTRAINT chk_descricao_not_null;
-- Finalmente: set NOT NULL no catálogo (usa a constraint validada — instantâneo):
ALTER TABLE produto ALTER COLUMN descricao SET NOT NULL;
ALTER TABLE produto DROP CONSTRAINT chk_descricao_not_null;
-- ── Tabela de referência rápida ───────────────────────────────────────────────
--
-- Operação | Risco | Alternativa Segura
-- ──────────────────────────────────┼──────────────┼──────────────────────────────
-- ADD COLUMN NOT NULL sem DEFAULT | Bloqueia | 3 passos: add nullable → backfill → set NOT NULL
-- ADD COLUMN com DEFAULT dinâmico | Bloqueia | Add nullable + UPDATE em batches
-- RENAME COLUMN | Quebra app | Expand-contract (nova coluna + trigger + deploy + drop)
-- DROP COLUMN | Irreversível | Verificar que nenhuma app usa → DROP
-- ALTER COLUMN TYPE incompatível | Bloqueia | Nova coluna + backfill + swap
-- CREATE INDEX (sem CONCURRENTLY) | Bloqueia | CREATE INDEX CONCURRENTLY
-- ADD CONSTRAINT sem NOT VALID | Bloqueia | ADD CONSTRAINT NOT VALID + VALIDATE separado
-- DROP TABLE | Irreversível | Soft delete primeiro → confirmar → DROP
-- TRUNCATE | Irreversível | Só em staging/dev ou após backup confirmado
-- ── ADD COLUMN com DEFAULT constante (PostgreSQL 11+) ────────────────────────
-- Desde o PostgreSQL 11, adicionar NOT NULL com DEFAULT constante é instantâneo:
ALTER TABLE produto ADD COLUMN em_destaque BOOLEAN NOT NULL DEFAULT FALSE;
-- O default é guardado no catálogo do sistema — não reescreve as linhas existentes
-- As linhas antigas "vêem" o default virtualmente até serem actualizadas
-- ✅ Seguro em produção sem 3 passos — APENAS com defaults constantes
-- ❌ Não funciona para defaults dinâmicos como NOW() ou nextval()
-- ── RENAME TABLE com zero-downtime ───────────────────────────────────────────
-- Criar a nova tabela, mover dados, criar view com o nome antigo:
CREATE TABLE encomenda_v2 (LIKE encomenda INCLUDING ALL);
INSERT INTO encomenda_v2 SELECT * FROM encomenda;
ALTER TABLE encomenda RENAME TO encomenda_deprecated;
ALTER TABLE encomenda_v2 RENAME TO encomenda;
-- A app continua a funcionar sem alterações de código
-- Ou usar uma view transitória:
ALTER TABLE encomenda RENAME TO pedido;
CREATE VIEW encomenda AS SELECT * FROM pedido; -- compatibilidade retroactiva
-- ── Flyway: undo migrations (versão paga) ────────────────────────────────────
-- U{versão}__{descrição}.sql — executado pelo comando flyway undo
-- db/migration/
-- ├── V5__add_indices_performance.sql
-- └── U5__add_indices_performance.sql ← undo da V5
-- U5__add_indices_performance.sql:
DROP INDEX CONCURRENTLY IF EXISTS idx_encomenda_estado;
DROP INDEX CONCURRENTLY IF EXISTS idx_produto_categoria;
-- ── Flyway community: rollback manual ────────────────────────────────────────
-- Na versão gratuita, não existe undo automático.
-- Padrão: escrever sempre a migração de rollback como uma nova migração (forward-only):
-- V5__add_coluna_risco.sql (migração normal)
ALTER TABLE produto ADD COLUMN margem NUMERIC(5,2);
-- Se correu mal e precisamos de reverter → nova migração:
-- V6__rollback_coluna_risco.sql
ALTER TABLE produto DROP COLUMN IF EXISTS margem;
-- Esta abordagem mantém o histórico completo e nunca altera ficheiros existentes
-- ── Transacções em migrações (PostgreSQL DDL transaccional) ──────────────────
-- No PostgreSQL, DDL é transaccional — uma migração que falha a meio pode ser revertida
-- V7__migration_complexa.sql
BEGIN;
ALTER TABLE utilizador ADD COLUMN preferencias JSONB;
ALTER TABLE utilizador ADD COLUMN notificacoes_email BOOLEAN NOT NULL DEFAULT TRUE;
CREATE INDEX idx_utilizador_preferencias ON utilizador USING GIN (preferencias);
-- Se algo falhar aqui, as alterações acima são revertidas automaticamente
-- A tabela flyway_schema_history não regista esta versão → pode corrigir e re-executar
COMMIT;
-- Flyway executa cada migration dentro de uma transacção por default no PostgreSQL
-- Excepções: CREATE INDEX CONCURRENTLY não pode correr dentro de uma transacção
-- Para esses casos, usar flywaydb.mixed=true ou separar em migrations distintas
-- ── application.properties ───────────────────────────────────────────────────
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=false
# baseline-on-migrate=true: para bases de dados existentes sem histórico Flyway
# Marca a versão actual como "baseline" e só aplica migrações a partir daí
spring.flyway.validate-on-migrate=true
# Verifica checksums antes de executar — detecta ficheiros alterados
spring.flyway.out-of-order=false
# false: rejeita migrações fora de ordem (seguro para produção)
# true: permite (útil quando múltiplos developers criam migrações em paralelo)
spring.flyway.table=flyway_schema_history
# Nome da tabela de controlo (default: flyway_schema_history)
-- ── Estrutura do projecto ─────────────────────────────────────────────────────
-- src/
-- └── main/
-- └── resources/
-- └── db/
-- └── migration/
-- ├── V1__init_schema.sql
-- ├── V2__seed_categorias.sql
-- └── V3__add_auditoria.sql
-- ── Executar migrações via Maven ──────────────────────────────────────────────
-- mvn flyway:migrate ← aplica migrações pendentes
-- mvn flyway:info ← mostra estado de todas as migrações
-- mvn flyway:validate ← verifica checksums sem aplicar
-- mvn flyway:repair ← repara entradas falhadas na tabela de histórico
-- ── Liquibase (alternativa ao Flyway) ────────────────────────────────────────
-- Liquibase usa XML/YAML/JSON em vez de SQL puro:
-- db/changelog/db.changelog-master.yaml
-- databaseChangeLog:
-- - changeSet:
-- id: 4-add-telefone-utilizador
-- author: ruben
-- changes:
-- - addColumn:
-- tableName: utilizador
-- columns:
-- - column:
-- name: telefone
-- type: varchar(30)
-- rollback:
-- - dropColumn:
-- tableName: utilizador
-- columnName: telefone
--
-- Vantagem do Liquibase: rollback automático definido no mesmo ficheiro
-- Vantagem do Flyway: SQL puro — mais legível e portável entre ferramentas
NOT NULL: em PostgreSQL 11+ com defaults constantes é instantâneo; com defaults dinâmicos ou versões anteriores usar 3 passos (add nullable → backfill em batches → set NOT NULL com NOT VALID).CREATE INDEX CONCURRENTLY em produção — nunca CREATE INDEX a seco em tabelas com tráfego.ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT separado para adicionar constraints sem bloquear.BEGIN/COMMIT é revertida automaticamente se falhar.spring.flyway.validate-on-migrate=true detecta ficheiros alterados antes de executar.pg_sleep em backfills massivos — reduz pressure na base de dados e evita lock contention.