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, maxwait rising, process CPU moderate. The fix is default_pool_size or query time, covered in Configuring PgBouncer Pool Size and max_client_conn.
  • Process saturation: cl_waiting = 0, sv_idle > 0, avg_wait_time low, but application-side query latency far above avg_xact_time, and the process at one full core.
  • Network or TLS cost: process CPU high with a large %system share and TLS enabled on both sides. More processes help here too, but check that client_tls_sslmode and server_tls_sslmode are 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.

Per-core CPU on a PgBouncer host during saturation Core 3 runs at 100 percent while the other seven cores are mostly idle. SHOW POOLS reports zero waiting clients and idle server connections at the same time. Per-core CPU utilisation, 8-core proxy host 0 1 2 3 4 5 6 7 100 % SHOW POOLS cl_waiting 0 sv_idle 14 maxwait 0 looks healthy application latency 40 ms against avg_xact_time of 4 ms: the rest is queueing
A saturated PgBouncer reports healthy pools because the delay occurs before the event loop reaches the client. The CPU profile, not the pool view, shows the bottleneck.

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.

Backend connections with one and three PgBouncer processes One process with a pool of 60 opens 60 backend connections. Three processes with the same file open 180. Three processes with a pool of 20 each open 60 again. 1 process default_pool_size 60 60 backend connections 3 processes, same file default_pool_size 60 180 backend connections 3 processes, divided default_pool_size 20 60 backend connections Per-process limits multiply with the process count max_db_connections and max_user_connections multiply the same way
so_reuseport multiplies every per-process limit. Dividing default_pool_size by the process count keeps the database seeing the same number of backend connections.

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.

Three PgBouncer processes sharing one port Application connections arrive on port 6432 and the kernel distributes them across three processes. A cancel request that lands on process 1 for a query on process 3 is forwarded over the peer link. Each process opens its own backend pool of 20. Application pools connect to :6432 Kernel so_reuseport hash per connection pgbouncer peer 1 pool 20 pgbouncer peer 2 pool 20 pgbouncer peer 3 pool 20 cancel forward PostgreSQL 60 backend connections
The kernel picks a process for each new connection. Peers forward cancel requests to the process that owns the query, and each process keeps its own admin socket.

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?
Faster cores raise the ceiling proportionally, and that is often enough. But a single process is still one core; past that point, adding processes is the only way to use more of the machine without adding more proxy hosts.
Can I run several processes on different ports instead?
Yes, and before 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?
Each process tracks prepared statements separately when 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?
Run one exporter per process pointed at that process’s Unix socket, and label the series with the peer id. Summing across processes gives the host total; the per-process series show imbalance. The exporter setup is in Setting Up the PgBouncer Prometheus Exporter.
Does so_reuseport work in containers?
Yes, inside one network namespace. Several PgBouncer containers in the same pod share the pod’s network namespace and can bind the same port with so_reuseport = 1; containers in separate pods cannot, and need a Service in front instead.