8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"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 мс, 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) => 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 }</code></pre>\n<p>Тест с отсутствующим родителем должен вернуть <code>stop-incomplete-trace</code>, а с <code>start > 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>"
|
||
}
|