DEV Community

Cover image for Caching: Tu primera gran victoria (10k-50k)
Norman Torres
Norman Torres

Posted on

Caching: Tu primera gran victoria (10k-50k)

Esto es fantasía (Parte 4).

En la parte anterior resolvimos el cuello de botella más obvio: la base de datos. Agregamos índices, separamos lecturas de escrituras con réplicas de RDS y metimos connection pooling. Con eso llegamos vivos a los 10,000 usuarios únicos al mes.

Para finales de abril ya estábamos empujando los 50,000.

Y apareció un problema distinto.

La base de datos ya no estaba “rota”. Los queries eran razonables. Las réplicas respondían. Pero seguíamos usando PostgreSQL para hacer trabajo repetido: recalcular el mismo dashboard, el mismo balance, la misma gráfica de 12 meses y el mismo reporte comparativo una y otra vez.

Cada apertura de la app era:

  • leer balances,
  • sumar transacciones,
  • recalcular categorías,
  • regenerar agregados del mes,
  • volver a armar el mismo JSON de respuesta.

Aunque nada hubiera cambiado desde hace 30 segundos.

Eso es absurdo. Si ya pagaste el costo de calcular algo y el dato sigue siendo válido, NO lo vuelvas a pagar.

El síntoma

Esta vez no teníamos un query monstruoso tirando la base abajo. Teníamos miles de consultas “normales” repitiéndose sin necesidad.

Los patrones eran clarísimos:

  • un usuario abría la app web y móvil al mismo tiempo;
  • el dashboard se refrescaba al volver al foreground;
  • los reportes de “últimos 12 meses” se recalculaban cada vez que tocaban un filtro;
  • durante horas pico, miles de usuarios pedían prácticamente la misma información una y otra vez.

El resultado:

  • las réplicas de lectura volvían a rozar el 90% de CPU;
  • el p95 del dashboard subía arriba de 1.5s;
  • la base hacía trabajo útil, sí, pero también muchísimo trabajo inútil.

Y ese es el tipo de problema que duele porque no se arregla comprando más CPU. Se arregla dejando de hacer trabajo repetido.

Paso 1: Elegir qué sí cachear

El error clásico con caché es querer guardar todo. Eso termina mal: más complejidad, datos viejos y bugs difíciles de rastrear.

Nuestra regla fue simple. Solo cacheamos datos que cumplieran estas tres condiciones:

  1. Son caros de calcular.
  2. Se leen muchas veces.
  3. Pueden tolerar unos segundos o minutos de desfase.

Con esa regla, los candidatos fueron obvios:

  • dashboard principal del usuario;
  • balances agregados;
  • reportes por rango de fechas;
  • breakdowns por categoría;
  • comparativos mes contra mes.

Y también quedó claro qué NO cachear:

  • autenticación;
  • permisos;
  • operaciones críticas de escritura;
  • movimientos recién capturados que el usuario espera ver reflejados al instante sin estrategia explícita de consistencia.

La caché no reemplaza la base de datos. La caché es una capa para evitar trabajo repetido, no una excusa para perder control de la verdad.

Paso 2: ElastiCache como memoria compartida

Podíamos haber cacheado en memoria dentro de cada instancia de la API. Suena tentador, pero era una trampa.

Teníamos múltiples instancias detrás de un load balancer. Si cada una guardaba su propia caché:

  • cada deploy enfriaba todo;
  • cada instancia tenía respuestas distintas;
  • un usuario podía pegarle a una instancia “caliente” y al siguiente request caer en otra “fría”.

Necesitábamos una caché compartida, rápida y administrada. Ahí entró Amazon ElastiCache for Redis.

                           ┌──────────────────────┐
                           │     ElastiCache      │
                           │       Redis          │
                           │   (hot data layer)   │
                           └──────────▲───────────┘
                                      │
                         GET / SET / EXPIRE / INCR
                                      │
        ┌─────────────────────────────┼─────────────────────────────┐
        │                             │                             │
