Почему продвинутое кэширование — необходимость для 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)