DATABASE / ARCHITECTURE

Why You Still Need PgBouncer Even with Client-Side Connection Pooling

Client-side connection pools make connection acquisition faster for apps; PgBouncer prevents PostgreSQL from collapsing under connection exhaustion. A deep dive into multi-process architectures, memory bloat, and microservices connection multiplication.

DB PATCH10 min read# PostgreSQL# Database operations# Backend

Language: English·阅读中文版

On this page
Deep dive into the PostgreSQL connection model · Microservices multiplier effect · PgBouncer in Production (Part 1)

A PostgreSQL elephant relaxing in a pool, with a PgBouncer pipe regulating the connection pool flow

“Our microservice code is already configured with HikariCP (or Druid, Prisma, go-pg), and every Pod has a strict connection cap. Why do we need an extra PgBouncer layer in our infrastructure?”

This is the single most common question backend developers ask when a DBA asks them not to connect directly to the database port, but to route through PgBouncer instead.

In many engineers’ intuition, software architecture should follow Occam’s razor: do not multiply entities beyond necessity. Tearing down the familiar direct connection to port 5432 and adding an intermediary component looks like amplifying risk: traversing an extra network hop adds latency, introduces another potential point of failure, and demands dedicated operational maintenance and configuration. If the application already manages a local connection pool, isn’t placing another pooler in front of the database redundant?

This is a widespread and dangerous misconception.

Although both are labeled “connection pools”—which causes enormous confusion—their fundamental roles and objectives are entirely different:

Client-side connection pools solve the problem of “how applications acquire connections quickly”;
PgBouncer solves the problem of “how PostgreSQL survives connection spikes without collapsing”.


1. The Original Sin: PostgreSQL’s Process-per-Connection Model

To understand why a database requires an independent external connection pooler, we must look at PostgreSQL’s foundational concurrency architecture.

Unlike MySQL or SQL Server, which rely on single-process, multi-threaded models, PostgreSQL steadfastly adheres to a pure Unix philosophy: the Process-per-Connection (multi-process) architecture [1].

Whenever an application client initiates a new connection to PostgreSQL, the operating system executes the following steps:

[Client Connection Request]
        │
        ▼
[Postmaster Listening Process]
        │
        ├─► fork() system call ──► [Spawn dedicated postgres backend worker]
                                      │
                                      ├─► Map shared_buffers shared memory
                                      └─► Allocate private memory (Stack / Heap / Cache / work_mem)
  1. Heavy fork() overhead: The Postmaster main process must issue an OS-level fork() system call to spawn an entirely separate postgres backend worker process;
  2. Isolated address space: The OS allocates dedicated virtual address space, page table entries, file descriptors, and kernel task structures (task_struct) for every single process;
  3. Substantial private memory footprint: In addition to mapping the shared shared_buffers, the backend worker process allocates its own private heap and stack.

This dictates an immutable physical reality: PostgreSQL connections are inherently expensive.

In production, an idle connection that does nothing consumes anywhere from several megabytes to tens of megabytes of private RAM; once a connection begins executing complex analytical queries, sorting, or large transactions, memory consumption scales dramatically.


2. The Hidden Thorn: Not a Real Memory Leak, But Space That Never Shrinks

In routine operations, backend developers frequently ask:

“All our database connections are long-lived, and every transaction in our business logic commits cleanly. Why does system monitoring show that the backend worker processes’ resident memory (RSS) only ever grows and never drops?”

The intuitive assumption is that PostgreSQL must have a memory leak.

Strictly speaking, PostgreSQL implements a rigorous hierarchical memory context (MemoryContext) system. Once a transaction finishes, transaction-scoped contexts are reset and reclaimed immediately. True C-level memory leaks are extraordinarily rare in mature releases.

Yet in production, DBAs regularly witness an unsettling sight: inspecting top or ps reveals backend postgres processes that have been alive for weeks quietly consuming hundreds of megabytes or even several gigabytes of RSS—turning into memory-gorging behemoths.

This is not a software logic leak; it is memory space that cannot be returned to the OS. In production, it stems from two unavoidable physical phenomena:

① Persistent Metadata and Plan Cache Bloat (Syscache & Plancache)

The longer a connection lives and the more varied its workloads, the more table, index, partition, enum, and trigger metadata is cached inside that process’s private memory (relcache and syscache). Furthermore, execution plans generated for prepared statements remain cached. In microservice environments touching hundreds of tables or thousands of partitions, this persistent private cache alone can inflate an otherwise idle process to hundreds of megabytes.

② Linux glibc Heap Fragmentation

When a query performs complex sorting (ORDER BY), aggregation (GROUP BY), or hash joins, it temporarily allocates substantial memory governed by work_mem. Once the query completes, the database code dutifully calls free().
However, under the default Linux memory allocator (glibc malloc), heap fragmentation and memory gaps prevent brk from releasing pages back to the kernel if small chunks near the high-water mark remain in use.

The result: backend process resident set size (RSS) grows monotonically and never drops.

Without a global mechanism to systematically retire and rebuild these bloated processes, available physical memory is slowly devoured by long-lived connections.


3. The Microservices Multiplier Effect and OOM Hazards

“But we only configured 20 connections max in each microservice Pod’s pool! That’s already very conservative!”

In the monolithic era, applications ran on a handful of servers. With 20 connections per server, the database handled a modest 60 concurrent connections, and everything hummed along smoothly.

In containerized, auto-scaling Kubernetes architectures, however, client connection pools multiply exponentially:

[Microservice A]  10 Pods × 20 connections = 200 connections
[Microservice B]  20 Pods × 20 connections = 400 connections
[Microservice C]  30 Pods × 20 connections = 600 connections (scales to 50 Pods during spikes = 1,000 connections)
...
--------------------------------------------------
Total: Thousands of direct physical connections hammering PostgreSQL simultaneously!

Client-side connection pools are distributed, isolated, and uncoordinated. Individual Pods have zero visibility into other Pods, and they have no concept of the database’s actual saturation limits.

The real danger comes from cascading failures:

  1. A minor release introduces an unindexed query, inflating execution latency from 5ms to 1.5s;
  2. Inbound requests pile up, saturating application worker thread pools;
  3. Client-side connection pools perceive an “exhaustion of available connections” and aggressively open new connections up to their maximumPoolSize;
  4. Hundreds of connections simultaneously transition into active query execution, slamming the database!

The Performance Cliff

Many assume that granting more connections linearly scales database concurrency. In reality, PostgreSQL’s concurrency curve is a steep cliff:

Throughput (TPS)
  ▲
  │         /───\  ← Optimal sweet spot (typically 2~4 × CPU core count) [2]
  │        /     \
  │       /       \
  │      /         \
  │     /           \
  │    /             \─────┐ ← Thundering herds, lock contention, and CPU cache misses cause throughput collapse
  └─────────────────────────────► Active Connections

Authoritative benchmarks from the community and industry demonstrate that on a 16-core or 32-core server, the sweet spot for PostgreSQL system throughput (TPS) typically occurs around 30 to 60 active concurrent queries [2].

Once active concurrency exceeds several multiples of CPU cores, CPU cycles are squandered on:

  • Constant OS kernel context switching among thousands of processes;
  • CPU L1/L2/L3 cache-line thrashing;
  • Intense contention on engine-level spinlocks and lightweight latches (such as ProcArrayLock).

The Ultimate Nightmare: Linux OOM Killer and Crash Recovery

Even more lethal is memory exhaustion. In PostgreSQL, work_mem is allocated per operator (a single query with multiple sorts and hash joins can allocate multiples of work_mem).

When thousands of connections execute simultaneously, instantaneous memory demand spikes dangerously:

Peak Memory Demand ≈ (Active Concurrent Connections × Operators Per Query × work_mem) + Process Private Caches + shared_buffers

Once physical RAM is exhausted, the Linux OOM Killer intervenes and terminates a high-memory postgres backend process.

In PostgreSQL, if any backend worker is forcefully killed with SIGKILL, the Postmaster process assumes shared memory integrity has been compromised. To protect data from corruption, it immediately terminates all running backends, aborts active transactions, and initiates Crash Recovery by replaying WAL logs. The entire service remains down for minutes.


4. Enter PgBouncer: The Featherweight Gatekeeper

To break this deadlock, an unyielding line of defense must stand before the database: PgBouncer.

PgBouncer is the most proven connection pooler in the PostgreSQL ecosystem (originally developed and open-sourced by Skype). Written in pure C, it is built entirely on libevent non-blocking I/O (Linux epoll).

Its defining quality is that it is extraordinarily lightweight:

DimensionNative PostgreSQL ConnectionPgBouncer Client Connection
Underlying EntityDedicated OS physical process (fork)Non-blocking socket in an event loop
Memory per ConnectionSeveral MB to tens of MB (private RSS)Approx. 2 ~ 4 KB (compact metadata struct)
10,000 ConnectionsHost crashes from OOM or CPU lockupConsumes a few tens of MB with negligible CPU

A modest virtual machine running PgBouncer can easily maintain tens of thousands of client connections with virtually no operating system scheduling friction.


5. The Core Weapon: Time-Sliced Execution in Transaction Pooling

PgBouncer provides three pooling modes (pool_mode), each with distinct trade-offs:

  • Session Pooling: Like a private chauffeur. A client claims a dedicated physical PostgreSQL connection upon connecting and holds it until disconnecting. It offers 100% compatibility, but does not solve the connection explosion problem;
  • Statement Pooling: Like a bus that only travels one stop. The connection is released after every single SQL statement. This breaks multi-statement transactions (BEGIN...COMMIT errors out) and is rarely used in production;
  • Transaction Pooling: The core engine and primary production weapon of PgBouncer.

Why Transaction Pooling Yields 50x Leverage

Developers often assume that adopting transaction pooling requires massive application rewrites. In reality, for the vast majority of standard business workloads, no code modifications are needed at all. The secret lies in high-frequency, time-sliced reuse of physical connections.

In typical web microservices, an HTTP request might span 50ms, but the active database transaction often executes in just 2 to 5ms:

Client View:   [── Open Connection ──────────────────── Hold Connection ────────────────── Release ──]
Business App:  [ Validation 10ms ] [ SQL Tx 3ms ] [ Remote RPC 25ms ] [ SQL Tx 2ms ] [ JSON 10ms ]
Physical PG:                       [ Bound ]                          [ Bound ]
                                   └── Immediately returned to pool on COMMIT for other requests ──┘

For more than 90% of a request’s lifespan, the client is executing business code, invoking upstream microservices via RPC, waiting on async queues, or processing network I/O.

Under transaction pooling:

  1. Prior to issuing BEGIN (or an implicit single-statement transaction), the client merely holds a lightweight socket descriptor consuming a few kilobytes in PgBouncer;
  2. The moment a transaction begins, PgBouncer assigns an active backend PostgreSQL connection from its physical pool;
  3. The moment the transaction issues COMMIT or ROLLBACK, PgBouncer unbinds the physical connection and returns it to the pool immediately!

From the application’s perspective, every Pod appears to have an always-available connection. From the database’s perspective, a few dozen physical worker processes seamlessly power thousands of interleaved client requests!

(Note: Because physical connections are reassigned between transactions, applications cannot rely on session-level state across transaction boundaries, such as session temporary tables or global SET commands. However, modern versions of PgBouncer natively support client-side prepared statements [3].)


6. Under the Hood: The Four Defenses PgBouncer Builds

Understanding transaction pooling reveals the four robust defenses PgBouncer establishes for PostgreSQL:

① Absorption of Traffic Surges

When microservice traffic spikes and thousands of Pods open connections at once, those connections queue harmlessly within PgBouncer’s epoll event queue with minimal memory overhead. PostgreSQL is never subjected to a wave of fork() storms. The database perceives only an orderly, capped stream of queries.

② Enforcing the Performance Sweet Spot

Regardless of how high external concurrency spikes, PgBouncer restricts active backend connections to the optimal throughput range (typically 30 to 60). Free from CPU context thrashing and ProcArrayLock latch contention, database TPS reaches peak efficiency while P99 tail latency drops significantly.

③ Eliminating OOM Crashes

By capping physical backend connections through default_pool_size, overall memory consumption is bounded by a deterministic mathematical ceiling:

Peak Memory ≤ shared_buffers + Max Backend Connections × (Base Private Memory + Operator Peak × work_mem)

This eliminates the hazard of traffic surges triggering the Linux OOM Killer and knocking the database offline.

④ Connection Recycling to Eradicate Memory Bloat

PgBouncer provides server_lifetime (e.g., 3,600 seconds) and server_idle_timeout.
Once a connection reaches its configured lifespan and is not servicing a transaction, PgBouncer gracefully closes the physical connection. PostgreSQL reclaims the backend process, and PgBouncer spawns a clean replacement.

This periodic recycling completely wipes out cumulative metadata cache bloat and glibc heap fragmentation, putting an end to runaway resident memory.


7. Conclusion: Two Defenses, Two Completely Different Missions

Returning to the initial question: why do we still need to connect through PgBouncer if the application already has a connection pool?

The answer is clear:

  • Client-side connection pools form the “Application Defense”: They optimize local efficiency, preventing individual processes from paying repeated TCP handshake and authentication costs;
  • PgBouncer forms the “Database Kernel Fortress”: It enforces global coordination, shielding the database from connection multiplication, eliminating CPU lock thrashing, neutralizing memory bloat, and preventing OOM crashes.

In distributed, dynamically autoscaling environments, a client-side connection pool without PgBouncer is a ticking time bomb waiting for the next traffic spike.


Further reading


(Part 1 of the PgBouncer in Production series: Architecture & Concepts)

Back to articles

Search articles

English content · Esc to close