Stage 7 · Master
Phase 18 — Performance Engineering
Connection Pooling
Diagnose a pool-exhaustion incident from pgxmetrics data, size MaxConns against a shared PostgreSQL instance's real ceiling, and add a statement timeout before adding more connections.
Read the Incident From the Metrics That Already Exist
At 09:14 during a marketing push, api-gateway's error rate on every organization-service route spikes to 8%. The errors are not database errors — they are Go's own context deadline exceeded, surfaced from pgxpool.Pool.Acquire, which means requests are timing out waiting for a connection the pool does not have, not waiting for a slow query to finish. Observability added services/organization/internal/pgxmetrics to organization-service's registry — and only organization-service's, which is why this incident is visible on a dashboard at all rather than being guessed at. This is the first time anyone has read that collector under real load rather than at rest.
hoa_organization_postgres_pool_acquired_connections
/
hoa_organization_postgres_pool_max_connectionsThe ratio holds at 1.0 for the full 90 seconds of the spike — every configured connection is checked out simultaneously, and any goroutine arriving after that has nothing left to acquire.
A timeout inside Acquire happens before any SQL is sent — it proves the pool itself is the bottleneck. A timeout from QueryRow or Exec after Acquire succeeded would instead point at a slow query, which is a different lesson (Lesson 2's join and pagination fixes) and a different fix.
MaxConns Must Be Sized Against Postgres's Ceiling, Not Chosen Per Service
pkg/postgres/pool.go exposes MaxConnections as a per-service config value, and every service that adopted it has defaulted to 20 without anyone doing the arithmetic across the whole platform. Staging runs one shared PostgreSQL instance with max_connections = 100. Eleven services, each running two replicas for availability, each configured for MaxConns=20, is a theoretical ceiling of 440 connections against a server that accepts 100.
max_connections = 100
# reserved_connections for superuser/replication, not available to services
superuser_reserved_connections = 397 connections is the real, shared budget every service replica competes for — not a number any single service's config file can see or protect on its own.
| Service | Replicas | MaxConns (current) | Theoretical ceiling contribution |
|---|---|---|---|
| organization | 2 | 20 | 40 |
| user | 2 | 20 | 40 |
| auth | 3 | 20 | 60 |
| gateway | 2 | 10 | 20 |
| 8 remaining domain services | 2 each | 20 | 320 |
The marketing spike did not change the ceiling; it changed how many services simultaneously ran near their own configured maximum at the same moment, which had never happened together before. The fix is not raising organization's MaxConns — that makes the shared ceiling worse for every other service — it is giving each service a budget that sums to something the server can actually serve.
// DatabaseMaxConns is derived, not hard-coded: (shared budget) / (services * replicas),
// rounded down, with headroom left for migrations and manual psql sessions.
// 97 available / 11 services / avg 2.3 replicas ≈ 3.8 → 3, with a documented
// per-service override for auth, which legitimately needs more due to token
// verification's higher request volume.
const DatabaseMaxConns = 3
func Load() (Config, error) {
// ...
maxConns, err := platformenv.Int32("DATABASE_MAX_CONNS", DatabaseMaxConns)
if err != nil {
return Config{}, err
}
// ...
}Making the default an explicit, commented constant — instead of a locally convenient 20 — is what stops the next service's author from repeating the same unexamined default.
A Missing Statement Timeout Turns One Slow Query Into a Pool-Wide Incident
Lowering MaxConns without a statement timeout trades one incident for a worse one: with fewer connections available, a single query that never finishes — the N+1 loop from Lesson 2, before its fix shipped, held a connection open for the full duration of 41 sequential round trips — now starves a proportionally larger share of the pool's already-smaller budget.
func Open(ctx context.Context, input PoolConfig) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(input.URL)
if err != nil {
return nil, fmt.Errorf("parse database URL: %w", err)
}
config.MaxConns = input.MaxConnections
config.MinConns = input.MinConnections
config.MaxConnLifetime = input.MaxLifetime
config.MaxConnIdleTime = input.MaxIdleTime
config.HealthCheckPeriod = input.HealthCheckTime
// Every connection this pool opens carries a server-side statement_timeout.
// A runaway query now fails after 5s instead of holding a connection
// (and, transitively, one eleventh of the platform's shared budget) forever.
config.ConnConfig.RuntimeParams["statement_timeout"] = fmt.Sprintf(
"%dms", input.StatementTimeout.Milliseconds(),
)
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create database pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}RuntimeParams sets the value once per new physical connection at startup — every session PostgreSQL opens for this pool inherits it, so no individual query call site can forget to bound its own runtime.
Consider PgBouncer When Replica Count, Not Query Load, Is the Constraint
Even correctly sized, eleven services times several replicas each is a lot of idle-but-reserved connections during quiet periods, because MinConns keeps a floor open per replica regardless of traffic. PgBouncer in transaction-pooling mode sits between every service and PostgreSQL, multiplexing many application-level connections onto a much smaller number of real backend connections that are only held for the duration of a single transaction.
| Mode | What holds a real backend connection | Fits this platform's pattern? |
|---|---|---|
| Session pooling | One app connection maps 1:1 to one backend connection for its whole lifetime | No — no multiplexing benefit over pgxpool alone |
| Transaction pooling | Only the duration of one transaction; released back to the pool between transactions | Yes — most repository calls are single-transaction request/response |
| Statement pooling | Only the duration of one statement | No — breaks multi-statement transactions and prepared-statement caching pgx relies on |
PgBouncer's transaction mode cannot safely support session-scoped SET (outside SET LOCAL), LISTEN/NOTIFY, or advisory locks held across transactions, because the underlying backend connection can be handed to a different client between transactions. Lesson 2's SET LOCAL work_mem pattern was written the way it was specifically because it is transaction-scoped and PgBouncer-safe; a session-scoped SET would silently leak into another service's transaction.
Verify the Fix Under the Same Concurrency That Caused the Incident
A config change to MaxConns and a new statement_timeout are both unverified until they are exercised by the same concurrent load pattern that produced the original incident — not just confirmed present in a config file.
# before: MaxConns=20 per replica, no statement_timeout
hoa_organization_postgres_pool_acquired_connections 20 (pegged for 90s)
hoa_organization_postgres_pool_canceled_acquires_total 184
# after: MaxConns=3 per replica, statement_timeout=5s, PgBouncer transaction pooling in front
hoa_organization_postgres_pool_acquired_connections 2.1 (average, brief peaks to 3)
hoa_organization_postgres_pool_canceled_acquires_total 0canceled_acquires_total reaching zero under the identical load pattern that produced 184 cancellations is the actual proof the incident is fixed — a smaller MaxConns number alone would be meaningless without this comparison.
Applied exercise
Size the auth service's pool against its real traffic shape
services/auth-service verifies a JWT on every gateway-routed request, making it the platform's highest-QPS database client, yet it inherited the same MaxConns=20 default as every other service.
- Pull auth's pgxmetrics acquired/max ratio and acquire_duration over the last week of Grafana history.
- Compute a defensible MaxConns value using the shared-budget formula from this lesson, accounting for auth's 3 replicas.
- Decide whether auth's token-verification query needs a shorter statement_timeout than the platform default, and justify the number.
- Identify one session-scoped behavior (if any) in auth's current queries that would break under PgBouncer transaction pooling.
Deliverable
A documented MaxConns value with its formula, a justified statement_timeout, and an explicit compatibility note for PgBouncer transaction pooling.
Completion checks
- The MaxConns figure references the shared 97-connection budget, not an isolated guess.
- The statement_timeout choice is tied to auth's actual query latency distribution, not copied verbatim from organization.
- Any session-scoped SQL construct is flagged, not silently assumed safe under transaction pooling.
During the incident, error logs show 'context deadline exceeded' from pgxpool.Pool.Acquire, not from any Query or Exec call. What does this specifically indicate?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.