Introdução ao database/sql e drivers em Go

Introdução ao database/sql e drivers em Go

O pacote database/sql em Go: drivers e import em branco, o pool de conexões e a conta com várias réplicas, QueryRow, Query e o rows que trava o pool se não for fechado, Exec, transações com defer tx.Rollback, prepared statements, placeholders contra SQL injection e db.Stats.
Golang

• • 16 min de leitura

Go oferece o pacote database/sql como interface universal para bancos de dados relacionais. Ele define um conjunto de tipos e métodos que funcionam da mesma forma independentemente do banco usado — MySQL, PostgreSQL, SQLite ou qualquer outro que tenha um driver compatível.

Essa separação entre interface e implementação é elegante: o código da aplicação interage apenas com database/sql, enquanto o driver específico do banco fica isolado em uma dependência registrada via init(). Trocar de banco de dados, em teoria, exige mudar apenas o driver e o DSN de conexão.

Arquitetura: interface e drivers

Aplicação Go
     │
     ▼
database/sql  ← interface unificada (biblioteca padrão)
     │
     ▼
Driver         ← implementação específica (dependência externa)
     │
     ▼
Banco de Dados ← MySQL, PostgreSQL, SQLite, etc.

Os drivers mais usados:

Banco Driver Módulo
MySQL / MariaDB go-sql-driver/mysql github.com/go-sql-driver/mysql
PostgreSQL lib/pq (em modo de manutenção; o próprio projeto recomenda o pgx) github.com/lib/pq
PostgreSQL pgx github.com/jackc/pgx/v5/stdlib
SQLite mattn/go-sqlite3 github.com/mattn/go-sqlite3
SQLite (puro Go) modernc/sqlite modernc.org/sqlite

Registrando um driver

Drivers se registram automaticamente através de seu init() quando importados com _:

package main

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

    _ "github.com/go-sql-driver/mysql"  // registra o driver MySQL
)

func main() {
    // DSN: usuario:senha@protocolo(host:porta)/banco?parametros
    dsn := "root:senha@tcp(localhost:3306)/minha_aplicacao?parseTime=true&charset=utf8mb4"

    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal("Erro ao abrir conexão:", err)
    }
    defer db.Close()

    // sql.Open NÃO estabelece conexão — apenas valida o DSN
    // Ping verifica a conexão real
    if err := db.Ping(); err != nil {
        log.Fatal("Banco inacessível:", err)
    }

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

O pool de conexões

sql.DB não é uma única conexão — é um pool de conexões gerenciado automaticamente. Configurar o pool corretamente é essencial para performance e estabilidade:

func configurarPool(db *sql.DB) {
    // Máximo de conexões abertas simultâneas
    // Padrão: 0 (ilimitado) — sempre configure explicitamente
    db.SetMaxOpenConns(25)

    // Máximo de conexões ociosas no pool
    // Padrão: 2 — aumentar reduz overhead de criação de conexões
    db.SetMaxIdleConns(10)

    // Tempo máximo de vida de uma conexão
    // Útil quando o banco tem timeout de conexão ociosa
    db.SetConnMaxLifetime(5 * time.Minute)

    // Tempo máximo que uma conexão ociosa pode ficar no pool
    db.SetConnMaxIdleTime(2 * time.Minute)
}

Valores recomendados dependem da carga, mas um ponto de partida razoável para um serviço web:

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

Inicialização completa e verificada

Uma função de inicialização robusta para qualquer banco:

package main

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

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

type ConfigDB struct {
    Driver          string
    DSN             string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
    ConnMaxIdleTime time.Duration
}

func abrirBancoDados(config ConfigDB) (*sql.DB, error) {
    db, err := sql.Open(config.Driver, config.DSN)
    if err != nil {
        return nil, fmt.Errorf("abrir banco: %w", err)
    }

    db.SetMaxOpenConns(config.MaxOpenConns)
    db.SetMaxIdleConns(config.MaxIdleConns)
    db.SetConnMaxLifetime(config.ConnMaxLifetime)
    db.SetConnMaxIdleTime(config.ConnMaxIdleTime)

    // Verifica conectividade com timeout
    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)
    }

    return db, nil
}

