Трассы: где операция потратила время
В главе разобрано, из чего состоит трасса, как читать этапы на шкале времени, почему длительности вложенных и параллельных этапов нельзя просто складывать и как связать HTTP-запрос с фоновой задачей.
Чтобы найти шаг, на котором операция теряет время, сохраняют начало и длительность каждого её этапа.
- Раздел
- Основы наблюдаемости
- Уровень
- Начальный · Python знаком
- Связанные главы
- 1.3 — инструментация, 1.5 — логи
- Зачем это знать
- Находить этап, на котором медленная операция теряет время
§1Операция состоит из этапов
Метрики из главы 1.4 показали, что два запроса из двадцати длились около трёх секунд. Логи из главы 1.5 показывают, что с этими запросами произошло: код 200, исключения нет. Но ни метрика, ни запись о событии не отвечают на вопрос, куда ушли три секунды.
Обработчик /products выполняет три шага: validate проверяет параметры, dependency.wait ждёт ответа зависимости, serialize готовит JSON. Если для каждого шага сохранить момент начала и длительность, станет видно, какой шаг занял время. Такую запись о шагах одной операции называют трассой.
По трек-номеру видны все этапы пути посылки: приём, сортировочный центр, таможня, курьер — и время на каждом. Если доставка заняла месяц, по этапам видно, где посылка лежала.
По идентификатору запроса видны все этапы его обработки и длительность каждого. Если запрос занял три секунды, по этапам видно, на каком из них ушло время.
Трасса (trace) — запись о выполнении одной операции в виде связанных этапов на общей шкале времени. Трасса показывает, из каких шагов состояла операция, в каком порядке они шли и сколько длился каждый.
Этап трассы (span) — один интервал работы внутри операции: вызов функции, запрос к базе данных, обращение к другому сервису. У этапа есть имя, время начала, длительность и ссылка на родительский этап.
Английское span означает промежуток, пролёт. В русскоязычной документации слово обычно оставляют без перевода. В тексте справочника используется слово «этап», а в коде и интерфейсах инструментов встретится span.
Трасса полезна уже в одном процессе, как на стенде. Распределённой её называют, когда этапы одной операции выполняются в разных сервисах: API вызывает сервис цен, тот обращается к кэшу, а общая трасса связывает все три.
Первоисточник: OpenTelemetry · Traces ↗
Трасса состоит из этапов. Следующий раздел показывает, что записано в одном этапе.
§2Что хранит этап трассы
В сервисе стенда этап — словарь из трёх полей. Ниже запись медленного запроса из файла events.json.
"spans": [
{"name": "validate", "offset_ms": 0.004, "duration_ms": 11.168},
{"name": "dependency.wait (simulated)", "offset_ms": 11.177, "duration_ms": 3000.058},
{"name": "serialize", "offset_ms": 3011.24, "duration_ms": 10.419}
]Поле name — имя этапа: что делал код на этом участке. Поле offset_ms — смещение: сколько миллисекунд прошло от начала запроса до начала этапа. На схеме оно задаёт, где начинается полоса. Поле duration_ms — длительность этапа в миллисекундах. Оно задаёт длину полосы.
Числа в записи согласованы между собой. validate начался почти сразу и длился 11,168 мс, значит, закончился около 11,17 мс, и следующий этап начался на 11,177 мс. Ожидание зависимости длилось 3000 мс и закончилось на 3011,2 мс, где начался serialize. Этапы идут друг за другом без промежутков.
В стандартных системах трассировки у этапа больше полей. Они нужны, чтобы собирать этапы из разных процессов в одну трассу.
| Поле | Назначение |
|---|---|
| trace_id | Идентификатор всей операции; одинаковый у всех её этапов |
| span_id | Идентификатор этого этапа |
| parent_span_id | Идентификатор родительского этапа; по нему строится дерево |
| start_time, end_time | Абсолютное время начала и конца |
| attributes | Пары ключ–значение с подробностями: SQL-запрос, адрес, размер ответа |
| events | События внутри этапа с отметкой времени, например исключение |
| status | Результат этапа: успешно или с ошибкой |
Поля этапа — это числа. Чтобы увидеть по ним, где ушло время, этапы рисуют на шкале.
§3Как читать шкалу времени
Этапы трассы рисуют горизонтальными полосами друг под другом на общей оси времени. Из-за ступенчатого вида такую диаграмму называют водопадом (waterfall). Ниже этапы медленного запроса из прогона experiment.py.
Верхняя полоса — весь запрос, три нижние — его этапы. Кнопка «Следующий этап» выделяет этапы по очереди, под схемой появляются начало и длительность выбранного этапа. Нажать можно и на имя этапа.
Верхняя полоса GET /products — весь запрос. Это корневой этап, все остальные входят в него. Ниже три дочерних этапа. Нажимайте «Следующий этап» и сравнивайте положение полосы с числами под схемой.
Полоса dependency.wait занимает почти всю ширину шкалы: из трёх секунд запроса три приходятся на ожидание зависимости. Проверка параметров и подготовка ответа занимают по 11 мс и на такой шкале видны как тонкие черты.
Блок TRACE внизу страницы стенда рисует те же этапы, что и водопад выше, но для другого медленного запроса.
- Время обработчика. 3022,3 мс — длина всей шкалы TRACE.
- validate. 11,1 мс, тонкая черта у левого края.
- dependency.wait. 3000,6 мс, полоса на всю ширину.
- serialize. 10,6 мс, тонкая черта у правого края.
- 11,1
- validate
- 3000,6
- dependency.wait
- 10,6
- serialize
Сумма этапов совпадает со временем обработчика 3022,3 мс. Значит, между этапами код почти ничего не делает.
На стенде в имени этапа указано, что ожидание смоделировано. В настоящем приложении этап database длительностью 3 секунды оставил бы несколько вариантов: запрос ждал свободное соединение, ждал сеть или долго выполнялся сам SQL. Чтобы трасса отвечала точно, крупный этап делят на части: получение соединения, отправка запроса, выполнение, чтение результата.
На стенде этапы идут друг за другом, и их длительности складываются в длительность запроса. В рабочих сервисах так бывает не всегда, и следующие три раздела разбирают три таких случая.
§4Вложенные этапы не складывают
Этапы образуют дерево. Обработчик вызывает функцию загрузки товаров, та отправляет SQL-запрос, и каждый вызов становится этапом внутри этапа вызывающей функции.
| Этап | Родитель | Интервал | Длительность |
|---|---|---|---|
| GET /products | — | 0–500 мс | 500 мс |
| load_products | GET /products | 50–450 мс | 400 мс |
| SELECT products | load_products | 100–400 мс | 300 мс |
Сложить все длительности
500 + 400 + 300 = 1200 мс, а запрос длился 500 мс.
300 мс SQL уже входят в 400 мс функции загрузки, а те — в 500 мс запроса.
Посчитать собственное время
Время этапа за вычетом дочерних: 500 − 400 = 100 мс, 400 − 300 = 100 мс, у SQL все 300 мс.
Сумма собственных времён — ровно 500 мс.
Собственное время каждого этапа — время, не занятое дочерними этапами. Здесь оно показывает, что больше всего времени тратит сам запрос к базе.
Вложенные этапы — первый случай, когда длительности не складываются. Второй случай — этапы, которые выполняются одновременно.
§5Параллельные этапы перекрываются
Обработчик может ждать несколько зависимостей одновременно. В асинхронном Python для этого используют asyncio.gather. Ниже сравниваются последовательный и параллельный вызовы двух зависимостей: кэша на 40 мс и внешнего API на 900 мс.
import asyncio
import time
async def call(name, seconds):
await asyncio.sleep(seconds)
return name
async def main():
start = time.perf_counter()
await call("redis", 0.04)
await call("external_api", 0.9)
print(f"последовательно: {(time.perf_counter() - start) * 1000:.0f} мс")
start = time.perf_counter()
await asyncio.gather(call("redis", 0.04), call("external_api", 0.9))
print(f"параллельно: {(time.perf_counter() - start) * 1000:.0f} мс")
asyncio.run(main())последовательно: 942 мс
параллельно: 902 мсПоследовательно ≈ 940 мс
redis: 0–40 мс, external_api: 40–940 мс. Второй await ждёт завершения первого, длительности складываются.
Параллельно ≈ 900 мс
redis: 0–40 мс, external_api: 0–900 мс. Обе корутины запущены сразу, общее время равно самой долгой.
На водопаде параллельные этапы начинаются в одной точке и перекрываются. Их длительности тоже не складывают: 40 + 900 мс не равно 900 мс операции.
Цепочку этапов, от которой зависит длительность всей операции, называют критическим путём. Если ускорить кэш с 40 до 5 мс, параллельная операция всё равно будет длиться 900 мс: на критическом пути стоит внешний API. По трассе видно, ускорение какого этапа сократит операцию.
Третий случай обратный: сумма этапов оказывается меньше длительности операции.
§6Промежутки между этапами
Иногда между полосами на водопаде остаётся пустое место: родительский этап длится 800 мс, дочерние в сумме 300 мс, и 500 мс ничем не покрыты.
Пустой промежуток означает, что в это время не шёл ни один измеренный этап. Причин несколько. Код выполнял работу, для которой этап не создали. Процесс ждал блокировку. Асинхронная задача ждала своей очереди в цикле событий, потому что цикл был занят синхронной работой.
Как закрыть промежуток измерениями
В трассе отчёта родительский этап build_report длится 800 мс. Внутри него один дочерний этап SELECT rows на 300 мс, остальные 500 мс пустые. Писать в отчёт «база тормозит» нельзя: измеренный SQL быстрый, неизвестен непокрытый участок.
Разработчик добавляет два этапа: render_rows вокруг сборки строк отчёта и write_pdf вокруг записи файла. На каждый участок — одна строка инструментации. После выпуска новой версии следующая трасса показывает картину целиком.
| Этап | Длительность | Что изменилось |
|---|---|---|
build_report | 800 мс | Как и раньше |
SELECT rows | 300 мс | Как и раньше |
render_rows | 480 мс | Был частью пустого промежутка |
write_pdf | 15 мс | Был частью пустого промежутка |
Промежуток сократился с 500 до 5 мс, и место задержки найдено: это сборка строк отчёта. База данных отвечает быстро. С трассами обычно так и работают: пустой участок подсказывает, куда добавить следующий этап.
По одному промежутку причину не определить. Добавляют этапы в подозрительные участки кода или смотрят записи логов, которые попадают в этот интервал по времени и request_id. Точно можно утверждать только одно: текущая инструментация этот участок не покрывает.
До сих пор вся операция выполнялась внутри одного HTTP-запроса. Следующий раздел разбирает операцию, которая продолжается после ответа.
§7HTTP-ответ и фоновая задача
Не вся работа выполняется внутри HTTP-запроса. Отчёт строится несколько секунд, и держать всё это время соединение открытым не нужно. Поэтому маршрут POST /reports кладёт задание в очередь и сразу отвечает кодом 202 Accepted — «принято к обработке». Отчёт строит отдельный процесс-обработчик, когда до задания дойдёт очередь.
Числа придуманы для примера, но соотношение типичное: HTTP-ответ занимает доли секунды, а основное время уходит на очередь и выполнение.
HTTP-запрос длился 40 мс. Задание ещё 1,2 секунды ждало свободный обработчик, а потом строилось 5 секунд. Пользователь получил отчёт через 6,2 секунды после нажатия кнопки, а метрика длительности API показывает 40 мс.
Из этого примера следуют два вывода. Первый: у фоновой операции три разных времени — ответ API, ожидание в очереди и выполнение задания. Каждое отвечает на свой вопрос, и одно не заменяет другое.
Второй вывод касается связи между частями. Фоновую задачу не рисуют внутри этапа HTTP-запроса, как будто запрос её ждал. Идентификатор трассы кладут в сообщение очереди, и обработчик создаёт свои этапы с тем же trace_id.
Когда одна задача обрабатывает сразу несколько сообщений, в OpenTelemetry используют связи между этапами (span links) вместо отношения «родитель — потомок».
Каждая трасса описывает одну операцию. Последний раздел показывает, как по отдельным трассам судить о сервисе в целом.
§8Одна трасса и весь сервис
Трасса описывает одну операцию. Длинный этап в ней показывает, где время потратил этот запрос, но не говорит, как ведут себя остальные.
Поэтому трассы используют вместе с метриками. Метрика сообщает, что p95 вырос и медленными стали 5% запросов. Затем берут несколько трасс медленных запросов и несколько трасс быстрых и сравнивают этапы. Если у всех медленных длинный один и тот же этап, а у быстрых он короткий, место проблемы найдено.
Сэмплирование трасс (sampling) — сохранение только части трасс. Сервис с десятками тысяч запросов в секунду не может хранить трассу каждого, поэтому сохраняет выборку.
По началу операции
Решение принимается в начале, пока результат неизвестен. Требует мало ресурсов, но медленные и ошибочные трассы попадают в выборку с той же вероятностью, что и обычные.
По результату
Решение принимается после завершения операции. Можно сохранить все ошибки и все медленные трассы, но трассы приходится держать в буфере Collector.
Первоисточник: OpenTelemetry · Sampling ↗
§9Частые ошибки
| Что делают | Что получается | Как правильно |
|---|---|---|
| Складывают длительности всех этапов трассы | Сумма больше длительности операции | Учитывать вложенность и считать собственное время этапов |
| Складывают длительности параллельных этапов | Завышенная оценка и неверный выбор, что ускорять | Смотреть перекрытие и критический путь на шкале времени |
| Оставляют один крупный этап «database» | Непонятно, ждали соединение, сеть или SQL | Делить крупные этапы на получение соединения, выполнение и чтение |
| Считают пустой промежуток на шкале ожиданием | Причину приписывают без измерений | Добавить этапы на непокрытый участок или сверить с логами |
| Измеряют фоновую задачу только по ответу API 202 | Задержка очереди и выполнения не видна | Измерять ответ API, ожидание в очереди и выполнение отдельно |
| Считают долю медленных запросов по сохранённым трассам | Сэмплирование искажает пропорции | Долю считать по метрикам, трассы использовать для поиска места |
§10Проверьте себя
Запрос длился 600 мс, дочерний этап загрузки данных — 450 мс, вложенный в него SQL — 400 мс. Каково собственное время этапа загрузки данных?
450 − 400 = 50 мс. Остальные 400 мс приходятся на вложенный SQL-запрос.
Обработчик одновременно вызвал два сервиса: на 300 мс и на 700 мс. Сколько займёт ожидание и ускорение какого вызова его сократит?
Около 700 мс. Сократить ожидание может только ускорение вызова на 700 мс: он лежит на критическом пути.
На снимке медленного запроса сумма этапов совпала с временем обработчика. Что это говорит о коде между этапами?
Код между этапами почти не тратит времени. Все значимые затраты покрыты измеренными этапами.
API ответил 202 за 30 мс, а пользователь получил файл через 2 минуты. Какие измерения помогут найти задержку?
Время ожидания задания в очереди и время выполнения задания обработчиком, связанные с исходным запросом общим идентификатором трассы.
Из 100 сохранённых трасс 80 медленные. Можно ли сказать, что 80% запросов медленные?
Нельзя, если неизвестна стратегия сэмплирования. Медленные трассы могли сохраняться целиком, а обычные — малой долей. Долю медленных считают по метрикам.
§11Шпаргалка
- Трасса — этапы одной операции на общей шкале времени.
- Этап (span): имя, начало, длительность, родитель, атрибуты, статус.
- trace_id общий для операции, span_id свой у каждого этапа.
- Вложенные этапы входят в родительский; их длительности не складывают.
- Параллельные этапы перекрываются; длительность задаёт критический путь.
- Пустой промежуток — участок без измерений. Ожидание он не доказывает.
- Фоновую задачу связывают с запросом через trace_id в сообщении очереди.
- Сэмплирование сохраняет часть трасс; доли считают по метрикам.