Queueing to Death, or Dying to Reconnect
Two incidents reach the on-call channel with the same title — "database issues" — and opposite causes.
Incident A: latency climbs, then requests start failing with database/sql: connection is already in use style pressure and long stalls before errors. db.Stats() says OpenConnections is pinned at the limit and WaitCount grows every second. The pool has no free slots; every new query queues behind someone else. This is exhaustion: the limit is reached because either the limit is too small or — much more often — queries and transactions are holding connections too long.
Incident B: a warm service under steady load starts throwing driver: bad connection, connection reset by peer, and occasional TLS handshake errors, while the database's own CPU looks calm. Connections are being created and destroyed constantly instead of reused. This is churn: idle timeouts, lifetimes, or an intermediary (load balancer, firewall) is killing connections the pool still believes are good.
The two are diagnosed from the same place — the pool's own counters:
s := db.Stats()
log.InfoContext(ctx, "db pool",
"max_open", s.MaxOpenConnections,
"open", s.OpenConnections, // in use + idle
"in_use", s.InUse,
"idle", s.Idle,
"wait_count", s.WaitCount, // cumulative queries that had to queue
"wait_time", s.WaitDuration, // total time spent queueing
"idle_closed", s.MaxIdleClosed, // idle conns evicted by idle policy
"lifetime_closed", s.MaxLifetimeClosed, // conns retired at ConnMaxLifetime
)
Reading it is straightforward: WaitCount climbing means exhaustion — requests are queueing for a slot. MaxIdleClosed climbing fast means the idle cache is too small for the traffic pattern, so connections are thrown away moments after being returned. MaxLifetimeClosed clustering means connections are retiring en masse — fine as a slow safety net, suspicious if it lines up with your error spikes.
One structural truth before tuning: the pool is a concurrency limiter for database work, and that is a feature. Ten thousand HTTP goroutines cannot all query at once when MaxOpenConns is 25 — the excess queues at your process, where a context deadline can bound the wait, instead of overwhelming the server. The knobs decide where the backpressure sits; setting them wrong moves the failure rather than removing it. With the symptoms separated, the settings themselves come next.
Four Knobs, Each With a Way to Get It Wrong
The entire tuning surface is four methods, and every default is wrong for production in a different way:
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25) // 0 = UNLIMITED — never ship this
db.SetMaxIdleConns(25) // default is 2
db.SetConnMaxIdleTime(5 * time.Minute) // drop idle sockets promptly
db.SetConnMaxLifetime(30 * time.Minute) // safety net, not a timer
// ... ping, then return
}
SetMaxOpenConns — the hard ceiling. The zero default means unlimited: every goroutine that reaches the database gets its own connection, and a traffic spike becomes a connection spike that the database answers with FATAL: sorry, too many clients already. Pick the number from the budget, not the app's wishes: Postgres's max_connections (typically 100) is shared by every service, replica, migration job, and psql session pointing at it. Ten instances at 25 each is 250 hopeful connections into a 100-connection server. The starting point many teams land on is a small multiple of CPU cores — connections execute queries, and more than cores' worth mostly adds context switching — then measure before raising it.
SetMaxIdleConns — the reuse cache. The default of 2 means: after a burst of 20 queries, 18 connections are closed rather than kept warm, and the next burst re-dials them. The symptom looks like Incident B while MaxIdleClosed climbs. Set idle equal to (or slightly below) max open: idle connections are the pool working correctly, and an idle socket costs the database almost nothing compared with a handshake.
SetConnMaxIdleTime — polite reaping. Idle connections beyond this age are dropped on return. This is where you retire sockets the load balancer or firewall has already killed server-side — a stale idle connection is a future bad connection error. Rule: idle time comfortably below the intermediary's idle timeout (5 minutes against a 10-minute LB kill), so you close first, on your own terms.
SetConnMaxLifetime — the slow safety net. Connections are retired after this age regardless of health, which handles NAT/LB connection limits and secret rotations. The trap is setting it short: connections created together expire together, and a 5-minute lifetime on a busy pool becomes a synchronized reconnect wave every 5 minutes — Incident B on a metronome. Keep it long (30+ minutes), because idle-time already handles the routine cleanup.
Notice what the four knobs cannot fix: a 3-second query holding a slot makes every other query wait 3 seconds — MaxOpenConns converts slow queries into queueing by design. If WaitCount is climbing, the honest first question is which query is slow (transactions held open across HTTP calls being the usual culprit), before any limit goes up. The numbers are the second conversation; the counters decide whether you are having the first.
What the Counters Say, and When to Stop Tuning Go
Tuning without the counters is folklore — someone doubles MaxOpenConns after a bad week, the incident does not recur for a month, and the number calcifies. Exporting db.Stats() as metrics turns each knob into a hypothesis you can check.
// scrape loop or per-request metrics export
s := db.Stats()
metrics.Gauge("db_pool_open_connections", float64(s.OpenConnections))
metrics.Gauge("db_pool_in_use", float64(s.InUse))
metrics.CounterAdd("db_pool_wait_total", float64(s.WaitCount))
metrics.CounterAdd("db_pool_wait_seconds_total", s.WaitDuration.Seconds())
metrics.CounterAdd("db_pool_idle_closed_total", float64(s.MaxIdleClosed))
metrics.CounterAdd("db_pool_lifetime_closed_total", float64(s.MaxLifetimeClosed))
Because the counters are cumulative, alert on their rate of change and pair each signal with its decision:
| Signal | Meaning | First response |
|---|---|---|
WaitCount rate > 0 |
queries queueing — exhaustion | find the slow holder (slow query, open tx); raise open only if holds are short |
InUse ≈ max open, high |
sustained concurrency at the ceiling | check query duration; consider read replicas before a bigger ceiling |
MaxIdleClosed rate high |
idle cache evicting under traffic | raise MaxIdleConns toward max open |
MaxLifetimeClosed spikes |
synchronized retirements | lengthen ConnMaxLifetime; verify it exceeds LB churn |
OpenConnections low, no waits |
pool healthy | stop tuning; optimize queries for latency instead |
The last row matters most: a pool with no waits is not your bottleneck, and every minute spent adjusting it is a minute not spent on the 400 ms query. go tool pprof against your service and the database's own slow-query log answer latency questions that no pool setting can (pprof and profiling).
Then there is the arithmetic that no Go setting escapes: instances × max open must fit in the database's connection budget. Twenty replicas at 50 is 1,000 against Postgres's 100 — and no amount of careful tuning inside one process fixes fleet-level math. When the fleet outgrows the server, pooling moves out of the application: PgBouncer in transaction mode lets every instance hold many client connections while the server sees few. Transaction pooling has a cost — session state (prepared statement names, SET variables, advisory locks tied to a session) cannot survive across uses — so the application must behave accordingly. That is a deliberate architecture change, made when the dashboard shows a healthy app pool and a saturated database accept queue.
The full decision ladder: measure waits → fix slow holders → rebalance idle and lifetime from close counters → raise the ceiling within the database budget → move pooling to a shared proxy when the fleet no longer fits. Each rung is justified by a counter, which is what separates tuning from superstition.
Quiz
1. Latency climbs and db.Stats() shows OpenConnections == MaxOpenConnections with WaitCount rising each second. What is the correct first move?
2. A service under moderate load shows frequent reconnects with MaxIdleClosed climbing fast, while MaxOpen is never reached. What is wrong?
3. Error spikes recur on a perfectly regular interval equal to ConnMaxLifetime. What happened?
4. Twenty replicas each set SetMaxOpenConns(50); Postgres reports 'too many clients already'. Where is the mistake?
Flashcards
Question
What are database/sql's four pool knobs, and what are their defaults?
Click to reveal answer
Answer
SetMaxOpenConns (0 = unlimited), SetMaxIdleConns (2 — the classic churn source), SetConnMaxIdleTime (0 = keep idle forever), SetConnMaxLifetime (0 = never retire). Production sets all four: a budget-derived ceiling, idle matched to it, idle-time below the LB's kill timer, and a long lifetime as safety net.
Question
Exhaustion and churn look similar in an error log — how do db.Stats counters tell them apart?
Click to reveal answer
Answer
Exhaustion: WaitCount/WaitDuration climbing with Open == MaxOpen (requests queueing for slots). Churn: MaxIdleClosed or MaxLifetimeClosed climbing while waits stay flat (connections destroyed and re-dialed). One calls for shorter holds or a bigger ceiling; the other for idle/lifetime fixes.
Question
Why is an unlimited MaxOpenConns dangerous even if the app usually runs one query at a time?
Click to reveal answer
Answer
Under a spike every request goroutine grabs its own connection, demand multiplies into the database's fixed max_connections budget, and the server starts refusing new clients — turning your service outage into everyone's outage. A ceiling keeps the flood inside your process where context deadlines can bound the queue.
Question
What is the right relationship between ConnMaxIdleTime and a load balancer's idle-connection timeout?
Click to reveal answer
Answer
Idle time should be shorter than the LB's, so your pool drops the socket first, cleanly, on your schedule — otherwise the LB kills it server-side and a later checkout discovers the corpse as 'driver: bad connection'. Close before the network does.
Question
When do you move from SetMaxOpenConns to an external pooler like PgBouncer?
Click to reveal answer
Answer
When fleet math no longer fits: instances × per-instance ceiling exceeds the server's max_connections, and the accept queue saturates while each app pool looks healthy. Transaction-mode pooling multiplexes onto few server connections — at the cost of losing session state (named prepared statements, SET, session advisory locks).
Revision Notes
Key Takeaways
- 1. The pool is a deliberate backpressure valve: waits mean something is holding connections, and the fix usually starts with the holder, not the limit
- 2. Defaults sabotage production differently — unlimited open invites server meltdown, idle=2 forces churn, zero lifetimes keep stale sockets
- 3. Counter pairs tell the story: waits = exhaustion, closes = churn; each has a different remedy
- 4. Fleet math beats local tuning: size against max_connections across all instances, or move pooling to a proxy
- 5. A healthy pool is a stop signal — beyond it, latency work moves to queries and profiling
Interview Tips
- • "How do you tune database/sql's pool?" — walk the four knobs with defaults, then say you size from the DB budget and let db.Stats() (WaitCount, close counters) drive every change
- • "Requests queue but the database is idle — what is happening?" — exhaustion at the app pool: slow holds pinning slots; show how Stats distinguishes it from a slow server
- • "max_connections errors during deploys" — fleet arithmetic, per-instance ceilings, then PgBouncer transaction mode and its session-state caveats
- • "What would you monitor?" — wait rate, in-use vs max, idle/lifetime close rates, plus the database's own active-connection count as the cross-check
Cheat Sheet
Connection Pool Tuning
Diagnose from db.Stats():
| Counter pattern | Diagnosis | Move |
|---|---|---|
| WaitCount↑, Open = MaxOpen | exhaustion — something holds slots | find slow query / held tx; raise ceiling only if holds short |
| MaxIdleClosed↑, waits flat | churn — idle cache too small | raise MaxIdleConns toward MaxOpen |
| MaxLifetimeClosed spikes | synchronized retirements | lengthen ConnMaxLifetime |
| waits flat, Open low | healthy | stop tuning; query latency is elsewhere |
Sizing rules: ceiling derived from DB max_connections shared across the fleet (rule of thumb: ~2–4× cores per instance, budget first); idle = open; ConnMaxIdleTime < LB idle timeout; ConnMaxLifetime long (30m+).
Code template:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxIdleTime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)
// export Stats: open, in_use, wait_count, wait_time, idle_closed, lifetime_closed
Decision ladder: measure waits -> fix holders -> rebalance idle/lifetime from close counters -> raise ceiling within budget -> external pooler when instances × ceiling > server budget.
Continue Learning
Before You Start
- No prerequisites listed.