Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|19 de março de 202611 min de leitura2.4k Palavras|

Parte da nossa série Performance & Scalability

Leia o guia completo

Padrões de cache Redis para aplicativos da Web

As consultas ao banco de dados são o gargalo de desempenho mais comum em aplicações web. Uma consulta mal indexada que leva 200 ms, chamada a cada carregamento de página para 1.000 usuários simultâneos, gera 200 segundos de CPU de banco de dados por segundo – uma espiral mortal. Redis é o antídoto: um armazenamento na memória de menos de um milissegundo que absorve a carga de leitura que, de outra forma, atingiria seu banco de dados repetidamente para obter dados idênticos.

Mas o cache não é apenas “colocar coisas no Redis”. O padrão errado causa bugs de dados obsoletos, desastres estrondosos ou crescimento ilimitado de memória. Este guia aborda os quatro padrões canônicos de cache, estratégias de invalidação de cache, prevenção de debandada e implementação de produção do NestJS para que você armazene o cache corretamente desde o início.

Principais conclusões

  • Cache-aside (carregamento lento) é o padrão mais seguro - armazena em cache apenas o que é realmente solicitado
  • Write-through mantém o cache e o banco de dados sincronizados, mas adiciona latência de gravação — use para dados lidos com frequência e gravados com pouca frequência
  • A debandada de cache (rebanho trovejante) destrói bancos de dados quando uma chave popular expira — use expiração antecipada probabilística ou bloqueios mutex
  • O TTL deve ser definido em todas as chaves — o crescimento ilimitado do Redis é um incidente esperando para acontecer
  • A invalidação de cache é difícil: prefira TTLs curtos e invalidação orientada a eventos a tentar invalidar perfeitamente
  • As chaves de cache devem incluir todas as dimensões da consulta: contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}
  • Use namespaces e padrões de chave do Redis para ativar a invalidação em massa
  • Monitorar taxa de acerto do cache; abaixo de 80% significa que seus TTLs são muito curtos ou que sua estrutura de chave está errada

Configuração do Redis no NestJS

pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) return null;
    return JSON.parse(value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  // Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
  async invalidatePattern(pattern: string): Promise<void> {
    let cursor = '0';
    do {
      const [nextCursor, keys] = await this.redis.scan(
        cursor,
        'MATCH', pattern,
        'COUNT', 100
      );
      cursor = nextCursor;
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    } while (cursor !== '0');
  }

  async remember<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>
  ): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) return cached;

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';

@Global()
@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'single',
        options: {
          host: config.get('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get('REDIS_PASSWORD'),
          db: config.get<number>('REDIS_DB', 0),
          keyPrefix: 'ecosire:',
        },
      }),
    }),
  ],
  providers: [CacheService],
  exports: [CacheService],
})
export class AppCacheModule {}

Padrão 1: Cache-Aside (carregamento lento)

O padrão mais comum. Leia do cache; em caso de falha, leia o banco de dados e preencha o cache.

// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
  const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;

  return this.cache.remember(cacheKey, 300, async () => {
    const results = await this.db.query.contacts.findMany({
      where: and(
        eq(contacts.organizationId, orgId),
        search
          ? or(
              ilike(contacts.name, `%${search}%`),
              ilike(contacts.email, `%${search}%`)
            )
          : undefined
      ),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });
    return results;
  });
}

Prós: Simples; cache contém apenas o que é realmente solicitado. Contras: A primeira solicitação é sempre lenta (cache miss); os dados podem ficar obsoletos até que o TTL expire.

Quando invalidar: Após criar/atualizar/excluir, exclua todas as chaves correspondentes a contacts:{orgId}:*.


Padrão 2: Write-Through

Atualize o cache simultaneamente com o banco de dados em cada gravação. Troca a latência de gravação pela consistência de leitura.

async update(orgId: string, contactId: string, dto: UpdateContactDto) {
  // 1. Update the database
  const [updated] = await this.db
    .update(contacts)
    .set({ ...dto, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, contactId),
        eq(contacts.organizationId, orgId)
      )
    )
    .returning();

  // 2. Update the individual contact cache immediately (write-through)
  const singleKey = `contact:${orgId}:${contactId}`;
  await this.cache.set(singleKey, updated, 3600);

  // 3. Invalidate list caches (they now contain stale order/counts)
  await this.cache.invalidatePattern(`contacts:${orgId}:*`);

  return updated;
}

Quando usar: registros lidos com frequência e escritos com pouca frequência (perfis de usuário, configurações, catálogo de produtos).


Padrão 3: Prevenção de estouro de cache

