Всем привет! Сегодня хочу поделиться с вами нашим опытом ускорения асинхронного микросервиса на Python примерно на 25%. Я расскажу, какие действия мы предпринимали с командой, что помогло, а что оказалось не особенно полезно с точки зрения ускорения сервиса.
Небольшое предисловие: мы в Иви постоянно работаем над тем, чтобы наши сервисы отвечали быстро и их максимальная предельная нагрузка повышалась. В процессе очередного анализа сервисов, мы выяснили, что один из них, о котором пойдет речь в статье, отвечает довольно медленно, учитывая его особенности. И мы решили его ускорять.
О сервисе
Это асинхронный сервис, написанный на Python 3.8. Он агрегирует множество данных, от совсем небольших proto-сообщений, до объемных json из других сервисов Иви, обрабатывает их и на основе этих данных выдает ответ.
Первые шаги
Мы с командой надеялись на быстрое решение, рассчитывая, что обновление версии Python улучшит перфоманс сервиса. Мы хотели соблюсти баланс между затраченными ресурсами и результатом.
Обновление с версии 3.8 давно напрашивалось, и мы решили обновляться до версии Python 3.12. Во-первых, в ней были внесены некоторые оптимизации в интерпретатор, вследствие чего повышалась общая производительность приложений. Во-вторых, один из главных сервисов Иви на тот момент был переведен на эту версию и получил очень хороший буст к производительности. Время ответа снизилось на 10%.
Также мы перешли на использование uvloop вместо стандартного цикла событий asyncio, который использовался нашим uvicorn-сервером. По заверениям авторов uvloop он обещает двухкратный прирост производительности.

Долгожданный релиз, и...прироста не случилось. Ни на процент. Мы поняли, что «легкой» дорога к повышению производительности не выйдет, и начали исследовать дальше, подробно изучать специфику работы сервиса. Бинго! Мы обнаружили, что объем данных, получаемых от других сервисов, колеблется от обычных 10Кб до пиковых 100Кб, и мы, вероятно, тратим значительную часть времени на сериализацию. Проанализировав этот факт, мы решили посмотреть в сторону оптимизации процессов сериализации/десериализации данных.
Итак, на очереди была замена стандартного парсинга с использованием библиотеки json на пакет orjson, который, согласно бенчмаркам, дает прирост скорости сериализации от 50% до 200%. Локальные тесты с помощью утилиты JMeter действительно показывали улучшение производительности, и в этот раз релиза мы ждали с ещё большим оптимизмом. Но, так как статья продолжается, вы понимаете, что переезд на новую библиотеку сериализации нам не помог. Прирост производительности был слишком незначительным, чтобы рапортовать об успехе.
Стоит упомянуть, что мы с командой провели целый ряд нагрузочных тестирований сервиса. После каждого «раунда» мы оптимизировали количество воркеров, используемых одним подом, количество подов, используемых сервером, а также увеличили число ядер и оперативной памяти для пода. Эти действия позволили нам кратно увеличить максимально выдерживаемое количество РПС, но, что неудивительно, никак не повлияли на время ответа.
Еще одним дополнительным исследованием, которое мы проводили, является запуск профайлера, а именно py-spy. Это отличный инструмент для поиска в коде «узких» мест, благодаря которому, мы смогли найти код, работающий неэффективно. Например, сервис, применяя бизнес-логику, должен открывать файл и читать оттуда некоторые данные, которые являются постоянными. В текущей реализации, приложение открывало и читало файл на каждый новый запрос вместо того, чтобы прочитать файл один раз при старте и сохранить данные в памяти. К сожалению, влияние этих мест на общее время ответа было минимальным, но опыт поиска был полезен.
Телеметрия — ключ к успеху
На просторах Интернета можно найти множество статей о важности мониторинга и сквозного наблюдения (observability) микросервисов. Мы пишем огромное количество важных метрик, настраиваем триггеры, мониторинги, детекторы аномалий для повышения наблюдаемости наших сервисов. Также все наши сервисы переходят на сквозное наблюдение. Теперь можно полностью отследить запрос от пользователя до конкретного микросервиса. В этом качестве мы используем opentelemetry для экспорта трейсов и Jaeger для их мониторинга.
Мы решили рассмотреть телеметрию как инструмент поиска узких мест. Запросы внешних сервисов отвечали согласно своим нормальным показателям, а проблем в коде приложения, где могло быть заметно замедление, как было описано выше, мы не нашли.
К сожалению, наше приложение не прошло знаменитый Утиный тест. Иногда то, что выглядит как утка, крякает как утка, на самом деле не утка. Так было и с нашим асинхронным приложением. Казалось, что использование асинхронных запросов во внешние сервисы, объединение нескольких запросов с помощью asyncio.gather()
делает наш сервис полностью асинхронным. Каково же было наше удивление, когда, взглянув на трейсы запросов нашего приложения, мы обнаружили, что часть запросов выполняется последовательно. Причем эти запросы, в основном, были самыми «тяжелыми» и долгими. Нас осенило, что проблема времени ответа сервиса состоит в том, что при некоторых запросах наш сервис не параллелит походы в сеть.

