Golang

Go com MySQL e MariaDB: conexão, queries e transações Já leu

11 min de leitura

Go com MySQL e MariaDB: conexão, queries e transações
MySQL é o banco de dados relacional mais usado no mundo, e MariaDB é seu fork comunitário — binário e protocolo compatíveis, o mesmo driver Go funciona para ambos sem alteração. Juntos dominam o mercado de aplicações web

MySQL é o banco de dados relacional mais usado no mundo, e MariaDB é seu fork comunitário — binário e protocolo compatíveis, o mesmo driver Go funciona para ambos sem alteração. Juntos dominam o mercado de aplicações web, especialmente no ecossistema PHP/WordPress, mas são igualmente relevantes em backends Go.

O driver oficial da comunidade é o go-sql-driver/mysql — maduro, amplamente testado e com suporte a todas as funcionalidades modernas do MySQL 5.7+ e MariaDB 10.3+.


Configuração do ambiente

# Instalar o driver
go get github.com/go-sql-driver/mysql

# Subir MySQL com Docker para desenvolvimento
docker run --name mysql-dev \
    -e MYSQL_ROOT_PASSWORD=senha \
    -e MYSQL_DATABASE=app \
    -e MYSQL_USER=usuario \
    -e MYSQL_PASSWORD=senha \
    -p 3306:3306 \
    -d mysql:8.0

# MariaDB é idêntico
docker run --name mariadb-dev \
    -e MARIADB_ROOT_PASSWORD=senha \
    -e MARIADB_DATABASE=app \
    -e MARIADB_USER=usuario \
    -e MARIADB_PASSWORD=senha \
    -p 3306:3306 \
    -d mariadb:11

Estrutura do banco de dados

CREATE DATABASE IF NOT EXISTS app
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;

USE app;

CREATE TABLE categorias (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nome       VARCHAR(100) NOT NULL,
    slug       VARCHAR(100) NOT NULL UNIQUE,
    criado_em  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE produtos (
    id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    categoria_id INT UNSIGNED NOT NULL,
    nome         VARCHAR(200) NOT NULL,
    descricao    TEXT,
    preco        DECIMAL(10, 2) NOT NULL,
    estoque      INT NOT NULL DEFAULT 0,
    ativo        BOOLEAN NOT NULL DEFAULT TRUE,
    criado_em    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
                  ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (categoria_id) REFERENCES categorias(id)
);

CREATE TABLE pedidos (
    id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    cliente_nome VARCHAR(200) NOT NULL,
    total        DECIMAL(10, 2) NOT NULL DEFAULT 0,
    status       ENUM('pendente','confirmado','enviado','entregue','cancelado')
                 NOT NULL DEFAULT 'pendente',
    criado_em    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE itens_pedido (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    pedido_id   INT UNSIGNED NOT NULL,
    produto_id  INT UNSIGNED NOT NULL,
    quantidade  INT NOT NULL,
    preco_unit  DECIMAL(10, 2) NOT NULL,
    FOREIGN KEY (pedido_id) REFERENCES pedidos(id),
    FOREIGN KEY (produto_id) REFERENCES produtos(id)
);

Conexão e configuração do DSN

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "time"

    "github.com/go-sql-driver/mysql"
)

func conectarMySQL() (*sql.DB, error) {
    // Usando a struct Config do driver para configuração tipada
    cfg := mysql.Config{
        User:                 "usuario",
        Passwd:               "senha",
        Net:                  "tcp",
        Addr:                 "localhost:3306",
        DBName:               "app",
        ParseTime:            true,   // converte DATETIME → time.Time
        Loc:                  time.UTC,
        Collation:            "utf8mb4_unicode_ci",
        MultiStatements:      false,  // segurança: desabilita múltiplos statements
        InterpolateParams:    false,  // segurança: sempre usar placeholders
        Timeout:              5 * time.Second,
        ReadTimeout:          30 * time.Second,
        WriteTimeout:         30 * time.Second,
        AllowNativePasswords: true,
    }

    db, err := sql.Open("mysql", cfg.FormatDSN())
    if err != nil {
        return nil, fmt.Errorf("abrir conexão: %w", err)
    }

    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(10)
    db.SetConnMaxLifetime(5 * time.Minute)
    db.SetConnMaxIdleTime(2 * time.Minute)

    ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancelar()

    if err := db.PingContext(ctx); err != nil {
        db.Close()
        return nil, fmt.Errorf("verificar conexão: %w", err)
    }

    log.Println("MySQL conectado com sucesso")
    return db, nil
}

O parâmetro ParseTime=true é essencial — sem ele, campos DATETIME e TIMESTAMP chegam como []byte em vez de time.Time, exigindo conversão manual.


Tipos Go ↔ MySQL

Tipo MySQL Tipo Go recomendado
INT, BIGINT int, int64
INT UNSIGNED uint, uint64
DECIMAL, NUMERIC string ou float64
FLOAT, DOUBLE float64
VARCHAR, TEXT string
BOOLEAN, TINYINT(1) bool
DATETIME, TIMESTAMP time.Time (com ParseTime=true)
DATE time.Time
JSON []byte ou struct com json.Unmarshal
NULL em qualquer tipo sql.NullString, sql.NullInt64, etc.

Valores NULL com sql.Null*

type Produto struct {
    ID          uint
    CategoriaID uint
    Nome        string
    Descricao   sql.NullString  // pode ser NULL no banco
    Preco       float64
    Estoque     int
    Ativo       bool
    CriadoEm   time.Time
    AtualizadoEm time.Time
}

func buscarProduto(ctx context.Context, db *sql.DB, id uint) (*Produto, error) {
    query := `
        SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
               criado_em, atualizado_em
        FROM produtos
        WHERE id = ?
    `

    var p Produto
    err := db.QueryRowContext(ctx, query, id).Scan(
        &p.ID,
        &p.CategoriaID,
        &p.Nome,
        &p.Descricao,   // sql.NullString lida com NULL automaticamente
        &p.Preco,
        &p.Estoque,
        &p.Ativo,
        &p.CriadoEm,
        &p.AtualizadoEm,
    )

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("produto %d não encontrado", id)
    }
    if err != nil {
        return nil, fmt.Errorf("buscarProduto: %w", err)
    }

    return &p, nil
}

