Современные распределённые системы и базы данных всё чаще используют gRPC в качестве надёжного и эффективного протокола для межсервисного взаимодействия и обмена данными. gRPC, основанный на протоколе HTTP/2, предлагает функциональность мультиплексирования потоков RPC по одним TCP-соединениям и считается очень перспективным решением для построения высокопроизводительных клиент-серверных архитектур. Однако на практике выявляются определённые сложности и «узкие места», особенно в условиях сетей с низкой задержкой, которые могут существенно ухудшать производительность и приводить к неожиданным задержкам на стороне клиента. Об этом подробно рассказывает опыт команды YDB, открытого распределённого SQL-хранилища, столкнувшейся с непростой проблемой при нагрузочном тестировании их системы. YDB активно применяет gRPC для предоставления своего API клиентам.
При проведении нагрузочных тестов и бенчмарков наблюдалась нестандартная ситуация: чем меньше узлов в кластере, тем сложнее было нагрузить систему, и при уменьшении размера кластера появлялись простаивающие ресурсы, но при этом клиентская латентность нарастала. Это указывало на то, что узкое место находится именно на стороне клиента, а не сервера или сети, что вызвало глубокое исследование причин. Основная архитектура gRPC предполагает, что клиентская часть использует каналы (channels) для установления TCP-соединений с серверами. Каждый gRPC-канал связан с одним или несколькими HTTP/2-потоками, которые позволяют параллельно выполнять множество вызовов RPC. При этом граничное количество параллельных потоков HTTP/2 на соединение обычно ограничено (дефолтно это 100).
В случае, когда число активных RPC достигает этого лимита, новые запросы откладываются в очереди на клиенте, что может привести к задержкам и снижению пропускной способности. Рекомендации документации gRPC предлагают два варианта решения для таких высоконагруженных сценариев — создавать отдельные каналы для разных частей приложения с высокой нагрузкой или использовать пул каналов с разными настройками, чтобы обеспечить распределение запросов по нескольким TCP-соединениям. В реальности обе рекомендации являются частью единого подхода — увеличение количества каналов с уникальными параметрами для разгрузки каждого TCP-соединения. Чтобы проверить гипотезы и создать воспроизводимую среду для исследования проблемы, специалисты YDB разработали простой асинхронный gRPC микробенчмарк на C++. Он состоит из сервера и клиента, которые выполняют короткие ping-запросы с минимальным полезным нагрузочным сообщением.
В тестах клиент запускался с разным количеством параллельных рабочих потоков, каждый из которых поддерживал один одновременно выполняющийся запрос (in-flight равно 1). Сервер и клиент размещались на разных выделенных физических машинах с процессорами Intel Xeon Gold и сетью 50 Гбит/с, для чего использовалась оптимальная установка расположения CPU и потоков на NUMA-ноды для минимизации системных задержек. Реальные результаты показали существенное отклонение от теоретически идеальной линейной масштабируемости в количестве запросов в секунду. Помимо этого, с ростом числа параллельных запросов наблюдался быстрый рост клиентской латентности, что в сочетании с закрытым циклом запрос-ответ ограничивало пропускную способность. Проведённый сетевой анализ tcpdump подтвердил отсутствие потерь пакетов, минимальное время отклика сервера и отсутствие сетевых задержек или задержек в протоколах TCP, таких как Nagle или delayed ACK.
Основной «пузырь» задержки приходился именно на паузу в 150-200 микросекунд на стороне клиента между обработкой пакетов и отправкой новых запросов. Интересно, что независимо от того, создавались ли каналы отдельно для каждого рабочего потока или использовался пул каналов с одинаковыми параметрами, наблюдалась только одна TCP-сессия для всех соединений, что неизбежно накладывало ограничение на количество параллельных RPC, работающих через один HTTP/2 транспорт. Ранее разработчики отмечали, что конфигурация каналов с идентичными настройками приводит к репозиторию каналов с повторным использованием TCP-соединений — нежелательный эффект при попытках распараллеливания нагрузки. Обходным решением стало использование каналов с различными аргументами конфигурации или же включение флага GRPC_ARG_USE_LOCAL_SUBCHANNEL_POOL, позволяющего каждому каналу использовать собственный пул TCP-подсоединений. В результате таких изменений каждому рабочему потоку могли быть сопоставлены независимые TCP-соединения.
Это привело к резкому росту пропускной способности, которая выросла примерно в 6 раз для обычных RPC и более чем в 4 раза для стриминговых вызовов, при этом уровень латентности заметно упал и стал расти очень медленно по мере увеличения числа одновременных запросов. Данные экспериментов свидетельствуют, что в условиях сетей с низкой задержкой внутренняя архитектура gRPC-клиента, основанная на одном TCP-соединении с ограничением по числу параллельных HTTP/2 потоков, становится серьёзным ограничителем производительности. Это неожиданно, поскольку считается, что именно сеть и серверы часто ограничивают пропускную способность, а клиентская библиотека gRPC в плане масштабирования — достаточно прозрачно и эффективно обрабатывает нагрузку. Впрочем, при увеличении сетевой задержки до 5 миллисекунд, что характерно для географически распределённых систем, эффект узкого места значительно ослабел. При таких условиях использование множества каналов с уникальными параметрами лишь немного улучшало показатели по сравнению с общим каналом.
Это связано с тем, что время ожидания сети и отклик сервера становится доминирующим фактором в общей задержке, а ограничения в граничном числе HTTP/2 потоков меньше влияют на конечную производительность. Таким образом, обнаруженное узкое место клиентского gRPC-стека является преимущественно проблемой быстрых локальных сетей или высокопроизводительных сред, где сетевые задержки минимальны и роль программного стека особенно возрастает. В таких случаях единственным эффективным решением, проверенным на практике, является создание множества независимых gRPC-каналов с индивидуальными параметрами, что обеспечивает физическое распределение запросов по разным TCP-соединениям. Для разработчиков и архитекторов систем на базе gRPC, особенно тех, кто стремится реализовать максимальную производительность в кластерах с низкими сетевыми задержками и обеспечивает высокую плотность одновременных соединений, данный опыт является важным уроком. Пренебрежение ограничениями HTTP/2 потоков на уровне TCP-соединений и попытки масштабировать нагрузку через один канал могут привести к существенному падению общей пропускной способности и росту латентности.
Необходимо внимательно проектировать клиентскую часть с учётом этих факторов и использовать механизмы мультиканальности. Стоит также отметить, что gRPC постоянно развивается, и возможны улучшения в будущих версиях, которые помогут оптимизировать работу клиентского стека и снизить накладные расходы. Команда YDB приглашает сообщество к обсуждению и предложению дополнительных оптимизаций через их публичный GitHub. В итоге, проблемы, выявленные командой YDB в ходе нагрузочного тестирования, демонстрируют, что максимальная производительность систем, использующих gRPC, достигается не только за счёт быстрого и стабильного сервера, а в большой степени зависит от грамотно сконфигурированной клиентской части. Распределение нагрузки между несколькими TCP-соединениями, уникальные настройки каналов и осознанное управление параллелизмом позволяют минимизировать клиентские задержки и добиться высокой устойчивости приложения в условиях реальных нагрузок и требований к низкой латентности.
Благодаря таким знаниям специалисты могут создавать более эффективные и масштабируемые высоконагруженные распределённые системы в современных дата-центрах и облачных средах.