{ "index": 262, "slug": "editorial-2020-09-field-tracing-basics", "title": "Как читать распределённый trace: duration, parent и critical path", "excerpt": "Практический разбор медленного запроса: как проверить связность trace, не сложить вложенные интервалы дважды и выбрать следующую проверку вместо поспешного увеличения timeout.", "contentHtml": "
Пользователь ждёт ответ API 240 мс, а в trace видит несколько span: gateway, catalog, pricing и inventory. Команда складывает все duration, получает 720 мс и начинает искать «лишнюю» задержку. Другая команда видит самый длинный span inventory и сразу увеличивает timeout. Обе реакции могут ошибиться. Первая удваивает время вложенных операций. Вторая меняет лимит, но не проверяет, кто удерживает ответ.
\nЦена ошибки — не только несколько миллисекунд в отчёте. Неверный диагноз закрепляет плохую конфигурацию, маскирует потерю контекста и переносит проблему на следующий релиз. Правильный разбор начинается с наблюдаемых полей: один ли это trace, кто родитель span, где начинается и заканчивается каждый интервал, какая ветка завершается последней.
\nTrace показывает путь операции через границы компонентов. Span описывает отдельный участок этого пути. Parent связывает участок с предыдущей операцией. Duration равен разности end - start. Эти факты позволяют объяснить задержку, но только если записи относятся к одной логической истории и используют сопоставимую шкалу времени.
Родительский span обычно включает интервалы своих children. Поэтому duration родителя и duration child нельзя складывать как последовательные операции. Если две дочерние ветки перекрываются, их время тоже не складывается. Сначала нужно найти интервал, который удерживает ответ до конца, затем проверить его собственные участки.
\nНиже — controlled example. Все значения придуманы для объяснения метода. Это не замер реального сервиса и не production-результат. Корневой span gateway.handle живёт от 0 до 240 мс. Он вызывает catalog.lookup от 20 до 220 мс. Внутри catalog две параллельные ветки: pricing.read от 30 до 70 мс и inventory.fetch от 30 до 200 мс. Inventory запускает inventory.adapter от 100 до 170 мс.
| Span | Parent | Интервал | Duration |
|---|---|---|---|
gateway.handle | root | 0–240 ms | 240 ms |
catalog.lookup | gateway.handle | 20–220 ms | 200 ms |
pricing.read | catalog.lookup | 30–70 ms | 40 ms |
inventory.fetch | catalog.lookup | 30–200 ms | 170 ms |
inventory.adapter | inventory.fetch | 100–170 ms | 70 ms |
Из таблицы нельзя заключить, что запрос занял 240 + 200 + 40 + 170 + 70 мс. Children находятся внутри parent. Pricing и inventory идут параллельно с 30 до 70 мс. Поэтому pricing не добавляет 40 мс после inventory. Корневой ответ заканчивается в 240 мс, а не в сумме всех строк.
\nСначала проверьте форму интервалов. Для каждого span должно выполняться end >= start. Child должен находиться внутри parent, если модель использует обычное дерево вложенных операций. Если child выходит за границы родителя, это не повод сразу рисовать красную полосу. Причиной может быть ошибка закрытия span, асинхронная работа после ответа или неверная модель связи. Запишите факт и остановите арифметику до выяснения.
const spans = [\n { name: 'gateway.handle', parent: null, start: 0, end: 240 },\n { name: 'catalog.lookup', parent: 'gateway.handle', start: 20, end: 220 },\n { name: 'pricing.read', parent: 'catalog.lookup', start: 30, end: 70 },\n { name: 'inventory.fetch', parent: 'catalog.lookup', start: 30, end: 200 },\n { name: 'inventory.adapter', parent: 'inventory.fetch', start: 100, end: 170 },\n];\n\nfunction duration(span) {\n return span.end - span.start;\n}\n\nfunction isInside(child, parent) {\n return child.start >= parent.start && child.end <= parent.end;\n}\nЭта функция проверяет только учебные интервалы. Она не исправляет часы разных машин, не восстанавливает потерянный span и не доказывает, что transport передал контекст. В настоящей системе сначала установите, откуда пришли timestamps и как инструмент описывает асинхронные связи.
\nВ этом примере последний child catalog — inventory.fetch: он заканчивается в 200 мс, тогда как pricing заканчивается в 70 мс. Поэтому путь до конца ответа проходит через gateway.handle → catalog.lookup → inventory.fetch. Adapter находится внутри inventory и помогает объяснить его работу, но его 70 мс уже входят в 170 мс inventory.
Для более точной проверки разложите выбранные интервалы на exclusive-участки. У gateway остаются 40 мс вне catalog: 0–20 и 220–240. У catalog остаются 30 мс вне объединения children: 20–30 и 200–220. У inventory остаются 100 мс вне adapter: 30–100 и 170–200. Adapter даёт 70 мс собственного участка. Сумма 40 + 30 + 100 + 70 = 240 мс совпадает с root duration. Это проверка разложения конкретной модели, а не универсальный алгоритм для любого trace-хранилища.
| Симптом | Возможная причина | Проверка | Действие |
|---|---|---|---|
| Все duration в сумме больше root | Вложенные span посчитали повторно | Сверить интервалы parent и child | Считать overlap и exclusive-участки |
| У соседнего сервиса новый trace-id | Context не передали или создали новый root | Сравнить traceparent на границе | Проверить inject/extract и решение о новой границе |
| Child заканчивается после parent | Span закрыли рано или работа асинхронна | Сопоставить lifecycle операции и timestamps | Исправить закрытие либо описать link/async-модель |
| Самый длинный span не объясняет ответ | Он перекрывается с другой веткой | Найти ветку с последним end | Проверить critical path, а не максимум duration |
| Trace обрывается на proxy | Sampler, фильтр или transport не сохранил context | Сравнить входной и исходящий carrier | Добавить точечную проверку boundary и не обещать полноту |
Связность trace не появляется из названий span. Клиент должен передать контекст, а принимающая сторона — извлечь его и создать новый child. Для W3C Trace Context заголовок traceparent содержит version, trace-id, parent-id и trace-flags. Пример ниже синтетический. Он показывает форму данных, но не является рабочим токеном и не доказывает прохождение через конкретный proxy.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n\n// gateway: inject current span context\n// catalog: extract traceparent, create child span\n// inventory: inject catalog child as the next parent\nЕсли catalog создаёт новый root, downstream trace может выглядеть аккуратно, но связь с gateway потеряна. Если parser принимает нулевой или неверно оформленный ID, система получает ложную иерархию. Если выборка не сохраняет часть span, trace остаётся неполным. В каждом случае отрицательный путь важнее красивого графика: нужно уметь сказать «связь не доказана».
\nend - start, child не выходит за parent в выбранной модели.Обычное дерево span плохо описывает fan-out с несколькими родителями, очередь, retry и работу, которая продолжается после ответа. Для таких случаев нужны links или другая модель причинности. Нельзя объявлять critical path доказанным, если timestamps пришли с несинхронизированных часов. Нельзя считать отсутствие span доказательством отсутствия работы: его мог отфильтровать sampler.
\nTrace также не заменяет профиль CPU, план базы данных, сетевой capture или бизнес-метрику. Он показывает наблюдаемую структуру и интервалы выбранной instrumentation. Если span широк, следующая проверка должна сузить его до конкретного adapter или внешнего вызова. Если контекст потерян, сначала восстановите boundary, а не оптимизируйте случайный участок.
\nРазбор готов, когда для одного проверочного запроса выполнены четыре условия: все использованные записи имеют объяснимую связь с root; для каждого вывода указана опора в trace; арифметика не считает вложенные или параллельные интервалы дважды; после изменения есть повторяемая проверка того же симптома и отрицательного пути. Если хотя бы одно условие не выполнено, честный результат — «причина пока не доказана». Такой ответ полезнее, чем уверенный, но неверный виновник.
\n