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
Permission receipt para Claude Code: alcance, prueba y rollback
Patrón de permission receipt para Claude Code: acciones permitidas, aprobación, pruebas, rollback y CTA de ingresos.
Agent Harness seguro para Claude Code y Codex: permisos, verificacion y rollback
Diseña un Agent Harness seguro para Claude Code y Codex con permisos, plan, verificaciones y rollback.
Subagentes de Claude Code: guía práctica para delegar trabajo de forma segura
Guía práctica de subagentes en Claude Code para dividir artículos y código: reglas, prompts, riesgos y checklist.