Módulo 8 · Rendimiento y caché

Lección 39 — Pools, paginación y compresión

Conexiones, listados y respuestas más ligeras sin recortar contenido.

Publicada
En esta lección
  1. Objetivos
  2. 1. El pool de conexiones: la aritmética que evita el colapso
  3. 2. El pool de Redis y los dos colapsos
  4. 3. Paginación: offset vs cursor
  5. 4. El contrato de paginación (13) y los límites
  6. 5. Compresión: el trade-off medido
  7. Autoevaluación

Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — cierre del módulo de rendimiento Prerrequisito: Lección 38 — Estrategias de caché


Objetivos

  1. Dimensionar el pool de conexiones (BD y Redis) con la aritmética de gunicorn workers × threads y CONN_MAX_AGE.
  2. Paginar listados con cursor (keyset) en vez de offset, y negociar el tamaño de página sin romper el contrato (13).
  3. 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:

python
# 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:

python
# 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 pidas

El í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:

python
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
# 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 nada

El 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

  1. 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?
  2. CONN_MAX_AGE vs pool de psycopg 3 vs PgBouncer: ¿qué problema resuelve cada uno y cuál rompe los advisory locks?
  3. ¿Por qué el offset degrada linealmente y el cursor no? ¿Quéventana móvil rompe el offset con inserciones concurrentes?
  4. ¿Por qué se elimina el total de los listados grandes y qué se ofrece en su lugar al front?
  5. ¿Cuándo la compresión GANA y cuándo paga CPU sin beneficio? ¿Qué papel juega Vary: Accept-Encoding con el caché?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.