Как же так получилось?
Наверное, каждый разработчик, работавший на больших проектах, сталкивался со сложной бизнес-логикой приложения. Наша команда не стала исключением.
Наш сервис действительно был асинхронным, отправлял часть запросов во внешние сервисы с использованием asyncio.gather()
, но не учитывал что некоторые из запросов можно не отправлять при определенном наборе параметров, а для некоторых запросов не нужно дожидаться ответа предыдущей пачки.
С помощью телеметрии мы собрали для анализа полную картину прохождения вызовов во внешние сервисы.
Особое внимание мы уделили «тяжелым запросам» — искали возможность вызывать внешний сервис как можно раньше (ближе к моменту получения запроса в наш сервис).
Также мы анализировали вызовы во внешние сервисы, которые опираются на результаты предыдущих запросов — рассматривали возможность вместо последовательных вызовов, делать их конкурентно и отбросить лишний запрос в конце.
В итоге у нас получилось собрать значительную часть самых долгих вызовов внешних сервисов в пачку, которую мы запрашиваем асинхронно при получении запроса в наш сервис.
Важно понимать, что такая схема может принести результат только в случае, если запросы выполняются примерно за одно и то же время.

Неочевидные моменты библиотеки aiohttp
Но это еще не все. Проводя нагрузочные тестирования сервиса, мы обнаружили в логах сообщения о таймаутах вызовов внешних сервисов, при этом сам внешний сервис, по своим собственным показателям, нашей нагрузки совершенно не почувствовал и его время ответа не выходило за пределы допустимого. Это верный признак того, что асинхронный сервис не справляется с нагрузкой. Немного поразмыслив, мы решили «подкрутить» настройки популярной библиотеки для асинхронных запросов в Python aiohttp. У данной библиотеки есть механизм коннекторов — способов отправки HTTP-запросов. По умолчанию используется TCPConnector, в настройках которого есть один очень интересный параметр limit. Этот параметр может больно ударить по нагруженному сервису, который отправляет множество асинхронных запросов, ведь он регулирует число одновременно открытых соединений и установлен по умолчанию в значение 100. Обращайте внимание на этот параметр в своих приложениях, он может стать тем самым «бутылочным горлышком». Примерно рассчитать, какое значение лимита вам нужно установить на одну сессию, можно по формуле
(RPS / количество воркеров) * количество запросов в сессии
Вот как это выглядит в коде:
connector = TCPConnector(family=socket.AF_INET, limit=1000,)
client = aiohttp.ClientSession(connector=connector,)
Итог
Наша формула успеха была такова:
1) Глубокий анализ потока запроса, который включал в себя телеметрию в виде библиотеки opentelemetry, и интерфейса для просмотра трейсов jaeger, профайлинг с помощью py-spy
2) Перенос тяжелых запросов, если это позволяет логика приложения, в так называемую функцию префетча, которая еще до начала обработки команды сделает пачку асинхронных запросов и закэширует их для дальнейшего использования бизнес-логикой.
3) Кастомизация параметров в библиотеке aiohttp для увеличения числа конкурентно работающих запросов
Это простое на первый взгляд решение и дало нам желаемое уменьшение времени ответа c 300ms до 220ms или на 27%.

На что стоит обратить внимание
Итоговый чек-лист нетрудоемкого ускорения сервисов на Python можно изобразить так.

Выводы
Оптимизация производительности — это не просто обновление версий библиотек или языка. Важно понимать архитектуру сервиса, его реальные узкие места и применять точечные улучшения. В нашем случае только детальный анализ потока запросов позволил выявить узкое место и устранить его. Итог — сокращение времени ответа сервиса на 27% (с 300ms до 220ms) без кардинального переписывания кода.