[perf][server] Низковисящие оптимизации бэка: триграммные индексы для саджестов, 4 лишних DB-запроса на каждый API-вызов, ре-эмбеддинг без дедупа, CT… #348
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Суть
Серверная пара к клиентскому перф-комплекту (#342/#343/#344/#346, комментарии — #340). Бэкенд в форке уже сильно оптимизирован (персист Y.Doc дебаунсится 10–45 с, history коалесцируется через
jobId+delay, FTS на GIN, пагинация безCOUNT(*), breadcrumbs одним рекурсивным CTE, CASL-права в Redis-кэше, broadcast'ы скоупены) — но осталась россыпь дешёвых точечных фиксов, каждый из которых бьёт по горячему пути:Суммарно — одна миграция и пара десятков строк кода, без изменения поведения.
Диагноз (по соотношению эффект/усилия)
1. Саджесты: leading-wildcard LIKE без триграммного индекса
search.service.ts (
/search/suggest):LOWER(f_unaccent(pages.title)) LIKE LOWER(f_unaccent('%q%'))— и то же дляusers.name,groups.name. Leading%по функции ⇒ btree бесполезен ⇒ скан страниц воркспейса на каждый keystroke в спотлайте и пикерах. Расширенияpg_trgm+unaccentуже установлены (миграция20250729T213756), ноgin_trgm_ops-индексов в миграциях нет ни одного (проверено grep'ом).2. Горячий путь auth: 4 некэшированных DB-запроса до хендлера
На каждый запрос: domain.middleware.ts грузит workspace (
findFirst()self-hosted /findByHostname()cloud) → jwt.strategy.ts грузит тот же workspace повторно (workspaceRepo.findById), затем юзера (userRepo.findById) и сессию (userSessionRepo.findActiveById). Совпадение workspace уже гарантировано проверкойreq.raw.workspaceId === payload.workspaceIdв началеvalidate(). CASL-слой при этом уже закэширован (getUserSpaceRoles,canUserEditPage—withCache, 5 с) — некэшированной осталась только identity-часть.3. AI-очередь: ре-эмбеддинг на каждое сохранение без jobId/delay
persistence.extension.ts#L437-L442:
aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {pageIds:[page.id]…})без опций. Консьюмер (embedding-indexer.service.ts → reindexPage) на каждый джоб re-chunk'ает всю страницу, зовёт внешний embedding-API по всем чанкам и переписывает все строкиpage_embeddingsв транзакции — при concurrency 1. Активная правка = очередь копится за латентностью внешнего API, давя на Redis/БД. Правильный паттерн ({jobId, delay}+ возрастной дебаунс) уже реализован вenqueuePageHistoryв этом же файле.4.
filterAccessiblePageIds: рекурсивный CTE без workspace-level short-circuitpage-permission.repo.ts#L656-L731: short-circuit
hasRestrictedPagesInSpaceсрабатывает только при переданномspaceId. Без него — полный рекурсивный обход предков + anti-join наpage_accessвсегда. Горячие вызовы безspaceId: favorites (грузится при старте приложения,favorite.service.ts), notifications, recent по всем спейсам, created-by, глобальный поиск (search.service.ts). В типичном воркспейсе ограниченных страниц ноль — вся работа впустую.5. Пакет мелочи (по строчке-две)
content: page.repo.ts#L584getDeletedPagesInSpace—.select('content'), UI рендерит только title/icon/deletedBy.syncTransclusion).oldMentionedUserIdsуже вычисляется рядом (persistence.extension.ts#L414-L435).comment.controller.ts(findById) иcomment.service.ts(findByIdповторно).page_history:ORDER BY id DESCпри индексе(page_id, created_at DESC)— лишний sort; выровнять (индекс(page_id, id DESC)либо сортировка поcreated_at).comments:WHERE page_id ORDER BY idпри индексе только(page_id)— композитный(page_id, id).getShareForPage+hasRestrictedAncestor— два рекурсивных CTE по предкам подряд + 3 независимых await'а настроек последовательно (share.service.ts, share.controller.ts) —Promise.all+ слить обходы.hasRestrictedPagesInSpace— единственная проверка прав безwithCache(page.service.ts#L423).ValidationPipe { transform: true }глобально (main.ts): class-transformer деп-клонирует многомегабайтныйcontentна каждом create/update/import страницы.statement_timeoutу пула (database.module.ts);maintainLockв redis-sync.extension.ts#L222-L231 ставит interval без clearInterval предыдущего (утечка при reload-without-unload).Проверено и уже правильно (не трогать): индексы дерева/комментариев/избранного/нотификаций (
20260329T163516-add-new-indexes); list-эндпоинтыcontentне отдают (кроме trash выше); N+1 по breadcrumbs/членствам нет; per-keystroke путь collab чист; history-очередь — эталон коалесинга;removeOnCompleteу очередей ограничен; логирование без сериализации тел; основной/search— индексированныйtsv @@cts_headlineтолько по ≤25 строкам после LIMIT.Границы изменения
Только
apps/server: одна миграция (индексы), auth-путь (middleware + strategy), persistence.extension (опции enqueue + short-circuit'ы), пара repo/service-методов, main.ts (pipe), database.module.ts (конфиг пула). Поведение API 1:1; кэши — с короткими TTL и явной инвалидацией. Схема данных не меняется (только индексы).Решение
1. Миграция с триграммными индексами
Плюс индексы из «мелочи»:
page_history (page_id, id DESC)(или смена сортировки),comments (page_id, id).2. Auth-путь: убрать дубль +
withCachejwt.strategy.tsпереиспользоватьreq.raw.workspace, когдаreq.raw.workspaceId === payload.workspaceId(равенство уже проверено выше по коду), fallback на запрос.domain.middleware.ts: обернуть lookup в существующийwithCache(ключ — константа для self-hosted /workspace:byhost:<subdomain>для cloud, TTL 10–30 с) с инвалидацией на обновление workspace.withCacheuser:<wsId>:<userId>, TTL 5–15 с, явныйdelпри disable/logout/смене роли.sessionId, TTL ~5 с, явныйdelв revoke/logout — отзыв мгновенный, TTL лишь страховка.trackActivityуже throttled — не трогать.Итог: тёплый авторизованный запрос — 0 pre-handler DB-запросов вместо 4.
3. Дедуп AI-очереди (одна строка)
4. Workspace-level short-circuit прав
Кэшируемый
hasRestrictedPagesInWorkspace(workspaceId)(EXISTS поpage_access.workspace_id, зеркально per-space версии,withCacheс тем жеPERMISSION_CACHE_TTL_MS≈ 5 с — самозаживающий, отдельная инвалидация не обязательна) + short-circuit вfilterAccessiblePageIds, когдаspaceIdне передан.5. Пакет мелочи
По списку из диагноза: удалить
.select('content')в trash; short-circuitsyncTransclusionпри отсутствии transclusion/reference-узлов и пустых существующих записях; дифф меншенов перед enqueue; прокинуть загруженную страницу из comment.controller в service;Promise.all+ объединённый обход предков в share;withCacheдляhasRestrictedPagesInSpace; route-scoped pipe сtransform:falseдля content-несущих DTO (валидация остаётся);statement_timeout(щедрый, ~30 с, чтобы не убить export) в конфиг пула;clearInterval-guard вmaintainLock.Крайние случаи
LOWER(f_unaccent(col))), иначе планировщик их не возьмёт — проверитьEXPLAINпосле миграции.f_unaccent— IMMUTABLE-обёртка, для expression-индекса пригодна. На больших таблицах build индекса при миграции на старте займёт время — приемлемо для self-hosted, но отметить в CHANGELOG.delв logout/revoke/disable; TTL держать ≤15 с. Не кэшировать негативные результаты.addс существующим delayedjobId— это и есть коалесинг; корректно, т.к. процессор читает актуальное состояние страницы в момент выполнения. Проверить, что завершённый джоб не блокирует повторныйjobId(removeOnComplete: trueу AI-очереди уже стоит).hasRestrictedPagesInWorkspace: после выдачи первого page-level ограничения кэш до 5 с может отдавать «нет ограничений» — окно то же, что у существующих permission-кэшей (осознанный трейд-офф форка), поведение консистентно.transform:falseдля page-DTO: убедиться, чтоwhitelistи валидация остальных полей сохраняются (ручная проверка формыcontent— лёгкая, без прогонки через class-transformer).statement_timeout: не задеть экспорт больших спейсов (грузит все страницы одним запросом) — либо 30–60 с глобально, либо переопределение в export-сессии.Тесты / проверка
EXPLAIN (ANALYZE)саджест-запроса до/после: Bitmap Index Scan по trgm-индексу вместо Seq Scan.filterAccessiblePageIds: воркспейс без ограничений → вход == выход без CTE (спай на db); с ограничениями — прежнее поведение.persistence.extension, comment, share — прогнать; trash-эндпоинт больше не содержитcontentв ответе.bullметрики/Redis).Вне скоупа
ts_headlineпо большимtext_contentв полном поиске — bounded LIMIT 25, оставляем.План работ
page_history(page_id, id DESC)+comments(page_id, id);EXPLAIN-проверка.req.raw.workspace+withCacheworkspace/user/session + инвалидация в logout/disable (п. 2).{jobId, delay}дляPAGE_CONTENT_UPDATED(п. 3).hasRestrictedPagesInWorkspace+ short-circuit (п. 4).