Migrations com golang-migrate

1. Introdução ao golang-migrate

Migrations são uma forma controlada e versionada de gerenciar alterações no esquema do banco de dados. Em projetos Go, onde a simplicidade e a previsibilidade são valorizadas, o uso de migrations traz benefícios como rastreabilidade, reprodutibilidade e facilidade de deploy.

O golang-migrate (v4) é uma biblioteca leve e independente que oferece suporte a múltiplos bancos de dados (PostgreSQL, MySQL, SQLite, etc.) e fontes de arquivos (sistema de arquivos, embed, S3, GitHub). Diferente de soluções acopladas a ORMs como GORM ou Goose, o golang-migrate funciona de forma desacoplada, permitindo que você use qualquer driver SQL nativo.

Comparado a outras ferramentas:
- GORM AutoMigrate: prático para desenvolvimento, mas perigoso em produção por não versionar alterações destrutivas.
- Goose: similar em filosofia, mas com menos drivers e fontes disponíveis.
- sql-migrate: depende de arquivos YAML e é menos flexível para uso programático.

O golang-migrate se destaca por sua API limpa, CLI nativa e ampla gama de adaptadores.

2. Instalação e Configuração Inicial

Para instalar a CLI do migrate:

go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

A tag postgres compila com suporte ao driver PostgreSQL. Para outros bancos, use mysql, sqlite, etc.

A estrutura de diretórios recomendada:

migrations/
├── 000001_create_users_table.up.sql
├── 000001_create_users_table.down.sql
├── 000002_add_email_unique.up.sql
└── 000002_add_email_unique.down.sql

Cada migration é composta por um par de arquivos: {versão}_{descricao}.up.sql e {versão}_{descricao}.down.sql.

3. Criando Migrations: UP e DOWN

Para criar uma nova migration via CLI:

migrate create -ext sql -dir migrations -seq create_users_table

Isso gera dois arquivos. O .up.sql contém as alterações a aplicar:

-- 000001_create_users_table.up.sql
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

O .down.sql contém o rollback:

-- 000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;

Boas práticas:
- Use IF NOT EXISTS/IF EXISTS para tornar as migrations idempotentes.
- Mantenha cada migration atômica (uma única alteração lógica).
- Versionamento sequencial (000001, 000002...).
- Nunca edite uma migration já aplicada em produção — crie uma nova.

4. Conectando ao Banco de Dados com pgx

O golang-migrate suporta nativamente o driver pgx (recomendado para PostgreSQL). A string de conexão segue o formato padrão:

package main

import (
    "database/sql"
    "fmt"
    "log"
    "os"

    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/pgx"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        dbURL = "postgres://user:password@localhost:5432/mydb?sslmode=disable"
    }

    db, err := sql.Open("pgx", dbURL)
    if err != nil {
        log.Fatal("Erro ao conectar:", err)
    }
    defer db.Close()

    driver, err := pgx.WithInstance(db, &pgx.Config{})
    if err != nil {
        log.Fatal("Erro ao criar driver:", err)
    }

    fmt.Println("Conexão estabelecida com sucesso")
}

Use variáveis de ambiente para evitar expor credenciais no código.

5. Executando Migrations em Código

A execução programática oferece controle total sobre o processo:

package main

import (
    "log"
    "os"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/pgx"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    migrationsPath := "file://migrations"
    dbURL := os.Getenv("DATABASE_URL")

    m, err := migrate.New(migrationsPath, dbURL)
    if err != nil {
        log.Fatal("Erro ao criar migrate:", err)
    }
    defer m.Close()

    // Aplica todas as migrations pendentes
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        log.Fatal("Erro no Up:", err)
    }

    // Controle granular com Steps
    // m.Steps(2)  // Sobe 2 migrations
    // m.Steps(-1) // Desce 1 migration

    // Rollback completo
    // m.Down()

    // Forçar versão em caso de dirty state
    // m.Force(1)
}

Para verificar o estado atual:

version, dirty, err := m.Version()
if err != nil {
    log.Fatal(err)
}
log.Printf("Versão atual: %d, Dirty: %v", version, dirty)

6. Migrations em Pipeline CI/CD

Em ambientes de staging e produção, a execução deve ser segura e controlada.

Estratégia recomendada:

  1. Container de inicialização: Use um init container no Kubernetes ou script de pré-deploy que execute as migrations.
  2. Lock de banco: O golang-migrate usa locks nativos do banco (ex: pg_advisory_lock no PostgreSQL) para evitar concorrência.
  3. Rollback automatizado: Em caso de falha, registre o erro e pare o deploy.

Exemplo de script para deploy:

package main

import (
    "log"
    "os"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/pgx"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func runMigrations() error {
    m, err := migrate.New("file://migrations", os.Getenv("DATABASE_URL"))
    if err != nil {
        return err
    }
    defer m.Close()

    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }
    return nil
}

func main() {
    if err := runMigrations(); err != nil {
        log.Fatal("Falha nas migrations:", err)
    }
    log.Println("Migrations aplicadas com sucesso")
}

7. Boas Práticas e Troubleshooting

Versionamento semântico: Nomeie migrations com prefixo numérico sequencial. Migrações irreversíveis (como remoção de colunas) devem ser evitadas ou ter .down.sql bem testado.

Logging: Habilite logs detalhados em desenvolvimento:

import "github.com/golang-migrate/migrate/v4/logger"

m.Log = &logger.Default{}

Problemas comuns:

  1. Dirty state: Ocorre quando uma migration falha no meio da execução. Solução: m.Force(version) para marcar como limpa e corrigir manualmente.
  2. Conflitos de versão: Evite editar migrations já aplicadas. Crie uma nova migration corretiva.
  3. Timeout: Migrações longas (grandes volumes de dados) podem exceder o timeout padrão. Configure database/sql com SetConnMaxLifetime.

Exemplo de tratamento de dirty state:

version, dirty, err := m.Version()
if dirty {
    log.Printf("Banco em dirty state na versão %d. Aplicando force...", version)
    if err := m.Force(int(version)); err != nil {
        log.Fatal("Erro ao forçar versão:", err)
    }
    // Corrija manualmente o banco antes de continuar
}

Referências