La lección anterior dejó el catálogo de Kilómetro Cero en PostgreSQL con jsonb y una promesa: que Redis se pondría delante. Esta lección cumple la promesa y cierra el módulo. Un caché guarda una copia de datos que cuesta obtener (una consulta a la base de datos, una llamada gRPC a inventario, una imagen redimensionada) en un lugar más rápido y más cercano, para no volver a pagar ese coste mientras la copia siga siendo útil. Parece simple, y en su forma básica lo es; lo difícil es todo lo que rodea a "mientras siga siendo útil": cuándo caduca la copia, cómo se invalida cuando el dato cambia, qué pasa cuando miles de peticiones descubren a la vez que ha caducado, qué hacer con la clave que todos piden, y cómo evitar que la caché devuelva algo que la base de datos ya no dice. Veremos los patrones (cache-aside, read-through, write-through, write-behind, refresh-ahead), los problemas clásicos y sus soluciones, la comparación Memcached/Redis, y Redis Cluster con sus 16 384 slots como reencarnación del particionado de 04-01. La práctica pone todo en servicios/catalogo/cache.py, con invalidación por los eventos stock.actualizado de Kafka, y mide la diferencia de latencia con y sin caché durante la "Semana del Queso Artesano".
Contenido
- Por qué cachear: latencia y carga en la base de datos
- Dónde cachear: cliente, CDN, aplicación y caché distribuida
- Patrones de caché
- Invalidación y TTL: los dos problemas difíciles
- Problemas clásicos: stampede, hot keys, penetración
- Consistencia entre caché y base de datos
- Memcached frente a Redis
- Redis Cluster: slots, réplicas y failover
- Práctica:
servicios/catalogo/cache.pyy Redis Cluster - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Por qué cachear: latencia y carga en la base de datos
Cachear responde a dos problemas distintos que suelen aparecer juntos: latencia (la petición tarda demasiado) y carga (el origen no aguanta tantas peticiones). Unos números para sentir la diferencia:
| Origen del dato | Latencia típica | Peticiones/s por instancia |
|---|---|---|
| Memoria local del proceso (diccionario Python) | 100 ns | Millones |
| Redis / Memcached en la misma red | 0,2–1 ms | 100 000–1 000 000 |
| PostgreSQL, consulta indexada simple | 1–5 ms | 10 000–50 000 |
PostgreSQL, consulta con joins y jsonb |
5–50 ms | 500–5 000 |
| Llamada gRPC a otro servicio (que a su vez consulta su base) | 5–20 ms | Depende del servicio |
El cálculo de Kilómetro Cero para la "Semana del Queso Artesano": la web sirve 6 millones de vistas de producto al día, concentradas en 12 horas, con un pico de 3 veces la media: unas 420 vistas/s de pico. Cada vista compone la ficha del producto con una consulta al catálogo (producto + productor + fotos + variantes, unos 8 ms en PostgreSQL) y una llamada gRPC a inventario para el stock por mercado (otros 4 ms). Sin caché: 420 consultas/s de 8 ms sobre PostgreSQL (ocupando 3,4 s de CPU por cada segundo: tres o cuatro núcleos solo para el catálogo, y creciendo con la campaña) y 12 ms de latencia mínima por ficha. Pero el catálogo cambia unas 300 veces al día (precios, descripciones, fotos), es decir, una escritura por cada 20 000 lecturas. Con una caché que sirva el 98 % de las vistas, PostgreSQL recibe 8 consultas/s en lugar de 420, y la ficha se compone en menos de 1 ms. La ratio lecturas/escrituras es el primer indicador de que un dato es cacheable; el segundo es cuánta obsolescencia tolera, y el catálogo tolera segundos.
- Dónde cachear: cliente, CDN, aplicación y caché distribuida
Una petición atraviesa varias capas, y cada una puede cachear:
flowchart LR
N[Navegador de Ana<br/>caché HTTP] --> CDN[CDN<br/>borde cercano]
CDN --> W[Web / API]
W --> L[Caché local<br/>en proceso]
L --> R[(Redis<br/>caché distribuida)]
R --> DB[(PostgreSQL<br/>km0_catalogo)]
W --> I[inventario gRPC]
| Capa | Qué cachea | Alcance | Ventaja | Límite |
|---|---|---|---|---|
| Cliente (navegador, app) | Respuestas HTTP con Cache-Control, ETag (04-03) |
Un usuario | Latencia cero, sin red | Solo sirve a ese usuario; invalidación imposible (solo caducidad) |
| CDN (08-04) | Contenido estático y respuestas cacheables por URL | Todos los usuarios cercanos a un borde | Absorbe el egress (04-03) y el tráfico de imágenes | Solo para respuestas idénticas para todos; invalidación por URL, con retardo |
| Caché de aplicación local | Objetos en la memoria del proceso (functools.lru_cache, cachetools) |
Una instancia | Nanosegundos; sin dependencias | Cada instancia tiene su copia (10 instancias = 10 copias frías y desincronizadas); se pierde al reiniciar; consume la memoria del proceso |
| Caché distribuida (Redis, Memcached) | Objetos serializados por clave | Todas las instancias del servicio | Una sola copia compartida; sobrevive a reinicios de la aplicación; capacidad de decenas de GB | Un salto de red (~0,5 ms); otro sistema que operar |
En la práctica se combinan: una caché local pequeña con TTL muy corto (1–5 s) para las claves más calientes, delante de Redis para todo, delante de la base de datos. Esta lección se centra en la caché distribuida, que es la que resuelve el problema de carga sobre la base de datos de forma compartida entre todas las instancias de catalogo.
- Patrones de caché
Cómo se relacionan la aplicación, la caché y la base de datos define el patrón:
| Patrón | Lectura | Escritura | Quién habla con la BD | Ventajas | Inconvenientes | Uso típico |
|---|---|---|---|---|---|---|
| Cache-aside (lazy loading) | La aplicación mira la caché; si falla (miss), lee la BD y escribe en la caché | La aplicación escribe en la BD y invalida (borra) la clave | La aplicación | Simple; solo se cachea lo que se pide; la caché puede caerse sin perder datos | Cada miss cuesta dos viajes; la primera lectura es lenta; la aplicación contiene la lógica | El más común: catálogo, perfiles |
| Read-through | La aplicación pide a la caché; la caché lee la BD si no lo tiene | Igual que cache-aside | La caché (o una biblioteca) | La aplicación no sabe de la BD; lógica centralizada | Necesita una caché programable (o una capa); mismo coste de miss | Bibliotecas tipo Caffeine, cachés de ORM |
| Write-through | Como read-through | La aplicación escribe en la caché y la caché escribe síncronamente en la BD | La caché | La caché siempre está actualizada; lecturas siempre calientes tras escribir | Escritura más lenta (dos sistemas); se cachean datos que quizá nadie lea | Datos que se leen justo después de escribirse |
| Write-behind (write-back) | Como read-through | La aplicación escribe en la caché; la caché escribe en la BD después, en lotes | La caché | Escrituras rapidísimas; agrupa escrituras | Si la caché muere, se pierden escrituras pendientes; la BD va por detrás | Contadores de visitas, métricas |
| Refresh-ahead | La caché recalcula las claves calientes antes de que caduquen | Cualquiera | La caché | Sin misses en las claves calientes; latencia estable | Hay que predecir qué se pedirá; carga extra en la BD | Portadas, rankings, fichas más vistas |
sequenceDiagram
participant A as catalogo (app)
participant R as Redis
participant DB as PostgreSQL
Note over A,DB: Cache-aside: lectura con miss
A->>R: GET producto:queso-curado
R-->>A: (nil)
A->>DB: SELECT … WHERE slug = 'queso-curado'
DB-->>A: fila
A->>R: SET producto:queso-curado <json> EX 300
Note over A,DB: Lectura con hit
A->>R: GET producto:queso-curado
R-->>A: <json>
Note over A,DB: Escritura
A->>DB: UPDATE producto SET precio = 15.20 WHERE slug = 'queso-curado'
A->>R: DEL producto:queso-curado
Kilómetro Cero usa cache-aside para el catálogo, con la variante de invalidación por eventos del apartado 6, y refresh-ahead para las 200 fichas más visitadas durante las campañas. El stock, que cambia con cada reserva, no se cachea de la misma forma: la ficha muestra "disponible / pocas unidades / agotado" derivado de un valor con TTL de 10 s, y la reserva real siempre va a inventario; es un ejemplo de que la tolerancia a la obsolescencia se decide por dato, no por servicio.
- Invalidación y TTL: los dos problemas difíciles
"Solo hay dos problemas difíciles en informática: la invalidación de cachés y nombrar las cosas" (Phil Karlton). La frase es un chiste porque es verdad: decidir cuándo una copia deja de ser válida exige saber cuándo cambió el original, y en un sistema distribuido nadie tiene esa información completa y a tiempo. Hay dos mecanismos, y se usan juntos:
- TTL (time to live): cada clave caduca a los N segundos. Es simple, robusta (la caché se autolimpia aunque la invalidación falle), y acota la obsolescencia máxima. Su límite: es un compromiso ciego. Un TTL de 5 minutos para el precio de
queso-curadosignifica que la subida de precio puede tardar 5 minutos en verse; uno de 5 segundos significa 12 misses por minuto por clave aunque el precio no cambie en un mes. - Invalidación explícita: cuando el dato cambia, alguien borra (o actualiza) la clave. Es precisa, pero exige que todos los caminos de escritura la ejecuten (el panel del productor, el importador masivo, el script de corrección que un administrador ejecuta a mano…) y que el borrado llegue (si Redis está inaccesible en ese instante, la clave vieja sobrevive). Con varias cachés (local + Redis + CDN), hay que invalidar en todas.
La combinación práctica: invalidación explícita como mecanismo principal y TTL como red de seguridad, con un TTL lo bastante largo para que los misses sean raros y lo bastante corto para que un fallo de invalidación no dure horas. Para el catálogo de Kilómetro Cero: TTL de 10 minutos e invalidación por eventos.
Una regla derivada: invalidar (borrar) es más seguro que actualizar la caché al escribir. Si dos escrituras concurrentes actualizan la caché en distinto orden que la base de datos, la caché queda con el valor viejo hasta el TTL; si ambas borran, la siguiente lectura recarga el valor correcto. Lo veremos con detalle en el apartado 6.
- Problemas clásicos: stampede, hot keys, penetración
Cache stampede / thundering herd. La clave producto:queso-curado caduca a las 12:00:00 en plena campaña. En los siguientes 50 ms, 40 peticiones descubren el miss a la vez y las 40 lanzan la misma consulta de 8 ms a PostgreSQL, y las 40 escriben el mismo valor en Redis. Con miles de claves caducando en la misma ventana (porque todas se cargaron juntas al desplegar), la base de datos recibe una avalancha que puede tumbarla, lo que alarga las consultas, lo que acumula más peticiones en miss. Soluciones, que se combinan:
| Solución | Cómo | Coste |
|---|---|---|
| Lock (mutex) por clave | El primero que detecta el miss adquiere un lock en Redis (SET lock:producto:queso-curado <id> NX EX 5); recalcula y publica; los demás esperan (o sirven el valor caducado si existe) |
Los que esperan añaden latencia; el lock debe caducar por si el dueño muere |
| Request coalescing | Dentro de un proceso, las peticiones concurrentes de la misma clave se agrupan y solo una va al origen (single-flight) | Solo protege dentro de una instancia; con 10 instancias, 10 consultas en lugar de 400 |
| TTL con jitter | TTL = base ± aleatorio (300 s ± 30 s) para que las claves no caduquen en bloque | Ninguno |
| Early recompute (probabilístico) | Al leer una clave próxima a caducar, con una probabilidad creciente se recalcula antes de que expire (XFetch) | Alguna recarga anticipada innecesaria |
| Servir valor caducado mientras se recarga (stale-while-revalidate) | Se guarda el valor con TTL lógico menor que el físico; si el lógico expiró, se devuelve el viejo y una tarea lo refresca | Obsolescencia controlada |
Hot keys. queso-curado en la Semana del Queso Artesano recibe 100 veces más lecturas que el producto medio. En una caché distribuida con particionado (apartado 8), esa clave vive en un nodo, que se convierte en el cuello de botella mientras los demás están ociosos. Soluciones: caché local en cada instancia con TTL corto para las claves calientes (absorbe el 99 % antes de llegar a Redis), réplicas de lectura del nodo que la tiene, o fragmentar la clave (producto:queso-curado:0 … :9 con el mismo contenido, elegidas al azar en lectura, todas invalidadas en escritura) para repartirla en 10 nodos.
Penetración de caché. Un bot (o un enlace roto) pide producto:queso-cuadrado, que no existe. Cache-aside busca en Redis (miss), consulta PostgreSQL (no hay fila) y no escribe nada en la caché porque no hay valor: cada petición de una clave inexistente llega a la base de datos. Con un ataque de un millón de slugs inventados, la caché no sirve de nada. Soluciones: cachear la ausencia (SET producto:queso-cuadrado "" EX 60, un valor centinela con TTL corto) y, para conjuntos de claves enormes, un filtro de Bloom delante: una estructura probabilística compacta que responde "seguro que no existe" o "quizá existe" sin falsos negativos, de modo que las claves que seguro no existen se rechazan sin tocar ni la caché ni la base (Redis lo ofrece con el módulo RedisBloom; Cassandra lo usa internamente para las SSTables, como vimos en 04-04).
- Consistencia entre caché y base de datos
La caché es una réplica de la base de datos (03-04) sin ningún protocolo de replicación: la mantiene la aplicación, a mano, y por eso las anomalías son responsabilidad del código. Las dos preguntas son qué hacer al escribir (actualizar o invalidar) y en qué orden.
Considera actualizar la caché al escribir, con dos escrituras concurrentes de precio (A: 15,20; B: 15,90):
A: UPDATE precio = 15.20 B: UPDATE precio = 15.90 (BD = 15.90, B ganó) B: SET caché = 15.90 A: SET caché = 15.20 (caché = 15.20: INCONSISTENTE hasta el TTL)
Con invalidación (borrar), el mismo entrelazado deja la clave borrada y la siguiente lectura carga 15,90: correcto. Queda el orden entre escribir en la BD e invalidar:
| Orden | Anomalía posible | Probabilidad |
|---|---|---|
| Invalidar, luego escribir en BD | Entre ambas, una lectura hace miss, carga el valor viejo de la BD y lo cachea; después llega la escritura: caché vieja hasta el TTL | Alta (la ventana es toda la escritura) |
| Escribir en BD, luego invalidar | Una lectura hace miss antes de la escritura, la escritura y la invalidación ocurren, y después la lectura (lenta) escribe el valor viejo en la caché | Baja (la lectura de la BD tendría que ser más lenta que una escritura completa), pero posible |
| Escribir, invalidar, y volver a invalidar tras un retardo (double delete) | Cubre el caso anterior borrando de nuevo pasado el tiempo de una lectura | Muy baja; complejidad extra |
La respuesta habitual es "escribir en BD, luego invalidar", con TTL como red de seguridad y, si el dato es sensible, doble borrado. Y hay una solución estructuralmente mejor, que retoma 02-05: invalidar a partir de los eventos de cambio. inventario ya publica stock.actualizado en pedidos.eventos mediante la tabla outbox, en la misma transacción que el cambio, y ese evento llega seguro (el relay lo garantiza) y llega después del commit (por construcción). Un consumidor de catalogo borra la clave al recibirlo. Con eso:
- La invalidación no depende de que cada camino de escritura se acuerde de borrar: cualquier cambio que pase por la outbox invalida.
- Si Redis está caído cuando llega el evento, el consumidor no confirma el offset y lo reintenta: la invalidación es duradera.
- El orden es siempre "commit, luego invalidar".
- El precio es el retardo del pipeline (decenas de milisegundos, a veces segundos), durante el cual la caché sirve el valor viejo: consistencia eventual con una ventana acotada, aceptable para el catálogo y para el indicador de stock, no para la reserva (que nunca lee de la caché).
sequenceDiagram
participant Inv as inventario
participant PG as PostgreSQL km0_inventario<br/>(stock + outbox)
participant Rel as relay outbox
participant K as Kafka pedidos.eventos
participant Con as catalogo (consumidor)
participant R as Redis
participant Web as Web
Inv->>PG: BEGIN; UPDATE stock…; INSERT outbox(stock.actualizado); COMMIT
Rel->>PG: lee outbox
Rel->>K: publica stock.actualizado (clave = producto)
K-->>Con: stock.actualizado {producto: queso-curado, mercado: Girona, disponible: 1}
Con->>R: DEL stock:queso-curado:Girona
Con->>K: commit offset
Web->>R: GET stock:queso-curado:Girona
R-->>Web: (nil) → miss → gRPC inventario → SET con TTL 10 s
- Memcached frente a Redis
Los dos sistemas de caché distribuida más usados se parecen en lo básico (clave-valor en memoria, red, latencia submilisegundo) y difieren en casi todo lo demás:
| Memcached | Redis | |
|---|---|---|
| Modelo de datos | Cadenas de bytes | Cadenas, hashes, listas, conjuntos, sorted sets, streams, HyperLogLog, bitmaps, geoespacial, JSON y Bloom con módulos |
| Hilos | Multihilo: escala verticalmente con núcleos | Un hilo principal para comandos (E/S multihilo desde la 6); escala horizontalmente con Cluster |
| Persistencia | Ninguna: es una caché pura | Opcional: RDB (instantáneas) y AOF (registro de comandos) |
| Replicación | Ninguna (el cliente decide, a menudo con hashing consistente) | Primario-réplica asíncrona; Sentinel para failover; Cluster para particionado |
| Operaciones atómicas | incr, cas |
Todas las operaciones son atómicas; transacciones MULTI/EXEC; scripts Lua y funciones atómicas |
| Expiración | Por clave, con desalojo LRU | Por clave, con varias políticas de desalojo (LRU, LFU, aleatoria, por TTL) |
| Pub/sub, colas, locks | No | Sí: pub/sub, streams, SET NX EX para locks |
| Memoria | Slab allocator muy eficiente para valores pequeños | Mayor sobrecoste por clave; estructuras compactas para hashes pequeños |
| Cuándo elegirlo | Caché pura de cadenas, máxima simplicidad, máxima eficiencia por núcleo | Casi siempre que se necesite algo más que get/set: estructuras, locks, contadores, colas ligeras, persistencia opcional |
Kilómetro Cero elige Redis: necesita el lock del stampede (SET NX EX), hashes para las fichas, sorted sets para el ranking de productos más vistos (refresh-ahead), y opcionalmente streams. Y necesitará Redis Cluster cuando una sola instancia no baste.
- Redis Cluster: slots, réplicas y failover
Un Redis de una sola instancia sirve cientos de miles de operaciones por segundo y decenas de GB; para ir más allá, o para tolerar la caída de un nodo, está Redis Cluster, que es el particionado de 04-01 en su variante de número fijo de particiones:
- El espacio de claves se divide en 16 384 slots. La clave se asigna con
slot = CRC16(clave) mod 16384. No hay anillo ni vnodes: hay 16 384 particiones fijas repartidas entre los maestros (con 3 maestros: 0–5460, 5461–10922, 10923–16383). - Cada maestro posee un rango de slots y tiene una o más réplicas (replicación asíncrona, 03-04). Los nodos se conocen por gossip y comparten el mapa de slots.
- El cliente es informado (opción (c) de 04-01): descarga el mapa de slots y envía cada comando al maestro correcto. Si el mapa está desactualizado, el nodo responde
-MOVED 1149 172.22.0.4:6379y el cliente actualiza y reintenta; durante una migración de slots responde-ASK.redis-pylo gestiona conRedisCluster. - Hash tags: solo la parte entre llaves se hashea:
{queso-curado}:fichay{queso-curado}:stock:Gironacaen en el mismo slot, lo que permite operaciones multiclave (MGET, transacciones, scripts Lua) sobre ellas. Sin hash tag, unMGETde claves en slots distintos falla conCROSSSLOT. - Failover: si un maestro deja de responder durante
cluster-node-timeout(15 s por defecto), la mayoría de los maestros lo declaraFAILy una de sus réplicas se promociona (una elección con época, en la línea de Raft de 03-03). Las escrituras no replicadas aún se pierden: Redis Cluster es AP con ventanas de pérdida, adecuado para una caché, no para datos que no se puedan reconstruir. - Reequilibrado:
redis-cli --cluster rebalanceoreshardmueven slots enteros entre maestros, clave a clave conMIGRATE, mientras el clúster sigue sirviendo: el número fijo de particiones de 04-01 en acción.
Como alternativa a Cluster cuando no hace falta particionar, Redis Sentinel vigila un primario con réplicas y hace failover automático sin repartir los datos.
- Práctica:
servicios/catalogo/cache.py y Redis Cluster
servicios/catalogo/cache.py y Redis ClusterRedis simple en docker-compose.yml para la primera parte:
# km0/docker-compose.yml (fragmento)
services:
redis:
image: redis:7.4
command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru", "--appendonly", "no"]
ports: ["6379:6379"]allkeys-lru hace que, al llenarse los 512 MB, Redis desaloje las claves menos usadas recientemente: comportamiento de caché. appendonly no porque una caché no necesita persistencia.
El módulo de caché (pip install redis). Implementa cache-aside con TTL y jitter, lock contra stampede, cacheo de ausencias y un consumidor de stock.actualizado que invalida:
# km0/servicios/catalogo/cache.py
"""Cache-aside del catálogo con Redis: TTL con jitter, lock anti-stampede, invalidación por eventos."""
import json
import random
import time
import uuid
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
TTL_PRODUCTO = 600 # 10 min: red de seguridad; la invalidación real llega por eventos
TTL_STOCK = 10 # el indicador de stock tolera 10 s de retraso
TTL_AUSENCIA = 60 # cachear "no existe" contra la penetración
JITTER = 0.1 # ±10 %
CENTINELA = "__NO_EXISTE__"
def _ttl_con_jitter(base: int) -> int:
return int(base * random.uniform(1 - JITTER, 1 + JITTER))
# --- lock anti-stampede ---------------------------------------------------------------------
def _adquirir_lock(clave: str, ttl_ms: int = 3000) -> str | None:
token = str(uuid.uuid4())
return token if r.set(f"lock:{clave}", token, nx=True, px=ttl_ms) else None
_LIBERAR = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""") # solo el dueño libera (comparar y borrar de forma atómica)
def _liberar_lock(clave: str, token: str) -> None:
_LIBERAR(keys=[f"lock:{clave}"], args=[token])
# --- cache-aside genérico ---------------------------------------------------------------------
def obtener(clave: str, cargar, ttl: int, espera_max: float = 2.0):
"""Devuelve el valor cacheado o lo carga con `cargar()` protegido por lock.
`cargar` devuelve None si el dato no existe (se cachea la ausencia)."""
valor = r.get(clave)
if valor is not None:
return None if valor == CENTINELA else json.loads(valor)
inicio = time.monotonic()
while True:
token = _adquirir_lock(clave)
if token:
try:
valor = r.get(clave) # ¿alguien lo cargó mientras esperábamos el lock?
if valor is not None:
return None if valor == CENTINELA else json.loads(valor)
dato = cargar()
if dato is None:
r.set(clave, CENTINELA, ex=TTL_AUSENCIA)
else:
r.set(clave, json.dumps(dato), ex=_ttl_con_jitter(ttl))
return dato
finally:
_liberar_lock(clave, token)
# otro proceso está cargando: esperamos un poco y reintentamos la lectura
time.sleep(0.02)
valor = r.get(clave)
if valor is not None:
return None if valor == CENTINELA else json.loads(valor)
if time.monotonic() - inicio > espera_max:
return cargar() # degradación: ir al origen sin cachear
# --- funciones del catálogo -------------------------------------------------------------------
def producto(slug: str, cargar_de_bd):
return obtener(f"producto:{slug}", lambda: cargar_de_bd(slug), TTL_PRODUCTO)
def stock(slug: str, mercado: str, consultar_inventario):
return obtener(f"stock:{slug}:{mercado}", lambda: consultar_inventario(slug, mercado), TTL_STOCK)
def invalidar_producto(slug: str) -> None:
r.delete(f"producto:{slug}")
def invalidar_stock(slug: str, mercado: str | None = None) -> None:
if mercado:
r.delete(f"stock:{slug}:{mercado}")
else:
claves = list(r.scan_iter(f"stock:{slug}:*"))
if claves:
r.delete(*claves)
# --- consumidor de stock.actualizado (invalidación por eventos, 02-05) -------------------------
def consumir_stock_actualizado():
from kafka import KafkaConsumer # pip install kafka-python
consumidor = KafkaConsumer("pedidos.eventos", group_id="catalogo-cache",
bootstrap_servers="localhost:9092",
enable_auto_commit=False,
value_deserializer=lambda b: json.loads(b.decode()))
for msg in consumidor:
ev = msg.value
if ev["tipo"] == "stock.actualizado":
d = ev["datos"]
invalidar_stock(d["producto"], d.get("mercado"))
print(f"invalidado stock:{d['producto']}:{d.get('mercado', '*')} por {ev['id_evento']}")
elif ev["tipo"] == "producto.actualizado":
invalidar_producto(ev["datos"]["slug"])
consumidor.commit() # solo después de invalidar: si Redis falla, se reintentaLo importante de cada parte:
obteneres cache-aside con lock por clave:SET lock:<clave> <token> NX PX 3000solo tiene éxito para el primer proceso; los demás esperan 20 ms y releen. El lock caduca en 3 s por si el dueño muere._LIBERARes un script Lua para que "si el token es mío, borra" sea atómico: sin él, un proceso lento podría borrar el lock de otro. (Es un lock de caché: si falla, el coste es una consulta duplicada; un lock de corrección, como el del relay de 03-03, se hace con etcd.)- La doble comprobación tras adquirir el lock evita que el segundo en llegar recargue lo que el primero acaba de escribir.
- Si la espera supera
espera_max, se degrada a consultar el origen directamente sin cachear: mejor una consulta extra que una petición colgada. _ttl_con_jitterdesincroniza las caducidades;CENTINELAcachea las ausencias contra la penetración.- El consumidor reutiliza el tópico
pedidos.eventosy la envolturaid_evento/tipo/version/fecha_ms/origen/datosde 02-05.enable_auto_commit=Falsey elcommit()después de invalidar hacen la invalidación al menos una vez: borrar dos veces es inofensivo (idempotente), no borrar es el error que queremos evitar. Nótese que las claves de partición de Kafka son el id de pedido, así que losstock.actualizadode un mismo producto pueden llegar por particiones distintas y en distinto orden; como invalidamos (no actualizamos), el orden no importa: es la razón de fondo de la regla "borrar, no actualizar".
Medición de latencia (simulaciones/latencia_cache.py): simulamos la ficha de queso-curado con una "base de datos" que tarda 8 ms y comparamos:
# km0/simulaciones/latencia_cache.py
import statistics, time
from servicios.catalogo import cache
def cargar_de_bd(slug):
time.sleep(0.008) # PostgreSQL: consulta de la ficha, 8 ms
return {"slug": slug, "nombre": "Queso curado de oveja", "productor": "Quesería Montblanc",
"precio": 14.50, "fotos": ["fotos/queso-curado/miniatura-800.jpg"]}
def medir(f, n=2000):
tiempos = []
for _ in range(n):
t0 = time.perf_counter(); f(); tiempos.append((time.perf_counter() - t0) * 1000)
tiempos.sort()
return statistics.mean(tiempos), tiempos[len(tiempos) // 2], tiempos[int(n * 0.99)]
cache.invalidar_producto("queso-curado")
print("sin caché media/p50/p99 (ms): %.2f / %.2f / %.2f" % medir(lambda: cargar_de_bd("queso-curado")))
print("con caché media/p50/p99 (ms): %.2f / %.2f / %.2f" % medir(lambda: cache.producto("queso-curado", cargar_de_bd)))Cuarenta veces menos latencia, y (lo que importa a PostgreSQL) una consulta en lugar de 2 000. Ejecuta después python -c "from servicios.catalogo.cache import *; [invalidar_producto('queso-curado') for _ in range(1)]" desde otra terminal mientras corre la medición con 20 hilos y verás en MONITOR de redis-cli un único SET producto:queso-curado tras cada DEL, con los otros 19 hilos leyendo el valor recién cargado: el lock trabajando.
Redis Cluster con tres maestros y tres réplicas, para la segunda parte. Seis nodos con cluster-enabled yes y un contenedor que los une:
# km0/docker-compose.yml (fragmento)
x-redis-cluster: &redis-cluster
image: redis:7.4
command: ["redis-server", "--cluster-enabled", "yes", "--cluster-node-timeout", "5000",
"--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
services:
redis-1: { <<: *redis-cluster, hostname: redis-1 }
redis-2: { <<: *redis-cluster, hostname: redis-2 }
redis-3: { <<: *redis-cluster, hostname: redis-3 }
redis-4: { <<: *redis-cluster, hostname: redis-4 }
redis-5: { <<: *redis-cluster, hostname: redis-5 }
redis-6: { <<: *redis-cluster, hostname: redis-6 }
redis-cluster-init:
image: redis:7.4
depends_on: [redis-1, redis-2, redis-3, redis-4, redis-5, redis-6]
command: >
sh -c "sleep 5 && redis-cli --cluster create
redis-1:6379 redis-2:6379 redis-3:6379 redis-4:6379 redis-5:6379 redis-6:6379
--cluster-replicas 1 --cluster-yes"--cluster-replicas 1 asigna una réplica a cada maestro (los tres primeros son maestros, los tres últimos réplicas, emparejados evitando el mismo host cuando es posible). Comprobaciones:
docker compose exec redis-1 redis-cli cluster info | head -6 # cluster_state:ok, cluster_slots_assigned:16384
docker compose exec redis-1 redis-cli cluster nodes # 3 master con rangos de slots, 3 slave
docker compose exec redis-1 redis-cli cluster keyslot producto:queso-curado # p. ej. 1149
docker compose exec redis-1 redis-cli cluster keyslot "{queso-curado}:ficha" # mismo slot que…
docker compose exec redis-1 redis-cli cluster keyslot "{queso-curado}:stock:Girona" # …este
docker compose exec redis-1 redis-cli -c set producto:queso-curado '{"precio":14.5}' # -c: sigue MOVED
docker compose exec redis-3 redis-cli set producto:queso-curado x # sin -c: (error) MOVED 1149 172.22.0.4:6379
docker compose exec redis-1 redis-cli mget producto:queso-curado producto:tomate-rosa # (error) CROSSSLOTCLUSTER KEYSLOT muestra el CRC16 mod 16384 de cada clave y demuestra que las hash tags entre llaves sitúan claves relacionadas en el mismo slot. La respuesta MOVED sin -c es el mapa de particiones hablando con el cliente. Y CROSSSLOT recuerda que las operaciones multiclave, en una caché particionada, exigen colocalización deliberada.
Failover: docker compose stop redis-1; a los 5 s (cluster-node-timeout), cluster nodes muestra redis-1 como master,fail y su réplica promocionada a master con los slots 0–5460; las escrituras a esas claves siguen funcionando. Al arrancar redis-1, se une como réplica del nuevo maestro. Desde Python:
from redis.cluster import RedisCluster
rc = RedisCluster(host="redis-1", port=6379, decode_responses=True) # desde un contenedor de la red de Compose
rc.set("producto:queso-curado", "…") # el cliente enruta por slot
print(rc.cluster_keyslot("producto:queso-curado"))RedisCluster de redis-py descarga el mapa de slots, enruta cada comando y sigue MOVED/ASK automáticamente: el módulo cache.py funciona sin cambios sustituyendo redis.Redis por RedisCluster, salvo por scan_iter (que recorre todos los maestros) y por las operaciones multiclave, que necesitan hash tags. Una mejora natural del módulo sería nombrar las claves {queso-curado}:producto y {queso-curado}:stock:Girona para poder invalidar todo lo de un producto con un solo DEL multiclave.
Errores Comunes y Consejos
- Cachear sin TTL "porque ya invalidamos". La invalidación fallará alguna vez (un camino de escritura olvidado, Redis caído en el instante justo). El TTL es la red de seguridad; ponlo siempre.
- Actualizar la caché al escribir en lugar de invalidar. Dos escrituras concurrentes dejan la caché con el valor equivocado hasta el TTL. Borra; la siguiente lectura carga el valor bueno.
- Invalidar antes de escribir en la base de datos. Ventana grande para cachear el valor viejo. Escribe primero, invalida después; mejor aún, invalida desde los eventos de la outbox.
- Todas las claves con el mismo TTL, cargadas a la vez tras un despliegue. Caducan juntas: estampida. Jitter y, para lo caliente, refresh-ahead.
- Sin cacheo de ausencias. Un bot con slugs inventados atraviesa la caché y llega a la base de datos en cada petición. Centinela con TTL corto o filtro de Bloom.
- Un lock sin caducidad o liberado sin comprobar el dueño. Un proceso muerto bloquea la clave para siempre; un proceso lento libera el lock de otro.
NX PXy liberación con script Lua. - Confundir el lock de la caché con un lock de corrección. El de Redis con
SET NXpuede fallar en un failover (las escrituras asíncronas se pierden); si de él depende no vender dos veces la última pieza, usa etcd (03-03) o la base de datos. - Cachear la respuesta de la reserva de stock. El indicador "quedan pocas unidades" puede ir 10 s por detrás; la reserva real nunca lee de la caché. Decide la tolerancia a la obsolescencia por dato.
- Operaciones multiclave en Redis Cluster sin hash tags.
CROSSSLOT. Diseña las claves con{tag}desde el principio; cambiarlas después obliga a vaciar la caché. - Tratar Redis Cluster como base de datos. El failover pierde las escrituras no replicadas. Es una caché (o un almacén reconstruible), y
pedidoseinventarioviven donde decidió 04-04.
Ejercicios
Ejercicio 1. Implementa en cache.py la variante stale-while-revalidate de obtener: guarda en Redis un hash con los campos valor y caduca_logico (marca de tiempo), con TTL físico igual al doble del lógico. Si al leer el valor lógico ha caducado, devuelve el valor viejo inmediatamente y lanza la recarga (en un hilo, protegida por el mismo lock). Explica qué gana la web durante la Semana del Queso Artesano frente a la versión con lock del apartado 9 y qué se pierde.
Ejercicio 2. El evento stock.actualizado se publica en pedidos.eventos con clave de partición = id de pedido. Dos reservas de queso-curado en Girona (pedidos P-2026-000125 y P-2026-000126) generan dos eventos en particiones distintas que el consumidor catalogo-cache puede procesar en orden inverso. Explica (a) por qué la invalidación por borrado es correcta en cualquier orden; (b) qué pasaría si el consumidor en lugar de borrar hiciera SET stock:queso-curado:Girona <disponible del evento>; (c) cómo cambiaría la situación si inventario publicara los eventos de stock con clave = producto, y qué se pierde con ese cambio (pista: 02-04 y la relación con pedido.creado).
Ejercicio 3. Durante la campaña, producto:queso-curado recibe 15 000 lecturas/s y su slot vive en redis-1, que satura (el resto de maestros está al 10 %). Propón dos soluciones combinadas, escribe el código de la fragmentación de la clave en N copias ({queso-curado}:producto:0..N-1) con lectura aleatoria e invalidación de todas, y razona por qué la hash tag {queso-curado} hace que la fragmentación no ayude en Redis Cluster y cómo nombrarías las copias para que sí ayude.
Soluciones
Solución 1:
import threading
def obtener_swr(clave: str, cargar, ttl_logico: int):
h = r.hgetall(clave)
ahora = time.time()
if h:
valor = None if h["valor"] == CENTINELA else json.loads(h["valor"])
if float(h["caduca_logico"]) > ahora:
return valor # fresco
# caducado lógicamente: devolvemos el viejo y recargamos en segundo plano
threading.Thread(target=_recargar_swr, args=(clave, cargar, ttl_logico), daemon=True).start()
return valor
return _recargar_swr(clave, cargar, ttl_logico) # sin valor: carga síncrona (con lock)
def _recargar_swr(clave, cargar, ttl_logico):
token = _adquirir_lock(clave)
if not token: # otro proceso recarga; leer lo que haya
h = r.hgetall(clave)
return json.loads(h["valor"]) if h and h["valor"] != CENTINELA else None
try:
dato = cargar()
r.hset(clave, mapping={"valor": CENTINELA if dato is None else json.dumps(dato),
"caduca_logico": time.time() + _ttl_con_jitter(ttl_logico)})
r.expire(clave, ttl_logico * 2) # TTL físico: red de seguridad
return dato
finally:
_liberar_lock(clave, token)Lo que gana la web: durante la campaña, ninguna petición de una clave caliente espera a la base de datos (ni siquiera la que dispara la recarga), así que el p99 se mantiene en el submilisegundo de Redis en lugar de saltar a 8 ms cada 10 minutos; y la estampida es imposible porque solo una recarga corre a la vez y las demás sirven el valor antiguo. Lo que se pierde: obsolescencia adicional (el valor viejo se sirve hasta que la recarga acaba, y si el origen falla, hasta el TTL físico), memoria (un hash por clave) y complejidad (hilos en segundo plano, que en un servidor asíncrono se harían con una tarea). Para el precio de una ficha es un buen trato; para el indicador de stock con TTL de 10 s, discutible.
Solución 2:
(a) DEL es idempotente y no lleva valor: da igual cuál de los dos eventos se procese primero, el resultado final es "la clave no está", y la siguiente lectura la recarga desde inventario, que tiene el valor correcto (disponible después de ambas reservas). La invalidación convierte un problema de orden en un problema de existencia, que no tiene orden.
(b) Con SET <disponible del evento>, si el evento de P-2026-000126 (disponible = 1) se procesa antes que el de P-2026-000125 (disponible = 2), la caché acaba en 2 mientras el stock real es 1: incorrecta hasta el TTL de 10 s. Sería necesario comparar fecha_ms o una versión y descartar eventos antiguos (el patrón last-writer-wins de 03-04, con sus riesgos de reloj de 01-05), que es más código y más fallos que un DEL.
(c) Con clave = producto, todos los stock.actualizado de queso-curado irían a la misma partición y llegarían en orden (02-04), y entonces SET sería seguro. Pero se pierde la colocalización con los demás eventos del pedido: pedido.creado, stock.reservado y pago.confirmado de P-2026-000125 dejarían de estar en la misma partición que su stock.actualizado, y los consumidores que reconstruyen la historia de un pedido en orden (la saga por coreografía de 03-05, analitica) perderían esa garantía. Alternativa limpia: un tópico separado inventario.stock con clave = producto para los eventos de stock, que es lo que haría un diseño maduro; mientras tanto, el DEL funciona con cualquier clave.
Solución 3:
Soluciones combinadas: (1) una caché local en cada instancia de catalogo (cachetools.TTLCache(maxsize=500, ttl=2)) para las claves más leídas, que absorbe la inmensa mayoría de las 15 000 lecturas/s antes de llegar a Redis, e invalidada también por el consumidor de eventos (cada instancia consume con su propio group_id, o se publica un PUBLISH de Redis a todas); (2) fragmentar la clave en N copias para repartirla entre maestros:
N_COPIAS = 8
def clave_copia(slug, i): return f"producto:{slug}:c{i}" # sin hash tag: cada copia en su propio slot
def producto_fragmentado(slug, cargar_de_bd):
i = random.randrange(N_COPIAS)
return obtener(clave_copia(slug, i), lambda: cargar_de_bd(slug), TTL_PRODUCTO)
def invalidar_producto_fragmentado(slug):
for i in range(N_COPIAS):
r.delete(clave_copia(slug, i)) # en Cluster: N DEL, uno por slotCon la hash tag {queso-curado} todas las copias tendrían el mismo slot (solo se hashea lo que hay entre llaves) y por tanto el mismo maestro: se repartiría la carga entre claves, no entre nodos, que es justo lo que no sirve. Sin hash tag (producto:queso-curado:c0…c7), CRC16 de cada nombre completo cae en slots distintos y, con alta probabilidad, en maestros distintos; se puede comprobar con CLUSTER KEYSLOT y ajustar los sufijos hasta que las 8 copias cubran los 3 maestros. El coste: 8 misses en lugar de 1 tras cada invalidación (irrelevante con 300 cambios al día) y 8 DEL por invalidación, que ya no puede ser multiclave (van a slots distintos); las réplicas de lectura de Redis Cluster (READONLY en el cliente) son la tercera opción, que reparte lecturas del mismo slot entre maestro y réplica sin cambiar las claves, a cambio de leer con retraso de replicación.
Conclusión
Una caché distribuida es una réplica de conveniencia mantenida a mano por la aplicación, y por eso concentra en el código todos los problemas de replicación que las bases de datos resuelven por protocolo. Cachear compensa cuando la ratio lecturas/escrituras es alta y el dato tolera cierta obsolescencia, como el catálogo de Kilómetro Cero con 20 000 lecturas por escritura: la medición ha bajado la ficha de queso-curado de 8 ms a 0,2 ms y ha quitado a PostgreSQL el 98 % de las consultas de la Semana del Queso Artesano. Hemos situado la caché en sus capas (cliente, CDN, local, distribuida), recorrido los patrones (cache-aside como elección, refresh-ahead para lo caliente, write-through y write-behind para otros casos), y tratado la invalidación con la regla "TTL como red, invalidación explícita como mecanismo, borrar en vez de actualizar, después de escribir en la base de datos". Contra la estampida: lock por clave con token y script Lua, coalescing, jitter y recarga anticipada; contra las claves calientes: caché local, réplicas y fragmentación; contra la penetración: centinelas y filtros de Bloom. La invalidación por los eventos stock.actualizado de pedidos.eventos ha convertido la outbox de 02-05 en el mecanismo de coherencia entre inventario y la caché de catalogo, duradero y correcto en cualquier orden. Redis ha ganado a Memcached por sus estructuras y sus locks, y Redis Cluster ha cerrado el círculo con el módulo: 16 384 slots fijos, MOVED como mapa de particiones en manos del cliente, hash tags para colocalizar, réplicas y failover asíncrono que lo hacen adecuado como caché y no como base de datos.
Con esta lección se cierra el Módulo 4, y cada dato de Kilómetro Cero tiene ya su sitio:
| Dato | Dónde vive | Lección | Por qué |
|---|---|---|---|
| Pedidos (historial, "mis pedidos", sagas) | Cassandra km0_pedidos, particionado por cliente_id (+ mes), RF=3, LOCAL_QUORUM |
04-01, 04-04 | Escritura intensiva, consultas estables, disponibilidad |
| Stock por producto y mercado | PostgreSQL km0_inventario (primario + réplica), particionado por producto_slug |
03-04, 04-01, 04-04 | Contador CP con restricciones y transacciones locales |
| Pagos | PostgreSQL | 04-04 | CP, bajo volumen, auditoría |
| Catálogo (fichas, productores) | PostgreSQL con jsonb + Redis (cache-aside, TTL 10 min, invalidación por eventos) |
04-04, 04-05 | 20 000 lecturas por escritura; tolera segundos de retraso |
| Indicador de stock en la ficha | Redis, TTL 10 s, invalidado por stock.actualizado |
04-05 | Tolerancia explícita a 10 s; la reserva nunca lee de aquí |
Posiciones de furgoneta-3 |
Cassandra, partición repartidor + día, ONE |
04-01, 04-04 | AP, 2,4 M escrituras/día, acceso por repartidor |
| Índices por mercado y productor | Tablas materializadas desde pedidos.eventos |
04-01 | Índice global asíncrono, sin scatter/gather |
| Fotos de productos | MinIO km0-fotos, versionado, URLs prefirmadas, CDN delante |
04-03 | Objetos inmutables servidos por HTTP; egress |
| Facturas PDF y backups de PostgreSQL | MinIO km0-facturas, km0-backups, ciclo de vida |
04-03 | Inmutables, retención, coste por GB |
Eventos de pedidos.eventos y logs de clics |
HDFS /km0/eventos/<día>/, /km0/clics/<día>/, un fichero por día |
04-02 | Lago de datos: masivo, secuencial, write-once |
| Mapa de particiones y liderazgo | etcd | 03-03, 04-01 | Consenso para el estado de coordinación |
Los datos están repartidos entre muchos nodos y sabemos por qué cada uno está donde está. La pregunta que sigue es cómo procesarlos en masa ahora que ya no caben en una máquina: cómo calcular las ventas de la Semana de la Vendimia por productor y mercado sobre los cientos de millones de eventos del lago, cómo entrenar recomendaciones con los clics de un año, cómo alimentar en tiempo real el panel de reparto. Es el terreno del Módulo 5, la computación distribuida, que empieza por los modelos de computación distribuida: qué significa repartir un cálculo, y no solo un dato, entre muchos nodos.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
