Declaração do Problema
Precisa de evoluir o esquema da sua base de dados ao longo do tempo, mantendo zero downtime, garantindo a integridade dos dados e suportando capacidades de rollback.
Princípios de Migração
| Princípio | Descrição |
|---|
| Backward Compatible | A versão antiga da aplicação funciona com o novo esquema |
| Forward Compatible | A nova versão da aplicação funciona com o esquema antigo (durante o rollout) |
| Reversible | Cada migração tem um script de rollback |
| Idempotent | Executar a migração várias vezes produz o mesmo resultado |
| Small & Incremental | Preferir muitas migrações pequenas a alterações «big-bang» |
Padrões de Migração Seguros
1. Adicionar uma Coluna (Seguro)
-- Migração: add_email_verified_column
-- Seguro: nova coluna com valor por defeito; ainda não há leituras que dependam dela
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false;
-- O código da aplicação pode ser feito deploy antes ou depois
-- O código antigo ignora a nova coluna; o novo código utiliza-a
2. Renomear uma Coluna (Multi-Etapas)
Passo 1: Adicionar nova coluna
-- Migração 001: add_full_name_column
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
Passo 2: Preencher dados (backfill)
-- Migração 002: backfill_full_name
UPDATE users SET full_name = CONCAT(first_name, ' ', last_name) WHERE full_name IS NULL;
Passo 3: Fazer deploy de código que escreve em ambas e lê da nova
// Versão 2 do código: escrita dupla
class User extends Model
{
protected $fillable = ['first_name', 'last_name', 'full_name'];
public function save(array $options = [])
{
// Escrever em ambas durante a transição
if ($this->isDirty('first_name') || $this->isDirty('last_name')) {
$this->full_name = $this->first_name . ' ' . $this->last_name;
}
return parent::save($options);
}
public function getDisplayNameAttribute()
{
// Ler da nova coluna; fallback para a antiga
return $this->full_name ?? ($this->first_name . ' ' . $this->last_name);
}
}
Passo 4: Remover colunas antigas (após todos os pods estarem no novo código)
-- Migração 003: remove_old_name_columns (executar após o deploy completo)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
3. Adicionar Restrição NOT NULL (Multi-Etapas)
-- Passo 1: adicionar a coluna como nullable
ALTER TABLE orders ADD COLUMN customer_id INTEGER;
-- Passo 2: fazer backfill das linhas existentes
UPDATE orders SET customer_id = (
SELECT id FROM customers WHERE customers.email = orders.customer_email
);
-- Passo 3: adicionar a restrição (após o backfill estar concluído)
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;
-- Passo 4: adicionar foreign key
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
4. Remover uma Coluna (Sequência Segura)
// Passo 1: deixar de ler a coluna no código
// Remover todas as queries SELECT que incluam a coluna
// Passo 2: deixar de escrever na coluna
// Remover todas as instruções INSERT/UPDATE que definam a coluna
// Passo 3: fazer deploy e verificar que não há erros
// Passo 4: remover a coluna (migração)
-- Migração: drop_legacy_status_column
-- Executar apenas após as alterações de código estarem em produção
ALTER TABLE orders DROP COLUMN legacy_status;
Ferramentas de Migração Específicas por Framework
Laravel Migrations
// database/migrations/2024_01_15_create_orders_table.php
class CreateOrdersTable extends Migration
{
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('status')->default('pending');
$table->decimal('total', 10, 2);
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
});
}
public function down()
{
Schema::dropIfExists('orders');
}
}
// Executar migrações
php artisan migrate
// Rollback do último batch
php artisan migrate:rollback
// Rollback de passos específicos
php artisan migrate:rollback --step=3
Ruby on Rails Migrations
# db/migrate/20240115_add_tracking_to_orders.rb
class AddTrackingToOrders < ActiveRecord::Migration[7.0]
# Desativar transação para tabelas grandes
disable_ddl_transaction!
def change
# Adicionar coluna com índice concorrente
add_column :orders, :tracking_number, :string
add_index :orders, :tracking_number,
algorithm: :concurrently,
if_not_exists: true
end
end
# Executar migrações
rails db:migrate
# Rollback
rails db:rollback STEP=1
Django Migrations
# orders/migrations/0002_add_tracking.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='tracking_number',
field=models.CharField(max_length=100, null=True, blank=True),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['tracking_number'], name='tracking_idx'),
),
]
# Executar migrações
python manage.py migrate
# Criar migração a partir de alterações no model
python manage.py makemigrations
# Rollback
python manage.py migrate orders 0001
Migrações em Tabelas Grandes
Alteração de Esquema Online com pt-online-schema-change (MySQL)
# Adicionar coluna a uma tabela grande sem bloquear
pt-online-schema-change \
--alter "ADD COLUMN new_field VARCHAR(255)" \
--execute \
--host=localhost \
--user=root \
--ask-pass \
D=mydb,t=large_table
Índice Concorrente no PostgreSQL
-- Criar índice sem bloquear escritas
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created_at);
-- Se falhar, remover primeiro o índice inválido
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created;
Atualizações em Lote para Tabelas Grandes
// Atualização em lote no Laravel
class BackfillUserSettings extends Migration
{
public function up()
{
// Processar em lotes para evitar problemas de memória
User::query()
->whereNull('settings')
->chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->update(['settings' => json_encode(['theme' => 'light'])]);
}
});
}
}
# Atualização em lote no Rails
class BackfillUserSettings < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def up
User.where(settings: nil).find_in_batches(batch_size: 1000) do |batch|
User.where(id: batch.map(&:id)).update_all(settings: '{"theme": "light"}')
sleep(0.1) # Reduzir a carga na base de dados
end
end
end
Migração no Pipeline de CI/CD
Job de Migração no GitLab CI
migrate-database:
stage: deploy
image: myapp:${CI_COMMIT_SHA}
script:
# Verificar migrações pendentes
- php artisan migrate:status
# Executar migrações
- php artisan migrate --force
# Verificar migração
- php artisan migrate:status | grep -q "No pending migrations"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- database/migrations/**/*
environment:
name: production
Kubernetes Job para Migrações
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration-${CI_COMMIT_SHA}
namespace: production
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z postgres 5432; do sleep 2; done']
containers:
- name: migrate
image: myapp:${CI_COMMIT_SHA}
command: ["php", "artisan", "migrate", "--force"]
envFrom:
- secretRef:
name: app-secrets
Estratégias de Rollback de Migração
Suporte de Rollback ao Nível da Aplicação
// Feature flag para rollback
class OrderController extends Controller
{
public function store(Request $request)
{
$order = new Order();
$order->user_id = auth()->id();
$order->total = $request->total;
// Novo campo, com feature flag
if (Feature::active('new-order-tracking')) {
$order->tracking_number = $this->generateTracking();
}
$order->save();
return response()->json($order);
}
}
Snapshot da Base de Dados Antes da Migração
#!/bin/bash
# pre-migration-backup.sh
set -e
BACKUP_NAME="pre-migration-$(date +%Y%m%d-%H%M%S)"
# Criar snapshot
pg_dump $DATABASE_URL > /tmp/${BACKUP_NAME}.sql
# Carregar para o S3
aws s3 cp /tmp/${BACKUP_NAME}.sql s3://db-backups/migrations/${BACKUP_NAME}.sql
# Guardar o nome do backup para eventual rollback
echo $BACKUP_NAME > /tmp/last-migration-backup
echo "Backup created: $BACKUP_NAME"
Testes de Migração
Testar Migrações em CI
test-migrations:
stage: test
services:
- postgres:15
variables:
DATABASE_URL: postgres://test:test@postgres:5432/test
script:
# Aplicar todas as migrações
- php artisan migrate --force
# Executar migrações down
- php artisan migrate:rollback --step=999
# Executar up novamente
- php artisan migrate --force
# Semear (seed) e verificar
- php artisan db:seed
- php artisan test --filter=DatabaseTest
Testes com Base de Dados Shadow
test-migration-on-copy:
stage: pre-deploy
script:
# Criar cópia da base de dados de produção
- pg_dump $PROD_DATABASE_URL | psql $SHADOW_DATABASE_URL
# Executar migração na shadow
- DATABASE_URL=$SHADOW_DATABASE_URL php artisan migrate --force
# Executar smoke tests
- DATABASE_URL=$SHADOW_DATABASE_URL php artisan test --filter=SmokeTest
# Remover a base de dados shadow
- psql $SHADOW_DATABASE_URL -c "DROP DATABASE shadow_db"
Checklist de Migração
Antes da Migração
- [ ] Confirmar a propriedade dos dados e as dependências
- [ ] Migração testada em ambiente de staging com dados semelhantes aos de produção
- [ ] Script de rollback testado
- [ ] Backup da base de dados criado e verificado com um exercício de restauro
- [ ] Equipa notificada da janela de migração
- [ ] Dashboards de monitorização prontos
Durante a Migração
- [ ] Monitorizar CPU e I/O da base de dados
- [ ] Monitorizar taxas de erro da aplicação
- [ ] Monitorizar latência das queries
- [ ] Monitorizar tempos de espera por locks
- [ ] Verificar replication lag (se aplicável)
Após a Migração
- [ ] Verificar que a migração foi concluída com sucesso
- [ ] Executar smoke tests
- [ ] Verificar logs da aplicação quanto a erros
- [ ] Executar verificações de reconciliação (comparar contagens de registos, somas ou hashes)
- [ ] Atualizar a documentação
- [ ] Limpar colunas/tabelas antigas (após período de tolerância)
A Mentalidade de um Senior de Base de Dados
Engenheiros seniores tratam as bases de dados como infraestrutura crítica. Investem em:
- Backups e exercícios de recuperação - Nunca executar uma migração grande sem um backup verificado
- Clareza na propriedade dos dados - Antes de alterar uma base de dados, definir que sistema é dono de cada dataset
- Padrões seguros de evolução do esquema - Passos aditivos, backward-compatible, com pontos de rollback
- Verificações de reconciliação - Comparação automatizada de contagens de registos e integridade dos dados
Armadilhas Comuns a Evitar
- Executar migrações grandes sem backups testados
- Introduzir alterações de esquema incompatíveis sem versionamento
- Ignorar replication lag durante backfills pesados
- Adicionar índices sem validar o impacto nas queries
- Falhar na documentação dos passos de migração
Trate as migrações como alterações de produção, não como scripts pontuais. Quando as operações de base de dados são previsíveis e seguras, todo o sistema se torna mais fácil de evoluir.
Artigos Relacionados na Wiki