gRPC сегодня широко используется как мощная, производительная и удобная платформа для организации межсервисного взаимодействия. В мире распределённых систем, где важна скорость отклика и обработка большого числа запросов, gRPC кажется очевидным выбором благодаря поддержке HTTP/2, продвинутой сериализации и многопоточной работе. Однако, несмотря на очевидные преимущества, на практике можно столкнуться с неожиданными проблемами производительности, которые скрыты не на стороне сервера или сети — а именно в клиентской реализации gRPC. Экспериментальная ситуация, описанная в исследовании команды YDB, выявила, что при использовании gRPC в кластерах с низкой сетевой задержкой, клиентское приложение становится источником значительных задержек, тормозящих производительность всей системы. При уменьшении числа узлов в кластере нагрузка на бенчмарки растёт, но значительно возрастают латентность и простои ресурсов, что нехарактерно для так называемых «узловых» проблем производительности.
На первый взгляд, эти явления трудно было связать с самим gRPC, однако тщательный анализ сетевого взаимодействия и внутренних процессов клиента показал, что ключевая проблема заключается в том, как gRPC управляет TCP-соединениями и RPC-потоками. Технология gRPC основывается на HTTP/2, который позволяет мультиплексировать несколько RPC-запросов через одно TCP-соединение. Несмотря на эту архитектурную особенность, у HTTP/2 и gRPC существуют ограничения на максимальное количество одновременных потоков в одном соединении. Стандартно лимит составляет примерно сто каналов одновременно. Когда этот лимит достигается, новые запросы помещаются в очередь, что приводит к увеличению задержек и падению общей пропускной способности системы.
В документации gRPC рекомендуются два способа решения данной проблемы: создание отдельного канала (channel) для каждого «горячего» участка нагрузки или использование пула каналов с различными конфигурациями, чтобы добиться параллелизма и загрузить разные TCP-соединения одновременно. Практические тесты, проведённые с помощью разработанного собственноручно микро-бенчмарка для гRPC, выявили, что при отсутствии оптимизаций все клиентские потоки на самом деле фактически используют одно TCP-соединение. Это приводит к значительным периодам бездействия между пакетами из-за чрезмерного ожидания освобождения потоков в HTTP/2, что негативно сказывается на показателях задержек и пропускной способности. Даже незначительное увеличение числа параллельно выполняемых запросов приводит к линейному росту задержек, что делает невозможным эффективное масштабирование по горизонтали. Дополнительный анализ сетевого трафика с помощью tcpdump и Wireshark подтвердил, что в сети отсутствовали проблемы с потерями пакетов, задержками или перегрузкой.
TCP параметры, такие как отключённый алгоритм Нагла, размер окон и другие настройки, были оптимальными, а сервер оперативно отвечал на запросы. Следовательно, узким местом оказался именно клиентский код gRPC, а также внутренняя архитектура и поведение библиотеки при обработке потоков и очередей. Важно отметить, что все эксперименты проводились на мощном оборудовании с двухпроцессорными серверами Intel Xeon Gold 6338 и скоростной сетью 50 Гбит/с с RTT в пределах 0.04 миллисекунд. На таких условиях сетевые факторы практически не влияли на задержки, что позволило безошибочно локализовать источник проблемы именно внутри клиента gRPC.
В ходе дальнейшего эксперимента была проверена гипотеза о том, что создание и использование множества отдельных gRPC-каналов с уникальными параметрами мешает их объединению в один субканал, который использует одно и то же TCP-соединение. Включение специальной настройки GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL позволило добиться того, что для каждого рабочего потока создавался индивидуальный канал с отдельным TCP-соединением. Данное изменение резко улучшило как пропускную способность, так и показатели задержки, приблизив систему к «идеальной» линейной масштабируемости. Через такой подход удалось добиваться в несколько раз больших значений запросов в секунду и существенно снижать пиковые latencies без значительного нагрева ресурсов. Интересен также результат тестов в сетях с высокой задержкой — около 5 миллисекунд RTT.
В этих условиях разница между использованием одного канала и пула каналов резко сокращается. Повышение латентности сети нивелирует накладные расходы от реализации мультиканального подхода, что говорит о том, что проблема проявляется именно в средах с низкой сетевой задержкой и высокой пропускной способностью. Таким образом, для сетевых инфраструктур с высокой задержкой данный клиентский баг фактически не мешает достижению хороших результатов, что надо учитывать при проектировании систем. Выводы из проведённого исследования имеют важное прикладное значение для разработчиков и инженеров, работающих с gRPC в распределённых системах или микросервисных архитектурах. Во-первых, необходимо понимать, что настройка клиента gRPC является ключевым фактором при работе в сетях с низкой задержкой.
Универсальное использование одного канала на все параллельные запросы может привести к неожиданным узким местам и негативно влиять на итоговую производительность. Во-вторых, сочетание разделения каналов на отдельные потоки с уникальными конфигурациями и включение локального пула субканалов позволяет добиться оптимальных показателей. Это объединяет два подхода, которые ранее рассматривались как взаимоисключающие, в одну эффективную стратегию повышения производительности. Кроме того, необходимо уделять внимание фактору NUMA-распределения потоков и процессорных ядер при запуске серверов и клиентов. Пример команды taskset, которая фиксирует потоки в пределах одного NUMA-узла, служит хорошей практикой для минимизации простоев вызванных переключением контекста между процессорами и кэш-промахами.
При разработке микросервисов важно регулярно проверять свои load-тесты и бенчмарки на предмет подобных узких мест. Использование простых эхо-серверов или ping-микробенчмарков позволит диагностировать виновника задержек — клиент или сервер. В случае обнаружения проблем на стороне клиента — полезно провести эксперименты с разным количеством каналов и параметров субканалов, а также анализировать сетевой трафик для исключения влияния инфраструктуры. Стоит учитывать, что оптимизации, описанные в данном исследовании, основываются на конкретной версии и реализации gRPC (1.72.
0). Другие версии или языковые реализации могут показывать похожие или иные паттерны, поэтому важно проводить собственные тесты в соответствии с целевой средой. Впрочем, результаты сквозных сравнений на С++ и Java свидетельствуют о широком распространении выявленной проблемы. Для команд, стремящихся максимально увеличить производительность сетевых вызовов, наилучшим решением становится гибкая архитектура клиента, позволяющая динамически создавать множество каналов с индивидуальными параметрами и использовать локальные пулы субканалов. Такой подход удешевляет задержки, разгружает очереди внутренних потоков и снимает топовые ограничения HTTP/2.
В итоге развивается система, способная обработать значительно больше запросов с низкой латентностью, что важно для современных сервисов в масштабах облаков и высокочастотных транзакций. В заключение можно отметить, что описанная узкая точка — не редкость в высокопроизводительных системах, работающих через транспортные протоколы, предполагающие делегирование потоков и мультиплексирование. Опыт YDB подтверждает необходимость комплексного взгляда на архитектуру клиент-серверных приложений с фокусом на реализацию каждого уровня взаимодействия, а не только на «более мощные» серверы или оптимизацию сетевого оборудования. Только так можно добиться гармоничного баланса между скоростью, масштабируемостью и надежностью. Читайте также о том, как другие платформы и базы данных решают схожие проблемы, и следите за обновлениями в gRPC-библиотеках, поскольку развитие технологий ведёт к появлению новых методов оптимизаций и устранения внутренних конфликтов на уровне протоколов.
Не пренебрегайте высоким качеством тестирования и мониторинга, чтобы вовремя выявлять узкие места, даже в самых неожиданных слоях вашей инфраструктуры.