Skip to main content

ADR 010: User Model — Single Table (без декомпозиции)

Status

Accepted - 2026-03-06

Context

Вопрос

Нужно ли разбивать User модель (~63 колонки) на несколько таблиц (UserStats, UserSteamProfile, UserBanInfo)?

Текущая структура User (~63 scalar поля)

ГруппаКол-воHot path
Identity (id, name, avatar)8Да — каждый запрос
Экономика (scrap, xp, level)4Да — каждое начисление
Quiz счётчики3Да — каждый квиз
Стрики + Streak Points6Да — ежедневно
Счётчики активности8Да — лидерборды, профиль
Cooldowns + timestamps6Да — проверка кулдаунов
Steam + верификация11Нет — только при привязке/выводе
Бан + апелляции8Частично — isBanned на каждый запрос
Titles4Нет — только UI
Прочее (referral, push, status)5Частично

Рассмотренные варианты декомпозиции

МодельПоляВыигрышПроблемы
UserSteamProfile11 steam*User → 52steamId@unique + @@index, используется в webhook matching. Требует JOIN или двухшаговый запрос
UserBanInfo8 ban/appeal*User → 55isBanned читается в auth middleware на каждом запросе. Вынос = JOIN на hot path или денормализация флага (полумера)
UserStatsНе применимоДетализация scrap/xp по источникам (scrapFrom*, xpFrom*) живёт только на UserSeasonStats, не на User. Предложение из первого аудита было основано на неверных данных

Decision

Оставить User как единую таблицу. Не декомпозировать.

Причины

1. PostgreSQL справляется

63 колонки — далеко от лимита (~1600). Row size ~400 bytes — умещается в один 8KB block. Нет реальных performance-проблем.

2. Atomic updates без транзакций

Одна prisma.user.update() vs $transaction([user.update, steamProfile.update]). Меньше кода, меньше точек отказа, проще отладка.

3. Counter drift решён архитектурно

Централизованные сервисы addScrap() и addUserXP() — единая точка входа для всех начислений. season-stats-updater.ts обновляет scrapTotalEarned + scrapFrom{Source} в одном upsert. Reconciliation cron не нужен.

4. Blast radius непропорционален выигрышу

  • UserSteamProfile: ~8 файлов, средний риск (webhook matching по steamId)
  • UserBanInfo: ~5 файлов, но isBanned на auth hot path — высокий риск
  • Выигрыш чисто когнитивный, не технический

5. Денормализованные счётчики оправданы

quizzesCompleted, casesOpened и др. — теоретически SUM(UserSeasonStats.*), но используются в лидербордах и профиле напрямую. Один SELECT vs агрегация по сезонам.

Когда пересмотреть

  • Row lock contention при >100K активных пользователей
  • Рефакторинг Steam-интеграции (естественный момент для выноса UserSteamProfile)
  • ALTER TABLE users ADD COLUMN начнёт вызывать заметные lock-и в production

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

  • Counter consistency: scrap.service.ts, xp.service.ts, season-stats-updater.ts
  • Quest refactoring: ADR в PLAN_rustquest/ (39 rust* → rustParams Json?)