Go Admin

O que Todo Dev Deve Saber sobre PostgreSQL com Go: pgx Driver, Transactions e Connection Pool Já leu

Introdução ao pgx: Por que ele é a melhor escolha para Go Quando você começar a trabalhar com PostgreSQL em Go, rapidamente descobrirá que existem várias bibliotecas disponíveis. O pgx é amplamente considerado a melhor opção da comunidade, e não é por acaso. Diferentemente do driver puro , o pgx foi construído especificamente para PostgreSQL, aproveitando seus recursos nativos como tipos customizados, prepared statements eficientes e melhor tratamento de erros. O pgx oferece dois níveis de abstração: a interface padrão (compatibilidade) e sua própria API de baixo nível, que é mais expressiva e performática. Para aplicações sérias, você usará principalmente a API do pgx diretamente. A escolha é sua, mas neste artigo focaremos na abordagem direta com pgx, que é o padrão da indústria para sistemas de alta performance. Instalação e Configuração Inicial Instalando o pgx Comece adicionando o pgx ao seu projeto Go: Se você também precisar de suporte , instale o driver: Estabelecendo sua primeira conexão A forma

Introdução ao pgx: Por que ele é a melhor escolha para Go

Quando você começar a trabalhar com PostgreSQL em Go, rapidamente descobrirá que existem várias bibliotecas disponíveis. O pgx é amplamente considerado a melhor opção da comunidade, e não é por acaso. Diferentemente do driver puro database/sql, o pgx foi construído especificamente para PostgreSQL, aproveitando seus recursos nativos como tipos customizados, prepared statements eficientes e melhor tratamento de erros.

O pgx oferece dois níveis de abstração: a interface database/sql padrão (compatibilidade) e sua própria API de baixo nível, que é mais expressiva e performática. Para aplicações sérias, você usará principalmente a API do pgx diretamente. A escolha é sua, mas neste artigo focaremos na abordagem direta com pgx, que é o padrão da indústria para sistemas de alta performance.

Instalação e Configuração Inicial

Instalando o pgx

Comece adicionando o pgx ao seu projeto Go:

go get github.com/jackc/pgx/v5

Se você também precisar de suporte database/sql, instale o driver:

go get github.com/jackc/pgx/v5/stdlib

Estabelecendo sua primeira conexão

A forma correta de trabalhar com bancos de dados é sempre usar um context. O pgx foi desenhado com esta filosofia desde o início. Aqui está um exemplo funcional:

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5"
)

func main() {
    // URL de conexão PostgreSQL
    dbURL := "postgres://usuario:senha@localhost:5432/meu_banco"

    // context com timeout para evitar travamentos
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // Conecta ao banco
    conn, err := pgx.Connect(ctx, dbURL)
    if err != nil {
        log.Fatalf("Erro ao conectar: %v", err)
    }
    defer conn.Close(ctx)

    // Testa a conexão
    var greeting string
    err = conn.QueryRow(ctx, "select 'PostgreSQL com pgx funcionando!'").Scan(&greeting)
    if err != nil {
        log.Fatalf("Erro na query: %v", err)
    }

    fmt.Println(greeting)
}

A chave aqui é entender que tudo em pgx é context-aware. Você passa um context em cada operação, permitindo que a aplicação tenha controle fino sobre timeouts e cancelamento. Não force a barra — sempre use contexts corretamente.

Connection Pool e Gerenciamento de Recursos

O papel crítico do pool de conexões

Uma conexão TCP é um recurso caro. Criar uma nova conexão para cada requisição destruiria a performance de qualquer aplicação. Por isso, usamos um pool — reutilizamos conexões existentes quando disponíveis. O pgx fornece pgxpool, que é exatamente isso: um pool robusto e thread-safe de conexões.

