Módulo 8 · Rendimiento y caché

Lección 38 — Estrategias de caché

Qué cachear, cómo invalidar y qué hacer con los datos obsoletos.

Publicada
En esta lección
  1. Ejercicio 1 — El filtro
  2. Ejercicio 2 — Single-flight
  3. Ejercicio 3 — HTTP caching
  4. Ejercicio 4 — Invalidación por eventos
  5. Ejercicio 5 — La medición
  6. Resumen del profesor

Ejercicio 1 — El filtro

Dato¿Viejo ok?¿Invalidable?Decisión
Listado publicadosí (5 min)sí (por evento)cache-aside + HTTP
Detalle de eventosí (60 s)sícache-aside + ETag
Disponibilidad asientosNO decide compra—NO (12 informa, TX decide)
Tarifas de la orgsí (15 min)sí (al editar)cache-aside
Config features (27)sí (30 s)sí (deploy)cache-aside corto
Perfil usuariosí (60 s)sí (+ purge RGPD)cache-aside + PII policy
Reserva activaNO—NO (es estado transaccional)
Búsquedasí (2 min)por query (key = params)cache-aside con key canónica
  1. La violación clásica encontrada: la disponibilidad del listado cacheada en la vista de checkout para "ahorrar la query" — el usuario ve A1 libre, paga, 409. El caché INFORMA (listado), la TX DECIDE (10): el fix es quitar el caché del checkout, no refinarlo. La segunda: permisos de staff cacheados sin TTL — la escalada de rol del 21 tardaría hasta... nunca: sin TTL, la válvula de seguridad no existe.
  1. El FLUSHALL en staging: el sistema pierde velocidad (browse reconstruye en ~2-4 s por ciudad) pero nada se rompe EXCEPTO el contador de visitas del leaderboard (12) escrito con INCR sin persistir — write-behind disfrazado. Fix: aceptar la pérdida (contadores no críticos, documentado) o persistirlos al outbox. La decisión se documenta, no se sufre.

Ejercicio 2 — Single-flight

  1. La evidencia del desastre:
SIN protección:  50 threads / clave fría  →  50 queries de listado  (4.1 s)
CON single-flight: 50 threads             →  2 queries (1 reconstructor + 1 del perdedor sin stale)  (0.3 s)

El test: ThreadPoolExecutor(50) + CaptureQueriesContext(connection) tras cache.clear() — el desastre sin protección es el pico de las 10:00 del viernes reproducido en un test.

  1. La migración de shape: :v3 sigue siendo servida mientras exista (correcto para el shape viejo), :v4 se reconstruye en el primer miss. Cero deploys de invalidación: el versionado de clave ES la migración — la misma técnica del ETag del §5 y del cache busting de assets estáticos.

Ejercicio 3 — HTTP caching

  1. El test del 304:
python
def test_listado_304(self):
    r1 = self.client.get("/api/v1/events?city=Madrid")
    r2 = self.client.get("/api/v1/events?city=Madrid",
                         HTTP_IF_NONE_MATCH=r1["ETag"])
    self.assertEqual(r2.status_code, 304)
    self.assertEqual(r2.content, b"")
  1. Los JAMÁS public: todo lo que requiere Authorization y es personal: Cache-Control: private, no-store (el intermediario NO debe guardarlo; el 40/43 lo respeta). El test de los 3 endpoints (reservas, perfil, pago) es el guard del contrato de la 23: un header mal puesto en un proxy expone el historial de compras de un usuario a otro.
  1. El ADR (extracto): "CDN para listados públicos. Ganancia estimada: 92% hit rate en browse (k6/36: 70% del tráfico). Invalidación: ETag por max_updated_at + max-age 60 — sin purga activa necesaria. Riesgo: stale 60 s aceptado por negocio. Revisión: si el negocio exige <60 s, purga por API del CDN solo en publicaciones (evento raro)".

Ejercicio 4 — Invalidación por eventos

  1. El test de la invalidación viva:
python
def test_event_updated_invalida(self):
    cache.set("events:Madrid:v3", [DATO_VIEJO], 300)
    publicar_evento(OutboxEvent(event_type="EventUpdated", payload={"ciudad": "Madrid"}))
    drenar_consumidores()
    self.assertEqual(cache.get("events:Madrid:v3"), None)   # o el dato nuevo si el consumidor lo reescribe
  1. La garantía: "si la invalidación falla, el dato viejo vive máximo TTL (300 s) y el sistema CONVERGE solo" — el TTL no es optimización: es la válvula de la 32. La línea del runbook: "dato viejo > TTL en soporte = bug del consumidor, no del caché" (el dump del 35/47 lo diagnostica).
  1. El purge RGPD: el export (23) dispara cache.delete_pattern(f"profile:{user_id}:") y el GET posterior reconstruye SIN el usuario borrado. El delete_pattern (SCAN en Redis, no KEYS) sobre un namespace acotado es barato; sobre global es un suicidio de latencia — la clave con prefijo por usuario hace el purge posible.

Ejercicio 5 — La medición

  1. El before/after (meseta 200, mezcla 70% browse):
SIN caché:  p95 browse 412 ms   listado: 91k queries totales  pg total_time 6.2e5 ms
CON caché:  p95 browse  38 ms   listado:  1.2k queries        pg total_time 8.4e3 ms
hit rate: 98.7% (el 1.3% miss = reconstrucciones cada 5 min + single-flight)
  1. El costo: 214 MB de Redis para 50k eventos × claves por ciudad (la clave es un dict comprimido). Política: maxmemory-policy allkeys-lru — el caché es prescindible (el test del FLUSHALL lo probó). El riesgo documentado: si Redis evicta la clave lock: durante un single-flight, dos reconstrucciones entran a la vez (stampede parcial: 2 queries, no 50 — el costo del LRU es aceptable; el lock crítico de la 12 NO vive en el mismo Redis LRU: vive en el lock duradero de la 31).
  1. El docs/caching.md (extracto): "NO cachear: disponibilidad para decidir compra (TX, 10), reserva activa (estado), permisos sin TTL (21), tokens (18), el ledger (08: dinero). Razón única: el costo de una invalidación rota supera SIEMPRE la latencia ahorrada."

Resumen del profesor

  • El filtro de dos preguntas (¿puede estar viejo? ¿sabes invalidarlo?) decide; el perfilado (37) solo dice qué es lento.
  • Cache-aside + single-flight + stale + jitter vence al stampede; la clave versionada (:v3) es la migración de shape sin deploys.
  • El TTL es la válvula de convergencia (32): la invalidación por eventos vive y el TTL acota su fallo; lo que no pasa el filtro queda escrito en docs/caching.md para siempre.