Inicio / Artículos

Mantener los cachés de Redis honestos en Node.js: invalidación sin lecturas obsoletas

Compare el TTL, la eliminación al escribir, el modelo pub/sub y la invalidación de claves versionadas en Redis para Node.js, observe las posibles situaciones de conflicto que cada uno de ellos genera, y cree un wrapper para caché con comportamiento de fallo abierto.

Editor: REACTAPP.TOP16 min de lectura3730 palabras

Fuentes: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie

Imagínese a un usuario que sube una nueva foto de perfil, recibe una respuesta de éxito, vuelve a cargar la página pero sigue viendo la foto antigua. Lo intenta de nuevo con el mismo resultado, y unos minutos después la nueva foto aparece por sí sola. El informe de error que sigue es casi imposible de reproducir, ya que Redis sigue proporcionando una copia de la fila que la base de datos ya ha reemplazado. Una base de datos lenta al menos devuelve la respuesta correcta; un caché rápido que devuelve una incorrecta es peor que no tener caché alguno. Esta guía se centra en la ruta de escritura de un caché Redis en Node.js: cuatro formas de invalidar entradas, cuándo cada una justifica su costo, la condición de carrera que deja abierta cada una, un wrapper reutilizable cacheAside() que combina protecciones para la ruta de lectura con invalidación, y los errores que con mayor frecuencia mantienen valores obsoletos activos.

El aspecto de lectura del caché, es decir, la carga fuera del caché, la protección contra colas y lo que ocurre cuando una clave activa vence su plazo, es un tema aparte; nuestra visión general de los fundamentos y dificultades del caché en Redis aborda los conceptos básicos. Ninguna de estas técnicas evita que los datos se vuelvan obsoletos después de una escritura, y eso es lo que trata el resto de este artículo.

Cuatro estrategias de invalidación

Las estrategias que se detallan a continuación están ordenadas aproximadamente según el orden en que debería considerarlas, desde la más económica hasta la más compleja.

Estrategia 1: depender únicamente del TTL

Asigne un plazo de vencimiento a cada entrada, acepte que un valor pueda tener esa cantidad de segundos de antigüedad y registre ese número en un lugar al que el equipo del producto pueda acceder.

Esta es la respuesta correcta para casos en los que hay más datos de lo que la mayoría de los equipos suponen. El texto del catálogo, las jerarquías de categorías, el texto del perfil público y los tipos de cambio horarios rara vez necesitan estar actualizados al milisegundo. Cuando un valor puede retrasarse cinco minutos, un plazo de vencimiento de 300 segundos es todo lo que necesita el código:

await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);

La foto del perfil es otra historia. La persona que realiza el cambio espera verla de inmediato, y un plazo de vencimiento no puede garantizarlo. En el caso de datos editados por el usuario, es necesario invalidarlos en el momento en que se realiza la escritura.

Estrategia 2: eliminar la entrada al escribir

Después de actualizar la base de datos, elimine la clave de caché correspondiente. La siguiente lectura fallará, cargará la nueva fila y volverá a llenar el caché:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);

  return updated;
}

Dos detalles hacen que esto funcione correctamente.

Eliminar en lugar de sobrescribir. Dado que ya posees el valor actualizado, escribirlo en Redis con SET parece una optimización gratuita. El problema surge cuando hay dos escritores concurrentes: sus comandos SET pueden llegar a Redis en el orden opuesto a los commits en la base de datos, lo que hace que la escritura anterior permanezca en caché hasta que se agote el TTL. Las operaciones de eliminación no dependen del orden; sea cual sea la que llegue primero, el resultado es una clave vacía.

Escribe en la base de datos antes de eliminar. Invertir el orden abre un amplio margen de tiempo:

// Wrong: delete before write
await redis.del(`user:${id}`);                     // 1. cache is empty
const updated = await db.users.update(id, patch);  // 2. write the row

// Between 1 and 2, a reader can miss the cache, read the OLD row,
// and put it back. The cache now holds the old value until its TTL.