func main() {
    config := ConfigDB{
        Driver:          "mysql",
        DSN:             "root:senha@tcp(localhost:3306)/app?parseTime=true",
        MaxOpenConns:    25,
        MaxIdleConns:    10,
        ConnMaxLifetime: 5 * time.Minute,
        ConnMaxIdleTime: 2 * time.Minute,
    }

    db, err := abrirBancoDados(config)
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    defer db.Close()

    fmt.Println("Banco de dados pronto")
}

QueryRow: buscando um único registro

type Usuario struct {
    ID        int
    Nome      string
    Email     string
    CriadoEm time.Time
}

func buscarUsuarioPorID(ctx context.Context, db *sql.DB, id int) (*Usuario, error) {
    query := `
        SELECT id, nome, email, criado_em
        FROM usuarios
        WHERE id = ?
    `

    var u Usuario
    err := db.QueryRowContext(ctx, query, id).Scan(
        &u.ID,
        &u.Nome,
        &u.Email,
        &u.CriadoEm,
    )

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

    return &u, nil
}

sql.ErrNoRows é o erro retornado quando nenhuma linha é encontrada — é uma condição esperada, não um erro de banco de dados. Sempre trate-o separadamente.

Query: buscando múltiplos registros

func listarUsuariosAtivos(ctx context.Context, db *sql.DB, limite int) ([]Usuario, error) {
    query := `
        SELECT id, nome, email, criado_em
        FROM usuarios
        WHERE ativo = true
        ORDER BY nome
        LIMIT ?
    `

    rows, err := db.QueryContext(ctx, query, limite)
    if err != nil {
        return nil, fmt.Errorf("listarUsuarios: %w", err)
    }
    defer rows.Close() // CRÍTICO: sempre fechar rows

    var usuarios []Usuario
    for rows.Next() {
        var u Usuario
        if err := rows.Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm); err != nil {
            return nil, fmt.Errorf("listarUsuarios scan: %w", err)
        }
        usuarios = append(usuarios, u)
    }

    // Verificar erro após iteração — pode indicar problema de rede ou cursor
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("listarUsuarios rows: %w", err)
    }

    return usuarios, nil
}

O defer rows.Close() é obrigatório — Rows ocupa uma conexão do pool enquanto não for fechado. Esquecer o Close em código de alta carga esgota o pool.

Exec: inserindo, atualizando e deletando

func criarUsuario(ctx context.Context, db *sql.DB, nome, email, senhaHash string) (int64, error) {
    query := `
        INSERT INTO usuarios (nome, email, senha_hash, ativo, criado_em)
        VALUES (?, ?, ?, true, NOW())
    `

    resultado, err := db.ExecContext(ctx, query, nome, email, senhaHash)
    if err != nil {
        return 0, fmt.Errorf("criarUsuario: %w", err)
    }

    // ID gerado pelo banco para o novo registro
    id, err := resultado.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("obter ID inserido: %w", err)
    }

    return id, nil
}

func atualizarEmail(ctx context.Context, db *sql.DB, id int, novoEmail string) error {
    query := `UPDATE usuarios SET email = ?, atualizado_em = NOW() WHERE id = ?`

    resultado, err := db.ExecContext(ctx, query, novoEmail, id)
    if err != nil {
        return fmt.Errorf("atualizarEmail: %w", err)
    }

    linhasAfetadas, err := resultado.RowsAffected()
    if err != nil {
        return fmt.Errorf("verificar linhas afetadas: %w", err)
    }
    if linhasAfetadas == 0 {
        return fmt.Errorf("usuário %d não encontrado", id)
    }

    return nil
}

func deletarUsuario(ctx context.Context, db *sql.DB, id int) error {
    resultado, err := db.ExecContext(ctx,
        "DELETE FROM usuarios WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("deletarUsuario: %w", err)
    }

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

    return nil
}

Transações

Transações garantem que um conjunto de operações seja executado atomicamente — ou todas têm sucesso, ou nenhuma persiste:

func transferirSaldo(
    ctx context.Context,
    db *sql.DB,
    contaOrigemID,
    contaDestinoID int,
    valor float64,
) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelReadCommitted,
        ReadOnly:  false,
    })
    if err != nil {
        return fmt.Errorf("iniciar transação: %w", err)
    }

    // Garante rollback em caso de erro ou pânico
    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p) // repropaga o pânico
        }
    }()

    // Verifica saldo da conta de origem com lock
    var saldo float64
    err = tx.QueryRowContext(ctx,
        "SELECT saldo FROM contas WHERE id = ? FOR UPDATE",
        contaOrigemID,
    ).Scan(&saldo)
    if err != nil {
        tx.Rollback()
        return fmt.Errorf("verificar saldo: %w", err)
    }

    if saldo < valor {
        tx.Rollback()
        return fmt.Errorf("saldo insuficiente: %.2f < %.2f", saldo, valor)
    }

    // Debita da origem
    _, err = tx.ExecContext(ctx,
        "UPDATE contas SET saldo = saldo - ? WHERE id = ?",
        valor, contaOrigemID,
    )
    if err != nil {
        tx.Rollback()
        return fmt.Errorf("debitar origem: %w", err)
    }

    // Credita no destino
    _, err = tx.ExecContext(ctx,
        "UPDATE contas SET saldo = saldo + ? WHERE id = ?",
        valor, contaDestinoID,
    )
    if err != nil {
        tx.Rollback()
        return fmt.Errorf("creditar destino: %w", err)
    }

    // Registra a transferência
    _, err = tx.ExecContext(ctx,
        `INSERT INTO transferencias (origem_id, destino_id, valor, realizada_em)
         VALUES (?, ?, ?, NOW())`,
        contaOrigemID, contaDestinoID, valor,
    )
    if err != nil {
        tx.Rollback()
        return fmt.Errorf("registrar transferência: %w", err)
    }

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

    return nil
}

Repetir tx.Rollback() em cada ramo de erro funciona, mas é fácil esquecer um. A forma idiomática é registrar defer tx.Rollback() logo depois do BeginTx e remover todas as chamadas avulsas, inclusive o bloco com recover: se a função retornar antes do Commit, por erro ou pânico, a transação é desfeita; se o Commit já aconteceu, o Rollback adiado não faz nada e apenas devolve sql.ErrTxDone, como um teste com SQLite confirmou. Um detalhe à parte: saldos em float64 herdam os problemas de arredondamento do ponto flutuante — em sistemas financeiros, guarde centavos em inteiros ou use o tipo DECIMAL do banco com uma biblioteca decimal.

Prepared statements

Para queries executadas repetidamente, prepared statements melhoram performance e segurança:

type RepositorioUsuarios struct {
    db           *sql.DB
    stmtBuscar   *sql.Stmt
    stmtListar   *sql.Stmt
    stmtCriar    *sql.Stmt
}

func NovoRepositorioUsuarios(db *sql.DB) (*RepositorioUsuarios, error) {
    stmtBuscar, err := db.Prepare(
        "SELECT id, nome, email FROM usuarios WHERE id = ?")
    if err != nil {
        return nil, fmt.Errorf("preparar buscar: %w", err)
    }

    stmtListar, err := db.Prepare(
        "SELECT id, nome, email FROM usuarios ORDER BY nome LIMIT ?")
    if err != nil {
        stmtBuscar.Close()
        return nil, fmt.Errorf("preparar listar: %w", err)
    }

    stmtCriar, err := db.Prepare(
        "INSERT INTO usuarios (nome, email, criado_em) VALUES (?, ?, NOW())")
    if err != nil {
        stmtBuscar.Close()
        stmtListar.Close()
        return nil, fmt.Errorf("preparar criar: %w", err)
    }

    return &RepositorioUsuarios{
        db:         db,
        stmtBuscar: stmtBuscar,
        stmtListar: stmtListar,
        stmtCriar:  stmtCriar,
    }, nil
}

func (r *RepositorioUsuarios) Fechar() {
    r.stmtBuscar.Close()
    r.stmtListar.Close()
    r.stmtCriar.Close()
}

func (r *RepositorioUsuarios) BuscarPorID(ctx context.Context, id int) (*Usuario, error) {
    var u Usuario
    err := r.stmtBuscar.QueryRowContext(ctx, id).Scan(&u.ID, &u.Nome, &u.Email)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return &u, err
}

SQL injection: nunca interpolação de strings

O placeholder ? (MySQL) ou $1 (PostgreSQL) é a proteção contra SQL injection. Nunca construa queries concatenando strings com dados do usuário:

// NUNCA FAÇA ISSO
query := fmt.Sprintf("SELECT * FROM usuarios WHERE nome = '%s'", nome)
// nome = "'; DROP TABLE usuarios; --"  ← SQL injection

// SEMPRE USE PLACEHOLDERS
query := "SELECT * FROM usuarios WHERE nome = ?"
db.QueryContext(ctx, query, nome) // o valor viaja separado do comando SQL e nunca é interpretado como código

Monitorando o pool com sql.DBStats

func logEstatisticasPool(db *sql.DB, logger *slog.Logger) {
    stats := db.Stats()
    logger.Info("pool de conexões",
        "abertas",        stats.OpenConnections,
        "em_uso",         stats.InUse,
        "ociosas",        stats.Idle,
        "esperas",        stats.WaitCount,
        "tempo_espera",   stats.WaitDuration,
        "max_ociosas",    stats.MaxIdleClosed,
        "max_vida",       stats.MaxLifetimeClosed,
    )
}

O database/sql separa o que o código pede do que cada banco faz, e o que ele entrega em troca é um pool de conexões que precisa ser tratado como recurso escasso. Quase todos os problemas de produção vêm de conexões que não voltam ao pool — o rows não fechado, a transação sem Rollback num caminho de erro — ou de limites que ignoram quantas réplicas existem. Com defer rows.Close() e defer tx.Rollback() escritos por hábito, placeholders em todos os valores e db.Stats() no monitoramento, o resto são detalhes de cada banco, tema dos próximos artigos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma função de listagem faz db.QueryContext, lê só a primeira linha para montar um resumo e retorna, sem chamar rows.Close(). Com pouco tráfego, nada acontece. Num pico, o serviço inteiro para de responder, com as requisições esperando o banco, embora o banco esteja ocioso. O que está preso, e como confirmar?

Ver resposta

✓ Resposta: Estão presas as conexões do pool. Enquanto um *sql.Rows não é fechado, nem lido até o fim, ele segura a conexão que o produziu, e a conexão não volta ao pool. Cada chamada da função "empresta" uma conexão para sempre, e, quando o limite de SetMaxOpenConns é atingido, toda consulta seguinte fica esperando uma conexão livre que não vai aparecer. O banco está ocioso porque, para ele, as conexões estão abertas e paradas. Num teste com SQLite e pool de uma conexão, um rows lido pela metade e não fechado fez a consulta seguinte esperar até o prazo e falhar com context deadline exceeded; depois do rows.Close(), tudo voltou ao normal. A confirmação em produção vem de db.Stats(): InUse igual ao máximo, Idle em zero e WaitCount subindo sem parar. A correção é o defer rows.Close() na linha seguinte ao QueryContext, como o artigo insiste, e, quando só uma linha interessa, usar QueryRowContext, que libera a conexão sozinho ao chamar Scan.

Exercício 2

A função transferirSaldo do artigo chama tx.Rollback() em cada um dos cinco caminhos de erro e ainda tem um defer com recover. Um novo desenvolvedor acrescenta uma sexta etapa e esquece o Rollback no erro dela. O que acontece com a transação e com a conexão? Mostre a forma que torna esse esquecimento impossível.

Ver resposta

✓ Resposta: A função retorna com a transação aberta. A conexão continua reservada para ela, e os locks que ela obteve, como o SELECT ... FOR UPDATE sobre a conta de origem, continuam valendo. Outras transferências da mesma conta ficam bloqueadas até o banco ou o driver desistirem, e o pool perde uma conexão, com os mesmos sintomas do exercício anterior. A forma robusta é defer tx.Rollback() logo depois do BeginTx, sem nenhuma outra chamada de Rollback e sem o recover. Em qualquer saída antes do Commit, seja por erro, retorno antecipado ou pânico, o defer desfaz a transação. Depois de um Commit bem-sucedido, o Rollback adiado não faz nada: num teste com SQLite, ele devolveu sql: transaction has already been committed or rolled back, que é o sql.ErrTxDone, e o dado gravado continuou lá. É o mesmo padrão do defer mu.Unlock(): a liberação fica declarada uma única vez, junto da aquisição.

Exercício 3

O método BuscarPorID do repositório com prepared statements devolve nil, nil quando o usuário não existe. Um handler faz u, err := repo.BuscarPorID(ctx, id), confere só o erro e acessa u.Nome. O que acontece com um ID inexistente, e qual contrato é melhor?

