PgBouncer is the go-to connection pooler for PostgreSQL, but it has one well-known limitation: it is single-threaded. A single PgBouncer process uses one CPU core no matter how many the machine has. On a 16-vCPU box, that means one core does all the connection pooling while the other fifteen sit idle -- and the pooler starts capping throughput long before Postgres runs out of room.
ClickHouse's Managed Postgres team just published a deep dive showing how they solved this. By running a fleet of PgBouncer processes with SO_REUSEPORT and adding peering for query cancellation, they achieved 4x the throughput on identical hardware. The post went viral on Hacker News with 188 upvotes, and the approach has immediate practical value for anyone running Postgres at scale.
The Problem: Single-Threaded Architecture
PgBouncer's architecture is elegantly simple: one process, one event loop, one CPU core. This design makes it predictable and easy to debug, but it also means PgBouncer hits a ceiling well before the hardware does.
The bottleneck manifests in two ways:
- CPU saturation: The single core maxes out under load, throughput plateaus, and latency increases as everything contends for one thread
- Connection limits:
max_client_conncaps total connections. The pooler rejects new clients once this limit is reached withFATAL: no more connections allowed
In benchmarks on a 16-vCPU AWS EC2 instance, a single PgBouncer process peaked at around 87,000 transactions per second and then degraded under more load, sliding to 77,000 tps at 256 concurrent clients.
ClickHouse's Solution: A Fleet of Processes
The ClickHouse team took a straightforward approach: instead of one PgBouncer, run multiple processes sized to the available cores, all sharing the same port.
SO_REUSEPORT
The key enabler is the SO_REUSEPORT socket option, which allows multiple processes to bind to the same TCP port. The kernel load-balances incoming connections across the processes. Clients connect to a single endpoint and never know there is more than one PgBouncer behind it.
This is the mechanism PgBouncer's own documentation points to for using more than one core. It is single-threaded per process, and SO_REUSEPORT is how you put every core to work.
The Catch: Query Cancellation
A PostgreSQL cancel request arrives on a brand-new connection carrying a cancel key, separate from the connection running the query. With SO_REUSEPORT, the kernel is free to hand that new connection to a different process than the one holding the session. The cancel lands on a process that has never heard of the query, and nothing happens.
This is a real problem. Without cancellation, users would be unable to interrupt long-running queries -- a non-starter for production workloads.
Peering Fixes Cancellation
ClickHouse implemented peering between the PgBouncer processes. When a cancel request arrives on the wrong process, it is forwarded to the process that actually owns the session. Cancellation works across the entire fleet, even though any given request can arrive anywhere.
The peering layer is transparent to clients. From the outside, the fleet behaves exactly like a single PgBouncer process -- just faster and with more capacity.
Connection Budget Management
Pooling runs in transaction mode, so a server connection is returned to the pool the moment a transaction commits. The connection budget is split across the fleet: max_db_connections is divided by the number of processes, so the fleet as a whole never oversubscribes PostgreSQL.
Benchmark Results
The team ran both configurations on identical AWS EC2 instances: a 16-vCPU instance for the pooler, a separate box for Postgres, and a third machine driving load with pgbench in select-only, transaction-pooled mode.
| Metric | Single Process | Fleet (16 processes) | Improvement |
|---|---|---|---|
| Peak throughput | ~87k tps | ~336k tps | 3.9x |
| CPU utilization (in-guest) | ~9% | ~52% | 5.8x more efficient |
| CPU utilization (CloudWatch) | ~16% | ~60% | 3.75x more efficient |
| Behavior under load | Degrades to 77k tps | Keeps climbing | Stable under pressure |
The single process never gets past about one core of work. Under load, top shows the PgBouncer process pinned at ~97% CPU on a single core, while the 16-vCPU box as a whole stays under 10% utilized. The fleet spreads across the machine, reaching roughly 8 cores busy, and still had headroom when Postgres and the load generator became the limit.
At a handful of connections the single process is actually fine, even a hair faster, since there is nothing to parallelize and the fleet's connections are spread thin. The gap opens exactly where it matters: under real concurrency, where one core becomes the wall.
Practical Implementation
When to Scale
A single PgBouncer is fine until the pooler, not Postgres, is what caps your throughput. You should consider the fleet approach when:
- PgBouncer CPU is consistently above 80% on a single core
- Connection limits are being hit regularly
- You have 8+ vCPUs available for the pooler
- Throughput is plateauing but Postgres has headroom
Configuration Tips
- Size the fleet to available cores: Run one PgBouncer per vCPU allocated to the pooler
- Split connection budgets: Divide
max_db_connectionsby the number of processes - Enable SO_REUSEPORT: Configure each process to bind the same address and port
- Implement peering: Ensure cancellation requests are forwarded between processes
- Monitor aggregate metrics: Watch total CPU usage across the fleet, not per-process
Frequently Asked Questions
Does this work with PgBouncer's transaction mode? Yes. The ClickHouse implementation uses transaction mode, where a server connection is returned to the pool after each transaction commits.
Are there any downsides to the fleet approach? At very low connection counts (under 8 clients), the single process is marginally faster because there is nothing to parallelize and the fleet's connections are spread thin. The benefit appears exactly where it matters: under real concurrency.
Is SO_REUSEPORT supported on all platforms? SO_REUSEPORT is available on Linux (since kernel 3.9) and recent versions of macOS. It is not available on Windows.
Does ClickHouse offer this as a managed service? Yes. Every ClickHouse Managed Postgres server ships with this multi-process PgBouncer setup by default.
Does the peering add latency? The peering overhead is negligible compared to the throughput gains. Cancellation requests are forwarded via local IPC, not over the network.
How does this compare to connection poolers like pgpool-II or Odyssey? PgBouncer remains the most widely deployed Postgres pooler due to its simplicity and low overhead. The fleet approach brings multi-core scaling to PgBouncer without changing its architecture. Odyssey supports multi-threading natively but requires a different operational model.
Key Takeaways
- PgBouncer is single-threaded and caps throughput on multi-core machines
- Running multiple processes with SO_REUSEPORT enables 4x throughput on 16-vCPU instances
- Query cancellation works across the fleet via peering between processes
- Connection budgets must be split proportionally across the fleet
- The approach is production-tested at ClickHouse and available in their Managed Postgres service
- At low concurrency the single process is fine; at scale the fleet approach is transformative
Conclusion
Scaling PgBouncer from a single thread to a multi-process fleet is a textbook systems engineering win. The approach is simple, well-understood, and independently verified with production benchmarks. For anyone running Postgres on multi-core hardware with significant connection throughput, this multi-process pattern with SO_REUSEPORT and peering turns the pooler back into plumbing instead of a bottleneck.
Every ClickHouse Managed Postgres server ships with this setup by default. But the pattern is general: any PgBouncer deployment on Linux can benefit from the same approach.
Sources: ClickHouse Blog: How we scale PgBouncer in ClickHouse Managed Postgres, Hacker News Discussion
Automated Transmission
This entry was synthesized and populated dynamically using native API integrations.