20 KiB
Design: профилирование фронта и бэка (#202)
Зачем
Заказчик сообщил (пересказом, без деталей), что:
- тормозит скролл — предположительно любой большой формы, там много скроллить;
- модальное окно по нажатию кнопки открывается не сразу.
Бэк ранее профилировался базово, поэтому основное подозрение — фронт, включая утечки памяти. Задача — подсветить проблемные места с доказательствами. Исправлять ничего не нужно: исправления пойдут отдельными задачами по результатам отчёта.
Цели
- Воспроизводимый локальный стенд, поднимаемый одной-двумя командами, чтобы позже снять замеры «после» на тех же данных и сценариях.
- Замеры по двум симптомам заказчика и по памяти, для фронта и для бэка, которые обслуживают эти сценарии.
- Отчёт: список проблемных мест, у каждого — симптом, доказательство (цифры и ссылка на артефакт: trace, профиль, heap snapshot, EXPLAIN), место в коде (
file:line), оценка влияния, приоритет.
Не цели
- Исправления кода приложения, даже очевидные.
- Нагрузочное тестирование многими пользователями.
- Интеграция замеров в CI.
- Воспроизведение прод-ресурсов на стенде (см. «Ограничения»).
Анти-цели (провал, даже если формально сделано)
- Выводы без замера. Находки статического анализа допустимы только с явной пометкой «не подтверждено замером» и отдельно от подтверждённых.
- Замеры только на dev-сборке фронта (
rsbuild dev): она искажает картину React. - Правки в
api/src,api/alembic,web/src,web/rsbuild.config.tsили в зависимостях приложения ради замеров. Всё, что нужно стенду, живёт вperf/. - Цифры, которые нельзя повторить: каждая цифра отчёта получается командой из
perf/.
Ограничения и известные факты
- Машина заказчика сильно медленнее машины разработчика. Фронт мерить с замедлением CPU через CDP (
Emulation.setCPUThrottlingRate) в двух режимах — 4× и 6×, а также без замедления для сравнения. - Прод-сервер — около 1 vCPU / 4 GB (
raisa_config.ymlустарел). Стенд по указанию владельца ресурсами не ограничивается. Цифры бэка в отчёте помечаются как полученные на быстром железе, без лимитов. - БД — только Postgres, схема
v3. SQLite не годится: миграции на PL/pgSQL. Postgres 16 в docker (образ доступен). - Зависимости: npm берётся из публичного registry, для Python —
api/requirements-dev.txt, без корпоративного Nexus.raisa-fastapi-protected-apiне нужен (закомментирован в коде). - При
DEBUG=trueмиграции на старте не выполняются. Стенд сам делаетcd api && alembic upgrade head. Авторизация — локальный JWT (/api/v1/auth/login). - cwd бэка —
api/. Собранный фронт API отдаёт изapi/web— либо так, либо через отдельный статический сервер; на выбор исполнителя, лишь бы это была prod-сборка. - Таблица листа (
web/src/components/RealtimeTable/RealtimeTable.jsx) уже использует виртуализацию строк и колонокmaterial-react-table. Гипотезу «нет виртуализации» не проверять как основную. - Состояние WebSocket-блокировок в памяти процесса — бэк в один процесс.
- Браузер — Chromium (Playwright), методика на CDP. У заказчика Яндекс.Браузер (Chromium) — цифры переносимы. В методике отчёта зафиксировать версию Chromium стенда и оговорку: расширения и собственные фоновые процессы Яндекс.Браузера стенд не воспроизводит.
Что решено
Стенд (perf/)
- Postgres 16 в docker, миграции alembic до head, затем генерация данных.
- Бэк —
uvicornизapi/в одном процессе, без лимитов. Фронт —npm run build, отдача prod-сборки. - Для React Profiler — отдельная профилирующая сборка (alias
react-dom→react-dom/profiling;scheduler/tracingв React 18 нет) через собственный конфиг rsbuild вperf/, который расширяетweb/rsbuild.config.tsи не меняет его. Основные цифры (время, FPS, long tasks) снимаются на обычной prod-сборке; профилирующая сборка нужна только для ответа «что перерисовывается». perf/README.md: как поднять стенд, сгенерировать данные, прогнать сценарии и где лежат результаты.
Данные
- Объём заказчик не сообщил. Генератор параметризуется масштабом. Базовый масштаб — «большой, но правдоподобный»: формы FORM_1, FORM_2, FORM_4 за один год по оргединицам, в листах — строки по всему дереву статей расходов из сида (
0003_initial_data.sql), строки ВСП и проекты для FORM_3/FORM_4. Выбранные числа и их обоснование исполнитель записывает в отчёт. - Второй прогон на удвоенном масштабе показывает, как время растёт с объёмом.
- Данные создаются через те же SQL-функции
v3.add_*, что использует приложение, где это практично. Прямые INSERT — только там, где функции нет; в этом случае генератор не должен нарушать инварианты, которые обеспечивают функции.
Роль и этапы
- Основная роль замеров — EXECUTOR_RF с открытым окном этапа на правку (жалуется, скорее всего, исполнитель): таблица рендерит колонки и права на строки по роли и этапу (
RealtimeTable.jsx:111,:263–273), модалки выбора ВСП и статьи доступны только при праве править строки. Контрольный прогон — ADMIN, только для скролла на базовом масштабе. - Генератор создаёт пользователей, оргединицы и этапы (
form_phase) с окнами, открытыми на дату замера.
Сценарии фронта (Playwright + CDP)
- Скролл. Для каждого листа каждой формы (FORM_1: AHR/CAP/OPER/SMETA с direction; FORM_2: основные листы и сателлиты; FORM_4: листы и STRUCTURE) и отчёта проекта (FORM_3): вертикальный скролл от начала до конца и обратно, горизонтальный по всем колонкам. Метрики: кадры и потерянные кадры, long tasks (>50 мс) и их суммарное время, время скриптов, стиля и layout из trace. Каждый прогон — в трёх режимах CPU.
1a. Скролл при живом соавторе. Тот же скролл на базовом масштабе в режиме 6×, пока второй пользователь (скрипт через WebSocket, без браузера) правит ячейки этого листа с заданной частотой (1 и 5 правок/с). Входящие
cell_updated/cell_edit_start/row_addedобрабатываются вRealtimeContext.js:105–160и могут перерисовывать таблицу во время скролла. Это один фоновый соавтор, а не нагрузочный тест. 1b. Своды (вторым приоритетом после листов форм): свод 1 (pages/SvodPage/SummaryTable.jsx), свод 2 (pages/Svod2Page/Svod2Page.jsx),ProjectSummariesPage— скролл и смена фильтров; данные генератора должны давать правдоподобно заполненные своды по всем оргединицам. SQL сводных функций — в замеры бэка. - Модалки. Перечень строится по коду: все модалки и диалоги, доступные со страниц форм, задачи и проекта — этапы (
SettingModal), добавление года, экспорт, редактирование проекта, создание проекта и т.д. Модалки админки — вторым приоритетом. Метрика: от клика до первого кадра с видимой модалкой (requestAnimationFrameпосле появления в DOM), long tasks в этом окне, какие компоненты перерисовались (по профилирующей сборке). Отдельно — открытие модалки поверх открытого большого листа, если такое есть. - Память. Длительный сценарий — циклы «открыть лист → скролл → правка ячеек через WebSocket → уйти на список задач → открыть другой лист», около 20 циклов. Метрики:
JSHeapUsedSizeпо циклам после принудительного GC, heap snapshots в начале, середине и конце, detached DOM nodes, число слушателей событий, число открытых WebSocket. Рост между снимками разбирается до retainers.
Замеры бэка
- Время ответа каждого запроса, который фронт делает в сценариях выше (из сетевого лога Playwright), плюс отдельный прогон этих же запросов без браузера — медиана и p95 по N повторам.
pg_stat_statementsза время сценариев: топ запросов и функций по суммарному и среднему времени. Для топа —EXPLAIN (ANALYZE, BUFFERS).- Размер ответов: тяжёлый JSON листа влияет и на фронт.
- Профиль Python (py-spy) — только если время ответа заметно больше времени SQL.
Статический разбор
Параллельно замерам — разбор кода фронта по местам, на которые указывают профили: нестабильные значения контекстов, пересоздание колонок и конфигов на рендер, слушатели и подписки без очистки, таймеры, WebSocket без закрытия. Каждая находка ссылается на file:line и помечена: подтверждена замером или нет.
Отчёт perf/REPORT.md
По-русски, для заказчика и команды:
- Короткое резюме: что тормозит, почему и насколько, топ-5 мест.
- Методика и окружение: железо, версии, масштаб данных, режимы CPU, оговорка про прод-ресурсы.
- Таблица проблемных мест: № | место (
file:line) | симптом | доказательство | влияние | приоритет (P1–P3) | подтверждено замером (да/нет). - Подробности по каждому месту.
- Что не удалось проверить и почему.
Сырые артефакты (traces, heap snapshots) складываются в perf/results/<дата>/ и в git не коммитятся (.gitignore). Коммитятся сводные JSON с цифрами и отчёт.
На усмотрение исполнителя
- Язык и инструменты генератора данных (Python + asyncpg или SQL), структура
perf/, docker compose или скрипты. - Точные числа масштаба (с обоснованием в отчёте), число повторов, пороги.
- Как обходить UI в сценариях (селекторы, прямые URL страниц).
Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-10-25 | Стенд коммитится в perf/ в репозиторий |
Нужен повтор замеров «после» на тех же данных | Лишний каталог в репо; удаляется одним коммитом |
| 2026-10-25 | «Стенд не ограничивай» понято как «без лимитов CPU и памяти у бэка и БД» | Ближайшая трактовка реплики владельца сразу после обсуждения лимитов | Цифры бэка оптимистичнее прода; оговорено в отчёте |
| 2026-10-25 | Замедление CPU фронта 4× и 6× | Стандартные пресеты DevTools для медленной машины; реальная машина заказчика неизвестна | Неверный масштаб абсолютных цифр; относительный рейтинг мест не меняется |
| 2026-10-25 | Отчёт — markdown в perf/REPORT.md |
Формат для заказчика владелец не выбрал; markdown легко превратить в документ | Переоформление отчёта |
| 2026-10-25 | Масштаб данных задаёт исполнитель, плюс прогон на удвоенном масштабе | Реальные объёмы неизвестны | Неверная абсолютная величина; удвоение показывает чувствительность |
| 2026-10-25 | skip_specs: true |
Change не меняет поведение системы | — |
| 2026-10-25 | Основная роль — EXECUTOR_RF с открытым окном, ADMIN — контрольный прогон скролла | Жалобы, скорее всего, от исполнителей; рендер зависит от роли и этапа | Замеры на конфигурации, которой нет у заказчика; ADMIN-прогон показывает разницу |
| 2026-10-25 | Добавлен сценарий «скролл при одном фоновом соавторе» (1 и 5 правок/с), но не нагрузочный тест | Совместная работа — основной режим системы, входящие WS-события перерисовывают таблицу | +1 сценарий; если не влияет — так и будет написано |
| 2026-10-25 | Своды 1, 2 и сводные проектов — в скоупе вторым приоритетом | Пользователь может называть свод «формой»; это тяжёлые таблицы | Больше работы по данным и сценариям |
| 2026-10-25 | Postgres 16 | Версия прод-БД неизвестна; 16 — актуальная стабильная | Мелкие расхождения планов запросов с продом |
| 2026-10-25 | R-1 (design #11): Task 3 заполняет svods/modal_pages манифеста по маршрутам из Routes.jsx |
Иначе сценарии T5/T7 не получат список страниц | Доработка T3 при T5/T7 |
| 2026-10-25 | Email админа стенда admin@perf-stand.com вместо admin@perf.local |
/api/v1/users/me падает 500: email-validator отклоняет .local в response_model |
Другой email у админа стенда; на замеры не влияет |
| 2026-10-25 | В perf/requirements.txt добавлен greenlet |
api/requirements-dev.txt его не содержит, alembic на asyncpg падает без него |
Лишняя зависимость в venv стенда |
| 2026-10-25 | perf/web/rsbuild.perf.config.ts импортирует @rsbuild/core по относительному пути ../../web/node_modules/@rsbuild/core |
Конфиг в perf/ не видит web/node_modules через резолв пакетов |
Путь ломается при смене раскладки каталогов |
| 2026-10-25 | В .gitignore шаблон для трейсов perf/**/*trace*.json |
Формулировка «*.json trace» неоднозначна | Лишний или пропущенный файл в игноре |
| 2026-10-25 | В .gitignore добавлено исключение !perf/docker-compose.yml |
Корневой .gitignore игнорирует docker-compose.yml |
Файл стенда не попадёт в коммит |
| 2026-10-25 | В манифесте сегмент «нет direction» в URL листа — литерал null |
TableList.jsx строит путь через ${table?.direction}; при null из API получается null (при undefined было бы undefined) |
Переходы на листы без direction дадут 404/пустую страницу |
| 2026-10-25 | Агрегатор считает median чётного n как среднее двух центральных, p95 — nearest-rank; нечисловые и bool-метрики игнорируются | Бриф задаёт nearest-rank только для p95; median по стандартному определению | Небольшое расхождение чисел с ручным расчётом |
| 2026-10-25 | results.append валидирует наличие всех обязательных ключей записи |
Ранний отказ на кривом формате от Node-стороны | Лишний ValueError при расширении формата |