Pular para o conteúdo

Trilha de Go · parte 22 de 37

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

Tamanho da fonte:
Largura da coluna:

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).

Ciclo de Vida de Migrações com Goose
figura 1 · Ciclo de Vida de Migrações com Goose

Mecânica Interna:

  1. Inspeção de Estado: O Goose conecta ao banco e consulta a tabela goose_db_version para descobrir qual foi a última versão aplicada (is_applied = true).
  2. 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 .sql disponíveis com o estado do banco.
  3. 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 tabela goose_db_version e confirma o COMMIT.
  4. Rollback (Down): Ao reverter uma versão, o Goose executa o bloco -- +goose Down e 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

ComandoDescriçãoExemplo de Uso
createCria novo arquivo de migração com timestampgoose create add_phone_to_accounts sql
upAplica todas as migrações pendentesgoose postgres "DSN" up
up-by-oneAplica exatamente uma migração à frentegoose postgres "DSN" up-by-one
downReverte a última migração aplicadagoose postgres "DSN" down
statusLista todas as migrações e status (Pending/Applied)goose postgres "DSN" status
versionExibe a versão atual do schema no bancogoose postgres "DSN" version
redoReverte a última migração e a aplica novamentegoose postgres "DSN" redo
resetReverte 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.

Padrão Expand and Contract para Migrações Zero-Downtime
figura 2 · Padrão Expand and Contract para Migrações Zero-Downtime

As 3 Fases do Padrão:

  1. Fase 1: Expand (Expansão Retrocompatível)
    • O schema é expandido adicionando o novo campo como NULLABLE ou com valor DEFAULT.
    • 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).
  2. 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.
  3. 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 NULL ao novo campo e descartando a coluna antiga (DROP COLUMN).

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 .sql existente. 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 Down garante testabilidade reversa nos pipelines de CI.
  • Evite Locks de Longa Duração: Nunca adicione colunas NOT NULL com valores calculados dinâmicos em tabelas com milhões de linhas em uma única instrução síncrona; prefira NULLABLE, popule em lote e aplique a restrição NOT NULL na 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-only ou ./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:embed para 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.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?