Por Qué Importa el Caché
El caché es la práctica de almacenar copias de datos frecuentemente accedidos en una capa de almacenamiento de alta velocidad para que las solicitudes futuras de esos datos puedan servirse más rápido. En el diseño de sistemas, el caché es una de las herramientas más efectivas para mejorar el rendimiento y reducir la carga en sistemas downstream.
Considera una aplicación web típica: cada carga de página podría requerir 10-20 consultas a la base de datos. Si tu sitio tiene 10,000 usuarios concurrentes, eso significa 100,000-200,000 consultas por ciclo de carga de página. Una capa de caché bien diseñada puede reducir la carga de la base de datos en un 80-95%.
Caché en Cada Capa
- Caché del Navegador: Los navegadores cachean recursos estáticos (CSS, JS, imágenes) localmente basándose en cabeceras HTTP de caché.
- Caché CDN: Los CDNs cachean contenido en ubicaciones edge geográficamente cercanas a los usuarios.
- Caché de Aplicación: Cachés en memoria como Redis o Memcached almacenan resultados computados, datos de sesión y objetos frecuentes.
- Caché de Base de Datos: Los cachés de consultas de base de datos y buffer pools cachean páginas de datos frecuentemente accedidas en memoria.
- Caché de CPU: Los cachés L1/L2/L3 en el procesador almacenan direcciones de memoria frecuentemente accedidas.
Patrones de Caché
Cómo tu aplicación interactúa con el caché y la base de datos se define por el patrón de caché que elijas. Cada patrón tiene diferentes compensaciones en términos de consistencia, complejidad y rendimiento.
1. Cache-Aside (Carga Perezosa)
En el patrón cache-aside, la aplicación es responsable de leer y escribir tanto en el caché como en la base de datos. El caché no interactúa con la base de datos directamente.
Flujo de lectura: Verifica el caché primero. Si los datos se encuentran (cache hit), retórnalos. Si no (cache miss), lee de la base de datos, almacena el resultado en el caché y retórnalo.
Flujo de escritura: Escribe en la base de datos y luego invalida (elimina) la entrada del caché para que la siguiente lectura obtenga datos frescos.
// Cache-Aside Pattern Implementation
class UserService {
private cache: Redis;
private db: Database;
async getUser(userId: string): Promise<User> {
// Step 1: Check the cache
const cached = await this.cache.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached); // Cache hit
}
// Step 2: Cache miss - read from database
const user = await this.db.query('SELECT * FROM users WHERE id = $1', [userId]);
// Step 3: Store in cache with TTL
await this.cache.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
async updateUser(userId: string, data: Partial<User>): Promise<void> {
// Step 1: Update the database
await this.db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
// Step 2: Invalidate the cache
await this.cache.del(`user:${userId}`);
// Next read will fetch fresh data from DB and repopulate cache
}
}
Ventajas: Solo los datos frecuentemente accedidos se cachean (uso eficiente de memoria), resiliente a fallos del caché (recurre a la BD), simple de implementar.
Desventajas: La primera solicitud de cualquier elemento siempre es un cache miss (arranque en frío), potencial de datos obsoletos entre escritura e invalidación.
2. Read-Through
En el patrón read-through, el caché se sitúa entre la aplicación y la base de datos. La aplicación solo interactúa con el caché. Cuando hay un cache miss, el propio caché es responsable de cargar los datos de la base de datos.
Esto es similar a cache-aside pero la diferencia clave es que la lógica de carga de datos vive en la capa de caché en lugar del código de la aplicación. Esto simplifica la aplicación y centraliza la lógica de caché.
3. Write-Through
En el patrón write-through, cada escritura va al caché primero, y el caché inmediatamente escribe los datos en la base de datos antes de retornar éxito al cliente. Esto asegura que el caché y la base de datos estén siempre sincronizados.
- Ventajas: Fuerte consistencia entre caché y base de datos, sin datos obsoletos.
- Desventajas: Mayor latencia de escritura (cada escritura impacta tanto caché como base de datos), el caché puede contener datos que nunca se leen.
4. Write-Behind (Write-Back)
En el patrón write-behind, la aplicación escribe datos en el caché, y el caché escribe asincrónicamente en la base de datos en segundo plano. Esto reduce dramáticamente la latencia de escritura porque la aplicación no espera la escritura en la base de datos.
// Write-Behind Pattern (conceptual implementation)
class WriteBehindCache {
private cache: Map<string, any> = new Map();
private writeQueue: Array<{ key: string; value: any }> = [];
private flushInterval: number = 1000; // Flush every second
constructor(private db: Database) {
// Background flush to database
setInterval(() => this.flushToDatabase(), this.flushInterval);
}
async write(key: string, value: any): Promise<void> {
// Write to cache immediately (fast)
this.cache.set(key, value);
// Queue for async database write
this.writeQueue.push({ key, value });
// Returns immediately - does NOT wait for DB write
}
async read(key: string): Promise<any> {
return this.cache.get(key); // Always read from cache
}
private async flushToDatabase(): Promise<void> {
const batch = [...this.writeQueue];
this.writeQueue = [];
if (batch.length === 0) return;
// Batch write to database for efficiency
await this.db.batchInsert(batch);
}
}
Advertencia: Riesgos del Write-Behind
Write-behind proporciona el mejor rendimiento de escritura pero conlleva un riesgo: si el caché se cae antes de escribir en la base de datos, pierdes datos. Usa este patrón solo cuando alguna pérdida de datos es aceptable (ej. analíticas, logging) o cuando tienes un caché durable (como Redis con persistencia AOF).
Políticas de Desalojo de Caché
Los cachés tienen memoria limitada, así que cuando el caché está lleno y se necesita almacenar un nuevo elemento, el caché debe decidir qué elemento existente eliminar. Esta decisión se rige por la política de desalojo.
Comparación de Políticas de Desalojo
| Política | Desaloja | Mejor Para | Debilidad |
|---|---|---|---|
| LRU (Menos Recientemente Usado) | Elemento menos recientemente accedido | Cargas generales | Escaneos únicos contaminan el caché |
| LFU (Menos Frecuentemente Usado) | Elemento menos frecuentemente accedido | Datos calientes estables | Elementos antiguos populares persisten |
| FIFO (Primero en Entrar, Primero en Salir) | Elemento más antiguo en caché | Casos de uso simples | Ignora patrones de acceso |
| TTL (Tiempo de Vida) | Elemento después de duración especificada | Datos sensibles al tiempo | Necesita ajuste cuidadoso del TTL |
| Aleatorio | Elemento aleatorio | Patrones de acceso uniformes | Comportamiento impredecible |
LRU es la opción predeterminada para la mayoría de los sistemas porque funciona bien en la práctica: los elementos accedidos recientemente tienden a ser accedidos de nuevo pronto. Redis usa un algoritmo LRU aproximado muestreando un pequeño número de claves y desalojando la menos recientemente usada entre ellas.
Redis vs Memcached
Redis y Memcached son los dos sistemas de caché en memoria más populares. Aunque sirven propósitos similares, tienen diferencias significativas que afectan cuál deberías elegir.
Comparación Redis vs Memcached
| Característica | Redis | Memcached |
|---|---|---|
| Estructuras de datos | Strings, listas, conjuntos, conjuntos ordenados, hashes, streams | Solo strings |
| Persistencia | Snapshots RDB y log AOF | Ninguna (caché puro) |
| Replicación | Primary-replica incorporada | Sin replicación nativa |
| Clustering | Redis Cluster (incorporado) | Sharding del lado del cliente |
| Pub/Sub | Sí | No |
| Scripting Lua | Sí | No |
| Hilos | Un solo hilo (I/O multi-hilo desde 6.0) | Multi-hilo |
| Mejor para | Caché complejo, sesiones, colas, tablas de clasificación | Caché simple clave-valor a escala |
Elige Redis cuando necesites estructuras de datos ricas, persistencia, pub/sub o replicación incorporada. Elige Memcached cuando necesites un caché multi-hilo simple para almacenamiento clave-valor a gran escala y no necesites características avanzadas.
Caché CDN
Una Red de Distribución de Contenido (CDN) cachea contenido en ubicaciones edge alrededor del mundo, sirviendo solicitudes desde la ubicación más cercana al usuario. Los CDNs son esenciales para reducir la latencia para usuarios globales y descargar tráfico de los servidores de origen.
- Recursos estáticos: Imágenes, CSS, archivos JavaScript, fuentes y videos son candidatos perfectos para CDN.
- Contenido dinámico: Las respuestas de API también pueden cachearse en el CDN con TTLs cortos (ej. 5-30 segundos) para ganancias significativas de rendimiento.
- Cabeceras de caché: Controla el comportamiento del CDN usando cabeceras Cache-Control, ETag y Vary.
// Setting cache headers for CDN
app.get('/api/products', async (req, res) => {
const products = await getProducts();
// Cache in CDN for 60 seconds, allow stale for 300 seconds while revalidating
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.set('CDN-Cache-Control', 'max-age=300'); // CDN-specific header (Cloudflare)
res.json(products);
});
app.get('/api/user/profile', async (req, res) => {
const profile = await getUserProfile(req.userId);
// Private data - do NOT cache in CDN
res.set('Cache-Control', 'private, no-store');
res.json(profile);
});
Estrategias de Invalidación de Caché
Phil Karlton dijo famosamente: "Solo hay dos cosas difíciles en Ciencias de la Computación: la invalidación de caché y nombrar cosas." La invalidación de caché es el proceso de eliminar o actualizar datos obsoletos en el caché cuando los datos subyacentes cambian.
1. Expiración Basada en Tiempo (TTL)
Establece un tiempo de vida en cada entrada del caché. Después de que el TTL expira, la entrada se elimina automáticamente. Este es el enfoque más simple pero puede servir datos obsoletos hasta que el TTL expire.
2. Invalidación Basada en Eventos
Cuando los datos cambian en la base de datos, publica un evento que dispara la invalidación del caché. Esto proporciona consistencia casi en tiempo real pero requiere un sistema de eventos.
3. Invalidación Basada en Versión
Incluye un número de versión en la clave del caché. Cuando los datos cambian, incrementa la versión. Las entradas antiguas del caché se vuelven huérfanas naturalmente y eventualmente expiran.
// Event-based cache invalidation with Redis Pub/Sub
class CacheInvalidator {
private redis: Redis;
private subscriber: Redis;
constructor() {
this.redis = new Redis();
this.subscriber = new Redis();
// Listen for invalidation events
this.subscriber.subscribe('cache-invalidation');
this.subscriber.on('message', (channel, message) => {
const { pattern } = JSON.parse(message);
this.invalidatePattern(pattern);
});
}
async invalidatePattern(pattern: string): Promise<void> {
// Find all keys matching the pattern
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
// Called when data changes
async onDataChange(entityType: string, entityId: string): Promise<void> {
// Publish invalidation event to all cache instances
await this.redis.publish('cache-invalidation', JSON.stringify({
pattern: `${entityType}:${entityId}:*`,
}));
}
}
Mejores Prácticas de Diseño de Caché
- Establece TTLs en todo. Incluso si tienes invalidación activa, los TTLs sirven como red de seguridad contra errores.
- Usa cache-aside para la mayoría de los casos. Es simple, efectivo y resiliente a fallos del caché.
- Monitorea las tasas de aciertos. Un caché con baja tasa de aciertos está desperdiciando memoria. Apunta a tasas de aciertos del 90%+.
- Evita la estampida del rebaño. Cuando una entrada popular del caché expira, cientos de solicitudes pueden golpear la base de datos simultáneamente. Usa bloqueo o stale-while-revalidate para prevenir esto.
- Cachea en la granularidad correcta. Cachea objetos individuales en lugar de páginas enteras cuando sea posible, para invalidación más precisa.