Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Caché

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

Resumen teórico

Imagina que estás haciendo la tarea y necesitas un lápiz. Los mejores lápices están en una caja grande en el depósito, al final del pasillo. Ir hasta allá cada vez es lento. Entonces guardas algunos lápices en tu estuche, justo sobre el escritorio. Ese estuche es un cache: un lugar pequeño y rápido que guarda copias de cosas que son lentas de conseguir.

Las computadoras hacen esto todo el tiempo. El navegador guarda imágenes para no descargarlas otra vez. Un sitio guarda la respuesta de una pregunta pesada para no preguntarle de nuevo a la base de datos. Hasta el procesador de tu computadora tiene caches. Un buen cache hace que un sitio lento parezca instantáneo y protege a una base de datos pequeña de una multitud de visitantes.

Pero el cache tiene un problema. Una copia puede quedar vieja. Si la caja de lápices de verdad cambia y tu estuche no, empiezas a usar cosas desactualizadas. Casi todo lo difícil de los caches viene de una pregunta: ¿cómo mantenemos la copia lo bastante cerca de la verdad?

En este resumen aprenderás por qué y dónde usar cache, las principales formas de escribir datos, cómo tirar copias viejas, qué pasa cuando el cache se llena y quién sale, los famosos problemas de multitud (estampida, penetración y avalancha), los encabezados HTTP que controlan el cache del navegador, qué ofrece Redis y qué puede salir mal con la consistencia. Al final podrás leer un diseño de cache y decir qué hace bien y dónde puede romperse.

Por qué y dónde usar cache

Piensa en la biblioteca de la escuela. El origen es la gran biblioteca de la ciudad, con todos los libros que se han impreso. Ir hasta allá toma una hora. En tu salón hay un estante pequeño con los libros que todos piden. Ese estante es el cache. La biblioteca de la ciudad sigue siendo la fuente de la verdad: si las dos no coinciden, la biblioteca tiene razón y el estante es solo una copia.

Un cache funciona gracias a la . Pocos elementos populares reciben la mayoría de los pedidos. Si el cache guarda esos pocos elementos, la mayoría de los pedidos se responden rápido.

Las palabras que necesitas

  • Hit (acierto): lo que pediste está en el cache. ¡Rápido!
  • Miss (fallo): no está, así que hay que ir al origen. Lento.
  • Tasa de aciertos (hit rate): cuántas consultas fueron aciertos, respecto de todas las consultas. Es aciertos dividido entre (aciertos más fallos).
  • Cache frío: un cache vacío, por ejemplo justo después de reiniciar. Casi todo es fallo.
  • Calentamiento (warm-up): el tiempo en que los fallos van llenando el cache y la tasa de aciertos sube.
  • Stale (desactualizada): una copia más vieja que el dato real.

¿De verdad el cache lo hace todo más rápido?

Cada solicitud paga primero la consulta al cache. Solo los aciertos evitan el viaje al origen. Por eso el tiempo promedio es un promedio ponderado: tasa de aciertos por el tiempo de un acierto, más tasa de fallos por el tiempo de un fallo. Un fallo cuesta más que no tener cache, porque pagamos la consulta fallida y además el origen.

Ejemplo: un acierto cuesta 2 ms y un fallo cuesta 2 ms más 100 ms en el origen. Con 90% de aciertos el promedio es 0,9 x 2 + 0,1 x 102 = 12 ms. El gráfico muestra cuánto importa la tasa de aciertos.

Latencia promedio según la tasa de aciertos (acierto = 2 ms, fallo = 102 ms)
0% de aciertos
102 ms
50% de aciertos
52 ms
90% de aciertos
12 ms
99% de aciertos
3 ms

Un cache incluso puede empeorar las cosas. Supón que el origen responde en 20 ms y consultar un cache remoto cuesta 5 ms. El promedio es 5 + (1 - h) x 20, donde h es la tasa de aciertos. Solo supera a 20 ms cuando h es mayor que 25%. Con 10% de aciertos el promedio pasa a 23 ms: más lento que no tener cache.

Las capas de cache

Una solicitud puede ser respondida por varios caches, uno tras otro. Cuanto más cerca del usuario se encuentra la respuesta, menos trabajo y menos red se gastan.

usuario -> cache del navegador -> CDN / proxy -> cache de la app -> base
            (privado)               (compartido)    (en el proceso   (buffer pool,
                                                     o Redis)        cache de páginas)

Más cerca del usuario = más rápido y más barato.
Dónde puede detenerse una solicitud
Capas de cache
CapaQuién puede usar la copiaBueno para
NavegadorSolo ese usuario (un cache privado)Imágenes, scripts y estilos que el usuario ya descargó
CDN o proxy inversoMuchos usuarios (un cache compartido)Archivos estáticos servidos desde servidores repartidos por el mundo, cerca de la gente
Cache de la aplicaciónUna instancia (en el proceso) o todas (Redis)Resultados de consultas lentas y de cálculos pesados
Buffer pool de la baseLa propia base de datosPáginas de disco de tablas e índices leídas hace poco

Una CDN (red de entrega de contenido) es un grupo de servidores repartidos por el mundo. Responde el más cercano a ti, así que el dato viaja poco. Su cache es compartido por todos los usuarios.

La base de datos también tiene su propio cache, el buffer pool, que mantiene en memoria páginas de disco leídas hace poco. Acelera toda consulta, pero la consulta aún se analiza y se ejecuta. Guardar el resultado ya listo en el cache de la aplicación también ahorra ese trabajo, al precio de que el resultado envejezca.

¿En el proceso o distribuido?

