← Voltar para o blog

database/sql em Go: Pool de Conexões, MaxOpenConns e Produção

Aprenda o pool de conexões do database/sql em Go: SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, timeouts, prepared statements e armadilhas de produção com MySQL, PostgreSQL e SQLite.

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.
  • MaxIdleClosed e MaxLifetimeClosed: 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 SetMaxOpenConns para um valor que faça sentido para o banco, não ilimitado.
  • Iguale SetMaxIdleConns a MaxOpenConns para evitar reopen em rajadas.
  • Defina SetConnMaxLifetime menor que o timeout do proxy de rede.
  • Defina SetConnMaxIdleTime para fechar conexões em horários de baixa.
  • Sempre defer rows.Close() e cheque rows.Err() após o loop.
  • Sempre use a versão Context de cada método com timeout apropriado.
  • Exponha db.Stats() como métricas e alerte sobre WaitDuration crescendo.
  • 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.