Connection Routing & Pooling Strategies
Effective database routing requires balancing latency, consistency, and operational resilience across a live data plane where misconfiguration has immediate user impact. Modern architectures distribute read traffic across read replicas while preserving strict write guarantees on the primary. A routing layer that silently misclassifies SELECT ... FOR UPDATE as a read, or a pool sized below the replicaβs max_connections, triggers cascading failures β connection exhaustion, write fan-out to replicas, and stale-read windows that violate SLAs. This guide covers production-grade routing patterns, pool lifecycle design, and failure mitigation from endpoint discovery through observability.
Architecture Overview
The diagram below shows the data flow through a production connection routing stack: applications connect to a proxy tier that splits reads from writes, pools connections per replica, and monitors replication lag to gate session affinity decisions.
Routing Approach Trade-off Matrix
| Approach | Read Latency Impact | Operational Complexity | Failure Surface | Best-fit Workload |
|---|---|---|---|---|
| DNS-based discovery | High (TTL propagation lag) | Low | Split-brain on stale records | Static multi-region, low churn |
| Proxy-layer splitting (ProxySQL, PgBouncer) | Minimal (+0.1β0.5 ms hop) | Medium-High (proxy HA required) | Proxy SPOF; misclassified writes | High-throughput OLTP, mixed workloads |
| Application-layer routing (ORM router) | Zero (no extra hop) | Medium (per-service coupling) | Async context leakage | Microservices with query-level context |
| Service mesh / sidecar | Low (loopback) | High (mesh control plane) | Control plane partitions | Kubernetes-native, polyglot stacks |
| Read-your-writes + sticky session | Medium (session tracking) | Medium | Stale reads on proxy restart | User-facing feeds, profile pages |
Configuration Baseline
The minimal working configuration for a PgBouncer read replica pool with separate primary and replica pools:
# pgbouncer.ini β minimal production baseline
[databases]
# Primary: receives all writes and urgent reads
app_primary = host=db-primary.internal port=5432 dbname=app pool_size=20
# Replica pool: receives all read-only queries
app_replica = host=db-replica-az1.internal port=5432 dbname=app pool_size=50 pool_mode=transaction
[pgbouncer]
# transaction mode reuses connections aggressively; use session mode only
# if your app relies on SET LOCAL or advisory locks per connection
pool_mode = transaction
# Hard cap β never exceed replica max_connections
max_client_conn = 500
# Validate connections before reuse (catches silent replica disconnects)
server_check_query = SELECT 1
server_check_delay = 15
# Idle connections beyond this interval are closed and recycled
server_idle_timeout = 30
# Refuse new connections rather than queue indefinitely
client_login_timeout = 5
# TLS to replicas (required in production)
server_tls_sslmode = require-- ProxySQL routing rules (via admin interface on port 6032)
-- Rule 1: force SELECT FOR UPDATE and SELECT FOR SHARE to the primary
INSERT INTO mysql_query_rules
(rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES
(1, 1, '(?i)select.*for\\s+(update|share)', 10, 1),
(2, 1, '^(SELECT|SHOW|EXPLAIN)', 20, 1), -- replicas (hostgroup 20)
(3, 1, '.*', 10, 1); -- everything else: primary (hostgroup 10)
-- Raise to match your replica's max_connections
UPDATE global_variables SET variable_value='200'
WHERE variable_name='mysql-max_connections';
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;Endpoint Discovery & Network Topology
Clients must reliably locate primaries and replicas without introducing routing black boxes. Discovery mechanisms dictate how quickly topology changes propagate to application layers, a concern that interacts directly with how you handle replication lag during failover windows.
| Decision Factor | DNS-Based Resolution | Proxy / Service Mesh | Direct Socket Mapping |
|---|---|---|---|
| Propagation Latency | High (TTL-bound) | Low (control plane push) | Instant (client cache) |
| Failover Granularity | Coarse (record swap) | Fine (connection drain) | Manual (app restart) |
| Operational Overhead | Low | Medium-High | High |
| Best Use Case | Static multi-region | Dynamic auto-scaling | Bare-metal / legacy |
# Application DNS client tuning β resolv.conf options
resolver_options: "timeout:1 attempts:2 rotate"
dns:
ttl_override: 30s # override OS default; short TTL limits stale-record windows
srv_resolution: true # use SRV records for port-aware replica discovery
fallback_endpoints:
- db-primary.internal:5432
- db-replica-az2.internal:5432
health_check_interval: 10s # active probing supplements passive TTL expiryFailure modes: DNS cache poisoning redirects traffic to decommissioned nodes. Network partitions trigger split-brain routing when clients resolve stale SRV records. Mitigate by pairing short TTLs (30 s) with circuit breakers and a static fallback list in application configuration.
Proxy-Layer Read/Write Splitting
Centralising routing logic in a dedicated data plane β as covered in depth by the proxy-layer read/write splitting guide β isolates topology awareness from application code. Proxies parse query syntax, classify operations, and distribute connections across healthy replicas.
| Decision Factor | Stateless Proxy | Stateful Connection Router |
|---|---|---|
| Memory Footprint | Minimal | High (session tracking) |
| Query Classification | Regex / AST parsing | Transaction-boundary aware |
| SPOF Risk | Mitigated via load balancer | Requires active-active cluster |
| Best Use Case | High-throughput OLAP | Strict OLTP / transactional |
Failure modes: Unbounded connection queues exhaust proxy memory during traffic spikes. Misclassified SELECT ... FOR UPDATE statements route to replicas and return errors or stale locks. TLS handshake bottlenecks emerge under high concurrency when CPU-bound proxies lack hardware acceleration. Enforce strict AST parsing and cap queue depths with backpressure signals.
Application-Level Query Routing
Embedding routing decisions within the application framework or ORM provides granular control over query execution paths. Developers gain visibility into routing logic but inherit framework coupling. The ORM middleware routing guide details how to configure context managers and database routers safely.
| Decision Factor | Framework Middleware | Manual Connection Strings |
|---|---|---|
| Developer Velocity | High | Low |
| Topology Coupling | Tight (framework version-locked) | Loose |
| Context Overhead | Async boundary risks | Explicit management |
| Best Use Case | Microservices with rich ORM use | Legacy monoliths |
# SQLAlchemy async router β minimal production pattern
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
read_engine = create_async_engine("postgresql+asyncpg://replica-az1.internal/app",
pool_size=20, max_overflow=10)
write_engine = create_async_engine("postgresql+asyncpg://primary.internal/app",
pool_size=10, max_overflow=5)
ReadSession = sessionmaker(read_engine, class_=AsyncSession, expire_on_commit=False)
WriteSession = sessionmaker(write_engine, class_=AsyncSession, expire_on_commit=False)
async def get_session(operation: str):
"""Return the correct session for 'read' or 'write' operations."""
if operation == "read":
return ReadSession()
return WriteSession()Failure modes: Async context leakage routes subsequent requests to incorrect pools. Unhandled routing exceptions cascade into connection starvation. Enforce strict transaction boundaries, add explicit connection release hooks, and validate routing hints at test time.
Connection Pool Architecture & Lifecycle
Pools manage TCP reuse, authentication overhead, and resource allocation across distributed nodes. Improper sizing causes memory bloat or connection exhaustion during traffic spikes. The connection pool architecture guide covers replica-aware pool sizing and lifecycle design in detail.
| Decision Factor | Eager Initialisation | Lazy Initialisation |
|---|---|---|
| Cold-start Latency | Zero | High |
| Memory Baseline | Fixed | Dynamic |
| Scaling Responsiveness | Slow (pre-warmed) | Fast (on-demand) |
| Best Use Case | Predictable OLTP workloads | Bursty / serverless |
{
"pool_config": {
"min_idle": 10,
"max_active": 50,
"idle_timeout_ms": 30000,
"validation_query": "SELECT 1",
"validation_interval_ms": 15000,
"leak_detection_threshold_ms": 60000,
"replica_affinity_weights": { "az1": 0.6, "az2": 0.4 }
}
}Failure modes: Pool exhaustion occurs when connection acquisition exceeds replica max_connections. Zombie connections hold locks during failovers. Authentication token expiration mid-lifecycle drops pooled connections silently. Implement connection validation, enforce idle timeouts, and rotate credentials via sidecar proxy.
To protect against exhaustion during a failover, see avoiding connection exhaustion during replica failover.
Read Consistency & Session Affinity
Distributed replicas introduce replication lag, threatening read-your-writes guarantees. Session affinity routes subsequent requests to the same replica until it catches up. The sticky sessions guide details how to track session state without leaking memory or dropping affinity across proxy restarts.
| Decision Factor | Strict Consistency (primary reads) | Eventual + Sticky Routing |
|---|---|---|
| Read Latency | High (primary fallback) | Low (replica direct) |
| Load Distribution | Uneven (primary overloaded) | Balanced |
| Tracking Overhead | Minimal | High (session tokens) |
| Best Use Case | Financial ledgers, auctions | User profiles, activity feeds |
CONSISTENCY_MODE=sticky
SESSION_TOKEN_HEADER=X-DB-Session-ID
REPLICATION_LAG_THRESHOLD_MS=500
FALLBACK_TO_PRIMARY_ON_LAG=true
CACHE_INVALIDATION_SYNC=asyncWhen replication lag exceeds REPLICATION_LAG_THRESHOLD_MS, the router falls back to primary for that session β preventing stale reads without requiring application changes.
Failure modes: Proxy restarts strip session affinity headers, routing users to stale replicas. High lag violates read-your-writes guarantees despite sticky routing. Session state tracking without eviction policies leaks memory. Propagate session tokens via HTTP headers, enforce lag thresholds, and implement automatic primary fallback on drift.
Observability, Failover & Debugging
Routing layers require continuous telemetry to detect degradation before it reaches users. Combine Prometheus metrics with distributed tracing to correlate routing decisions with application error rates.
| Decision Factor | Static Thresholds | Adaptive / Predictive Routing |
|---|---|---|
| Failover Speed | Fixed delay | Dynamic (heuristic or ML) |
| False Positive Rate | High | Low (trend analysis) |
| Telemetry Overhead | Low | Medium-High |
| Best Use Case | Stable, predictable environments | Cloud-native, autoscaling |
observability:
metrics:
prometheus_exporter: true
scrape_interval: 15s
custom_labels: ["routing_path", "replica_lag_ms", "pool_utilisation"]
tracing:
propagation: w3c_tracecontext # required across proxy hops
sampling_rate: 0.1
circuit_breaker:
failure_threshold: 5
timeout: 30s
half_open_requests: 3 # probe health before full restore
failover:
auto_promote: true
split_brain_detection: quorum_voteKey Prometheus queries for routing health:
# Pool saturation: ratio of active to max connections per replica
pgbouncer_pools_cl_active / pgbouncer_pools_cl_maxwait
# Replication lag in seconds (PostgreSQL)
pg_replication_lag_seconds{replica="az1"}
# Routing error rate by path
rate(proxy_query_errors_total[5m]) by (routing_path)Failure modes: Noisy metrics trigger alert fatigue, masking genuine routing degradation. Automated failover during network partitions causes split-brain scenarios. Tracing context loss across proxy hops obscures root-cause analysis. Implement quorum-based promotion and enforce W3C trace propagation end-to-end.
Section Index
Proxy-Layer Read/Write Splitting
Implementing Read/Write Splitting at the Proxy Layer walks through ProxySQL rule authoring, query classification edge cases (SELECT ... FOR UPDATE, multi-statement transactions), and TLS configuration for the proxy-to-replica path. It covers the write-leakage failure mode and how to detect it before it reaches production.
- How to Implement Read/Write Splitting in Spring Data JPA β Spring-specific AbstractRoutingDataSource wiring with transaction-aware context holders.
ORM Middleware for Automatic Query Routing
ORM Middleware for Automatic Query Routing covers database router configuration in Django, SQLAlchemy engine switching, Prisma middleware hooks, and the async boundary pitfalls that silently route writes to read-only pools.
Connection Pool Architecture for Read Replicas
Connection Pool Architecture for Read Replicas details pool sizing formulas per replica, idle timeout tuning, connection validation queries, and affinity weighting across availability zones.
- Configuring PgBouncer for Read-Only Connection Pools β transaction vs session mode tradeoffs with annotated
pgbouncer.ini. - Avoiding Connection Exhaustion During Replica Failover β pre-failover drain patterns, queue depth caps, and fast reconnect configuration.
Sticky Sessions in Distributed Database Reads
Managing Sticky Sessions in Distributed Database Reads details session token propagation via HTTP headers, lag-threshold fallback logic, and memory-bounded session state eviction.
Production-Readiness Checklist
FAQ
Should I route at the proxy layer or in application code?
Proxy-layer routing (ProxySQL, PgBouncer) centralises topology awareness and requires no application changes, but adds a network hop and a potential single point of failure. Application-layer routing (SQLAlchemy router, Django database routers) gives you per-query context and zero added latency, but couples topology knowledge to every service that runs queries. The right choice depends on whether your team owns the proxy infrastructure and how many services share the same database.
How do I prevent SELECT FOR UPDATE from routing to a replica?
Configure your proxyβs query classification rules to match SELECT ... FOR UPDATE and route it to the primary host group. In ProxySQL this means adding a rule with the regex pattern (?i)select.*for\\s+update above the general SELECT replica rule and assigning it the write hostgroup. At the ORM layer, SQLAlchemyβs with_for_update() and Djangoβs select_for_update() should always run inside a using('default') or equivalent write-database context.
What is the right pool size per replica?
Start with: pool_size = (replica_vCPUs Γ 2) + effective_disk_spindles, capped at the replicaβs max_connections minus 5 for admin headroom. Monitor pg_stat_activity for wait_event_type = 'Client' β sustained waits indicate pool starvation; connections idle for minutes indicate over-provisioning. Adjust min_idle and max_active incrementally under representative load.
How does session affinity interact with replication lag?
When replication lag breaches the configured threshold, the router must fall back to the primary rather than continuing to serve the session from a lagging replica. This fallback increases primary load and may widen the lag further. Design your lag threshold (REPLICATION_LAG_THRESHOLD_MS) conservatively relative to your SLA, and alert before it triggers fallback β see detecting and handling replication lag in real time.
Related
β Database Read Replicas & Connection Routing Patterns β architecture overview, topology design, and replication mode selection.
- Replication Lag & Consistency Management β measure and bound lag; configure freshness-based query routing and fallback strategies.
- Implementing Read/Write Splitting at the Proxy Layer β ProxySQL and PgBouncer rule authoring, write-leakage prevention, and TLS setup.
- Connection Pool Architecture for Read Replicas β pool sizing, idle timeout tuning, and AZ affinity weighting.
- Managing Sticky Sessions in Distributed Database Reads β session token propagation, lag-threshold fallback, and eviction policies.
- ORM Middleware for Automatic Query Routing β Django, SQLAlchemy, and Prisma routing middleware with async boundary guidance.