{ "index": 60, "slug": "editorial-2026-05-practice-systems-performance", "title": "Критический путь запроса: как найти задержку и не перепутать её с причиной", "excerpt": "Запрос медленный, хотя CPU свободен. Разбираем end-to-end путь, отделяем очередь от работы и задаём проверку, после которой оптимизацию можно обсуждать без догадок.", "contentHtml": "
Пользователь ждёт ответ десять секунд, а CPU сервиса держится на двадцати процентах. Команда меняет SQL, увеличивает пул потоков или поднимает таймаут. Симптом иногда маскируется, но путь не становится быстрее. Цена ошибки — релиз без эффекта, дополнительная нагрузка и потеря исходного сигнала. Следующий инженер уже не видит, что именно сравнивали.
\nНизкая загрузка CPU не опровергает медленный запрос. End-to-end время включает ожидание, работу и вызовы зависимостей. Чтобы выбрать действие, нужно разложить один путь на связанные интервалы и удержать одну границу сравнения. Учебные значения ниже не являются измерениями production-системы. Они показывают способ рассуждать.
\nRoot span задаёт границу от приёма запроса до ответа. Дочерний span показывает названную операцию внутри этой границы. Запрос может ждать admission queue, свободное соединение, блокировку, диск, DNS, TLS или ответ удалённого сервиса. Пока он ждёт, CPU может почти не работать.
\nНазвание интервала ограничивает вывод. queue-wait означает отдельно записанное ожидание в очереди. database-execution означает интервал вызова базы в этой trace. external-dependency означает границу внешнего вызова. Ни одно из этих названий само по себе не доказывает причину задержки. Если ожидание не размечено, его нужно оставить неизвестным.
Критический путь — это не рейтинг сервисов и не сумма всех span. Это временная цепь внутри одного root span. Связность важнее красивого графика: у каждого дочернего span должен существовать parent, начало не должно быть позже конца, а единицы времени должны совпадать. Если два вызова идут параллельно, их длительности нельзя складывать как последовательные.
\nРассмотрим условную trace fixed-trace-01. Root длится 1 000 units. Очередь занимает 520, вызов БД — 150, внешний каталог — 200. Промежутки между интервалами не получили отдельного объяснения. Поэтому их нельзя автоматически назвать сетью или дополнительной работой.
| Сегмент | Роль | Интервал | Длительность | Что можно сказать |
|---|---|---|---|---|
| fixed-admission-queue | queue-wait | 40–560 | 520 | Самый длинный названный сегмент этой записи |
| fixed-db-call | database-execution | 570–720 | 150 | Интервал вызова БД в этой trace |
| fixed-catalog-call | external-dependency | 730–930 | 200 | Интервал внешнего вызова в этой trace |
| fixed-gateway | end-to-end | 0–1 000 | 1 000 | Граница пути, а не объяснение причины |
В этой записи fixed-admission-queue длиннее двух других названных сегментов. Это единственный прямой вывод о порядке длительностей. Нельзя из него заключить, что очередь является bottleneck при другой нагрузке, что изменение gateway ускорит пользователя или что БД не требует исследования. Для любого такого утверждения нужна отдельная проверка.
Код ниже работает с заранее заданным объектом. Он не обращается к сети, базе, часам, профайлеру или телеметрии. Числа условны. Пример проверяет связность и интервалы, а не показывает результат реального сервиса.
\nconst 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));\nobservation-ready здесь означает только, что запись связна и содержит корректные условные интервалы. Если parent равен missing-01, результат должен быть stop-incomplete-trace. Если начало больше конца, функция должна остановиться. Отрицательный путь не является исключением из метода. Он показывает, что неполный материал нельзя превращать в уверенный диагноз.
| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| CPU низкий, запрос медленный | В end-to-end время вошло ожидание | Разделить queue-wait, локальную работу и дочерние вызовы | Назвать только покрытые span; остаток оставить unknown |
| Длинный span совпал с пиком latency | Span включает ожидание upstream или retry | Проверить parent/child, повторные вызовы и дочерние интервалы | Не объявлять span причиной без отдельного сигнала |
| Второй прогон короче первого | Изменилась нагрузка или форма входа | Сверить cohort, requests, concurrency и shape | Снять сравнение и повторить на общей границе |
| Есть root, но нет parent у дочернего span | Потеря записи или неверная связь ID | Проверить полный экспорт и уникальность идентификаторов | Вернуть stop; не дорисовывать дерево по времени |
| После изменения есть одна короткая запись | Нет baseline и распределения наблюдений | Повторить тот же сценарий и сохранить контроль | Назвать observation, а не improvement |
Длинный интервал сообщает, что в конкретной записи он длинный. Он не сообщает, почему это произошло и какое изменение его сократит. Очередь может зависеть от admission policy или ограниченного ресурса. Вызов БД может ждать соединение до начала исполнения. Внешний вызов может включать локальную подготовку. Одна trace не выбирает между этими объяснениями.
\nПолезно разделять три фразы. Наблюдение: «queue-wait занимает 520 units в fixed-trace-01». Гипотеза: «правило допуска создаёт часть ожидания». Проверка: «сравнить заранее определённые записи с теми же cohort, requests, concurrency и shape». Перескакивать от первой фразы к третьей нельзя. Тем более нельзя сразу объявлять эффект изменения.
Та же граница действует для базы. database-execution = 150 — не диагноз SQL, не рекомендация индекса и не оценка бюджета. Если команда хочет исследовать запрос, она формулирует новый вопрос и сохраняет текущую запись как baseline только после проверки сопоставимости. Уменьшение знакомого локального шага не становится правильным действием из-за того, что его проще измерить.
Baseline и candidate можно сравнивать только внутри явно названной контрольной границы. В учебном примере это fixed-load-a, 12 логических запросов, concurrency 3 и fixed-read-shape-a. Если второй прогон использует 24 запроса, concurrency 6 или другую форму входа, он отвечает на другой вопрос. Более короткий root не доказывает ускорение.
Смена одного поля уже важна. Если выросла concurrency, очередь может измениться без изменения кода. Если изменилась форма данных, база может выбрать другой план. Если другой cohort пришёл из другого окна, кэш и внешняя зависимость могли иметь иное состояние. Запись должна сделать эти условия видимыми, а не прятать их в подписи графика.
\nОтдельно проверяйте параллельность. Дочерние span могут пересекаться. В таком случае их сумма превысит время root и не покажет стоимость пути. Сначала определите временную зависимость. Если это невозможно, оставьте вывод на уровне «интервалы пересекаются» и не выбирайте самый большой span как причину.
\nЕсли trace неполная, остановитесь на stop-incomplete-trace. Если ожидание помечено только как unknown-delay, не называйте его очередью. Если нагрузка отличается, верните stop-incomparable-load. Если в записи уже есть утверждение «стало быстрее», но нет сопоставимого контроля, снимите claim и сохраните только наблюдение.
Такая остановка экономит время. Неполный trace легко вставить в убедительный рассказ и трудно разобрать после нескольких изменений. Именованная причина stop сохраняет недостающий факт: нужно восстановить parent, назвать ожидание или выровнять нагрузку. Отказ от вывода точнее, чем правдоподобное объяснение пустого места.
\nSampling может убрать нужный span. Collector может потерять событие или доставить его не по порядку. Асинхронный worker может продолжить работу после root. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезной, но снижают силу вывода. Ограничение нужно записать рядом с наблюдением.
\nWaterfall не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Он не задаёт SLA и не заменяет нагрузочный тест. RFC 9110 описывает семантику HTTP, а не бюджет latency приложения. Для эксплуатационного решения нужны отдельные измерения, контрольные группы и критерии остановки.
\nУчебный код нельзя подключать к реальной телеметрии без новой проверки. Он использует одну запись, фиксированные числа и заранее известные поля. Он не проверяет экспорт, прокси, клиентские повторы или права доступа. Production-результат появляется только после отдельного эксперимента с описанной средой.
\nРазбор готов, если другой инженер без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу, отличить названную задержку от unknown и воспроизвести stop на неполной trace или несопоставимой нагрузке. Это критерий качества evidence, а не обещание ускорения.
\nИзменение можно оценивать отдельно, когда baseline и candidate сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а результат не маскирует ошибку ростом таймаута или потерей сигнала. До этого корректный итог звучит так: «В fixed-trace-01 при fixed-load-a queue-wait — самый длинный названный сегмент. Эффект изменения не заявлен». Другой инженер должен получить тот же вывод из той же записи.