AspectoCache en el procesoCache distribuido (por ejemplo Redis)
VelocidadEl más rápido: sin red, sin conversiónUn viaje de red por cada consulta
CopiasUna copia por instancia: la memoria se repite y las copias pueden diferirUna copia que ven todas las instancias
Después de reiniciar la appTodo se pierdeLos datos siguen ahí

Imagina una app en 4 instancias, cada una con su cache en el proceso. Una instancia cambia un producto en la base y lo quita de su propio cache. Las otras tres siguen con el producto viejo hasta que sus copias expiren.

Un cache puede esconder una base débil

Digamos que un servicio recibe 10.000 lecturas por segundo con 95% de aciertos. La base solo ve 500 consultas por segundo y soporta 1.000. Entonces Redis se reinicia y vuelve vacío. Por un momento, las 10.000 lecturas caen sobre la base, diez veces lo que aguanta, hasta que el cache se caliente. Por eso hay que planear el cache frío: calentarlo antes de enviar tráfico, limitar las solicitudes o dejar holgura de capacidad en la base.

¿Qué vale la pena guardar en cache?

  • Bueno: dato que se lee muchas más veces de las que cambia, caro de construir y que puede tener unos instantes de retraso (la página de un catálogo de productos).
  • Malo: dato que cambia en cada solicitud, dato distinto en cada solicitud y dato que debe ser exacto en el momento de usarlo.
↑ Volver al índice

Estrategias: cache-aside, read-through, write-through, write-behind

Ahora tienes un cache y una base. Surgen dos preguntas. En la lectura: ¿quién llena el cache? En la escritura: ¿quién cambia primero y cuándo le decimos "listo" al usuario? Cada estrategia de cache es una respuesta a esas preguntas.

Lectura: cache-aside y read-through

Imagina que le pides un libro a un ayudante. En el cache-aside (también llamado lazy loading) no hay ayudante: tú mismo miras en el estante y, si el libro no está, vas a la biblioteca, lo traes y pones una copia en el estante. El código de la aplicación hace los tres pasos. Solo entra al cache el dato que de verdad se pidió.

def get_user(id):
    value = cache.get("user:" + id)      # 1. mirar en el cache
    if value is not None:
        return value                      # acierto
    value = db.query("SELECT ... WHERE id = ?", id)  # 2. fallo: preguntar a la base
    cache.set("user:" + id, value, ttl=300)         # 3. llenar el cache
    return value
Cache-aside: la aplicación hace los tres pasos

En el read-through sí tienes un ayudante. Solo le pides la clave al cache. Si no está, la capa de cache (o su biblioteca) llama a una función cargadora para traer el valor de la fuente. Las dos estrategias cargan en el mismo momento, en un fallo. La diferencia es quién es responsable: el código de la aplicación (cache-aside) o la capa de cache (read-through).

El cache-aside es excelente cuando hay muchas más lecturas que escrituras, como un catálogo de productos leído miles de veces por segundo y modificado unas pocas veces al día. También sigue funcionando si el cache se cae: la app simplemente lee la base, más despacio.

Escritura: cuatro formas

Estrategias de escritura
EstrategiaQué hace la escrituraEl usuario oye "listo" después deVentajaCosto
Write-throughEscribe la base y el cacheQue ambos se actualicenLa siguiente lectura es un acierto con dato nuevoEscritura más lenta; el cache puede guardar datos que nadie lee
Write-behind (write-back)Escribe solo el cache; la base despuésSolo el cacheMuy rápido; muchas escrituras se vuelven pocasUn dato confirmado puede perderse si el cache muere antes del flush
Write-aroundEscribe solo la baseLa baseEl cache no se llena de datos que nadie leeLa primera lectura después de escribir siempre es un fallo
Escritura en cache-asideActualiza la base y luego borra la claveBase y borradoLa siguiente lectura carga el valor nuevoLa primera lectura después de escribir es un fallo

Write-behind en detalle

En el write-behind, un worker envía los cambios pendientes a la base cada pocos segundos. Si la misma clave cambió varias veces entre dos flushes, solo se envía el último valor. Esto se llama coalescencia (coalescing). Supón que el flush corre cada 10 s y llegan estas escrituras:

0-10 s : A=9, B=4, A=8, A=7     flush a los 10 s -> UPDATE A=7, UPDATE B=4
10-20 s: B=3, A=6, B=2          flush a los 20 s -> UPDATE A=6, UPDATE B=2
20-30 s: C=5, D=1               flush a los 30 s -> UPDATE C=5, UPDATE D=1

9 escrituras en el cache -> 6 actualizaciones en la base
Varias escrituras a una misma clave en un intervalo se vuelven una sola actualización

Esto hace al write-behind perfecto para cosas como contadores de vistas: miles de incrementos por segundo, y perder unos segundos de cuenta no importa. Es incorrecto para dinero o pedidos.

Hay dos peligros. Primero, entre flushes el cache está adelantado respecto de la base. Un reporte que lee la base directamente ve el valor viejo. Segundo, si el cache pierde su memoria antes del flush, toda escritura que a los clientes se les dijo que se guardó desaparece. Una escritura hecha directo en la base entre dos flushes también es sobrescrita por el siguiente flush, sin error para nadie.

Por qué el cache-aside borra en lugar de guardar

Cuando el dato cambia, el cache-aside normalmente actualiza la base y luego borra la clave. ¿Por qué no guardar el valor nuevo? Porque dos escritores pueden llegar a la base en un orden y al cache en el orden contrario, y el valor más viejo ganaría en el cache. Borrar no lleva valor, así que no puede estar equivocado: quien borre al final, la clave desaparece y la siguiente lectura carga la verdad.