func exibirDescricao(p *Produto) {
    if p.Descricao.Valid {
        fmt.Println("Descrição:", p.Descricao.String)
    } else {
        fmt.Println("Descrição: (não informada)")
    }
}

Repositório completo de produtos

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

type ProdutoInput struct {
    CategoriaID uint
    Nome        string
    Descricao   string
    Preco       float64
    Estoque     int
}

type RepoProdutosMySQL struct {
    db *sql.DB
}

func NovoRepoProdutos(db *sql.DB) *RepoProdutosMySQL {
    return &RepoProdutosMySQL{db: db}
}

func (r *RepoProdutosMySQL) Criar(ctx context.Context, input ProdutoInput) (*Produto, error) {
    query := `
        INSERT INTO produtos (categoria_id, nome, descricao, preco, estoque)
        VALUES (?, ?, ?, ?, ?)
    `

    var descNullable sql.NullString
    if input.Descricao != "" {
        descNullable = sql.NullString{String: input.Descricao, Valid: true}
    }

    resultado, err := r.db.ExecContext(ctx, query,
        input.CategoriaID,
        input.Nome,
        descNullable,
        input.Preco,
        input.Estoque,
    )
    if err != nil {
        return nil, fmt.Errorf("criar produto: %w", err)
    }

    id, err := resultado.LastInsertId()
    if err != nil {
        return nil, fmt.Errorf("obter ID: %w", err)
    }

    return r.BuscarPorID(ctx, uint(id))
}

func (r *RepoProdutosMySQL) BuscarPorID(ctx context.Context, id uint) (*Produto, error) {
    query := `
        SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
               criado_em, atualizado_em
        FROM produtos
        WHERE id = ?
    `

    var p Produto
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &p.ID, &p.CategoriaID, &p.Nome, &p.Descricao,
        &p.Preco, &p.Estoque, &p.Ativo, &p.CriadoEm, &p.AtualizadoEm,
    )
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("produto %d não encontrado", id)
    }
    if err != nil {
        return nil, fmt.Errorf("buscar produto: %w", err)
    }

    return &p, nil
}

func (r *RepoProdutosMySQL) Listar(ctx context.Context, apenasAtivos bool, limite, offset int) ([]Produto, error) {
    query := `
        SELECT id, categoria_id, nome, descricao, preco, estoque, ativo,
               criado_em, atualizado_em
        FROM produtos
        WHERE (? = false OR ativo = true)
        ORDER BY nome
        LIMIT ? OFFSET ?
    `

    rows, err := r.db.QueryContext(ctx, query, apenasAtivos, limite, offset)
    if err != nil {
        return nil, fmt.Errorf("listar produtos: %w", err)
    }
    defer rows.Close()

    var produtos []Produto
    for rows.Next() {
        var p Produto
        if err := rows.Scan(
            &p.ID, &p.CategoriaID, &p.Nome, &p.Descricao,
            &p.Preco, &p.Estoque, &p.Ativo, &p.CriadoEm, &p.AtualizadoEm,
        ); err != nil {
            return nil, fmt.Errorf("scan produto: %w", err)
        }
        produtos = append(produtos, p)
    }

    return produtos, rows.Err()
}

