Capítulo 02 · Lecturas
Caché
Diez mil personas miran las mismas zapatillas. Hit, miss, stale, stampede. Redis como copia, no como verdad.
18 min intro
Las zapatillas del 123
Imaginate que seguimos en la misma tienda. Antes de que exista el pedido 456, Ana está en la ficha: fotos, talla, 89,90 €, un párrafo. Esa página es product:123. Un martes cualquiera la piden mucho. En Black Friday, sin parar.
El checkout que ya diseñamos aguanta el clic de Comprar con una transacción corta en Postgres. La ficha es otro tipo de paliza: la misma lectura, mil veces por segundo, de un dato que casi no cambia. Postgres puede con mucho. No está pensado para que lo uses como altavoz. Un caché es guardar cerca, en memoria, el resultado que sale caro de volver a pedir. La siguiente visita no toca la base. No es magia de «ir más rápido». Es dejar de repetir trabajo. El precio — y este capítulo va de ese precio — es que ahora tenés dos sitios con el precio de las zapatillas, y a veces no van a coincidir.
Hit, miss, y por qué el porcentaje miente un poco
Ana abre la ficha. La API recibe GET /products/123 y mira el caché. Si product:123 está, es un hit: lo devolvés y Postgres ni se entera. Si no está, es un miss: vas a la base, leés la fila, escribís en el caché y respondés. La próxima Ana — o las mil que vienen detrás — no pagan ese viaje.
Client → API → caché
│ miss
▼
Postgres → se rellena el caché
La métrica que vas a oír es el hit rate: hits partido por peticiones. Noventa de cada cien ni tocan la DB. Suena bien. Solo es bueno si el diez por ciento que falla no tumba Postgres. Un hit rate del 99% sobre una key que se pide diez veces al día no te salva Black Friday: casi no había tráfico que salvar. Un 70% sobre product:123 en el pico, si ese 30% son diez mil misses a la vez, es un incidente. El número solo se entiende junto al tráfico y junto a qué cacheás. Cachear la ficha tiene sentido. Cachear el stock con el que prometés el último 42, no tanto.
En una entrevista de system design no basta con «lo pongo en Redis». Di la key y la forma. La ficha del 123 cabe en un hash o un JSON: precio, tallas, un título. Un ranking de «más vendidas» es un sorted set, para sacar el top 10 sin ordenar vos en la API. Una sesión es un string con caducidad. Redis no es una caja mágica: es un servidor de estructuras. Si no decís cuál, no diseñaste el caché. Nombraste un producto.
La app mira primero: cache-aside
El patrón de casi siempre se llama cache-aside. La aplicación manda. Postgres sigue siendo la verdad, igual que en el capítulo anterior.
valor = cache.get("product:123")
si no está:
valor = db.get(123)
cache.set("product:123", valor)
devolver valor
Si Redis se cae, dejás de preguntarle y leés la base: más lento, el catálogo sigue existiendo. Si el caché fuera la autoridad, Redis caído es tienda cerrada, y eso contradice lo que ya decidimos: el 456 y el precio de cobro viven en Postgres.
Otros tres nombres, con el mismo 123, porque en entrevista van a aparecer y conviene saber qué estás eligiendo.
Write-through. Cada vez que un editor cambia el precio a 79,90, escribís en el caché y en Postgres en el mismo request. El visitante siguiente ve 79,90. Cada escritura cuesta dos sitios. Si uno falla, volvés al dual write: un mundo dijo que sí y el otro no.
Write-behind. Escribís solo en el caché y Postgres se entera después. Bonito para un contador de vistas de la ficha. Letal para el precio o el stock. Si Redis se reinicia antes de vaciar hacia la base, el 79,90 nunca existió para la contabilidad.
Write-around. El write va solo a Postgres. El caché se llena cuando alguien lee, no cuando alguien edita.
Para la ficha, el default es cache-aside. Cuando el precio cambia, no intentás dejar Redis «perfecto» en el mismo instante. Actualizás la verdad y sacás la copia:
UPDATE products SET price = 79.90 WHERE id = 123
DEL product:123
Borrás la key. El próximo GET hace miss, lee 79,90 y vuelve a llenar. Postgres manda. Actualizar el caché en el write parece más fresco; es otra vez dos escrituras que pueden divergir. El DEL es más humilde: admitís un hueco hasta la próxima lectura. Si el DEL falla, te quedás un rato con 89,90 en memoria. Por eso existe el TTL: un techo. Aunque nadie invalide, esa mentira caduca.
El precio viejo y los sesenta segundos
Un editor pone las zapatillas a 79,90. Un visitante, medio segundo después, sigue viendo 89,90. Eso es stale. No es un bug misterioso. Es el diseño: dos sitios, un hueco entre que Postgres ya sabe y Redis todavía no. La misma clase de atraso que vimos entre primario y réplica, solo que acá el segundo sitio es memoria y lo pusimos nosotros a propósito.
La pregunta no es «¿cómo hago el caché perfectamente consistente?». Es cuántos segundos de mentira aguanta el negocio. Los likes de una foto: diez segundos, nadie denuncia. El precio en la ficha: feo, pero si el checkout lee el precio otra vez de Postgres al pagar, el daño es una etiqueta mal puesta — Ana vio 89,90 y le cobraron 79,90, o al revés, y eso se discute en soporte, no en el depósito. El precio con el que cobrás, o el stock que prometés en «añadir al carrito», es otra conversación: eso a veces no se cachea, o se cachea con TTL corto e invalidación inmediata. Mezclar «ficha bonita» con «verdad para cobrar» es cómo vendés un 42 que ya no tenés.
SET product:123 ... EX 60: dentro de un minuto, esta copia muere. Sesenta segundos significan más hits y más riesgo de precio viejo. Diez, más fresco y más misses contra Postgres. Sin TTL, si un día se te olvida el DEL, el 89,90 vive para siempre. El TTL no sustituye invalidar. Es el cinturón por si el airbag no salta.
El caché es finito. No entra el catálogo entero más las sesiones más lo que se te ocurra. LRU echa lo que hace más tiempo que nadie toca. LFU, lo que menos se pidió. En una entrevista de system design no recites las siglas. Di: la memoria se llena, algo sale, y una key popular no debería depender de vivir eternamente en RAM. El 123, si es el producto del día, va a volver a entrar. El producto que nadie mira, no.
A veces no invalidás en el mismo request del editor. Escribís en Postgres, publicás un evento, y un worker — colas — hace el DEL. Ganas desacople: el editor no espera a Redis. Perdés un hueco extra. Si el hueco es inaceptable para el precio de la ficha, el DEL va síncrono en el write, y punto.
Diez mil misses a la vez
Black Friday. product:123 está en Redis con TTL de sesenta segundos y el hit rate es hermoso. En el segundo 60 la key expira. En el 61 llegan diez mil GET de gente que está mirando las mismas zapatillas. Los diez mil ven miss. Los diez mil van a Postgres con el mismo SELECT * FROM products WHERE id = 123. Eso es un cache stampede (thundering herd). El caché no falló. Caducó a la vez para todo el mundo, y la cola que el caché estaba absorbiendo aterrizó entera en la base.
Tres maneras, combinables, de no convertir un TTL en un incidente:
Un solo request regenera. Un lock (SET lock:product:123 NX EX 5). Quien lo gana va a Postgres y rellena Redis. Los demás esperan ese resultado o, si todavía tenés el valor viejo, lo servís un segundo más. Diez mil personas, un SELECT.
Early refresh. Antes de caducar, un request de vez en cuando — no todos — renueva la key. Nunca hay un agujero en el que la key no existe y todo el mundo corre a la DB.
Jitter en el TTL. No pongas sesenta redondos a todas las fichas del catálogo. Pon sesenta más un aleatorio. El stampede de una key lo arreglás con lock. El de todo el catálogo a las 12:00 en punto, cuando mil productos vencen juntos, se arregla haciendo que no venzan juntos.
El stampede es muchos miss de una key que sí existe y acaba de caducar. No lo confundas con lo que viene: una key que está y quema, o mil keys que nunca existieron.
Una key famosa y un millón de IDs que no existen
Las zapatillas se viralizan. No es que caduquen. Están en Redis y aun así un solo nodo, una sola key product:123, recibe millones de get. Redis es rápido. Una key en un shard no es infinita: un core, una estructura, un punto. Eso es una hot key. El hit rate es altísimo — casi nadie hace miss — y el hardware de ese clavo, no. El número de la métrica te miente otra vez: todo «va bien» hasta que esa caja no da más.
Qué hacés, en este diseño. Réplicas de Redis para leer, igual que réplicas de Postgres: copias, no trozos. Un caché local en cada proceso de la API (L1, unos segundos) y Redis detrás (L2): las diez mil Ana de una misma instancia no viajan diez mil veces a Redis por el mismo JSON. Si el dato es público — la foto, el HTML de la ficha — un CDN en el borde, que ni siquiera entra a tu API. Request coalescing: si ya hay mil goroutines esperando el mismo 123, una sola va a Redis y las demás se suben a esa respuesta.
Luego el otro ataque, que se parece a un miss y no lo es. Un script pide product:99999991, 99999992… IDs que no existen en products. Cada uno es miss. El caché no tiene nada que devolver. Cada miss va a Postgres, que recorre el índice, no encuentra fila, y responde 404. El bot acaba de usar tu base como oráculo de «¿existe este id?». Eso es cache penetration: el caché no te cubre porque nunca hay nada que guardar.
El arreglo simple: cachear el vacío. product:99999991 → NOT_FOUND con TTL corto. El próximo golpe del bot ni toca Postgres. El más fino: un Bloom filter delante, que dice «este id seguro no está» sin guardar una key por cada invención. Para un diseño, nombrar negative cache ya demuestra que ves el agujero. El Bloom es el plus si el entrevistador quiere profundidad.
Tres nombres, tres historias, las tres con esta tienda. Stampede: el 123 existía y caducó para todos a la vez. Hot key: el 123 existe, está en Redis, y el tráfico quema el clavo. Penetration: el 99999991 no existe y cada pregunta cava hasta el disco.
Tres APIs, tres mentiras
Subís tres instancias de la API detrás del load balancer, que es lo normal cuando Ana no es la única. Cada una se guarda product:123 en un Map de proceso, «para ir más rápido». Un editor cambia el precio. La instancia A borra su copia. B y C siguen sirviendo 89,90. Ana refresca y el precio baila: 79,90, 89,90, 79,90, según a qué máquina la mande el balancer. No es un bug de Redis. Es que nunca hubo un Redis: hubo tres cachés que no se hablan.
Si el dato se comparte entre procesos, el caché tiene que ser compartido. Eso es Redis (o Memcached). El caché local no desaparece: L1 para la hot key, TTL corto, un poco de stale a cambio de no golpear Redis diez mil veces por segundo desde cada caja. L1 solo, para un catálogo que todas las instancias tienen que mostrar igual, es una trampa.
Redis no es «Postgres en RAM». Como caché de ficha: hash o string más TTL, y listo. En otros problemas lo vas a usar para contadores, rate limit, locks, sesiones. En este capítulo el job es caché. Memcached basta si solo querés blobs que caducan. Redis gana cuando la estructura es la feature: el top de ventas, una ventana de límite. Elegí por el access pattern, igual que SQL o NoSQL.
Redis se cae en Black Friday
El miedo fácil: Redis down, tienda down. El diseño decente: Redis down, leemos Postgres. Graceful degradation. El miedo bueno, el que hay que diseñar, es el otro: Redis down, las cien mil req/s que él absorbía aterrizan en Postgres, y entonces sí, tienda down — y con ella el checkout del 456, que no tenía nada que ver con la ficha. «Si Redis falla, voy a la DB» sin más es cómo convertís un incidente de caché en uno de datos.
Hace falta frenar. Rate limit delante. Un circuit breaker que deje de pegarle a Postgres cuando ya está ahogado. Un L1 que aguante unos segundos con la ficha que cada proceso ya tenía. No es heroísmo. Es no dispararle a tu fuente de verdad el caudal del caché.
El caso contrario también hay que nombrarlo. Postgres cae y Redis sigue. Podés servir hits: Ana sigue viendo las zapatillas. No hay writes. No hay checkout fiable, porque el 456 no se puede persistir. We can keep serving cached product pages; we cannot take new orders. En entrevista eso demuestra que el caché es copia, no suelo.
El caso sucio, más de martes que de Black Friday: un JSON de un schema viejo en Redis. Agregaste un campo a la ficha, deployste la API, y Redis sigue devolviendo el objeto de ayer. TTL, invalidación, a veces versionar la key (product:123:v2) cuando cambia el formato. Si no, el miss más caro es el que no ves: la app rompe al parsear.
La ficha, entera
Ana abre las zapatillas. La API pregunta a Redis product:123. Hit: responde. Miss: Postgres, SET con TTL de unos sesenta segundos más jitter. En entrevista dijiste que es un hash, no «un Redis».
Un editor baja el precio. Postgres primero. Luego DEL product:123. El checkout, cuando cobra el 456, no se fía de esa ficha: vuelve a leer el precio o el stock de la fuente.
Llega el pico. La key caduca. Un lock evita el stampede. El 123 se vuelve famoso: L1 más réplicas, las fotos por CDN. Un bot barre IDs inventados: NOT_FOUND cacheado. Tres APIs no tienen tres Maps eternos: tienen Redis compartido.
Redis se cae. No mandás el caudal entero a Postgres. Degradás y protegés.
Visitante → API → L1 (TTL corto)
│ miss
▼
Redis
│ miss
▼
Postgres
Editor cambia precio → Postgres → DEL product:123
Si podés contar esto en voz alta, el caché ya no es una nota al pie del diagrama. Es el mismo oficio que las colas: un colchón que esconde capacidad y mentiras — acá la mentira se llama stale — y que hay que diseñar sabiendo que miente.
Qué espera cada nivel
Un mid pone Redis, cache-aside, TTL, y un DEL en el write.
Un senior nombra stampede, hot key y penetration por separado, no manda el QPS del caché a la DB cuando Redis cae, y no cachea el precio con el que cobra.
Si te lo preguntan
¿Por qué Redis? The product page is read constantly and barely changes; we keep product:123 in Redis as a hash with a TTL so Postgres is not on the hot path.
¿Cache-aside? The app checks the cache. On a miss it loads from the database and fills the cache. The database stays the source of truth.
¿Si expira? The next request misses and rebuilds. If the key is hot, we lock or refresh early so we do not stampede the database.
¿El coste? We buy latency and capacity. We pay with stale data, invalidation, eviction, stampedes, hot keys, and a failover that can kill the database if we are not careful.
Stampede: a popular key expires and thousands of requests rebuild it at once.
Hot key vs penetration: hot key is one real key taking all the traffic; penetration is missing keys that always fall through to the DB.
¿DELETE o UPDATE? Delete. Let the next read rebuild. Updating the cache on write is another dual write.
Redis se cae: we fall back to the database, but we shed load. The cache QPS must not become the database QPS.