Skip to content

ADR-005: Стратегия кэширования (Redis)

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект требует кэширования для:

  • Снижения нагрузки на PostgreSQL
  • Ускорения частых запросов (справочники, каталог)
  • Хранения сессий и временных данных
  • Rate limiting

Инфраструктура

Согласно общее.md:

  • Redis 7.2.x
  • 1-2 GB памяти на MVP
  • Политика вытеснения: allkeys-lru

Характеристики нагрузки

ДанныеЧастота чтенияЧастота записиОбъём
Справочники (марки/модели)Очень высокаяРедко~5 MB
КатегорииОчень высокаяРедко~1 MB
Список товаровВысокаяСредняяЗависит от фильтров
Карточка товараВысокаяНизкая~5 KB/товар
СессииВысокаяСредняя~1 KB/сессия
Геоданные (города)ВысокаяНикогда~2 MB

Решение

Архитектура кэширования

┌─────────────────────────────────────────────────────────────┐
│                        КЛИЕНТ                                │
└─────────────────────────┬───────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                     APPLICATION                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                   Cache Layer                        │    │
│  │  ┌───────────┐  ┌───────────┐  ┌───────────────┐    │    │
│  │  │ Read-aside│  │Write-thru │  │ Invalidation  │    │    │
│  │  │  (lazy)   │  │  (sync)   │  │   (events)    │    │    │
│  │  └─────┬─────┘  └─────┬─────┘  └───────┬───────┘    │    │
│  └────────┼──────────────┼────────────────┼────────────┘    │
└───────────┼──────────────┼────────────────┼─────────────────┘
            │              │                │
            ▼              ▼                ▼
┌─────────────────────────────────────────────────────────────┐
│                        REDIS                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  Strings    │  │   Hashes    │  │  Sorted Sets        │  │
│  │ (sessions)  │  │ (entities)  │  │ (search results)    │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │    Sets     │  │   Pub/Sub   │  │  Streams            │  │
│  │ (blacklist) │  │ (invalidate)│  │ (events queue)      │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                      PostgreSQL                              │
│                   (источник истины)                          │
└─────────────────────────────────────────────────────────────┘

Стратегии кэширования по типам данных

1. Справочники (Cache-Aside + Long TTL)

Марки, модели, поколения автомобилей, категории, города

typescript
// src/services/cache/reference-cache.ts
import Redis from 'ioredis';

const REFERENCE_TTL = 24 * 60 * 60; // 24 часа

export class ReferenceCacheService {
  constructor(private redis: Redis) {}

  async getCarMakes(): Promise<CarMake[]> {
    const cacheKey = 'ref:car_makes';
    
    // 1. Проверить кэш
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached);
    }
    
    // 2. Загрузить из БД
    const makes = await this.carMakeRepository.findAll({
      populate: ['models', 'models.generations'],
      orderBy: { name: 'ASC' },
    });
    
    // 3. Сохранить в кэш
    await this.redis.setex(cacheKey, REFERENCE_TTL, JSON.stringify(makes));
    
    return makes;
  }

  async invalidateCarMakes(): Promise<void> {
    await this.redis.del('ref:car_makes');
  }
}

Структура ключей справочников:

КлючДанныеTTL
ref:car_makesВсе марки с моделями24h
ref:categoriesДерево категорий24h
ref:citiesГорода с районами и метро24h
ref:regionsРегионы24h

2. Сессии пользователей (String + TTL)

typescript
// src/services/auth/session-cache.ts
const SESSION_TTL = 15 * 60; // 15 минут (как access token)

export class SessionCacheService {
  async setSession(userId: string, sessionData: SessionData): Promise<void> {
    const key = `session:${userId}`;
    await this.redis.setex(key, SESSION_TTL, JSON.stringify(sessionData));
  }

  async getSession(userId: string): Promise<SessionData | null> {
    const key = `session:${userId}`;
    const data = await this.redis.get(key);
    return data ? JSON.parse(data) : null;
  }

