Módulo 6: Connection Pooling Avanzado

Entrega del módulo 6: tuneando el pool del bookstore

¿Qué vas a construir y por qué?

Vas a tomar una versión del bookstore con pool deliberadamente mal configurado (que satura a 50 RPS sostenidos con errores connection pool exhausted) y la vas a llevar a sostener al menos 200 RPS sin errores, midiendo la mejora cuantificada antes/después.

El proyecto integra todo el módulo:

  • Cápsula 02 (fundamentos): vas a diagnosticar el problema con pg_stat_activity y SHOW POOLS.
  • Cápsula 03 (SQLAlchemy tuning): vas a tunear pool_size, max_overflow, pool_pre_ping, pool_recycle.
  • Cápsula 04 (asyncpg + AsyncEngine): vas a aplicar los patrones canónicos de FastAPI async.
  • Cápsula 05 (PgBouncer fundamentos): vas a agregar PgBouncer en transaction mode.
  • Cápsula 06 (gotchas): vas a aplicar statement_cache_size=0 para evitar el bug de prepared statements.
  • Cápsula 07 (sizing y monitoreo): vas a dimensionar con criterio y verificar con SHOW POOLS durante el benchmark.

Al terminar, tendrás:

  1. Un docker-compose.yml con PostgreSQL + PgBouncer + FastAPI funcionando.
  2. Tu app FastAPI tuneada según la configuración recomendada del módulo.
  3. Resultados de benchmark before/after en formato tabla con números concretos.
  4. Un análisis breve de qué intervención movió más la aguja (típicamente: introducir PgBouncer y aplicar statement_cache_size=0).

Esto es el mini-proyecto del módulo 6. El proyecto integrador final del módulo 8 va a aplicar técnicas de toda la guía (no solo este módulo) sobre el bookstore, incluyendo este pool tuning como una de las cinco optimizaciones medidas.


Objetivo del proyecto

Al completar este proyecto:

  • Habrás reproducido un escenario real de pool saturado con wrk lanzando carga sostenida.
  • Habrás aplicado todas las técnicas del módulo en orden coherente.
  • Tendrás un benchmark cuantitativo que demuestra mejora ≥ 4x en throughput sostenido sin errores.
  • Habrás documentado tus decisiones en un BENCHMARKS.md que sirve como artefacto portfolio.

Cómo encaja en lo que aprendiste

Concepto del móduloDónde se usa en el proyecto
Cápsula 02: fundamentos del poolDiagnóstico inicial con pg_stat_activity y SHOW POOLS.
Cápsula 03: SQLAlchemy tuningConfigurar pool_size, max_overflow, pool_pre_ping, pool_recycle.
Cápsula 04: asyncpg + AsyncEnginePatrón Depends(get_db), expire_on_commit=False, lifespan handler.
Cápsula 05: PgBouncer fundamentosSetup de PgBouncer en transaction mode, SHOW POOLS para diagnóstico.
Cápsula 06: gotchasAplicar statement_cache_size=0 para evitar InvalidSQLStatementNameError.
Cápsula 07: sizingDimensionar pool_size empíricamente, monitorear con SHOW POOLS.

Pensa el proyecto como una intervención ordenada: primero medís el problema, después aplicás el fix más barato (tunear el pool de SQLAlchemy), después el más estructural (introducir PgBouncer), después el más sutil (statement cache). En cada paso, medís y comparás.


Especificaciones técnicas

Stack

  • Base de datos: PostgreSQL 16
  • Connection pooler: PgBouncer 1.22+
  • App: FastAPI 0.110+
  • ORM: SQLAlchemy 2.0+
  • Driver: asyncpg 0.29+
  • Benchmarking: wrk
  • Orquestación: Docker Compose

Setup inicial

Necesitas tener Docker y wrk instalados.

# wrk en macOS
brew install wrk

# wrk en Linux
sudo apt-get install wrk

# Crear directorio del proyecto
mkdir bookstore-pool-project
cd bookstore-pool-project

Estructura del proyecto