El write-through tiene este problema. Con dos escritores y sin lock, la base puede terminar con 3 mientras el cache termina con 4, el valor que se confirmó primero. Sin tiempo de vida, queda mal hasta la siguiente escritura. Otro problema: la base y el cache son dos sistemas sin transacción en común. Si la base confirma y la actualización del cache falla, el cache queda viejo. El orden habitual es escribir primero la fuente de la verdad.

Refresh-ahead

El refresh-ahead recarga una entrada popular en segundo plano justo antes de que expire, para que los lectores nunca esperen un fallo. El riesgo es gastar trabajo en entradas que nadie vuelve a pedir, por eso suele aplicarse solo a claves usadas recientemente.

↑ Volver al índice

Invalidación y tiempo de vida

Un cartón de leche tiene una fecha impresa. Pasada la fecha, lo tiras, aunque se vea bien. Un cache hace lo mismo con un TTL, el tiempo de vida (time to live): cuántos segundos puede quedarse una entrada. Otra forma es tirar el cartón apenas sabes que la tienda tiene leche nueva. Eso es invalidación: quitar o reescribir una entrada cuando el origen cambia.

Un TTL no vuelve correcto al dato. Solo limita por cuánto tiempo puede estar equivocado. Con un TTL de 60 s, una copia puede ir hasta 60 s atrasada. Por eso el TTL se mantiene como red de seguridad incluso cuando existe invalidación explícita.

La perilla del TTL

Subir el TTL cambia frescura por velocidad
TTLTasa de aciertosFrescuraCarga en el origen
Corto (30 s)MenorMás frescoMayor
Largo (10 min)MayorMás viejoMenor

Comandos de Redis para la expiración

Redis es un almacén en memoria muy popular, usado como cache. Estos son los comandos y reglas que debes conocer:

  • SET key value EX 300 guarda un valor que expira en 300 segundos.
  • TTL key devuelve los segundos que quedan. -1 significa que la clave existe pero no tiene expiración. -2 significa que la clave no existe.
  • EXPIRE key seconds fija el plazo contando desde ahora. Si la clave ya tenía uno, el nuevo lo reemplaza. Ejemplo: SET k v EX 60 en t = 0 y EXPIRE k 100 en t = 20 dan TTL = 70 en t = 50.
  • Desde Redis 7.0, EXPIRE tiene condiciones: NX (solo si no hay expiración), XX (solo si hay), GT (solo si el nuevo tiempo es mayor) y LT (solo si es menor). Una clave sin expiración cuenta como infinita para GT y LT.
SET cart:7 "a" EX 300
SET cart:7 "b"        # cambia el valor Y borra la expiración
TTL cart:7             # -> -1  (¡nunca expira!)

SET cart:7 "b" KEEPTTL # conserva el plazo anterior
Un SET simple borra el TTL

Cómo elimina Redis las claves expiradas

Redis lo hace de dos maneras. La forma pasiva: cuando un cliente pide una clave expirada, Redis la borra en ese momento. La forma activa: cada cierto tiempo, Redis prueba algunas claves al azar que tienen TTL y borra las expiradas. Así, una clave expirada nunca se devuelve, pero puede seguir usando memoria un tiempo después de su plazo.

Expiración absoluta y deslizante

Con la expiración absoluta, la entrada muere un tiempo fijo después de escribirse. Con la expiración deslizante (sliding), cada lectura renueva el plazo (por ejemplo GETEX key EX 60). La deslizante sirve bien para sesiones. Pero si una clave se lee cada 10 s con un TTL deslizante de 60 s, nunca expira y se puede servir un valor viejo para siempre. Para datos que cambian en el origen, añade invalidación explícita o una vida máxima absoluta.

Cosas difíciles de la invalidación

  • Una fila, muchas claves. Un producto puede estar en product:5, en una lista category:2:page:1 y en un resultado de búsqueda search:shoes. Si el precio cambia y el código borra solo product:5, la lista y la búsqueda conservan el precio viejo hasta expirar. El cache no sabe qué entradas vienen de qué datos.
  • Cache negativo. Guardar la respuesta "no existe" evita repetir una consulta inútil. Pero si el usuario 77 se crea dos minutos después de guardar "no encontrado" por 24 h, los lectores siguen viendo "no encontrado". Dale a estas entradas un TTL corto (segundos o minutos) y bórralas al crear el elemento cuando sea posible.
  • Muchas instancias con caches locales. Con un TTL fijo de 30 s y sin mensajes, dos instancias pueden discrepar hasta 30 s. Publica un mensaje de invalidación a todas las instancias y mantén el TTL como red de seguridad.
  • Buscar claves. KEYS product:* recorre todas las claves en un solo comando (costo O(N)) y bloquea a los demás clientes. Usa SCAN, que avanza con un cursor y devuelve unas pocas claves por llamada, o mantén un conjunto con las claves de cada grupo.

Dos trucos útiles

Claves versionadas. Pon una versión en los nombres: cat:v{N}:..., donde N está en cat:version. Para invalidar todo el catálogo, ejecuta INCR cat:version. Nadie vuelve a armar los nombres antiguos, así que todo el grupo queda inválido de una vez. Las claves viejas aún ocupan memoria, por eso necesitan un TTL.

Soft purge. Un purge duro borra la entrada y la siguiente solicitud espera al origen. Un soft purge solo la marca como vieja: se sigue sirviendo un rato corto mientras una actualización corre en segundo plano. La latencia se mantiene estable y el origen sufre menos presión.