Quando uma chave de cache popular expira, centenas de solicitações simultâneas perdem o cache simultaneamente e atingem o banco de dados - o "rebanho trovejante". O banco de dados entra em colapso sob a carga repentina.

Padrão de bloqueio mutex

Apenas uma solicitação reconstrói o cache; outros esperam e tentam novamente:

// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>
): Promise<T> {
  // Try cache first
  const cached = await this.get<T>(key);
  if (cached !== null) return cached;

  const lockKey = `lock:${key}`;
  const lockOwner = `${process.pid}-${Date.now()}`;

  // Try to acquire lock: NX = only set if not exists, EX = expire in 30s
  const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);

  if (acquired === 'OK') {
    try {
      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      // Atomic check-and-delete: only release if we still own the lock
      // Uses Lua defined with defineCommand for atomicity
      const currentOwner = await this.redis.get(lockKey);
      if (currentOwner === lockOwner) {
        await this.redis.del(lockKey);
      }
    }
  } else {
    // Another process is rebuilding — wait 100ms and retry
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, 100);
      // Store timer reference so Node.js does not block on it
      timer.unref?.();
    });
    return this.getWithLock(key, ttlSeconds, factory);
  }
}

Expiração antecipada probabilística (XFetch)

Comece a reconstruir o cache probabilisticamente antes que ele expire, espalhando a carga de recomputação:

async getWithEarlyExpiration<T>(
  key: string,
  ttlSeconds: number,
  factory: () => Promise<T>,
  beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
  const metaKey = `${key}:meta`;
  const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
  const value = await this.get<T>(key);

  if (value !== null && meta) {
    const elapsed = Date.now() / 1000 - meta.createdAt;
    const remaining = meta.ttl - elapsed;

    // XFetch: trigger early recomputation probabilistically
    const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
    if (!shouldRecompute) return value;
  }

  const newValue = await factory();
  const now = Date.now() / 1000;
  await this.set(key, newValue, ttlSeconds);
  await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);

  return newValue;
}

Padrão 4: invalidação de cache baseada em tags

Agrupe chaves de cache por tags lógicas e invalide uma tag inteira de uma só vez:

// Tag-based invalidation using Redis sets
async setWithTags(
  key: string,
  value: unknown,
  ttlSeconds: number,
  tags: string[]
): Promise<void> {
  await this.set(key, value, ttlSeconds);

  // Add key to each tag's member set using a pipeline
  const pipeline = this.redis.pipeline();
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
  }
  await pipeline.exec();
}

async invalidateByTag(tag: string): Promise<void> {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = this.redis.pipeline();
    for (const cacheKey of keys) {
      pipeline.del(cacheKey);
    }
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

// Usage
await this.setWithTags(
  `contacts:${orgId}:page:1`,
  contacts,
  300,
  [`org:${orgId}`, 'contacts']
);

// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);

Cache de sessão e autenticação

O Redis é ideal para armazenamento de sessões de autenticação — elimine pesquisas no banco de dados em todas as solicitações:

// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
  const key = `session:${userId}`;
  await this.redis.hset(key,
    'id',             session.id,
    'email',          session.email,
    'role',           session.role,
    'organizationId', session.organizationId,
    'tokenVersion',   String(session.tokenVersion)
  );
  await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}

