Voltar para Artigos
Back-end13 min de leitura

Escalando APIs com Cache no Redis

Cache-Aside, Write-Through e Write-Around explicados com código real. Estratégias de invalidação, prefixos, TTL dinâmico e como evitar Cache Stampede em produção.

12 de agosto de 2026

Sua rota de listagem de produtos é chamada 500 vezes por minuto. Cada chamada executa um JOIN entre 3 tabelas, aplica filtros, ordena por relevância e retorna 50 itens. O banco de dados executa essa query 500 vezes por minuto — todas idênticas, todas retornando o mesmo resultado porque ninguém atualizou nada. Isso é desperdício puro de I/O e CPU.

O Redis resolve isso armazenando o resultado na RAM — a camada de memória mais rápida que existe. Uma leitura do banco pode levar 50ms; a mesma leitura do Redis leva 0.5ms. Mas cache não é só set e get: você precisa entender os padrões de escrita, estratégias de invalidação e como evitar problemas como Cache Stampede e dados obsoletos.

Configuração do Cliente Redis (ioredis)

bash
npm install ioredis
npm install -D @types/ioredis
RedisClient.ts
import Redis from 'ioredis';

// Singleton: uma única conexão reutilizada em toda a aplicação
let client: Redis | null = null;

export function getRedisClient(): Redis {
  if (!client) {
    client = new Redis({
      host: process.env.REDIS_HOST ?? 'localhost',
      port: Number(process.env.REDIS_PORT ?? 6379),
      password: process.env.REDIS_PASSWORD,
      db: Number(process.env.REDIS_DB ?? 0),

      // Reconexão automática em caso de queda
      retryStrategy: (times) => {
        if (times > 3) return null; // Desiste após 3 tentativas
        return Math.min(times * 200, 2000); // Backoff: 200ms, 400ms, 600ms...
      },

      // Timeout para evitar requisições travadas
      commandTimeout: 5000,

      // Lazy connect: não conecta até o primeiro comando
      lazyConnect: true,
    });

    client.on('error', (err) => {
      // Log mas não derruba a aplicação — cache deve ser degradável
      console.error('[Redis] Erro de conexão:', err.message);
    });

    client.on('connect', () => {
      console.log('[Redis] Conectado com sucesso.');
    });
  }

  return client;
}

A Interface e o Provider: Abstraindo o ioredis

Nunca use o ioredis diretamente nos seus Use Cases ou Services. Abstraia atrás de uma interface — isso permite trocar Redis por Memcached, ou usar um provider em memória nos testes:

ICacheProvider.ts
export interface ICacheProvider {
  // Salva um valor com TTL em segundos (undefined = sem expiração)
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;

  // Recupera um valor tipado. Retorna null se não existe ou expirou
  get<T>(key: string): Promise<T | null>;

  // Invalida uma chave específica
  del(key: string): Promise<void>;

  // Invalida todas as chaves que começam com um prefixo
  // Ex: delByPrefix('products:') remove 'products:list', 'products:featured', etc.
  delByPrefix(prefix: string): Promise<void>;

  // Verifica se uma chave existe
  exists(key: string): Promise<boolean>;

  // Define TTL de uma chave existente
  expire(key: string, ttlSeconds: number): Promise<void>;
}
RedisCacheProvider.ts
import type { ICacheProvider } from '../../domain/providers/ICacheProvider';
import { getRedisClient } from './RedisClient';

export class RedisCacheProvider implements ICacheProvider {
  private client = getRedisClient();
  // Prefixo global para isolar este app em um Redis compartilhado
  private prefix = process.env.REDIS_KEY_PREFIX ?? 'app:';

  private buildKey(key: string): string {
    return `${this.prefix}${key}`;
  }

  async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
    const serialized = JSON.stringify(value);
    const fullKey = this.buildKey(key);

    if (ttlSeconds) {
      // EX: expiração em segundos
      await this.client.set(fullKey, serialized, 'EX', ttlSeconds);
    } else {
      await this.client.set(fullKey, serialized);
    }
  }

  async get<T>(key: string): Promise<T | null> {
    const value = await this.client.get(this.buildKey(key));
    if (!value) return null;

    try {
      return JSON.parse(value) as T;
    } catch {
      // Valor corrompido — invalida e retorna null
      await this.del(key);
      return null;
    }
  }

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

  async delByPrefix(prefix: string): Promise<void> {
    // SCAN é não-bloqueante. KEYS é bloqueante — nunca use KEYS em produção!
    const fullPrefix = this.buildKey(prefix);
    let cursor = '0';

    do {
      const [nextCursor, keys] = await this.client.scan(
        cursor,
        'MATCH', `${fullPrefix}*`,
        'COUNT', 100
      );
      cursor = nextCursor;

      if (keys.length > 0) {
        await this.client.del(...keys);
      }
    } while (cursor !== '0');
  }

  async exists(key: string): Promise<boolean> {
    const result = await this.client.exists(this.buildKey(key));
    return result === 1;
  }

  async expire(key: string, ttlSeconds: number): Promise<void> {
    await this.client.expire(this.buildKey(key), ttlSeconds);
  }
}

Padrões de Cache: Cache-Aside, Write-Through e Write-Around

