Как искать проблемы производительности в Python

У каждого бывают ситуации, когда сервис в проде начинает тормозить. Клиенты жалуются, дашборды пестрят красными графиками, а перед глазами открывается бездна метрик, логов и трейсов. В этот момент возникает непреодолимое желание открыть профайлер и начать оптимизировать наугад. И это главная ошибка.

Меня зовут Юра Ашуров, я тимлид в Точка Банк. В статье расскажу, почему не стоит начинать расследование с профайлера и какие метрики помогают сузить область поиска. Всё это — на примере двух реальных кейсов с высоконагруженными проектами.

Почему нельзя начинать с профайлера

Профайлер — мощный инструмент, но при неправильном применении часто становится генератором ложных гипотез. Он показывает только то, что происходит внутри кода, но не объясняет, почему система стала работать хуже. В такой ситуации легко начать оптимизировать не то, что нужно.

В результате мы: 

  • Тратим лишнее время. Пока ищем проблему вслепую, ситуация может измениться: очередь успеет разгрузиться, аномальный трафик исчезнет, проблемный под перезапустится. В итоге мы потеряем окно, в которое проблему можно было легко диагностировать.

  • Усложняем архитектуру без гарантии результата. Часто, чтобы получить временное ускорение, добавляют кэш. Но это не бесплатная оптимизация: кэш нужно поддерживать, учитывать в новой логике и использовать, только если нам позволительно показывать устаревшие данные. Более того, подобные улучшения могут лишь скрыть реальную проблему, система станет сложнее, а причина деградации останется. 

  • Запускаем лишний инженерный цикл. Любое изменение требует ресурсов команды. Нужно написать код, провести ревью, протестировать, выкатить в продакшен, поддерживать. Если мы не знаем точно, в чём причина, можно потратить ресурсы всей команды зря.

  • Можем прийти к ложным выводам. Иногда получается ускорить отдельную функцию или показатель, но общая проблема при этом остаётся. 

Главное правило, которое мы вывели: нельзя оптимизировать код, пока не найдена конкретная метрика, которая деградировала. Сначала нужно понять, что именно сломалось, затем — найти причину, и только после этого выбирать инструмент и вносить изменения. 

С чего начинается любое расследование

Чтобы не гадать на кофейной гуще, можно использовать простую формулу: 

Симптом → Scope → Гипотеза → Инструмент → Фикс → Проверка

Разберём подробнее, как это работает:

  1. Поиск симптомов. Пока жалоба не превратилась в измеримый показатель, оптимизировать просто нечего. Поэтому ищем конкретную метрику, которая деградировала. В идеале иметь OpenTelemetry или аналогичный стек. Если ваш сервис не собирает вообще никаких метрик, придётся обойтись логированием, хотя будет не очень удобно, и время на поиск конкретной деградации явно увеличится.

  2. Сужение области поиска (Scope). Определяем, где именно возникает деградация. Наборы метрик помогают быстро отсечь лишнее и понять направление поиска. 

  3. Построение гипотезы. Предполагаем, что именно стало причиной проблем. Важно не менять этапы местами: гипотеза должна строиться на основе метрик, а не наоборот. Иначе есть риск искать проблему совсем не там. 

  4. Подтверждение гипотезы инструментом. Только теперь стоит использовать профайлер, трассировку и другие специализированные инструменты. То есть они нужны не для того, чтобы искать проблему «в целом», а чтобы подтвердить или опровергнуть уже сформулированную гипотезу.

Сигнал

Корреляция / гипотеза

Инструмент

Первый шаг

P95 вырос на одном route

Внешний N+1, SQL, serialization

Trace + route dashboard

Найти самый дорогой span

SQL быстрый, app span большой

Hydration / serialization

Trace + py.spy / Scalene

Разделить hydration / validation / serialization

ACK rate ниже publish rate

Consumer стал медленнее

Queue dashboard

Смотреть handler duration

Event loop lag растёт

CPU или sync IO внутри async

