Pular para o conteúdo

Trilha de Go · parte 18 de 37

database/sql e connection pooling em Go

Como o pool de conexões do database/sql funciona por dentro, quais os quatro parâmetros de capacidade e como controlar transações.

Publicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:

No desenvolvimento de aplicações web e microsserviços de alto desempenho, a forma como sua aplicação interage com o banco de dados relacional determina a sua estabilidade sob pico de carga. Quase todos os incidentes graves de indisponibilidade em sistemas backend não são causados por código de negócio lento, mas por saturação, esgotamento e vazamento de conexões com o banco de dados.

Em Go, a biblioteca padrão fornece o pacote database/sql, uma camada de abstração universal, thread-safe e extremamente performática que gerencia internamente um Pool de Conexões TCP (Connection Pool) completo.

No entanto, o database/sql possui armadilhas conceituais sutis: funções que aparentam conectar mas não conectam, vazamentos silenciosos de sockets causados pela omissão de um único defer e parâmetros padrão de configuração que podem degradar a performance em ambientes corporativos se não forem ajustados.

Esta aula ensina a mecânica interna do database/sql, a anatomia do pool de conexões, a sintonia fina dos 4 parâmetros vitais de capacidade e a implementação de transações ACID resilientes com context.Context.


O Mapa da Aula

ParteSeçãoO que você vai dominar
1. Fundamentos1 e 2A arquitetura de drivers com sql.Register e a pegadinha clássica do sql.Open vs PingContext
2. O Connection Pool por Dentro3 e 4Conexões In-Use, Idle e Wait Queue, e os 4 parâmetros vitais de sintonia fina
3. A Armadilha de Vazamento5 e 6Por que esquecer defer rows.Close() satura o pool e a importância do rows.Err()
4. Transações ACID Seguras7 e 8O padrão infalível de BeginTx com defer tx.Rollback(), isolamento e cancelamento por Context
5. Observabilidade e Produção9 e 10Métricas em tempo real com db.Stats(), código prático completo e checklist de resiliência

Pré-requisitos recomendados: Context e Ponteiros.


Parte 1: Fundamentos e a Ilusão do sql.Open

1. Como o database/sql se conecta aos drivers

O pacote database/sql não sabe como falar o protocolo de rede de nenhum banco de dados específico. Ele define apenas interfaces abstratas e genéricas. Para utilizá-lo com PostgreSQL, MySQL ou SQLite, você precisa importar um driver concreto.

A convenção idiomática do Go utiliza uma importação anônima (com sublinhado _) para que a função init() do driver registre a si mesma no pacote database/sql:

import (
    "database/sql"
    
    // Importação anônima registra o driver sob o nome "pgx" ou "postgres"
    _ "github.com/jackc/pgx/v5/stdlib"
)

Por baixo dos panos, o driver executa a função global sql.Register("pgx", &Driver{}) durante a inicialização do programa.

2. A Armadilha nº 1: sql.Open NÃO abre conexões!

Muitos desenvolvedores acreditam que chamar sql.Open testa se o banco de dados está online. Isso é uma ilusão perigosa:

// ⚠️ CUIDADO: Este código executa com SUCESSO mesmo se o banco estiver desligado!
db, err := sql.Open("pgx", "postgres://usuario:senha@servidor_inexistente:5432/db")
if err != nil {
    // Esse erro só ocorre se a STRING da DSN for malformada sintaticamente!
    log.Fatal(err)
}

O que o sql.Open realmente faz:

  1. Ele apenas inicializa a struct interna do pool de conexões em memória.
  2. Ele valida se o nome do driver solicitado (ex: "pgx") foi devidamente registrado.
  3. Nenhum socket TCP físico é aberto neste momento.

A Verificação Obrigatória com PingContext:

Para garantir que o banco está vivo, aceitando conexões e que as credenciais estão corretas, você deve invocar obrigatoriamente db.PingContext:

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// Estabelece a primeira conexão TCP física e valida a autenticação
if err := db.PingContext(ctx); err != nil {
    log.Fatalf("falha real de conexão com o banco de dados: %v", err)
}

Parte 2: A Anatomia Interna do Connection Pool

O objeto *sql.DB não representa uma conexão única, mas sim um gerenciador de pool concorrente e thread-safe:

anatomia connection pool
figura 1 · anatomia connection pool

Quando centenas de goroutines disparam requisições simultâneas:

  1. Se houver uma conexão ociosa (Idle) no pool, o Go a entrega instantaneamente para a goroutine solicitante.
  2. Se não houver conexões ociosas e o limite máximo (MaxOpenConns) ainda não tiver sido atingido, o Go abre um novo socket TCP físico com o banco.
  3. Se o teto máximo de conexões estiver ocupado, a goroutine é colocada em uma fila de espera (Wait Queue) até que outra goroutine termine sua query e devolva o socket.