  async extendSession(userId: string): Promise<void> {
    const key = `session:${userId}`;
    await this.redis.expire(key, SESSION_TTL);
  }

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

3. Blacklist токенов (Set + TTL)

typescript
// При logout или принудительном разлогине
export class TokenBlacklistService {
  async addToBlacklist(tokenId: string, expiresIn: number): Promise<void> {
    // expiresIn = оставшееся время жизни токена
    await this.redis.setex(`blacklist:${tokenId}`, expiresIn, '1');
  }

  async isBlacklisted(tokenId: string): Promise<boolean> {
    return await this.redis.exists(`blacklist:${tokenId}`) === 1;
  }
}

4. Результаты поиска товаров (Cache-Aside + Short TTL)

typescript
// src/services/cache/product-search-cache.ts
import crypto from 'crypto';

const SEARCH_TTL = 5 * 60; // 5 минут

export class ProductSearchCacheService {
  private getSearchKey(filters: ProductFilters): string {
    // Создаём хэш от параметров поиска
    const hash = crypto
      .createHash('md5')
      .update(JSON.stringify(filters))
      .digest('hex');
    return `search:products:${hash}`;
  }

  async getCachedResults(filters: ProductFilters): Promise<SearchResult | null> {
    const key = this.getSearchKey(filters);
    const cached = await this.redis.get(key);
    
    if (cached) {
      // Обновить TTL при попадании (LRU-подобное поведение)
      await this.redis.expire(key, SEARCH_TTL);
      return JSON.parse(cached);
    }
    
    return null;
  }

  async cacheResults(filters: ProductFilters, results: SearchResult): Promise<void> {
    const key = this.getSearchKey(filters);
    await this.redis.setex(key, SEARCH_TTL, JSON.stringify(results));
  }

  // Инвалидация при изменении товаров
  async invalidateSearchCache(): Promise<void> {
    const keys = await this.redis.keys('search:products:*');
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }
}

5. Карточка товара (Hash + TTL)

typescript
// src/services/cache/product-cache.ts
const PRODUCT_TTL = 30 * 60; // 30 минут

export class ProductCacheService {
  async getProduct(id: string): Promise<Product | null> {
    const key = `product:${id}`;
    const data = await this.redis.hgetall(key);
    
    if (Object.keys(data).length === 0) {
      return null;
    }
    
    return this.deserializeProduct(data);
  }

  async setProduct(product: Product): Promise<void> {
    const key = `product:${product.id}`;
    const data = this.serializeProduct(product);
    
    await this.redis
      .pipeline()
      .hset(key, data)
      .expire(key, PRODUCT_TTL)
      .exec();
  }

  async invalidateProduct(id: string): Promise<void> {
    await this.redis.del(`product:${id}`);
    // Также инвалидируем поисковый кэш
    await this.invalidateSearchCache();
  }

  private serializeProduct(product: Product): Record<string, string> {
    return {
      id: product.id,
      title: product.title,
      price: product.price.toString(),
      status: product.status,
      categories: JSON.stringify(product.categories),
      compatibility: JSON.stringify(product.compatibility),
      images: JSON.stringify(product.images),
      seller: JSON.stringify(product.seller),
      // ... другие поля
    };
  }
}

6. Rate Limiting (String + INCR)

typescript
// src/services/rate-limit.ts
export class RateLimitService {
  async checkLimit(
    key: string, 
    maxRequests: number, 
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
    const redisKey = `ratelimit:${key}`;
    
    const multi = this.redis.multi();
    multi.incr(redisKey);
    multi.ttl(redisKey);
    
    const results = await multi.exec();
    const count = results[0][1] as number;
    const ttl = results[1][1] as number;
    
    if (count === 1) {
      // Первый запрос — установить TTL
      await this.redis.expire(redisKey, windowSeconds);
    }
    
    return {
      allowed: count <= maxRequests,
      remaining: Math.max(0, maxRequests - count),
      resetIn: ttl > 0 ? ttl : windowSeconds,
    };
  }
}

7. Счётчики просмотров (HyperLogLog)

Для подсчёта уникальных просмотров без хранения каждого IP:

typescript
// src/services/analytics/views-counter.ts
export class ViewsCounterService {
  async addView(productId: string, visitorId: string): Promise<void> {
    const key = `views:unique:${productId}:${this.getTodayKey()}`;
    await this.redis.pfadd(key, visitorId);
    
    // TTL = 2 дня (чтобы успеть агрегировать)
    await this.redis.expire(key, 2 * 24 * 60 * 60);
  }