bookstore-pool-project/
├── docker-compose.yml
├── pgbouncer/
│   └── pgbouncer.ini  (opcional, podés usar env vars del docker-compose)
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── db.py
│   ├── models.py
│   └── routes.py
├── seed.sql
├── benchmarks/
│   ├── run_bench.sh
│   └── monitor_pools.sh
├── BENCHMARKS.md  ← artefacto entregable
├── Dockerfile
└── requirements.txt

Funcionalidades obligatorias

1. Setup base con problema reproducible

docker-compose.yml que levante:

  • PostgreSQL 16 con max_connections=100.
  • (Inicialmente sin PgBouncer.)
  • App FastAPI con SQLAlchemy + asyncpg, mal configurada a propósito (pool_size=5, sin pool_pre_ping, sin pool_recycle).
  • Schema con tabla books y al menos 10,000 filas seedeadas.
  • Endpoint GET /books/{id} que ejecuta SELECT * FROM books WHERE id = $1.

Validación: poder reproducir el problema con:

wrk -t4 -c100 -d30s http://localhost:8000/books/1

Esperado: errores de pool saturado, throughput limitado a ~50 RPS, latencia p99 > 500ms.

2. Configurar PgBouncer en docker-compose

Agregar PgBouncer al docker-compose.yml:

  • En transaction mode (POOL_MODE: transaction).
  • DEFAULT_POOL_SIZE: 25, MAX_CLIENT_CONN: 200.
  • Expuesto en puerto 6432.
  • App apunta a PgBouncer (no a PG directo).

Validación: poder hacer psql -h localhost -p 6432 -U bookstore y conectarse.

3. Tunear app/db.py con la configuración recomendada

Aplicar todas las técnicas del módulo:

  • pool_size=20, max_overflow=0 (PgBouncer absorbe).
  • pool_timeout=10, pool_pre_ping=True, pool_recycle=3600.
  • connect_args con statement_cache_size=0 (gotcha cápsula 06).
  • application_name en server_settings para identificar en pg_stat_activity.
  • expire_on_commit=False en async_sessionmaker.
  • Depends(get_db) con session-per-request.
  • Lifespan handler con await engine.dispose().

Validación: la app levanta sin errores y sirve requests correctamente.

4. Benchmarking before/after con números concretos

Script run_bench.sh que:

  • Ejecute wrk -t4 -c100 -d60s contra /books/{id} con IDs random.
  • Reporte: throughput, p50, p95, p99, errors.
  • Loggee también SHOW POOLS cada 5 segundos durante el benchmark.

Resultados esperados (orden de magnitud):

ConfiguraciónThroughputp50p95p99Errores
Antes (mal configurado)~50 RPS200ms1500ms5000msMuchos pool exhausted
Después (tuneado)≥200 RPS<30ms<100ms<300ms0

5. Documentar en BENCHMARKS.md

Markdown con:

  • Setup descripto.
  • Tabla before/after.
  • Análisis de qué intervención movió más la aguja.
  • Lecciones aprendidas.

Validaciones y manejo de errores

Qué debe validarse

  • App responde 200 OK en /books/1 después del setup.
  • PgBouncer aparece corriendo en docker compose ps.
  • PgBouncer admin acepta conexión: psql -h localhost -p 6432 -U bookstore pgbouncer -c "SHOW POOLS".
  • App conectada vía PgBouncer (verificar con application_name en pg_stat_activity).
  • Benchmark sin errores 5xx en throughput target.

Errores que deben manejarse

  • connection pool exhausted (antes del fix): documentar que es lo esperado en la fase "antes". No es bug del proyecto.
  • InvalidSQLStatementNameError (sin statement_cache_size=0): demostrar que aparece sin el fix y desaparece con él. Es parte del aprendizaje.
  • App no inicia: revisar logs (docker compose logs app). Típicamente: DATABASE_URL mal configurada o PgBouncer aún no listo.

Ejemplo de implementación mínima

Esto es el esqueleto base funcional. Lo extendés con la lógica de seed, monitor, etc.

requirements.txt

fastapi==0.110.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
pydantic==2.5.0

Dockerfile

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose.yml (versión final con todo)

version: "3.9"

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: bookstore
      POSTGRES_PASSWORD: bookstore
      POSTGRES_DB: bookstore
    command:
      - postgres
      - -c
      - max_connections=100
      - -c
      - shared_buffers=256MB
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./seed.sql:/docker-entrypoint-initdb.d/seed.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U bookstore"]
      interval: 5s
      timeout: 3s
      retries: 5

  pgbouncer:
    image: edoburu/pgbouncer:1.22.1
    environment:
      DB_USER: bookstore
      DB_PASSWORD: bookstore
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: bookstore
      POOL_MODE: transaction
      MAX_CLIENT_CONN: "200"
      DEFAULT_POOL_SIZE: "25"
      RESERVE_POOL_SIZE: "5"
      AUTH_TYPE: scram-sha-256
      ADMIN_USERS: bookstore
      STATS_USERS: bookstore
    ports:
      - "6432:5432"
    depends_on:
      postgres:
        condition: service_healthy

  app:
    build: .
    environment:
      DATABASE_URL: "postgresql+asyncpg://bookstore:bookstore@pgbouncer:5432/bookstore"
    ports:
      - "8000:8000"
    depends_on:
      - pgbouncer

volumes:
  postgres_data:

seed.sql

CREATE TABLE IF NOT EXISTS books (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    year INT,
    pages INT
);

-- Seed 10,000 books
INSERT INTO books (title, author, year, pages)
SELECT
    'Book ' || gs,
    'Author ' || (gs % 100),
    1900 + (gs % 125),
    100 + (gs % 800)
FROM generate_series(1, 10000) AS gs;

CREATE INDEX idx_books_author ON books(author);

app/db.py (versión FINAL tuneada)

import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator

from fastapi import FastAPI
from sqlalchemy.ext.asyncio import (
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

DATABASE_URL = os.environ.get(
    "DATABASE_URL",
    "postgresql+asyncpg://bookstore:bookstore@localhost:6432/bookstore",
)

engine = create_async_engine(
    DATABASE_URL,
    # Pool del cliente (capsula 03)
    pool_size=20,
    max_overflow=0,           # PgBouncer absorbe picos
    pool_timeout=10,
    pool_pre_ping=True,
    pool_recycle=3600,
    # Async / asyncpg (capsulas 04 + 06)
    echo=False,
    connect_args={
        "statement_cache_size": 0,             # FIX para PgBouncer transaction mode
        "prepared_statement_cache_size": 0,    # defensivo
        "server_settings": {
            "application_name": "bookstore-api",
            "statement_timeout": "10000",
        },
    },
)

SessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
    autoflush=False,
)


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with SessionLocal() as session:
        try:
            yield session
        finally:
            await session.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()

app/models.py

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    author: Mapped[str]
    year: Mapped[int | None]
    pages: Mapped[int | None]

app/routes.py

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_db
from app.models import Book

router = APIRouter()


@router.get("/health")
async def health():
    return {"status": "ok"}


@router.get("/books/{book_id}")
async def get_book(book_id: int, db: AsyncSession = Depends(get_db)):
    book = await db.scalar(select(Book).where(Book.id == book_id))
    if not book:
        raise HTTPException(404, "Book not found")
    return {
        "id": book.id,
        "title": book.title,
        "author": book.author,
        "year": book.year,
        "pages": book.pages,
    }

app/main.py

from fastapi import FastAPI
from app.db import lifespan
from app.routes import router

app = FastAPI(lifespan=lifespan)
app.include_router(router)

benchmarks/run_bench.sh

#!/bin/bash
# benchmarks/run_bench.sh — corre wrk con monitor de PgBouncer en paralelo

set -e
DURATION=60s
CONCURRENCY=100
THREADS=4
TARGET="http://localhost:8000/books/1"

echo "=== Benchmark: c=$CONCURRENCY t=$THREADS d=$DURATION ==="
echo "Target: $TARGET"
echo ""

# Lanzar monitor en background
./benchmarks/monitor_pools.sh > /tmp/pool_monitoring.log 2>&1 &
MONITOR_PID=$!

# Esperar 2s para que monitor capture baseline
sleep 2

# Correr wrk
wrk -t$THREADS -c$CONCURRENCY -d$DURATION --latency $TARGET | tee /tmp/wrk_output.txt

# Detener monitor
kill $MONITOR_PID 2>/dev/null || true

