В последние годы gRPC стал одним из наиболее популярных протоколов для межсервисного взаимодействия благодаря своей производительности, поддержке HTTP/2 и удобству интеграции в современных распределённых системах. Несмотря на его распространённое использование и репутацию устойчивого и быстрого решения, разработчики YDB.tech столкнулись с неожиданной проблемой, которая выявила узкое место именно на стороне клиента gRPC, особенно в сетях с низкой задержкой. Это открытие не только повлияло на внутренние показатели, но и стало важным сигналом для всего сообщества, использующего gRPC в схожих условиях. В данной статье разберёмся в причинах проблемы, отметим особенности её проявления и рассмотрим рабочие методы её решения.
Современные распределённые системы и базы данных, такие как YDB — открытая распределённая SQL-база, ориентированная на высокую доступность, масштабируемость и строгую согласованность с поддержкой полноценных ACID-транзакций, активно используют gRPC для предоставления API клиентам. Инженеры YDB регулярно проводят нагрузочное тестирование с помощью генераторов нагрузки, выступающих в роли gRPC-клиентов. При уменьшении размера кластера они заметили странную закономерность – несмотря на уменьшение узлов, нагрузка на кластер была ниже ожидаемой, а при этом задержка на клиенте постоянно росла. При анализе сетевых пакетов и системных показателей выяснилось, что проблема вовсе не в сети или сервере, а на стороне клиента. gRPC, являясь надстройкой над HTTP/2, использует каналы для установления TCP-соединений с сервером.
Каждый канал может обслуживать множество одновременных RPC-вызовов, при этом HTTP/2 мультиплексирует эти потоки в одном TCP-соединении. На практике gRPC ограничивает количество параллельных потоков на соединение по умолчанию примерно в 100. Если нагрузка слишком высока, дополнительные запросы оказываются в очереди, ожидая освобождения потоков. В документации рекомендуют два подхода для обхода данной лимитации: создавать отдельные каналы для каждой высоконагруженной части приложения или использовать пул каналов с уникальными настройками для распределения нагрузки. Однако в контексте YDB представленная проблема оказалась более глубокая – обе рекомендации представляли собой фактически один и тот же шаг по устранению узкого места.
Для проверки гипотезы была разработана простая тестовая программа на C++, реализующая ping-подобный gRPC-бенчмарк с асинхронным сервером и синхронным клиентом. Сервер и клиент развернули на мощных физических серверах с процессорами Intel Xeon и подключением в 50 Гбит/с внутри локальной сети с минимальной задержкой (около 0.04 мс при ping). Ожидалось, что низкая сетевая латентность позволит достигать очень высокой пропускной способности и низких задержек. Тем не менее, эксперименты показали снижение производительности при увеличении числа параллельных запросов, а также рост клиентской задержки.
Анализ трафика с помощью tcpdump и Wireshark выявил, что в несмотря на то, что сервер отвечает быстро и сеть практически не являлась узким местом, клиентские запросы отправляются пачками с заметными паузами между ними. При этом была задействована лишь одна TCP-сессия, что ограничивало параллелизм и приводило к очередям внутри gRPC. Эксперименты с каналами, созданными для каждого рабочего потока, но имеющими одинаковые настройки и, следовательно, разделяющими TCP-соединение, не давали улучшения. Решением стала настройка каждого канала с уникальными аргументами, что заставило gRPC создавать отдельные TCP-соединения для каждого канала, или активация флага GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, обеспечивающего локальное распределение. После внесения корректировок результаты стали значительно лучше — производительность выросла почти в 6 раз при обычных RPC и в 4.
5 раза при использовании стриминговых вызовов. Одновременно рост латентности при увеличении числа параллельных запросов стал значительно более плавным. Это свидетельствует о том, что именно ограничение на количество одновременных потоков в одном HTTP/2-соединении и стандартизированный пул каналов gRPC создавали узкое место на клиенте. Интересно, что в сетях с более высокой задержкой — например, порядка 5 миллисекунд, — проблема практически не проявлялась. Там добавление дополнительных каналов приводило лишь к незначительному повышению производительности при большой нагрузке, а базовая производительность при использовании одного соединения была достаточно высокой.
Это объясняется тем, что в сетях с большей латентностью сетевые задержки доминируют над внутренними механизмами мультиплексирования, и очереди уменьшают влияние ограничений по потокам. Таким образом, в средах с низкой сетевой задержкой архитектурные особенности реализации gRPC клиента могут ограничивать производительность системы. Очевидный фактор – максимум потоков в HTTP/2 соединении – приводит к тому, что несмотря на быстрое сетевое взаимодействие и высокий потенциал многопоточности, на практике приложение ограничивается одним соединением, что создает бутылочное горлышко. Правильное распределение нагрузки с созданием множества каналов с уникальными аргументами или использование локального пула подканалов позволяет эффективно использовать возможности сети и процессора. На практике это значит, что разработчики систем с высокими требованиями к производительности и латентности должны внимательно подходить к настройке gRPC клиентов.
Не всегда стандартные рекомендации документации позволяют избежать проблем, нужно проводить собственные тесты с реальными сценариями, учитывая специфику сети и архитектуры приложения. В некоторых случаях может оказаться полезным не только раздельное использование каналов, но и тонкая настройка параметров пулов, потоков завершения и времени ожидания. Исследование команды YDB.tech показало, что подобное узкое место встречается не только в C++ реализации gRPC, но выявлено также на Java клиенте, а значит может быть актуально для большинства реализаций. Вдобавок, они опубликовали микробенчмарк с открытым исходным кодом, чтобы другие специалисты могли воспроизвести проблему и проверить собственные гипотезы по оптимизации.
В общественном обсуждении и совместных проверках заинтересованы многие разработчики высоконагруженных систем и облачных сервисов. В заключение можно отметить, что высокая сеть с низкой задержкой не всегда гарантирует высокую производительность при использовании широко распространённых RPC-фреймворков. Нельзя игнорировать внутренние ограничения протоколов и библиотек при масштабировании нагрузки. S оптимальной настройкой gRPC клиента и правильным управлением количеством TCP-соединений можно существенно увеличить пропускную способность, снизить латентность и избежать простаивающих ресурсов. Это улучшит пользовательский опыт, позволит более эффективно использовать аппаратные мощности и ускорит развитие распределённых облачных систем.
Разработчикам рекомендуется проводить нагрузочное тестирование в условиях приближенных к боевым, использовать уникальные аргументы при создании каналов в gRPC клиенте и при необходимости включать локальные пулы подканалов. Также полезно следить за обновлениями в gRPC, так как сообщество постоянно работает над повышением эффективности и устранением подобных узких мест. Понимание и устранение таких узких мест помогает создавать более устойчивые и масштабируемые сервисы, что является важным фактором успеха на современном рынке технологий. Опыт YDB.tech демонстрирует, как глубокий анализ и внимательное изучение клиентской части сетевого взаимодействия способны выявить причины проблем и предложить действенные решения, которые сложно обнаружить на первый взгляд.
Поэтому разработчикам и архитекторам систем на базе gRPC стоит уделять не меньше внимания их клиентам, чем серверам, т.к. именно там могут прятаться парадоксальные ограничения, не позволяющие полностью раскрыть потенциал распределённых технологий.