Databases · advanced
How to load-test a database connection pool
Run a controlled load test that proves a database connection pool stays bounded: acquired count never exceeds configured max, pending queue never grows without bound, and no connection survives past its expected lifetime after load ends. Treat the test as a contract, not a benchmark—define ceilings for acquired, pending, and p99 latency before ramping, and verify each ceiling after.
The symptoms
- •Acquired connection count plateaus at the configured pool maximum and pending requests begin timing out
- •Application p99 latency rises sharply once offered concurrency approaches or exceeds pool size
- •Database-side session count remains near max_connections even after the load tapers off
- •Pending/waiting client queue grows during sustained load and fails to drain within the acquire timeout
- •File descriptor or memory usage on the application process climbs across repeated runs and does not return to baseline
- •Application logs show acquire-timeout errors or "remaining connection slots are reserved" messages from the database
Likely causes
- •Pool maximum sized smaller than the peak concurrency the workload offers
- •Connections held longer than expected because release is missing on error or early-return paths
- •No statement or transaction timeout, so a single slow query occupies a connection indefinitely
- •Acquire timeout set so high that saturation is masked behind unbounded waiting
- •Idle connections not recycled, leaving stale rows visible on the database side
- •Combined offered concurrency from multiple app instances exceeds the database's max_connections
First ten minutes
- 01Capture the pool configuration: min, max, idleTimeoutMillis, connectionTimeoutMillis, and statement or transaction timeout
- 02Record idle baseline pool stats (totalCount, idleCount, waitingCount) before any load is applied
- 03Confirm the database's max_connections leaves headroom above pool max summed across all app instances
- 04Note the workload shape: read vs write mix, average transaction length, and expected peak concurrency
- 05Choose a ramp profile that exceeds expected peak by a defined margin (e.g., 2x pool max)
- 06Define success criteria up front: acquired ceiling, pending ceiling, p99 ceiling, and post-load settle time
- 07Select an isolated environment so DB-side CPU and IO pressure do not contaminate the signal
Evidence to collect
- •Pool metrics over time: totalCount, idleCount, waitingCount, and any per-event acquire/release counters
- •Application latency percentiles p50/p95/p99 and error rate per request class
- •Database pg_stat_activity session count grouped by state, plus the longest active transaction age
- •Database slow query log entries time-correlated with ramp steps and saturation onset
- •Open file descriptor count and resident memory for each application process across the run
- •Application error log entries for acquire timeout, release-after-error, and connection-reset events
Where to look
- •Application pool metrics endpoint or pool debug logs that report acquire and release events
- •Database pg_stat_activity view for live sessions, states, and oldest transaction age
- •Database server log for connection, disconnection, and slow query lines around the ramp window
- •Process inspection for the application: /proc/<pid>/fd count and ps output to confirm connection sockets are released
- •Load generator logs to verify offered concurrency actually reached the intended target and was not throttled client-side
- •Distributed tracing spans marking transaction boundaries where a connection must be released on every exit
Diagnostic steps
- 01Establish an idle baseline: confirm the pool settles to configured min and idleCount is non-zero before any load is applied
- 02Run a low-concurrency ramp and observe when waitingCount first becomes non-zero; this is the contention onset point
- 03Increase offered concurrency in steps until waitingCount plateaus or the configured connectionTimeoutMillis fires
- 04Repeat the ramp with the statement or transaction timeout disabled to separate DB slowness from pool sizing effects
- 05Stop the load generator and verify the pool returns to configured min within idleTimeoutMillis; persistent excess indicates a leak
- 06Inject a fault on a controlled path (force a query error mid-transaction) and confirm the connection is released on the error branch
- 07Re-run with multiple application instances to model combined offered concurrency against the database's max_connections
- 08Compare two identical runs after a configuration change using the same ramp profile to attribute deltas correctly
Common mistakes
- •Declaring success on throughput alone while ignoring waitingCount, p99, and post-load settle behavior
- •Driving load from a single concurrent client, which cannot reproduce contention against a multi-slot pool
- •Skipping the post-load check, allowing slow leaks that only appear after minutes of idle to go undetected
- •Setting connectionTimeoutMillis so high that exhaustion is masked behind long waits rather than surfaced as errors
- •Testing only read-only traffic and missing write paths where locks or row contention extend hold time
- •Comparing runs while database CPU or IO is also under unrelated pressure, contaminating the pool signal
Safe fixes
- •Adjust pool maximum only after confirming the database has headroom and per-connection hold time is bounded
- •Set an explicit connectionTimeoutMillis so backlog fails fast and surfaces saturation as a visible error rate
- •Add a per-statement or per-transaction timeout to cap the worst-case hold time on each connection
- •Audit every code path that acquires a connection and ensure release is reached on success, error, and early-return branches
- •Enable idle connection recycling so stale sessions do not accumulate in pg_stat_activity between bursts
- •Re-run the identical ramp after each change and diff acquired, waiting, p99, and settle-time before accepting the result
Prove the fix
- 01Acquired count never exceeds the configured pool maximum at any sampled point across the full ramp window
- 02WaitingCount is non-zero only briefly during ramp transitions and drains within the configured connectionTimeoutMillis
- 03Application p99 latency stays within the predefined target up to the chosen peak offered concurrency
- 04After load stops, acquired returns to the configured minimum within idleTimeoutMillis with no stragglers
- 05Database pg_stat_activity returns to baseline count with no long-lived idle-in-transaction rows older than the expected threshold
- 06Open file descriptor count on the application process does not trend upward across repeated identical runs
Prevention and next steps
- •Include pool saturation scenarios in CI load tests rather than only throughput-oriented smoke runs
- •Alert on waitingCount depth and on the rate of acquire-timeout errors, not only on request error rate
- •Track active connections per instance versus configured max as a rolling metric to catch drift early
- •Document the supported concurrency envelope and refresh it whenever pool or schema changes ship
Safe commands and checks
psql -h <db-host> -p 5432 -U <user> -d <db-name> -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state;" psql -h <db-host> -p 5432 -U <user> -d <db-name> -c "SELECT pid, state, now() - query_start AS age, left(query, 80) FROM pg_stat_activity WHERE state <> 'idle' ORDER BY age DESC LIMIT 10;" psql -h <db-host> -p 5432 -U <user> -d <db-name> -c "SELECT setting FROM pg_settings WHERE name = 'max_connections';" curl -s http://<app-host>:<metrics-port>/<pool-stats-path> | jq '.totalCount, .idleCount, .waitingCount' pgrep -f <app-process-name> # obtain <pid> for the running application process ls /proc/<pid>/fd | wc -l # count open file descriptors, including connection sockets, for the app process ps -o pid,rss,nlwp,cmd -p <pid> # read resident memory and thread count for the app process grep -c "connectionTimeout" <app-log-path> # count acquire-timeout events in the application log over the test window