Event loop lag + spans + profiler

Разделить CPU и sync IO

Working set растёт ступеньками

Batch копит данные

Memory graph + memray

Связать рост с batch/job size

Pool wait выше query time

Не хватает соединений

DB pool metrics + traces

Проверить transaction duration

  1. Внесение изменений. Устраняем «узкие места». Лучше делать это поэтапно: если объединить несколько оптимизаций в один релиз, будет сложно понять, какое изменение действительно помогло.

  2. Проверка результата. После релиза снова смотрим на ту же метрику, с которой начиналось расследование. Если она улучшилась, значит, гипотеза оказалась верной. 

На словах схема выглядит простой, но на практике расследование редко заканчивается после первого исправления. Устранив одно узкое место, можно обнаружить следующее. Поэтому после каждого изменения важно заново возвращаться к метрикам и повторять цикл, пока проблема не будет решена полностью.

Какие метрики должны быть у сервиса

Чтобы быстро определить, где именно произошел сбой, понадобится шесть групп метрик.

Метрики

Что показывают

Задержки

Пользователь действительно стал ждать дольше?

Проходимость

Система стала выполнять меньше работы?

Ошибки

Запросы перестали завершаться успешно?

Очереди

Запросы или сообщения начали накапливаться, потому что система не успевает их обрабатывать? 

Сатурация

Сервис упёрся в ограничения по CPU, памяти, диску или другим ресурсам?

Рестарты

Ухудшение связано с падением процессов или нехваткой ресурсов?

Такое разделение помогает не распыляться на всё сразу. Вместо того, чтобы одновременно смотреть на десятки графиков, нужно всего лишь последовательно ответить на три вопроса:

  • Что: какая метрика деградировала?

  • Где: в какой части мы замечаем деградацию?

  • Почему: с чем коррелируют симптомы?

Только после этого можно переходить к поиску конкретной строки кода. Рассмотрим на практике, как это работает.

Кейс №1. Медленная карточка товара

Представим типичную ситуацию — от бизнеса приходит жалоба: «Карточка товара стала открываться медленно». Для пользователя этого описания достаточно. Для инженера — нет.

Во-первых, слово «медленно» нельзя измерить. Во-вторых, совершенно непонятно, где искать причину. Медленнее стал весь сервис? Только один эндпоинт? Виновата база данных или внешнее API?  

Чтобы разобраться, действуем по нашей формуле:

1. Проверяем, что именно деградировало.

Смотрим на дашборды и видим следующую картину:

  • вырос 95-й процентиль времени ответа c 300 ms до 1800 ms;

  • RPS практически не изменился;

  • количество ошибок (5xx rate) осталось прежним;

  • очереди запросов не увеличились.

Из этого уже можно сделать вывод, что проблема не у одного конкретного юзера, а общая. 

2. Сужаем область поиска деградации.

Если бы проблема затрагивала в целом сервис или инфраструктуру, мы бы увидели другой набор симптомов: просадки на всех путях сервиса, общий рост нагрузки, возможно, выросший RPS. Но этого нет, значит, проблема локальная. Скорее всего, медленнее стал один конкретный путь обработки запроса. Именно его и стоит исследовать дальше. 

3. Смотрим request path.

Открываем trace медленного запроса и изучаем:

  • Payload;

  • SQL спаны;

  • Внешние вызовы;

  • Бизнес логику / обработку данных.

Как искать проблемы производительности в Python - 1

Трассировка показывает, что значительную часть времени запрос проводит в одном сервисе обогащения данных (enrichment). Внутри него множество одинаковых последовательных вызовов внешнего API. 

Запрос отправляется, приходит ответ, затем отправляется следующий запрос. На таймлайне это выглядит как длинная цепочка одинаковых операций, которые постоянно ждут завершения предыдущей задачи.

4. Выдвигаем гипотезу.

Здесь уже можно предположить, что проблема похожа на классический N+1. Обычно такое случается при работе с ORM (но и самостоятельно несложно написать такой SQL-запрос).

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