Configurando um pool profissional

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()

    // Configuração manual do pool
    config, err := pgxpool.ParseConfig("postgres://usuario:senha@localhost:5432/meu_banco")
    if err != nil {
        log.Fatalf("Erro ao fazer parse da config: %v", err)
    }

    // Ajustes de performance
    config.MaxConns = 25                    // máximo de conexões ativas
    config.MinConns = 5                     // mínimo mantido aquecido
    config.MaxConnLifetime = 5 * time.Minute // reconectar a cada 5 minutos
    config.MaxConnIdleTime = 2 * time.Minute // fechar se inativa por 2 min
    config.HealthCheckPeriod = 30 * time.Second // verificar saúde a cada 30s

    // Criar o pool
    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        log.Fatalf("Erro ao criar pool: %v", err)
    }
    defer pool.Close()

    // Verificar a conexão
    err = pool.Ping(ctx)
    if err != nil {
        log.Fatalf("Ping falhou: %v", err)
    }

    fmt.Println("Pool de conexões criado e verificado com sucesso!")

    // Usar o pool em uma operação
    var name string
    err = pool.QueryRow(ctx, "SELECT 'Olá do pool'::text").Scan(&name)
    if err != nil {
        log.Fatalf("Erro: %v", err)
    }

    fmt.Println(name)
}

Os valores que você define aqui são críticos. Se MaxConns é muito baixo, sua aplicação ficará enfileirada esperando conexões. Se é muito alto, você sobrecarregará o servidor PostgreSQL. Para a maioria das aplicações web com Go, 20-30 é um bom ponto de partida. Ajuste conforme monitorar a métrica de "espera por conexão".

Transações: O Coração da Integridade de Dados

Entendendo transações ACID

Uma transação é um conjunto de operações que ou todas completam com sucesso, ou nenhuma toma efeito. Isto garante a consistência do seus dados. No mundo PostgreSQL com pgx, transações são a forma correta de fazer múltiplas operações relacionadas.

Implementando transações corretamente

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

// Função que demonstra uma transação real
func transferirSaldo(pool *pgxpool.Pool, contaOrigem, contaDestino int64, valor float64) error {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Iniciar transação explicitamente
    tx, err := pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("falha ao iniciar transação: %w", err)
    }

    // IMPORTANTE: usar defer para rollback em caso de erro
    defer tx.Rollback(ctx)

    // Primeira operação: deduzir da conta origem
    var saldoOrigem float64
    err = tx.QueryRow(ctx, 
        "UPDATE contas SET saldo = saldo - $1 WHERE id = $2 RETURNING saldo",
        valor, contaOrigem).Scan(&saldoOrigem)
    if err != nil {
        return fmt.Errorf("falha ao debitar: %w", err)
    }

    if saldoOrigem < 0 {
        return fmt.Errorf("saldo insuficiente")
    }

    // Segunda operação: adicionar à conta destino
    _, err = tx.Exec(ctx,
        "UPDATE contas SET saldo = saldo + $1 WHERE id = $2",
        valor, contaDestino)
    if err != nil {
        return fmt.Errorf("falha ao creditar: %w", err)
    }

    // Registrar a transação
    _, err = tx.Exec(ctx,
        "INSERT INTO historico (conta_origem, conta_destino, valor, data) VALUES ($1, $2, $3, $4)",
        contaOrigem, contaDestino, valor, time.Now())
    if err != nil {
        return fmt.Errorf("falha ao registrar: %w", err)
    }

    // Se chegou aqui, tudo correu bem. Fazer commit.
    err = tx.Commit(ctx)
    if err != nil {
        return fmt.Errorf("falha ao fazer commit: %w", err)
    }

    return nil
}

func main() {
    config, _ := pgxpool.ParseConfig("postgres://usuario:senha@localhost:5432/meu_banco")
    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    // Executar a transferência
    err := transferirSaldo(pool, 1, 2, 100.50)
    if err != nil {
        log.Printf("Transferência falhou: %v", err)
    } else {
        fmt.Println("Transferência concluída com sucesso!")
    }
}

Note a estrutura: você inicia a transação, executa operações, e sempre faz defer do rollback antes de qualquer coisa. Se tudo correr bem, você faz commit explicitamente, o que cancela o rollback deferred. Se qualquer erro ocorrer, o defer do rollback garante que nada foi alterado no banco. Isto é uma pattern que você verá em todo código Go profissional.

Níveis de isolamento e configurações avançadas

