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 | Promise-based | Дедупликация Steam API запросов (min interval 3 сек) |
Детали реализации
Avatar Cache — единственный файловый кеш:
- Хранит аватары в
/static/images/avatars/{telegramId}.{ext} - Проверка актуальности по
mtimeфайла - Поддержка
forceRefreshпри смене аватара - Версионирование URL через mtime (cache-busting для CDN)
Bot Inventory Cache — in-memory Map с мониторингом:
getStats()возвращает{ entries, totalItems }— количество записей в кеше и суммарное число предметовcleanup()удаляет 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 (любая AchievementCategory: QUIZ, CASES, RECYCLE, COLLECTION, SOCIAL, SPECIAL, RUST, STREAK, ECONOMY, PROGRESSION) |
| Детали кейса | 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 спина) |
| Quest Pool (ротация) | goloot:v1:quests:season:{seasonId}:pool | 120s | quest-rotation.service.ts | Per-season (глобальный пул для ротации) |
| Leaderboard | goloot:v1:leaderboard:{seasonId}:{limit}:{offset} | 120s | season.repository.ts | Глобальный |
| Steam User Lookup | goloot:v1:steam:user:{steamId} | 300s | steam-linking.service.ts | Per-user (Rust webhook hot path) |
| Budget Exhausted | goloot:v1:budget:{seasonId}:exhausted | 15s | budget.service.ts | Per-season (drop-mechanic pre-TX query) |
| Boost Config | goloot:v1:luck-pool:{userId}:{seasonId}:boost | 30s | luck-pool.service.ts | Per-user (drop-mechanic pre-TX query) |
| Drop Overrides | goloot:v1:drop-override:{userId}:{seasonId} | 120s | admin-drop-override.service.ts | Per-user (admin-only mutations) |
Инвалидация
| Данные | Кто инвалидирует | Стратегия |
|---|---|---|
| Активный сезон | admin-season-lifecycle.controller.ts, admin-season-setup.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, quest-reward.service.ts | invalidateByPrefix() — SCAN + DEL всех quests:active:* |
| Quest Pool (ротация) | admin-season-setup.controller.ts, admin-quest.controller.ts, season-lifecycle.job.ts, season-case.service.ts | invalidateByPrefix() — SCAN + DEL всех quests:season:* |
| Детали кейса | admin-case.controller.ts, admin-reward.controller.ts, admin-item.controller.ts | invalidate(key) или invalidateByPrefix() |
| Детали спина | admin-spin.controller.ts, admin-reward.controller.ts, admin-item.controller.ts | invalidateByPrefix() — SCAN + DEL всех spin:* |
| Leaderboard | — | TTL-only (2 мин). Осознанный выбор: ранги меняются плавно, 2-минутная задержка приемлема |
| Steam User Lookup | steam-linking.service.ts при linkSteamToUser() | invalidate(key) — для старого и нового steamId |
| Budget Exhausted | craft.service.ts (после TX), budget-admin.service.ts (5 методов), budget-lifecycle.service.ts, season-reset.service.ts, admin-season.controller.ts | invalidate(key) при мутации бюджета или сезона. Craft: инвалидация ПОСЛЕ $transaction (избежание race condition) |
| Boost Config | luck-pool.service.ts (addToPool, markInactive, periodChange, clearBlocked), craft.service.ts (после TX), season-reset.service.ts, admin-season.controller.ts | invalidate(key) при мутации pool entry. invalidateByPrefix() при season reset/delete |
| Drop Overrides | admin-drop-override.service.ts (upsert, remove), season-reset.service.ts, admin-season.controller.ts | invalidate(key) при admin action. invalidateByPrefix() при season reset/delete |
Ключевые решения реализации
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)
Layer 2 v1: ~1.7M PG queries/день (100 q/sec peak) — season, cases, spins, quests
Layer 2 v2: ~1.4M PG queries/день (80 q/sec peak) — + budget, boost, overrides
Экономия: ~35-40% нагрузки на PostgreSQL
Осознанно НЕ кэшируется
| Данные | Почему |
|---|---|
| User profile / stats | Per-user данные, меняются часто. Per-user кэш используется только для drop-mechanic (boost config, drop overrides) — они меняются редко |
| Quest progress (user) | Меняется при каждом действии, read-after-write consistency критична |
| User balance (scrap/xp) | Финансовые данные, consistency важнее скорости |
| Analytics (admin) | 1 пользователь админки, нет конкурентных запросов |
Следующий этап масштабирования
| Порог | Что добавить |
|---|---|
| 5,000+ DAU | Per-user кэш для profile и user quests (drop-mechanic per-user кэш уже реализован: boost config, drop overrides) |
| 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 — синхронизация данных между слоями