attrs_by_company = {}

for company_id, external_ids in
 external_ids_by_company.items():
    attrs_by_company[company_id] =
await attrs_client.get_attrs_by_ids(
        company_id=company_id,
        external_ids=list(external_ids),
    )

5. Вносим изменения.

Решить эту проблему можно разными способами: 

  • Batch API: можно сократить количество сетевых обращений и отправить один запрос со списком объектов вместо множества отдельных. Но такую возможность предоставляет далеко не каждый API.

  • Контролируемая конкурентность: если пакетных запросов нет, можно выполнять обращения параллельно. Но важно не отправлять сразу сотни запросов — используйте семафоры, таймауты и лимиты на количество одновременно выполняемых операций, чтобы не перегрузить внешний сервис и не оказаться в ситуации постоянного ожидания рейт-лимитов.

  • Кэширование: подходит в случаях, когда одни и те же данные запрашиваются повторно. Но кэш не должен быть универсальным способом ускорения. Его стоит добавлять только, когда он действительно устраняет найденное узкое место, а не просто маскирует проблему, при этом создавая новые инженерные риски.

В нашем случае мы решили распараллелить обращения к внешнему сервису.

6. Проверяем метрики.

Снова открываем график 95-го процентиля. Видим, что стало лучше, но не настолько, как ожидалось. Значит, найдена лишь одна из причин деградации, но не единственная.

Как искать проблемы производительности в Python - 2

7. Снова смотрим трассировку.

После устранения первого узкого места профиль выполнения изменился. Работа с данными после SQL-запроса занимала заметное время и раньше, но на фоне предыдущей проблемы не доминировала в общем времени выполнения. 

Теперь трейс показывает, что сам SQL выполняется быстро, а основная часть времени уходит на обработку полученного результата в Python. Значит, именно этот этап становится главным кандидатом для дальнейшей оптимизации.

q = select(Product).where(Product.id == pk)

if with_offers:
    q = q.options(joinedload(Product.offers))   # collection

if with_stocks:
    q = q.options(joinedload(Product.stocks))   # collection

При этом узким местом оказалась не база данных, а гидрация результата на стороне ORM. Для БД вернуть несколько тысяч строк по простому индексированному запросу обычно недорого. Но для ORM каждая строка должна быть прочитана драйвером, преобразована из протокольного представления в Python-объекты и сопоставлена с моделями.

ORM проверяет identity map, чтобы одной записи базы соответствовал один экземпляр сущности внутри сессии, определяет, нужно ли создать новый объект или использовать уже существующий, заполняет его атрибуты и добавляет связанные сущности в коллекции. 

При joinedload() коллекций одна и та же родительская сущность повторяется во множестве строк, поэтому эти действия частично повторяются для каждой строки, даже если новых уникальных объектов почти не появляется.

Это потенциальная проблема любой ORM с identity map и материализацией объектного графа, а не особенность SQLAlchemy. База быстро возвращает плоский набор строк, но ORM должна восстановить из него уникальные объекты и связи между ними.

Для проверки данной гипотезы удобно использовать профайлер, например scalene.

Решение было очевидное: для коллекций joinedload() заменили на selectinload(). Родительские объекты и связанные коллекции теперь загружаются отдельными запросами. Запросов стало больше, но исчезло перемножение строк: каждая сущность передаётся и гидратируется примерно один раз. 

8. Смотрим метрики.

После второго исправления картина меняется. 95-й процентиль становится лучше, но проблема всё ещё не исчезает полностью. Поэтому продолжаем поиски.

Как искать проблемы производительности в Python - 3

9. Возвращаемся в трейс.

def parse_product_snapshot (
    value: dict[str, Any] | list[Any] | str                        
    ) -> dict[str,Any] | list[Any] :
    if isinstance(value, (list, dict)):
        return value
    return json.loads(value)


  for item in items:
      product = parse_product_snapshot(item.product)
      response.append(
          ProductDTO(product=product).model_dump()
      )