  async getUniqueViews(productId: string): Promise<number> {
    const key = `views:unique:${productId}:${this.getTodayKey()}`;
    return await this.redis.pfcount(key);
  }

  private getTodayKey(): string {
    return new Date().toISOString().split('T')[0]; // 2026-01-04
  }
}

Инвалидация кэша

Событийная инвалидация

typescript
// src/subscribers/product-cache-subscriber.ts
import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework";

export default async function productCacheSubscriber({
  event,
  container,
}: SubscriberArgs<{ id: string }>) {
  const cacheService = container.resolve<ProductCacheService>("productCacheService");
  
  await cacheService.invalidateProduct(event.data.id);
}

export const config: SubscriberConfig = {
  event: [
    "product.created",
    "product.updated", 
    "product.deleted",
  ],
};

Pub/Sub для распределённой инвалидации

При горизонтальном масштабировании:

typescript
// src/services/cache/cache-invalidator.ts
export class CacheInvalidator {
  private subscriber: Redis;
  private publisher: Redis;

  constructor() {
    this.subscriber = new Redis(process.env.REDIS_URL);
    this.publisher = new Redis(process.env.REDIS_URL);
    
    this.subscriber.subscribe('cache:invalidate');
    this.subscriber.on('message', this.handleInvalidation.bind(this));
  }

  async invalidate(pattern: string): Promise<void> {
    // Публикуем событие для всех инстансов
    await this.publisher.publish('cache:invalidate', pattern);
  }

  private async handleInvalidation(channel: string, pattern: string): Promise<void> {
    const keys = await this.redis.keys(pattern);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }
}

Конфигурация Redis

redis.conf

ini
# Память
maxmemory 1gb
maxmemory-policy allkeys-lru

# Persistence (для dev можно отключить)
save 900 1
save 300 10
save 60 10000

# Безопасность
requirepass ${REDIS_PASSWORD}

# Сеть
bind 127.0.0.1
port 6379

# Логирование
loglevel notice
logfile /var/log/redis/redis-server.log

Подключение в приложении

typescript
// src/config/redis.ts
import Redis from 'ioredis';

export const createRedisClient = (): Redis => {
  return new Redis({
    host: process.env.REDIS_HOST || 'localhost',
    port: Number(process.env.REDIS_PORT) || 6379,
    password: process.env.REDIS_PASSWORD,
    db: 0,
    retryStrategy: (times) => {
      const delay = Math.min(times * 50, 2000);
      return delay;
    },
    maxRetriesPerRequest: 3,
  });
};

Мониторинг

Метрики для сбора

typescript
// src/services/cache/cache-metrics.ts
export class CacheMetrics {
  async getStats(): Promise<CacheStats> {
    const info = await this.redis.info('stats');
    const memory = await this.redis.info('memory');
    
    return {
      hits: this.parseInfo(info, 'keyspace_hits'),
      misses: this.parseInfo(info, 'keyspace_misses'),
      hitRate: this.calculateHitRate(info),
      usedMemory: this.parseInfo(memory, 'used_memory'),
      maxMemory: this.parseInfo(memory, 'maxmemory'),
      evictedKeys: this.parseInfo(info, 'evicted_keys'),
    };
  }

  private calculateHitRate(info: string): number {
    const hits = this.parseInfo(info, 'keyspace_hits');
    const misses = this.parseInfo(info, 'keyspace_misses');
    const total = hits + misses;
    return total > 0 ? (hits / total) * 100 : 0;
  }
}

Последствия

Положительные

  • Производительность: справочники < 5ms вместо 50-100ms
  • Масштабируемость: снижение нагрузки на PostgreSQL
  • Гибкость: разные стратегии для разных данных
  • Observability: метрики hit/miss rate

Отрицательные

  • Консистентность: возможна временная рассинхронизация
  • Сложность: дополнительный слой для поддержки
  • Память: требуется мониторинг использования

Митигация

ПроблемаРешение
Stale dataКороткий TTL + событийная инвалидация
Cache stampedeMutex lock при загрузке популярных ключей
Memory pressureМониторинг + алерты при >80%
Cold startPreload справочников при старте

Связанные решения