Caching Strategy
Стратегия кеширования в GOLOOT: текущая реализация, архитектурные решения и план масштабирования.
1. Summary
Goal: Минимизировать латентность и нагрузку на PostgreSQL/внешние API без усложнения архитектуры.
Принцип: Кешируем только то, что реально создаёт нагрузку. Простейшее решение — in-memory Map с TTL — предпочтительнее Redis, пока работает.
2. Architecture
Два слоя кеширования
| Слой | Хранение | Назначение | Когда использовать |
|---|---|---|---|
| In-Memory | Map в RAM процесса | External API, конфигурация, state | Данные per-instance, TTL < 30 мин, размер < 100MB |
| Redis | Отдельный процесс | Горячие PG queries | Данные shared, TTL 2-10 мин, инвалидация из админки |
In-memory быстрее Redis на ~1-5ms per read. Для external API кешей (Steam, Telegram) это оптимально — данные per-instance, не нужна синхронизация. Redis нужен только для PG query cache, где latency менее критична, но volume огромный.
3. Current Implementation (Layer 1)
Инвентарь кешей
- External API
- Internal State
- Config & Templates
Кеши для снижения нагрузки на внешние сервисы (Steam, Telegram).
| Кеш | Файл | TTL | Что кеширует |
|---|---|---|---|
| Avatar Cache | users/services/avatar-cache.service.ts | 24ч | Аватары Telegram (файловый кеш) |
| Bot Inventory | steam-trade-bot/services/bot-inventory-cache.service.ts | 5 мин | Инвентарь Steam бота |
| Steam Inventory Public | steam-verification/services/steam-api.client.ts | 1 мин | Публичность инвентаря |
| Trade In-Flight Dedup | steam-trade-bot/services/trade-manager.service.ts | 3 сек | Дедупликация 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, agecleanup()удаляет expired entries- Configurable TTL через конструктор
Trade In-Flight Dedup — Promise-based:
- Хранит pending Promise в Map
- Повторный запрос ждёт первый Promise вместо нового вызова Steam
- Автоматическая очистка после resolve
Кеши для внутреннего состояния приложения.
| Кеш | Файл | TTL | Что кеширует |
|---|---|---|---|
| Maintenance Mode | maintenance/services/maintenance.service.ts | 10 сек | Singleton состояние maintenance |
| Rate Limiter | telegram/services/rate-limiter.service.ts | 1 мин (user) / 1 сек (global) | Timestamps запросов для rate limiting |
| Referral Alerts | referrals/services/referral-alerting.service.ts | Динамический | Правила алертов + активные алерты |
| Season Timers | seasons/services/season-timer.service.ts | До срабатывания | setTimeout handles для SCHEDULED сезонов |
Детали реализации
Maintenance Mode — module-level variable:
let cache: { state: MaintenanceState; fetchedAt: number } | null
- Инвалидируется автоматически при
activate(),deactivate(),schedule() - Самый частый кеш — проверяется на каждый запрос пользователя
- Экономит 1 PG SELECT per 10 секунд (вместо per request)
Rate Limiter — два уровня:
- Per-user:
Map<userId, timestamps[]>— окно 1 минута, лимит 10 запросов - Global:
timestamps[]— окно 1 секунда, лимит 50 запросов - Периодическая очистка каждые 5 минут (предотвращает memory leak)
Season Timers — setTimeout с рекурсивным перепланированием:
- Обходит лимит JavaScript
setTimeoutв 24.8 дня - Восстанавливается при перезапуске (
rescheduleAllOnBoot())
Кеши для конфигурации, которая меняется редко или никогда.
| Кеш | Файл | TTL | Что кеширует |
|---|---|---|---|
| Quiz Templates | quizzes/services/quiz-generator.service.ts | App lifetime | JSON шаблоны вопросов из файла |
| Telegram Topics | telegram/services/telegram-topics.service.ts | App lifetime | Topic IDs из ENV переменных |
| Telegram Config | telegram/services/telegram-topics.service.ts | App lifetime | Bot token, Group ID из ENV |
| Multi-Select State | telegram/services/multi-select-state.service.ts | 30 мин | Состояние quiz ответов в боте |
Детали реализации
Quiz Templates — lazy-loaded singleton:
- Загружается из
backend/data/quiz-templates.jsonпри первом вызове - Мёржит universal templates в каждую категорию
- Живёт весь lifetime процесса (файл не меняется в runtime)
Telegram Topics/Config — lazy-init pattern:
ensureInitialized()читает ENV vars при первом вызовеinitializedflag предотвращает повторное чтение
Multi-Select State — in-memory Map с периодической очисткой:
setIntervalкаждые 5 минут удаляет записи старше 30 минут- Предотвращает memory leak от незавершённых квизов
Потребление памяти
| Кеш | Размер (оценка при 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.ts | CacheService — getOrSet, invalidate, invalidateByPrefix |
common/cache/cache-keys.ts | Key schema (goloot:v1:*), TTL constants, prefix patterns |
common/cache/index.ts | Singleton export (cacheService) |
config/redis.config.ts | Redis client initialization |
common/cache/cache.service.test.ts | Unit-тесты |
Что кэшируется
| Данные | Redis Key | TTL | Где кэшируется | Паттерн |
|---|---|---|---|---|
| Активный сезон | goloot:v1:season:active | 300s | season.repository.ts | Глобальный (один для всех) |
| Активные квесты | goloot:v1:quests:active:{category} | 120s | quest-progress.service.ts | Per-category (QUIZ, CASES, SPECIAL, all) |
| Детали кейса | goloot:v1:case:{caseId}:details | 600s | case-opening.service.ts | Per-entity (5-10 кейсов) |
| Детали спина | goloot:v1:spin:{spinId}:details | 600s | user-spin.service.ts | Per-entity (2-3 спина) |
| Leaderboard | goloot:v1:leaderboard:{seasonId}:{limit}:{offset} | 120s | season.repository.ts | Глобальный |
Инвалидация
| Данные | Кто инвалидирует | Стратегия |
|---|---|---|
| Активный сезон | admin-season-lifecycle.controller.ts, season-lifecycle.job.ts | invalidate(key) — удаление конкретного ключа |
| Активные квесты | admin-season-setup.controller.ts, admin-quest.controller.ts, season-lifecycle.job.ts, season-case.service.ts | invalidateByPrefix() — SCAN + DEL всех quests:active:* |
| Детали кейса | admin-case.controller.ts, admin-reward.controller.ts | invalidate(key) или invalidateByPrefix() |
| Детали спина | admin-spin.controller.ts, admin-reward.controller.ts | invalidateByPrefix() — SCAN + DEL всех spin:* |
| Leaderboard | — | TTL-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 / stats | Per-user данные — shared cache неэффективен без per-user ключей |
| Quest progress (user) | Меняется при каждом действии, read-after-write consistency критична |
| User balance (scrap/xp) | Финансовые данные, consistency важнее скорости |
| Analytics (admin) | 1 пользователь админки, нет конкурентных запросов |
Следующий этап масштабирования
| Порог | Что добавить |
|---|---|
| 5,000+ DAU | Per-user кэш (profile, user quests) |
| 50,000+ пользователей в сезоне | Redis Sorted Sets для leaderboard |
| 2+ реплики backend | Rate 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_total | key_prefix (season, quests, case, spin, leaderboard) | Успешные чтения из кэша |
cache_miss_total | key_prefix | Промахи → fetcher → SET в Redis |
cache_error_total | operation (get, set, invalidate, invalidateByPrefix, serialize) | Ошибки Redis |
Как читать: Hit rate = hit / (hit + miss). Целевой показатель > 90% для season/case/spin (высокий TTL), > 70% для quests/leaderboard (низкий TTL).
Общие метрики
| Метрика | Источник | Порог для действий |
|---|---|---|
| PG queries/sec | pg_stat_statements | > 150 q/sec sustained |
| PG connection pool utilization | Prisma metrics | > 80% → увеличить pool |
| API p95 latency | Observability | > 200ms → профилировать hot paths |
| Node.js heap size | process.memoryUsage() | > 500MB → проверить in-memory кеши на утечки |
| In-memory cache entries | Custom metrics | Rate Limiter > 50K entries → уменьшить cleanup interval |
| Redis cache hit rate | cache_hit_total / (hit + miss) | < 50% → проверить TTL и инвалидацию |
Проверка на memory leaks
Все in-memory кеши с Map должны иметь механизм очистки:
| Кеш | Очистка | Как проверить |
|---|---|---|
| Rate Limiter | cleanup() каждые 5 мин | Map.size не растёт бесконечно |
| Multi-Select State | cleanup() каждые 5 мин | Map.size ~ concurrent quizzes |
| Bot Inventory | Ручной cleanup() | Map.size = 1 (один бот) |
| Steam Public | TTL 1 мин, auto-expire | Map.size ~ unique Steam checks/min |
8. Related
- ADR: Redis Integration — решения по Redis (rejected improvements + cache-aside реализация)
- Architecture Overview — общая архитектура
- Observability — метрики и мониторинг
- Data Sync — синхронизация данных между слоями