Files

8 lines
24 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 60,
"slug": "editorial-2026-05-practice-systems-performance",
"title": "Медленный запрос при низком CPU: практический маршрут от браузера до базы",
"excerpt": "Пошаговый способ разобрать долгий end-to-end запрос: зафиксировать одну границу, связать браузерный timing с trace, проверить базу отдельно и остановиться там, где данных недостаточно.",
"contentHtml": "<p>Пользователь ждёт страницу десять секунд, а CPU сервиса держится на двадцати процентах. В такой ситуации легко переписать SQL, увеличить пул потоков или поднять timeout. Эти изменения могут убрать симптом на одном прогоне и оставить причину нетронутой. Сначала нужно определить, где именно прошло время: в браузере, на прокси, в очереди, в коде сервиса или в зависимости.</p>\n<p>Ниже — практический маршрут для одного воспроизводимого HTTP-сценария. Он не обещает найти bottleneck по одной картинке. Он помогает собрать минимальный набор сигналов, связать его через идентификатор запроса и выбрать следующий эксперимент. Все числовые значения в примере учебные, если прямо не указано обратное.</p>\n<h2>Начать с одной контрольной границы</h2>\n<p>До изменения кода запишите ровно тот запрос, который хотите ускорить: метод и маршрут, статус, размер ответа, время начала, длительность, идентификатор запроса, версию приложения и состояние кэша. Для повторного прогона добавьте cohort — фиксированный набор входных данных, число запросов, concurrency и форму ответа. Эти поля нужны не для отчётности: без них два коротких ответа могут быть результатом разных условий.</p>\n<p>Сформулируйте исходный факт без диагноза: «<code>GET /catalog/42</code> вернул <code>200</code> за 10 000&nbsp;мс, CPU процесса — около 20%». Фраза «медленная база» уже является гипотезой. Её можно проверять, но нельзя прятать в поле симптома. Если trace отобрана sampling-правилом или часть заголовков удаляет прокси, запишите это рядом с измерением.</p>\n<div class='table-scroll'><table><caption>Минимальная контрольная граница для сравнения</caption><thead><tr><th scope='col'>Поле</th><th scope='col'>Пример</th><th scope='col'>Зачем фиксировать</th><th scope='col'>Что ломает сравнение</th></tr></thead><tbody><tr><td>Маршрут и метод</td><td><code>GET /catalog/42</code></td><td>Определяют операцию и набор middleware</td><td>Сравнение с другим endpoint</td></tr><tr><td>Вход и форма ответа</td><td><code>item=42</code>, JSON v2</td><td>Влияют на размер данных и план запроса</td><td>Другой id или набор полей</td></tr><tr><td>Нагрузка</td><td><code>12</code> запросов, concurrency <code>3</code></td><td>Определяет очередь и конкуренцию за ресурсы</td><td>Другая параллельность или cohort</td></tr><tr><td>Кэш и версия</td><td>miss, build <code>abc123</code></td><td>Разделяют cold и warm путь</td><td>Попадание в кэш или иной код</td></tr><tr><td>Идентификаторы</td><td><code>request_id</code> и <code>traceparent</code></td><td>Связывают клиент, сервис и зависимости</td><td>Поиск только по timestamp</td></tr></tbody></table></div>\n<h2>Разложить end-to-end путь по владельцам времени</h2>\n<p>У end-to-end измерения есть граница от начала навигации или HTTP-запроса до события, которое вы считаете результатом. Внутри неё могут быть последовательные и параллельные участки. Браузер измеряет навигацию и ресурсы, сервис создаёт root span, а дочерние span описывают отдельные операции. Запрос к базе или партнёру может быть дочерним client span, но его имя не доказывает, что именно он создал задержку.</p>\n<p>Сначала ищите разрыв между границами. Если браузерная запись показывает десять секунд, а root span сервиса — две, оставшиеся восемь секунд не следует называть «сетевыми» без отдельного сигнала. Это может быть очередь перед сервисом, прокси, повтор на клиенте или ожидание до отправки. Если root длится десять секунд, а названные дочерние операции занимают только две, восемь секунд остаются неизвестными.</p>\n<figure><img src='/assets/editorial/2026/systems-performance-2026-bottleneck-evidence-loop.svg' alt='Схема диагностики задержки: полный trace с названными сегментами сравнивается с одинаковой контрольной нагрузкой, проходит контрпример и приводит к следующей проверке либо именованной остановке' loading='lazy' /><figcaption>Наблюдение становится действием только после проверки полноты trace и сопоставимости нагрузки. Контрпример с пропущенным сегментом или другой нагрузкой должен остановить причинный вывод.</figcaption></figure>\n<p>Например, учебный root длится 1 000 условных единиц. В нём есть очередь 520, запрос к базе 150 и внешний вызов 200. Названные интервалы не покрывают весь root: остаётся 130 единиц промежутков и неразмеченного времени. Можно сказать, что очередь — самый длинный названный участок этой записи. Нельзя сказать, что она является production bottleneck или что изменение очереди сократит ответ.</p>\n<h2>Воспроизвести проверку на локальном снимке</h2>\n<p>Небольшой скрипт ниже проверяет только то, что можно проверить по переданному объекту: parent/child-связи, интервалы и контрольную границу. Он не читает телеметрию и не симулирует latency. Сохраните его как временный фрагмент в консоли браузера или запустите через <code>node</code>, предварительно заменив HTML-сущности обратно на символы в обычном JS-файле.</p>\n<pre><code>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) =&gt; span.id));\n const connected = trace.spans.every((span) =&gt;\n span.parent === trace.root.id || ids.has(span.parent)\n );\n const timed = [trace.root, ...trace.spans].every((span) =&gt;\n Number.isFinite(span.start) &amp;&amp;\n Number.isFinite(span.end) &amp;&amp;\n span.end &gt;= 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 }</code></pre>\n<p>Тест с отсутствующим родителем должен вернуть <code>stop-incomplete-trace</code>, а с <code>start &gt; end</code> — <code>stop-invalid-interval</code>. Это полезный отрицательный путь: он запрещает восстановить дерево по удобному совпадению времени. Статус <code>observation-ready</code> означает только «структуру можно читать», а не «причина найдена».</p>\n<h2>Проверить браузерный участок отдельно</h2>\n<p>В браузере начните с Navigation Timing, а не с устного впечатления «страница открывается долго». API возвращает измерения текущей навигации. Для документа важны границы, которые соответствуют вашему критерию: например, время до первого байта, DOM construction или load event. Название метрики должно быть частью результата, иначе команда сравнит разные события под одним словом «загрузка».</p>\n<pre><code>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}</code></pre>\n<p>Эти вычисления показывают интервалы браузерной навигации в миллисекундах. Они не раскрывают внутреннюю очередь сервера и не заменяют trace. В SPA, при переходе без полной навигации, нужный пользовательский сценарий может быть resource timing или собственным User Timing-маркером. Не переносите приведённые поля на любой сценарий без проверки типа навигации и момента, когда запись появилась.</p>\n<h2>Проверить сервис и базу по отдельным сигналам</h2>\n<p>На стороне сервиса найдите root по <code>traceparent</code> или request id, затем проверьте parent/child-связи, начало и конец каждого span, статус, retry и ошибки. OpenTelemetry использует span как единицу работы и допускает root span с подоперациями; контекст trace связывается между границами через стандартизированные HTTP-заголовки. Ни один из этих механизмов не создаёт пропущенный span и не превращает корреляцию в доказательство причины.</p>\n<p>Если подозрение остаётся на PostgreSQL, измерьте SQL отдельно на сопоставимом наборе данных. <code>EXPLAIN</code> показывает план, который выбрал планировщик. <code>EXPLAIN ANALYZE</code> дополнительно выполняет запрос, поэтому используйте его для безопасного <code>SELECT</code> в окружении, где нагрузка и данные контролируемы. Сравнивайте план и фактические строки, а не только число cost: оценка плана — условная величина, зависящая от статистики и среды.</p>\n<pre><code>EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)\nSELECT id, title\nFROM catalog_items\nWHERE id = 42;</code></pre>\n<p>Этот запрос не доказывает, что индекс нужен. Он отвечает на более узкий вопрос: какой план и фактические чтения получены для конкретного SQL и конкретных данных. Если запрос изменяет данные, не запускайте <code>EXPLAIN ANALYZE</code> без отдельной безопасной процедуры: анализ выполняет оператор. Полученный план нужно связать с тем же request/trace, иначе это лишь похожий локальный тест.</p>\n<h2>Не выбирать действие по самому длинному span</h2>\n<div class='table-scroll'><table><caption>Как перейти от симптома к следующей проверке</caption><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Что оно подтверждает</th><th scope='col'>Чего оно не подтверждает</th><th scope='col'>Следующий шаг</th></tr></thead><tbody><tr><td>CPU низкий, root длинный</td><td>В путь входит ожидание или работа вне CPU</td><td>Конкретную очередь, БД или сеть</td><td>Разделить root на named spans и проверить разрывы</td></tr><tr><td>Есть длинный <code>database-call</code></td><td>Долгий интервал вызова БД в этой trace</td><td>Неэффективный SQL или необходимость индекса</td><td>Снять SQL и сопоставимый <code>EXPLAIN</code></td></tr><tr><td>Браузер дольше сервиса</td><td>Между границами есть неразобранный интервал</td><td>Что именно делал прокси или клиент</td><td>Проверить redirect, resource timing, retry и gateway</td></tr><tr><td>Второй прогон короче</td><td>Вторая запись имеет меньшую длительность</td><td>Эффект изменения кода</td><td>Сверить cohort, concurrency, кэш, build и метрику</td></tr><tr><td>Нет parent или единицы времени</td><td>Наблюдаемость неполна</td><td>Положение и причина сегмента</td><td>Вернуть именованный stop и восстановить сигнал</td></tr></tbody></table></div>\n<p>Корректная цепочка выглядит так: наблюдение — «<code>queue-wait=520</code> в <code>root-01</code>»; гипотеза — «часть времени создаёт ограничение admission»; проверка — «снять отдельный metric ожидания на той же нагрузке»; действие — «изменить один фактор и сравнить заранее выбранную метрику». Если проверка не может опровергнуть гипотезу, это не проверка, а подтверждение удобной истории.</p>\n<h2>Провести один малый эксперимент</h2>\n<ol><li>Зафиксируйте baseline: маршрут, статус, метрику, cohort, число запросов, concurrency, кэш, build и sampling.</li><li>Сохраните исходные браузерные timings, root span, дочерние span и ошибки под одним идентификатором.</li><li>Проверьте связность дерева и единицы времени. Все пропуски назовите явно, не заполняйте их догадкой.</li><li>Сформулируйте одну гипотезу и условие, при котором вы её отклоните.</li><li>Выберите одно изменение: например, добавить измерение выдачи соединения, а не одновременно увеличить пул и переписать SQL.</li><li>Повторите тот же сценарий с той же контрольной границей. Укажите, что изменилось и что осталось прежним.</li><li>Сравните медиану и хвост распределения, если наблюдений достаточно; отдельно проверьте ошибки, timeout, throughput и потребление ресурса.</li><li>Запишите результат как observation, improvement или stop. Improvement допустим только при сопоставимом baseline и проверенных побочных сигналах.</li></ol>\n<p>Если отдельный сигнал подтвердил ожидание в пуле, следующим действием может быть проверка времени выдачи соединения и конкуренции. Увеличение пула без такого сигнала способно перенести очередь в базу. Если подтверждена локальная работа CPU, нужен профайлер или измерение конкретной функции. Если виден только unknown, сначала улучшите наблюдаемость. Выбор действия должен следовать границе доказательства.</p>\n<h2>Когда остановиться и назвать ограничение</h2>\n<p>Верните <code>stop-incomplete-trace</code>, если дочерний span ссылается на отсутствующего родителя. Верните <code>stop-unknown-delay</code>, если большой интервал не имеет подтверждённой роли. Верните <code>stop-incomparable-load</code>, если изменились cohort, concurrency, кэш или форма ответа. Эти статусы не означают, что система исправна или неисправна. Они фиксируют, почему причинный вывод пока запрещён и какой сигнал нужно добыть.</p>\n<p>Sampling может исключить нужную запись. Collector может потерять событие. Асинхронная задача может продолжить работу после завершения root и потребовать span link, а не вложенного дочернего span. Retry создаёт несколько похожих операций. Часы разных узлов могут расходиться. Контекст через <code>traceparent</code> помогает связать границы, но посредник может не передать заголовок, а заголовок не гарантирует полноту сбора.</p>\n<p>Одна trace не показывает p95, p99, throughput, распределение ошибок или поведение при насыщении. Одна browser timing-запись не описывает все устройства и сети. Один <code>EXPLAIN</code> не заменяет серию запросов на данных, похожих на production. Поэтому условные 520 и 1 000 units нельзя превращать в миллисекунды, SLA или обещание ускорения.</p>\n<h2>Критерий готового разбора</h2>\n<p>Разбор можно передавать следующему инженеру, если он без устных пояснений находит исходный симптом, видит контрольную границу, связывает request с trace, проверяет parent/child и отличает названный интервал от неизвестного. Он должен суметь запустить локальный отрицательный пример, понять, какую гипотезу проверяет следующий шаг, и увидеть, почему другая нагрузка отменяет сравнение.</p>\n<p>Финальная формулировка должна возвращать границу: «При <code>fixed-catalog-a</code>, 12 запросах и concurrency 3 в <code>root-01</code> самый длинный названный интервал — <code>queue-wait</code>, 520 условных единиц. Причина задержки и эффект изменения не доказаны; следующая проверка — отдельный сигнал ожидания». Это полезнее, чем уверенное «сервис тормозит из-за очереди».</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://opentelemetry.io/docs/concepts/signals/traces/' target='_blank' rel='noopener noreferrer'>OpenTelemetry: Traces</a> — официальная модель trace, root span, дочерних span, контекста и span kind; документация описывает телеметрию, но не доказывает причинность конкретной задержки.</li><li><a href='https://www.w3.org/TR/trace-context/' target='_blank' rel='noopener noreferrer'>W3C Trace Context</a> — рекомендация W3C для передачи <code>traceparent</code> и <code>tracestate</code> между HTTP-границами; наличие заголовка не гарантирует полноту записи.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Navigation_timing' target='_blank' rel='noopener noreferrer'>MDN: Navigation timing</a> — официальное описание <code>PerformanceNavigationTiming</code> и его временных полей; это браузерный слой, а не серверный профайлер.</li><li><a href='https://www.postgresql.org/docs/current/using-explain.html' target='_blank' rel='noopener noreferrer'>PostgreSQL: Using EXPLAIN</a> — официальное описание планов, <code>EXPLAIN ANALYZE</code> и ограничений оценочных cost; результат относится к конкретному SQL, данным и среде.</li></ul>"
}