Кэширование в TypeScript: Redis, Memcached и встроенные решения (продвинутый уровень)

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему продвинутое кэширование — необходимость для TypeScript-приложений

В современных высоконагруженных системах кэширование перестало быть опцией — это обязательный элемент архитектуры. Для TypeScript-разработчиков, работающих с Node.js, выбор правильной стратегии кэширования может снизить время ответа API с 200 мс до 2 мс и уменьшить нагрузку на базу данных на 80-90%. В этой статье мы рассмотрим три подхода к кэшированию на продвинутом уровне: Redis, Memcached и встроенные решения на TypeScript.

1. Встроенное кэширование в TypeScript: от простого Map до LRU-кэша

Встроенные решения идеальны для монолитных приложений или микросервисов, где не требуется распределённый кэш. Самый примитивный вариант — использование Map, но для продакшена он не годится из-за отсутствия TTL и контроля памяти.

Реализация LRU-кэша с TTL

Рассмотрим продвинутую реализацию LRU (Least Recently Used) кэша с автоматической очисткой:

interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}

class LRUCache<T> {
  private cache: Map<string, CacheEntry<T>>;
  private readonly maxSize: number;
  private readonly ttlMs: number;

  constructor(maxSize: number = 100, ttlMs: number = 60000) {
    this.cache = new Map();
    this.maxSize = maxSize;
    this.ttlMs = ttlMs;
  }

  get(key: string): T | undefined {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return undefined;
    }

    // Перемещаем ключ в конец (самый свежий)
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.value;
  }

  set(key: string, value: T): void {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // Удаляем самый старый (первый) элемент
      const oldestKey = this.cache.keys().next().value;
      this.cache.delete(oldestKey);
    }

    this.cache.set(key, {
      value,
      expiresAt: Date.now() + this.ttlMs,
    });
  }

  clear(): void {
    this.cache.clear();
  }
}

// Использование
const userCache = new LRUCache<User>(500, 300000); // 500 элементов, 5 минут TTL

Продвинутый паттерн: декоратор кэширования

Для чистоты кода используем декораторы TypeScript:

function Cached(ttlMs: number = 60000) {
  const cache = new LRUCache<any>(100, ttlMs);

  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const key = `${propertyKey}_${JSON.stringify(args)}`;
      const cached = cache.get(key);
      if (cached !== undefined) return cached;

      const result = await originalMethod.apply(this, args);
      cache.set(key, result);
      return result;
    };

    return descriptor;
  };
}

class UserService {
  @Cached(120000) // 2 минуты
  async getUser(id: string): Promise<User> {
    // Дорогой запрос к БД
    return db.findUser(id);
  }
}

2. Redis: распределённое кэширование с продвинутыми структурами данных

Redis — это не просто key-value хранилище. Его поддержка сложных структур данных (Hash, List, Set, Sorted Set) открывает возможности для реализации продвинутых стратегий кэширования.

Кэширование с инвалидацией по тегам

Проблема: при обновлении одной сущности нужно инвалидировать несколько кэшей. Решение — теги:

import Redis from 'ioredis';

class TaggedCache {
  private redis: Redis;
  private readonly keyPrefix: string;

  constructor(redisUrl: string, prefix: string = 'cache') {
    this.redis = new Redis(redisUrl);
    this.keyPrefix = prefix;
  }

  async get<T>(key: string, tags: string[]): Promise<T | null> {
    const cacheKey = this.buildKey(key);
    const tagVersion = await this.getTagVersion(tags);

    const cached = await this.redis.get(cacheKey);
    if (!cached) return null;

    const parsed = JSON.parse(cached);
    // Если версия тега изменилась — кэш устарел
    if (parsed.tagVersion !== tagVersion) {
      await this.redis.del(cacheKey);
      return null;
    }

    return parsed.data as T;
  }

  async set<T>(key: string, data: T, tags: string[], ttl: number = 3600): Promise<void> {
    const cacheKey = this.buildKey(key);
    const tagVersion = await this.getTagVersion(tags);

    const payload = JSON.stringify({
      data,
      tagVersion,
      createdAt: Date.now(),
    });

    await this.redis.setex(cacheKey, ttl, payload);
  }

  async invalidateTags(tags: string[]): Promise<void> {
    const pipeline = this.redis.pipeline();
    for (const tag of tags) {
      const tagKey = `tag:${tag}`;
      pipeline.incr(tagKey); // Увеличиваем версию тега
      pipeline.expire(tagKey, 86400); // TTL для тега
    }
    await pipeline.exec();
  }

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

  private async getTagVersion(tags: string[]): Promise<string> {
    const versions = await Promise.all(
      tags.map(tag => this.redis.get(`tag:${tag}`))
    );
    return versions.join('_');
  }
}

// Пример использования
const cache = new TaggedCache('redis://localhost:6379');

// Кэшируем профиль пользователя с тегами
await cache.set('user:123', userData, ['user:123', 'profile'], 600);

// При обновлении профиля инвалидируем все связанные кэши
await cache.invalidateTags(['user:123', 'profile']);

Паттерн "Cache-Aside" с мьютексами (Thundering Herd)

Проблема "стада" (thundering herd) возникает, когда множество запросов одновременно пытаются пересоздать истекший кэш. Решение — распределённый мьютекс:

async function getWithMutex<T>(
  key: string,
  fetchData: () => Promise<T>,
  ttl: number = 300,
  lockTimeout: number = 5000
): Promise<T> {
  const redis = new Redis();
  const lockKey = `lock:${key}`;

  // Пытаемся получить из кэша
  const cached = await redis.get(key);
  if (cached)                

Блоги

Рекомендаций по книгам