system-design.io

Connection Pooling and DB Proxy Patterns

Bridge: The Restaurant Analogy

Imagine a popular restaurant that only has five tables. Every time a group of diners arrives, the host must clean a table, set silverware, and take a drink order before they can eat. If the restaurant tried to build a new table for each party, it would need unlimited space, staff, and time—clearly impossible. Instead, the host reuses the same five tables, clearing them as soon as a party leaves. This reuse cuts down setup time, lets the restaurant serve many more guests, and keeps costs predictable.

In software, a database connection is like that table: establishing it involves network handshakes, authentication, and allocating memory—expensive operations. Connection pooling reuses existing connections (the tables) so the application can handle many requests without paying the full setup cost each time. A database proxy adds another layer: it acts like a maître d’ who not only reuses tables but also decides which table (writer or reader) a party should sit at, balances load, and can even swap tables if one becomes sticky.


1. The Core Problem: Why Naive Connections Hurt

When an application opens a new TCP connection to a database for every query, it pays a steep price:

If the app fires hundreds of requests per second, these costs multiply quickly, leading to connection storms, exceeded max_connections limits, and slow response times.

“Show the broken state, then sell the fix.”

Consider a simple Python script that opens and closes a connection for every query:

import psycopg2
import time

def query_one(sql):
    conn = psycopg2.connect(
        host="localhost",
        dbname="app",
        user="app",
        password="secret"
    )
    cur = conn.cursor()
    cur.execute(sql)
    rows = cur.fetchall()
    cur.close()
    conn.close()          # expensive teardown
    return rows

# Simulating 100 rapid requests
for i in range(100):
    query_one("SELECT 1")

Each iteration pays the full connection cost, and the database must allocate and tear down a backend process each time.


2. Connection Pooling: Reusing the “Tables”

2.1 Atomic Unit: A Pool of Ready Connections

A connection pool is a cache of already‑opened, authenticated connections kept alive by a pool manager. When a request needs a DB connection, the manager:

  1. Looks for an idle connection in the pool.
  2. If found, hands it to the requester.
  3. If none are free and the pool hasn’t hit its max size, creates a new connection.
  4. If the pool is full, the requester waits (or fails after a timeout).

When the requester finishes, it returns the connection to the pool instead of closing it.

“We usually simplify things again…”

Minimal Python Pool (using psycopg2.pool)

from psycopg2 import pool
import threading

# Create a pool with 2‑10 connections
POSTGRES_POOL = pool.ThreadedConnectionPool(
    minconn=2,
    maxconn=10,
    host="localhost",
    dbname="app",
    user="app",
    password="secret"
)

def get_conn():
    return POSTGRES_POOL.getconn()

def put_conn(conn):
    POSTGRES_POOL.putconn(conn)

def query_pooled(sql):
    conn = get_conn()
    try:
        cur = conn.cursor()
        cur.execute(sql)
        return cur.fetchall()
    finally:
        put_conn(conn)      # always return, even on error

The pool hides the expensive open/close cycle; the same socket can serve dozens of queries.

2.2 Visualizing the Pool

+-------------------+       +-------------------+
|   Application     |       |   Connection Pool |
|  (requests)      |<----->|  [ idle | used ]  |
+-------------------+       +-------------------+
        ^                         ^
        | get_conn()              | put_conn()
        |                         |
        v                         v
+-------------------+       +-------------------+
|   Database Server |<----->|   Backend Sockets |
+-------------------+       +-------------------+

In this diagram, the pool sits between app and DB, recycling sockets.

2.3 Iterative Complexity: Adding Features to Fix Pain Points