Existem três padrões principais de uso de cache, cada um com trade-offs diferentes:

  • Cache-Aside (Lazy Loading) — a aplicação verifica o cache primeiro; se não existe, lê do banco e armazena no cache. É o padrão mais comum para reads. Dados só vão para o cache quando são requisitados.
  • Write-Through — toda escrita no banco também escreve no cache simultaneamente. Cache sempre atualizado, mas writes ficam mais lentos.
  • Write-Around — writes vão direto para o banco, sem popular o cache. O cache é populado apenas em reads subsequentes. Bom para dados que são raramente relidos após a escrita.
ListProductsUseCase.ts
// Padrão Cache-Aside
export class ListProductsUseCase {
  constructor(
    private productsRepo: IProductsRepository,
    private cache: ICacheProvider
  ) {}

  async execute(categoryId: string): Promise<Array<Product>> {
    // Chave com namespace para facilitar invalidação por prefixo
    const cacheKey = `products:category:${categoryId}`;

    // 1. Tenta recuperar do cache
    const cached = await this.cache.get<Array<Product>>(cacheKey);
    if (cached) return cached;

    // 2. Cache miss: busca no banco
    const products = await this.productsRepo.findByCategoryId(categoryId);

    // 3. Armazena no cache com TTL de 5 minutos
    await this.cache.set(cacheKey, products, 5 * 60);

    return products;
  }
}
UpdateProductUseCase.ts
// Invalidação após escrita: evita dados obsoletos no cache
export class UpdateProductUseCase {
  constructor(
    private productsRepo: IProductsRepository,
    private cache: ICacheProvider
  ) {}

  async execute(id: string, data: UpdateProductDTO): Promise<Product> {
    const product = await this.productsRepo.findById(id);
    if (!product) throw new AppError('Produto não encontrado.', 404);

    Object.assign(product, data);
    await this.productsRepo.save(product);

    // Invalida todas as chaves de produto:
    // 'products:category:*', 'products:featured', etc.
    await this.cache.delByPrefix('products:');

    // Ou, se quiser ser cirúrgico, invalida apenas a categoria afetada:
    // await this.cache.del(`products:category:${product.categoryId}`);

    return product;
  }
}

Cache Stampede: O Problema de Alta Concorrência

Cache Stampede (ou Dog Pile Effect) é um problema clássico: quando o cache de uma chave popular expira, centenas de requisições simultâneas detectam o cache miss e todas vão ao banco ao mesmo tempo para recomputar o valor. O banco recebe uma explosão de queries idênticas.

A solução é Mutex/Lock: a primeira requisição que detecta o cache miss adquire um lock, busca do banco e popula o cache. As demais aguardam e leem do cache quando ele é populado:

RedisCacheProvider.ts (getWithLock)
// Adicione ao RedisCacheProvider:
async getWithLock<T>(
  key: string,
  computeFn: () => Promise<T>,
  ttlSeconds: number,
  lockTtlSeconds: number = 10
): Promise<T> {
  // 1. Tenta recuperar do cache
  const cached = await this.get<T>(key);
  if (cached) return cached;

  const lockKey = this.buildKey(`lock:${key}`);

  // 2. Tenta adquirir o lock (SET NX EX: Set if Not eXists com Expiration)
  const acquired = await this.client.set(
    lockKey, '1', 'EX', lockTtlSeconds, 'NX'
  );

  if (acquired === 'OK') {
    // 3. Adquiriu o lock: computa o valor e popula o cache
    try {
      const value = await computeFn();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      await this.client.del(lockKey); // Libera o lock
    }
  } else {
    // 4. Não adquiriu: aguarda e tenta o cache novamente
    await new Promise((resolve) => setTimeout(resolve, 100));
    const retried = await this.get<T>(key);
    if (retried) return retried;
    // Se ainda não tem, computa sem lock (fallback)
    return computeFn();
  }
}

// Uso:
const products = await this.cache.getWithLock(
  `products:category:${categoryId}`,
  () => this.productsRepo.findByCategoryId(categoryId),
  300 // TTL 5 minutos
);

Estratégia de Chaves: Namespacing

Chaves bem nomeadas tornam invalidação cirúrgica possível. Use a convenção entidade:escopo:identificador:

text
Convenção de chaves:
  products:list                    → lista geral de produtos
  products:category:{categoryId}   → produtos de uma categoria
  products:featured                → produtos em destaque
  users:{userId}:profile           → perfil de um usuário específico
  users:{userId}:permissions       → permissões (cache por 1 min)
  orders:{orderId}                 → detalhes de um pedido

Invalidação por prefixo:
  delByPrefix('products:')         → invalida TODO o cache de produtos
  delByPrefix(`users:${userId}:`)  → invalida apenas o cache deste usuário
  del('products:featured')         → invalida apenas os destaques

Conclusão

Cache bem implementado não é só adicionar get e set. É definir estratégias de invalidação precisas (por prefixo, por evento), usar TTLs adequados para cada tipo de dado (segundos para preços, minutos para listas, horas para dados estáticos), e ter degradação elegante quando o Redis cai (nunca deixe o cache indisponível derrubar a API — o banco é o fallback). Com essas bases, o Redis se torna o multiplicador de performance mais barato que existe.