{ "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

Сначала зафиксировать симптом

\n

До изменения кода сохраните маршрут, метод, статус, длительность, размер ответа, timestamp и идентификатор запроса. Добавьте число логических запросов, concurrency, форму входных данных и версию конфигурации. Эти поля задают контрольную границу: без неё сравнение «до» и «после» может измерять разные условия.

\n

CPU — метрика ресурса, а не таймер конкретного запроса. Процессор может простаивать, пока поток ждёт свободное соединение или ответ удалённого сервиса. Даже высокий CPU не доказывает причину: горячий участок мог работать параллельно с ожиданием, а агрегированная метрика могла скрыть один перегруженный worker. В начале записи отделите факт от предположения: «root длился 10 000 мс, CPU процесса был около 20%» — факт; «запрос тормозит из-за базы» — пока гипотеза.

\n

Если trace отсутствует или связывает только часть пути, сила вывода ограничена. Запишите это сразу. Попытка восстановить дерево по времени логов полезна как поиск следующего сигнала, но не заменяет корректную parent/child-связь.

\n

Как trace раскладывает время

\n

Trace — это путь одной операции через систему. Span — отдельная единица работы внутри этого пути. Корневой span обычно описывает всю операцию, а дочерние span-ы — её подоперации. У каждого интервала должны быть начало, конец, идентификатор и связь с родителем. Эта модель помогает спросить: «какой участок наблюдается?» Она не отвечает автоматически на вопрос: «что произойдёт после изменения?»

\n

End-to-end время включает и работу, и ожидание. Очередь допуска, выдача соединения, блокировка строки, DNS, TLS, чтение диска, retry и ожидание ответа партнёра могут занимать время при низком CPU. Название span должно соответствовать записанной операции. db-call не означает всё время до базы, если ожидание соединения записано за его пределами или не записано вовсе.

\n

Следите за двумя границами. Первая — временная: root от начала принятия операции до отправки результата. Вторая — экспериментальная: одинаковые cohort, число запросов, concurrency, входные данные, версия приложения и правило отбора trace. Без второй границы короткий ответ после изменения может быть следствием меньшей нагрузки, попадания в кэш или другого набора данных.

\n
Цикл проверки задержки: полный trace с названными сегментами, одинаковая контрольная нагрузка, контрпример и решение продолжить проверку или остановиться
Сначала фиксируются сегменты trace и граница нагрузки. Контрпример проверяет, выдерживает ли гипотеза неполные данные; результатом может быть hand-off на следующую проверку или именованная причина остановки.
\n

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

\n
Как превратить наблюдение в проверяемое действие
СимптомВозможное объяснениеПроверкаДействие
CPU низкий, root медленныйЗапрос ждёт очередь, пул соединений или внешний ответСопоставить root с дочерними span-ами и метриками ожиданияНазвать покрытый участок; неизвестный остаток оставить unknown
Самый длинный span совпадает с пиком latencySpan включает retry или ожидание внутри зависимостиПроверить статус, число попыток, вложенные интервалы и границу сервисаНе объявлять span причиной без сигнала, который отделяет работу от ожидания
После изменения ответ корочеИзменились cohort, concurrency, кэш или форма данныхСверить контрольные поля и распределение наблюденийОтменить вывод и повторить с одной нагрузочной границей
Дочерний span ссылается на отсутствующего родителяПотерян экспорт, сломана передача контекста или неверен IDПроверить полный экспорт, уникальность ID и заголовок traceparentОстановить причинный вывод и починить наблюдаемость
Есть одна удачная записьНет пары и распределения, поэтому случай может быть выбросомПовторить сценарий и сравнить одинаковые квантили либо все наблюденияНазывать это наблюдением, а не эффектом изменения
\n

Учебная запись и отрицательный путь

\n

Ниже приведена полностью синтетическая запись. Числа условны, код не обращается к сети, базе, часам или профайлеру. Он проверяет только связность дерева и корректность локальных интервалов. Название observation-ready означает «запись можно читать», а не «причина найдена».

\n
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 — причина продолжить сбор данных, а не разрешение выбрать удобного виновника.

\n

Не складывать интервалы механически

\n

Дочерние операции могут идти последовательно или параллельно. В последовательном пути суммарная длительность часто близка к root за вычетом неразмеченных участков. В параллельном пути сумма дочерних span-ов может быть больше root. Поэтому сложение всех длительностей не даёт автоматически критический путь.

\n

Для критического пути нужно увидеть порядок зависимостей и момент, когда root действительно мог завершиться. Если два вызова стартовали рядом и один ждал другой только на стадии сборки ответа, их интервалы нельзя трактовать как две последовательные секунды. Полезнее построить waterfall: начало и конец каждого span-а, parent, зависимость и участок ожидания. Если система не экспортирует такие данные, вывод ограничивается известными границами.

\n

Также не смешивайте клиентскую и серверную задержку. Время до отправки HTTP-запроса, очередь на прокси, обработка в приложении и чтение ответа — разные участки. W3C Trace Context помогает передать идентификатор между границами, но сам заголовок не создаёт отсутствующие span-ы и не гарантирует, что каждый посредник сохранит запись. Для каждого разрыва нужен отдельный способ проверки.

\n

Порядок безопасного эксперимента

\n
  1. Опишите исходный симптом: маршрут, статус, длительность, размер ответа и временное окно.
  2. Сохраните trace и связанные логи до изменения. Отметьте root, дочерние операции, пропуски, retry и неизвестные интервалы.
  3. Проверьте связность ID, порядок времени и единицы измерения. Не дорисовывайте родителя по одному совпадению timestamp.
  4. Зафиксируйте control boundary: cohort, число запросов, concurrency, форму входа, кэш и версию приложения.
  5. Сформулируйте две гипотезы, например «ждём пул» и «ждём внешний ответ». Для каждой запишите наблюдение, которое её опровергнет.
  6. Выберите самый маленький эксперимент: отдельная метрика ожидания, временный лог границы пула, повторяемый запрос или контрольный прогон без одного вызова.
  7. Проверьте отрицательный случай: отсутствующий parent, пропущенный span и несопоставимая нагрузка должны останавливать вывод.
  8. Измените один фактор и повторите тот же сценарий. Сохраняйте записи до и после рядом с одинаковыми полями.
  9. Сравните не одну удачную запись, а распределение и соседние сигналы: ошибки, timeout, очередь, throughput и потребление ресурса.
  10. Запишите результат с границей: что измерено, что изменилось, в каких условиях и какой вопрос остался открытым.
\n

Выбор действия и критерий результата

\n

Если отдельный сигнал подтвердил ожидание в пуле, действие может быть локальным: проверить размер пула, время выдачи соединения и конкуренцию. Увеличивать пул без измерения опасно: можно перенести очередь в базу и поднять число одновременных запросов. Если подтверждён внешний вызов, сравните timeout, retry и кэширование, но не принимайте рост timeout за улучшение — пользователь может ждать дольше.

\n

Если подтверждена локальная работа CPU, тогда уместен профайлер или измерение конкретного участка. Если trace показывает только неизвестный остаток, сначала улучшите наблюдаемость. Выбор действия определяется границей доказательства: исправлять компонент, который виден на схеме, но не подтверждён сигналом, — дорогая гипотеза.

\n

Результат эксперимента должен включать baseline, контрольные поля, число наблюдений, метрику сравнения и побочный эффект. Фраза «стало быстрее» слишком коротка для воспроизводимого решения. Точнее: «на одинаковом наборе из 12 запросов при concurrency 3 медиана root изменилась с X до Y; число ошибок и таймаутов не выросло; p95 не проверялся». Если X и Y не измерены, так и напишите.

\n

Ограничения применимости

\n

Эта схема подходит для запросов, где можно получить сопоставимые временные интервалы и контекст нагрузки. Она не заменяет нагрузочный тест, профилирование, анализ блокировок или проверку пользовательского устройства. Одна трасса не показывает поведение хвоста распределения, стоимость соединений, throughput и эффект кэша.

\n

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

\n

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

\n

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

\n

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

\n

Измеренный эффект допустимо объявлять только при сопоставимых записях до и после, одном изменённом факторе, одинаковом способе измерения и проверенных ошибках и таймаутах. Иначе итог формулируется скромнее: «найден участок для дальнейшей проверки» или «данных недостаточно». Такая граница сохраняет время команды и не маскирует отсутствие причинного доказательства.

\n

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

\n" }