Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — cierre del módulo de rendimiento Prerrequisito: Lección 38 — Estrategias de caché
Objetivos
- Dimensionar el pool de conexiones (BD y Redis) con la aritmética de gunicorn workers × threads y CONN_MAX_AGE.
- Paginar listados con cursor (keyset) en vez de offset, y negociar el tamaño de página sin romper el contrato (13).
- Comprimir respuestas (gzip/brotli) midiendo el trade-off CPU vs ancho que la 36 ya midió.
1. El pool de conexiones: la aritmética que evita el colapso
Postgres tiene un límite duro de conexiones (max_connections, default 100) y cada conexión vive ~2-10 MB de RAM. La aritmética del desastre: 4 workers gunicorn × 8 threads × 3 réplicas = 96 conexiones potenciales — más el worker de Celery (misma aritmética), más el beat, más los management commands: te pasas del límite y el error too many clients es el síntoma (47). El diseño de TicketFlow: CONN_MAX_AGE + pool por proceso:
# settings.py
DATABASES = {"default": {
...env.db("DATABASE_URL"),
"CONN_MAX_AGE": 60, # reutiliza la conexión 60 s (evita el handshake por request)
"OPTIONS": {"pool": {"min_size": 2, "max_size": 10}}, # psycopg 3: pool real por proceso
}}Con psycopg 3 el pool vive dentro de cada proceso (cada worker gunicorn: su pool de 2-10 conexiones). El presupuesto final: Σ (procesos × max_size) < max_connections × 0.8 — el 20% restante es para psql de emergencia y migraciones. La alternativa gestionada: PgBouncer en modo transaction pooling (43) cuando el número de procesos escala, con SUS reglas (prepared statements desactivados, no advisory locks en transaction mode — la 31 usa advisory: decisión documentada).
2. El pool de Redis y los dos colapsos
Redis es single-threaded: el colapso no es de conexiones sino de comandos lentos. Las reglas del proyecto (ya aplicadas en 12/38): NADA de KEYS (usa SCAN o namespaces), los valores grandes (>100 KB) se comprimen o parten, y el pool de clientes (CONNECTION_POOL_KWARGS = {"max_connections": 50} por proceso) con la misma aritmética de arriba. El segundo colapso: el delete_pattern sin namespace (38): un SCAN sobre 1M de claves bloquea el event loop de Redis — siempre con prefijo acotado (profile:{user_id}:), nunca * desnudo. El monitoreo: redis-cli --latency y slowlog get (46) — el slowlog con entradas > 10 ms es el hallazgo antes del desastre.
3. Paginación: offset vs cursor
El offset (?page=3) es el default cómodo y el problema clásico: LIMIT 20 OFFSET 6000 hace que Postgres LEA Y DESCARTE 6000 filas (09), el p95 empeora linealmente con la profundidad, y con inserciones concurrentes la página 3 se repite o salta (el ventanal que se mueve). El cursor (keyset) pagina por clave estable:
# GET /api/v1/events?limit=20&cursor=01H... (el uuid ordenado, 13)
def listar_publicados(cursor: str | None, limit: int = 20):
qs = Event.objects.publicados().order_by("uuid")
if cursor:
qs = qs.filter(uuid__gt=decode_cursor(cursor)) # WHERE uuid > $cursor → índice puro
page = list(qs[: limit + 1]) # pide N+1 para saber si hay next
has_more = len(page) > limit
items = page[:limit]
next_cursor = encode_cursor(items[-1].uuid) if has_more else None
return {"items": items, "next_cursor": next_cursor} # sin "total": no lo pidasEl índice (uuid) hace el cursor O(log n) a cualquier profundidad; el orden NO cambia con inserciones (el cursor es el "ya vi hasta aquí", no el "página 3"). El total (COUNT) se elimina de los listados grandes: un COUNT sobre 2M filas por cada página es el N+1 de la paginación — "next_cursor" basta para el scroll infinito del front; si el negocio exige total, se cuenta cacheado (38) o se aproxima.
4. El contrato de paginación (13) y los límites
El shape público de la 13: {"items": [...], "next_cursor": "...|null"} con límites negociados: limit default 20, máximo 100 (min(limit, 100) — el cliente que pide ?limit=100000 es un ataque de ancho de banda, la 23 lo limita igual). La compatibilidad (14): los listados viejos con ?page= se mantienen con el CursorPagination DE DRF configurado como default y PageNumberPagination marcado deprecated con header (14) hasta que el front migre:
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "core.pagination.CursorPagination",
"PAGE_SIZE": 20,
}El test de contrato (35): la página 1 y 2 no comparten items, next_cursor de la 2 es null al final, y los items mantienen el orden por uuid — tres asserts que el front programa sin leer tu código.
5. Compresión: el trade-off medido
gzip/brotli reduce el JSON 5-10× a costa de CPU. La decisión NO es doctrinal: la 36/37 la midieron — el listado de 400 eventos sin comprimir: 240 KB, 38 ms de app; con gzip nivel 4: 28 KB, +6 ms de CPU. En el p95 global: la red (del datacenter al usuario móvil) gasta 180 ms en 240 KB vs 25 ms en 28 KB: la compresión gana en TODO lo que sale a Internet; solo el tráfico intra-VPC masivo (job de la 31 leyendo 2M filas del app al PG) paga CPU sin ganar ancho. Implementación: a nivel de gunicorn/nginx (43) — nunca en el middleware de Django si hay reverse proxy:
# nginx.conf (43): la capa que ya tienes
gzip on;
gzip_types application/json;
gzip_comp_level 4; # 4-6: la zona del trade-off; 9 es CPU de vanidad
gzip_min_length 1024; # comprimir 200 bytes no gana nadaEl trade-off final: Vary: Accept-Encoding (el caché del 38/CDN debe diferir por encoding o sirve comprimido a quien no sabe leerlo), y los payloads YA grandes por diseño (el export RGPD de la 23) salen comprimidos por el job, no por el request.
Autoevaluación
- Haz la aritmética completa de conexiones de tu despliegue (workers × threads × réplicas + workers Celery) y ¿por qué el 20% de margen no es negociable?
- CONN_MAX_AGE vs pool de psycopg 3 vs PgBouncer: ¿qué problema resuelve cada uno y cuál rompe los advisory locks?
- ¿Por qué el offset degrada linealmente y el cursor no? ¿Quéventana móvil rompe el offset con inserciones concurrentes?
- ¿Por qué se elimina el
totalde los listados grandes y qué se ofrece en su lugar al front? - ¿Cuándo la compresión GANA y cuándo paga CPU sin beneficio? ¿Qué papel juega
Vary: Accept-Encodingcon el caché?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.