echo ""
echo "=== Pool monitoring ==="
cat /tmp/pool_monitoring.log

echo ""
echo "=== Connections in PostgreSQL durante benchmark ==="
docker compose exec -T postgres psql -U bookstore -d bookstore -c "
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'bookstore'
GROUP BY application_name, state
ORDER BY count DESC;
"

benchmarks/monitor_pools.sh

#!/bin/bash
# benchmarks/monitor_pools.sh — loggea SHOW POOLS cada 5s

while true; do
    TS=$(date +%H:%M:%S)
    OUTPUT=$(PGPASSWORD=bookstore psql -h localhost -p 6432 -U bookstore pgbouncer -t -A -F, \
        -c "SELECT database, cl_active, cl_waiting, sv_active, sv_idle, maxwait_us FROM pgbouncer.pools WHERE database = 'bookstore';" 2>/dev/null)
    echo "$TS,$OUTPUT"
    sleep 5
done

Rúbrica de evaluación (auto-verificación)

Total: 100 puntos. Aprobado: ≥70 puntos.

Setup y reproducibilidad (25 puntos)

  • (5 pts) docker compose up -d levanta postgres + pgbouncer + app sin errores.
  • (5 pts) Endpoint /books/1 responde 200 OK.
  • (5 pts) Tabla books tiene ≥10,000 filas seedeadas.
  • (5 pts) PgBouncer es accesible desde puerto 6432 (psql -h localhost -p 6432 -U bookstore pgbouncer).
  • (5 pts) application_name = bookstore-api aparece en pg_stat_activity.

Configuración correcta (30 puntos)

  • (5 pts) app/db.py tiene pool_size=20, max_overflow=0 (PgBouncer multiplexa).
  • (5 pts) pool_pre_ping=True y pool_recycle=3600 configurados.
  • (5 pts) connect_args["statement_cache_size"] == 0 (gotcha cápsula 06).
  • (5 pts) expire_on_commit=False en async_sessionmaker.
  • (5 pts) Depends(get_db) con async with SessionLocal() correcto.
  • (5 pts) Lifespan handler con await engine.dispose() en shutdown.

Benchmarking y mejora medible (30 puntos)

  • (5 pts) Ejecutaste benchmark "antes" (con configuración mala) y documentaste resultados.
  • (5 pts) Ejecutaste benchmark "después" (configuración tuneada) y documentaste resultados.
  • (10 pts) Throughput "después" es ≥4x el "antes" en RPS sostenidos sin errores.
  • (5 pts) p95 latencia "después" < 100ms.
  • (5 pts) 0 errores pool exhausted o InvalidSQLStatementNameError en el benchmark "después".

Documentación (15 puntos)

  • (5 pts) BENCHMARKS.md existe con tabla before/after.
  • (5 pts) Análisis de qué intervención movió más la aguja (1-2 párrafos).
  • (5 pts) Output de SHOW POOLS capturado durante el benchmark "después".

Extra credit (opcional, hasta +20 puntos)

  • (+5 pts) Comparar transaction mode vs session mode con benchmark side-by-side.
  • (+5 pts) Demostrar que SIN statement_cache_size=0 aparecen errores intermitentes (capturar en logs).
  • (+5 pts) Setup de prometheus-pgbouncer-exporter con dashboard básico de cl_waiting.
  • (+5 pts) Dimensionar empíricamente con benchmark de 5 valores distintos de pool_size.

Errores comunes en este proyecto

Error 1: app no puede conectar a pgbouncer

Síntoma: logs de la app: connection refused o could not translate host name.

Por qué pasa: PgBouncer aún no levantó cuando la app intentó conectarse. Docker Compose depends_on solo espera que el contenedor esté up, no que esté listo.

Cómo corregir: agregar healthcheck en pgbouncer o lógica de retry en la app:

# Opción 1: retry en lifespan
async def lifespan(app: FastAPI):
    for attempt in range(10):
        try:
            async with engine.connect() as conn:
                await conn.execute(text("SELECT 1"))
            break
        except Exception:
            await asyncio.sleep(1)
    yield
    await engine.dispose()

