{ "index": 60, "slug": "editorial-2026-05-practice-systems-performance", "title": "Медленный запрос при низком CPU: практический маршрут от браузера до базы", "excerpt": "Пошаговый способ разобрать долгий end-to-end запрос: зафиксировать одну границу, связать браузерный timing с trace, проверить базу отдельно и остановиться там, где данных недостаточно.", "contentHtml": "

Пользователь ждёт страницу десять секунд, а CPU сервиса держится на двадцати процентах. В такой ситуации легко переписать SQL, увеличить пул потоков или поднять timeout. Эти изменения могут убрать симптом на одном прогоне и оставить причину нетронутой. Сначала нужно определить, где именно прошло время: в браузере, на прокси, в очереди, в коде сервиса или в зависимости.

\n

Ниже — практический маршрут для одного воспроизводимого HTTP-сценария. Он не обещает найти bottleneck по одной картинке. Он помогает собрать минимальный набор сигналов, связать его через идентификатор запроса и выбрать следующий эксперимент. Все числовые значения в примере учебные, если прямо не указано обратное.

\n

Начать с одной контрольной границы

\n

До изменения кода запишите ровно тот запрос, который хотите ускорить: метод и маршрут, статус, размер ответа, время начала, длительность, идентификатор запроса, версию приложения и состояние кэша. Для повторного прогона добавьте cohort — фиксированный набор входных данных, число запросов, concurrency и форму ответа. Эти поля нужны не для отчётности: без них два коротких ответа могут быть результатом разных условий.

\n

Сформулируйте исходный факт без диагноза: «GET /catalog/42 вернул 200 за 10 000 мс, CPU процесса — около 20%». Фраза «медленная база» уже является гипотезой. Её можно проверять, но нельзя прятать в поле симптома. Если trace отобрана sampling-правилом или часть заголовков удаляет прокси, запишите это рядом с измерением.

\n
Минимальная контрольная граница для сравнения
ПолеПримерЗачем фиксироватьЧто ломает сравнение
Маршрут и методGET /catalog/42Определяют операцию и набор middlewareСравнение с другим endpoint
Вход и форма ответаitem=42, JSON v2Влияют на размер данных и план запросаДругой id или набор полей
Нагрузка12 запросов, concurrency 3Определяет очередь и конкуренцию за ресурсыДругая параллельность или cohort
Кэш и версияmiss, build abc123Разделяют cold и warm путьПопадание в кэш или иной код
Идентификаторыrequest_id и traceparentСвязывают клиент, сервис и зависимостиПоиск только по timestamp
\n

Разложить end-to-end путь по владельцам времени

\n

У end-to-end измерения есть граница от начала навигации или HTTP-запроса до события, которое вы считаете результатом. Внутри неё могут быть последовательные и параллельные участки. Браузер измеряет навигацию и ресурсы, сервис создаёт root span, а дочерние span описывают отдельные операции. Запрос к базе или партнёру может быть дочерним client span, но его имя не доказывает, что именно он создал задержку.

\n

Сначала ищите разрыв между границами. Если браузерная запись показывает десять секунд, а root span сервиса — две, оставшиеся восемь секунд не следует называть «сетевыми» без отдельного сигнала. Это может быть очередь перед сервисом, прокси, повтор на клиенте или ожидание до отправки. Если root длится десять секунд, а названные дочерние операции занимают только две, восемь секунд остаются неизвестными.

\n
Схема диагностики задержки: полный trace с названными сегментами сравнивается с одинаковой контрольной нагрузкой, проходит контрпример и приводит к следующей проверке либо именованной остановке
Наблюдение становится действием только после проверки полноты trace и сопоставимости нагрузки. Контрпример с пропущенным сегментом или другой нагрузкой должен остановить причинный вывод.
\n

Например, учебный root длится 1 000 условных единиц. В нём есть очередь 520, запрос к базе 150 и внешний вызов 200. Названные интервалы не покрывают весь root: остаётся 130 единиц промежутков и неразмеченного времени. Можно сказать, что очередь — самый длинный названный участок этой записи. Нельзя сказать, что она является production bottleneck или что изменение очереди сократит ответ.

\n

Воспроизвести проверку на локальном снимке

\n

Небольшой скрипт ниже проверяет только то, что можно проверить по переданному объекту: parent/child-связи, интервалы и контрольную границу. Он не читает телеметрию и не симулирует latency. Сохраните его как временный фрагмент в консоли браузера или запустите через node, предварительно заменив HTML-сущности обратно на символы в обычном JS-файле.

\n
const input = {\n  root: { id: 'root-01', start: 0, end: 1000 },\n  spans: [\n    { id: 'queue-01', parent: 'root-01', role: 'queue-wait', start: 40, end: 560 },\n    { id: 'db-01', parent: 'root-01', role: 'database-call', start: 570, end: 720 },\n    { id: 'partner-01', parent: 'root-01', role: 'external-call', start: 730, end: 930 }\n  ],\n  boundary: {\n    route: 'GET /catalog/42',\n    cohort: 'fixed-catalog-a',\n    requests: 12,\n    concurrency: 3,\n    cache: 'miss',\n    build: 'abc123'\n  }\n};\n\nfunction reviewTrace(trace) {\n  const ids = new Set(trace.spans.map((span) => span.id));\n  const connected = trace.spans.every((span) =>\n    span.parent === trace.root.id || ids.has(span.parent)\n  );\n  const timed = [trace.root, ...trace.spans].every((span) =>\n    Number.isFinite(span.start) &&\n    Number.isFinite(span.end) &&\n    span.end >= span.start\n  );\n\n  if (!connected) return { status: 'stop-incomplete-trace' };\n  if (!timed) return { status: 'stop-invalid-interval' };\n  return { status: 'observation-ready', namedSpans: trace.spans.length };\n}\n\nconsole.log(reviewTrace(input));\n// { status: 'observation-ready', namedSpans: 3 }
\n

Тест с отсутствующим родителем должен вернуть stop-incomplete-trace, а с start > end — stop-invalid-interval. Это полезный отрицательный путь: он запрещает восстановить дерево по удобному совпадению времени. Статус observation-ready означает только «структуру можно читать», а не «причина найдена».

\n

Проверить браузерный участок отдельно

\n

В браузере начните с Navigation Timing, а не с устного впечатления «страница открывается долго». API возвращает измерения текущей навигации. Для документа важны границы, которые соответствуют вашему критерию: например, время до первого байта, DOM construction или load event. Название метрики должно быть частью результата, иначе команда сравнит разные события под одним словом «загрузка».

\n
const navigation = performance.getEntriesByType('navigation')[0];\n\nif (!navigation) {\n  console.log({ status: 'stop-no-navigation-entry' });\n} else {\n  console.table({\n    type: navigation.type,\n    redirectMs: navigation.redirectEnd - navigation.redirectStart,\n    ttfbMs: navigation.responseStart - navigation.requestStart,\n    domContentLoadedMs:\n      navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,\n    loadEventMs: navigation.loadEventEnd - navigation.loadEventStart,\n    totalMs: navigation.loadEventEnd - navigation.startTime\n  });\n}
\n

Эти вычисления показывают интервалы браузерной навигации в миллисекундах. Они не раскрывают внутреннюю очередь сервера и не заменяют trace. В SPA, при переходе без полной навигации, нужный пользовательский сценарий может быть resource timing или собственным User Timing-маркером. Не переносите приведённые поля на любой сценарий без проверки типа навигации и момента, когда запись появилась.

\n

Проверить сервис и базу по отдельным сигналам

\n

На стороне сервиса найдите root по traceparent или request id, затем проверьте parent/child-связи, начало и конец каждого span, статус, retry и ошибки. OpenTelemetry использует span как единицу работы и допускает root span с подоперациями; контекст trace связывается между границами через стандартизированные HTTP-заголовки. Ни один из этих механизмов не создаёт пропущенный span и не превращает корреляцию в доказательство причины.

\n

Если подозрение остаётся на PostgreSQL, измерьте SQL отдельно на сопоставимом наборе данных. EXPLAIN показывает план, который выбрал планировщик. EXPLAIN ANALYZE дополнительно выполняет запрос, поэтому используйте его для безопасного SELECT в окружении, где нагрузка и данные контролируемы. Сравнивайте план и фактические строки, а не только число cost: оценка плана — условная величина, зависящая от статистики и среды.

\n
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)\nSELECT id, title\nFROM catalog_items\nWHERE id = 42;
\n