Entre la eliminación y la actualización, cualquier lector que no encuentre los datos en caché cargará la fila antigua y volverá a guardarla allí.

Escribir primero reduce ese margen de tiempo sin eliminarlo por completo. El siguiente cronograma muestra cómo un lector lento aún puede recuperar datos obsoletos:

Reader                              Writer
------                              ------
GET user:42        -> miss
SELECT user 42     -> old row
                                    UPDATE user 42 (commit)
                                    DEL user:42
SET user:42 <- old row

El lector obtuvo la fila antes de que el escritor la guardara, y luego la almacenó después de que el escritor realizara la eliminación. Ese valor antiguo permanece en caché hasta que vence el TTL. Esta secuencia requiere un lector lento y un escritor que actúe exactamente en el momento equivocado, por lo que es rara; sin embargo, cuando una clave se lee miles de veces por minuto, lo que es raro puede convertirse fácilmente en algo diario.

La solución estándar es realizar una segunda eliminación, programada para durar un poco más de lo que tarda una carga normal:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  const key = `user:${id}`;
  await redis.del(key);
  // Catches a reader that loaded the old row before the commit.
  setTimeout(() => redis.del(key).catch(() => {}), 500).unref();
  return updated;
}

La llamada a unref() impide que el temporizador pendiente mantenga abierto el proceso durante el cierre, y el catch vacío asegura que una eliminación fallida nunca genere un rechazo no manejado.

Existen dos precauciones adicionales que deben tomarse:

  • Transacciones. Si la actualización se ejecuta dentro de una transacción de base de datos, ejecute la eliminación una vez que el commit haya tenido éxito, fuera del callback de la transacción. Eliminar antes del commit recrea el orden incorrecto mostrado anteriormente.
  • Crashs. Si el proceso se detiene entre el commit y la eliminación, la invalidación simplemente se pierde, y solo el TTL la corregirá con el tiempo. Cuando perder una invalidación es inaceptable, active la invalidación a partir de un feed de captura de datos cambiados, como Debezium en Postgres o MySQL, o flujos de cambios en MongoDB. De esta manera, la invalidación depende del propio commit en lugar de de que un manejador de solicitudes permanezca activo el tiempo suficiente.

Estrategia 3: difundir invalidaciones a los cachés de memoria local

Cuando cada proceso lee directamente desde Redis compartido, todos ven una sola orden DEL al mismo tiempo, y pub/sub no aporta nada.

Se vuelve necesario cuando los procesos mantienen una pequeña capa en memoria delante de Redis, como un Map o lru-cache, para evitar viajes de ida y vuelta por red con claves muy utilizadas. Cuando la instancia A elimina una clave de Redis, la copia local de la instancia B no se ve afectada. Publicar la clave invalidada permite que cada instancia descarte su propia copia:

// Every instance runs this at startup.
// A subscribed connection can't run other commands, hence duplicate().
const sub = redis.duplicate();
await sub.subscribe('cache:invalidate');
sub.on('message', (_channel, key) => localCache.delete(key));

// Whoever writes to the database:
async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);
  await redis.publish('cache:invalidate', `user:${id}`);

  return updated;
}

Observe la llamada a duplicate(): una conexión de Redis en modo suscriptor no puede emitir comandos ordinarios, por lo que la suscripción necesita una conexión propia.

Si utiliza Redis 6 o una versión más reciente, considere su soporte integrado de caché en el lado del cliente (CLIENT TRACKING) antes de implementar uno propio. El servidor recuerda las claves que ha obtenido una conexión y notifica a dicha conexión cuando se modifica alguna de ellas. El soporte varía entre las bibliotecas de cliente, por lo que debe confirmar que la suya lo implemente.

Estrategia 4: claves con versión

Cuando servir un valor antiguo representa un problema de corrección en lugar de uno estético, incluya un número de versión en la clave:

