{ "index": 60, "slug": "editorial-2026-05-practice-systems-performance", "title": "Критический путь запроса: как найти задержку и не перепутать её с причиной", "excerpt": "Запрос медленный, хотя CPU свободен. Разбираем end-to-end путь, отделяем очередь от работы и задаём проверку, после которой оптимизацию можно обсуждать без догадок.", "contentHtml": "

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

\n

Низкая загрузка CPU не опровергает медленный запрос. End-to-end время включает ожидание, работу и вызовы зависимостей. Чтобы выбрать действие, нужно разложить один путь на связанные интервалы и удержать одну границу сравнения. Учебные значения ниже не являются измерениями production-системы. Они показывают способ рассуждать.

\n

Механизм: время ответа состоит не только из работы

\n

Root span задаёт границу от приёма запроса до ответа. Дочерний span показывает названную операцию внутри этой границы. Запрос может ждать admission queue, свободное соединение, блокировку, диск, DNS, TLS или ответ удалённого сервиса. Пока он ждёт, CPU может почти не работать.

\n

Название интервала ограничивает вывод. queue-wait означает отдельно записанное ожидание в очереди. database-execution означает интервал вызова базы в этой trace. external-dependency означает границу внешнего вызова. Ни одно из этих названий само по себе не доказывает причину задержки. Если ожидание не размечено, его нужно оставить неизвестным.

\n

Критический путь — это не рейтинг сервисов и не сумма всех span. Это временная цепь внутри одного root span. Связность важнее красивого графика: у каждого дочернего span должен существовать parent, начало не должно быть позже конца, а единицы времени должны совпадать. Если два вызова идут параллельно, их длительности нельзя складывать как последовательные.

\n

Учебный пример: один root, три названных интервала

\n

Рассмотрим условную trace fixed-trace-01. Root длится 1 000 units. Очередь занимает 520, вызов БД — 150, внешний каталог — 200. Промежутки между интервалами не получили отдельного объяснения. Поэтому их нельзя автоматически назвать сетью или дополнительной работой.

\n
Состав одного учебного end-to-end пути
СегментРольИнтервалДлительностьЧто можно сказать
fixed-admission-queuequeue-wait40–560520Самый длинный названный сегмент этой записи
fixed-db-calldatabase-execution570–720150Интервал вызова БД в этой trace
fixed-catalog-callexternal-dependency730–930200Интервал внешнего вызова в этой trace
fixed-gatewayend-to-end0–1 0001 000Граница пути, а не объяснение причины
\n
\"Учебный
Учебная схема показывает порядок проверки: сначала root и названные интервалы, затем границы вывода. Она не изображает production latency.
\n

В этой записи fixed-admission-queue длиннее двух других названных сегментов. Это единственный прямой вывод о порядке длительностей. Нельзя из него заключить, что очередь является bottleneck при другой нагрузке, что изменение gateway ускорит пользователя или что БД не требует исследования. Для любого такого утверждения нужна отдельная проверка.

\n

Пример проверки структуры

\n

Код ниже работает с заранее заданным объектом. Он не обращается к сети, базе, часам, профайлеру или телеметрии. Числа условны. Пример проверяет связность и интервалы, а не показывает результат реального сервиса.

\n
const trace = {\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-execution', start: 570, end: 720 },\n    { id: 'catalog-01', parent: 'root-01', role: 'external-dependency', start: 730, end: 930 }\n  ],\n  load: { cohort: 'fixed-load-a', requests: 12, concurrency: 3, shape: 'fixed-read-shape-a' }\n};\n\nfunction review(input) {\n  const ids = new Set(input.spans.map((span) => span.id));\n  const connected = input.spans.every((span) =>\n    span.parent === input.root.id || ids.has(span.parent)\n  );\n  const timed = input.spans.every((span) =>\n    Number.isFinite(span.start) && 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', effect: 'not-claimed' };\n}\n\nconsole.log(review(trace));
\n

observation-ready здесь означает только, что запись связна и содержит корректные условные интервалы. Если parent равен missing-01, результат должен быть stop-incomplete-trace. Если начало больше конца, функция должна остановиться. Отрицательный путь не является исключением из метода. Он показывает, что неполный материал нельзя превращать в уверенный диагноз.

\n

Симптом → причина → проверка → действие

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
CPU низкий, запрос медленныйВ end-to-end время вошло ожиданиеРазделить queue-wait, локальную работу и дочерние вызовыНазвать только покрытые span; остаток оставить unknown
Длинный span совпал с пиком latencySpan включает ожидание upstream или retryПроверить parent/child, повторные вызовы и дочерние интервалыНе объявлять span причиной без отдельного сигнала
Второй прогон короче первогоИзменилась нагрузка или форма входаСверить cohort, requests, concurrency и shapeСнять сравнение и повторить на общей границе
Есть root, но нет parent у дочернего spanПотеря записи или неверная связь IDПроверить полный экспорт и уникальность идентификаторовВернуть stop; не дорисовывать дерево по времени
После изменения есть одна короткая записьНет baseline и распределения наблюденийПовторить тот же сценарий и сохранить контрольНазвать observation, а не improvement
\n

