Phil Karlton bromeaba con que existen dos problemas difíciles en computación: naming, cache invalidation y off-by-one errors. La invalidación se volvió la excusa para no cachear, y el código de cache sigue ocupando diez líneas en cualquier servicio. El resto del trabajo es elegir la clave, el TTL y el punto donde muere la copia.
Tres capas, tres formas de invalidar
Una aplicación web guarda copias del mismo dato en hasta tres lugares:
- CDN (edge): imágenes, CSS, JS bundle y páginas pre-renderizadas. TTL medido en días, purga por URL.
- Cache de aplicación: Redis o memoria para sesiones, configuraciones y queries repetidas. TTL medido en minutos, clave borrada por código.
- Base de datos: prepared statements, índices y materialized views. La base invalida junto con la escritura.
Purgas la CDN cuando publicas, ejecutas un DEL en la clave de Redis cuando cambia el registro y dejas que la base cuide su propio cache. Reusar la estrategia de una capa en la otra es el camino más corto para servir dato viejo con latencia de cache.
Cache-aside con Redis
El patrón cache-aside hace que el código pregunte al cache antes que a la base y llene el cache en el miss. La guía de cache-aside de Redis describe el flujo con un detalle que importa en producción: cuando una clave popular expira bajo carga, todos los lectores ven el miss en el mismo instante y golpean la base a la vez. La protección contra stampede entra después, con lock en Lua o refresh probabilístico.
// Cache-aside pattern
async function getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`
// 1. Pregunta al cache
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// 2. Miss: busca en la base
const user = await db.user.findUnique({ where: { id } })
if (!user) throw new AppError('NOT_FOUND', 404, 'Usuario no encontrado')
// 3. Graba en cache con TTL de 1 hora
await redis.setex(cacheKey, 3600, JSON.stringify(user))
return user
}
Mide el hit rate antes de cualquier optimización. Un cache con hit rate de 30% solo mueve parte de la carga de la base hacia Redis y dobla la ruta de datos que tienes que observar.
TTL como red de seguridad
TTL es la invalidación que no necesita código. Cada clave recibe un tiempo de vida y el EXPIRE de Redis descarta la clave cuando el tiempo se agota. El dato que cambia poco recibe TTL largo; el feed y el ranking reciben TTL corto.
// La configuración cambia por deploy: TTL de 24h
await redis.setex('config:app', 86400, JSON.stringify(config))
// Ranking recalculado por request: TTL de 5min
await redis.setex('feed:popular', 300, JSON.stringify(feed))
Un TTL corto también cubre el bug de invalidación olvidada: la copia vieja sobrevive como máximo el TTL. La guía de Amazon ElastiCache recomienda TTL en toda clave, incluidas las que actualizas con write-through, justo para capturar esas fallas.
Invalidación en el punto de escritura
Cuando cambia el registro, borras las claves afectadas junto con el update. El cache del objeto y el cache del listado que lo referencia son claves distintas y mueren en el mismo momento:
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await db.user.update({ where: { id }, data: data })
// Borra el objeto y el índice que lo referencia
await redis.del(`user:${id}`)
await redis.del('users:active')
return user
}
Write-through graba base y cache en la misma operación. El costo aparece en la latencia de escritura, porque cada update se vuelve dos viajes. Usa write-through en dato que se lee justo después de grabarlo, como carrito y sesión; para el resto, borra la clave y deja que la próxima lectura la repueble.
Headers que la CDN entiende
// Assets con hash en el nombre: un año
Cache-Control: public, max-age=31536000, immutable
// API autenticada: siempre revalida
Cache-Control: private, max-age=0, must-revalidate
ETag: "abc123"
// Página de contenido: sirve stale y revalida en background
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
stale-while-revalidate viene del RFC 5861 (Nottingham, 2010): el cache devuelve la respuesta vencida y revalida sin bloquear al cliente. El RFC 9111 (HTTP Caching, 2022) define el resto de la negociación, incluido el ETag que usa If-None-Match. Los assets con hash en el nombre de archivo ganan immutable y TTL de un año sin riesgo, porque reemplazar el archivo genera otro nombre.
Cache warming después del deploy
Un deploy nuevo deja los caches vacíos y la primera lectura de cada item paga el cold start. Precargar los items más leídos en el startup devuelve la latencia que el usuario espera en el primer request:
async function warmCache() {
const popular = await db.product.findMany({
orderBy: { views: 'desc' },
take: 100
})
await Promise.all(popular.map(p =>
redis.setex(`product:${p.id}`, 7200, JSON.stringify(p))
))
}
AWS documenta el mismo procedimiento como pre-population en el whitepaper Performance at Scale with Amazon ElastiCache. Para datos de referencia que no dependen del tráfico, la guía de prefetch cache cubre la versión alimentada por CDC.
Qué se queda fuera del cache
Saldo en tiempo real, precio promocional calculado por request y query simple que la base responde en menos de 10ms se quedan sin cache. Leer, grabar e invalidar cuesta más que la consulta original. Criterio de decisión: la misma lectura se repite, el origen es lento y aceptas el dato desactualizado dentro del TTL.
Referencias
- Redis docs: cache-aside, EXPIRE y prefetch cache
- Amazon ElastiCache User Guide: Caching strategies (AWS)
- Performance at Scale with Amazon ElastiCache (whitepaper de AWS)
- RFC 5861, HTTP Cache-Control Extensions for Stale Content (Nottingham, 2010)
- RFC 9111, HTTP Caching (Fielding, Nottingham y Reschke, 2022)
¿Te gustó el contenido?
Construyo productos web y soluciones con IA de la manera correcta — arquitectura sólida, código sostenible y entrega real.
Hablemos