async function getOrder(id) {
  const version = (await redis.get(`order:${id}:ver`)) ?? '0';
  const key = `order:${id}:v${version}`;

const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const order = await db.orders.findById(id);
  if (!order) return null;

  await redis.set(key, JSON.stringify(order), 'EX', 86400);

  return order;
}

async function updateOrder(id, patch) {
  const updated = await db.orders.update(id, patch);
  await redis.incr(`order:${id}:ver`);

  return updated;
}

Las lecturas obtienen la versión actual desde una clave de contador y construyen la clave con los datos a partir de ella. Las escrituras simplemente incrementan el contador.

Ejecuta nuevamente la carrera de estrategia 2 bajo este esquema. El lector lento obtiene la fila obsoleta; mientras tanto, el escritor realiza el commit y aumenta el contador; luego el lector almacena en caché lo que ha obtenido. Su escritura se dirige a order:42:v0, una clave que ningún lector buscará nuevamente, por lo que la copia obsoleta permanece en una clave abandonada hasta que expira. Esa inmunidad es lo que se paga por la complejidad adicional.

Las reglas que lo mantienen seguro:

  • Solo los escritores modifican el contador, y solo con INCR. Si un lector lo inicializa con algo como un SET a 0, eso puede anular un incremento realizado un momento antes y restaurar la versión anterior.
  • Mantén el contador activo. Déjalo sin fecha de vencimiento o establece una mucho más larga que la vida útil de los registros de datos. Un contador que vence comienza de nuevo en cero, lo que hace que vuelvan a ser visibles los registros v0 o v1 restantes.
  • Los costos son dos viajes ida y vuelta por lectura en lugar de uno, además de la memoria ocupada por las versiones antiguas hasta que vencen. Este compromiso es justificado para órdenes, saldos, niveles de inventario y cualquier cosa relacionada con facturación, pero es excesivo para la mayoría de los demás datos.

    Elegir una estrategia

    Una comparación concisa:

    • Solo TTL: fácil de implementar; los datos pueden tener hasta una edad equivalente a su TTL; úsalo cuando se acepte cierto grado de obsolescencia y puedas especificar el valor del TTL.
    • Borrar al escribir: baja complejidad; los datos se actualizan de inmediato, salvo en casos raros de concurrencia que duran hasta que vence el TTL; es la opción predeterminada para todo lo que los usuarios editan.
    • Pub/sub: complejidad media; mantiene actualizadas las copias en memoria mientras llegan los mensajes; es necesario al añadir una caché local delante de Redis.
    • Claves versionadas: alta complejidad; las reconstrucciones obsoletas nunca se leen; están reservadas para datos en los que un valor antiguo implica errores financieros o de inventario.

    Para un servicio típico, los vencimientos combinados con la eliminación al escribir son suficientes, además de una eliminación adicional diferida para las claves con alto tráfico de lectura que los usuarios puedan modificar. Añada Pub/Sub el día en que introduzca una caché dentro del proceso, y mantenga las claves versionadas para ese pequeño número de entidades donde una respuesta incorrecta tiene un costo financiero.

    Un wrapper de referencia CacheAside()

    La lógica de caché dispersa entre los manejadores de solicitudes es donde se olvidan estas reglas, por lo que vale la pena escribir la lógica una vez e importarla en todas partes. El módulo a continuación utiliza ioredis y combina:

    • Llamadas a Redis en modo “fail-open” que recurren a la base de datos en lugar de rechazar las solicitudes,
    • política “stale-while-revalidate”, por la cual se sirven valores ligeramente obsoletos mientras se realiza una actualización,
    • modo de ejecución única por proceso, de modo que las solicitudes concurrentes dentro de un mismo proceso comparten la misma carga,
    • un bloqueo entre procesos con desbloqueo mediante comparación y eliminación, para que las instancias no reconstruyan siempre la misma clave,
    • invalidación a través de pub/sub sumada a una segunda eliminación diferida.
    // cache.js
    const Redis = require('ioredis');
    const { randomUUID } = require('node:crypto');
    const logger = require('./logger');
    
    // Fail fast, so a dead Redis makes requests slower instead of hanging them.
    const redis = new Redis(process.env.REDIS_URL, {
      commandTimeout: 250,
      maxRetriesPerRequest: 1,
    });
    
    // A subscribed connection can't run other commands, so it gets its own,
    // with default options so it keeps reconnecting.
    const sub = new Redis(process.env.REDIS_URL);
    const CHANNEL = 'cache:invalidate';
    
    // Compare-and-delete, as in Part 1: never release a lock you no longer own.
    const UNLOCK = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      end
      return 0
    `;
    
    // ---- Invalidation fan-out to in-process caches ----
    const listeners = new Set();
    sub.subscribe(CHANNEL).catch(err => logger.error({ err }, 'cache: subscribe failed'));
    sub.on('message', (_channel, key) => {
      for (const fn of listeners) {
        try { fn(key); } catch (err) { logger.warn({ err, key }, 'cache: listener failed'); }
      }
    });
    
    function onInvalidate(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    }
    
    // ---- Fail-open reads and writes ----
    async function readEntry(key) {
      const raw = await redis.get(key).catch(() => null);
      if (!raw) return null;
      try { return JSON.parse(raw); } catch { return null; }
    }
    
    async function writeEntry(key, value, { freshSec, staleSec }) {
      const now = Date.now();
      const entry = {
        value,
        freshUntil: now + freshSec * 1000,
        staleUntil: now + (freshSec + staleSec) * 1000,
      };
    
      await redis.set(key, JSON.stringify(entry), 'EX', freshSec + staleSec).catch(() => {});
      return value;
    }
    
    // ---- Single-flight: per process first, then across processes ----
    const inflight = new Map();
    
    function once(slot, fn) {
      const existing = inflight.get(slot);
      if (existing) return existing;
      const p = fn().finally(() => inflight.delete(slot));
      inflight.set(slot, p);
    
      return p;
    }
    
    async function rebuild(key, loader, opts, { wait }, attempt = 0) {
      const lockKey = `lock:${key}`;
      const token = randomUUID();
    
      // If Redis is down the SET rejects. Treat that as "got the lock" and just load.
      const got = await redis.set(lockKey, token, 'NX', 'EX', opts.lockSec).catch(() => 'OK');
      if (got === 'OK') {
        try {
          // Another instance may have finished a rebuild while we were getting the lock.
          const entry = await readEntry(key);
          if (entry && Date.now() < entry.freshUntil) return entry.value;
          return await writeEntry(key, await loader(), opts);
        } finally {
          await redis.eval(UNLOCK, 1, lockKey, token).catch(() => {});
        }
      }
    
      // Someone else holds the lock. A background refresh can leave the work to them.
      if (!wait) return undefined;
      await new Promise(r => setTimeout(r, 50));
      const entry = await readEntry(key);
      if (entry && Date.now() < entry.freshUntil) return entry.value;
      if (attempt >= 20) return loader(); // about a second of waiting: do it ourselves
    
      return rebuild(key, loader, opts, { wait }, attempt + 1);
    }
    
    // ---- Public API ----
    async function cacheAside(key, loader, { freshSec = 60, staleSec = 300, lockSec = 5 } = {}) {
      const opts = { freshSec, staleSec, lockSec };
      const entry = await readEntry(key);
      const now = Date.now();
      if (entry && now < entry.freshUntil) return entry.value;
      if (entry && now < entry.staleUntil) {
        // Stale but servable: return it now, refresh once in the background.
        once(`refresh:${key}`, () => rebuild(key, loader, opts, { wait: false }))
          .catch(err => logger.warn({ err, key }, 'cache: refresh failed'));
        return entry.value;
      }
    
      // Cold, or past staleUntil: the caller waits for a load.
      return once(`load:${key}`, () => rebuild(key, loader, opts, { wait: true }));
    }
    
    async function invalidate(key, { redeleteMs = 500 } = {}) {
      await redis.del(key).catch(() => {});
      await redis.publish(CHANNEL, key).catch(() => {});
    
      // Second delete catches a rebuild that read the old row before the write committed.
      setTimeout(() => redis.del(key).catch(() => {}), redeleteMs).unref();
    }
    module.exports = { cacheAside, invalidate, onInvalidate, redis };
    

    Al utilizarlo desde el código de la aplicación se requiere una llamada en la ruta de lectura y otra en la ruta de escritura:

    const { cacheAside, invalidate } = require('./cache');
    const user = await cacheAside(`user:${id}`, () => db.users.findById(id), {
      freshSec: 60,
      staleSec: 300,
    });
    
    async function updateUser(id, patch) {
      const updated = await db.users.update(id, patch);
      await invalidate(`user:${id}`);
      return updated;
    }
    

    Varias decisiones de diseño en el wrapper no son obvias, por lo que conviene leerlo detenidamente antes de adoptarlo.

    Ranuras separadas para operaciones de actualización y carga

    La actualización en segundo plano y la carga de bloqueo se identifican con prefijos diferentes: refresh: y load:. Cuando una actualización en segundo plano pierde la competencia por el bloqueo entre procesos, renuncia intencionadamente con undefined, ya que otra instancia está gestionando la reconstrucción. Si hay un llamante que realmente necesita un valor compartido en ese espacio, tampoco recibirá nada.

    Segunda lectura del caché después de obtener el bloqueo

    Una vez que rebuild() tiene el bloqueo, lee la entrada una vez más. Es posible que otro proceso haya completado la reconstrucción durante el tiempo de espera, y ejecutar el cargador dos veces desperdiciaría una consulta.

    Espereza limitada al obtener el bloqueo

    Un llamante que no puede obtener el bloqueo intenta conectarse cada 50 ms hasta un máximo de 20 veces, aproximadamente un segundo, y luego ejecuta el cargador por sí mismo. Unas pocas consultas duplicadas son preferibles a una cola de llamantes que nunca se vacía si el titular del bloqueo se detiene.

    Adquisición de bloqueo en modo de fallo abierto

    Si Redis no está disponible y SET NX rechaza la solicitud, el bloque .catch convierte ese rechazo en 'OK', y cada llamante procede con el cargador. Esto mantiene el servicio activo, pero también significa que la base de datos asume toda la carga hasta que Redis se recupera. Asegúrese de que pueda hacerlo.

    Ajuste de los valores predeterminados

    • lockSec debe ser mayor que la latencia p99 del cargador, para que el bloqueo no expire durante la reconstrucción.
    • redeleteMs también debe ser mayor que el tiempo de ejecución del cargador más lento. 500 ms son adecuados para una consulta indexada sencilla; una agregación en el panel de control que tarda cuatro segundos necesita un valor mayor.

    Qué se deja intencionalmente fuera

    No está incluida la expiración temprana probabilística. La función Stale-while-revalidate ya elimina el problema de la expiración abrupta para las claves que se leen con frecuencia, y combinar ambas en un único formato de entrada complica la determinación del momento en que se realiza la actualización. Para claves cuyos costos de reconstrucción son muy variables, una verificación probabilística puede reemplazar la comparación freshUntil en cacheAside().

    Dónde se encuentra cada aspecto en el código

    • Fallas, carga y almacenamiento: cacheAside() y rebuild()
    • Stale-while-revalidate: los campos freshUntil y staleUntil de cada entrada
    • Ejecución única por proceso: once()
    • Bloqueo entre procesos y desbloqueo seguro: rebuild() y el script Lua UNLOCK
  • Comportamiento de apertura en caso de fallo: .catch() en cada llamada a Redis, además de commandTimeout
  • Borrado al escribir con un segundo borrado diferido: invalidate()
  • Invalidación de la caché local: invalidate() y onInvalidate()
  • Errores comunes que generan datos obsoletos o dañados

    Estos se presentan aproximadamente ordenados según el daño que suelen causar.

    Tratar los errores de Redis como fatales

    Un simple await en una llamada a Redis lanza un error tan pronto como Redis queda inaccesible:

    const cached = await redis.get(key); // throws when Redis is unreachable
    

    La caché es una herramienta de optimización. Una interrupción en el servicio debería causar retrasos, nunca una oleada de respuestas 500. La solución mínima es convertir los fallos en errores de falta de caché:

    const cached = await redis.get(key).catch(() => null);
    

    Con los valores predeterminados de ioredis, el .catch por sí solo no es suficiente. Cuando se interrumpe la conexión, el cliente almacena en búfer las órdenes y sigue intentándolas, por defecto hasta 20 veces por solicitud, en lugar de fallar rápidamente, lo que hace que las solicitudes queden atascadas esperando a un servidor que ya no está disponible. Configurar commandTimeout y un valor bajo para maxRetriesPerRequest, como lo hace el cliente del wrapper, permite que las solicitudes lleguen al cargador de forma oportuna. Cada solicitud sigue incurrir en unos pocos cientos de milisegundos durante una interrupción, lo cual es tolerable en incidentes breves. Durante interrupciones prolongadas, un interruptor de circuito que deja de llamar a Redis tras varios errores consecutivos permite recuperar ese tiempo.

    Invalidación sin TTL

    Las eliminaciones se pierden: un proceso se bloquea después de realizar el commit, una implementación interrumpe una solicitud a mitad de camino, o una transmisión llega mientras un suscriptor está en proceso de reinicio. Sin TTL, cualquiera de estas situaciones deja una clave obsoleta en Redis indefinidamente. Asigne una fecha de vencimiento a cada clave, incluso si es larga, como un día. Las eliminaciones mantienen las entradas actualizadas la mayor parte del tiempo; la fecha de vencimiento es la medida de seguridad que garantiza que una entrada incorrecta no permanezca para siempre.

    Almacenar valores sin serializarlos

    Redis almacena cadenas de texto, por lo que pasar un objeto simple corrompe silenciosamente la entrada:

    await redis.set(key, JSON.stringify(value)); // correct
    await redis.set(key, value);                 // an object is stored as "[object Object]"
    

    Serialice con JSON.stringify al escribir y analice con JSON.parse al leer. Envuelva los valores desde el principio en un contenedor como { value, freshUntil, staleUntil }, incluso si al principio solo se utiliza value; agregar la opción de “stale-while-revalidate” más tarde no obliga a manejar dos formatos al mismo tiempo.

    También tome una decisión consciente respecto a los resultados null. El contenedor los almacena, una técnica llamada caché negativo, de modo que las solicitudes repetidas para una fila inexistente ya no lleguen a la base de datos, lo cual es generalmente deseable. En los casos en que una fila pueda existir poco después de no encontrarse, como por ejemplo un registro recién insertado consultado contra una réplica que está desactualizada, se deben utilizar fallos de caché con un tiempo de vencimiento más corto.

    Claves ilimitadas creadas a partir de la entrada del usuario

    Una clave derivada directamente de una cadena de consulta parece inofensiva:

    const key = `search:${req.query.q}`;
    

    Cada cadena de búsqueda única genera su propia clave, y cualquier cliente puede crear millones de ellas. Lo que ocurre a continuación está regulado por maxmemory-policy. Con allkeys-lru, la avalancha de solicitudes hace que las entradas realmente utilizadas sean eliminadas y las tasas de éxito caen drásticamente. Con noeviction, que es el valor utilizado por Redis autohospedado a menos que se configure de otra manera, se agota la memoria y las escrituras son rechazadas con errores de tipo OOM.

    Hashear la consulta no ayuda: un hash limita la longitud de cada clave, pero no el número total de claves. En su lugar, canonicézcalo la entrada (elimine espacios en blanco, convierta a minúsculas, ordene los parámetros) para que las búsquedas equivalentes apunten a una sola clave, mantenga estas entradas por poco tiempo y almacene en caché únicamente las búsquedas que son frecuentes o costosas. Configure maxmemory y una política de eliminación intencionadamente en lugar de utilizar los valores predeterminados.

    Compartir una instancia de Redis entre entornos

    Si la fase de pruebas y la producción comparten una instancia, ambos entornos leen y escriben en un user:42. Las instancias dedicadas son la verdadera solución; como alternativa, asigna un espacio de nombres a cada clave, algo que ioredis puede hacer por usted:

    const redis = new Redis(process.env.REDIS_URL, {
      keyPrefix: `${process.env.APP_ENV}:`,
    });
    

    Basa el prefijo en una variable diseñada específicamente para ello, como APP_ENV, ya que la fase de pruebas suele ejecutarse también con NODE_ENV=production. Ten en cuenta que keyPrefix solo cubre las claves, no los canales pub/sub, por lo que debes agregar tú mismo el entorno al nombre del canal de invalidación.

    Modificación de un valor en caché compartido

    Debido al mecanismo de ejecución única, las llamadas que ocurren al mismo tiempo obtienen un único objeto compartido en lugar de copias separadas:

    const a = cacheAside('user:42', load); // both wait on one promise
    const b = cacheAside('user:42', load);
    
    (await a).name = 'changed';
    console.log((await b).name); // 'changed'
    

    Cualquier dato obtenido de Redis se analiza para crear un objeto nuevo por cada llamante, por lo que el problema se limita al caso de las promesas compartidas, razón por la cual aparece con poca frecuencia y es difícil de reproducir. Considere los valores en caché como inmutables, o utilice structuredClone() antes de modificarlos. Congelar los valores devueltos con Object.freeze en el entorno de desarrollo hace que el código en modo estricto lance un error ante la asignación problemática en lugar de dañar los datos silenciosamente.

    Puntos clave

    • Establezca una fecha de vencimiento para cada clave: las eliminaciones mantienen los datos actualizados, mientras que las fechas de vencimiento determinan cuánto tiempo puede persistir un error.
    • Comience con el método de caché separado y eliminación al escribir: guarde los datos en la base de datos y luego elimine (nunca sobrescriba) la clave, fuera de cualquier transacción. Programe una eliminación adicional para las claves frecuentemente modificadas por los usuarios.
  • Activar el modo de fallo inmediato y configurar al cliente para que rechace las solicitudes rápidamente en lugar de ponerlas en cola mientras está desconectado.
  • Proteger las claves más utilizadas al leerlas; la combinación de “stale-while-revalidate” con un único proceso maneja la mayoría de los patrones de tráfico.
  • Usar pub/sub únicamente para cachés dentro del mismo proceso, y mantener los TTL locales cortos ya que los mensajes pueden perderse.
  • Reservar las claves versionadas para datos en los que la antigüedad constituya un error de corrección.
  • Limitar la cantidad de claves que pueden existir; el hashing solo limita su longitud.
  • Centralizar la caché en un único contenedor y ajustar sus valores predeterminados según las latencias medidas de los cargadores.
  • Una última advertencia: todo esto supone la existencia de un servidor Redis independiente. Las implementaciones Sentinel y Cluster se replican de forma asíncrona, y un cambio de fallo podría descartar las últimas escrituras, incluidas las claves de bloqueo de las que depende el mecanismo de envoltura. Tenga esto en cuenta antes de confiar en estos bloqueos para algo más que una coordinación básica.

    Lecturas relacionadas

  • Fundamentos del caché de Redis: Patrones, errores y conceptos en entrevistas — Aprenda cómo funciona el caché de Redis en aplicaciones Node.js, desde los métodos cache-aside y TTL hasta la protección contra colapsos, las políticas de eliminación y las preguntas comunes en entrevistas.
  • Colapsos en el caché en Node.js: Por qué un cache de Redis puede sobrecargar su base de datos — Entienda por qué una configuración básica de cache-aside con Redis sincroniza la carga en su base de datos cuando una clave importante vence, y cómo los bloqueos y las actualizaciones en segundo plano evitan esto.