↑ Volver al índice

Políticas de descarte: LRU, LFU, FIFO

Tu estuche es pequeño. Cuando está lleno y recibes un lápiz nuevo, uno tiene que salir. Descarte (eviction) es cuando el cache quita entradas porque la memoria se llenó. No lo confundas con la expiración, que quita una entrada porque su tiempo terminó. Una entrada descartada todavía puede estar fresca, y una expirada sale aunque sobre memoria.

La regla que elige quién sale es la política de descarte. Aquí hay tres clásicas.

PolíticaQuién saleApuesta que haceQué registra
LRU (menos recientemente usado)Aquel cuyo último uso es el más antiguoLo que se usó hace poco se volverá a usar prontoEl orden de uso
LFU (menos frecuentemente usado)Aquel con menos usosLo que se usó mucho seguirá usándoseUn contador por entrada
FIFO (primero en entrar, primero en salir)Aquel que entró primeroNinguna: es solo una filaEl orden de llegada

Simulando el LRU a mano

Mantén las claves en una fila, de la menos a la más reciente. Un acierto mueve la clave al final. Un fallo con el cache lleno quita la primera clave de la fila. Cache con espacio para 3, accesos A B C A D B A E:

A          -> [A]
B          -> [A B]
C          -> [A B C]
A (acierto)-> [B C A]
D          -> [C A D]    sale B
B          -> [A D B]    sale C
A (acierto)-> [D B A]
E          -> [B A E]    sale D

Final: A, B, E
LRU con 3 lugares (izquierda = menos reciente)

El FIFO es distinto del LRU

En el FIFO un acierto no cambia la posición. Con espacio para 3 y accesos A B C A B D, la fila es [A B C]. Los aciertos en A y B no cambian nada, así que D quita a A, la llegada más antigua. En el LRU la víctima habría sido C. Aunque A acabe de usarse, el FIFO la saca.

Compara en la secuencia A B C A D A E con 3 lugares: el LRU tiene 5 fallos y 2 aciertos. El FIFO tiene 6 fallos y 1 acierto, porque sacó a A mientras estaba en uso y tuvo que cargarla de nuevo.

Simulando el LFU a mano

Cada acceso suma 1 a un contador (la primera inserción cuenta como uno). En un fallo con el cache lleno, sale el contador más bajo, y los empates se resuelven con el menos recientemente usado. Con 3 lugares y accesos A A B C B D E: A=2, B=2, C=1, luego llega D y sale C (el más bajo); después llega E y sale D (cuenta 1). Las claves finales son A, B, E. Las claves nuevas empiezan con cuenta 1, así que son las primeras en descartarse.

Un barrido y una estrella antigua

Imagina 20 claves calientes leídas miles de veces y luego un reporte que lee 500 claves frías distintas, una vez cada una. En el LRU las claves frías empujan a las calientes fuera, porque la recencia no distingue "leída una vez" de "leída todo el tiempo". En el LFU las calientes sobreviven porque sus contadores son altos. El precio del LFU aparece en el caso contrario: una clave que fue popular hace meses conserva su contador alto y nunca sale. Por eso los LFU reales hacen que los contadores disminuyan con el tiempo.

Cómo construir un LRU rápido

Queremos get y put en O(1). Usa dos estructuras juntas. Un mapa hash encuentra la clave al instante y apunta a su lugar en la lista. Una lista doblemente enlazada guarda el orden de recencia; con punteros al nodo anterior y al siguiente, podemos soltar un nodo y ponerlo al final sin recorrer la lista. La víctima siempre es el nodo del otro extremo.

Descarte en Redis

maxmemory fija el límite de memoria y maxmemory-policy dice qué hacer al alcanzarlo. El nombre de una política tiene dos partes: el prefijo dice quién puede ser víctima (allkeys para cualquier clave, volatile solo para claves que tienen expiración) y el sufijo dice el criterio (lru, lfu, random o ttl).

  • noeviction: los comandos que agregarían datos fallan con un error; las lecturas siguen funcionando. Bueno cuando Redis guarda datos que no pueden desaparecer.
  • allkeys-lru y allkeys-lfu: la elección habitual para un cache puro.
  • volatile-lru: solo las claves con TTL pueden descartarse. Correcto cuando una instancia mezcla claves permanentes (configuración) y entradas de cache. Si ninguna clave tiene TTL, se comporta como noeviction. Dos instancias separadas son mejores cuando es posible.
  • maxmemory-samples: Redis no mantiene un LRU exacto. Examina una muestra aleatoria de claves (5 por defecto) y descarta la más antigua entre ellas. Subirlo a 10 se acerca más al LRU verdadero, con un poco más de CPU.
↑ Volver al índice

Estampida, penetración y avalancha

Los caches normalmente protegen a la base de datos. Pero hay tres momentos famosos en que el cache deja de protegerla y una multitud de solicitudes cae sobre el origen de golpe. Los nombres asustan, pero las ideas son simples.

1. Estampida de cache (cache stampede, thundering herd, dogpile)

Una clave muy popular expira. Hasta que alguien escribe una copia nueva, toda solicitud ve un fallo y ejecuta la misma consulta cara. El dato existe y es válido. El problema es la concurrencia en la ventana de recálculo. El tamaño de la manada es la tasa de solicitudes multiplicada por el tiempo de reconstrucción.

Ejemplo: la clave home:feed expira, 300 solicitudes llegan en los siguientes 200 ms y reconstruir toma 2 s. Sin protección, se ejecutan 300 consultas. Con un lock, solo 1.

Solución A: un lock

