O pacote database/sql da biblioteca padrão de Go traz um pool de conexões embutido. Muita gente acha que está usando o driver diretamente, mas desde a primeira linha de sql.Open o que roda é um pool que gerencia conexões, bloqueia chamadas quando o limite estoura e reutiliza sessões entre goroutines. O problema é que os defaults foram escolhidos para um notebook de desenvolvimento e não para um serviço sob carga. Em produção, conexões ociosas derrubadas por proxy, queries presas, too many connections no banco e latência irregular quase sempre vêm de três linhas de configuração que ninguém ajustou.
Este guia mostra como o pool do database/sql funciona, como dimensionar SetMaxOpenConns, SetMaxIdleConns e SetConnMaxLifetime para o seu workload real e quais armadilhas matam serviços em pico. Ele complementa o guia de pgxpool para PostgreSQL, o guia de transações, locks e retry, o guia de migrations e o guia de timeouts e cancelamento com context.
Como o pool do database/sql funciona
sql.DB não é uma conexão. É um pool que mantém zero ou mais conexões reais a um banco. Cada chamada de Query, Exec ou Begin pega uma conexão do pool, executa e devolve. Se todas as conexões estiverem em uso e o limite de conexões abertas ainda não foi atingido, o pool abre uma nova. Se o limite foi atingido, a chamada fica bloqueada esperando uma conexão livre — ou cancelada pelo contexto, se você passou um.
db, err := sql.Open("pgx", dsn)
if err != nil {
return err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute)
A diferença entre sql.Open e db.Ping é o ponto onde muita gente descobre que o DSN estava errado: Open só valida o formato da string, sem tocar no banco. Sempre chame PingContext na inicialização do serviço para falhar cedo, antes que o graceful shutdown precise lidar com isso sob carga.
Os três parâmetros que importam
SetMaxOpenConns
Limita o número máximo de conexões abertas simultaneamente, incluindo as em uso e as ociosas. O default é ilimitado, o que em produção vira uma bomba: cada goroutine de handler que precisa do banco abre uma conexão, e em um pico de 2 mil requisições concorrentes você cria 2 mil conexões. O banco recusa com too many connections, o proxy na frente corta conexões ociosas, e a aplicação passa a abrir e fechar conexões numa frenesi que derruba a latência.
O número certo não é “o máximo que o banco aguenta”. É o ponto onde a vazão para de crescer — geralmente entre 2x e 4x o número de cores do banco de dados, ou o limite definido pelo DBA. Para PostgreSQL, valores típicos ficam entre 10 e 50 por instância de aplicação; para MySQL, entre 20 e 100. Meça: o que importa é a relação entre conexões abertas, latência de query e throughput. Se dobrar o pool não aumenta a vazão, o gargalo está em outro lugar (lock de linha, índice faltante, rede) e adicionar conexões só piora.
SetMaxIdleConns
Limita conexões ociosas mantidas no pool. O default é 2. Isso significa que se o serviço recebe rajadas de tráfego, a cada rajada o pool abre novas conexões (pagando o custo de handshake TLS e autenticação) e depois fecha o excesso. Em workloads com variação de carga isso vira um ciclo de abrir e fechar que domina o perfil.
A regra prática: SetMaxIdleConns deve ser igual a SetMaxOpenConns na maioria dos casos. Assim, quando o tráfego cai, as conexões ficam ociosas esperando a próxima rajada em vez de serem destruídas. O custo de manter 25 conexões ociosas é trivial perto do custo de reabri-las a cada pico.
SetConnMaxLifetime e SetConnMaxIdleTime
SetConnMaxLifetime limita o tempo total de vida de uma conexão, mesmo que esteja em uso constante. O default é ilimitado. Em produção isso é perigoso porque proxies (HAProxy, AWS RDS proxy, Cloud SQL Auth Proxy) e firewalls costumam cortar conexões paradas ou longas sem avisar a aplicação. O sintoma é o temido EOF, connection reset by peer ou write: broken pipe em uma query que funcionava segundos antes.
Defina SetConnMaxLifetime para algo menor que o timeout do proxy — geralmente 5 minutos é seguro. SetConnMaxIdleTime, mais agressivo, fecha conexões ociosas individualmente, útil quando o tráfego cai de verdade (por exemplo, de madrugada) e você não quer sustentar o pool cheio.
Prepared statements e o cache de conexões
O database/sql cacheia prepared statements por conexão. Quando você chama db.Prepare, ele cria um prepared statement em cada conexão sob demanda — a primeira vez que uma conexão executa aquela query. Isso tem duas consequências. Primeiro, em pools grandes, a primeira rajada pode disparar dezenas de prepares simultâneos, o que sobrecarrega o banco. Segundo, prepared statements retêm estado na conexão (sessões, variáveis temporárias), o que complica o reuso.
Para queries executadas milhões de vezes, prepared statements valem a pena. Para queries raras ou dinâmicas, prefira db.QueryContext direto, que prepara implicitamente e descarta. O guia de sqlc mostra uma alternativa que gera código tipado a partir de SQL, eliminando esse dilema.
Context, timeouts e o efeito em cascata
Nunca chame db.Query sem contexto em produção. A versão sem contexto não respeita cancelamento: se o banco travar, a goroutine fica presa para sempre, segurando uma conexão do pool. Em poucos minutos, todas as conexões estão bloqueadas e o serviço inteiro para de responder.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT id, nome FROM usuarios WHERE ativo = $1", true)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return ErrTimeoutBanco
}
return err
}
defer rows.Close()
O timeout deve ser definido no nível certo. Não use um único timeout global de 30 segundos para tudo: queries de dashboard podem aceitar 10 segundos, mas uma query em hot path de login precisa voltar em 200ms. Configure timeouts por rota, sempre usando o contexto da requisição como base, e deixe o guia de context, timeout e cancelamento explicar o padrão completo.
Uma armadilha comum: passar o contexto errado para o BeginTx. Se o contexto expira durante uma transação, o Go faz rollback automaticamente, mas a transação pode ter efeitos colaterais visíveis — como locks liberados, outbox parcialmente escrito. Para transações críticas, combine timeout com o padrão outbox e idempotência para que retries sejam seguros.
Fechando Rows,Stmt e transações
Toda chamada de Query devolve *sql.Rows. Se você não chamar rows.Close(), a conexão não volta para o pool e vira um vazamento silencioso. Em serviços de longa duração isso se manifesta como o pool secando e todas as chamadas travando. A regra é simples: sempre defer rows.Close(). O custo de fechar duas vezes é zero (a segunda é no-op), o custo de não fechar é um incidente.
rows, err := db.QueryContext(ctx, "SELECT ...")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var u Usuario
if err := rows.Scan(&u.ID, &u.Nome); err != nil {
return err
}
// ...
}
if err := rows.Err(); err != nil {
return err
}
rows.Err() depois do loop é o detalhe que muita gente esquece: Next retorna false no fim OU no erro, e só Err() distingue os dois.
Monitorando o pool em produção
O database/sql expõe métricas via db.Stats() que valem ouro:
OpenConnections: conexões abertas no momento.InUse: conexões executando queries.Idle: conexões ociosas.WaitCount: número de chamadas que precisaram esperar por conexão.WaitDuration: tempo total esperando conexões.MaxIdleClosedeMaxLifetimeClosed: conexões fechadas por política do pool.
Exponha essas métricas no Prometheus — o guia de OpenTelemetry e observabilidade mostra como instrumentar a aplicação. Os sinais de problema são claros: WaitCount crescendo indica que o pool é pequeno demais; MaxIdleClosed alto indica que SetMaxIdleConns está abaixo do tráfego real; MaxLifetimeClosed estável é saudável, mas se OpenConnections bate em MaxOpenConns e WaitDuration cresce, o banco ou o pool estão saturados.
database/sql vs driver específico
Para PostgreSQL em serviços sérios, pgxpool costuma ser melhor que database/sql com o driver pgx: o pgx nativo fala o protocolo binário, suporta tipos customizados, COPY e batching que o database/sql não expõe. Para MySQL, o driver go-sql-driver/mysql sobre database/sql é o padrão da indústria. Para SQLite, modernc.org/sqlite (puro Go) ou mattn/go-sqlite (CGO) — o detalhe crítico aqui é que SQLite só permite um escritor por vez, então SetMaxOpenConns(1) é comum para o modo WAL com leitores paralelos.
A regra de ouro: comece com database/sql pela portabilidade, mude para um driver específico quando precisar de recursos que a interface abstrai fora. O guia de migrations mantém o schema portável independente do driver escolhido.
Checklist de produção
- Defina
SetMaxOpenConnspara um valor que faça sentido para o banco, não ilimitado. - Iguale
SetMaxIdleConnsaMaxOpenConnspara evitar reopen em rajadas. - Defina
SetConnMaxLifetimemenor que o timeout do proxy de rede. - Defina
SetConnMaxIdleTimepara fechar conexões em horários de baixa. - Sempre
defer rows.Close()e chequerows.Err()após o loop. - Sempre use a versão
Contextde cada método com timeout apropriado. - Exponha
db.Stats()como métricas e alerte sobreWaitDurationcrescendo. - Rode com pgxpool se quiser extrair o máximo do PostgreSQL.
O pool do database/sql é uma das peças mais subestimadas da biblioteca padrão de Go: funciona bem por padrão no notebook e falha silenciosamente em produção. Os três parâmetros certos removem 90% dos incidentes de banco que afligem serviços Go em escala. Para quem mantém backends em mais de uma linguagem, o padrão de configurar explicitamente pool, lifetime e timeout é universal — vale comparar com a abordagem do Python Dev Brasil sobre performance e concorrência em backends ao desenhar pools entre stacks.