Ejercicio 1 — El filtro
| Dato | ¿Viejo ok? | ¿Invalidable? | Decisión |
|---|---|---|---|
| Listado publicado | sí (5 min) | sí (por evento) | cache-aside + HTTP |
| Detalle de evento | sí (60 s) | sí | cache-aside + ETag |
| Disponibilidad asientos | NO decide compra | — | NO (12 informa, TX decide) |
| Tarifas de la org | sí (15 min) | sí (al editar) | cache-aside |
| Config features (27) | sí (30 s) | sí (deploy) | cache-aside corto |
| Perfil usuario | sí (60 s) | sí (+ purge RGPD) | cache-aside + PII policy |
| Reserva activa | NO | — | NO (es estado transaccional) |
| Búsqueda | sí (2 min) | por query (key = params) | cache-aside con key canónica |
- 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.
- 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
- 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.
- La migración de shape:
:v3sigue siendo servida mientras exista (correcto para el shape viejo),:v4se 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
- El test del 304:
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"")- 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.
- 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
- El test de la invalidación viva:
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- 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).
- El purge RGPD: el export (23) dispara
cache.delete_pattern(f"profile:{user_id}:")y el GET posterior reconstruye SIN el usuario borrado. Eldelete_pattern(SCAN en Redis, no KEYS) sobre un namespace acotado es barato; sobreglobal es un suicidio de latencia — la clave con prefijo por usuario hace el purge posible.
Ejercicio 5 — La medición
- 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)- 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 clavelock: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).
- 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.