Как не спутать наблюдение с причинностью

\n

Длинный интервал сообщает, что в конкретной записи он длинный. Он не сообщает, почему это произошло и какое изменение его сократит. Очередь может зависеть от admission policy или ограниченного ресурса. Вызов БД может ждать соединение до начала исполнения. Внешний вызов может включать локальную подготовку. Одна trace не выбирает между этими объяснениями.

\n

Полезно разделять три фразы. Наблюдение: «queue-wait занимает 520 units в fixed-trace-01». Гипотеза: «правило допуска создаёт часть ожидания». Проверка: «сравнить заранее определённые записи с теми же cohort, requests, concurrency и shape». Перескакивать от первой фразы к третьей нельзя. Тем более нельзя сразу объявлять эффект изменения.

\n

Та же граница действует для базы. database-execution = 150 — не диагноз SQL, не рекомендация индекса и не оценка бюджета. Если команда хочет исследовать запрос, она формулирует новый вопрос и сохраняет текущую запись как baseline только после проверки сопоставимости. Уменьшение знакомого локального шага не становится правильным действием из-за того, что его проще измерить.

\n

Что значит сопоставимая нагрузка

\n

Baseline и candidate можно сравнивать только внутри явно названной контрольной границы. В учебном примере это fixed-load-a, 12 логических запросов, concurrency 3 и fixed-read-shape-a. Если второй прогон использует 24 запроса, concurrency 6 или другую форму входа, он отвечает на другой вопрос. Более короткий root не доказывает ускорение.

\n

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

\n

Отдельно проверяйте параллельность. Дочерние span могут пересекаться. В таком случае их сумма превысит время root и не покажет стоимость пути. Сначала определите временную зависимость. Если это невозможно, оставьте вывод на уровне «интервалы пересекаются» и не выбирайте самый большой span как причину.

\n

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

\n
  1. Зафиксируйте исходный симптом: маршрут, метод, статус, длительность, размер ответа, время и идентификатор запроса.
  2. Сохраните один trace до изменения кода или конфигурации. Отметьте root, дочерние операции, пропуски и неизвестные интервалы.
  3. Проверьте parent/child-связи, границы интервалов и единицы времени. Отдельно отметьте overlap.
  4. Выпишите контрольную границу: cohort, число логических запросов, concurrency и форму входных данных.
  5. Отделите ожидание от исполнения БД и внешнего вызова. Не называйте неразмеченный остаток причиной.
  6. Сформулируйте гипотезу и проверку, которая может её опровергнуть. Один длинный span не заменяет такую проверку.
  7. Прогоните отрицательный случай: отсутствующий parent, некорректный интервал, unknown-delay или другая нагрузка должны вернуть точный stop.
  8. Измените один фактор только после фиксации baseline. Повторите тот же сценарий на той же контрольной границе.
  9. Сравните исходный симптом с candidate и проверьте соседние сигналы: ошибки, таймауты, очередь, throughput и потребление ресурсов.
\n

Что делать с отрицательным путём

\n

Если trace неполная, остановитесь на stop-incomplete-trace. Если ожидание помечено только как unknown-delay, не называйте его очередью. Если нагрузка отличается, верните stop-incomparable-load. Если в записи уже есть утверждение «стало быстрее», но нет сопоставимого контроля, снимите claim и сохраните только наблюдение.

\n

Такая остановка экономит время. Неполный trace легко вставить в убедительный рассказ и трудно разобрать после нескольких изменений. Именованная причина stop сохраняет недостающий факт: нужно восстановить parent, назвать ожидание или выровнять нагрузку. Отказ от вывода точнее, чем правдоподобное объяснение пустого места.

\n

Ограничения

\n

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

\n

Waterfall не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Он не задаёт SLA и не заменяет нагрузочный тест. RFC 9110 описывает семантику HTTP, а не бюджет latency приложения. Для эксплуатационного решения нужны отдельные измерения, контрольные группы и критерии остановки.

\n

Учебный код нельзя подключать к реальной телеметрии без новой проверки. Он использует одну запись, фиксированные числа и заранее известные поля. Он не проверяет экспорт, прокси, клиентские повторы или права доступа. Production-результат появляется только после отдельного эксперимента с описанной средой.

\n

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

\n

Разбор готов, если другой инженер без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу, отличить названную задержку от unknown и воспроизвести stop на неполной trace или несопоставимой нагрузке. Это критерий качества evidence, а не обещание ускорения.

\n

Изменение можно оценивать отдельно, когда baseline и candidate сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а результат не маскирует ошибку ростом таймаута или потерей сигнала. До этого корректный итог звучит так: «В fixed-trace-01 при fixed-load-a queue-wait — самый длинный названный сегмент. Эффект изменения не заявлен». Другой инженер должен получить тот же вывод из той же записи.

\n

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

\n" }