Теперь видим, что большая часть времени уходит на преобразование данных внутри приложения. Каждый шаг по-отдельности не занимает много времени. Но при сотнях или тысячах объектах в запросе, стоимость преобразований становится ощутимой.

Что можно сделать в этой ситуации:

  • Убрать лишние преобразования — отказаться от повторной сериализации, создания промежуточных моделей и других операций, которые не несут ценности. 

  • Уменьшить Payload — чем меньше данных проходит через пайплайн, тем дешевле их обработка.

  • Взять более быстрый encoder — например, заменить стандартный json на orjson. Но в некоторых случаях это просто перекладывает нагрузку из CPU в память.

В нашем случае я предложил использовать замороженный dataclass для хранения уже типизированных данных и самописное приведение к словарю.. По сравнению с Pydantic такая модель требует меньше расходов.

@dataclass(frozen=True, slots=True)
class ProductCardDTO:
   id: int
   title: str
   badges: tuple[str, ...]
   price: int

   def to_dict(self) -> dict:

        return {
           "id": self.id,
           "title": self.title,
           "badges": self.badges,
           "price": self.price,
        }

payload = [card.to_dict() for card in cards]

10. Проверяем метрики.

После исправления третьей проблемы снова возвращаемся к графику. Наш 95-й персентиль вернулся к прежнему уровню. Именно таким должен быть закономерный финал любого кейса.

Как искать проблемы производительности в Python - 4

Как мы видим, в данном случае, потребовалось три итерации расследования. Если бы команда пыталась оптимизировать всё сразу, разобраться, какой именно фикс принёс результат, было бы практически невозможно. Поэтому важно соблюдать принцип: одно изменение — одна проверка.

Кейс №2. Растущая очередь и падающий консьюмер 

Второй кейс связан с асинхронной обработкой. В системе есть очередь, в которую попадают задачи на печать. В какой-то момент пользователи начинают жаловаться, что ждут документы по 30–40 минут. 

Действуем по тому же принципу:

1. Смотрим метрики.

На дашборде очереди видно сразу несколько тревожных признаков:

  • acknowledge rate снижается;

  • message ready вырос до 40 минут;

  • процессинг (p95) увеличился с 2 до 12 секунд;

  • consumer count — без изменений.

Этого достаточно, чтобы сделать важный вывод: проблема не в продьюсере — сообщения поступают как обычно. Проблема находится именно на стороне обработки: консьюмеры перестали успевать разбирать очередь. 

2. Сужаем поиск.

В трейсе видим функцию, на которую приходится около 90% времени обработки сообщений, но потенциальных проблем в ней сразу несколько.

async def handle_message(msg):
    postings = msg.payload["postings"]

    # 1. CPU: sync-валидация всего batch
    validated = [
        model_validate_date(Dto, p)
        for p in postings
]

    # 2. Dependency: внешний сервис документов
    docs = await handler.get_documents(validated)

    # 3. Batch: весь результат одной порцией
    await enrich_and_save(validated, docs)

На этом этапе делать выводы «на глаз» опасно. Вместо этого формулируем несколько гипотез и для каждой подбираем подходящий инструмент.

Гипотеза

Что делаем

Внешняя зависимость отвечает слишком долго.

Измеряем время ответа.

Большая часть времени уходит на вычисления внутри Python.

Используем профайлер.

Консьюмер получает слишком большой пакет данных и просто не успевает его обработать.

Подключаем профайлер и смотрим, что происходит с потреблением RAM.

3. Используем инструменты.

Проверка подтверждает гипотезу:

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

  • Длительная обработка блокирует event loop. Пока coroutine занята вычислениями, цикл событий не может переключиться на выполнение других задач.

  • Начинают расти технические метрики: увеличиваются p95 и event loop lag.

В результате страдает уже не только конкретный обработчик. Сообщения начинают обрабатываться медленнее, очередь растёт, а из-за задержек с отправкой heartbeat брокер считает консьюмер зависшим.

