Scale PgBouncer Across All CPU Cores on Ubuntu with SO_REUSEPORT

PgBouncer is one of the most popular PostgreSQL connection poolers, but it has an important limitation: each PgBouncer process is single-threaded. No matter how many CPU cores your server has, a single PgBouncer instance can only fully utilize one of them.

In this tutorial you’ll learn how to run multiple PgBouncer processes on Ubuntu, all listening on the same port using Linux’s SO_REUSEPORT feature. The Linux kernel distributes incoming client connections across the processes, allowing PgBouncer to scale with the number of available CPU cores.

What you’ll build

  • Ubuntu server with PostgreSQL
  • Multiple PgBouncer instances
  • Shared listening port using SO_REUSEPORT
  • Load-balanced client connections
  • Transaction pooling across all CPU cores

Prerequisites

  • Ubuntu 22.04 or newer
  • PostgreSQL server
  • Root or sudo access
  • PgBouncer installed

Update your system:

sudo apt update
sudo apt upgrade -y

Install PgBouncer:

sudo apt install pgbouncer

Verify the installation:

pgbouncer --version

Step 1 – Understand the Problem

A normal PgBouncer deployment looks like this:

Clients
    │
    ▼
PgBouncer (1 process)
    │
    ▼
PostgreSQL

Even on a 16-core machine, only one CPU core is doing the connection pooling.

A better architecture is:

              Clients
                  │
                  ▼
         Port 6432 (SO_REUSEPORT)
       ┌──────┬──────┬──────┬──────┐
       ▼      ▼      ▼      ▼
 PgBouncer PgBouncer PgBouncer PgBouncer
    #1        #2        #3        #4
       │      │      │      │
       └──────┴──────┴──────┘
              │
              ▼
         PostgreSQL

Each process runs independently while Linux automatically distributes incoming TCP connections.


Step 2 – Create a PgBouncer Configuration

Create a configuration directory:

sudo mkdir -p /etc/pgbouncer

Example configuration:

[databases]
postgres = host=127.0.0.1 port=5432 dbname=postgres

[pgbouncer]

listen_addr = * listen_port = 6432 auth_type = md5 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction max_client_conn = 1000 default_pool_size = 100 so_reuseport = 1 log_connections = 1 log_disconnections = 1

The important option is:

so_reuseport = 1

This tells every PgBouncer process to bind the same TCP port.


Step 3 – Create the User List

Generate a password hash or manually add users.

Example:

"postgres" "md5xxxxxxxxxxxxxxxxxxxxxxxx"

Save it as:

/etc/pgbouncer/userlist.txt

Step 4 – Run Multiple PgBouncer Processes

Instead of launching one process:

pgbouncer /etc/pgbouncer/pgbouncer.ini

launch several.

For example:

pgbouncer /etc/pgbouncer/pgbouncer.ini &
pgbouncer /etc/pgbouncer/pgbouncer.ini &
pgbouncer /etc/pgbouncer/pgbouncer.ini &
pgbouncer /etc/pgbouncer/pgbouncer.ini &

On a 16-core machine you would typically start one process per core.

You can verify they’re running:

ps aux | grep pgbouncer

Step 5 – Verify Port Sharing

Check that every process is listening on port 6432.

sudo ss -ltnp | grep 6432

Example output:

LISTEN 0 128 *:6432
LISTEN 0 128 *:6432
LISTEN 0 128 *:6432
LISTEN 0 128 *:6432

This confirms multiple processes are sharing the same socket.


Step 6 – Connect Normally

Clients connect exactly as before.

Host: your-server
Port: 6432

No client-side changes are required.

The Linux kernel distributes incoming connections across the PgBouncer processes automatically.


Step 7 – Divide Connection Limits

Each PgBouncer instance enforces its own limits.

If your desired total limit is:

max_client_conn = 1600

and you’re running 16 processes, configure each instance with:

max_client_conn = 100

Likewise, divide:

max_db_connections
default_pool_size

among all running instances to avoid oversubscribing PostgreSQL.


Step 8 – Understand Query Cancellation with Multiple PgBouncer Processes

One important side effect of running multiple PgBouncer processes behind SO_REUSEPORT is how PostgreSQL query cancellation works.