Pain Point Added Feature Result
Exhaustion – all connections busy, requests stall Waiting queue + timeout Requests wait up to a limit; fail fast if timeout exceeded.
Stale connections – network drop leaves broken socket in pool Connection validation (ping/keep‑alive) Before lending a connection, run a cheap query (SELECT 1) to verify liveness.
Uneven load – some connections overused while others idle Round‑robin or LRU allocation Spreads usage evenly, reducing hot sockets.
Resource leaks – app forgets to return connection Context managers / try‑finally Guarantees put_conn even when exceptions occur.
Burst traffic – sudden spike exceeds maxconn max_overflow (extra overflow connections) Allows temporary surplus, then shrinks back.

Example: SQLAlchemy’s QueuePool with validation

from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool

engine = create_engine(
    "postgresql://app:secret@localhost/app",
    poolclass=QueuePool,
    pool_size=10,          # steady size
    max_overflow=5,        # allow 5 extra in bursts
    pool_timeout=30,       # wait 30s for a connection
    pool_recycle=1800,     # recycle after 30 min
    pool_pre_ping=True     # validate before use
)

Here pool_pre_ping runs a lightweight query to avoid handing out dead connections.

2.4 Limitations of Simple Pooling

Even with a pool, problems can arise:

These limits motivate the database proxy pattern.


3. Enter the DB Proxy: A “Maître d’” for Connections

3.1 What Is a Proxy?

In software design, a proxy is an object that stands in for another object, controlling access to it while possibly adding behavior (logging, caching, routing). The proxy implements the same interface as the real subject, so clients need not change.

3.2 Why a Proxy for Databases?

A database proxy sits between the application and the database server(s) and can:

3.3 Visualizing the Proxy Stack

+-------------------+       +-------------------+       +-------------------+
|   Application     | --->  |   DB Proxy        | --->  |   Database Cluster|
|   (many clients) |       | (pool + routing) |       | (writer/readers) |
+-------------------+       +-------------------+       +-------------------+

App talks to the proxy using the standard DB protocol; the proxy talks to one or more DB nodes.

3.4 Problem‑Solution Narrative: From Pinning to Multiplexing

Broken state: An application using a client‑side pool may pin a connection—e.g., after executing SET timezone='UTC', it keeps that connection for the life of a thread, even while idle. This prevents the pool from reusing that connection for other threads, leading to under‑utilization and more server connections than needed.

Fix: A proxy that supports connection multiplexing can take the logical client connection, bind it to a physical server connection only for the duration of a transaction, then return that server connection to a shared pool. Multiple client connections can thus share fewer server connections.

3.5 Simple TCP Proxy in Python (Illustrative)

Below is a minimal, educational TCP proxy that forwards bytes between a client and a PostgreSQL server. It does not understand the protocol—it simply shows the proxy concept.

import socket
import threading

def handle_client(client_sock, addr):
    # Connect to the real DB (hardcoded for demo)
    db_sock = socket.create_connection(("localhost", 5432))
    def forward(src, dst):
        try:
            while True:
                data = src.recv(4096)
                if not data:
                    break
                dst.sendall(data)
        finally:
            src.close()
            dst.close()
    t1 = threading.Thread(target=forward, args=(client_sock, db_sock))
    t2 = threading.Thread(target=forward, args=(db_sock, client_sock))
    t1.start()
    t2.start()
    t1.join()
    t2.join()

def run_proxy(host="0.0.0.0", port=5433):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((host, port))
    sock.listen(5)
    print(f"Proxy listening on {host}:{port}")
    while True:
        client, addr = sock.accept()
        print(f"Accepted from {addr}")
        threading.Thread(target=handle_client, args=(client, addr)).start()

if __name__ == "__main__":
    run_proxy()

In practice, real proxies (e.g., ProxySQL, AWS RDS Proxy) parse the database protocol to enable smart routing and multiplexing.


4. Categorical Chunking: Types of Pooling \& Proxy Variants

4.1 Pooling Variants (by Intent)

Category Where It Lives Typical Use
Client‑side pool Inside the application language/library (e.g., HikariCP, psycopg2 pool) Reduces per‑app connection overhead.
Server‑side pool In a middleware layer (e.g., PgBouncer, Oracle Shared Server) Shares connections across many apps; reduces DB backend processes.
Proxy‑side pool Embedded in a DB proxy (e.g., RDS Proxy, ProxySQL) Combines multiplexing with routing; offers protocol‑aware reuse.

