ObservabilityLIX · Database ObservabilityDatabaseObs
Connection Pool
What you'll learn
- Explain the difference between the database server pool, the connection broker pool and the application pool
- Read pg_stat_activity, SHOW PROCESSLIST and HikariCP metrics to identify saturation and its cause
- Diagnose the four common shapes of pool pressure (under-sized, leaky, idle-stuck, server-side ceiling)
- Set thresholds for active, idle-in-transaction and waiting connection counts that page before the queue rejects requests
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A 03:00 page. The application is returning 500s. Five hundred of
the last thousand requests have failed. The on-call engineer
opens Grafana. The application pool panel shows
hikaricp_connections_active at the configured maximum,
hikaricp_connections_idle at zero, and
hikaricp_connections_pending rising. The database’s
pg_stat_activity_count{state="active"} is sitting at
max_connections minus ten. The replica is idle. The
application pool is the bottleneck and the database pool is
already at its ceiling because of it.
This is the connection pool’s failure shape. The pool is not a worker queue or a thread pool. The pool is the deterministic upper bound on the number of simultaneous statements the application can run against the database. The bound is on either end of the connection: the application pool chooses how many to open; the database pool chooses how many to accept. The question is which end is exhausted and how to tell.
What a connection pool is
A connection pool is the bounded set of TCP connections between two software components that use connections as a unit of work. Production stacks have at least two pools in series:
Application Application Connection Database
thread pool broker server
---------> -----------> -------------> ---------------
request opens a multiplexes accepts up to
runs connection many app max_connections
per query on demand, conns onto (PostgreSQL) or
reuses on fewer broker max_user_connections
next request conns (MySQL)
The layers are not optional. The application pool exists because TCP setup with a database has a measurable cost (TLS handshake, authentication, statement-prepare caching). The broker pool exists because the application’s sum-of-pools is much larger than the database wants to accept. The database ceiling exists because every accepted connection holds memory that the engine cannot share.
The metrics split along the same lines. Each layer has its own counts for active (currently executing), idle (open but not running a statement) and waiting (the layer above has requested a slot that the pool cannot provide).
The shapes that matter, all observed through the same three numbers:
| Pool layer | Active | Idle | Waiting | Mean |
|---|---|---|---|---|
| Application (HikariCP, pgxpool) | rising | falling | rising | pool too small OR queries too slow |
| Broker (PgBouncer, ProxySQL) | steady | steady | rising | broker max_client_conn reached |
| Database (pg_stat_activity) | at ceiling | near zero | climbing inside database | max_connections reached or queries stalled |
A pool at saturation looks the same in all three: active is at the maximum, idle is at zero, waiting is climbing. The only way to tell the layers apart is to read all three.
Why a sysadmin cares
Saturation is the most commonly diagnosed — and most commonly misdiagnosed — incident class in production database operations. Two distinct failure classes share the same symptom:
- Legitimate saturation. The workload exceeds the pool’s capacity. Either the pool is too small for the workload, or the workload is too large for the pool. The fix is upstream (add capacity) or downstream (reduce query latency).
- Leaked saturation. A code path opens connections but does
not return them to the pool. Active stays at the maximum,
idle stays at zero, but the cause is not workload — it is a
missing
conn.close(). The fix is in the application code, not in the pool size.
A third shape appears less often but is more dangerous: the idle-in-transaction leak. A connection opens a transaction, acquires a row-level lock, and never commits or rolls back. The pool’s active count is fine; the database’s idle-in-transaction count climbs. Eventually the database refuses new connections and the broker starts queuing. The application sees timeouts; the database sees a vacuum queue blocked on a row lock.
How it works
A connection pool is a small object that owns a fixed number of
TCP connections. Application code calls pool.acquire(),
receives a Connection object, runs queries, and calls
Connection.close() to return the slot. The pool enforces the
bound; code that holds more than the bound waits.
Worker thread Pool Database
--------------- ---------------- -----------
pool.acquire() -----> {active: 8, idle: 2,
max: 10, waiting: 0}
|
+---> existing idle conn reused
|
statement; commit
|
conn.close() --------> {active: 7, idle: 3,
waiting: 0}
The floor and ceiling are the same shape across pools:
- Minimum. The pool pre-opens a small number of connections on startup to avoid the cost of the first request.
- Maximum. Hard ceiling. Calls to
acquire()past the ceiling either queue (HikariCPconnectionTimeout, pgxpoolconn_wait_timeout) or fail fast (PgBouncerserver_reset_query, ProxySQLconnect_retries_on_failure). - Acquire timeout. After this many milliseconds of queueing, the call returns an error to the application. This timeout is the boundary between “user sees a slowdown” and “user sees a 500.”
The three numbers — active, idle, waiting — answer the question “what is the pool doing right now?” at any moment. The right pool, on the right host, with the right workload, spends most of its time idle with active running 30-60% of the maximum and waiting at zero.
How to configure it
Two surfaces: the application pool and the broker pool. Each needs its own metrics.
Application pool (HikariCP example, Java).
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000 # ms before acquire fails
idle-timeout: 600000 # how long idle is idle
max-lifetime: 1800000 # recycle after 30 min
pool-name: app-primary
register-mbeans: true # expose HikariCP MBeans
metric-registry-class: io.prometheus.client.hikaricp.HikariCPCollector
The exporter exposes these metrics under the hikaricp_ prefix.
The most useful for this lesson:
hikaricp_connections_active pool connections in use
hikaricp_connections_idle pool connections idle
hikaricp_connections_pending threads waiting for a slot
hikaricp_connections_acquire_seconds histogram of acquire time
Broker pool (PgBouncer example).
# pgbouncer.ini
[databases]
app = host=10.0.4.10 port=5432 dbname=app
[pgbouncer]
listen_addr = 10.0.4.11
listen_port = 6432
auth_type = md5
default_pool_size = 20 # per database-user
max_client_conn = 200 # per broker process
reserve_pool_size = 4
server_idle_timeout = 600
query_wait_timeout = 30 # seconds; user-facing timeout
The metrics are exposed via PgBouncer’s stats; the
postgres_exporter can be configured to scrape them through a
custom query file.
# queries.yaml (excerpt; for pgwatch2 or a custom exporter)
- name: pgbouncer_pools
query: |
SHOW POOLS;
metrics:
- app_name:
usage: LABEL
- cl_active:
usage: COUNTER
- cl_waiting:
usage: GAUGE
- sv_active:
usage: COUNTER
- sv_idle:
usage: COUGE
The cl_* columns are client-side (application to broker); the
sv_* columns are server-side (broker to database). The most
useful single number for the operator is cl_waiting — a
non-zero value is the application queuing at the broker.
Prometheus alerts for the three layers.
# /etc/prometheus/rules/connection_pool.yaml (excerpt)
groups:
- name: connection_pool
rules:
- alert: AppPoolSaturated
expr: |
hikaricp_connections_active / hikaricp_connections_max
> 0.9
for: 5m
labels:
severity: ticket
annotations:
summary: 'App pool saturated on {{ $labels.instance }}'
- alert: BrokerQueueing
expr: pgbouncer_pools_cl_waiting > 5
for: 2m
labels:
severity: page
- alert: DatabaseAtCeiling
expr: |
pg_stat_activity_count
/ on(instance) pg_settings_max_connections
> 0.85
for: 2m
labels:
severity: page
- alert: IdleInTransactionLeak
expr: |
pg_stat_activity_count{state="idle in transaction"} > 3
for: 5m
labels:
severity: ticket
The four alerts cover the four common shapes. The thresholds are deliberately conservative; tune upward after the first month of operational use.
How to validate it
Every step is READ-ONLY.
# 1. From the application server, inspect HikariCP via the admin endpoint
# (if Prometheus client is bound) or via JMX.
curl -sf http://app:8080/metrics | grep -E '^hikaricp_connections_'
# HELP hikaricp_connections_active Active connection count
hikaricp_connections_active{pool="app-primary"} 7
hikaricp_connections_idle{pool="app-primary"} 13
hikaricp_connections_pending{pool="app-primary"} 0
hikaricp_connections_max{pool="app-primary"} 20
# 2. From the broker host, query PgBouncer's stats directly.
psql -h 10.0.4.11 -p 6432 -U pgbouncer -d pgbouncer -c 'SHOW POOLS;'
database | user | cl_active | cl_waiting | sv_active | sv_idle | pool_mode
----------+-------+-----------+------------+-----------+---------+-----------
app | app | 7 | 0 | 7 | 13 | transaction
# 3. From the database, inspect server-side state.
psql -h 10.0.4.10 -U db_exporter -d app \
-c "SELECT state, count(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;"
state | count
----------------+-------
active | 12
idle | 31
idle in transaction | 2
fastpath function call | 5
# 4. Confirm the alert rules parse and evaluate.
promtool check rules /etc/prometheus/rules/connection_pool.yaml
# 5. Confirm Prometheus is recording the alert state transitions
# on a staging instance by simulating load with a small script.
# Validate against the staging environment first.
The four outputs confirm: the application pool is reporting
three of the four expected gauges, the broker is reporting
client- and server-side splits, the database’s pg_stat_activity
is reporting the state breakdown, and the alert rules parse.
The fifth step is load generation, which is service-impact and
should never run first on a production host.
How it can fail
Five failure shapes cover the overwhelming majority of pool incidents in production.
- Pool too small. The application pool is configured for
baseline traffic; the workload has grown. Symptom: the
active / maxratio sits above 0.9 most days;pendingrises under traffic spikes. Fix: grow the pool, but only after confirming the database ceiling is not the binding constraint. - Idle-in-transaction leak. A business transaction holds a
connection open without committing for minutes at a time.
Symptom:
pg_stat_activity_count\{state="idle in transaction"\}rises without bound; the application thread is otherwise idle; the database’s lock-wait graph shows the held locks. Fix: client-sidestatement_timeoutand the application’s transaction discipline. - Server-side ceiling. The application opens more pool
members than the database will accept. Symptom:
pg_settings_max_connectionsis reached; new connections returnFATAL: too many clients. The application pool is “fine” by its own metric; the database is the limit. Fix: cap the application pool below the database ceiling, or introduce a broker pool with multiplexing. - Broker queueing. PgBouncer is configured in transaction
mode and the application’s
default_pool_sizeis below the application’s pool size. Symptom:cl_waitingis non-zero whilesv_activeis belowdefault_pool_size; application latency rises. Fix: aligndefault_pool_sizeto the application’s actual concurrency. - Network dropouts. The application holds a connection
that is dead on the database side; the application does
not know for a while. Symptom: HikariCP’s
connection-acquire-secondshistogram tail spike;pg_stat_activityshows the connection state asidleand the TCP socket as half-closed. Fix: enablekeepalive_timeandkeepalive_intervalon the application host.
How to troubleshoot it
Diagnose in this order. The cheapest evidence comes first.
- Read the application’s pool metrics first. Decide whether
the application pool is saturated or merely busy. If the
pending count is non-zero and
active / maxis above 0.9, the application pool is the bottleneck. - Read the broker’s
cl_waiting. A non-zero value means the broker is queueing. A zero value means the broker is not; the bottleneck is at the application or at the database. - Read the database’s
pg_stat_activitybreakdown. Astateof “active” at the database ceiling is a server-side ceiling. A non-trivial “idle in transaction” count is a leak. Await_event_typeofLockon multiple rows is a contention issue (next lesson). - Read the database’s
max_connections. A singlepg_settings_max_connectionsmetric, plus the samepg_stat_activity_count, gives the saturation ratio. - Inspect a stuck connection. When the pending count is non-zero, sample one waiting application thread (jstack, pprof, or application-side logging) and note what statement it is running. The statement text is the next evidence.
Security implications
The pool is a trust boundary. Each connection holds credentials or assumes an authentication state. Two surfaces to control:
- Credentials on the application side. HikariCP keeps the
database password in a
DataSourceconfiguration. Bind the exporter or the application side to a secret manager; do not put the password in plain text. PgBouncer’s auth file lives in/etc/pgbouncer/userlist.txt— same rule. - Authentication state on the broker side. In transaction
mode, PgBouncer reuses a server connection across multiple
client connections.
server_reset_query = DISCARD ALLis the safety belt; without it, session state can leak between users. Usepool_mode = transactionandserver_reset_query_always = 1for multi-tenant pools. - Audit. Ship both the application logs and the broker’s
stats to Loki. Alert on
FATAL: too many clientsfrom the database log and on any non-zerocl_waitingcount above a threshold.
Performance implications
Performance is dominated by three numbers.
- Acquire latency.
hikaricp_connections_acquire_secondsp99 should sit under 50 ms for a healthy application pool. A p99 above 200 ms means the pool is queueing or the application is spending most of its time outside the database. - Wait time on the broker side. A non-zero
cl_waitingfor more than a few seconds at a time means the broker is the bottleneck. Either growdefault_pool_sizeor move to transaction pooling on a faster broker host. - Server-side tail.
pg_stat_activity’swait_event_typehistogram (when present) is the early signal of contention. The next lesson addresses the lock-side numbers.
The trade-off of conservative pool sizing is predictable latency under load; the cost of an undersized pool is a tail that no cache can hide.
Production guidance
- Set the application pool ceiling such that
app_pool_max * app_replicas < broker_pool_max. Otherwise the broker is the ceiling and the broker queueing is the page. - Alert on any non-zero
cl_waitingsustained for any duration; the number itself is rarely the issue, the existence of it is. - Run
SHOW POOLSfrom the broker against a synthetic load to confirmcl_waitingis the metric you think it is. - Set
server_reset_query_always = 1on PgBouncer for multi-tenant pools. - Treat
idle-in-transactionas a metric that should be near zero in steady state. - Avoid long-running transactions inside the application pool. The connection is the unit of work; the transaction is the unit of work inside it. Each can be tuned.
Verification
You should now be able to answer:
- What are the three layers of connection pools in a typical production stack and what does each one’s saturation look like?
- What is the difference between
cl_waitingandsv_idleon PgBouncer? - What four alerts are worth defining for pool saturation?
- How do you distinguish a leaked connection from an under-sized pool?
- What is the most common mis-diagnosis of a saturated pool?
Quiz
Knowledge check · 8 questions
Q1. In a three-layer stack (application / broker / database), which pair of readings reveals a saturated application pool?
Q2. A production incident shows cl_waiting at zero, hikaricp_connections_pending at 5, and pg_stat_activity_count at max_connections minus 5. Which layer is the bottleneck?
Q3. PgBouncer cl_waiting at a non-zero value is always a problem worth investigating.
Q4. Which two signals together most reliably indicate an idle-in-transaction leak in PostgreSQL?
Q5. Which of these are sensible production alerts for connection-pool pressure? (Select all that apply.)
Q6. Name the PgBouncer stat column that reports the number of client connections waiting for a server connection in the broker pool.
Q7. What is the right first action when a pool page fires at 03:00?
Q8. Why is raising the application pool maximum without checking max_connections on the database a common mis-diagnosis?
Passing score: 75%. Answers are checked in this browser.