Normally, when you press Ctrl+C in psql or an application calls a query timeout, PostgreSQL does not send the cancellation over the existing connection. Instead, it opens a new TCP connection containing a special cancel request and a secret cancellation key.

With a single PgBouncer instance, that cancel request reaches the same process managing the client session, so the query is cancelled immediately.

With multiple PgBouncer processes sharing a port, the Linux kernel may route the new cancel connection to any PgBouncer instance.

Client
   │
   ├──────── Query ───────► PgBouncer #3 ─────► PostgreSQL
   │
   └── Cancel Request ───► PgBouncer #1

In this example, PgBouncer #1 has no knowledge of the client session being managed by PgBouncer #3, so the cancel request cannot be matched to the running query.

Reproducing the Issue

Start a long-running query:

SELECT pg_sleep(60);

Before the query finishes, press Ctrl+C.

Depending on which PgBouncer process receives the cancel request, the query may continue running instead of stopping immediately.


Step 9 – How to Handle Query Cancellation

There are several ways to address this limitation.

Option 1 – Use a Single PgBouncer Process

The simplest solution is to run only one PgBouncer instance.

This guarantees that every cancel request reaches the process managing the client session, but it also limits PgBouncer to a single CPU core.

Option 2 – Use PgBouncer Peering

Recent versions of PgBouncer support peering, allowing multiple PgBouncer processes to communicate with one another.

If a cancel request arrives at the wrong process, it is forwarded internally to the process that owns the client session.

                Cancel
Client ─────► PgBouncer #1
                   │
             Peer Communication
                   │
                   ▼
             PgBouncer #3
                   │
              Cancel Query
                   ▼
              PostgreSQL

This preserves normal query cancellation while still allowing multiple PgBouncer processes to share the workload.

If your PgBouncer version supports peering, it is the recommended approach for production deployments using SO_REUSEPORT.

Option 3 – Accept the Limitation

Some workloads never issue query cancellations.

For example:

  • short-lived OLTP transactions
  • connection pooling for stateless web services
  • applications that rely on database statement timeouts instead of client-side cancellation

In these environments, missing client-initiated cancellations may not be a significant concern.

Best Practice

For production systems that need both high throughput and correct query cancellation, combine:

  • multiple PgBouncer processes
  • SO_REUSEPORT
  • PgBouncer peering
  • transaction pooling

This configuration allows PgBouncer to utilize multiple CPU cores while ensuring cancel requests are forwarded to the process managing the client session, preserving expected PostgreSQL behavior.


Step 10 – Use Transaction Pooling

For maximum throughput, enable transaction pooling:

pool_mode = transaction

In this mode:

  • a PostgreSQL connection is borrowed for a transaction
  • it is returned immediately after the transaction commits
  • another client can reuse it

This minimizes the number of backend PostgreSQL connections.


Step 11 – Benchmark the Setup

Install pgbench:

sudo apt install postgresql-contrib

Initialize a benchmark database:

createdb bench
pgbench -i bench

Run a benchmark through PgBouncer:

pgbench \
-c 256 \
-j 16 \
-T 60 \
-p 6432 \
bench

Monitor CPU usage while the test runs:

pidstat -u 1

or

htop

With multiple PgBouncer processes, you should observe CPU utilization spread across multiple cores instead of a single process saturating one core.


Step 12 – Monitor Active Connections

Connect to the PgBouncer admin console:

psql \
-h localhost \
-p 6432 \
-U postgres \
pgbouncer

Useful commands:

SHOW POOLS;
SHOW CLIENTS;
SHOW SERVERS;
SHOW STATS;

These provide visibility into pool usage and active client/server connections.


Conclusion

Running multiple PgBouncer processes with SO_REUSEPORT is an effective way to scale PostgreSQL connection pooling on multi-core Ubuntu servers. By allowing every PgBouncer instance to listen on the same port, the Linux kernel transparently distributes client connections while applications continue using a single endpoint.

For production deployments, be sure to:

  • Run one PgBouncer process per CPU core (or benchmark to find the optimal number).
  • Split connection limits across all instances.
  • Use transaction pooling when appropriate.
  • Consider a peering or coordination mechanism if reliable PostgreSQL query cancellation is required across multiple PgBouncer processes.
Published on: 11 July
Posted by: Sami K.