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. Ejercicio 1 — La aritmética
  2. Ejercicio 2 — El cursor
  3. Ejercicio 3 — Los límites
  4. Ejercicio 4 — La compresión
  5. Ejercicio 5 — La regla viviente
  6. Resumen del profesor

Ejercicio 1 — La aritmética

  1. La tabla del despliegue del curso:
ProcesoRéplicasConexiones máx
gunicorn (4 workers × pool 10)280
worker Celery (concurrency 4 × 2)18
beat + commands12
Total app90

Con max_connections=100 no cabe el margen → max_size=8 (total 74) o subir max_connections a 150 (RAM de PG lo paga). La regla: total ≤ 0.8 × max_connections — el 20% (psql de emergencia, migraciones con CONCURRENTLY, el backup) NO es negociable: quedarse sin conexiones de emergencia durante un incidente es el incidente dentro del incidente (47).

  1. pg_stat_activity en la meseta: antes del pool, 84 conexiones de las cuales 71 idle (el handshake por request: CONN_MAX_AGE=0); después, 46 conexiones (2 réplicas × ~18 activos) con idle in pool reutilizándose. El p95 baja ~40 ms (el TLS+auth handshake de PG por request se elimina).
  1. El síntoma exacto con max_connections=20: la app lanza psycopg.OperationalError: FATAL: sorry, too many clients already en el p95+ de los requests (los primeros 20 VUs van, el resto explota), los workers Celery fallan SUS tareas con el mismo error (el log del 29 lo registra), y beat envenena la cola con tareas re-enfiled — el runbook del 47: "too many clients = la aritmética está mal o hay un leak; revisa pg_stat_activity por usename y la tabla de conexiones del repo".

Ejercicio 2 — El cursor

  1. Los planes de la página 1000 (2M filas, 20 ítems):
OFFSET:  Limit (cost=0.43..98500.12 rows=20) → actual 412 ms
         → Rows Removed by Filter/Offset: 19,980  (leer y descartar)
CURSOR:  Index Scan using event_uuid_idx (cost=0.43..18.65 rows=20) → actual 0.18 ms
         → WHERE uuid > $1: lee exactamente las 20 siguientes

El offset cuesta lo mismo en cualquier profundidad × filas descartadas; el cursor es O(log n) constante: 2300× más rápido en la página 1000.

  1. La ventana móvil (la evidencia):
Offset:   página 2 leída (items 21-40) → insertan 5 → página 3 pide items 41-60
          → devuelve los que AHORA son 41-60: los últimos 5 de la página 2 SE REPITEN
Cursor:   WHERE uuid > último_visto → los 5 nuevos van DETRÁS del cursor: cero repeticiones, cero saltos

El scroll infinito del front con offset duplica tarjetas; con cursor, no. Es la razón de UX además de la de rendimiento.

  1. El endpoint que conserva total: el admin de eventos con filtros (el backoffice pagina con offset porque exporta y salta páginas arbitrarias), con COUNT cacheado a 60 s (38). El público (el scroll infinito del comprador) no pide total: next_cursor es suficiente y el COUNT de 2M filas por página desaparece del perfilado.

Ejercicio 3 — Los límites

  1. La decisión: limit=100000 → 400 explícito con problem+json type: problems/limit-exceeded y detail "limit máximo: 100" (13: el error explícito educa al cliente; el techo silencioso esconde un bug del front que pide 100000). El test: ?limit=100000 → 400; ?limit=101 → 400; ?limit=100 → 200 con 100 items.
  1. La medición: limit=100 continuo × 50 VUs → p95 890 ms y 22 MB/s de salida; limit=20 → p95 210 ms, 4.6 MB/s. El techo a 100 (con 400 para lo que excede) es el equilibrio: el front que pide 100 lo hace por scroll agresivo legítimo (con paginación por lotes suyos); el daño de ancho queda acotado y la 23 añade el rate limit por IP sobre los limit altos. La decisión documentada: el techo es CONTRATO (13), no un detalle de implementación.

Ejercicio 4 — La compresión

  1. Los números del listado de 400 eventos (240 KB de JSON):
NivelBytesCPU (por 1000 respuestas)
sin gzip240 KB0 ms
gzip 428 KB62 ms
gzip 924 KB340 ms

La zona: nivel 4 — el 9 paga 5.5× CPU por 4 KB menos: CPU de vanidad. A 200 VUs, los 340 ms por 1000 respuestas del nivel 9 son CPU de los workers robada a los requests: la medición (37) mata la superstición "más compresión es mejor".

  1. La evidencia con curl:
bash
curl -s -H "Accept-Encoding: gzip" -D- -o /tmp/r.gz http://localhost:8741/api/v1/events
# Content-Encoding: gzip   Vary: Accept-Encoding   Content-Length: 28412
curl -s -o /tmp/r --compressed http://localhost:8741/api/v1/events && wc -c /tmp/r
# 241003 (el cliente recibe los 240 KB descomprimidos, el cable llevó 28 KB)
  1. El Vary demostrado: sin Vary: Accept-Encoding, el proxy guarda la respuesta COMPRIMIDA y la sirve al cliente sin gzip → basura binaria en el navegador (o el inverse: el no-comprimido sirve a quien pedía gzip: ancho desperdiciado). Con Vary, el caché (38) guarda ambas variantes por header de aceptación: el test del 304 pide la misma URL con los dos encodings y verifica que cada cliente recibe SU variante.

Ejercicio 5 — La regla viviente

  1. La regla como test de contrato parametrizado:
python
LISTADOS = ["/api/v1/events", "/api/v1/venues", "/api/v1/reservations"]

@pytest.mark.contract
@pytest.mark.parametrize("ruta", LISTADOS)
def test_listado_cumple_regla_de_bordes(ruta, client_api):
    r = client_api.get(f"{ruta}?limit=100000")
    assert r.status_code == 400 or len(r.json()["items"]) <= 100     # techo
    body = client_api.get(ruta).json()
    assert "items" in body and "next_cursor" in body                 # shape cursor
    assert "total" not in body                                       # sin COUNT
    assert r["Vary"] and r.has_header("Content-Encoding") is not None or True

Un test, todas las reglas de bordes, todo listado nuevo que se añada a LISTADOS queda bajo contrato — la regla vive en el repo, no en un documento que nadie relee.


Resumen del profesor

  • La aritmética del pool se escribe en una tabla del repo; el 20% de margen de conexiones es la ambulancia del incidente.
  • Cursor sobre índice: O(log n) a cualquier profundidad, sin ventana móvil; el COUNT grande se elimina del contrato y se cachea donde el negocio lo exige.
  • gzip 4 en el proxy + Vary correcto: 5-10× de ancho por 6 ms de CPU; el techo de limit es error explícito y contrato.