{ "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До изменения кода запишите ровно тот запрос, который хотите ускорить: метод и маршрут, статус, размер ответа, время начала, длительность, идентификатор запроса, версию приложения и состояние кэша. Для повторного прогона добавьте cohort — фиксированный набор входных данных, число запросов, concurrency и форму ответа. Эти поля нужны не для отчётности: без них два коротких ответа могут быть результатом разных условий.
\nСформулируйте исходный факт без диагноза: «GET /catalog/42 вернул 200 за 10 000 мс, CPU процесса — около 20%». Фраза «медленная база» уже является гипотезой. Её можно проверять, но нельзя прятать в поле симптома. Если trace отобрана sampling-правилом или часть заголовков удаляет прокси, запишите это рядом с измерением.
| Поле | Пример | Зачем фиксировать | Что ломает сравнение |
|---|---|---|---|
| Маршрут и метод | GET /catalog/42 | Определяют операцию и набор middleware | Сравнение с другим endpoint |
| Вход и форма ответа | item=42, JSON v2 | Влияют на размер данных и план запроса | Другой id или набор полей |
| Нагрузка | 12 запросов, concurrency 3 | Определяет очередь и конкуренцию за ресурсы | Другая параллельность или cohort |
| Кэш и версия | miss, build abc123 | Разделяют cold и warm путь | Попадание в кэш или иной код |
| Идентификаторы | request_id и traceparent | Связывают клиент, сервис и зависимости | Поиск только по timestamp |
У end-to-end измерения есть граница от начала навигации или HTTP-запроса до события, которое вы считаете результатом. Внутри неё могут быть последовательные и параллельные участки. Браузер измеряет навигацию и ресурсы, сервис создаёт root span, а дочерние span описывают отдельные операции. Запрос к базе или партнёру может быть дочерним client span, но его имя не доказывает, что именно он создал задержку.
\nСначала ищите разрыв между границами. Если браузерная запись показывает десять секунд, а root span сервиса — две, оставшиеся восемь секунд не следует называть «сетевыми» без отдельного сигнала. Это может быть очередь перед сервисом, прокси, повтор на клиенте или ожидание до отправки. Если root длится десять секунд, а названные дочерние операции занимают только две, восемь секунд остаются неизвестными.
\nНапример, учебный root длится 1 000 условных единиц. В нём есть очередь 520, запрос к базе 150 и внешний вызов 200. Названные интервалы не покрывают весь root: остаётся 130 единиц промежутков и неразмеченного времени. Можно сказать, что очередь — самый длинный названный участок этой записи. Нельзя сказать, что она является production bottleneck или что изменение очереди сократит ответ.
\nНебольшой скрипт ниже проверяет только то, что можно проверить по переданному объекту: parent/child-связи, интервалы и контрольную границу. Он не читает телеметрию и не симулирует latency. Сохраните его как временный фрагмент в консоли браузера или запустите через node, предварительно заменив HTML-сущности обратно на символы в обычном JS-файле.
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 означает только «структуру можно читать», а не «причина найдена».
В браузере начните с Navigation Timing, а не с устного впечатления «страница открывается долго». API возвращает измерения текущей навигации. Для документа важны границы, которые соответствуют вашему критерию: например, время до первого байта, DOM construction или load event. Название метрики должно быть частью результата, иначе команда сравнит разные события под одним словом «загрузка».
\nconst 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На стороне сервиса найдите root по traceparent или request id, затем проверьте parent/child-связи, начало и конец каждого span, статус, retry и ошибки. OpenTelemetry использует span как единицу работы и допускает root span с подоперациями; контекст trace связывается между границами через стандартизированные HTTP-заголовки. Ни один из этих механизмов не создаёт пропущенный span и не превращает корреляцию в доказательство причины.
Если подозрение остаётся на PostgreSQL, измерьте SQL отдельно на сопоставимом наборе данных. EXPLAIN показывает план, который выбрал планировщик. EXPLAIN ANALYZE дополнительно выполняет запрос, поэтому используйте его для безопасного SELECT в окружении, где нагрузка и данные контролируемы. Сравнивайте план и фактические строки, а не только число cost: оценка плана — условная величина, зависящая от статистики и среды.
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)\nSELECT id, title\nFROM catalog_items\nWHERE id = 42;\nЭтот запрос не доказывает, что индекс нужен. Он отвечает на более узкий вопрос: какой план и фактические чтения получены для конкретного SQL и конкретных данных. Если запрос изменяет данные, не запускайте EXPLAIN ANALYZE без отдельной безопасной процедуры: анализ выполняет оператор. Полученный план нужно связать с тем же request/trace, иначе это лишь похожий локальный тест.
| Наблюдение | Что оно подтверждает | Чего оно не подтверждает | Следующий шаг |
|---|---|---|---|
| CPU низкий, root длинный | В путь входит ожидание или работа вне CPU | Конкретную очередь, БД или сеть | Разделить root на named spans и проверить разрывы |
Есть длинный database-call | Долгий интервал вызова БД в этой trace | Неэффективный SQL или необходимость индекса | Снять SQL и сопоставимый EXPLAIN |
| Браузер дольше сервиса | Между границами есть неразобранный интервал | Что именно делал прокси или клиент | Проверить redirect, resource timing, retry и gateway |
| Второй прогон короче | Вторая запись имеет меньшую длительность | Эффект изменения кода | Сверить cohort, concurrency, кэш, build и метрику |
| Нет parent или единицы времени | Наблюдаемость неполна | Положение и причина сегмента | Вернуть именованный stop и восстановить сигнал |
Корректная цепочка выглядит так: наблюдение — «queue-wait=520 в root-01»; гипотеза — «часть времени создаёт ограничение admission»; проверка — «снять отдельный metric ожидания на той же нагрузке»; действие — «изменить один фактор и сравнить заранее выбранную метрику». Если проверка не может опровергнуть гипотезу, это не проверка, а подтверждение удобной истории.
Если отдельный сигнал подтвердил ожидание в пуле, следующим действием может быть проверка времени выдачи соединения и конкуренции. Увеличение пула без такого сигнала способно перенести очередь в базу. Если подтверждена локальная работа CPU, нужен профайлер или измерение конкретной функции. Если виден только unknown, сначала улучшите наблюдаемость. Выбор действия должен следовать границе доказательства.
\nВерните stop-incomplete-trace, если дочерний span ссылается на отсутствующего родителя. Верните stop-unknown-delay, если большой интервал не имеет подтверждённой роли. Верните stop-incomparable-load, если изменились cohort, concurrency, кэш или форма ответа. Эти статусы не означают, что система исправна или неисправна. Они фиксируют, почему причинный вывод пока запрещён и какой сигнал нужно добыть.
Sampling может исключить нужную запись. Collector может потерять событие. Асинхронная задача может продолжить работу после завершения root и потребовать span link, а не вложенного дочернего span. Retry создаёт несколько похожих операций. Часы разных узлов могут расходиться. Контекст через traceparent помогает связать границы, но посредник может не передать заголовок, а заголовок не гарантирует полноту сбора.
Одна trace не показывает p95, p99, throughput, распределение ошибок или поведение при насыщении. Одна browser timing-запись не описывает все устройства и сети. Один EXPLAIN не заменяет серию запросов на данных, похожих на production. Поэтому условные 520 и 1 000 units нельзя превращать в миллисекунды, SLA или обещание ускорения.
Разбор можно передавать следующему инженеру, если он без устных пояснений находит исходный симптом, видит контрольную границу, связывает request с trace, проверяет parent/child и отличает названный интервал от неизвестного. Он должен суметь запустить локальный отрицательный пример, понять, какую гипотезу проверяет следующий шаг, и увидеть, почему другая нагрузка отменяет сравнение.
\nФинальная формулировка должна возвращать границу: «При fixed-catalog-a, 12 запросах и concurrency 3 в root-01 самый длинный названный интервал — queue-wait, 520 условных единиц. Причина задержки и эффект изменения не доказаны; следующая проверка — отдельный сигнал ожидания». Это полезнее, чем уверенное «сервис тормозит из-за очереди».
traceparent и tracestate между HTTP-границами; наличие заголовка не гарантирует полноту записи.PerformanceNavigationTiming и его временных полей; это браузерный слой, а не серверный профайлер.EXPLAIN ANALYZE и ограничений оценочных cost; результат относится к конкретному SQL, данным и среде.