Этот запрос не доказывает, что индекс нужен. Он отвечает на более узкий вопрос: какой план и фактические чтения получены для конкретного SQL и конкретных данных. Если запрос изменяет данные, не запускайте EXPLAIN ANALYZE без отдельной безопасной процедуры: анализ выполняет оператор. Полученный план нужно связать с тем же request/trace, иначе это лишь похожий локальный тест.

\n

Не выбирать действие по самому длинному span

\n
Как перейти от симптома к следующей проверке
НаблюдениеЧто оно подтверждаетЧего оно не подтверждаетСледующий шаг
CPU низкий, root длинныйВ путь входит ожидание или работа вне CPUКонкретную очередь, БД или сетьРазделить root на named spans и проверить разрывы
Есть длинный database-callДолгий интервал вызова БД в этой traceНеэффективный SQL или необходимость индексаСнять SQL и сопоставимый EXPLAIN
Браузер дольше сервисаМежду границами есть неразобранный интервалЧто именно делал прокси или клиентПроверить redirect, resource timing, retry и gateway
Второй прогон корочеВторая запись имеет меньшую длительностьЭффект изменения кодаСверить cohort, concurrency, кэш, build и метрику
Нет parent или единицы времениНаблюдаемость неполнаПоложение и причина сегментаВернуть именованный stop и восстановить сигнал
\n