Error 2: InvalidSQLStatementNameError durante el benchmark

Síntoma: errores 500 intermitentes durante carga sostenida. Logs muestran prepared statement "__asyncpg_stmt_xxx__" does not exist.

Por qué pasa: olvidaste statement_cache_size=0 en connect_args. Es exactamente el bug de la cápsula 06.

Cómo distinguir: error específico, aparece intermitente bajo carga, no en local con bajo throughput.

Cómo corregir: agregar connect_args={"statement_cache_size": 0} en create_async_engine. Reiniciar app.

Error 3: throughput "después" no mejora

Síntoma: después de aplicar todas las técnicas, throughput sigue limitado. SHOW POOLS muestra cl_waiting=0 y sv_active bajo.

Por qué pasa: el cuello no es el pool — es otra cosa (CPU del cliente saturado, queries muy rápidas que ya están al límite teórico).

Cómo distinguir: monitor CPU del contenedor de la app. Si está al 90%, el cuello es el cliente, no el pool.

Cómo corregir: este proyecto está pensado para un cuello de pool. Si tu hardware es muy potente, puede que el problema "antes" no se reproduzca con pool_size=5. Bajar a pool_size=2 para forzar saturación, o aumentar concurrency en wrk (-c 500).

Error 4: PgBouncer auth falla

Síntoma: FATAL: password authentication failed for user "bookstore".

Por qué pasa: mismatch entre AUTH_TYPE de PgBouncer y método de autenticación de PostgreSQL. PG 16 default es scram-sha-256, PgBouncer debe usar el mismo.

Cómo distinguir: error en logs de PgBouncer al intentar conectarse a PG.

Cómo corregir: asegurar AUTH_TYPE: scram-sha-256 en PgBouncer (compatible con PG 16 default).

Error 5: olvidaste application_name

Síntoma: durante debugging no podés distinguir tus conexiones de otras.

Por qué pasa: application_name no configurado en connect_args.server_settings.

Cómo distinguir: pg_stat_activity muestra valores genéricos.

Cómo corregir: agregar "application_name": "bookstore-api" en server_settings. Reiniciar app.

Error 6: pool over-dimensionado

Síntoma: subiste pool_size a 200 esperando mejor throughput. PostgreSQL responde con FATAL: too many connections.

Por qué pasa: pool_size × num_instancias > max_connections. Sin PgBouncer, este es el cap absoluto.

Cómo distinguir: error en logs cuando intentás abrir más conexiones de las permitidas.

Cómo corregir: con PgBouncer, este problema no debería aparecer (pool del cliente va a PgBouncer, no a PG directo). Si lo ves, verificá que tu app realmente apunta a PgBouncer (puerto 6432), no a PG directo (5432).


¿Qué hacer si te atoras?

  • Si el setup no funciona → revisá docker compose logs <servicio>. La mayoría de problemas son de orden de startup o auth.
  • Si una técnica del módulo no es clara → vuelve a la cápsula correspondiente. La rúbrica linkea cada criterio a la cápsula que lo enseña.
  • Si los números no mejoran → verificá SHOW POOLS durante el benchmark. Si cl_waiting > 0 constante, el pool es el cuello. Si no, es otra cosa.
  • Si aparece InvalidSQLStatementNameError → es la cápsula 06. statement_cache_size=0.
  • Si la app responde lento incluso con pool tuneado → puede que el cuello sea I/O del disco o CPU del PG. Esto se cubre en módulos posteriores y no es scope de este proyecto.

Recursos para el proyecto

  1. PgBouncer documentation — usage — referencia administrativa.
  2. SQLAlchemy 2.0 — async docs — patrones canónicos.
  3. asyncpg — connection pools — driver oficial.
  4. wrk — benchmarking tool — herramienta usada para medir.
  5. edoburu/pgbouncer Docker image — la imagen del docker-compose.
  6. Cápsula 06 del módulo (gotchas) — refresco del fix statement_cache_size=0.
  7. Cápsula 07 del módulo (sizing) — refresco de monitoring.

Lo que sigue

