Redis caching con Claude Code: TTL, invalidación y anti-stampede
Guía práctica para diseñar Redis caching con Claude Code: keys, TTL, invalidación, Node.js, pruebas y checklist de revisión.
Redis es un almacén clave-valor rápido en memoria: guarda datos en RAM y los recupera por key. Es útil para agregados de base de datos, respuestas de APIs externas, rankings, sesiones cortas y rate limits. También es fácil romper una aplicación con él si Claude Code no recibe una política clara: precios obsoletos, inventario incorrecto, datos de usuario en caché compartida o Redis bloqueado por búsquedas amplias.
Esta guía convierte Redis caching en un flujo de trabajo para Claude Code: política de caché, diseño de key, TTL, invalidación, prevención de cache stampede, implementación Node.js, pruebas y checklist de review. Para una vista de varias capas, lee Claude Code caching strategies. Si Redis también se usa para jobs, revisa Claude Code queue system.
flowchart LR
Request["HTTP request"] --> Cache["Redis cache"]
Cache -->|hit| Response["Fast response"]
Cache -->|miss| Lock["Short lock"]
Lock --> Loader["DB or external API"]
Loader --> Cache
Loader --> Response
Admin["Update event"] --> Invalidate["Delete known keys"]
Invalidate --> Cache
Política antes del código
Antes de implementar, escribe una tabla de política en CLAUDE.md o en el prompt. Claude Code puede leer el flujo de datos, pero no puede adivinar cuánto stale data tolera el negocio.
| Dato | Key de ejemplo | TTL | Invalidación | Riesgo |
|---|---|---|---|---|
| Lista pública de productos | claudecodelab:v1:products:list:es | 5 min | Borrar tras actualizar producto | No dejar cambios de precio solo al TTL |
| Artículo | claudecodelab:v1:posts:item:{slug} | 10 min | Publicar, editar o despublicar | No cachear drafts ni previews |
| Métricas admin | claudecodelab:v1:analytics:daily:{date} | 30 s | Normalmente TTL | No usar como fuente contable |
| API externa | claudecodelab:v1:exchange-rate:usd-eur | 1 a 15 min | TTL o refresh manual | Revisar términos del proveedor |
| Datos de usuario logueado | Excluir por defecto | 0 s | Ninguna | Evitar caché compartida |
La regla: datos públicos, repetibles y regenerables son buenos candidatos; permisos, facturación, autenticación y datos personales quedan fuera salvo que haya un diseño de seguridad explícito.
Prompt para Claude Code
Añade una capa Redis cache-aside a esta app Node.js.
Requisitos:
1. Usar el paquete oficial node-redis llamado redis
2. Construir keys como claudecodelab:v1:{domain}:{resource}:{id}
3. Elegir TTL desde la política de caché y añadir jitter de hasta 10%
4. Tras updates, borrar keys relacionadas solo después de que el DB write tenga éxito
5. No usar KEYS en producción; usar keys conocidas, SCAN o sets de keys relacionadas
6. Evitar cache stampede con un lock corto para keys populares
7. Añadir pruebas node:test para key, rango TTL, getOrSet y misses concurrentes
Devuelve:
- Archivos modificados
- Pruebas ejecutadas
- Datos excluidos de caché y motivo
Incluye estas reglas en tu checklist de code review con Claude Code para revisar invalidación, no solo velocidad.
Implementación Node.js
El cliente oficial para Node.js es el paquete redis; el patrón está en la node-redis guide.
mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install redis
docker run --name redis-cache-demo -p 6379:6379 -d redis:7-alpine
Centraliza keys y TTL.
// cache-policy.js
const CACHE_PREFIX = "claudecodelab";
const CACHE_VERSION = "v1";
const CACHE_POLICY = {
productList: { ttl: 300, jitter: 30 },
productItem: { ttl: 600, jitter: 60 },
dailyStats: { ttl: 30, jitter: 5 },
};
function normalizePart(value) {
const part = String(value).trim().toLowerCase();
if (part.length === 0) {
throw new Error("cache key part must not be empty");
}
return encodeURIComponent(part);
}
function cacheKey(parts) {
if (!Array.isArray(parts) || parts.length === 0) {
throw new Error("cacheKey requires a non-empty parts array");
}
return [CACHE_PREFIX, CACHE_VERSION, ...parts.map(normalizePart)].join(":");
}
function ttlWithJitter(baseSeconds, maxJitterSeconds = 30) {
if (!Number.isInteger(baseSeconds) || baseSeconds <= 0) {
throw new Error("base TTL must be a positive integer");
}
const jitter = Math.max(0, Math.floor(maxJitterSeconds));
return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}
module.exports = { CACHE_POLICY, cacheKey, ttlWithJitter };
Usa un cliente Redis compartido.
// redis-client.js
const { createClient } = require("redis");
const redis = createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
redis.on("error", (error) => {
console.error("Redis Client Error", error);
});
let connecting;
async function getRedis() {
if (redis.isOpen) return redis;
if (!connecting) {
connecting = redis.connect();
}
await connecting;
return redis;
}
async function closeRedis() {
if (redis.isOpen) {
await redis.quit();
}
connecting = undefined;
}
module.exports = { getRedis, closeRedis };
El helper usa cache-aside con JSON, TTL y un lock corto SET NX PX.
// redis-cache.js
const { randomUUID } = require("node:crypto");
const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class RedisJsonCache {
constructor(redis, options = {}) {
this.redis = redis;
this.defaultTtl = options.defaultTtl || 300;
this.lockMs = options.lockMs || 5000;
this.waitMs = options.waitMs || 50;
this.waitRetries = options.waitRetries || 10;
}
async get(key) {
const raw = await this.redis.get(key);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
await this.redis.del(key);
return null;
}
}
async set(key, value, ttlSeconds = this.defaultTtl) {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error("ttlSeconds must be a positive integer");
}
await this.redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
}
async invalidate(keys) {
const list = Array.isArray(keys) ? keys : [keys];
if (list.length === 0) return 0;
return this.redis.del(list);
}
async getOrSet(key, ttlSeconds, loader) {
const cached = await this.get(key);
if (cached !== null) {
return { value: cached, cacheStatus: "hit" };
}
const lockKey = `${key}:lock`;
const token = randomUUID();
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: this.lockMs });
if (acquired === "OK") {
try {
const fresh = await loader();
await this.set(key, fresh, ttlSeconds);
return { value: fresh, cacheStatus: "miss" };
} finally {
await this.redis.eval(UNLOCK_SCRIPT, { keys: [lockKey], arguments: [token] });
}
}
for (let attempt = 0; attempt < this.waitRetries; attempt += 1) {
await sleep(this.waitMs);
const afterWait = await this.get(key);
if (afterWait !== null) {
return { value: afterWait, cacheStatus: "hit-after-wait" };
}
}
const fallback = await loader();
await this.set(key, fallback, Math.max(5, Math.floor(ttlSeconds / 3)));
return { value: fallback, cacheStatus: "miss-after-timeout" };
}
}
module.exports = { RedisJsonCache };
Ejemplo ejecutable. En una app real, cambia loadProductsFromDb() por Prisma, Supabase o tu API. Recursos relacionados: Prisma ORM with Claude Code y Supabase integration with Claude Code.
// demo-products.js
const { CACHE_POLICY, cacheKey, ttlWithJitter } = require("./cache-policy");
const { getRedis, closeRedis } = require("./redis-client");
const { RedisJsonCache } = require("./redis-cache");
const db = {
products: [
{ id: "p1", locale: "es", name: "Plantilla CLAUDE.md", price: 9, published: true },
{ id: "p2", locale: "es", name: "Formación Claude Code", price: 199, published: true },
],
};
async function loadProductsFromDb(locale) {
await new Promise((resolve) => setTimeout(resolve, 80));
return db.products.filter((product) => product.locale === locale && product.published);
}
async function listPublishedProducts(cache, locale) {
const key = cacheKey(["products", "list", locale]);
const ttl = ttlWithJitter(CACHE_POLICY.productList.ttl, CACHE_POLICY.productList.jitter);
return cache.getOrSet(key, ttl, () => loadProductsFromDb(locale));
}
async function main() {
const redis = await getRedis();
const cache = new RedisJsonCache(redis);
const first = await listPublishedProducts(cache, "es");
const second = await listPublishedProducts(cache, "es");
console.log({ firstStatus: first.cacheStatus, secondStatus: second.cacheStatus, products: second.value });
await cache.invalidate([cacheKey(["products", "list", "es"])]);
await closeRedis();
}
main().catch(async (error) => {
console.error(error);
await closeRedis();
process.exitCode = 1;
});
node demo-products.js
Pruebas
Prueba la key, el TTL, el hit y el miss concurrente.
// redis-cache.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { cacheKey, ttlWithJitter } = require("./cache-policy");
const { RedisJsonCache } = require("./redis-cache");
class FakeRedis {
constructor() {
this.store = new Map();
}
async get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (entry.expiresAt && entry.expiresAt <= Date.now()) {
this.store.delete(key);
return null;
}
return entry.value;
}
async set(key, value, options = {}) {
if (options.NX && (await this.get(key)) !== null) return null;
const ttlMs = options.PX || (options.EX ? options.EX * 1000 : 0);
this.store.set(key, { value, expiresAt: ttlMs ? Date.now() + ttlMs : 0 });
return "OK";
}
async del(keys) {
const list = Array.isArray(keys) ? keys : [keys];
let deleted = 0;
for (const key of list) {
if (this.store.delete(key)) deleted += 1;
}
return deleted;
}
async eval(_script, options) {
const [key] = options.keys;
const [token] = options.arguments;
if ((await this.get(key)) === token) return this.del(key);
return 0;
}
}
test("cacheKey encodes dynamic parts", () => {
assert.equal(cacheKey(["Products", "List", "es/ES"]), "claudecodelab:v1:products:list:es%2Fes");
});
test("ttlWithJitter stays inside the expected range", () => {
for (let i = 0; i < 50; i += 1) {
const ttl = ttlWithJitter(300, 30);
assert.ok(ttl >= 300);
assert.ok(ttl <= 330);
}
});
test("getOrSet caches the first loader result", async () => {
const cache = new RedisJsonCache(new FakeRedis());
let loads = 0;
const first = await cache.getOrSet("products:list", 60, async () => {
loads += 1;
return [{ id: "p1" }];
});
const second = await cache.getOrSet("products:list", 60, async () => {
loads += 1;
return [{ id: "p2" }];
});
assert.equal(first.cacheStatus, "miss");
assert.equal(second.cacheStatus, "hit");
assert.equal(loads, 1);
assert.deepEqual(second.value, [{ id: "p1" }]);
});
test("getOrSet waits instead of running duplicate loaders", async () => {
const cache = new RedisJsonCache(new FakeRedis(), { waitMs: 5, waitRetries: 20 });
let loads = 0;
const loader = async () => {
loads += 1;
await new Promise((resolve) => setTimeout(resolve, 20));
return { total: 42 };
};
const results = await Promise.all([
cache.getOrSet("analytics:daily", 30, loader),
cache.getOrSet("analytics:daily", 30, loader),
]);
assert.equal(loads, 1);
assert.deepEqual(results[0].value, { total: 42 });
assert.deepEqual(results[1].value, { total: 42 });
});
node --test redis-cache.test.js
Casos de uso
Catálogos públicos y listas de artículos son el primer caso: muchos lectores reciben la misma respuesta y las keys son fáciles de borrar tras una edición.
Dashboards admin son el segundo caso. PV, conversión e ingresos preliminares pueden estar unos segundos o minutos atrasados; permisos, facturas y seguridad no.
APIs externas son el tercer caso. Tipo de cambio, clima, planes SaaS y metadatos públicos pueden cachearse brevemente si los términos lo permiten.
Rate limits son el cuarto caso. INCR y EXPIRE funcionan bien para ventanas cortas; autenticación requiere revisión de seguridad separada.
Errores comunes
Una key incompleta reutiliza respuestas incorrectas. Si idioma, moneda, tenant, rol, query o versión cambian el resultado, deben estar en la key.
No borres la caché antes del write en DB. El orden seguro es write exitoso, borrar keys Redis conocidas y luego purgar CDN si aplica.
Evita KEYS user:* en producción. Usa keys conocidas, sets de keys relacionadas o comandos de mantenimiento con SCAN.
Si necesitas cachear “no encontrado”, guarda { found: false }; el helper anterior trata null como miss.
Checklist de review
- Datos personales, permisos y facturación excluidos o justificados
- Keys completas para locale, tenant, rol, query y versión
- TTL explicado por frescura de negocio
- Invalidación después del write exitoso
- Sin
KEYSen producción - Lock, jitter o fallback contra stampede
- Pruebas de key, TTL, hit y miss concurrente
- Métricas o logs de hit rate
Referencias y siguiente paso
Incluye en las tareas de Claude Code la Redis documentation, node-redis guide, Redis patterns y Redis optimization.
Para convertir esto en flujo de equipo, empieza por productos y plantillas de ClaudeCodeLab. Si necesitas mapear Redis, CDN, writes de DB y observabilidad en un repositorio real, usa formación y consultoría Claude Code.
En el demo Node.js de Masa, la primera petición llamó al loader, la segunda fue Redis hit y dos misses concurrentes ejecutaron el loader una sola vez. El mayor beneficio fue escribir key, TTL, invalidación y criterios de review antes de pedir cambios a Claude Code.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Crea un log de presupuesto para Claude Code antes de que el coste se vuelva borroso
Registra quién usa Claude Code, para qué trabajo y qué resultado produjo en el equipo.
Revisión de 3 minutos antes del commit: confirma qué tocó Claude Code
Cómo detectar en 3 minutos los cambios que Claude Code amplió por su cuenta antes del commit: alcance, diff, prueba y stage selectivo.
El registro de riesgos antes de llevar Claude Code a tu equipo
Cómo armar un registro de riesgos que evita accidentes de permisos, CI y publicación al llevar Claude Code a tu equipo.