4.2 Proxy Variants (by Behavior)

Variant Primary Goal Example
Transparent proxy Intercept without client config change; often uses same port as DB. AWS RDS Proxy (client connects to proxy endpoint).
Forward proxy Outbound traffic control; app must be configured to point to proxy. Corporate DB gateway that enforces TLS and logging.
Reverse proxy Presents itself as a DB to clients; routes to backend clusters. ProxySQL for MySQL/MariaDB load balancing.
Smart proxy Understands DB protocol to perform routing, shuffling, or rewriting. Vitess (MySQL sharding), PgCat (PostgreSQL).

4.3 Combining Pooling \& Proxy

A modern architecture often stacks both:

  1. Application → uses a small client‑side pool (e.g., size 5).
  2. Client connections go to a DB proxy that maintains a larger server‑side pool (e.g., size 50) and performs multiplexing.
  3. The proxy routes to read replicas for SELECT and to the writer for INSERT/UPDATE/DELETE.

This yields: low latency from the client pool, high connection efficiency from the proxy, and workload‑aware routing.


5. Best Practices \& Operational Guidance

5.1 Connection Pooling

  1. Size based on concurrency, not request rate – monitor in_use vs. idle.
  2. Set pool_recycle to avoid exceeding DB server’s wait_timeout.
  3. Enable pool_pre_ping (or equivalent) to weed out dead connections.
  4. Use context managers (with in Python) to guarantee return.
  5. Log acquire times and wait ratios; trigger alerts if >80% utilization for sustained periods.
  6. Handle exhaustion with exponential backoff and jitter.

5.2 Database Proxy

  1. Terminate TLS at the proxy to offload CPU from DB nodes.
  2. Enable IAM or LDAP auth so the proxy can manage credentials centrally.
  3. Configure read‑weighting appropriately (e.g., 80% reads to replicas).
  4. Monitor multiplexing ratio (client connections : server connections).
  5. Pin detection – watch for sessions that hold connections long after transactions end; consider using session‑level pinning metrics.
  6. Test failover – verify that proxy promotes a replica correctly when writer fails.

6. Historical Context


7. Putting It All Together: A Sample Architecture

Below is a diagram (ASCII art) showing a typical production stack that layers pooling and proxy.

+-------------------+        +-------------------+        +-------------------+
|   Web Services    |        |   API Gateway     |        |   Service Mesh    |
|   (Python/Go)    | <----> | (Auth, Rate Lim) | <----> | (mTLS, Observ.)   |
+-------------------+        +-------------------+        +-------------------+
          |                         |                         |
          | (small client pool)     | (TCP to proxy)          |
          v                         v                         v
+-------------------+        +-------------------+        +-------------------+
|   Connection      |        |   DB Proxy        |        |   DB Cluster      |
|   Pool (HikariCP) | <----> | (Multiplex +     | <----> | (Writer + 2x      |
|   size=8          |        |  Routing)         |        |  Read Replicas)   |
+-------------------+        +-------------------+        +-------------------+
          |                         |                         |
          | (pooled server conns)   | (protocol‑aware routing)|
          v                         v                         v
+-------------------+        +-------------------+        +-------------------+
|   Backend Sockets |        |   Logs & Metrics  |        |   Auto‑Failover   |
|   (Postgres)      |        |   (Prometheus)    |        |   (Patroni)       |
+-------------------+        +-------------------+        +-------------------+

The app keeps a tiny client pool; the proxy does the heavy lifting of multiplexing, read/write split, and observability.


8. Conclusion

Connection pooling and DB proxy patterns solve complementary problems:

When used together, they enable applications to scale to thousands of concurrent requests while keeping latency predictable and infrastructure costs under control.