┌───────▼────────┐           ┌────────▼────────┐           ┌────────▼────────┐
│   API A        │           │   API B         │           │   API C         │
│   NestJS       │           │   NestJS        │           │   NestJS        │
└───────▲────────┘           └────────▲────────┘           └────────▲────────┘
        │                             │                             │
        └─────────────────────────────┼─────────────────────────────┘
                                      │
                               Cache miss → DB
                                      │
                         ┌────────────▼────────────┐
                         │    RDS Writer/Readers    │
                         │ PostgreSQL + replicas    │
                         └──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

¿Por qué ElastiCache y no Redis autogestionado en una EC2?

  • porque queríamos alta disponibilidad sin andar parcheando infraestructura a mano;
  • porque queríamos métricas, failover y backups gestionados;
  • porque el problema ya era de escala, no de “hack rápido”.

A esta altura del juego, meter un componente crítico y administrarlo artesanalmente era comprar deuda operativa. No, gracias.

Paso 3: Patrones de caché que sí nos sirvieron

No existe “usar caché”. Existen patrones. Y elegir mal el patrón te mete en un pantano de inconsistencias.

1. Cache-aside para lecturas pesadas

Este fue el patrón principal.

La API pregunta primero a Redis. Si la clave existe, responde desde caché. Si no existe, consulta PostgreSQL, arma la respuesta y la guarda con TTL.

