Skip to main content

Caching Strategy

Стратегия кеширования в GOLOOT: текущая реализация, архитектурные решения и план масштабирования.


1. Summary

Goal: Минимизировать латентность и нагрузку на PostgreSQL/внешние API без усложнения архитектуры.

Принцип: Кешируем только то, что реально создаёт нагрузку. Простейшее решение — in-memory Map с TTL — предпочтительнее Redis, пока работает.


2. Architecture

Два слоя кеширования

СлойХранениеНазначениеКогда использовать
In-MemoryMap в RAM процессаExternal API, конфигурация, stateДанные per-instance, TTL < 30 мин, размер < 100MB
RedisОтдельный процессГорячие PG queriesДанные shared, TTL 2-10 мин, инвалидация из админки
Почему два слоя, а не один Redis?

In-memory быстрее Redis на ~1-5ms per read. Для external API кешей (Steam, Telegram) это оптимально — данные per-instance, не нужна синхронизация. Redis нужен только для PG query cache, где latency менее критична, но volume огромный.


3. Current Implementation (Layer 1)

Инвентарь кешей

Кеши для снижения нагрузки на внешние сервисы (Steam, Telegram).

КешФайлTTLЧто кеширует
Avatar Cacheusers/services/avatar-cache.service.ts24чАватары Telegram (файловый кеш)
Bot Inventorysteam-trade-bot/services/bot-inventory-cache.service.ts5 минИнвентарь Steam бота
Steam Inventory Publicsteam-verification/services/steam-api.client.ts1 минПубличность инвентаря
Trade In-Flight Dedupsteam-trade-bot/services/trade-manager.service.ts3 секДедупликация Steam API запросов
Детали реализации

Avatar Cache — единственный файловый кеш:

  • Хранит аватары в /static/images/avatars/{telegramId}.{ext}
  • Проверка актуальности по mtime файла
  • Поддержка forceRefresh при смене аватара
  • Версионирование URL через mtime (cache-busting для CDN)

Bot Inventory Cache — in-memory Map с мониторингом:

  • getStats() возвращает hit rate, size, age
  • cleanup() удаляет expired entries
  • Configurable TTL через конструктор

Trade In-Flight Dedup — Promise-based:

  • Хранит pending Promise в Map
  • Повторный запрос ждёт первый Promise вместо нового вызова Steam
  • Автоматическая очистка после resolve

Потребление памяти

КешРазмер (оценка при 10K DAU)Рост с юзерами
Avatar Cache~500MB на дискеЛинейный
Bot Inventory~1-5MBНе растёт
Steam Inventory Public~100KBЛинейный (медленно)
Maintenance Mode~1KBНе растёт
Rate Limiter~1MBЛинейный
Quiz Templates~2MBНе растёт
Telegram Topics/Config~1KBНе растёт
Multi-Select State~100KBЛинейный (медленно)
Итого в RAM~10MB
Потеря при редеплое

Все in-memory кеши теряются при редеплое. Это ОК — они быстро прогреваются (TTL < 30 мин). Avatar cache на диске — не теряется.


4. Current Implementation: Redis PG Query Cache (Layer 2)

Cache-aside слой для горячих PG queries. Реализован в backend/src/common/cache/.

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

ФайлНазначение
common/cache/cache.service.tsCacheService — getOrSet, invalidate, invalidateByPrefix
common/cache/cache-keys.tsKey schema (goloot:v1:*), TTL constants, prefix patterns
common/cache/index.tsSingleton export (cacheService)
config/redis.config.tsRedis client initialization
common/cache/cache.service.test.tsUnit-тесты

Что кэшируется

ДанныеRedis KeyTTLГде кэшируетсяПаттерн
Активный сезонgoloot:v1:season:active300sseason.repository.tsГлобальный (один для всех)
Активные квестыgoloot:v1:quests:active:{category}120squest-progress.service.tsPer-category (QUIZ, CASES, SPECIAL, all)
Детали кейсаgoloot:v1:case:{caseId}:details600scase-opening.service.tsPer-entity (5-10 кейсов)
Детали спинаgoloot:v1:spin:{spinId}:details600suser-spin.service.tsPer-entity (2-3 спина)
Leaderboardgoloot:v1:leaderboard:{seasonId}:{limit}:{offset}120sseason.repository.tsГлобальный

Инвалидация

ДанныеКто инвалидируетСтратегия
Активный сезонadmin-season-lifecycle.controller.ts, season-lifecycle.job.tsinvalidate(key) — удаление конкретного ключа
Активные квестыadmin-season-setup.controller.ts, admin-quest.controller.ts, season-lifecycle.job.ts, season-case.service.tsinvalidateByPrefix() — SCAN + DEL всех quests:active:*
Детали кейсаadmin-case.controller.ts, admin-reward.controller.tsinvalidate(key) или invalidateByPrefix()
Детали спинаadmin-spin.controller.ts, admin-reward.controller.tsinvalidateByPrefix() — SCAN + DEL всех spin:*
LeaderboardTTL-only (2 мин). Осознанный выбор: ранги меняются плавно, 2-минутная задержка приемлема
Ключевые решения реализации

Envelope pattern { d: value } — решает проблему различия между "ключа нет в Redis" (GET → null) и "в кэше лежит null" (нет активного сезона). Без envelope оба случая выглядят одинаково.

