{ "index": 211, "slug": "editorial-2022-02-field-performance-budget", "title": "Когда total зелёный, а компонент красный: как проверять бюджет производительности", "excerpt": "Общий score может пройти, пока отдельная часть маршрута уже превысила допуск. Разбираем контракт сравнения, named budgets, отрицательный путь и проверяемое действие без выдуманных production-выводов.", "contentHtml": "

Страница не стала заметно медленнее по общему числу, но один этап маршрута пересёк свой предел. CI показывает зелёный total, а отчёт рядом отмечает красный scriptTicks. Команда пропускает изменение, потому что итог выглядит безопасным. Цена ошибки — накопленная деградация: следующий релиз добавит ещё один небольшой расход, а найти момент поломки будет уже трудно.

\n

Бюджет производительности нужен не для одного красивого числа. Он разделяет маршрут на именованные части и проверяет каждую часть в одинаковых условиях. Если total равен 93 при допустимых 98, а scriptTicks равен 34 при допустимых 30, результат должен быть отказом компонента. Зелёная сумма не отменяет красную ветку. Это учебный пример с условными единицами. Он не сообщает скорость реальной страницы.

\n

Тезис: сначала контракт, потом цифры

\n

Сравнение baseline и candidate имеет смысл только при одном контракте. Контракт включает маршрут, сценарий, версию рабочей нагрузки, единицу измерения и условия запуска. К условиям относятся, например, состояние кеша, набор данных и профиль устройства. Если хотя бы одно условие изменилось, различие чисел нельзя назвать регрессией. Сначала нужно вернуть сопоставимость.

\n

После проверки контракта система проверяет named components. Для каждого компонента нужны четыре значения: фактическое значение, limit, tolerance и итоговый allowed. Формула проста: allowed = limit + tolerance. Компонент проходит, если actual <= allowed. Aggregate вычисляется отдельно. Он показывает запас общего бюджета, но не получает права скрывать отказ части.

\n

Такой порядок ограничивает вывод. Comparable PASS означает только то, что известные проверки прошли. Comparable FAIL означает, что при одинаковых условиях хотя бы один именованный компонент превысил предел. Несопоставимый вход не означает FAIL и не означает PASS. Он означает, что сравнение остановилось до интерпретации.

\n

Механизм на коротком примере

\n

Представим маршрут /training/checkout/review и сценарий анонимной корзины с одним товаром. В бюджете заданы четыре учебных компонента. В baseline скрипты занимают 26 условных тиков. В candidate — 34. Limit равен 28, tolerance — 2, поэтому allowed равен 30. Aggregate candidate равен 93 при aggregate allowed 98.

\n
const budget = {\n  contract: {\n    route: \"/training/checkout/review\",\n    scenario: \"anonymous-cart-with-one-item\",\n    conditions: { cache: \"warm\", workloadVersion: 1 }\n  },\n  components: {\n    documentTicks: { limit: 26, tolerance: 1 },\n    styleTicks: { limit: 16, tolerance: 1 },\n    scriptTicks: { limit: 28, tolerance: 2 },\n    renderTicks: { limit: 22, tolerance: 1 }\n  },\n  aggregateAllowed: 98\n};\n\nconst baseline = {\n  ...budget.contract,\n  timingFields: {\n    documentTicks: 24,\n    styleTicks: 15,\n    scriptTicks: 26,\n    renderTicks: 20\n  }\n};\n\nconst candidate = {\n  ...budget.contract,\n  timingFields: {\n    documentTicks: 24,\n    styleTicks: 15,\n    scriptTicks: 34,\n    renderTicks: 20\n  }\n};\n\nconst sameContract = (left, right) =>\n  left.route === right.route &&\n  left.scenario === right.scenario &&\n  left.conditions.cache === right.conditions.cache &&\n  left.conditions.workloadVersion === right.conditions.workloadVersion;\n\nfunction compareBudget(budget, baseline, candidate) {\n  if (!sameContract(baseline, candidate)) {\n    return { status: \"INCOMPARABLE\", reason: \"contract differs\" };\n  }\n\n  const components = Object.entries(budget.components).map(([name, rule]) => {\n    const actual = candidate.timingFields[name];\n    const allowed = rule.limit + rule.tolerance;\n    return { name, actual, allowed, status: actual <= allowed ? \"PASS\" : \"FAIL\" };\n  });\n  const aggregate = Object.values(candidate.timingFields)\n    .reduce((sum, value) => sum + value, 0);\n  const aggregateStatus =\n    aggregate <= budget.aggregateAllowed ? \"PASS\" : \"FAIL\";\n\n  return {\n    status: components.some(({ status }) => status === \"FAIL\")\n      || aggregateStatus === \"FAIL\" ? \"FAIL\" : \"PASS\",\n    components,\n    aggregate,\n    aggregateStatus\n  };\n}\n\nconsole.log(compareBudget(budget, baseline, candidate));\n// FAIL: scriptTicks actual 34, allowed 30; aggregate 93 is PASS
\n

В реальном коде проверка должна вернуть не только boolean. Отчёту нужны имя компонента, actual, allowed, status и граница следующего действия. Иначе человеку придётся восстановить причину по общей сумме. Это снова превращает бюджет в декоративный показатель.

\n

Следующий шаг после такого отказа не обязан быть большим. Сначала проверьте вход, который принадлежит scriptTicks: размер изменившегося bundle, новый dynamic import, число обработанных элементов или другой заранее выбранный источник. Если источник не определён, не называйте библиотеку причиной. Число 34 показывает нарушение допуска, но не объясняет его.

\n

Как читать отчёт

\n

Читайте результат сверху вниз. Сначала откройте validation и убедитесь, что baseline и candidate сравнимы. Затем посмотрите список failed components. После этого прочитайте actual и allowed конкретной ветки. Aggregate оставьте напоследок. Такой порядок не даёт зелёному total занять место решения.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Total PASS, component FAILСумма скрывает распределение расходовСравнить actual с allowed по имениСчитать overall FAIL и исследовать одну границу
Входы отличаютсяBaseline и candidate несопоставимыСверить route, scenario и conditionsОстановить verdict и повторить с одним контрактом
Все components PASS, total растётОбщий запас сокращаетсяСравнить aggregate с его пределомНайти компонент с наибольшим вкладом
Один компонент скачет между запускамиШум измерения или нестабильное условиеПроверить повторяемость и профиль запускаУточнить протокол до изменения limit
Причина не видна в отчётеБюджет хранит только числоПроверить наличие source и owner boundaryДобавить один наблюдаемый вход, а не гипотезу
\n

Иллюстрация границ вывода

\n
\"Схема
Сначала проверяется сопоставимость входов, затем named component и aggregate. Учебная схема показывает порядок решения, а не измерение конкретного production-маршрута.
\n

Иллюстрация важна из-за отрицательного пути. Если cache или scenario изменились, стрелка не должна вести к красной метрике. Система должна вернуть состояние «сравнение недействительно» и объяснить, какое условие разошлось. Если этого не сделать, изменение среды выглядит как изменение приложения.

\n

Порядок действий

\n
  1. Запишите симптом. Сохраните route, scenario, baseline, candidate и точное имя компонента. Не начинайте с предположения о виновной библиотеке.
  2. Проверьте контракт. Сверьте версию рабочей нагрузки, единицы, кеш, данные, профиль запуска и остальные объявленные условия. Любое различие блокирует числовой verdict.
  3. Проверьте allocation. Для каждого компонента посчитайте allowed = limit + tolerance. Укажите actual и границу рядом.
  4. Отделите component от aggregate. Сначала сформируйте список failed components. Общую сумму используйте как контекст, не как разрешение пропустить красную ветку.
  5. Сузьте исследование. Выберите одну границу: bundle, route input, dynamic import, обработку данных или другую реально наблюдаемую часть. Следующий сбор должен различать хотя бы две гипотезы.
  6. Повторите тот же compare. После небольшого изменения сохраните прежний контракт. Если маршрут, сценарий или условия изменились, создайте новый baseline и укажите причину, а не сравнивайте несопоставимые числа.
  7. Зафиксируйте предел вывода. Напишите, что результат подтверждает и чего не подтверждает. Учебные ticks не превращайте в browser trace, пользовательскую метрику или SLA.
\n

Как связать учебные поля с браузером

\n

Для даты этой статьи важен статус API. В феврале 2022 года Navigation Timing Level 2 был Working Draft со снимком от 2 февраля, а User Timing Level 3 — Candidate Recommendation Snapshot от 2 декабря 2021 года. Эти статусы описывают состояние спецификаций, а не поддержку каждого браузера. Поэтому перенос контракта требует отдельной проверки доступности.

\n

Платформа даёт реальные источники, но не готовую таблицу для любого проекта. PerformanceNavigationTiming описывает временные отметки навигации текущего документа. User Timing даёт named marks и measures. Эти интерфейсы помогают выбрать источник для конкретного production-поля. Они не говорят, что условный scriptTicks равен времени выполнения JavaScript или что четыре учебных тика уже собраны браузером.

\n

Перед переносом модели составьте mapping для каждого поля: имя бюджета, источник, момент получения, единица, условия доступности, преобразование и владелец. Если поле зависит от браузера, укажите поддержку и fallback. Если данных нет, верните отсутствие данных. Не подставляйте похожее число из другого API только потому, что оно удобно для формулы.

\n

Например, navigation entry может описать загрузку документа, но не объяснить стоимость долгого обработчика после загрузки. Для пользовательского взаимодействия нужен отдельный источник и отдельная методика. Смешивание этих наблюдений в один total создаёт точный, но бессмысленный score. Named budget полезен только там, где его граница совпадает с тем, что действительно измеряется.

\n

Ограничения и отрицательный путь

\n

Бюджет не доказывает, что страница быстрая для всех пользователей. Один запуск не заменяет распределение по устройствам, сетям и сценариям. Aggregate не заменяет пользовательские метрики. Synthetic compare не является браузерным trace, если браузер не выполнял заданную процедуру. Учебные числа в примере нельзя выдавать за наблюдения реального сервиса.

\n

Есть и обратный путь после отказа. Если повторная проверка показывает, что изменился cache, а не код, не повышайте limit и не объявляйте regression. Исправьте условия и повторите сравнение. Если условия одинаковы, но компонент снова превышает allowed, собирайте один конкретный источник на его границе. Если источник не позволяет отличить причины, это ограничение знания, а не повод написать более сильный вывод.

\n

Повышение limit допустимо только как отдельное решение. Оно меняет защиту от роста и должно иметь владельца, причину и новый ожидаемый предел. Нельзя лечить failed component увеличением aggregate: это убирает сигнал, но не уменьшает стоимость маршрута.

\n

Проверяемый критерий готовности

\n

Проверка готова, когда система выполняет четыре условия. Она останавливает compare при различии контракта. Она показывает каждый component actual, limit, tolerance и allowed. Она возвращает FAIL, если хотя бы один named component превысил allowed, даже при зелёном aggregate. Она выводит следующее узкое действие и явно отделяет измеренное от неизвестного.

\n

Для учебного примера критерий можно проверить так: одинаковые route, scenario и conditions дают scriptTicks=34, allowed=30, aggregate=93, aggregateAllowed=98 и общий статус FAIL; изменение только cache или scenario даёт состояние несопоставимости; ни один из этих результатов не называется production-измерением. Для реального маршрута к этому набору добавьте источник каждого поля и повторяемый протокол запуска.

\n

Проверяемые источники

\n" }