Patrón Cache Invalidation
Estrategias para mantener frescos los datos en caché: expiración TTL, invalidación explícita, write-through y evicción basada en eventos.
Descripción general
La invalidación de caché elimina o actualiza entradas obsoletas para que las lecturas siguientes obtengan datos frescos. Sin ella, un valor en caché puede seguir sirviendo datos desactualizados para siempre. Lo realmente difícil es detectar cuándo cambiaron los datos y reaccionar rápido, sin cargar de sobrecarga cada petición.
Hay cuatro estrategias principales de invalidación: expiración basada en TTL, invalidación explícita en escritura, invalidación basada en eventos vía pub/sub, y claves versionadas. Cada una intercambia garantías de consistencia por complejidad de implementación. La documentación de Redis sobre patrones de caché cubre la mecánica de evicción detrás de todas ellas.
Cuándo usarlo
- Los datos en caché cambian en la base de datos y las lecturas obsoletas causan errores de corrección
- Necesitas invalidar claves concretas, no toda la caché
- Varias instancias de la aplicación deben mantenerse sincronizadas cuando una modifica datos
- El TTL solo no basta porque la ventana de datos obsoletos es demasiado larga
- Quieres minimizar los fallos de caché manteniendo los datos frescos
- Puedes saltarte todo esto si las entradas son de escritura única: los datos inmutables nunca quedan obsoletos, así que la invalidación es peso muerto
Solución
Estrategia 1: Expiración basada en TTL
Pon un TTL en cada entrada de caché y casi has terminado: las entradas expiradas se eliminan solas, así que la siguiente lectura falla y tiene que recargar datos frescos. Si ese flujo te suena, es el patrón Cache-Aside.
import redis
import json
# decode_responses=True para que r.get() devuelva str en lugar de bytes
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id: str) -> dict:
key = f"user:{user_id}"
cached = r.get(key)
if cached:
return json.loads(cached)
user = db.query_one("SELECT * FROM users WHERE id = %s", [user_id])
r.setex(key, 300, json.dumps(user)) # TTL de 5 minutos
return user
Estrategia 2: Invalidación explícita en escritura
Después de actualizar la base de datos, borra la clave de caché correspondiente, así la siguiente lectura falla y recarga desde la BD.
def update_user(user_id: str, name: str, email: str):
db.execute(
"UPDATE users SET name = %s, email = %s WHERE id = %s",
[name, email, user_id]
)
# Invalidar la entrada de caché
r.delete(f"user:{user_id}")
# También invalidar cachés derivadas
r.delete(f"user:{user_id}:profile")
r.delete(f"users:list")
Estrategia 3: Invalidación por eventos con Redis Pub/Sub
Cuando una instancia actualiza datos, publica un evento de invalidación. Todas las instancias se suscriben y limpian sus cachés locales.
import redis
import json
import threading
class CacheInvalidator:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.pubsub = redis_client.pubsub()
self.pubsub.subscribe("cache:invalidate")
self._listener = threading.Thread(target=self._listen, daemon=True)
self._listener.start()
def invalidate(self, key: str):
self.redis.delete(key)
self.redis.publish("cache:invalidate", json.dumps({"key": key}))
def invalidate_pattern(self, pattern: str):
for key in self.redis.scan_iter(pattern):
self.redis.delete(key)
self.redis.publish("cache:invalidate", json.dumps({"pattern": pattern}))
def _listen(self):
for message in self.pubsub.listen():
if message["type"] != "message":
continue
data = json.loads(message["data"])
if "key" in data:
self.redis.delete(data["key"])
elif "pattern" in data:
for key in self.redis.scan_iter(data["pattern"]):
self.redis.delete(key)
invalidator = CacheInvalidator(redis.Redis(host='localhost', port=6379))
def update_user(user_id: str, name: str, email: str):
db.execute(
"UPDATE users SET name = %s, email = %s WHERE id = %s",
[name, email, user_id]
)
invalidator.invalidate(f"user:{user_id}")
TypeScript con Redis Pub/Sub
import { createClient } from 'redis';
class CacheInvalidator {
private publisher: ReturnType<typeof createClient>;
private subscriber: ReturnType<typeof createClient>;
constructor(publisher: ReturnType<typeof createClient>, subscriber: ReturnType<typeof createClient>) {
this.publisher = publisher;
this.subscriber = subscriber;
this.subscriber.subscribe('cache:invalidate', (message) => {
const data = JSON.parse(message);
if (data.key) {
this.publisher.del(data.key);
} else if (data.pattern) {
// Borrar todas las claves que coinciden
this.publisher.keys(data.pattern).then((keys) => {
if (keys.length > 0) this.publisher.del(keys);
});
}
});
}
async invalidate(key: string): Promise<void> {
await this.publisher.del(key);
await this.publisher.publish('cache:invalidate', JSON.stringify({ key }));
}
async invalidatePattern(pattern: string): Promise<void> {
const keys = await this.publisher.keys(pattern);
if (keys.length > 0) await this.publisher.del(keys);
await this.publisher.publish('cache:invalidate', JSON.stringify({ pattern }));
}
}
Estrategia 4: Claves versionadas
En lugar de invalidar, cambia el nombre de la clave. La clave antigua simplemente expira vía TTL por sí sola, así que ninguna lectura compite con la invalidación.
def get_user_version(user_id: str) -> str:
# Con decode_responses=True devuelve str; sin él obtendrías b"1"
# y la clave quedaría como user:123:vb'1'
version = r.get(f"user:{user_id}:version")
if version is None:
version = "1"
r.set(f"user:{user_id}:version", version)
return version
def get_user(user_id: str) -> dict:
version = get_user_version(user_id)
key = f"user:{user_id}:v{version}"
cached = r.get(key)
if cached:
return json.loads(cached)
user = db.query_one("SELECT * FROM users WHERE id = %s", [user_id])
r.setex(key, 300, json.dumps(user))
return user
def update_user(user_id: str, name: str, email: str):
db.execute(
"UPDATE users SET name = %s, email = %s WHERE id = %s",
[name, email, user_id]
)
# Incrementar la versión — la clave antigua queda huérfana y expira vía TTL
r.incr(f"user:{user_id}:version")
Explicación
Así intercambian las cuatro estrategias complejidad de implementación por consistencia:
-
TTL: la entrada de caché expira tras una duración fija. Simple, pero los datos pueden quedar obsoletos hasta esa duración. Sirve para datos que cambian poco o donde la obsolescencia es aceptable.
-
Invalidación explícita: el camino de escritura borra la clave de caché después de actualizar la base de datos, así que la ventana de obsolescencia es casi cero. El riesgo es una clave olvidada: una escritura que no ejecuta su invalidación deja datos obsoletos hasta que el TTL expira.
-
Basada en eventos: cuando varias instancias comparten una caché, la invalidación de una instancia tiene que llegar a todas las demás. Redis pub/sub difunde el evento de invalidación y cada suscriptor borra la clave, lo que mantiene consistente todo el clúster.
-
Claves versionadas: en lugar de borrar una clave, el camino de escritura incrementa un contador de versión. La siguiente lectura construye una clave nueva con la nueva versión, y la antigua queda huérfana hasta que el TTL expira. Así se evita la race condition en la que una lectura carga datos obsoletos entre la escritura a BD y el borrado de caché.
Ninguna de estas estrategias cambia el camino de lectura. Toda lectura sigue consultando primero la caché, falla hacia la base de datos cuando la clave no existe o expiró, y vuelve a escribir el resultado: el flujo de cache-aside. Lo que cambia es quién elimina la entrada obsoleta y con qué rapidez. Tenlo en cuenta al medir la obsolescencia: la métrica que importa es el hueco entre el commit a la base de datos y el momento en que la última instancia descarta el valor viejo, no el TTL en sí.
Hay una interacción más que conviene conocer: invalidar una clave caliente recrea el problema de stampede, porque todas las peticiones que llegan antes de que termine la primera recarga fallan a la vez. Si una sola clave absorbe la mayor parte de tu tráfico, combina la invalidación con el mecanismo de lock/single-flight del patrón de prevención de stampede en lugar de añadir más maquinaria encima.
Variantes
| Estrategia | Consistencia | Complejidad | Ideal para |
|---|---|---|---|
| Expiración TTL | Eventual (dentro del TTL) | Baja | Datos que cambian poco |
| Invalidación explícita | Fuerte (en la siguiente lectura) | Media | La mayoría de casos |
| Basada en eventos (pub/sub) | Fuerte (todo el clúster) | Alta | Despliegues multi-instancia |
| Claves versionadas | Fuerte (sin race) | Media | Lecturas de alta concurrencia |
| Invalidación por tags | Fuerte (agrupada) | Alta | Entradas de caché relacionadas |
Estas estrategias no se excluyen entre sí. Una caché de producción típica combina dos o tres: invalidación explícita en el camino de escritura para corrección, invalidación por tags para entidades agrupadas, y un TTL en cada clave como red de seguridad que captura lo que las otras dos dejen escapar. Las claves versionadas tienden a reemplazar los borrados explícitos en lugar de apilarse con ellos, porque ambas resuelven la misma race condition de lectura obsoleta.
Elige el TTL desde el lado del negocio, no de la infraestructura: pregúntate qué tan viejo puede ser un valor antes de causar un problema visible al usuario, y fija el TTL ligeramente por debajo de ese límite. Un catálogo de productos que tolera cinco minutos de retraso recibe un TTL de 300 segundos con invalidación explícita en las actualizaciones de producto; un store de sesiones recibe un TTL corto y nada más, porque toda escritura ya pasa por la caché.
Buenas prácticas
-
Si los stampedes son tu problema real, el patrón Cache Stampede Prevention profundiza más. La documentación de Azure sobre Cache-Aside describe el camino de lectura con carga bajo fallo en el que se apoyan todas estas estrategias.
-
Pon siempre un TTL como red de seguridad: incluso con invalidación explícita, un TTL captura las invalidaciones que se escapan. Ajústalo a la duración máxima de obsolescencia aceptable.
-
Invalida cachés derivadas: si guardas
user:123en caché y tambiénuser:123:profile, invalida ambas. Registra dependencias o usa invalidación por tags. -
Invalida después de la escritura a base de datos: no toques la clave de caché hasta que la actualización a BD haya tenido éxito. Si borras antes y la escritura falla, la siguiente lectura recarga datos obsoletos.
-
Usa pub/sub en despliegues multi-instancia: cuando varias instancias comparten una misma caché Redis, pub/sub es lo que mantiene sincronizado el estado local de cada una.
-
Monitoriza la tasa de invalidación: si invalidas más a menudo de lo que aciertas, la caché no se está pagando. Considera TTLs más largos o write-through.
Errores comunes
- Olvidar invalidar: el bug de caché más común. Cada camino de escritura debe invalidar las claves de caché correspondientes. Usa un wrapper o un hook del ORM para que no se pueda saltar.
- Invalidar antes de la escritura a BD: si la escritura falla, borraste la caché para nada y la siguiente lectura recarga los datos antiguos. Invalida siempre después de que la escritura tenga éxito.
- No invalidar cachés de lista: actualizar un usuario invalida
user:123, perousers:listsigue conteniendo el registro antiguo. Invalida también las cachés de lista y agregados. - Confiar solo en el TTL: con un TTL de 5 minutos tus datos pueden quedarse obsoletos durante 5 minutos. Añade invalidación explícita para cualquier dato sensible al tiempo.
- Race condition al invalidar: el hilo A lee de la BD, el hilo B escribe y borra la clave, y después el hilo A escribe datos obsoletos de vuelta en la caché. Usa claves versionadas o locks para evitarlo.
Temas avanzados
Escenario: Invalidación de caché para e-commerce
// Estrategias de invalidación de caché en un solo manager
class CacheManager {
constructor(private redis: RedisClient) {}
// Cada clave en caché registra los tags a los que pertenece
async setWithTags(key: string, value: unknown, tags: string[]): Promise<void> {
await this.redis.setex(key, 300, JSON.stringify(value));
for (const tag of tags) {
await this.redis.sadd(`tag:${tag}`, key);
}
}
// 1. Invalidación explícita: borrar la clave más cada grupo taggeado.
// Ojo: DEL no expande comodines — `del("products:category:*")` solo
// borraría una clave llamada literalmente "products:category:*".
async invalidateProduct(productId: string, categoryId: string): Promise<void> {
await this.redis.del(`product:${productId}`);
await this.invalidateByTag(`product:${productId}`);
await this.invalidateByTag(`category:${categoryId}`);
}
// 2. Invalidación por tags: borrar cada clave registrada bajo un tag
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) await this.redis.del(...keys);
await this.redis.del(`tag:${tag}`);
}
// 3. Invalidación con versión: incrementa la versión del namespace
async bumpVersion(namespace: string): Promise<void> {
await this.redis.incr(`version:${namespace}`);
}
async getWithVersion<T>(namespace: string, key: string): Promise<T | null> {
const version = (await this.redis.get(`version:${namespace}`)) ?? "1";
const fullKey = `${namespace}:${version}:${key}`;
const cached = await this.redis.get(fullKey);
return cached ? JSON.parse(cached) : null;
}
// 4. Write-through: refrescar la caché justo después de la escritura a BD
async updateProduct(product: Product): Promise<void> {
await db.update(product);
await this.setWithTags(`product:${product.id}`, product, [
`product:${product.id}`,
`category:${product.categoryId}`,
]);
}
}
Lecciones:
- Invalidación explícita: borrar la clave al actualizar
- Invalidación por tags: registrar las claves bajo tags y borrar todo el grupo en una llamada
- Basada en versión: incrementar la versión y dejar que las claves viejas expiren por TTL
- Write-through: refrescar la caché en la escritura a BD para máxima consistencia
- TTL como red de seguridad: si la invalidación falla, el TTL limpia igualmente
- El problema más difícil del caching es decidir cuándo invalidar
Código complementario: el repo companion de cache-invalidation-pattern tiene versiones ejecutables en Python y TypeScript de cada estrategia de esta página.
Preguntas frecuentes
¿Cuál es la mejor estrategia de invalidación de caché?
Realmente depende de tu caso: qué tan frescos deban estar los datos, con qué frecuencia cambian y cuántas instancias comparten la caché. Lo que sí funciona en casi cualquier lado es el TTL como red de seguridad base. A partir de ahí, añade invalidación explícita para datos que cambian y necesitan estar frescos de inmediato, invalidación por eventos en despliegues multi-instancia, y claves versionadas en escenarios de alta concurrencia donde una race condition realmente duela.
¿Cuál es el problema de cache stampede?
Cuando una clave de caché popular expira, todas las peticiones concurrentes fallan a la vez e inundan la base de datos. No es un problema de invalidación sino de expiración de TTL, así que la solución es el patrón cache-stampede-prevention (locks, single-flight).
¿Cómo funciona la invalidación por tags?
Los tags te permiten agrupar entradas relacionadas: guardarías user:123 con tags como ["user", "user:123"], y cuando ese usuario cambia borras todo lo que lleve el tag user:123, cachés derivadas incluidas. Redis no trae soporte de tags de serie; librerías como redis-tag-cache lo añaden.
¿Debo invalidar o actualizar la caché al escribir?
Borrar es la opción más simple y segura por defecto: la siguiente lectura simplemente recarga el registro fresco. Actualizar la caché al escribir (write-through) hace más rápida esa lectura, pero arriesga guardar datos obsoletos si la escritura a BD y la actualización de caché no son atómicas. Prefiere invalidación salvo que necesites write-through explícitamente.
¿Cómo gestiono la invalidación de caché en microservicios?
Publica un evento de dominio (por ejemplo ProductUpdated) cuando un servicio escriba datos, y deja que cada servicio que los tenga en caché se suscriba y elimine su copia local. Para una caché Redis compartida, pub/sub funciona igual: el escritor publica la invalidación y cada suscriptor borra la clave. Usa un TTL corto (~60s) más eventos para consistencia eventual, o write-through más invalidación síncrona cuando necesites consistencia fuerte. Evita la invalidación en cascada, donde un evento dispara muchos borrados: puede causar un thundering herd contra la base de datos.
Recursos Relacionados
Patrón Cache-Aside
Carga datos en el caché bajo demanda desde el almacenamiento principal. Un patrón de caché que da a la aplicación control total sobre qué y cuándo cachear.
PatternPatron Write-Through Cache
Escribe sincronamente en cache y backing store para que el cache siempre tenga los datos mas recientes sin invalidacion basada en TTL.
PatternPatron Cache Stampede Prevention
Previene thundering herd de cache misses con locks, single-flight y refresco temprano para proteger la base de datos de recargas concurrentes.
PatternPatrón Refresh-Ahead Cache
Refresca proactivamente entradas de caché antes de que expiren para eliminar fallos de caché en claves calientes y mantener latencia de lectura consistente.
GuideInvalidation de Cache
estrategias de invalidation de cache: TTL, event-driven, versioned keys, tag-based purging y write-through. Cubre invalidation multi-tier, race conditions y patrones de consistencia.
RecipeImplementar Invalidation de Cache Redis en Node.js
Invalida entradas de cache Redis en Node.js con expiracion TTL, eliminacion explicita, limpieza por patron e invalidacion distribuida con pub/sub.