async getUserSession(userId: string): Promise<UserSession | null> {
  const data = await this.redis.hgetall(`session:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
  return {
    ...data,
    tokenVersion: parseInt(data.tokenVersion, 10),
  } as UserSession;
}

async invalidateUserSession(userId: string): Promise<void> {
  await this.redis.del(`session:${userId}`);
}

Estratégia TTL por tipo de dados

Tipo de dadosTTL recomendadoEstratégia de invalidação
Perfil de usuário15 minutosNa atualização do perfil
Configurações da organização1 horaNa alteração das configurações
Catálogo de produtos24 horasNa criação/atualização do produto
Lista de postagens do blog10 minutosNa postagem, publicar
Contador de limite de taxa APIPor janela (anos 60)Expiração automática
Sessão de autenticação15 minutosAo sair ou revogar o token
Resultados da pesquisa5 minutosApenas TTL
Métricas agregadas1 minutoApenas TTL
Códigos únicos (auth)60 segundosApós utilização ou expiração
Consulta de lista paginada5 minutosEm qualquer mutação em org

Monitorando o desempenho do cache

// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
  const value = await this.redis.get(key);
  if (value) {
    await this.redis.incr('metrics:cache_hits');
    return JSON.parse(value) as T;
  }
  await this.redis.incr('metrics:cache_misses');
  return null;
}

// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
  const [hits, misses] = await this.redis.mget(
    'metrics:cache_hits',
    'metrics:cache_misses'
  );
  const h = parseInt(hits ?? '0', 10);
  const m = parseInt(misses ?? '0', 10);
  const total = h + m;
  const hitRate = total === 0 ? 1 : h / total;

  if (hitRate < 0.8) {
    this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
  }

  // Reset counters
  await this.redis.set('metrics:cache_hits', '0');
  await this.redis.set('metrics:cache_misses', '0');
}

Principais métricas do Redis para monitorar via redis-cli INFO stats:

  • keyspace_hits / keyspace_misses — taxa de acerto global (meta acima de 80%)
  • used_memory — observe o crescimento ilimitado
  • evicted_keys — chaves removidas pela política maxmemory (devem estar próximas de 0 para cache-aside)
  • connected_clients — utilização do pool de conexões
  • instantaneous_ops_per_sec — rendimento atual

Perguntas frequentes

Qual política de maxmemory devo usar para um cache?

Use allkeys-lru (remova as chaves usadas menos recentemente de todas as chaves) ou volatile-lru (remova as chaves LRU que possuem um TTL definido). Para uma carga de trabalho de cache pura, allkeys-lru é padrão – o Redis despeja automaticamente as chaves frias quando a memória está cheia. Nunca use noeviction para cache — ele retorna erros quando a memória está cheia em vez de despejar dados antigos.

Como evito armazenar dados confidenciais no Redis?

Nunca armazene em cache senhas brutas, chaves privadas, dados de cartões de pagamento ou SSNs. Para sessões de autenticação, armazene em cache os dados mínimos: userId, role, OrganizationId, tokenVersion — não cargas completas de JWT ou credenciais de API. Habilite Redis AUTH e TLS em produção. Se a sua instância do Redis estiver comprometida, apenas os metadados serão expostos quando você seguir esta disciplina.

Qual é a diferença entre SCAN e KEYS para exclusão de padrões?

KEYS pattern é uma operação de bloqueio O(n) que pausa todos os comandos do Redis durante a verificação — pode causar segundos de inatividade em grandes espaços de chave. SCAN não é bloqueador, iterando em pequenos pedaços com um cursor. Sempre use SCAN para exclusão do padrão de produção. A desvantagem é que SCAN pode não retornar todas as chaves se elas forem adicionadas ou excluídas durante a varredura – aceitável para invalidação de cache.

Devo usar uma instância separada do Redis para armazenamento em cache versus limitação de taxa versus sessões?

Para a maioria dos aplicativos, uma instância do Redis com diferentes prefixos de chave é adequada. Instâncias separadas fazem sentido quando: o cache precisa de um maxmemory-policy diferente do armazenamento de limite de taxa (cache: allkeys-lru, limites de taxa: noeviction) ou quando você precisa de escalabilidade e failover independentes. Em escala, use o Redis Cluster com clusters separados por tipo de carga de trabalho.

Como lidar com a invalidação de cache para consultas de lista paginada?

Os caches de listas paginadas são complicados – adicionar um contato na página 1 desloca todos os contatos nas páginas 2+. A solução pragmática: use TTLs curtos (2 a 5 minutos) e invalide todas as páginas da organização em qualquer gravação usando a invalidação de padrão (contacts:{orgId}:*). Para grandes organizações com grandes volumes de gravação, ignore totalmente a paginação do cache e confie na otimização no nível do banco de dados (índices adequados, índices de cobertura).

Como faço para testar o comportamento do cache no Vitest?

Simule o CacheService em testes de unidade com vi.fn() retornando valores apropriados para cenários de acerto/erro. Para testes de integração, use uma instância real do Redis (Docker) e use redis.flushdb() em beforeEach para começar do zero. Teste explicitamente o caminho de cache-hit (sem chamada de banco de dados) e o caminho de cache-miss (banco de dados chamado + cache preenchido).


Próximas etapas

O cache Redis é um dos investimentos de desempenho com maior ROI em um aplicativo da web. A diferença entre uma consulta de banco de dados de 200 ms e um acerto de 2 ms no Redis é de 100 vezes — em escala, isso se traduz diretamente em economia de custos de infraestrutura e melhoria da experiência do usuário.

ECOSIRE implementa cache Redis com cache-aside, invalidação de gravação, prevenção de stampede e monitoramento de taxa de acerto de cache em cada projeto NestJS. Explore nossos serviços de engenharia de back-end para saber como projetamos APIs escalonáveis ​​e de alto desempenho.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp