Stack: Django/DRF + Redis · Proyecto: TicketFlow Estado: Publicada — qué cachear, cómo invalidar y qué hacer con lo obsoleto Prerrequisito: Lección 37 — Perfilado
Objetivos
- Decidir QUÉ cachear con el criterio del costo de invalidación, no del entusiasmo: las dos preguntas del filtro.
- Elegir la estrategia (cache-aside, write-through, HTTP caching) por tipo de dato y acoplamiento aceptado.
- Blindar los tres desastres clásicos: stampede (12), invalidación rota y el caché como almacén de verdad.
1. El filtro de dos preguntas
El perfilado (37) dice qué es lento; el filtro dice qué SE PUEDE cachear sin comprar un problema: (1) ¿el dato puede ser viejo unos segundos sin romper el negocio? El listado de eventos: sí (un asiento vendido hace 2 s no engaña a nadie en el browse). La disponibilidad que decide la compra: NO (la 32: el caché informa, la TX decide). (2) ¿Sabes invalidarlo? Si la respuesta es "depende de quién toque qué y no está claro", el caché te da hits AVEZARADOS con sorpresas: la invalidación rota es peor que la lentitud (el usuario ve el evento borrado, o ve el precio viejo — el dato equivocado es infinitamente más caro que el dato lento). Si las dos respuestas son sí, cachear. Si solo la primera: arregla la invalidación primero o no caches.
2. Las tres estrategias y su acoplamiento
Cache-aside (la del 90% de los casos): la app pregunta al caché, si falta (miss) lee de BD y escribe el caché con TTL. BD y caché se escriben en momentos distintos: la inconsistencia es temporal por diseño.
def listado_publicado(ciudad: str) -> list[dict]:
key = f"events:published:{ciudad}:v3"
data = cache.get(key)
if data is None: # miss: 1 concurrente reconstruye (ver §4)
data = [evento_to_dict(e) for e in Event.objects.publicados().filter(ciudad=ciudad)]
cache.set(key, data, timeout=300)
return dataWrite-through: la app escribe a BD y caché en la misma operación (el caché nunca miss en lecturas calientes); costo: escribir en frío que quizá nadie lee, y el fallo de caché complica la escritura. En TicketFlow solo para lecturas calientes y estables (el config de precios de la org). Write-behind (caché como buffer, flush asíncrono): ganancia de escritura, riesgo de pérdida — NO para dinero; válido para contadores no críticos (analytics). Y la cuarta casa que no es de Redis: HTTP caching (más abajo): el caché del CDN/cliente es el más barato y el más olvidado.
3. La clave y el TTL: el diseño que casi nadie hace
La clave lleva la versión del shape (:v3): un cambio de contrato del dict → sube la versión y las claves viejas expiran solas (la invalidación masiva sin FLUSHDB, que tumba lo demás). El TTL no es un número de la suerte: es el stale-while-revalidate aceptado por el negocio — listados 5 min (el negocio acepta 5 min de viejo), perfil de evento 60 s, sesiones/capabilities NO se cachean (la 21). Y los datos personales: el caché con PII lleva la política de la 23 (TTL corto, cifrado en reposo si el Redis es compartido, y el derecho de supresión hace que el PII cacheado tenga que poder purgarse por usuario — el cache.delete_pattern(f"profile:{user_id}:*")).
La regla anti-caché-almacén: el caché siempre reconstruible desde la BD. Si borrar el Redis (FLUSHALL) rompe el negocio, hay datos de verdad en el caché — write-behind sin flush duradero — y ese es un bug esperando el reinicio. El test del proyecto: flushall en el entorno de staging → el sistema se degrada lento pero correcto.
4. El stampede: el desastre del mediodía
Cuando el caché de la clave caliente expira y 200 requests llegan a la vez: los 200 hacen MISS y los 200 golpean la BD con la misma query (el thundering herd del 12). Tres defensas en orden de preferencia: (1) single-flight: solo UN reconstruye, el resto espera brevemente o sirve el viejo:
def listado_protected(ciudad: str) -> list[dict]:
key = f"events:{ciudad}:v3"
lock_key = f"lock:{key}"
data = cache.get(key)
if data is not None:
return data
if cache.add(lock_key, "1", 10): # NX: solo un reconstructor
try:
data = reconstruir(ciudad)
cache.set(key, data, timeout=300)
finally:
cache.delete(lock_key)
return data
stale = cache.get(f"stale:{key}") # los demás: el viejo (si existe) o espera corta
return stale if stale is not None else reconstruir(ciudad)(2) el stale doble: servir el valor vencido mientras se revalida (la clave stale: con TTL largo y la lógica arriba); (3) jitter en los TTL (12): timeout=300 + random(0, 30) para que las claves no expiren TODAS a la hora en punto. El test que valida el blindaje: simula 50 threads sobre la key fría y cuenta las queries de BD en el CaptureQueriesContext: esperado 1-3, no 50.
5. HTTP caching y la invalidación por evento
La capa que prima: Cache-Control: public, max-age=60 en el listado público + ETag por contenido: el CDN y el navegador cachear lo que tu Redis ni toca:
from django.views.decorators.http import condition
def etag_listado(request, ciudad=None):
return f'events-{ciudad or "all"}-{Event.objects.max_updated().strftime("%s")}-v3'
@condition(etag_func=etag_listado)
def listar_eventos(request): ...El ETag con el max updated_at del conjunto: el 304 (no-body) ahorra el 90% del ancho del listado y la consulta que lo computa es índice puro (09). Invalidación de Redis: por evento de dominio (25/30) — EventUpdated/EventPublished disparan cache.delete(f"events:{ciudad}:v3") desde el consumidor; la invalidación viva por evento, el TTL como red de seguridad SIEMPRE (la invalidación por evento falla: el TTL es la válvula que acota el desastre a minutos). Lo que jamás: invalidación manual "cuando el soporte avise".
Autoevaluación
- Las dos preguntas del filtro: ¿qué dato de TicketFlow falla la 1ª, cuál la 2ª, y cuál pasa ambas?
- Cache-aside vs write-through: ¿qué inconsistencia temporal acepta cada uno y dónde NO cabe el write-behind en este proyecto?
- ¿Por qué la clave lleva versión (
:v3) y qué desastre evita frente a FLUSHDB o a invalidación manual? - Reproduce el stampede: ¿qué secuencia exacta lo dispara y cuáles son las tres defensas con su costo?
- ¿Qué cachearían el ETag/Cache-Control y cómo interactúa con la invalidación por eventos de dominio (25)?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.