¿Qué es el caché negativo (cachear resultados de ‘no encontrado’) y por qué importa para proteger tu backend?
Un caché que solo recuerda los aciertos deja que atacantes o bugs golpeen tu base de datos buscando claves que nunca existirán: el caché negativo cierra esa brecha.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es el caché negativo (cachear resultados de ‘no encontrado’) y por qué importa para proteger tu backend?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#El punto ciego del caché convencional
La mayoría de las estrategias de caché parten de un supuesto implícito: que lo que se busca existe. Un patrón cache-aside de lectura es simple — se consulta el caché y, si falla, se va a la base de datos, se trae la fila y se guarda en el caché para la próxima vez. Ese patrón protege maravillosamente a la base de datos para claves populares que sí existen. Pero no hace absolutamente nada por las claves que no existen, porque no hay nada que cachear. Cada búsqueda de user:99999999 (una cuenta borrada, un ID mal tipeado, o un atacante probando IDs secuenciales) pasa de largo por el caché y golpea la base de datos, siempre, sin excepción.
Este vacío tiene nombre en el mundo de los sistemas distribuidos: penetración de caché (cache penetration). Aparece de formas muy cotidianas — un cliente móvil que reintenta una petición sobre un recurso que ya fue borrado del lado del servidor, un endpoint público que recibe bots enumerando IDs en busca de alguno válido, o simplemente un bug donde una foreign key apunta a una fila que ya no existe. Nada de esto es exótico: es el comportamiento por defecto de un sistema que sólo cachea el "sí".
#Caché negativo: recordar también el "no"
El caché negativo cierra ese vacío cacheando la ausencia de un resultado, no sólo su presencia. Cuando una búsqueda vuelve vacía, en lugar de dejar ese espacio sin escribir, se guarda un valor centinela pequeño en el caché — con un TTL deliberadamente mucho más corto que el que se usa para datos reales. La siguiente petición por esa misma clave inexistente recibe su respuesta desde la capa de caché y nunca toca la base de datos.
Esa asimetría de TTL importa. Si un resultado "no encontrado" viviera una hora, un usuario que acaba de registrarse podría aparecer como inexistente para tu propio backend durante hasta una hora, sólo porque alguien (posiblemente el propio cliente, precargando datos) lo buscó medio segundo antes de que la transacción de alta terminara de confirmarse. Un TTL negativo corto — de segundos a un par de minutos — acota esa ventana de desactualización a algo por lo que jamás se abrirá un ticket de soporte, y aun así absorbe la mayor parte de un patrón de martilleo, ya que las búsquedas repetidas suelen llegar en una ráfaga concentrada y no distribuidas a lo largo de horas.
#Implementación
const redis = require("./redisClient"); // instancia de ioredisconst db = require("./db");const CENTINELA_NEGATIVO = "__NO_ENCONTRADO__";const TTL_POSITIVO_SEGUNDOS = 3600; // 1 hora — los registros reales cambian pococonst TTL_NEGATIVO_SEGUNDOS = 30; // 30 segundos — acota la desactualización tras un altaasync function obtenerUsuarioPorId(userId) {const cacheKey = `user:${userId}`;const cached = await redis.get(cacheKey);if (cached === CENTINELA_NEGATIVO) {// Acierto de caché negativo: ya sabemos que este ID no tiene nada detrás.return null;}if (cached !== null) {return JSON.parse(cached);}// Falla real: no existe todavía ni una entrada positiva ni una negativa.const user = await db.query("SELECT id, name, email FROM users WHERE id = $1", [userId]);if (!user) {await redis.set(cacheKey, CENTINELA_NEGATIVO, "EX", TTL_NEGATIVO_SEGUNDOS);return null;}await redis.set(cacheKey, JSON.stringify(user), "EX", TTL_POSITIVO_SEGUNDOS);return user;}// Invalida la entrada negativa apenas se crea el registro correspondiente,// para que un usuario recién creado nunca tenga que esperar a que expire el TTL negativo.async function alCrearUsuario(user) {await db.insert("users", user);await redis.del(`user:${user.id}`);}
Ahora existen tres estados donde antes había dos: un acierto positivo (devolver la fila cacheada), un acierto negativo (devolver "no encontrado" sin tocar la base de datos), y una falla real (ir a la base de datos y luego poblar la entrada de caché que el resultado merezca). El centinela tiene que ser algo que jamás pueda confundirse con un valor serializado real — una cadena fija fuera de tu formato de serialización habitual, un prefijo de clave dedicado, o incluso un espacio de claves separado en Redis son todas opciones válidas; lo que importa es poder distinguir "vacío porque es negativo" de "vacío porque nunca se cacheó nada".
#Dónde esto importa de verdad
- Ataques de enumeración. Un atacante recorriendo
/api/orders/1,/api/orders/2, … buscando uno que exista queda casi totalmente absorbido por el caché negativo — sólo el primer intento sobre cada ID llega a la base de datos. - Referencias rotas. Un
product_idque fue borrado pero sigue referenciado por carritos viejos, listas de deseos o entradas del índice de búsqueda genera búsquedas repetidas de una fila que jamás va a volver; el caché negativo evita que eso se convierta en carga de fondo constante. - Tormentas de reintentos. Un cliente que reintenta agresivamente un 404 (con una lógica de backoff mal hecha) deja de generar una consulta a la base de datos por cada reintento.
- Endpoints públicos de autocompletado o búsqueda. Son los más expuestos a entradas aleatorias o maliciosas, y los más propensos a recibir claves casi uniformemente aleatorias que fallan todas.
| Cache miss (sin caché negativo) | Acierto de caché negativo | Ataque de penetración de caché | |
|---|---|---|---|
| Dónde aterriza la petición | Base de datos, siempre | Sólo el caché | Base de datos, siempre |
| Costo por búsqueda repetida | Costo completo de la query | Lectura de caché ~O(1) | Costo completo de la query, al ritmo del atacante |
| Riesgo | Capacidad desperdiciada | Ninguno (acotado por el TTL corto) | Sobrecarga o caída de la base de datos |
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué una entrada de caché negativo suele usar un TTL mucho más corto que una positiva?
2. ¿Contra qué problema protege principalmente el caché negativo?