gRPC зарекомендовал себя как надежный и высокопроизводительный фреймворк для межсервисного взаимодействия, широко используемый в распределённых системах и современных приложениях. Однако даже у таких отлаженных технологий случаются тонкие моменты, которые могут значительно повлиять на производительность. Особенно это актуально для систем, где важна минимальная задержка и высокая пропускная способность, к которым относится YDB — масштабируемая распределённая SQL-база данных с поддержкой строгой согласованности и ACID-транзакций. Недавно специалисты YDB столкнулись с неожиданным узким местом именно на стороне клиента gRPC в условиях низколатентных сетевых соединений. В рамках этого обзора мы расскажем о природе обнаруженной проблемы, деталях её воспроизведения и известных решениях, которые помогут существенно повысить эффективность работы gRPC клиента, минимизировать задержки и максимизировать throughput, а также о том, при каких условиях узкое место становится критичным.
Прежде всего стоит напомнить, что gRPC строится на базе протокола HTTP/2, который допускает мультиплексирование многочисленных потоков (streams) внутри одного TCP-соединения. Клиент gRPC может создавать несколько каналов, каждый из которых управляет своими потоками. В теории, это обеспечивает высокую степень параллелизма и эффективное использование сетевых ресурсов без необходимости открытия большого числа TCP-соединений. Однако именно здесь и начала проявляться одна из ключевых проблем — при использовании небольшого количества каналов с активными запросами через единственное TCP-соединение граничное ограничение максимального числа одновременных HTTP/2-потоков — по умолчанию 100 — начинает серьезно влиять на общую производительность. В YDB для тестирования производительности gRPC использовались специальные «ping» микро-бенчмарки, которые выдавали очень интересные результаты.
При уменьшении количества узлов кластера оказывалось, что нагрузить базу данных сложнее, а на стороне клиента наблюдается рост задержек, несмотря на избыточные (холостые) ресурсы на сервере. Анализ коммуникаций с помощью tcpdump и Wireshark показал, что загрузка TCP-соединения не превышает лимиты и отсутствуют проблемы на уровне сети — напряжение концентрировалось именно внутри механизма gRPC-клиента. Клиент инициировал запросы, затем срабатывала пачка ответов с сервера, после чего наступал период паузы порядка 150-200 микросекунд, характерный именно для ожидания внутри клиента, а не на сети или сервере. Проблема усугублялась тем, что при использовании одного gRPC-канала на рабочий поток, но с одинаковыми параметрами канала, TCP соединение для всех каналов было единым. Таким образом фактически создавалась очередь запросов внутри единственного TCP потока, порождая задержки в ожидании освобождения HTTP/2-потоков.
Официальная документация gRPC упоминает о подобном подвешивании запросов при достижении лимита concurrent streams, рекомендую создавать отдельные каналы или использовать пул каналов с разными параметрами, чтобы ограничить накопление ожидающих запросов. Опыт YDB доказал, что решение состоит в комбинации этих подходов — создание отдельного gRPC-канала для каждого рабочего потока с уникальными параметрами или применением специального параметра GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, который заставляет gRPC создавать локальные подканалы и не делить один TCP сокет на несколько каналов. Это устраняет задержки, благодаря тому, что запросы из разных потоков идут по раздельным TCP соединениям и не создают очередей, связанные с лимитом одновременных HTTP/2 стримов. Результаты подобных изменений оказались впечатляющими: пропускная способность возросла сразу в несколько раз (примерно в 6 раз для обычных вызовов RPC и в 4,5 раза для потоковых RPC), при этом задержки значительно снизились и выросли гораздо медленнее с увеличением числа одновременных запросов. Важно отметить, что на сетях с более высокой задержкой порядка нескольких миллисекунд (например, 5 мс) эффект от разделения каналов менее заметен, что объяснимо тем, что задержка сети становится доминирующим фактором, а внутренняя очередность gRPC-каналов — менее критичной.
Исследование также показало, что на высокой производительности с низкой задержкой очень важно управлять распределением рабочих потоков эффективно — фиксация потоков в пределах одной NUMA-ноды повышает стабильность и предсказуемость результатов. Параллельное выполнение с учётом топологии процессоров, а также настройка gRPC-серверов с оптимальным количеством completion queues и рабочих потоков на очередь способствует эффективной обработке запросов и минимизации внутренних задержек. Узкое место на клиенте gRPC может проявиться в любых системах, где применяются долгоживущие подключения с большим числом коротких и быстрых RPC-вызовов, особенно если соединение с сервером характеризуется очень низкой сетевой задержкой и высокой пропускной способностью. В таких условиях любые дополнительные внутренние паузы, обусловленные архитектурой gRPC и HTTP/2, становятся заметным фактором, ограничивающим реальную производительность. Следовательно, разработчикам и архитекторам следует уделять внимание разделению каналов и настройке параметров каналов с целью максимального распараллеливания TCP соединений и оптимизации работы с HTTP/2 стримами.
На практике это означает необходимость создания каналов с уникальными параметрами для каждого рабочего потока или категории нагрузки, а также эксперименты с параметром GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, который заставляет клиентскую библиотеку gRPC использовать изолированные пуулы подканалов и тем самым раскрывает потенциал масштабирования параллельных вызовов. Такой подход особенно эффективен в высоконагруженных системах с низкой сетевой задержкой, где время отклика критично. Хотя описанные решения помогли значительно улучшить показатели, стоит признать, что оптимизации внутри клиентской библиотеки gRPC всё ещё могут быть предметом дальнейших исследований — в частности, связанные с внутренней борьбой за ресурсы, эффективностью планировщиков и механизма контроля потоков. Тем не менее практические рекомендации, выработанные в ходе работы YDB, уже способны послужить благом для многих проектов, испытывающих похожие проблемы с масштабируемостью и задержками. В итоге, несмотря на репутацию gRPC как производительного и стабильного решения, даже при использовании современных и актуальных версий клиентских библиотек стоит внимательно следить за тем, как распределяются каналы и TCP соединения внутри приложения.