3. Os 4 Parâmetros Vitais de Sintonia Fina

Por padrão, as configurações de fábrica do *sql.DB são agressivas: conexões simultâneas ilimitadas e apenas 2 conexões ociosas. Em produção, você deve configurar explicitamente estes 4 métodos:

// 1. Limite máximo de conexões abertas (in-use + idle)
db.SetMaxOpenConns(25)

// 2. Quantidade de conexões mantidas abertas e ociosas no pool
db.SetMaxIdleConns(25)

// 3. Tempo de vida máximo total de uma conexão antes de ser reciclada
db.SetConnMaxLifetime(15 * time.Minute)

// 4. Tempo máximo que uma conexão pode ficar ociosa sem uso
db.SetConnMaxIdleTime(5 * time.Minute)

1. SetMaxOpenConns(n): O Teto de Proteção

  • O que faz: Impõe um limite rígido ao número de sockets que a aplicação pode abrir no banco.
  • Por que é crucial: Servidores de banco (como PostgreSQL) possuem um teto rígido (max_connections = 100). Se 10 instâncias da sua API em Kubernetes tentarem abrir conexões sem limite, o PostgreSQL entrará em colapso retornando o erro fatal: FATAL: sorry, too many clients already.

2. SetMaxIdleConns(n): A Regra de Ouro do Desempenho

  • O que faz: Define quantas conexões prontas ficam no pool esperando a próxima requisição.
  • A Armadilha do Valor Padrão (2): Se sua API processa 20 requisições simultâneas e MaxIdleConns for 2, o Go abrirá 20 conexões, responderá as requisições e, ao final, matará 18 conexões imediatamente, mantendo apenas 2! Na próxima onda de requisições, ele precisará refazer todo o handshake TCP e TLS.
  • Regra de Ouro: Em APIs de alto tráfego, configure SetMaxIdleConns igual ou próximo a SetMaxOpenConns para reaproveitar sockets aquecidos continuamente.

3. SetConnMaxLifetime(d): Prevenção contra Falhas de Infraestrutura

  • O que faz: Fecha e recria a conexão quando ela atinge essa idade, independentemente de estar em uso recente.
  • Por que é crucial: Roteadores de nuvem (AWS NAT Gateways, balanceadores L4) encerram conexões TCP silenciosamente se ficarem ativas por muito tempo. Além disso, se o banco mudar de IP devido a um failover do AWS Aurora ou rotação de DNS, conexões com tempo de vida infinito continuariam presas à instância antiga morta.

4. SetConnMaxIdleTime(d): Economia de Recursos

  • Encerra conexões ociosas que não recebem tráfego há vários minutos, aliviando o consumo de RAM do banco durante a madrugada ou períodos de calmaria.

Parte 3: O Vazamento Silencioso de Conexões (rows.Close)

A principal causa de congelamento (freeze) de APIs Go em produção é o esgotamento do pool causado por esquecer de fechar o *sql.Rows:

ciclo vida query rows close
figura 2 · ciclo vida query rows close

4. Por que o *sql.Rows retém a conexão física?

Quando você executa db.QueryContext, o banco não devolve todos os 10.000 registros para a memória da aplicação de uma só vez. Ele estabelece um cursor e transmite pacotes de dados sob demanda via socket enquanto você invoca rows.Next().

Isso significa que enquanto o objeto rows estiver aberto, a conexão TCP física fica presa no estado In-Use. Nenhuma outra goroutine pode utilizá-la.

O Código Perigoso:

// ❌ CUIDADO: Vazamento fatal de conexão!
rows, err := db.QueryContext(ctx, "SELECT id, nome FROM usuarios")
if err != nil {
    return err
}
// Se esquecer rows.Close(), a conexão NUNCA volta para o pool!

for rows.Next() {
    var u Usuario
    if err := rows.Scan(&u.ID, &u.Nome); err != nil {
        return err // 🚨 PÂNICO: Se der erro no Scan, a função sai e o socket fica preso para sempre!
    }
}

Se o rows.Scan falhar na terceira linha, a função retorna imediatamente, o loop for não chega ao final e o socket TCP fica retido indefinidamente. Após algumas dezenas de requisições com erro, todas as conexões do pool ficam presas e sua aplicação inteira congela.

5. O Padrão Canônico à Prova de Falhas

A regra absoluta em Go para qualquer consulta de múltiplas linhas é adicionar defer rows.Close() imediatamente após checar o erro da query:

//  Padrão de Ouro Obrigatório
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
    return nil, err
}
defer rows.Close() // MANDATÓRIO: Garante devolução ao pool em qualquer saída

for rows.Next() {
    if err := rows.Scan(...); err != nil {
        return nil, err // O defer fechará e devolverá a conexão com segurança!
    }
}

// Checagem obrigatória pós-loop: detecta erros de rede ocorridos durante o streaming
if err := rows.Err(); err != nil {
    return nil, fmt.Errorf("erro no streaming de linhas: %w", err)
}

Parte 4: Transações ACID com context.Context

Transações agrupam múltiplas operações de escrita com garantia de atomicidade (tudo é aplicado com sucesso ou nada é modificado).

No Go, transações utilizam a struct *sql.Tx. Ao iniciar uma transação, o database/sql reserva uma conexão física TCP exclusiva do pool, vinculando todas as operações daquele bloco à mesma conexão até que ocorra um Commit() ou Rollback().

6. O Padrão Infalível de Rollback com defer

O maior risco em transações é uma função retornar no meio devido a um erro inesperado e esquecer de desfazer a transação, mantendo travas de tabela (table locks) abertas no banco.

A convenção canônica do Go resolve isso com elegância utilizando defer tx.Rollback():

func TransferirFundos(ctx context.Context, db *sql.DB, origemID, destinoID int, valor float64) error {
    // 1. Inicia a transação vinculada ao contexto
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return fmt.Errorf("falha ao iniciar transação: %w", err)
    }
    
    // 2. REGRA DE OURO: Defer Rollback imediato
    // Se tx.Commit() tiver sucesso mais abaixo, o tx.Rollback() vira um no-op inofensivo!
    defer tx.Rollback()

    // 3. Operação 1: Debita origem
    _, err = tx.ExecContext(ctx, "UPDATE contas SET saldo = saldo - $1 WHERE id = $2", valor, origemID)
    if err != nil {
        return fmt.Errorf("erro ao debitar: %w", err) // Rollback executado automaticamente pelo defer!
    }

    // 4. Operação 2: Credita destino
    _, err = tx.ExecContext(ctx, "UPDATE contas SET saldo = saldo + $1 WHERE id = $2", valor, destinoID)
    if err != nil {
        return fmt.Errorf("erro ao creditar: %w", err) // Rollback executado automaticamente pelo defer!
    }

    // 5. Se todas as etapas passaram, efetiva as alterações
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("erro ao comitar transação: %w", err)
    }

    return nil
}

Por que esse padrão é brilhante?

A especificação do database/sql garante que invocar Rollback() após um Commit() bem-sucedido não gera nenhum erro nem efeito colateral (ele retorna o erro sentinela sql.ErrTxDone e é ignorado). Se qualquer linha do meio falhar com erro ou disparar um pânico, o defer é executado na saída do escopo, revertendo a transação e liberando a conexão física de volta para o pool.


Parte 5: Observabilidade do Pool com db.Stats()

Você não precisa esperar o banco cair para saber se o seu pool de conexões está saudável. O método nativo db.Stats() entrega uma fotografia completa das métricas internas do pool em tempo de execução:

stats := db.Stats()

fmt.Printf("Conexões Abertas: %d\n", stats.OpenConnections)
fmt.Printf("Em Uso (Ativas): %d\n", stats.InUse)
fmt.Printf("Ociosas (Idle): %d\n", stats.Idle)
fmt.Printf("Total de Esperas por Conexão: %d\n", stats.WaitCount)
fmt.Printf("Tempo Total Esperando Conexão: %v\n", stats.WaitDuration)

Sinais de Alerta Críticos (Red Flags):

  • Se stats.WaitCount estiver crescendo continuamente, suas goroutines estão brigando por conexões. Aumente SetMaxOpenConns ou otimize as queries lentas.
  • Se stats.InUse estiver travado no valor máximo mesmo sem requisições chegando, você tem um vazamento de conexões (rows.Close() esquecido) no código.

Parte 6: Código Prático Completo de Produção

Abaixo está a implementação completa de um serviço de banco de dados robusto, demonstrando inicialização segura, pooling configurado, transação atômica e consulta com streaming protegido.

O Serviço de Banco (servico_banco.go):

package bancodados

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

type Conta struct {
	ID    int
	Saldo float64
}

type ServicoContas struct {
	db *sql.DB
}