// Se você precisar de mais controle, use TxOptions
func operacaoComIsolamento(pool *pgxpool.Pool) error {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Usar SERIALIZABLE para máxima segurança (mais lento)
    txOptions := pgx.TxOptions{
        Isolation: pgx.Serializable,
        AccessMode: pgx.ReadWrite,
    }

    tx, err := pool.BeginTx(ctx, txOptions)
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx)

    // ... suas operações aqui ...

    return tx.Commit(ctx)
}

Os níveis de isolamento (Read Uncommitted, Read Committed, Repeatable Read, Serializable) oferecem diferentes graus de segurança contra problemas de concorrência. Para 99% dos casos, o padrão Read Committed é adequado. Use Serializable apenas se realmente precisar — ele é significativamente mais lento.

Savepoints: divisão dentro de transações

func operacaoComSavepoint(pool *pgxpool.Pool) error {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    tx, _ := pool.Begin(ctx)
    defer tx.Rollback(ctx)

    // Primeira parte da operação
    _, _ = tx.Exec(ctx, "INSERT INTO logs (mensagem) VALUES ('Etapa 1')")

    // Criar um savepoint (ponto de restauração)
    _, _ = tx.Exec(ctx, "SAVEPOINT sp1")

    // Segunda parte — se falhar, volta só até o savepoint
    err := tx.QueryRow(ctx, "SELECT 1 WHERE FALSE").Scan()

    if err != nil {
        // Rollback apenas até o savepoint, não até o início
        tx.Exec(ctx, "ROLLBACK TO sp1")
        // Continuar a transação é possível
        _, _ = tx.Exec(ctx, "INSERT INTO logs (mensagem) VALUES ('Recuperado do erro')")
    }

    return tx.Commit(ctx)
}

Savepoints são úteis em operações complexas onde você quer poder recuperar de erro parcial sem abandonar toda a transação. Contudo, na maioria dos casos, estruturar seu código para falhas atômicas (tudo ou nada) é mais limpo.

Queries Eficientes, Prepared Statements e Batch Operations

Por que prepared statements importam

Prepared statements compilam a query uma vez no servidor e reutilizam o plano de execução para múltiplas execuções com parâmetros diferentes. Isto melhora performance e, mais importante, protege contra SQL injection.

package main

import (
    "context"
    "log"

    "github.com/jackc/pgx/v5/pgxpool"
)

func buscarUsuariosPorCidade(pool *pgxpool.Pool, cidade string) ([]string, error) {
    ctx := context.Background()

    // pgx usa prepared statements automaticamente internamente
    // Você não precisa fazer nada especial — basta usar placeholders $1, $2, etc.
    rows, err := pool.Query(ctx, 
        "SELECT nome FROM usuarios WHERE cidade = $1 ORDER BY nome",
        cidade)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var nomes []string
    for rows.Next() {
        var nome string
        if err := rows.Scan(&nome); err != nil {
            return nil, err
        }
        nomes = append(nomes, nome)
    }

    return nomes, rows.Err()
}

func main() {
    config, _ := pgxpool.ParseConfig("postgres://usuario:senha@localhost:5432/meu_banco")
    pool, _ := pgxpool.NewWithConfig(context.Background(), config)
    defer pool.Close()

    nomes, err := buscarUsuariosPorCidade(pool, "São Paulo")
    if err != nil {
        log.Fatal(err)
    }

    for _, nome := range nomes {
        println(nome)
    }
}

Operações em batch para performance extrema

Quando você precisa inserir ou atualizar milhares de registros, fazer uma query por vez é loucura. pgx oferece Batch, que agrupa múltiplas queries e as envia em uma única viagem de rede:

func inserirMuitosRegistros(pool *pgxpool.Pool, dados []map[string]interface{}) error {
    ctx := context.Background()

    // Criar um batch
    batch := &pgx.Batch{}

    // Adicionar múltiplas queries ao batch
    for _, d := range dados {
        batch.Queue(
            "INSERT INTO pessoas (nome, email, idade) VALUES ($1, $2, $3)",
            d["nome"], d["email"], d["idade"])
    }

    // Executar tudo de uma vez
    results := pool.SendBatch(ctx, batch)
    defer results.Close()

    // Verificar resultados
    for i := 0; i < len(dados); i++ {
        _, err := results.Exec()
        if err != nil {
            return err
        }
    }

    return nil
}