func (r *RepoProdutosMySQL) Atualizar(ctx context.Context, id uint, input ProdutoInput) error {
    query := `
        UPDATE produtos
        SET categoria_id = ?, nome = ?, descricao = ?, preco = ?, estoque = ?
        WHERE id = ?
    `

    var descNullable sql.NullString
    if input.Descricao != "" {
        descNullable = sql.NullString{String: input.Descricao, Valid: true}
    }

    resultado, err := r.db.ExecContext(ctx, query,
        input.CategoriaID, input.Nome, descNullable,
        input.Preco, input.Estoque, id,
    )
    if err != nil {
        return fmt.Errorf("atualizar produto: %w", err)
    }

    if n, _ := resultado.RowsAffected(); n == 0 {
        return fmt.Errorf("produto %d não encontrado", id)
    }

    return nil
}

func (r *RepoProdutosMySQL) Deletar(ctx context.Context, id uint) error {
    resultado, err := r.db.ExecContext(ctx,
        "DELETE FROM produtos WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("deletar produto: %w", err)
    }

    if n, _ := resultado.RowsAffected(); n == 0 {
        return fmt.Errorf("produto %d não encontrado", id)
    }

    return nil
}

Transação: criando pedido com itens

O caso de uso mais comum de transações é criar um registro principal e seus registros filhos atomicamente:

type ItemPedidoInput struct {
    ProdutoID  uint
    Quantidade int
}

type PedidoInput struct {
    ClienteNome string
    Itens       []ItemPedidoInput
}

func (r *RepoProdutosMySQL) CriarPedido(ctx context.Context, input PedidoInput) (uint, error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return 0, fmt.Errorf("iniciar transação: %w", err)
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // Insere o pedido
    resultado, err := tx.ExecContext(ctx,
        "INSERT INTO pedidos (cliente_nome) VALUES (?)",
        input.ClienteNome,
    )
    if err != nil {
        return 0, fmt.Errorf("criar pedido: %w", err)
    }

    pedidoID, err := resultado.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("obter ID do pedido: %w", err)
    }

    var totalPedido float64

    for _, item := range input.Itens {
        // Busca preço atual e verifica estoque
        var preco float64
        var estoque int
        err = tx.QueryRowContext(ctx,
            "SELECT preco, estoque FROM produtos WHERE id = ? AND ativo = true FOR UPDATE",
            item.ProdutoID,
        ).Scan(&preco, &estoque)
        if err == sql.ErrNoRows {
            return 0, fmt.Errorf("produto %d não encontrado ou inativo", item.ProdutoID)
        }
        if err != nil {
            return 0, fmt.Errorf("verificar produto: %w", err)
        }

        if estoque < item.Quantidade {
            return 0, fmt.Errorf("produto %d: estoque insuficiente (%d disponíveis)",
                item.ProdutoID, estoque)
        }

        // Insere o item do pedido
        _, err = tx.ExecContext(ctx,
            `INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unit)
             VALUES (?, ?, ?, ?)`,
            pedidoID, item.ProdutoID, item.Quantidade, preco,
        )
        if err != nil {
            return 0, fmt.Errorf("inserir item: %w", err)
        }

        // Atualiza estoque
        _, err = tx.ExecContext(ctx,
            "UPDATE produtos SET estoque = estoque - ? WHERE id = ?",
            item.Quantidade, item.ProdutoID,
        )
        if err != nil {
            return 0, fmt.Errorf("atualizar estoque: %w", err)
        }

        totalPedido += preco * float64(item.Quantidade)
    }

    // Atualiza total do pedido
    _, err = tx.ExecContext(ctx,
        "UPDATE pedidos SET total = ?, status = 'confirmado' WHERE id = ?",
        totalPedido, pedidoID,
    )
    if err != nil {
        return 0, fmt.Errorf("atualizar total: %w", err)
    }

    if err = tx.Commit(); err != nil {
        return 0, fmt.Errorf("confirmar transação: %w", err)
    }

    return uint(pedidoID), nil
}

Query com JOIN

