gRPC давно зарекомендовал себя как один из самых производительных и надёжных протоколов для межсервисного взаимодействия. Его основание на HTTP/2 и поддержка двунаправленных стримов делают его популярным среди разработчиков распределённых систем и высоконагруженных сервисов. Однако в недавних исследованиях, проведённых инженерами YDB, была выявлена неожиданная проблема, связанная с производительностью клиента в условиях низкой сетевой задержки. Эта проблема проявляется особенно ярко при высоких нагрузках на малом количестве серверных узлов, когда становится очевидно, что узким местом оказывается не сервер, а именно клиентская часть gRPC. В данной статье рассмотрим суть проблемы, детали экспериментов, а также практические советы по устранению данных ограничений, что имеет ключевое значение для оптимизации высокопроизводительных распределённых приложений.
YDB — это открытая распределённая база данных SQL, которая сочетает в себе высокую доступность, масштабируемость и поддержку строгой согласованности с ACID-транзакциями. В YDB API для взаимодействия с базой реализован именно с помощью gRPC. Для нагрузочного тестирования активно используются gRPC клиенты. Во время анализа производительности было замечено, что при уменьшении числа кластерных узлов нагрузка на них падает намного сильнее ожидаемого. Одновременно с этим наблюдался значительный рост задержек на стороне клиента, несмотря на наличие свободных ресурсов в кластере.
Дальнейшее расследование показало, что корень проблемы кроется именно в архитектуре и поведении gRPC клиента. Чтобы лучше понять природу проблемы, реализовали простой микробенчмарк на C++, который воспроизводит RPC-запросы типа ping с помощью современных возможностей gRPC, используя асинхронное API на стороне сервера и синхронное на клиенте. Тестирование было проведено на двух мощных физических машинах с многопроцессорными шестиядерными CPU, связанными между собой высокоскоростной сетью с пропускной способностью 50 Гбит/с и минимальными задержками порядка 0.04 миллисекунд. Это позволило минимизировать влияние сетевых факторов и сосредоточиться именно на поведении программной части.
Реакция сервера показала себя исключительно быстрой и стабильной: настройка 8 очередей завершения и 2 рабочих потока на каждую очередь позволила эффективно использовать ресурсы ЦПУ. Однако при запуске клиентов с разным числом параллельных запросов было замечено, что увеличение количества одновременно «висячих» запросов (in-flight) не приводит к ожидаемому линейно увеличению пропускной способности. Вместо этого рост производительности значительно замедлялся, а среднее время отклика клиента возрастало нелинейно. То есть, несмотря на минимальную нагрузку на сам сервер и отличная сеть, клиент gRPC проявлял «затык». Дальнейший анализ с помощью инструментов сетевого мониторинга показал, что, несмотря на многопоточность клиента, все запросы проходят по одному TCP-соединению.
Это связано с особенностью работы gRPC поверх HTTP/2, где единственный TCP-сокет мультиплексирует множество потоков RPC. Аппаратно в сети и ОС не наблюдалось проблем с пропускной способностью, TCP окно было адекватно установлено, задержки подтверждались как минимальные, а задержки на уровне TCP показывали активные паузы порядка 150-200 микросекунд между приёмом ответа и отправкой нового запроса. Таким образом, было выделено, что именно gRPC клиент создаёт задержки в момент ожидания свободных потоков HTTP/2 для новых запросов внутри одного TCP соединения. По умолчанию большинство реализаций gRPC накладывает ограничение на количество одновременных потоков внутри соединения — 100, а многие приложения используют гораздо меньше параллельных запросов на канал. Дополнительное усложнение добавляет попытка gRPC оптимизировать соединения, посредством повторного использования TCP соединений для разных каналов, если параметры каналов совпадают.
В результате все параллельные запросы проходят через одну коммутационную точку, что вызывает массовую конкуренцию и очередности внутри клиента. Официальная документация gRPC описывает две рекомендации для решения подобных ситуаций: создавать раздельные каналы для разных «физических» или логических областей нагрузки приложения либо использовать пул каналов с разными параметрами, чтобы они не делили один и тот же TCP сокет. Проведённые тесты в YDB показали, что и первый, и второй подходы по отдельности не дают значительного выигрыша, но их объединение — когда каждый воркер клиента работает со своим каналом, при этом каналы создаются с уникальными аргументами, принуждающими gRPC к созданию отдельных подканалов и TCP соединений — позволяет решить проблему. В эксперименте с применением «локального пула подканалов» (GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL) клиенты получили до шестикратного увеличения пропускной способности без резкого роста задержек при увеличении числа одновременных запросов на канал. Вместо линейного роста задержек наблюдалось лишь незначительное увеличение, что свидетельствует о значительном снижении внутренней конкуренции и улучшении масштабируемости клиента.
Интересно, что данное узкое место особенно заметно именно в условиях низкой сетевой задержки: при искусственном увеличении задержек сети до 5 миллисекунд узкое место исчезало, а мультиканальное решение давало лишь незначительную выгоду при больших нагрузках. Это даёт представление, что при высокопроизводительных сетях с низкими задержками внутри датацентров или виртуальных кластеров внутренние клиентские ограничения могут стать критичным фактором производительности, и их нужно устранять независимо от серверных оптимизаций. Результаты этих исследований имеют важное значение для разработчиков распределённых систем, где используются gRPC-протоколы. В частности, выявление и понимание внутренней архитектурной особенности gRPC, связанной с мультиплексированием потоков в одном TCP соединении и лимитами по количеству активных потоков HTTP/2, позволяют точечно направлять усилия на создание уникальных каналов с отдельными соединениями для каждого параллельного клиента или задачи. Подобный подход стоит учитывать при проектировании нагрузочного тестирования, организации взаимодействия микросервисов и построении систем с требованием минимальной задержки, особенно в финансовых сервисах, телекоммуникациях или высокочастотной торговле.
Обход клиентского узкого места gRPC снижает суммарное влияние сетевых и программных задержек и позволяет максимально использовать потенциал современного железа и сетевых интерфейсов. Стоит отметить, что пока исследование не исчерпывает всех возможных оптимизаций, оставляя простор для поиска дополнительных улучшений на уровне gRPC библиотек или системных настроек. Разработчикам и инженерам рекомендуется внимательно отслеживать схемы соединений, распределение каналов и характеристики сетевых потоков, а также использовать последние версии библиотек gRPC, учитывая их эволюцию в разработке. В заключение можно подчеркнуть, что выявленное узкое место клиента gRPC в сетях с низкой задержкой – уникальный и важный вызов для высокопроизводительных приложений. Его преодоление возможно при правильной стратегии распределения каналов и соединений, что позволяет добиться значительного повышения пропускной способности и снижения клиентских задержек.
Это иллюстрирует известный тезис о том, что оптимизация должна быть сосредоточена на реальном узком месте, а не на случайных компонентах системы. Благодаря глубокому техническому анализу и практическим рекомендациям, разработчики могут оптимизировать архитектуру gRPC клиентов и вывести эффективность своих систем на новый уровень.