En un fallo, la solicitud intenta crear un lock con un comando atómico: SET lock:home:feed token NX PX 5000. NX significa "solo si aún no existe" y PX 5000 significa "expira en 5000 ms". Solo una solicitud recibe OK. Esa solicitud consulta la base, guarda el valor y libera el lock. Las demás esperan un poco y leen el cache de nuevo.

  • El lock tiene expiración, así que si quien lo tiene se cae, nadie espera para siempre.
  • El lock guarda un token único. Para liberarlo comparamos el token y borramos solo si sigue siendo el nuestro, y esa comprobación y el borrado deben ocurrir juntos (un script ejecutado por el servidor). Si no, una solicitud lenta, cuyo lock ya expiró, podría borrar el lock que otra acaba de obtener.
  • La expiración es una apuesta sobre cuánto tarda el trabajo. Si la reconstrucción tarda 8 s y el lock dura 5 s, una segunda solicitud empieza a reconstruir hacia los 5 s y parte de la protección se pierde. Elige una expiración con margen de seguridad o renueva el lock mientras trabajas.
  • Después de ganar el lock, revisa el cache de nuevo: el ganador anterior puede haber guardado el valor justo antes.

Solución B: actualizar antes de expirar

El refresh anticipado (refresh-ahead) reconstruye el valor mientras el viejo aún se está sirviendo, así que nunca hay un momento sin copia. Una versión es la expiración anticipada probabilística: cada solicitud puede decidir, al azar, actualizar antes, con una probabilidad que crece a medida que se acerca el plazo, así que casi siempre solo una solicitud lo hace, sin lock.

Otra versión divide la expiración en dos. La entrada guarda { value, logicalExpiry }. El plazo lógico (digamos 5 minutos después de escribir) dice cuándo actualizar. El TTL físico en Redis (digamos 10 minutos) dice cuándo Redis lo borra. En el minuto 6 la entrada está vieja pero sigue ahí: la primera solicitud toma el lock de actualización y la segunda recibe el valor viejo de inmediato mientras la primera actualiza.

escritura        expiración lógica     TTL físico
 |-----------------|--------------------|
 0 min            5 min               10 min
  fresca           vieja pero servida,  borrada
                   una solicitud actualiza
Dos plazos: cuándo actualizar y cuándo borrar

Lo que NO es una solución

Subir el TTL de 60 s a una hora no detiene la estampida. El tamaño de la manada depende de la tasa de solicitudes y del tiempo de reconstrucción, no del TTL. El evento se vuelve más raro y el dato más viejo, pero cada vez es tan grave como antes. Otra trampa: el single flight dentro de cada proceso (las cargas concurrentes de una clave comparten una sola consulta) solo funciona dentro de un proceso. Con 8 instancias aún puede haber hasta 8 consultas por expiración. Para llegar a una sola, hace falta un lock compartido por todas las instancias.

Consultas a la base tras una expiración, 300 lectores (medido en el laboratorio de este repositorio)
Sin protección
299 consultas
Lock
1 consultas
Refresh anticipado
1 consultas

2. Penetración de cache

Aquí un cliente sigue pidiendo cosas que no existen en ningún lugar, como /users/-1 o /users/99999999. El cache nunca tiene nada que guardar, así que toda solicitud llega a la base. Puede ser un bug o un ataque. Tres defensas:

  • Validar la entrada y rechazar de inmediato los ids imposibles.
  • Guardar el resultado vacío con un TTL corto (cache negativo).
  • Poner un filtro de Bloom delante. Es una estructura compacta que responde "¿esto está en el conjunto?" con error en un solo sentido. "Ausente" siempre es verdad. "Quizá presente" a veces puede estar equivocado. Así se detienen los pedidos de ids que ciertamente no existen, y los pocos falsos positivos cuestan solo una consulta.

3. Avalancha de cache

Una avalancha ocurre cuando muchas claves quedan no disponibles a la vez. Causa 1: un job llena el cache a las 03:00 con 200.000 claves, todas con TTL de 1 h, y a las 04:00 todas expiran de golpe. Causa 2: el servidor de cache se cae.

Para la causa 1, añade jitter: un valor aleatorio en cada TTL. Una base de 600 s con jitter de ±10% da TTL de 540 s a 660 s, así que 200.000 claves escritas juntas expiran repartidas en 120 s en lugar de todas en el mismo segundo. Calienta también el cache poco a poco. Para la causa 2, da alta disponibilidad al cache y protege el origen con un circuit breaker y límite de solicitudes.

ProblemaQué pasaDefensas principales
EstampidaUna clave caliente expira; muchas solicitudes la reconstruyenLock, refresh anticipado, expiración probabilística
PenetraciónLos pedidos de claves que no existen siempre llegan a la baseValidación de entrada, cache negativo corto, filtro de Bloom
AvalanchaMuchas claves expiran juntas, o el cache desapareceJitter en el TTL, calentamiento gradual, alta disponibilidad, límite de solicitudes
↑ Volver al índice

Encabezados de cache HTTP

Cuando un servidor envía una página web, puede adjuntar pequeñas notas llamadas encabezados (headers). Algunos encabezados les dicen a los navegadores y a las CDN cómo hacer cache. Las reglas están escritas en los estándares RFC 9111 (cache HTTP) y RFC 5861 (contenido viejo).

Un cache usa el método más la URL como clave básica. Una respuesta está fresca mientras su tiempo de frescura sea mayor que su edad actual. Una respuesta vieja (stale) no se tira: el cache puede validarla, es decir, preguntarle al origen "¿esto cambió?".

Directivas de Cache-Control