Para um cenário com 10.000 inserts, batch operations são ordens de magnitude mais rápidas que loop com queries individuais.

Scanning eficiente com RowToStructTag

import "github.com/jackc/pgx/v5/pgtype"

type Usuario struct {
    ID    int    `db:"id"`
    Nome  string `db:"nome"`
    Email string `db:"email"`
}

func buscarUsuarios(pool *pgxpool.Pool) ([]Usuario, error) {
    ctx := context.Background()

    rows, _ := pool.Query(ctx, "SELECT id, nome, email FROM usuarios")
    defer rows.Close()

    // pgx.CollectRows automáticamente mapeia colunas para campos da struct
    usuarios, err := pgx.CollectRows(rows, pgx.RowToStructByName[Usuario])
    if err != nil {
        return nil, err
    }

    return usuarios, nil
}

Isto elimina o tedioso trabalho de fazer .Scan() manualmente para cada campo.

Tratamento de Erros e Resiliência

Diferenciando tipos de erro

Nem todo erro é igual. Alguns são transitórios (rede caiu momentaneamente), outros são permanentes (violação de constraint):

import (
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgconn"
)

func operacaoRobusta(pool *pgxpool.Pool) error {
    ctx := context.Background()

    _, err := pool.Exec(ctx, "INSERT INTO usuarios (email) VALUES ($1)", "teste@example.com")
    if err == nil {
        return nil
    }

    // Verificar se é um erro específico do PostgreSQL
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        switch pgErr.Code {
        case "23505": // unique_violation
            return fmt.Errorf("email já existe no banco")
        case "23502": // not_null_violation
            return fmt.Errorf("campo obrigatório vazio")
        case "23503": // foreign_key_violation
            return fmt.Errorf("referência inválida")
        default:
            return fmt.Errorf("erro do banco: %s", pgErr.Message)
        }
    }

    // Verificar se a conexão foi perdida (transitório)
    if errors.Is(err, pgx.ErrNoRows) {
        return fmt.Errorf("nenhum resultado encontrado")
    }

    return err
}

Conhecer os códigos de erro PostgreSQL é essencial para tratamento profissional.

Retry com backoff exponencial

import "time"

func operacaoComRetry(pool *pgxpool.Pool, maxTentativas int) error {
    var lastErr error

    for tentativa := 0; tentativa < maxTentativas; tentativa++ {
        err := operacaoQuePodefalhar(pool)
        if err == nil {
            return nil
        }

        lastErr = err

        // Não fazer retry para erros permanentes
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && !isTransient(pgErr.Code) {
            return err
        }

        // Esperar com backoff exponencial: 1s, 2s, 4s, etc.
        espera := time.Duration(math.Pow(2, float64(tentativa))) * time.Second
        time.Sleep(espera)
    }

    return fmt.Errorf("operação falhou após %d tentativas: %w", maxTentativas, lastErr)
}

func isTransient(code string) bool {
    // Código "08" é server-specific, "09" é triggered action exception, etc.
    return code == "40001" || code == "40P01" // serialization_failure, deadlock_detected
}

Conclusão

Você aprendeu três conceitos fundamentais que separam Go profissional de código de hobby:

  1. Connection pools não são opcionais — use pgxpool sempre. Conexões são caras; reutilizá-las é a diferença entre uma aplicação que aguenta 100 requisições/segundo e uma que aguenta 10.000.

  2. Transações garantem integridade — a pattern defer-rollback é sagrada. Se você não entendeu por que sempre fazer defer tx.Rollback(ctx) antes de tx.Commit(ctx), releia a seção de transações até ficar claro.

  3. Detalhes importam — contexts com timeouts, prepared statements automáticos, batch operations, tratamento específico de erros PostgreSQL. Cada um destes detalhes é a diferença entre código que passa em testes pequenos e código que funciona em produção com 10 milhões de requisições por dia.

Referências


Artigos relacionados