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_activityySHOW 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=0para evitar el bug de prepared statements. - Cápsula 07 (sizing y monitoreo): vas a dimensionar con criterio y verificar con
SHOW POOLSdurante el benchmark.
Al terminar, tendrás:
- Un
docker-compose.ymlcon PostgreSQL + PgBouncer + FastAPI funcionando. - Tu app FastAPI tuneada según la configuración recomendada del módulo.
- Resultados de benchmark before/after en formato tabla con números concretos.
- 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
wrklanzando 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.mdque sirve como artefacto portfolio.
Cómo encaja en lo que aprendiste
| Concepto del módulo | Dónde se usa en el proyecto |
|---|---|
| Cápsula 02: fundamentos del pool | Diagnóstico inicial con pg_stat_activity y SHOW POOLS. |
| Cápsula 03: SQLAlchemy tuning | Configurar pool_size, max_overflow, pool_pre_ping, pool_recycle. |
| Cápsula 04: asyncpg + AsyncEngine | Patrón Depends(get_db), expire_on_commit=False, lifespan handler. |
| Cápsula 05: PgBouncer fundamentos | Setup de PgBouncer en transaction mode, SHOW POOLS para diagnóstico. |
| Cápsula 06: gotchas | Aplicar statement_cache_size=0 para evitar InvalidSQLStatementNameError. |
| Cápsula 07: sizing | Dimensionar 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, sinpool_pre_ping, sinpool_recycle). - Schema con tabla
booksy al menos 10,000 filas seedeadas. - Endpoint
GET /books/{id}que ejecutaSELECT * 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_argsconstatement_cache_size=0(gotcha cápsula 06).application_nameenserver_settingspara identificar enpg_stat_activity.expire_on_commit=Falseenasync_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 -d60scontra/books/{id}con IDs random. - Reporte: throughput, p50, p95, p99, errors.
- Loggee también
SHOW POOLScada 5 segundos durante el benchmark.
Resultados esperados (orden de magnitud):
| Configuración | Throughput | p50 | p95 | p99 | Errores |
|---|---|---|---|---|---|
| Antes (mal configurado) | ~50 RPS | 200ms | 1500ms | 5000ms | Muchos pool exhausted |
| Después (tuneado) | ≥200 RPS | <30ms | <100ms | <300ms | 0 |
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/1despué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_nameenpg_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(sinstatement_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_URLmal 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 -dlevanta postgres + pgbouncer + app sin errores. - (5 pts) Endpoint
/books/1responde 200 OK. - (5 pts) Tabla
bookstiene ≥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-apiaparece enpg_stat_activity.
Configuración correcta (30 puntos)
- (5 pts)
app/db.pytienepool_size=20, max_overflow=0(PgBouncer multiplexa). - (5 pts)
pool_pre_ping=Trueypool_recycle=3600configurados. - (5 pts)
connect_args["statement_cache_size"] == 0(gotcha cápsula 06). - (5 pts)
expire_on_commit=Falseenasync_sessionmaker. - (5 pts)
Depends(get_db)conasync 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 exhaustedoInvalidSQLStatementNameErroren el benchmark "después".
Documentación (15 puntos)
- (5 pts)
BENCHMARKS.mdexiste 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 POOLScapturado 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=0aparecen errores intermitentes (capturar en logs). - (+5 pts) Setup de
prometheus-pgbouncer-exportercon dashboard básico decl_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 POOLSdurante el benchmark. Sicl_waiting > 0constante, 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
- PgBouncer documentation — usage — referencia administrativa.
- SQLAlchemy 2.0 — async docs — patrones canónicos.
- asyncpg — connection pools — driver oficial.
wrk— benchmarking tool — herramienta usada para medir.- edoburu/pgbouncer Docker image — la imagen del docker-compose.
- Cápsula 06 del módulo (gotchas) — refresco del fix
statement_cache_size=0. - 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-projectcorriendo y benchmark documentado enBENCHMARKS.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=0es lo más importante al adoptar PgBouncer transaction mode. - Ser capaz de leer
SHOW POOLSypg_stat_activitysin 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