DirectivaSignificado
max-age=NFresca por N segundos, en cualquier cache
s-maxage=NComo max-age, pero solo para caches compartidos (CDN, proxy). Allí gana sobre max-age y sobre Expires
no-storeNo guardes la respuesta en absoluto
no-cachePuedes guardarla, pero valida antes de cada reutilización. Si el origen responde 304, no se vuelve a descargar el cuerpo
privateSolo un cache privado (el navegador) puede guardarla
publicCualquier cache puede guardarla, incluso en casos donde normalmente no se permitiría
immutableNo cambiará mientras esté fresca, así que ni siquiera revalides al recargar
must-revalidateNunca sirvas una copia vieja sin una validación exitosa
stale-while-revalidate=NDurante N segundos después de que termina la frescura, sirve la copia vieja de inmediato y revalida en segundo plano
stale-if-error=NSirve la copia vieja si el origen falla (errores 500, 502, 503, 504)

Navegador y CDN pueden tener tiempos distintos

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=60, s-maxage=600
La CDN la guarda 600 s, el navegador 60 s

La CDN (cache compartido) usa s-maxage, es decir, 600 s. El navegador (cache privado) lo ignora y usa max-age, es decir, 60 s. Esto es útil porque el dueño del sitio puede limpiar (purge) una CDN, pero no puede entrar al navegador de cada visitante.

De dónde sale el tiempo de frescura

Un cache toma el primero de estos que exista, en este orden:

  1. s-maxage (solo caches compartidos)
  2. max-age
  3. Expires menos Date
  4. Una estimación heurística, normalmente una fracción del tiempo desde Last-Modified (10% es típico, pero no es una regla)

Así, con Cache-Control: max-age=120 y Expires una hora después, un cache privado usa 120 s: max-age gana sobre Expires.

Age: contar la edad de una copia

El encabezado Age dice cuánto tiempo ya estuvo la respuesta en caches anteriores. La edad actual es esa edad inicial más el tiempo que la respuesta lleva en este cache. El tiempo pasado en otros caches se descuenta de la frescura.

HTTP/1.1 200 OK
Date: Tue, 06 Oct 2026 12:00:00 GMT
Cache-Control: max-age=300
Age: 120
Llega a las 12:02:00

El navegador la recibe con edad 120 y un tiempo de frescura de 300, así que sigue fresca por 300 - 120 = 180 segundos más. Otro ejemplo: un cache compartido guarda max-age=600, s-maxage=120 con Age: 30. Después de 100 s en el cache, la edad es 30 + 100 = 130 y el tiempo de frescura es 120 (s-maxage), así que ya está vieja.

Validación: preguntar "¿cambió?"

Un validador da nombre a una versión del contenido. El ETag es una etiqueta opaca. El Last-Modified es una fecha. Una copia vieja se valida así:

cache  -> origen : GET /logo.png
                    If-None-Match: "v7"
origen -> cache  : 304 Not Modified   (sin cuerpo)
                   -> reutiliza mi copia, actualiza sus encabezados
   o
origen -> cache  : 200 OK + cuerpo nuevo  -> reemplaza la copia
Solicitud condicional

Con Last-Modified, la solicitud usa If-Modified-Since. Ante un 304, el cache mezcla los encabezados nuevos en la respuesta guardada (que vuelve a estar fresca) y sirve el cuerpo que ya tenía. Un ETag que empieza con W/ es débil: significa "equivalente", no "idéntico byte a byte".

Vary y Authorization

La misma URL puede devolver cosas distintas, por ejemplo comprimida o no. Vary: Accept-Encoding le dice al cache: reutiliza esta respuesta solo para solicitudes cuyo Accept-Encoding coincida con el original. Sin Vary, un cliente que no acepta gzip podría recibir una página comprimida.

Una solicitud con el encabezado Authorization suele ser sobre un solo usuario. Un cache compartido no debe reutilizar esa respuesta, a menos que el origen lo permita explícitamente con public, s-maxage o must-revalidate. Por ejemplo, Cache-Control: public, max-age=300 deja que el proxy la reutilice. Usa esto solo cuando la respuesta sea igual para todos los usuarios autorizados.

Vieja a propósito

Con Cache-Control: max-age=600, stale-while-revalidate=30, una solicitud que llega cuando la copia tiene 615 s (15 s después de terminar la frescura, dentro de la ventana de 30 s) puede recibir la copia vieja de inmediato, mientras el cache revalida en segundo plano. El usuario no espera.

Una receta para el despliegue

Un sitio tiene index.html (siempre la misma URL) y app.3f9a1c.js (el nombre incluye un hash del contenido y cambia en cada despliegue). El script nunca cambia con ese nombre, así que puede guardarse un año. El HTML siempre debe verificarse, porque es el que apunta al nuevo nombre del script.

ArchivoCache-ControlPor qué
index.htmlno-cacheSiempre revalidar; apunta a los nuevos nombres de archivo
app.3f9a1c.jsmax-age=31536000, immutableNunca cambia con este nombre; un año, sin revalidar ni siquiera al recargar
↑ Volver al índice

Estructuras de datos y persistencia de Redis

Redis es una base de datos que vive en memoria (RAM), lo que la hace muy rápida. Es más que una caja de texto: cada clave puede guardar un tipo de dato distinto, y cada tipo tiene comandos hechos para él. Usar el tipo correcto hace que trabaje el servidor, en lugar de tu código.

