{ "index": 58, "slug": "editorial-2026-05-field-systems-performance", "title": "Низкий CPU, длинный запрос: как найти границу задержки", "excerpt": "CPU почти свободен, а пользователь ждёт ответ десять секунд. Разбираем trace, очередь, сопоставимую нагрузку и границу, за которой нельзя объявлять причину.", "contentHtml": "
Симптом выглядит так: пользователь ждёт ответ десять секунд, а график CPU держится на двадцати процентах. В такой ситуации легко объявить виновным запрос к базе, добавить поток или увеличить timeout. Но низкий CPU не противоречит длинному ответу: запрос мог ждать очередь, соединение из пула, блокировку, диск или внешний сервис. Неизвестное ожидание не превращается в причину оттого, что оно попало в один end-to-end интервал.
\nРазберём учебный полевой сценарий: один HTTP-запрос, его trace и фиксированная контрольная нагрузка. Цель не в том, чтобы угадать узкое место по самому длинному отрезку. Нужно отделить наблюдение от гипотезы, назвать недостающий сигнал и только потом выбрать небольшое изменение. Такой порядок оставляет после расследования не впечатление, а запись, которую другой инженер сможет проверить.
\nДо изменения кода сохраните маршрут, метод, статус, длительность, размер ответа, timestamp и идентификатор запроса. Добавьте число логических запросов, concurrency, форму входных данных и версию конфигурации. Эти поля задают контрольную границу: без неё сравнение «до» и «после» может измерять разные условия.
\nCPU — метрика ресурса, а не таймер конкретного запроса. Процессор может простаивать, пока поток ждёт свободное соединение или ответ удалённого сервиса. Даже высокий CPU не доказывает причину: горячий участок мог работать параллельно с ожиданием, а агрегированная метрика могла скрыть один перегруженный worker. В начале записи отделите факт от предположения: «root длился 10 000 мс, CPU процесса был около 20%» — факт; «запрос тормозит из-за базы» — пока гипотеза.
\nЕсли trace отсутствует или связывает только часть пути, сила вывода ограничена. Запишите это сразу. Попытка восстановить дерево по времени логов полезна как поиск следующего сигнала, но не заменяет корректную parent/child-связь.
\nTrace — это путь одной операции через систему. Span — отдельная единица работы внутри этого пути. Корневой span обычно описывает всю операцию, а дочерние span-ы — её подоперации. У каждого интервала должны быть начало, конец, идентификатор и связь с родителем. Эта модель помогает спросить: «какой участок наблюдается?» Она не отвечает автоматически на вопрос: «что произойдёт после изменения?»
\nEnd-to-end время включает и работу, и ожидание. Очередь допуска, выдача соединения, блокировка строки, DNS, TLS, чтение диска, retry и ожидание ответа партнёра могут занимать время при низком CPU. Название span должно соответствовать записанной операции. db-call не означает всё время до базы, если ожидание соединения записано за его пределами или не записано вовсе.
Следите за двумя границами. Первая — временная: root от начала принятия операции до отправки результата. Вторая — экспериментальная: одинаковые cohort, число запросов, concurrency, входные данные, версия приложения и правило отбора trace. Без второй границы короткий ответ после изменения может быть следствием меньшей нагрузки, попадания в кэш или другого набора данных.
\n| Симптом | Возможное объяснение | Проверка | Действие |
|---|---|---|---|
| CPU низкий, root медленный | Запрос ждёт очередь, пул соединений или внешний ответ | Сопоставить root с дочерними span-ами и метриками ожидания | Назвать покрытый участок; неизвестный остаток оставить unknown |
| Самый длинный span совпадает с пиком latency | Span включает retry или ожидание внутри зависимости | Проверить статус, число попыток, вложенные интервалы и границу сервиса | Не объявлять span причиной без сигнала, который отделяет работу от ожидания |
| После изменения ответ короче | Изменились cohort, concurrency, кэш или форма данных | Сверить контрольные поля и распределение наблюдений | Отменить вывод и повторить с одной нагрузочной границей |
| Дочерний span ссылается на отсутствующего родителя | Потерян экспорт, сломана передача контекста или неверен ID | Проверить полный экспорт, уникальность ID и заголовок traceparent | Остановить причинный вывод и починить наблюдаемость |
| Есть одна удачная запись | Нет пары и распределения, поэтому случай может быть выбросом | Повторить сценарий и сравнить одинаковые квантили либо все наблюдения | Называть это наблюдением, а не эффектом изменения |
Ниже приведена полностью синтетическая запись. Числа условны, код не обращается к сети, базе, часам или профайлеру. Он проверяет только связность дерева и корректность локальных интервалов. Название observation-ready означает «запись можно читать», а не «причина найдена».
const trace = {\n root: { id: 'root-01', start: 0, end: 1000 },\n spans: [\n { id: 'queue-01', parent: 'root-01', name: 'admission-queue', start: 40, end: 560 },\n { id: 'db-01', parent: 'root-01', name: 'db-call', start: 570, end: 720 },\n { id: 'catalog-01', parent: 'root-01', name: 'catalog-call', start: 730, end: 930 }\n ],\n load: { cohort: 'load-a', requests: 12, concurrency: 3, shape: 'read-shape-a' }\n};\n\nfunction inspectTrace(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 ordered = input.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 (!ordered) return { status: 'stop-invalid-interval' };\n return { status: 'observation-ready', claim: 'not-measured' };\n}\n\nconsole.log(inspectTrace(trace));\nДля этой записи root длится 1000 условных единиц, очередь — 520, база — 150, каталог — 200. Сумма дочерних интервалов равна 870, но оставшиеся 130 нельзя назвать сетью: интервалы могли перекрываться, а часть работы могла не иметь span. Корректная запись результата звучит так: «130 единиц не покрыты названными интервалами; нужен отдельный сигнал».
\nТеперь замените parent: 'root-01' на parent: 'missing-01'. Функция вернёт stop-incomplete-trace. Если конец интервала меньше начала, появится stop-invalid-interval. Эти отрицательные пути важны: без них система может продолжить рассуждение по красивому, но повреждённому дереву. Неполный trace — причина продолжить сбор данных, а не разрешение выбрать удобного виновника.
Дочерние операции могут идти последовательно или параллельно. В последовательном пути суммарная длительность часто близка к root за вычетом неразмеченных участков. В параллельном пути сумма дочерних span-ов может быть больше root. Поэтому сложение всех длительностей не даёт автоматически критический путь.
\nДля критического пути нужно увидеть порядок зависимостей и момент, когда root действительно мог завершиться. Если два вызова стартовали рядом и один ждал другой только на стадии сборки ответа, их интервалы нельзя трактовать как две последовательные секунды. Полезнее построить waterfall: начало и конец каждого span-а, parent, зависимость и участок ожидания. Если система не экспортирует такие данные, вывод ограничивается известными границами.
\nТакже не смешивайте клиентскую и серверную задержку. Время до отправки HTTP-запроса, очередь на прокси, обработка в приложении и чтение ответа — разные участки. W3C Trace Context помогает передать идентификатор между границами, но сам заголовок не создаёт отсутствующие span-ы и не гарантирует, что каждый посредник сохранит запись. Для каждого разрыва нужен отдельный способ проверки.
\nЕсли отдельный сигнал подтвердил ожидание в пуле, действие может быть локальным: проверить размер пула, время выдачи соединения и конкуренцию. Увеличивать пул без измерения опасно: можно перенести очередь в базу и поднять число одновременных запросов. Если подтверждён внешний вызов, сравните timeout, retry и кэширование, но не принимайте рост timeout за улучшение — пользователь может ждать дольше.
\nЕсли подтверждена локальная работа CPU, тогда уместен профайлер или измерение конкретного участка. Если trace показывает только неизвестный остаток, сначала улучшите наблюдаемость. Выбор действия определяется границей доказательства: исправлять компонент, который виден на схеме, но не подтверждён сигналом, — дорогая гипотеза.
\nРезультат эксперимента должен включать baseline, контрольные поля, число наблюдений, метрику сравнения и побочный эффект. Фраза «стало быстрее» слишком коротка для воспроизводимого решения. Точнее: «на одинаковом наборе из 12 запросов при concurrency 3 медиана root изменилась с X до Y; число ошибок и таймаутов не выросло; p95 не проверялся». Если X и Y не измерены, так и напишите.
\nЭта схема подходит для запросов, где можно получить сопоставимые временные интервалы и контекст нагрузки. Она не заменяет нагрузочный тест, профилирование, анализ блокировок или проверку пользовательского устройства. Одна трасса не показывает поведение хвоста распределения, стоимость соединений, throughput и эффект кэша.
\nSampling может исключить нужный trace или span. Collector может потерять событие, а асинхронный worker — продолжить работу после завершения root. Повторные попытки создают несколько похожих операций. Часы разных узлов могут расходиться, поэтому абсолютное положение соседних интервалов требует осторожности. Эти условия не запрещают анализ, но снижают силу вывода и должны попасть в запись расследования.
\nНельзя переносить условные числа из примера в SLA. RFC 9110 описывает семантику HTTP-запросов и ответов, но не устанавливает бюджет времени конкретного приложения. Бюджет latency, допустимый процент ошибок и окно сравнения задаёт сама система вместе с её требованиями. Если таких требований нет, сначала согласуйте критерий, иначе эксперимент не имеет точки принятия решения.
\nРасследование можно передавать следующему инженеру, если он получает исходный симптом, trace, контрольную границу и список пропусков. Он должен найти root, проверить parent/child-связи, отличить названный интервал от unknown, повторить отрицательный путь и понять, какое наблюдение подтвердит или опровергнет следующую гипотезу.
\nИзмеренный эффект допустимо объявлять только при сопоставимых записях до и после, одном изменённом факторе, одинаковом способе измерения и проверенных ошибках и таймаутах. Иначе итог формулируется скромнее: «найден участок для дальнейшей проверки» или «данных недостаточно». Такая граница сохраняет время команды и не маскирует отсутствие причинного доказательства.
\ntraceparent и tracestate для передачи контекста; наличие заголовка не гарантирует полноту сбора.