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
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
| Parte | Seção | O que você vai dominar |
|---|---|---|
| 1. Fundamentos | 1 e 2 | A arquitetura de drivers com sql.Register e a pegadinha clássica do sql.Open vs PingContext |
| 2. O Connection Pool por Dentro | 3 e 4 | Conexões In-Use, Idle e Wait Queue, e os 4 parâmetros vitais de sintonia fina |
| 3. A Armadilha de Vazamento | 5 e 6 | Por que esquecer defer rows.Close() satura o pool e a importância do rows.Err() |
| 4. Transações ACID Seguras | 7 e 8 | O padrão infalível de BeginTx com defer tx.Rollback(), isolamento e cancelamento por Context |
| 5. Observabilidade e Produção | 9 e 10 | Mé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:
- Ele apenas inicializa a struct interna do pool de conexões em memória.
- Ele valida se o nome do driver solicitado (ex:
"pgx") foi devidamente registrado. - 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:
Quando centenas de goroutines disparam requisições simultâneas:
- Se houver uma conexão ociosa (Idle) no pool, o Go a entrega instantaneamente para a goroutine solicitante.
- 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. - 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
MaxIdleConnsfor 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
SetMaxIdleConnsigual ou próximo aSetMaxOpenConnspara 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:
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.WaitCountestiver crescendo continuamente, suas goroutines estão brigando por conexões. AumenteSetMaxOpenConnsou otimize as queries lentas. - Se
stats.InUseestiver 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
- Nunca use
sql.Opensemdb.PingContext(ctx): Garanta que a aplicação só suba se o banco responder no startup. - Sempre adicione
defer rows.Close()logo apósdb.QueryContext: Não confie que ofor rows.Next()chegará até o fim; erros noScancausam vazamento imediato de sockets se odeferfor omitido. - Configure
SetMaxIdleConnspróximo aSetMaxOpenConns: Evite a destruição e recriação frenética de sockets TCP a cada oscilação de tráfego. - Defina sempre
SetConnMaxLifetime: Previne falhas silenciosas de rede decorrentes de balanceadores de carga da nuvem e garante suporte a failover de DNS. - Sempre passe
context.Contextem todas as chamadas de banco: UseQueryContext,ExecContexteBeginTx. 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
- Crie uma tabela
produtoscom camposid,nomeequantidade. - Configure um
*sql.DBcomSetMaxOpenConns(2). - Dispare 10 goroutines concorrentes executando queries com
time.Sleepsimulado dentro do banco. - Inspecione
db.Stats()a cada segundo e observe o crescimento da métricaWaitCountcomprovando o funcionamento da fila de espera do pool.