Tipos comunes de Redis
TipoQué esUso típicoComandos de ejemplo
StringTexto o númeroUna página en cache, un contadorSET, GET, INCR
ListElementos en orden, se agrega y quita en ambos extremosColasLPUSH, RPOP
HashUna pequeña tabla de camposUn objeto, como escrituras pendientesHSET, HGET
SetElementos únicos, sin ordenEtiquetas, quién ya votóSADD, SISMEMBER
Sorted setMiembros únicos, cada uno con una puntuación; se mantienen en ordenRankingsZADD, ZRANGE
HyperLogLogUn resumen diminuto que estima cuántos elementos distintos hayVisitantes únicosPFADD, PFCOUNT

Ejemplos

  • Ranking: mostrar los 10 mejores jugadores. Un sorted set mantiene los miembros ordenados por puntos. ZADD inserta o actualiza; ZRANGE ... REV devuelve desde la puntuación más alta.
  • Contador: INCR visits sobre una clave que no existe la trata como 0, guarda 1 y devuelve 1.
  • Visitantes distintos: decenas de millones por día, un error de cerca de 1% es aceptable, la memoria debe seguir pequeña. Un HyperLogLog guarda solo un resumen probabilístico de tamaño acotado. El precio: no puedes listar los visitantes ni preguntar si uno fue visto. Para eso necesitas un set.
LPUSH jobs a   # lista: [a]
LPUSH jobs b   # lista: [b, a]
LPUSH jobs c   # lista: [c, b, a]
RPOP jobs      # saca por la derecha -> a   (lista: [c, b])
Una cola: entra por la izquierda, sale por la derecha (primero en entrar, primero en salir)

Comandos atómicos

Redis ejecuta un comando completo antes del siguiente. Así que cada comando es atómico. Pero una secuencia de comandos enviada por un cliente no lo es. Dos clientes haciendo lo siguiente con counter = 10:

v = GET counter     # leer
v = v + 1           # calcular en la app
SET counter v       # escribir
Leer, calcular, escribir: es posible una actualización perdida

Ambos pueden leer 10 y ambos escriben 11, así que el valor final es 11 o 12. Se perdió un incremento. La solución es hacer toda la operación en el servidor: INCR, o MULTI/EXEC con WATCH, o un script Lua cuando hay varios comandos.

Persistencia: ¿qué sobrevive a un reinicio?

La memoria se borra cuando se va la luz. Redis puede escribir a disco de dos maneras, o de ambas, o de ninguna.

AspectoRDB (snapshot)AOF (append-only file)
Qué guardaUna foto compacta de todos los datos en un instante, cada cierto tiempoUn registro de cada escritura, que se repite al reiniciar
ArchivoPequeño, carga rápidoMás grande
Pérdida en una fallaTodo lo posterior al último snapshotMucho menos
UsoRespaldos; un cache donde perder minutos no importaDatos que importan

Ejemplo: solo RDB. El último snapshot es de las 12:00:00, el siguiente sería a las 12:05:00, y a las 12:03:40 el proceso es eliminado. Al reiniciar, Redis vuelve a las 12:00:00 y pierde 3 min 40 s de escrituras.

AOF y fsync

Escribir en un archivo no es lo mismo que estar a salvo: el sistema operativo retiene los datos en memoria hasta un fsync. appendfsync elige la frecuencia:

  • always: sincroniza después de cada lote de comandos. El más seguro, el más lento.
  • everysec: una vez por segundo. El valor por defecto. Si se va la luz, puede perderse cerca del último segundo de escrituras.
  • no: decide el sistema operativo.

Si RDB y AOF están activados, al reiniciar Redis reconstruye los datos desde el AOF, porque es el más completo. Combinar ambos se recomienda cuando los datos importan.

Snapshots y memoria

Para tomar un snapshot sin detenerse, Redis hace un fork: un proceso hijo ve la memoria como era en ese instante y la escribe en un archivo temporal, que reemplaza al anterior al final. El sistema comparte páginas de memoria entre padre e hijo y copia una página solo cuando alguien la modifica. En una instancia con muchas escrituras se copian muchas páginas, así que el uso de memoria puede crecer bastante durante el BGSAVE, mucho más que el tamaño de los datos.

Persistencia no es replicación

El fsync protege cuando un proceso o máquina vuelve con el mismo disco. Una réplica en otra máquina protege contra perder la máquina. Pero la replicación de Redis es asíncrona por defecto: un cliente puede recibir OK mientras la escritura existe solo en el primario. Si la máquina del primario se destruye justo entonces y se promueve la réplica, esa escritura se pierde, incluso con appendfsync always.

¿Un cache necesita persistencia?

Si Redis es un cache puro delante de PostgreSQL con cache-aside, toda escritura va primero a PostgreSQL. Un reinicio solo significa un cache frío. Pero con write-behind, Redis guarda brevemente la única copia de algunas escrituras. Un reinicio las pierde. Entonces ya no es solo un cache y necesita persistencia y replicación.

↑ Volver al índice

Compromisos de consistencia

Un cache cambia frescura por velocidad. Responde con una copia sin preguntarle a la fuente. Una lectura que devuelve un valor en cache más viejo que el valor actual en la base es una lectura desactualizada (stale read).

¿Por qué no podemos actualizar la base y el cache en un solo paso? Son dos sistemas separados. Una transacción normal de base de datos cubre solo la base, no Redis. Entre las dos escrituras siempre hay un pequeño tiempo en que discrepan, y una falla en ese tiempo puede hacer que la diferencia dure. Decimos que son eventualmente consistentes: coinciden más tarde, tras la invalidación o el TTL, no en el mismo instante.

La carrera en el cache-aside

La entrada del cache acaba de expirar. El precio en la base es 10. Sigue a los dos actores:

