From a3434ed894cccdacd43bdcc853dc258b99c175ca Mon Sep 17 00:00:00 2001 From: nk Date: Sat, 6 Jun 2026 16:20:25 +0300 Subject: [PATCH] =?UTF-8?q?perf(backend):=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=8B,=20=D0=BA=D1=8D=D1=88=20=D1=87=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=20up?= =?UTF-8?q?sert,=20=D1=84=D0=B8=D0=BA=D1=81=20N+1=20=D0=BE=D0=B1=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - track_plays(played_at,track_id,station_id) покрывающий + tracks(first_seen_at) WHERE pending частичный (применены CONCURRENTLY на проде + миграция idempotent) - ChartsService.getTopTracks: in-memory TTL-кэш 90с по (period,genre,limit) → детальная страница и параллельные запросы не пересчитывают тяжёлые агрегации - NowPlayingService.ingest: не пишем now_playing и не шлём сокет, если трек не изменился (было ~20k бесполезных upsert/час) - enrichNowPlaying: вместо N+1 (300 upsert/мин) — один batched findMany по normKey Co-Authored-By: Claude Opus 4.8 --- .../20260606120000_perf_indexes/migration.sql | 5 +++ prisma/schema.prisma | 4 ++ src/charts/charts.service.ts | 25 ++++++++++-- src/enrich/enrichment.service.ts | 40 ++++++++++++++----- src/now-playing/now-playing.service.ts | 11 +++++ 5 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260606120000_perf_indexes/migration.sql diff --git a/prisma/migrations/20260606120000_perf_indexes/migration.sql b/prisma/migrations/20260606120000_perf_indexes/migration.sql new file mode 100644 index 0000000..a5ace58 --- /dev/null +++ b/prisma/migrations/20260606120000_perf_indexes/migration.sql @@ -0,0 +1,5 @@ +-- Покрывающий индекс под агрегацию чартов (идемпотентно — на проде уже создан CONCURRENTLY). +CREATE INDEX IF NOT EXISTS "track_plays_window_idx" ON "track_plays" ("played_at", "track_id", "station_id"); + +-- Частичный индекс под бэкфилл обогащения (Prisma не выражает partial WHERE в схеме). +CREATE INDEX IF NOT EXISTS "tracks_enrich_pending_idx" ON "tracks" ("first_seen_at" DESC) WHERE "enrich_status" = 'pending'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e407446..be64c32 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,6 +139,8 @@ model Track { likes TrackLike[] @@index([genre]) + // Частичный индекс под бэкфилл обогащения создаётся миграцией (Prisma не умеет + // partial WHERE): tracks_enrich_pending_idx (first_seen_at DESC) WHERE enrich_status='pending'. @@map("tracks") } @@ -154,6 +156,8 @@ model TrackPlay { @@index([trackId, playedAt]) @@index([playedAt]) @@index([stationId]) + // Покрывающий индекс под агрегацию чартов (WHERE played_at>=X → GROUP BY track_id, COUNT DISTINCT station_id) + @@index([playedAt, trackId, stationId], map: "track_plays_window_idx") @@map("track_plays") } diff --git a/src/charts/charts.service.ts b/src/charts/charts.service.ts index 5ffc9d7..03b4fbc 100644 --- a/src/charts/charts.service.ts +++ b/src/charts/charts.service.ts @@ -214,12 +214,23 @@ export class ChartsService { } } + // In-memory TTL-кэш чартов: чарт меняется медленно, а агрегации тяжёлые. + // Один пересчёт на (period, genre, limit) раз в CHART_TTL. + private chartCache = new Map(); + private static readonly CHART_TTL = 90_000; + // Чарт треков за период (с опциональным фильтром по жанру) async getTopTracks( period: ChartPeriod, limit: number, genre?: string, ): Promise<{ items: ChartEntry[] }> { + const cacheKey = `${period}|${genre ?? ''}|${limit}`; + const cached = this.chartCache.get(cacheKey); + if (cached && Date.now() - cached.at < ChartsService.CHART_TTL) { + return cached.data; + } + const since = this.periodStart(period); const duration = this.periodDuration(period); const prevSince = new Date(since.getTime() - duration); @@ -232,7 +243,11 @@ export class ChartsService { select: { id: true }, }); genreTrackIds = matched.map((t) => t.id); - if (genreTrackIds.length === 0) return { items: [] }; + if (genreTrackIds.length === 0) { + const empty = { items: [] }; + this.chartCache.set(cacheKey, { at: Date.now(), data: empty }); + return empty; + } } // Топ текущего периода: группировка по trackId @@ -248,7 +263,9 @@ export class ChartsService { }); if (currentGroups.length === 0) { - return { items: [] }; + const empty = { items: [] }; + this.chartCache.set(cacheKey, { at: Date.now(), data: empty }); + return empty; } const trackIds = currentGroups.map((g) => g.trackId); @@ -347,7 +364,9 @@ export class ChartsService { }; }); - return { items }; + const result = { items }; + this.chartCache.set(cacheKey, { at: Date.now(), data: result }); + return result; } // Детальная страница трека diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts index a62de11..d418207 100644 --- a/src/enrich/enrichment.service.ts +++ b/src/enrich/enrichment.service.ts @@ -92,19 +92,39 @@ export class EnrichmentService { const rows = await this.prisma.nowPlaying.findMany({ select: { artist: true, song: true }, }); - const todo: { id: string; artist: string; song: string; normKey: string }[] = []; + // Треки уже созданы в ChartsService.recordPlay — не upsert'им построчно (был + // N+1 на ~300 строк/мин), а читаем пачкой по normKey. Мусор/исключённые + // станции трек не создавали → их и не обогащаем (это правильно). + const normKeys = new Set(); for (const r of rows) { const artist = (r.artist ?? '').trim(); const song = (r.song ?? '').trim(); - if (!artist || !song) continue; - const normKey = this.buildNormKey(artist, song); - const track = await this.prisma.track.upsert({ - where: { normKey }, - create: { normKey, artist, song }, - update: {}, - select: { id: true, coverUrl: true, enrichStatus: true }, - }); - if (!track.coverUrl) todo.push({ id: track.id, artist, song, normKey }); + if (artist && song) normKeys.add(this.buildNormKey(artist, song)); + } + if (normKeys.size === 0) return; + + const tracks = await this.prisma.track.findMany({ + where: { normKey: { in: [...normKeys] } }, + select: { + id: true, + artist: true, + song: true, + normKey: true, + coverUrl: true, + enrichStatus: true, + }, + }); + + const todo: { id: string; artist: string; song: string; normKey: string }[] = []; + for (const track of tracks) { + if (!track.coverUrl) { + todo.push({ + id: track.id, + artist: track.artist, + song: track.song, + normKey: track.normKey, + }); + } // полное обогащение (жанр) — в общую очередь, если ещё не сделано if (track.enrichStatus !== 'done') this.enqueue(track.id); } diff --git a/src/now-playing/now-playing.service.ts b/src/now-playing/now-playing.service.ts index 7c31af1..33d4720 100644 --- a/src/now-playing/now-playing.service.ts +++ b/src/now-playing/now-playing.service.ts @@ -149,6 +149,17 @@ export class NowPlayingService { where: { stationId: stationDbId }, }); + // Ничего не изменилось (станцию опросили, трек тот же) — не пишем в БД и не + // шлём сокет: иначе ~20k бесполезных upsert/час и лишний churn индексов. + if ( + prev && + prev.song === song && + prev.artist === artist && + prev.coverUrl === coverUrl + ) { + return; + } + const updated = await this.prisma.nowPlaying.upsert({ where: { stationId: stationDbId }, create: { stationId: stationDbId, song, artist, coverUrl },