async function getDashboard(userId: string, month: string) {
  const version = await redis.get(`cache:version:user:${userId}:month:${month}`) ?? '1';
  const key = `dashboard:user:${userId}:month:${month}:v${version}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const dashboard = await reportsRepository.buildDashboard(userId, month);
  await redis.set(key, JSON.stringify(dashboard), 'EX', 300);

  return dashboard;
}
Enter fullscreen mode Exit fullscreen mode

¿Por qué funciona tan bien?
Porque deja a PostgreSQL como fuente de verdad y usa Redis solo como acelerador. Si Redis desaparece, el sistema sigue funcionando. Más lento, sí. Pero funciona.

2. Stale-while-revalidate para pantallas calientes

Para ciertas vistas hiperfrecuentes, como el home del usuario, preferimos devolver una respuesta apenas vencida en vez de hacer esperar a todos mientras recalculábamos.

La idea es simple:

  • si la entrada está fresca, la devolvés;
  • si está levemente vencida, devolvés la versión vieja;
  • disparás el recálculo en background;
  • el próximo request ya recibe el valor nuevo.

Esto baja muchísimo la latencia percibida y evita picos de carga cuando muchas personas piden la misma clave al mismo tiempo.

3. Request coalescing para evitar stampedes

Otro problema clásico: si una clave muy caliente expira, cincuenta requests hacen miss al mismo tiempo y los cincuenta se van a la base. Felicitaciones: acabás de transformar Redis en un decorado.

Para evitar eso usamos un lock corto por clave (SET NX EX). Uno recalcula. Los demás esperan unos milisegundos o reintentan.

Sin eso, la caché puede colapsar exactamente en el momento en que más la necesitás.

Paso 4: Invalidación inteligente

Acá está la parte que separa una caché útil de una caché peligrosa.

Todo el mundo ama hablar de hits. Nadie quiere hablar de invalidación. Pero la invalidación es EL problema.

Al principio hicimos lo más básico: poner TTL de 15 minutos. Eso sirvió como red de seguridad, pero no alcanzaba.

Porque TTL no es una estrategia de consistencia. TTL solo pone un límite al desastre.

Si un usuario registra un gasto nuevo, no podés decirle “bueno, en 15 minutos tu dashboard se acomoda”. Tenés que invalidar lo que cambió.

Lo que NO hicimos

No usamos FLUSHALL.
No borramos Redis completo por cada escritura.
No hicimos SCAN por patrones gigantes en producción.

Eso puede funcionar con 500 claves. Con cientos de miles, es una receta para pegarte un tiro en el pie.

Lo que sí hicimos

Diseñamos las claves con contexto suficiente para invalidar por partes:

balance:user:42
summary:user:42:month:2026-04
report:user:42:range:2026-01-01:2026-04-30
category-breakdown:user:42:month:2026-04
Enter fullscreen mode Exit fullscreen mode

Y encima metimos versionado por segmento para no tener que perseguir claves viejas una por una:

cache:version:user:42:dashboard => 18
cache:version:user:42:month:2026-04 => 7
Enter fullscreen mode Exit fullscreen mode

Entonces la clave final queda así:

dashboard:user:42:month:2026-04:v7
Enter fullscreen mode Exit fullscreen mode

Cuando entra una transacción nueva en abril, no borramos todo. Solo incrementamos la versión del usuario y del período afectado. Las claves viejas quedan obsoletas y expiran solas por TTL.

async function registerTransaction(input: CreateTransactionInput) {
  await db.write.tx(async (tx) => {
    await tx.insertTransaction(input);

    await Promise.all([
      redis.incr(`cache:version:user:${input.userId}:dashboard`),
      redis.incr(`cache:version:user:${input.userId}:month:${input.month}`),
      redis.del(`balance:user:${input.userId}`),
    ]);
  });
}
Enter fullscreen mode Exit fullscreen mode

Eso nos permitió algo CLAVE: invalidar solo lo necesario.

Si cambia una transacción de abril para el usuario 42:

  • invalidamos abril del usuario 42;
  • invalidamos su dashboard;
  • tal vez su balance actual;
  • pero NO tocamos los reportes de otro usuario;
  • NO borramos marzo;
  • NO vaciamos toda la caché.

Eso es invalidación inteligente: precisión quirúrgica en vez de martillazos.

El resultado

Después de meter ElastiCache, aplicar cache-aside, controlar stampedes y dejar de invalidar a lo bruto, los números cambiaron fuerte.

Métrica Antes Después
p95 dashboard 1.8s 120ms
p99 reportes pesados 6.2s 900ms
CPU en readers de RDS 85-90% 30-40%
Queries repetidas a la DB Altísimas Mucho menores
Cache hit ratio 0% 78-92%

La mejora más importante ni siquiera fue la latencia. Fue que la base de datos volvió a hacer trabajo con sentido.

PostgreSQL dejó de recalcular la misma película cien veces por hora.

El costo del crecimiento

La caché no es gratis. Pero comparado con seguir escalando réplicas para resolver trabajo repetido, salió baratísima.

Concepto Costo mensual (estimado)
2x EC2 t3.small (API) ~$30.00 USD
RDS Writer (db.r6g.xlarge) ~$180.00 USD
3x RDS Reader (db.r6g.medium) ~$135.00 USD
ElastiCache Redis (primary + replica) ~$65 - $80 USD
Application Load Balancer ~$20.00 USD
Data Transfer & Storage ~$20 - $30 USD
Total ~$450 - $475 USD

Sí, el costo sube otra vez.

Pero ahora estamos pagando por una capa que reduce latencia, libera a la base de datos y nos compra margen real para seguir creciendo. MUY distinto a seguir tirándole CPU a un problema mal modelado.

Lo que aprendimos

  1. La caché no corrige una mala arquitectura. Primero optimizás consultas y modelado. Después cacheás.
  2. TTL no alcanza. Sin invalidación inteligente, tarde o temprano servís datos viejos donde no debés.
  3. No todo merece caché. Si cacheás indiscriminadamente, convertís un sistema simple en uno opaco.
  4. El diseño de claves importa muchísimo. Si no podés nombrar bien una clave, probablemente tampoco vas a invalidarla bien.
  5. La mejor caché es la que falla sin romper el sistema. Redis acelera. PostgreSQL sigue mandando.

¿Qué sigue?

Con 50,000 usuarios, ya no estamos peleando solo contra lecturas repetidas. Ahora aparece otro problema: trabajos pesados que no deberían vivir dentro del request/response.

Importaciones masivas, recálculos históricos, generación de reportes complejos, webhooks y procesos asincrónicos empiezan a pedir su propio espacio.

La próxima victoria ya no viene de responder más rápido, sino de sacar trabajo del camino del usuario.

Top comments (0)