tiempo Lector R                 Escritor W
t1     GET price:7 -> fallo
t2     SELECT price -> 10
t3                                 UPDATE price = 12 (commit)
t4                                 DEL price:7
t5     SET price:7 = 10
Un lector con el valor viejo lo guarda después del borrado

Al final la base tiene 12 y el cache tiene 10. El borrado llegó demasiado pronto y no viene otra invalidación en camino. El valor viejo se queda hasta que termine el TTL, y por eso el TTL es la red de seguridad.

Orden de los pasos

Una escritura puede ser (A) actualizar la base y luego borrar la clave, o (B) borrar la clave y luego actualizar la base. Se prefiere el orden A. En B, la ventana dura toda la escritura en la base, y cualquier lectura que falle dentro de ella devuelve el valor viejo al cache. En A, el problema requiere un lector lento que leyó la base antes del commit y escribe en el cache después del borrado. Eso es raro, porque las lecturas suelen ser más rápidas que las escrituras.

Técnicas que reducen valores viejos

Ninguna hace atómicos al cache y a la base. Reducen la probabilidad o la duración del dato viejo:

  • Delayed double delete (doble borrado retrasado): borra la clave, actualiza la base, espera un poco y borra la clave otra vez, para eliminar lo que lectores lentos escribieron mientras tanto.
  • Versiones: guarda una versión con cada valor y rechaza escrituras más antiguas.
  • Invalidación desde el log de cambios: un proceso aparte observa los cambios de la base y borra las claves, así la aplicación no tiene que acordarse.
  • TTL corto: limita el peor caso.

Réplicas que se atrasan

Supón que las escrituras van al primario y los fallos del cache se llenan desde una réplica atrasada 2 s. El escritor confirma y borra la clave en t = 0. En t = 0,5 s un lector falla, lee la réplica (que aún dice 'Ana') y la guarda con TTL de 600 s. En t = 2 s la réplica por fin recibe la actualización. El cache conserva 'Ana' unos 600 s. Soluciones: llenar el cache desde el primario después de una escritura, retrasar la invalidación más allá del atraso de la réplica, o invalidar desde el log de cambios.

Escritores concurrentes y write-through

Dos escritores usan write-through, sin lock ni versión. A confirma 4 en la base primero, luego B confirma 3. Pero la escritura de B en el cache llega antes que la de A. La base termina con 3 y el cache con 4, la escritura más vieja. Queda mal hasta la siguiente escritura. Borrar la clave, o usar una versión, evita esto.

Write-behind y escrituras directas en la base

En el write-behind el cache guarda la versión más nueva y la base va atrasada. Si un script actualiza la base directamente entre dos flushes, el cache no lo ve, y el siguiente flush sobrescribe el cambio del script: una actualización perdida (lost update), sin error. El write-behind solo funciona si toda escritura pasa por el cache.

Read-your-own-writes

La inconsistencia más molesta es cuando guardas un formulario y la pantalla siguiente muestra el valor viejo. Read-your-own-writes (leer tus propias escrituras) promete que un usuario que acaba de escribir ve esa escritura en sus siguientes lecturas. Una forma común: durante un tiempo corto después de la escritura, las lecturas de ese usuario se saltan el cache o van a la base primaria. Los demás aún pueden ver datos un poco más viejos.

¿Qué puede estar viejo?

Pregunta: ¿qué pasa si actúo con un valor atrasado? Un contador de vistas o una lista de productos pueden tener unos segundos de retraso sin daño. El saldo de una cuenta usado para autorizar un retiro no puede estar viejo, o se podría pagar dinero que no existe. Esas lecturas deben ir a la fuente, dentro de la misma transacción que toma la decisión.

Dato¿Puede estar viejo?Dónde leer
Contador de vistas, listados, recomendacionesSí, por segundosCache
Saldo antes de un retiro, stock al comprar, permisosNoLa base, en la misma transacción
↑ Volver al índice

Juntando todo

Ya viste las piezas. Aquí está cómo encajan, como una lista de verificación para cualquier diseño de cache.

PreguntaHerramientas que aprendiste
¿Vale la pena un cache?Medir la tasa de aciertos; calcular la latencia promedio; revisar el punto de equilibrio
¿Dónde vive la copia?Navegador, CDN, en el proceso, Redis, buffer pool
¿Quién la llena y quién la escribe?Cache-aside, read-through, write-through, write-behind, write-around, refresh-ahead
¿Cómo sale una copia vieja?TTL, invalidación, claves versionadas, soft purge
¿Y si la memoria se llena?Descarte: LRU, LFU, FIFO, políticas maxmemory de Redis
¿Y si todos fallan a la vez?Lock, refresh anticipado, jitter, filtro de Bloom, cache negativo
¿Cómo hacen cache navegadores y CDN?Cache-Control, ETag, 304, Vary, stale-while-revalidate
¿Qué sobrevive a un reinicio?RDB, AOF, replicación
¿Qué tan equivocado puede estar?Consistencia eventual, ventana del TTL, read-your-own-writes

Errores comunes

  • Pensar que no-cache significa "nunca guardar". Significa "validar primero".
  • Escribir una clave con SET y olvidar que borró el TTL.
  • Usar solo un TTL más largo para detener una estampida.
  • Guardar páginas personales en un cache compartido.
  • Usar write-behind para datos que no pueden perderse.
  • Invalidar product:5 y olvidar las listas y búsquedas que lo contienen.
  • Ejecutar KEYS * en un Redis grande de producción.
  • Añadir un cache sin medir la tasa de aciertos.
↑ Volver al índice