async def handle_message(message):
    postings = message.payload["postings"]

    # sync block
    validated_postings = [

model_validate_data(CreateOrUpdatePostingExternalDto, posting)
        for posting in postings
    ]

# 300 items → 300 sync calls → no yield

4. Решаем проблему.

Самый очевидный способ — отказаться от обработки всего объёма данных за один проход. Вместо этого разбиваем его на чанки и обрабатываем их последовательно.

Благодаря этому:

  • Сокращается время непрерывной обработки. Консьюмер не «зависает» на одном огромном задании и быстрее завершает отдельные этапы работы.

  • Снижается нагрузка на память. После обработки очередного чанка промежуточные данные можно освободить, не дожидаясь завершения всей задачи.

  • Очередь движется быстрее. Даже если общий объем работы не изменился, сообщения обрабатываются более равномерно. Риск переполнения очереди и падения консьюмеров становится ниже.

5. Возвращаемся к метрикам.

После внесения изменений проверяем не только первоначальные показатели, но и дополнительные — например, heartbeat, retry и другие метрики, которые могли пострадать из-за изменения логики обработки.

Мы видим, что очередь начинает разбираться быстрее. Но поды по-прежнему периодически перезапускаются. Это значит, что первоначальная гипотеза объясняла только часть происходящего.

Как искать проблемы производительности в Python - 5

6. Снова открываем трассировку.

Теперь мы видим, что значительное время уходит на сохранение большого объёма данных. Причем само сохранение выглядит асинхронным и не вызывает подозрений. 

Перед записью приложение постепенно собирает всё новые и новые данные. Каждый следующий этап добавляет очередную коллекцию объектов. В какой-то момент объём занимаемой памяти становится настолько большим, что контейнер просто завершается по OOM.

async def enrich_and_save(self):
    await self._collect_orders()             # self.orders
    await self._collect_order_items()        # self.items
    await self._collect_warehouses()         #
self.warehouses
    await self._collect_shipments()          # self.shipments
    await self._collect_source_products()    # self.products
    self._prepare_data()                     # more dicts / workbook

7. Решаем проблему.

Уменьшить потребление памяти можно разными способами:

  • Обрабатывать данные чанками: не загружать в память весь объём сразу, а работать небольшими порциями: получили данные → обработали → записали → освободили память → перешли к следующему чанку.

  • Использовать потоковую запись: формировать и записывать файл последовательно, не собирая его целиком в памяти. По мере записи промежуточные объекты, которые больше не используются, могут быть освобождены, благодаря чему потребление памяти не растёт пропорционально размеру итогового файла.

  • Сокращать время жизни объектов и ресурсов: своевременно закрывать сессии и файлы, освобождать крупные буферы и не удерживать ссылки на больше ненужные коллекции. Это снижает пиковое потребление памяти и риск OOM-перезапусков.

В нашем случае мы выбрали потоковую запись, поскольку результат должен формироваться как единый документ. Разбиение обработки между несколькими задачами очереди потребовало бы дополнительной координации, сохранения порядка данных и последующей сборки частей, что усложнило бы решение без заметной пользы.

8. Проверяем метрики.

После внедрения изменений проверяем те же метрики, с которых начиналось расследование. График потребления памяти перестал расти, поды больше не перезапускаются, а время обработки сообщений не увеличилось — значит проблема действительно устранена.

Как искать проблемы производительности в Python - 6

Важные выводы

Эти кейсы сильно отличаются друг от друга. В первом случае проблема была в HTTP-запросе, во втором — в консьюмере очереди. Но если посмотреть на процесс целиком, то последовательность действий была примерно одинаковой: сначала нужно понять, что деградировало, затем — где и почему. 

Такой подход требует больше дисциплины, зато помогает не тратить время на лишние оптимизации и не усложнять систему без необходимости. 

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

Автор: iness0

Источник

Оставить комментарий