Lo que construiste aquí es prerequisito para el proyecto integrador final del módulo 8. En el módulo 7 vas a aprender sobre statistics, autovacuum y planner internals — temas que viven dentro de PostgreSQL (no a nivel app/pool). En el módulo 8 vas a tomar una versión completa del bookstore con cinco problemas distintos (incluyendo pool mal configurado, N+1, OFFSET grande, COUNT lento, índice GIN faltante) y vas a aplicar técnicas de toda la guía para optimizarlos, midiendo before/after para cada uno.

El pool tuning de este proyecto será uno de esos cinco problemas. Cuando llegues al módulo 8, vas a tener este componente ya internalizado: setup PgBouncer, configurar SQLAlchemy correctamente, validar con SHOW POOLS, medir con wrk. La diferencia ahí será la integración con los otros cuatro problemas y un proyecto más realista en escala.

Antes de avanzar al módulo 7, asegúrate de:

  • Tener tu bookstore-pool-project corriendo y benchmark documentado en BENCHMARKS.md.
  • Poder explicar a un compañero por qué cada parámetro del pool tiene el valor que tiene.
  • Saber que el fix statement_cache_size=0 es lo más importante al adoptar PgBouncer transaction mode.
  • Ser capaz de leer SHOW POOLS y pg_stat_activity sin googlear las columnas.

Plantilla sugerida de BENCHMARKS.md

# Benchmark del bookstore — pool tuning

## Setup

- PostgreSQL 16, contenedor Docker, max_connections=100.
- PgBouncer 1.22, transaction mode, default_pool_size=25.
- FastAPI con SQLAlchemy 2.0 + asyncpg.
- Benchmark con wrk: 4 threads, 100 conexiones, 60 segundos.
- Endpoint medido: `GET /books/1`.

## Resultados

### Antes (configuración mala)

- pool_size=5, sin pool_pre_ping, sin pool_recycle, sin PgBouncer.

| Métrica | Valor |
|---------|-------|
| Throughput | 47 RPS |
| p50 latencia | 215ms |
| p95 latencia | 1,650ms |
| p99 latencia | 5,200ms |
| Errores | 1,234 (TimeoutError) |

### Después (configuración recomendada del módulo)

- pool_size=20, max_overflow=0, pool_pre_ping=True, pool_recycle=3600.
- statement_cache_size=0, expire_on_commit=False.
- PgBouncer en transaction mode, default_pool_size=25.

| Métrica | Valor |
|---------|-------|
| Throughput | 2,150 RPS |
| p50 latencia | 18ms |
| p95 latencia | 52ms |
| p99 latencia | 115ms |
| Errores | 0 |

### Mejora cuantificada

- **Throughput: ~46x mejor** (47 → 2,150 RPS).
- **p95 latencia: ~30x mejor** (1,650ms → 52ms).
- **Errores: eliminados** (1,234 → 0).

## Análisis

La intervención que más movió la aguja fue **introducir PgBouncer en transaction mode** combinado con `statement_cache_size=0`. Sin PgBouncer, subir `pool_size` se topa con el cap de `max_connections` de PostgreSQL (100). Con PgBouncer, podemos tener `pool_size=20` por cada instancia de FastAPI sin saturar PG.

`pool_pre_ping=True` no afectó throughput (overhead despreciable) pero mejoró confiabilidad: 0 errores intermitentes después del fix.

`statement_cache_size=0` fue obligatorio: sin él, durante el benchmark aparecían errores intermitentes `InvalidSQLStatementNameError` que sin entender el contexto serían extremadamente difíciles de diagnosticar.

## Lecciones aprendidas

1. **Pooling es el problema #1 al escalar.** Con configuración default, la app saturó a 50 RPS. Con tuning correcto, sostiene 2,000+ RPS — 40x más capacidad sin agregar hardware.
2. **`statement_cache_size=0` es invisible hasta que rompe.** Sin haber leído la cápsula 06, hubiera debugeado por días.
3. **Métricas de PgBouncer (`SHOW POOLS`) son indispensables.** Permiten ver en tiempo real si el cuello es el pool del cliente o el de PgBouncer.
4. **Tres niveles de pool importan:** SQLAlchemy → PgBouncer → PostgreSQL. Cada uno con su parámetro y su error específico.

Módulo 6 — Database Performance & Query Tuning Guide