Корректная цепочка выглядит так: наблюдение — «queue-wait=520 в root-01»; гипотеза — «часть времени создаёт ограничение admission»; проверка — «снять отдельный metric ожидания на той же нагрузке»; действие — «изменить один фактор и сравнить заранее выбранную метрику». Если проверка не может опровергнуть гипотезу, это не проверка, а подтверждение удобной истории.

\n

Провести один малый эксперимент

\n
  1. Зафиксируйте baseline: маршрут, статус, метрику, cohort, число запросов, concurrency, кэш, build и sampling.
  2. Сохраните исходные браузерные timings, root span, дочерние span и ошибки под одним идентификатором.
  3. Проверьте связность дерева и единицы времени. Все пропуски назовите явно, не заполняйте их догадкой.
  4. Сформулируйте одну гипотезу и условие, при котором вы её отклоните.
  5. Выберите одно изменение: например, добавить измерение выдачи соединения, а не одновременно увеличить пул и переписать SQL.
  6. Повторите тот же сценарий с той же контрольной границей. Укажите, что изменилось и что осталось прежним.
  7. Сравните медиану и хвост распределения, если наблюдений достаточно; отдельно проверьте ошибки, timeout, throughput и потребление ресурса.
  8. Запишите результат как observation, improvement или stop. Improvement допустим только при сопоставимом baseline и проверенных побочных сигналах.
\n

Если отдельный сигнал подтвердил ожидание в пуле, следующим действием может быть проверка времени выдачи соединения и конкуренции. Увеличение пула без такого сигнала способно перенести очередь в базу. Если подтверждена локальная работа CPU, нужен профайлер или измерение конкретной функции. Если виден только unknown, сначала улучшите наблюдаемость. Выбор действия должен следовать границе доказательства.

\n

Когда остановиться и назвать ограничение

\n

Верните stop-incomplete-trace, если дочерний span ссылается на отсутствующего родителя. Верните stop-unknown-delay, если большой интервал не имеет подтверждённой роли. Верните stop-incomparable-load, если изменились cohort, concurrency, кэш или форма ответа. Эти статусы не означают, что система исправна или неисправна. Они фиксируют, почему причинный вывод пока запрещён и какой сигнал нужно добыть.

\n

Sampling может исключить нужную запись. Collector может потерять событие. Асинхронная задача может продолжить работу после завершения root и потребовать span link, а не вложенного дочернего span. Retry создаёт несколько похожих операций. Часы разных узлов могут расходиться. Контекст через traceparent помогает связать границы, но посредник может не передать заголовок, а заголовок не гарантирует полноту сбора.

\n

Одна trace не показывает p95, p99, throughput, распределение ошибок или поведение при насыщении. Одна browser timing-запись не описывает все устройства и сети. Один EXPLAIN не заменяет серию запросов на данных, похожих на production. Поэтому условные 520 и 1 000 units нельзя превращать в миллисекунды, SLA или обещание ускорения.

\n

Критерий готового разбора

\n

Разбор можно передавать следующему инженеру, если он без устных пояснений находит исходный симптом, видит контрольную границу, связывает request с trace, проверяет parent/child и отличает названный интервал от неизвестного. Он должен суметь запустить локальный отрицательный пример, понять, какую гипотезу проверяет следующий шаг, и увидеть, почему другая нагрузка отменяет сравнение.

\n

Финальная формулировка должна возвращать границу: «При fixed-catalog-a, 12 запросах и concurrency 3 в root-01 самый длинный названный интервал — queue-wait, 520 условных единиц. Причина задержки и эффект изменения не доказаны; следующая проверка — отдельный сигнал ожидания». Это полезнее, чем уверенное «сервис тормозит из-за очереди».

\n

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

\n" }