type ProdutoComCategoria struct {
    ProdutoID    uint
    ProdutoNome  string
    Preco        float64
    CategoriaNome string
}

func listarProdutosComCategoria(ctx context.Context, db *sql.DB) ([]ProdutoComCategoria, error) {
    query := `
        SELECT p.id, p.nome, p.preco, c.nome AS categoria_nome
        FROM produtos p
        INNER JOIN categorias c ON c.id = p.categoria_id
        WHERE p.ativo = true
        ORDER BY c.nome, p.nome
    `

    rows, err := db.QueryContext(ctx, query)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var lista []ProdutoComCategoria
    for rows.Next() {
        var item ProdutoComCategoria
        if err := rows.Scan(
            &item.ProdutoID,
            &item.ProdutoNome,
            &item.Preco,
            &item.CategoriaNome,
        ); err != nil {
            return nil, err
        }
        lista = append(lista, item)
    }

    return lista, rows.Err()
}

Migrações com SQL puro

Para projetos sem ORM, migrações com SQL puro são simples e controladas:

package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
)

var migracoes = []struct {
    versao int
    sql    string
}{
    {1, `CREATE TABLE IF NOT EXISTS schema_versao (versao INT PRIMARY KEY)`},
    {2, `ALTER TABLE produtos ADD COLUMN peso_kg DECIMAL(8,3)`},
    {3, `CREATE INDEX idx_produtos_categoria ON produtos(categoria_id)`},
}

func executarMigracoes(ctx context.Context, db *sql.DB) error {
    // Cria tabela de controle se necessário
    _, err := db.ExecContext(ctx,
        `CREATE TABLE IF NOT EXISTS schema_versao (versao INT PRIMARY KEY)`)
    if err != nil {
        return fmt.Errorf("criar tabela de versão: %w", err)
    }

    // Verifica versão atual
    var versaoAtual int
    err = db.QueryRowContext(ctx,
        "SELECT COALESCE(MAX(versao), 0) FROM schema_versao").Scan(&versaoAtual)
    if err != nil {
        return fmt.Errorf("verificar versão: %w", err)
    }

    for _, m := range migracoes {
        if m.versao <= versaoAtual {
            continue
        }

        log.Printf("Aplicando migração %d...", m.versao)
        if _, err := db.ExecContext(ctx, m.sql); err != nil {
            return fmt.Errorf("migração %d: %w", m.versao, err)
        }

        if _, err := db.ExecContext(ctx,
            "INSERT INTO schema_versao (versao) VALUES (?)", m.versao); err != nil {
            return fmt.Errorf("registrar migração %d: %w", m.versao, err)
        }

        log.Printf("Migração %d aplicada com sucesso", m.versao)
    }

    return nil
}

Resumo do que foi coberto

Este artigo apresentou MySQL e MariaDB com Go em profundidade: configuração do DSN com a struct mysql.Config, mapeamento de tipos Go para MySQL, valores NULL com sql.Null*, um repositório completo de produtos com todas as operações CRUD, transações com múltiplos statements e controle de estoque, queries com JOIN e um sistema simples de migrações. O próximo artigo explora SQLite para aplicações que precisam de um banco embutido.


Referências e leituras complementares

  • go-sql-driver/mysql — Documentação oficial do driver MySQL para Go. https://github.com/go-sql-driver/mysql

  • Documentação MySQL 8.0 — Referência completa do MySQL. https://dev.mysql.com/doc/refman/8.0/en/

  • Documentação MariaDB — Referência do MariaDB com compatibilidade MySQL. https://mariadb.com/kb/en/documentation/

  • golang-migrate — Ferramenta popular para migrações de banco de dados em Go. https://github.com/golang-migrate/migrate

  • sqlx — Extensão do database/sql com helpers para scan em structs. https://github.com/jmoiron/sqlx

  • Go database tutorial — Tutorial oficial de acesso a MySQL com Go. https://go.dev/doc/tutorial/database-access

Comentários

Mais em Golang

Padrões de concorrência: fan-out, fan-in e pipelines
Padrões de concorrência: fan-out, fan-in e pipelines

Os artigos anteriores apresentaram as ferramentas: goroutines, channels, sele...

Maps: criação, iteração e boas práticas
Maps: criação, iteração e boas práticas

Se slices s&atilde;o a espinha dorsal das sequ&ecirc;ncias em Go, maps s&atil...

Artigo 31 — Middlewares: logging, autenticação e CORS
Artigo 31 — Middlewares: logging, autenticação e CORS

Artigo 31 — Middlewares: logging, autenticação e CORS Middlewares: comportame...