Ver resposta

✓ Resposta: O handler entra em pânico com nil pointer dereference ao ler u.Nome. O contrato nil, nil obriga todo chamador a lembrar de uma segunda verificação, u == nil, que o compilador não exige e que o padrão if err != nil não cobre. Basta um chamador esquecer. Há contratos melhores. O mais comum em Go é devolver um erro que o chamador reconhece: o próprio sql.ErrNoRows embrulhado, ou um erro de domínio, como var ErrNaoEncontrado = errors.New("usuário não encontrado"), testado com errors.Is. Num teste com SQLite, a consulta sem linha devolveu sql: no rows in result set, e errors.Is(err, sql.ErrNoRows) deu true. Com um erro sentinela do próprio repositório, o handler traduz "não encontrado" em 404 sem saber que existe um banco SQL por trás. Outra forma é devolver (Usuario, bool, error), deixando a ausência explícita na assinatura. Qualquer um dos dois é melhor que um nil que parece sucesso.

Exercício 4

Um serviço roda em 20 réplicas, cada uma com db.SetMaxOpenConns(25), e o banco MySQL está configurado com max_connections = 300. Num pico, parte das réplicas passa a receber Error 1040: Too many connections. O artigo chama 25 de ponto de partida razoável. O que ficou faltando na conta?

Ver resposta

✓ Resposta: Faltou multiplicar. O limite do pool é por processo: 20 réplicas com até 25 conexões cada podem abrir até 500 conexões, e o banco aceita 300. No tráfego normal, cada réplica usa poucas conexões e nada acontece; no pico, todas crescem ao mesmo tempo até o máximo, e as que chegam por último são recusadas. Somam-se ainda as conexões de migrações, jobs, ferramentas de administração e monitoramento. A conta certa parte do banco: reserve uma folga para administração e divida o resto pelo número máximo de réplicas que o autoescalonamento pode criar, e não pelo número atual. Com 300 e uma folga de 50, cabem 12 conexões por réplica para 20 réplicas. Se o número for baixo demais para a carga, a solução é um pooler de conexões na frente do banco, como o ProxySQL para MySQL ou o PgBouncer para PostgreSQL, ou rever quanto tempo cada requisição segura a conexão. Um WaitCount alto em db.Stats() é o sinal de que o pool local ficou pequeno demais para aquela réplica.

Exercício 5

A seção sobre SQL injection diz que, com placeholder, o valor "é escapado automaticamente pelo driver" (foi a redação original). Por que "escapar" não descreve bem o que acontece, e em que situação um placeholder não protege, obrigando outra solução?

Ver resposta

✓ Resposta: Com placeholders, o caminho normal é o do comando preparado: o texto SQL, com os ? ou $1, vai ao banco num passo, e os valores vão separados, em outro. O banco nunca interpreta o valor como parte do comando, e por isso '; DROP TABLE usuarios; -- é tratado como um nome estranho, e não como SQL. Não há escape de aspas no meio do texto, que é a técnica frágil das bibliotecas antigas. Alguns drivers oferecem, como otimização, interpolar os valores no cliente, e aí sim fazem o escape; o interpolateParams do driver MySQL é um exemplo, e ele é desligado por padrão. A situação em que o placeholder não protege é quando a parte variável não é um valor, e sim um identificador ou palavra-chave: o nome de uma coluna em ORDER BY, a direção ASC ou DESC, o nome de uma tabela. Placeholders só substituem valores. Para esses casos, a solução é uma lista fechada: um map[string]string de "campo que o cliente pede" para "coluna real", e recusar qualquer coisa fora dela, nunca concatenando o texto recebido.

Comentários

Mais em Golang

Estudando e se aprofundando em Go
Estudando e se aprofundando em Go

Por que aprender Go hoje: a linguagem de Docker e Kubernetes, o que ela…

Linting, formatação e análise estática: gofmt, golangci-lint
Linting, formatação e análise estática: gofmt, golangci-lint

Qualidade de código em Go: gofmt, goimports, go vet e staticcheck com exemplos…

Structs: definição, inicialização e campos embutidos
Structs: definição, inicialização e campos embutidos

Structs em Go: literais com nome e posicionais, valor zero, ponteiros, structs…