Scaling PgBouncer Across CPU Cores with so_reuseport
This guide is part of PgBouncer Transaction vs Statement Pooling. PgBouncer is single-threaded: one process, one event loop, one CPU core. At modest traffic that is a strength, because there is no locking and the per-transaction overhead is tiny. At high transaction rates it becomes the ceiling. The symptom is confusing because every pool metric looks healthy — idle server connections are available and no clients are reported waiting — while application latency climbs and the proxy host’s load average sits at exactly one core:
$ pidstat -p $(pgrep -o pgbouncer) 1 3
Time UID PID %usr %system %CPU CPU Command
10:41:02 112 1893 61.00 38.00 99.00 3 pgbouncer
10:41:03 112 1893 60.00 40.00 100.00 3 pgbouncer
The objective is to confirm the proxy process, not the pool, is the bottleneck, then spread the load across several PgBouncer processes listening on the same port, without multiplying backend connections or breaking query cancellation.
Rapid incident diagnosis
Check the process, not just the pools. A PgBouncer process pinned near 100 % of one core is saturated regardless of what SHOW POOLS says. Look at it alongside the host’s per-core view:
mpstat -P ALL 1 3 | awk '$NF ~ /[0-9]/ && $3 ~ /^[0-9]+$/ {print "cpu"$3, "idle="$NF"%"}'
One core near zero idle while the rest are mostly idle is the single-thread signature.
Compare wait inside PgBouncer with wait before it. SHOW STATS reports avg_wait_time, the time clients spent waiting for a server connection. When the process itself is saturated, that number stays low, because the delay happens before PgBouncer gets to schedule the client at all:
-- psql -p 6432 -U pgbouncer pgbouncer
SHOW STATS; -- avg_xact_time, avg_wait_time per database
SHOW POOLS; -- cl_waiting, sv_idle, maxwait
The distinguishing pattern:
- Pool exhaustion:
cl_waiting > 0,sv_idle = 0,maxwaitrising, process CPU moderate. The fix isdefault_pool_sizeor query time, covered in Configuring PgBouncer Pool Size and max_client_conn. - Process saturation:
cl_waiting = 0,sv_idle > 0,avg_wait_timelow, but application-side query latency far aboveavg_xact_time, and the process at one full core. - Network or TLS cost: process CPU high with a large
%systemshare and TLS enabled on both sides. More processes help here too, but check thatclient_tls_sslmodeandserver_tls_sslmodeare not negotiating expensive handshakes on every connection because the application pool keeps reconnecting.
The gap between application-observed latency and avg_xact_time is the most direct measurement: it is the time spent queued in the kernel and the event loop.
Mathematical sizing or parameter formula
How many processes. Measure the transaction rate one process sustains at, say, 70 % of a core on your workload — TLS, query mix and packet sizes all move it — and divide the peak rate by it:
processes = ceil( peak_transactions_per_second ÷ per_process_rate_at_70pct )
If one process reaches 70 % CPU at 18,000 transactions per second and peak traffic is 52,000, you need ceil(52000 ÷ 18000) = 3 processes. Leave at least one core for the kernel’s network processing, so a 3-process deployment wants 4 or more cores.
How big each pool. Every PgBouncer process keeps its own pools. default_pool_size, max_db_connections and max_user_connections are all enforced per process, so the backend connection count multiplies:
backend_connections = processes × default_pool_size (per database/user pair)
default_pool_size_per_process = floor( intended_total ÷ processes )
A single process configured with default_pool_size = 60 becomes 180 backend connections when three processes load the same file. To keep the intended 60, set 20 per process. max_client_conn is per process as well, but because the kernel spreads clients across processes, each process only sees a share, so it can usually stay as it was.
How evenly load spreads. With so_reuseport, the kernel hashes each new connection’s address and port tuple to pick a listening socket. Many short-lived client connections spread evenly. A small number of long-lived application pools may not: 12 application pods with 10 connections each give only 120 hashes, and one process can end up with noticeably more sessions than another. Keep an eye on per-process SHOW POOLS counts; if imbalance matters, size each process’s pool with some slack rather than exactly intended_total ÷ processes.
Exact remediation & configuration
Run the processes from one shared configuration file plus a small per-instance override, using a systemd template so each instance gets its own PID file, Unix socket directory and peer identity. PgBouncer 1.19 or later is needed for peering.
Shared settings, /etc/pgbouncer/pgbouncer.ini:
[databases]
orders = host=10.0.1.20 port=5432 dbname=orders
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1 ; every process binds the same port
pool_mode = transaction
default_pool_size = 20 ; 60 intended total ÷ 3 processes
reserve_pool_size = 2
max_db_connections = 22 ; per process: 66 total ceiling
max_client_conn = 5000
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
stats_users = pgbstats
%include /etc/pgbouncer/instance.d/peers.ini
Peering makes query cancellation work. A cancel request is sent on a new connection, which the kernel may hash to a different process from the one running the query. With [peers] configured, a process that receives a cancel key it does not own forwards it to the peer that does:
; /etc/pgbouncer/instance.d/peers.ini
[peers]
1 = host=/run/pgbouncer-1
2 = host=/run/pgbouncer-2
3 = host=/run/pgbouncer-3
Per-instance settings, generated per process:
; /etc/pgbouncer/instance.d/2.ini
%include /etc/pgbouncer/pgbouncer.ini
[pgbouncer]
peer_id = 2
unix_socket_dir = /run/pgbouncer-2
pidfile = /run/pgbouncer-2/pgbouncer.pid
logfile = /var/log/pgbouncer/pgbouncer-2.log
The systemd template starts each instance from its own file:
# /etc/systemd/system/pgbouncer@.service
[Unit]
Description=PgBouncer instance %i
After=network.target
[Service]
Type=notify
User=pgbouncer
RuntimeDirectory=pgbouncer-%i
ExecStart=/usr/sbin/pgbouncer /etc/pgbouncer/instance.d/%i.ini
ExecReload=/bin/kill -HUP $MAINPID
LimitNOFILE=65536
Restart=on-failure
[Install]
WantedBy=multi-user.target
systemctl enable --now pgbouncer@1 pgbouncer@2 pgbouncer@3
Roll this out with no downtime by starting the new instances alongside the existing single process — which must also have so_reuseport = 1 set and reloaded first, or the new processes cannot bind the port — then stopping the old process with SHUTDOWN WAIT_CLIENTS so it finishes its sessions while new connections go to the new instances. Reduce the old process’s default_pool_size before starting, so the total during the overlap stays inside the backend budget.
Validation & verification
CPU spread. Under the same peak load, each process should sit well below a full core, and the total PgBouncer CPU should be similar to before — spreading work does not reduce it:
pidstat -C pgbouncer 5 3
Backend total unchanged. On PostgreSQL, the connection count from the proxy host must not exceed processes × max_db_connections:
SELECT client_addr, count(*) AS backends, count(*) FILTER (WHERE state = 'active') AS active
FROM pg_stat_activity
WHERE usename = 'orders_app'
GROUP BY client_addr;
Per-process balance. Query each process through its own Unix socket; a TCP connection to the admin console lands on an arbitrary process:
for i in 1 2 3; do
echo "process $i:"
psql -h /run/pgbouncer-$i -p 6432 -U pgbstats pgbouncer -Atc "SHOW POOLS;" \
| awk -F'|' '$1=="orders" {print " cl_active="$3, "sv_active="$7, "sv_idle="$8}'
done
Cancellation. Start a long query through the proxy, cancel it from the client, and confirm it stops on the server. Repeat about twenty times so the cancel lands on a different process at least some of the time:
for n in $(seq 1 20); do
timeout -s INT 1 psql -h proxy.internal -p 6432 -U orders_app orders -c "SELECT pg_sleep(30)" >/dev/null 2>&1
done
psql -h 10.0.1.20 -U postgres -Atc "SELECT count(*) FROM pg_stat_activity WHERE query LIKE 'SELECT pg_sleep%' AND state='active'"
timeout -s INT delivers the same signal as Ctrl-C, which makes psql send a cancel request for the running query. The final count must be 0; without peering, some of the sleeps would still be running.
Latency gap. The difference between application-side query latency and avg_xact_time should shrink to a millisecond or two once the event loops are no longer saturated.
Alternatives to more processes on one host
so_reuseport is the right answer when one proxy host has idle cores and one PgBouncer process is the bottleneck. It is not the only answer, and sometimes not the best one.
Reduce per-transaction work first. TLS termination is the largest avoidable cost. If PgBouncer terminates TLS from clients inside a private network and again to the database, each connection pays two handshakes and every packet is encrypted twice. Where the security model allows, terminate client TLS only at the network boundary, or reuse client connections for longer — an application pool with a longer maxLifetime generates fewer client handshakes. Check %system CPU in pidstat: a high system share points at packet and TLS work rather than PgBouncer’s own logic.
Scale out instead of up. Several PgBouncer hosts behind a TCP load balancer spread load across machines and remove the single-host failure domain. Each host still needs divided pool sizes, and a load balancer with idle timeouts shorter than the application’s connection lifetime will cut idle client connections, so align the balancer’s idle timeout with the pool settings.
Move pooling closer to the application. A PgBouncer sidecar per application pod distributes proxy CPU across the whole fleet. It trades one saturated process for many lightly loaded ones, at the cost of backend connections that scale with pod count — the trade-off examined in Running PgBouncer as a Kubernetes Sidecar.
Question the traffic. A PgBouncer process saturating at tens of thousands of transactions per second is often serving very small transactions that could be batched: per-row inserts from a queue consumer, a health check every second from every pod, or an ORM issuing one query per object. Reducing transaction count by batching reduces proxy CPU, database CPU and latency together.
| Approach | Proxy CPU per host | Backend connections | Failure domain | Operational cost |
|---|---|---|---|---|
| One process, faster core | Limited to one core | Unchanged | One host | Lowest |
so_reuseport, several processes |
Uses several cores | Divided pools, unchanged total | One host | Low |
| Several hosts behind a load balancer | Spread across hosts | Divided pools, unchanged total | Per host | Medium |
| Sidecar per application pod | Spread across fleet | Grows with pod count | Per pod | Medium |
| Batching in the application | Reduced at source | Often reduced | Unchanged | Code change |
A reasonable sequence is to remove avoidable TLS and chatty traffic, add processes with so_reuseport on the existing host, and only then add hosts. Each step is reversible and measurable with the same CPU and latency-gap checks used above.
Frequently Asked Questions
Is running PgBouncer on a larger single core an alternative?
Can I run several processes on different ports instead?
so_reuseport that was the usual approach, with a TCP load balancer or client-side host list in front. It works, but it adds a component and still needs pool sizes divided by the process count. so_reuseport removes the load balancer.Do prepared statements work across processes?
max_prepared_statements is enabled, and a client connection stays on one process for its whole life, so protocol-level prepared statements behave the same as with a single process. See Using Prepared Statements with PgBouncer Transaction Mode.How should metrics be collected from several processes?
Does so_reuseport work in containers?
so_reuseport = 1; containers in separate pods cannot, and need a Service in front instead.Related
- PgBouncer Transaction vs Statement Pooling — the parent guide to PgBouncer’s pooling modes.
- Configuring PgBouncer Pool Size and max_client_conn — sizing the pools that each process now divides.
- Diagnosing PgBouncer cl_waiting Spikes — the pool-exhaustion case this page rules out.
- Running PgBouncer as a Kubernetes Sidecar — the alternative of many small proxies instead of one large one.