Appearance
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 stampede | Mutex lock при загрузке популярных ключей |
| Memory pressure | Мониторинг + алерты при >80% |
| Cold start | Preload справочников при старте |
Связанные решения
- ADR-003: Аутентификация — сессии и blacklist
- ADR-004: Паттерны БД — денормализация
- ADR-007: Background Jobs — очереди в Redis