// NovoServicoContas inicializa o pool com parâmetros de produção e valida conectividade
func NovoServicoContas(driverName, dsn string) (*ServicoContas, error) {
	// 1. Inicializa o pool em memória
	db, err := sql.Open(driverName, dsn)
	if err != nil {
		return nil, fmt.Errorf("falha ao configurar DSN: %w", err)
	}

	// 2. Sintonia fina do Connection Pool
	db.SetMaxOpenConns(20)                  // Limite máximo de conexões simultâneas
	db.SetMaxIdleConns(20)                  // Mantém conexões aquecidas no pool
	db.SetConnMaxLifetime(10 * time.Minute) // Reciclagem preventiva de sockets
	db.SetConnMaxIdleTime(2 * time.Minute)  // Liberação de sockets ociosos

	// 3. Validação real da conexão física com o banco
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("servidor de banco inacessível: %w", err)
	}

	return &ServicoContas{db: db}, nil
}

func (s *ServicoContas) Close() error {
	return s.db.Close()
}

// Transferir executa uma transferência bancária com atomicidade ACID
func (s *ServicoContas) Transferir(ctx context.Context, origemID, destinoID int, valor float64) error {
	if valor <= 0 {
		return errors.New("o valor da transferência deve ser positivo")
	}

	tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return fmt.Errorf("falha ao abrir transação: %w", err)
	}
	defer tx.Rollback() // Seguro: reverte se não houver Commit

	// 1. Consulta saldo da conta de origem
	var saldoOrigem float64
	queryOrigem := "SELECT saldo FROM contas WHERE id = $1 FOR UPDATE"
	if err := tx.QueryRowContext(ctx, queryOrigem, origemID).Scan(&saldoOrigem); err != nil {
		return fmt.Errorf("conta de origem não encontrada: %w", err)
	}

	if saldoOrigem < valor {
		return errors.New("saldo insuficiente para transferência")
	}

	// 2. Deduz da conta de origem
	_, err = tx.ExecContext(ctx, "UPDATE contas SET saldo = saldo - $1 WHERE id = $2", valor, origemID)
	if err != nil {
		return fmt.Errorf("falha ao debitar: %w", err)
	}

	// 3. Acrescenta na conta de destino
	res, err := tx.ExecContext(ctx, "UPDATE contas SET saldo = saldo + $1 WHERE id = $2", valor, destinoID)
	if err != nil {
		return fmt.Errorf("falha ao creditar: %w", err)
	}

	linhas, _ := res.RowsAffected()
	if linhas == 0 {
		return errors.New("conta de destino não localizada")
	}

	// 4. Efetiva transação
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("falha ao comitar transação: %w", err)
	}

	return nil
}

// ListarTodasContas demonstra a prevenção obrigatória contra vazamento de sockets
func (s *ServicoContas) ListarTodasContas(ctx context.Context) ([]Conta, error) {
	query := "SELECT id, saldo FROM contas ORDER BY id ASC"
	rows, err := s.db.QueryContext(ctx, query)
	if err != nil {
		return nil, fmt.Errorf("erro na consulta: %w", err)
	}
	// MANDATÓRIO: Garante devolução da conexão para o pool
	defer rows.Close()

	var contas []Conta
	for rows.Next() {
		var c Conta
		if err := rows.Scan(&c.ID, &c.Saldo); err != nil {
			return nil, fmt.Errorf("erro ao mapear linha: %w", err)
		}
		contas = append(contas, c)
	}

	// Validação final de streaming
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("erro no fluxo de leitura: %w", err)
	}

	return contas, nil
}

Parte 7: Checklist de Resiliência para Produção

  1. Nunca use sql.Open sem db.PingContext(ctx): Garanta que a aplicação só suba se o banco responder no startup.
  2. Sempre adicione defer rows.Close() logo após db.QueryContext: Não confie que o for rows.Next() chegará até o fim; erros no Scan causam vazamento imediato de sockets se o defer for omitido.
  3. Configure SetMaxIdleConns próximo a SetMaxOpenConns: Evite a destruição e recriação frenética de sockets TCP a cada oscilação de tráfego.
  4. Defina sempre SetConnMaxLifetime: Previne falhas silenciosas de rede decorrentes de balanceadores de carga da nuvem e garante suporte a failover de DNS.
  5. Sempre passe context.Context em todas as chamadas de banco: Use QueryContext, ExecContext e BeginTx. Se o cliente HTTP cancelar a requisição ou o timeout expirar, o Go cancelará a consulta diretamente no servidor de banco de dados, poupando CPU e memória.

Exercício Prático Proposto

  1. Crie uma tabela produtos com campos id, nome e quantidade.
  2. Configure um *sql.DB com SetMaxOpenConns(2).
  3. Dispare 10 goroutines concorrentes executando queries com time.Sleep simulado dentro do banco.
  4. Inspecione db.Stats() a cada segundo e observe o crescimento da métrica WaitCount comprovando o funcionamento da fila de espera do pool.

↑ 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
?