Custom сериализация BigInt/Date — Prisma модели содержат BigInt и Date, которые JSON.stringify не обрабатывает. Tagged format: BigInt → { __t: 'B', v: '12345' }, Date → { __t: 'D', v: '2024-01-...' }.

SCAN вместо KEYS — команда KEYS блокирует Redis на время выполнения. SCAN — итеративный курсор, не блокирует. Используется для invalidateByPrefix().

Fire-and-forget SET — после cache miss запись в Redis выполняется через .catch() без await. Клиент получает ответ не дожидаясь SET.

Graceful degradation — если Redis недоступен (getRedisClient()null), getOrSet() вызывает fetcher напрямую. Ни один запрос не падает из-за Redis.

Ожидаемый эффект (при 10K DAU)

До:   ~2.3M PG queries/день (150 q/sec peak)
После: ~1.7M PG queries/день (100 q/sec peak)
Экономия: ~25-30% нагрузки на PostgreSQL

Осознанно НЕ кэшируется

ДанныеПочему
User profile / statsPer-user данные — shared cache неэффективен без per-user ключей
Quest progress (user)Меняется при каждом действии, read-after-write consistency критична
User balance (scrap/xp)Финансовые данные, consistency важнее скорости
Analytics (admin)1 пользователь админки, нет конкурентных запросов

Следующий этап масштабирования

ПорогЧто добавить
5,000+ DAUPer-user кэш (profile, user quests)
50,000+ пользователей в сезонеRedis Sorted Sets для leaderboard
2+ реплики backendRate limiting, SSE pub/sub через Redis

5. Decision Framework

Когда использовать In-Memory Map

  • Данные не нужно синхронизировать между процессами
  • TTL < 30 минут
  • Размер кеша < 100MB
  • Потеря при редеплое некритична
  • Кеш для внешних API (Steam, Telegram)

Когда использовать Redis

  • Данные одинаковы для многих пользователей (глобальные)
  • Объём hot queries > 100K/день на один тип данных
  • PG CPU > 50% sustained или connection pool исчерпывается
  • Нужна shared инвалидация (при мутации данных в админке)

Когда НЕ кешировать

  • Данные меняются при каждом запросе (user balance, quest progress)
  • Read-after-write consistency критична (финансовые операции)
  • Данные запрашиваются редко (< 1K/день)
  • Кеш создаёт больше сложности, чем экономит

6. Patterns

Cache-Aside (основной паттерн)

Read:
1. Проверить кеш → есть и валиден? → вернуть
2. Нет → SELECT из источника → записать в кеш с TTL → вернуть

Write (инвалидация):
1. Выполнить мутацию в источнике
2. Удалить ключ из кеша (не обновлять!)
3. Следующий read подтянет актуальные данные
Почему удалять, а не обновлять кеш?

Обновление кеша при записи создаёт race condition: два concurrent write могут записать устаревшее значение. Удаление безопаснее — следующий read гарантированно получит актуальные данные.

Lazy Initialization (для конфигурации)

1. initialized = false
2. При первом вызове → загрузить данные → initialized = true
3. Все последующие вызовы → вернуть из памяти
4. Никогда не инвалидировать (данные не меняются в runtime)

Используется для: Quiz Templates, Telegram Topics, Telegram Config.

Promise Deduplication (для external API)

1. Запрос приходит → проверить pending Map
2. Есть pending Promise? → вернуть тот же Promise (ждать)
3. Нет → создать Promise, положить в Map → выполнить запрос
4. После resolve → удалить из Map

Используется для: Steam Trade In-Flight Dedup.


7. Monitoring

Redis Cache метрики (Prometheus)

МетрикаLabelsОписание
cache_hit_totalkey_prefix (season, quests, case, spin, leaderboard)Успешные чтения из кэша
cache_miss_totalkey_prefixПромахи → fetcher → SET в Redis
cache_error_totaloperation (get, set, invalidate, invalidateByPrefix, serialize)Ошибки Redis

Как читать: Hit rate = hit / (hit + miss). Целевой показатель > 90% для season/case/spin (высокий TTL), > 70% для quests/leaderboard (низкий TTL).

Общие метрики

МетрикаИсточникПорог для действий
PG queries/secpg_stat_statements> 150 q/sec sustained
PG connection pool utilizationPrisma metrics> 80% → увеличить pool
API p95 latencyObservability> 200ms → профилировать hot paths
Node.js heap sizeprocess.memoryUsage()> 500MB → проверить in-memory кеши на утечки
In-memory cache entriesCustom metricsRate Limiter > 50K entries → уменьшить cleanup interval
Redis cache hit ratecache_hit_total / (hit + miss)< 50% → проверить TTL и инвалидацию

Проверка на memory leaks

Все in-memory кеши с Map должны иметь механизм очистки:

КешОчисткаКак проверить
Rate Limitercleanup() каждые 5 минMap.size не растёт бесконечно
Multi-Select Statecleanup() каждые 5 минMap.size ~ concurrent quizzes
Bot InventoryРучной cleanup()Map.size = 1 (один бот)
Steam PublicTTL 1 мин, auto-expireMap.size ~ unique Steam checks/min