Caché distribuida: consistencia, invalidación y fallos
Domina los patrones de caché distribuida: cache-aside, write-through, read-through y las estrategias de invalidación que afrontan la consistencia real.

El caching es la optimización de rendimiento más potente en sistemas distribuidos y la fuente de errores más peligrosa. Una caché bien implementada reduce la carga de la base de datos en un 90%. Una implementación deficiente sirve datos obsoletos durante horas mientras los desarrolladores se preguntan por qué los usuarios ven los precios de ayer.
La diferencia entre estos resultados no está en la tecnología de caché, sino en el patrón de caching. Cada patrón asume distintos compromisos entre consistencia, rendimiento y complejidad.
Cache-Aside: el patrón por defecto
Cache-aside (lazy loading) es el patrón más común. La aplicación gestiona la caché de forma explícita: comprueba la caché, consulta la base de datos cuando falla y llena la caché.
// ❌ Naive cache-aside with race condition
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
await redis.set(`user:${id}`, JSON.stringify(user));
// Race: another request might have updated the DB between
// our read and our cache write, caching stale data
return user;
}// ✅ Cache-aside with TTL and stampede protection
import { Redis } from "ioredis";
class CacheAside<T> {
constructor(
private redis: Redis,
private prefix: string,
private ttlSeconds: number = 300
) {}
async get(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
const cacheKey = `${this.prefix}:${key}`;
// Try cache first
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as T;
}
// Stampede protection: only one request populates cache
const lockKey = `${cacheKey}:lock`;
const acquired = await this.redis.set(
lockKey, "1", "EX", 10, "NX"
);
if (!acquired) {
// Another request is fetching; wait and retry cache
await new Promise(r => setTimeout(r, 100));
const retried = await this.redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
// Fetch from source
const value = await fetcher();
// Cache with TTL
await this.redis.set(
cacheKey,
JSON.stringify(value),
"EX",
this.ttlSeconds
);
// Release lock
await this.redis.del(lockKey);
return value;
}
async invalidate(key: string): Promise<void> {
await this.redis.del(`${this.prefix}:${key}`);
}
}
// Usage
const userCache = new CacheAside<User>(redis, "user", 300);
const user = await userCache.get("user-123", () =>
db.users.findById("user-123")
);El lock evita el cache stampede: cuando miles de peticiones impactan simultáneamente una clave de caché expirada, solo una consulta a la base de datos. El resto espera brevemente y obtiene el resultado recién cacheado.
Write-Through: consistencia a costa de latencia
El caching write-through actualiza la caché de forma síncrona con cada escritura en la base de datos. Las lecturas siempre son rápidas, pero las escrituras asumen el coste de ambas operaciones de almacenamiento.
// ❌ Cache and DB can get out of sync
async function updateUser(id: string, data: Partial<User>) {
await db.users.update(id, data); // DB updated
await redis.del(`user:${id}`); // Cache invalidated
// If the app crashes between these lines, cache is stale
}// ✅ Write-through with atomic-like guarantees
class WriteThroughCache<T> {
constructor(
private redis: Redis,
private prefix: string,
private ttlSeconds: number
) {}
async write(
key: string,
value: T,
dbWriter: (value: T) => Promise<void>
): Promise<void> {
const cacheKey = `${this.prefix}:${key}`;
// Write to DB first (source of truth)
await dbWriter(value);
// Then update cache
await this.redis.set(
cacheKey,
JSON.stringify(value),
"EX",
this.ttlSeconds
);
}
async read(key: string): Promise<T | null> {
const cacheKey = `${this.prefix}:${key}`;
const cached = await this.redis.get(cacheKey);
return cached ? (JSON.parse(cached) as T) : null;
}
}
// Usage
const productCache = new WriteThroughCache<Product>(redis, "product", 600);
await productCache.write(
"prod-456",
updatedProduct,
async (product) => {
await db.products.update(product.id, product);
}
);Write-through garantiza que la caché siempre tenga datos frescos tras una escritura. El compromiso es una mayor latencia de escritura (dos operaciones en lugar de una) y el riesgo de cachear datos de claves que raramente se leen.
Read-Through con stale-while-revalidate
Stale-while-revalidate sirve datos ligeramente obsoletos de inmediato mientras refresca la caché en segundo plano. Esto elimina la latencia de cache miss para los usuarios manteniendo los datos razonablemente frescos.
interface CacheEntry<T> {
value: T;
cachedAt: number;
staleAfter: number;
expireAfter: number;
}
class StaleWhileRevalidateCache<T> {
private refreshing = new Set<string>();
constructor(
private redis: Redis,
private prefix: string,
private freshSeconds: number = 60,
private staleSeconds: number = 300
) {}
async get(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
const cacheKey = `${this.prefix}:${key}`;
const raw = await this.redis.get(cacheKey);
if (raw) {
const entry: CacheEntry<T> = JSON.parse(raw);
const now = Date.now();
if (now < entry.staleAfter) {
// Fresh: return immediately
return entry.value;
}
if (now < entry.expireAfter) {
// Stale but usable: return immediately, refresh in background
this.refreshInBackground(key, cacheKey, fetcher);
return entry.value;
}
}
// Expired or missing: fetch synchronously
return this.fetchAndCache(key, cacheKey, fetcher);
}
private async fetchAndCache(
key: string,
cacheKey: string,
fetcher: () => Promise<T>
): Promise<T> {
const value = await fetcher();
const now = Date.now();
const entry: CacheEntry<T> = {
value,
cachedAt: now,
staleAfter: now + this.freshSeconds * 1000,
expireAfter: now + this.staleSeconds * 1000,
};
await this.redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
this.staleSeconds
);
return value;
}
private refreshInBackground(
key: string,
cacheKey: string,
fetcher: () => Promise<T>
): void {
if (this.refreshing.has(key)) return; // Already refreshing
this.refreshing.add(key);
this.fetchAndCache(key, cacheKey, fetcher)
.finally(() => this.refreshing.delete(key));
}
}Este patrón es excelente para datos que cambian periódicamente pero no necesitan precisión en tiempo real: catálogos de productos, perfiles de usuario, configuraciones. Los usuarios siempre obtienen una respuesta rápida, y los datos nunca tienen más de staleSeconds de antigüedad.
Caché multinivel
Las aplicaciones en producción suelen usar varias capas de caché: memoria en proceso, caché distribuida (Redis) y CDN. Cada capa responde a distintos patrones de acceso.
class MultiLevelCache<T> {
private l1: Map<string, { value: T; expires: number }> = new Map();
private l1MaxSize: number;
constructor(
private redis: Redis,
private prefix: string,
l1MaxSize: number = 1000,
private l1TtlMs: number = 10000,
private l2TtlSeconds: number = 300
) {
this.l1MaxSize = l1MaxSize;
}
async get(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
// L1: In-process memory (microseconds)
const l1Entry = this.l1.get(key);
if (l1Entry && l1Entry.expires > Date.now()) {
return l1Entry.value;
}
// L2: Redis (milliseconds)
const cacheKey = `${this.prefix}:${key}`;
const l2Value = await this.redis.get(cacheKey);
if (l2Value) {
const parsed = JSON.parse(l2Value) as T;
this.setL1(key, parsed);
return parsed;
}
// L3: Database (tens of milliseconds)
const value = await fetcher();
this.setL1(key, value);
await this.redis.set(
cacheKey,
JSON.stringify(value),
"EX",
this.l2TtlSeconds
);
return value;
}
private setL1(key: string, value: T): void {
// Simple eviction: remove oldest when full
if (this.l1.size >= this.l1MaxSize) {
const oldest = this.l1.keys().next().value;
if (oldest !== undefined) {
this.l1.delete(oldest);
}
}
this.l1.set(key, {
value,
expires: Date.now() + this.l1TtlMs,
});
}
async invalidate(key: string): Promise<void> {
this.l1.delete(key);
await this.redis.del(`${this.prefix}:${key}`);
// Note: other application instances still have L1 cache
// Publish invalidation event for cluster-wide L1 flush
await this.redis.publish(
`${this.prefix}:invalidate`,
key
);
}
}La caché L1 (en proceso) tiene un TTL muy corto porque no se puede invalidar entre instancias. La caché L2 (Redis) tiene un TTL más largo porque es compartida. Este enfoque por capas gestiona las lecturas de hot path en microsegundos manteniendo una consistencia razonable.
Gestión resiliente de fallos de caché
Los fallos de caché no deberían propagarse como fallos de aplicación. Cuando Redis cae, la aplicación debería degradarse a lecturas directas de base de datos, no colapsar.
class ResilientCache<T> {
private circuitOpen = false;
private failureCount = 0;
private lastFailure = 0;
constructor(
private redis: Redis,
private prefix: string,
private ttlSeconds: number,
private failureThreshold: number = 5,
private resetTimeMs: number = 30000
) {}
async get(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
// Check circuit breaker
if (this.circuitOpen) {
if (Date.now() - this.lastFailure > this.resetTimeMs) {
this.circuitOpen = false;
this.failureCount = 0;
} else {
// Circuit open: skip cache entirely
return fetcher();
}
}
try {
const cacheKey = `${this.prefix}:${key}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
this.failureCount = 0;
return JSON.parse(cached) as T;
}
const value = await fetcher();
// Best-effort cache write
this.redis
.set(cacheKey, JSON.stringify(value), "EX", this.ttlSeconds)
.catch(() => this.recordFailure());
return value;
} catch {
this.recordFailure();
return fetcher();
}
}
private recordFailure(): void {
this.failureCount++;
this.lastFailure = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.circuitOpen = true;
}
}
}El circuit breaker evita que los fallos de caché repetidos añadan latencia a cada petición. Tras cinco fallos de Redis, el circuito se abre y la aplicación va directamente a la base de datos hasta que Redis se recupere.
Conclusiones clave
Elegir un patrón de caching no consiste en seleccionar el "mejor", sino en adaptar el patrón a tus requisitos de consistencia. Cache-aside funciona para la mayoría de las cargas intensivas en lectura. Write-through funciona cuando las lecturas siempre deben ver la última escritura. Stale-while-revalidate funciona cuando un tiempo casi real es suficiente y la latencia es crítica.
Los patrones que causan más incidentes en producción son aquellos que ignoran los modos de fallo. Implementa siempre protección contra stampede para cache-aside, gestiona siempre los fallos de caché con circuit breakers y asume siempre que los despliegues multi-instancia necesitan invalidación entre instancias. Una caché que rinde perfectamente pero sirve datos obsoletos tras una actualización es peor que no tener caché.


