gRPC — популярный инструмент для коммуникации между сервисами, широко используемый в современных распределённых базах данных и микросервисных архитектурах. В YDB, открытой распределённой SQL базе данных, gRPC служит основным способом взаимодействия между клиентами и сервером. Несмотря на общепринятое мнение о высокой производительности gRPC, в условиях сетей с крайне низкой задержкой были выявлены неожиданные проблемы с производительностью, которые оказались связаны именно с клиентской частью протокола. Рассмотрим детально это явление и способы его обхода. Прежде всего, важно понять базовые принципы работы gRPC.
Он построен поверх протокола HTTP/2, где каждая RPC-вызов реализуется как отдельный поток в рамках HTTP/2-соединения. Клиент gRPC может использовать несколько каналов для общения с сервером, при этом каждый канал обычно устанавливает собственное TCP-соединение. При этом, если каналы создаются с одинаковыми параметрами, они могут использовать одно и то же TCP-соединение с мультиплексированием запросов. Ограничение числа одновременно открытых потоков в HTTP/2 для одного соединения обычно стоит по умолчанию на уровне 100. Если количество активных запросов превышает эту границу, последующие ожидают освобождения ресурсов, что негативно сказывается на задержке и через это на общей производительности.
Интересный момент заключается в том, что официальная документация по gRPC рекомендует для высоко загруженных приложений создавать отдельные каналы для различных областей нагрузки или использовать пул каналов с разными конфигурационными параметрами. Однако в YDB выявлено, что эти рекомендации не являются двумя независимыми вариантами решения, а фактически представляют собой два этапа единой стратегии устранения узкого места. Для исследования производительности был разработан простой микробенчмарк на C++, использующий актуальную версию gRPC (v1.72.0).
В тестировании применялись сервер и клиент, запущенные на отдельных мощных машинах с низкой сетевой задержкой (около 0.04 мс RTT), соединённых каналом 50 Гбит/с. В рамках теста клиент запускал несколько рабочих потоков, каждый с собственным gRPC каналом, выполняя последовательные запросы с минимальным числом одновременно выполняемых вызовов (in-flight равен 1). Эксперименты показали, что несмотря на высокую пропускную способность сети и низкую задержку, производительность клиента не линейно масштабировалась с увеличением числа одновременных запросов. Так, при 10-кратном увеличении числа клиентов реальное ускорение по пропускной способности достигало лишь примерно 3.
7 раза, а при 20-кратном увеличении — только в 4 раза. При этом наблюдалось значительное возрастание задержек на стороне клиента, что стало ключевым ограничивающим фактором. Удивительно, но даже минимальное число одновременных запросов сопровождалось латентностью, значительно превышающей сетевую задержку. Подробный анализ сетевого трафика с помощью инструментов tcpdump и Wireshark показал, что в работе TCP не происходило никаких сбоев или логических ограничений. Настройки TCP, включая отключение алгоритма Нагла и размер окна, были оптимальны.
Отмечалась регулярная пауза между завершением отправки очередного пакета и инициацией следующего запроса, что указывало скорее на проблему в уровне gRPC клиента. Дальнейшие эксперименты подтвердили, что даже если каждый рабочий поток использует отдельный канал, но каналы созданы с одинаковыми аргументами, они объединяются в рамках одного TCP-соединения и работают с общей очередью, ограниченной лимитом по количеству одновременных HTTP/2 потоков. Это становится скрытым узким местом, снижая производительность и увеличивая задержку. Решение проблемы заключалось в создании для каждого воркера своего канала с уникальными параметрами или включении флага GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, который заставляет gRPC создавать отдельные подконтексты для каналов, тем самым разделяя их TCP-соединения. Такая стратегия позволила добиться почти шестикратного увеличения пропускной способности для обычных RPC и почти в пять раз — для стриминговых вызовов.
Задержка при этом росла гораздо медленнее с увеличением числа параллельных запросов, что значительно повышало масштабируемость и общую производительность системы. Интересно отметить, что при использовании сети с гораздо большей задержкой (около 5 мс RTT) различия между моно- и мультиканальным подходом снижались. При более высокой сетевой латентности узкое место клиента становилось менее заметным, так как сеть сама по себе накладывала пределы на производительность. Это подчеркивает, что выявленная клиентская проблема особенно остра именно в условиях высокоскоростных локальных сетей с низкими задержками. Важной практической рекомендацией, которую можно извлечь из этого исследования, является необходимость тщательного управления количеством открытых TCP-соединений и каналов gRPC в клиентских приложениях, особенно при работе с высокопроизводительными запросами в низкозадержных сетях.
Перегрузка одного TCP-соединения множеством одновременных потоков не только снижает отзывчивость, но и ставит под угрозу возможность масштабирования приложений. Автор исследования призывает сообщество не ограничиваться приведённым решением и продолжать искать дополнительные оптимизации. Производительность gRPC клиента является ключевым звеном в масштабировании распределённых систем, и даже малые улучшения могут существенно повлиять на производительность всего приложения. Уже предложенный подход с созданием per-worker каналов с разными аргументами служит надежным и эффективным способом избавиться от клиентского узкого места. Таким образом, рассмотренная ситуация ярко демонстрирует, что в современных сетях с минимальной задержкой даже такие технически продвинутые и широко используемые инструменты, как gRPC, могут иметь скрытые сложности.