Database Migrations com Goose: Versionamento, Embed e Zero-Downtime
Como versionar o esquema do banco com Goose, embutir migrações no binário e aplicar mudanças sem downtime com o padrão expand e contract.
Publicada em · 8 min de leitura
Database Migrations com Goose: Versionamento, Embed e Zero-Downtime
À medida que microsserviços evoluem, a sincronização de esquemas de banco de dados entre ambientes locais, staging e produção torna-se um dos pontos mais críticos da arquitetura de software. Alterações manuais em bancos de dados produzem deriva de schema (schema drift), impossibilitam rollback determinístico e introduzem downtime desnecessário.
O Goose (github.com/pressly/goose/v3) é a ferramenta de migração de banco de dados mais consolidada no ecossistema Go. Ele combina a simplicidade do SQL puro, a possibilidade de migrações programáticas escritas em código Go, suporte a múltiplos dialetos (PostgreSQL, MySQL, SQLite, ClickHouse, etc.) e a capacidade única de embutir arquivos de migração diretamente no binário compilado através do pacote nativo embed do Go.
1. Como o Goose Opera: O Ciclo de Vida das Migrações
O Goose rastreia o histórico de alterações criando automaticamente uma tabela de controle chamada goose_db_version no banco de destino. Cada arquivo de migração recebe um identificador numérico único (timestamp ou sequencial).
Mecânica Interna:
- Inspeção de Estado: O Goose conecta ao banco e consulta a tabela
goose_db_versionpara descobrir qual foi a última versão aplicada (is_applied = true). - Descoberta de Arquivos: O Goose lê o diretório local (ou sistema de arquivos embutido
embed.FS) e compara os números de versão dos arquivos.sqldisponíveis com o estado do banco. - Execução Transacional: Para cada migração pendente, o Goose abre uma transação SQL, executa as instruções delimitadas por
-- +goose Up, insere o novo número de versão na tabelagoose_db_versione confirma oCOMMIT. - Rollback (
Down): Ao reverter uma versão, o Goose executa o bloco-- +goose Downe marca a versão como revertida ou remove o registro correspondente.
2. Anatomia de um Arquivo de Migração SQL
Os arquivos de migração do Goose seguem o padrão <versao>_<descricao>.sql. O conteúdo é dividido por comentários diretivos especiais interpretados pelo parser do Goose.
Exemplo: 00001_create_accounts_table.sql
-- +goose Up
-- SQL executado ao aplicar a migração
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_name VARCHAR(120) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
balance NUMERIC(15, 2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_accounts_email ON accounts (email);
-- +goose Down
-- SQL executado ao reverter a migração
DROP TABLE IF EXISTS accounts;
Lidando com Stored Procedures, Triggers e Blocos complexos
Sistemas como o PostgreSQL usam ponto-e-vírgula (;) internamente em funções PL/pgSQL. Para evitar que o Goose quebre o comando antes da hora, utilizam-se os delimitadores StatementBegin e StatementEnd:
-- +goose Up
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- +goose StatementEnd
CREATE TRIGGER trg_accounts_updated_at
BEFORE UPDATE ON accounts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- +goose Down
DROP TRIGGER IF EXISTS trg_accounts_updated_at ON accounts;
DROP FUNCTION IF EXISTS update_updated_at_column();
Desativando Transações: -- +goose NO TRANSACTION
Certos comandos SQL no PostgreSQL não podem ser executados dentro de um bloco de transação (BEGIN ... COMMIT). O exemplo mais clássico é a criação concorrente de índices para evitar travar leituras e escritas em tabelas de produção:
-- +goose Up
-- +goose NO TRANSACTION
CREATE INDEX CONCURRENTLY idx_accounts_owner_name ON accounts (owner_name);
-- +goose Down
-- +goose NO TRANSACTION
DROP INDEX CONCURRENTLY IF EXISTS idx_accounts_owner_name;
3. Trabalhando com a CLI do Goose
Instalação
go install github.com/pressly/goose/v3/cmd/goose@latest
Comandos Essenciais
| Comando | Descrição | Exemplo de Uso |
|---|---|---|
create | Cria novo arquivo de migração com timestamp | goose create add_phone_to_accounts sql |
up | Aplica todas as migrações pendentes | goose postgres "DSN" up |
up-by-one | Aplica exatamente uma migração à frente | goose postgres "DSN" up-by-one |
down | Reverte a última migração aplicada | goose postgres "DSN" down |
status | Lista todas as migrações e status (Pending/Applied) | goose postgres "DSN" status |
version | Exibe a versão atual do schema no banco | goose postgres "DSN" version |
redo | Reverte a última migração e a aplica novamente | goose postgres "DSN" redo |
reset | Reverte todas as migrações (zera o schema) | goose postgres "DSN" reset |
4. Migrações Embutidas no Binário Go com //go:embed
A prática moderna mais recomendada em Go é compilar as migrações dentro do próprio binário usando o pacote embed. Isso elimina a necessidade de copiar pastas de arquivos .sql para dentro de imagens Docker e garante que o binário contenha exatamente o código e o schema correspondentes.
Estrutura de Diretórios Recomendada
meu-projeto/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ └── database/
│ ├── migrations/
│ │ ├── 00001_create_accounts.sql
│ │ └── 00002_add_status_column.sql
│ └── migrator.go
├── go.mod
└── go.sum
Implementação do Migrator Embutido
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
// RunMigrations aplica todas as migrations embutidas no banco configurado
func RunMigrations(db *sql.DB, dialect string) error {
// 1. Configura o sistema de arquivos virtual
goose.SetBaseFS(embedMigrations)
// 2. Define o dialeto SQL (postgres, mysql, sqlite3, clickhouse, etc)
if err := goose.SetDialect(dialect); err != nil {
return fmt.Errorf("falha ao definir dialeto do goose: %w", err)
}
log.Println("Iniciando verificação e aplicação de migrações pendentes...")
// 3. Aplica todas as migrations encontradas no diretório virtual
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("erro durante execução do goose up: %w", err)
}
version, err := goose.GetDBVersion(db)
if err != nil {
return fmt.Errorf("falha ao obter versão atual do schema: %w", err)
}
log.Printf("Migrações concluídas com sucesso! Versão atual do banco: %d\n", version)
return nil
}
5. O Problema da Concorrência Multi-Pod e Distributed Locks
Em ambientes conteinerizados (Kubernetes, AWS ECS, Google Cloud Run), novas versões de um serviço frequentemente realizam deploy de múltiplas réplicas simultaneamente.
Estratégias de Mitigação:
Abordagem A: Kubernetes Job ou InitContainer Dedicado (Recomendado)
Antes de subir os pods da aplicação, o Helm ou manifesto do Kubernetes executa um Job único e efêmero que roda app migrate up. Somente quando o Job finaliza com sucesso (Exit Code 0), o Deployment dos pods principais é liberado.
Abordagem B: PostgreSQL Advisory Locks no Código Go
Se for mandatório rodar a migração no startup do próprio binário, deve-se adquirir um Distributed Advisory Lock no PostgreSQL antes de chamar goose.Up():
func RunMigrationsWithLock(db *sql.DB) error {
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
// Chave numérica arbitrária única para a aplicação
const migrationLockID = 847291038472
// pg_advisory_lock bloqueia outras instâncias até a conexão ser encerrada ou destravada
log.Println("Aguardando lock distribuído de migração...")
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1);", migrationLockID); err != nil {
return fmt.Errorf("falha ao adquirir pg_advisory_lock: %w", err)
}
defer conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1);", migrationLockID)
// Agora apenas UM pod no cluster executa as migrations por vez
return RunMigrations(db, "postgres")
}
6. Estratégia Zero-Downtime: O Padrão Expand and Contract
Em bancos de dados de produção, nunca execute alterações destrutivas (renomear colunas, remover colunas obrigatórias, mudar tipos de dados) de forma imediata. O padrão Expand and Contract (também conhecido como Parallel Run) garante migrações graduais sem indisponibilidade.
As 3 Fases do Padrão:
- Fase 1: Expand (Expansão Retrocompatível)
- O schema é expandido adicionando o novo campo como
NULLABLEou com valorDEFAULT. - A versão atual da aplicação (v1) continua funcionando sem quebrar.
- A nova versão da aplicação (v2) é implantada e passa a fazer Double Write (escreve no campo antigo e no campo novo, mas lê do antigo).
- O schema é expandido adicionando o novo campo como
- Fase 2: Transition (Migração em Segundo Plano)
- Um script ou background worker itera sobre os dados históricos em pequenos lotes e preenche o novo campo para os registros legados.
- A aplicação v2 agora altera a leitura para apontar exclusivamente para o novo campo.
- Fase 3: Contract (Contração e Limpeza)
- Quando 100% dos dados estiverem migrados e nenhum serviço em produção depender do campo antigo, aplica-se uma nova migração adicionando a restrição
NOT NULLao novo campo e descartando a coluna antiga (DROP COLUMN).
- Quando 100% dos dados estiverem migrados e nenhum serviço em produção depender do campo antigo, aplica-se uma nova migração adicionando a restrição
7. Integração de Testes de Migração com Testcontainers
Para garantir que suas migrations Up e Down funcionam perfeitamente antes de subir qualquer Pull Request, integre o Goose diretamente com Testes de Integração com Testcontainers:
func TestMigrationsLifecycle(t *testing.T) {
ctx := context.Background()
// Sobe um container real do PostgreSQL descartável
pgContainer, err := postgres.Run(ctx, "postgres:16-alpine")
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
defer db.Close()
// 1. Testa a subida de todas as migrations
err = database.RunMigrations(db, "postgres")
require.NoError(t, err, "goose Up não deveria falhar")
// 2. Valida se a versão atual é a esperada
ver, err := goose.GetDBVersion(db)
require.NoError(t, err)
assert.Greater(t, ver, int64(0))
// 3. Testa a reversão de todas as migrations para garantir idempotência do Down
err = goose.Reset(db, "migrations")
require.NoError(t, err, "goose Reset não deveria falhar")
verAfterReset, err := goose.GetDBVersion(db)
require.NoError(t, err)
assert.Equal(t, int64(0), verAfterReset)
}
8. Boas Práticas e Checklist de Produção
- Migrations são Imutáveis: Uma vez mergeada e aplicada em staging ou produção, nunca altere o arquivo
.sqlexistente. Sempre crie uma nova migração corretiva à frente. - Sempre Escreva o Bloco Down: Mesmo que você nunca planeje fazer rollback manual, a presença do
Downgarante testabilidade reversa nos pipelines de CI. - Evite Locks de Longa Duração: Nunca adicione colunas
NOT NULLcom valores calculados dinâmicos em tabelas com milhões de linhas em uma única instrução síncrona; prefiraNULLABLE, popule em lote e aplique a restriçãoNOT NULLna fase de Contração. - Separe Migrations de Schema e Migrations de Dados: Migrações DDL que alteram tabelas devem ser rápidas. Migrações de dados que afetam milhões de linhas devem rodar via jobs assíncronos.
- Use Flags de Linha de Comando no Binário: Permita que o mesmo binário da sua API aceite flags operacionais, por exemplo
./minha-api --migrate-onlyou./minha-api --version.
9. Resumo Conceitual
- O Goose rastreia o histórico do banco na tabela
goose_db_version. - Suporta SQL puro parametrizado, transações atômicas e código Go nativo.
- Use
//go:embedpara compilar as migrations dentro do binário final da aplicação. - Em clusters de microsserviços, previna condições de corrida usando Kubernetes Jobs dedicados ou PostgreSQL Advisory Locks.
- Para atualizações sem indisponibilidade em sistemas críticos, siga rigorosamente o ciclo Expand and Contract.
- Combine o Goose com SQLC - Type-Safe SQL e Testes de Integração com Testcontainers para uma esteira de persistência 100% segura e tipada.