JVM-клиент под нагрузкой: gRPC ManagedChannel и HttpClient на HTTP/2

Клиент на JVM для highload: пул gRPC-каналов и java.net.http HttpClient поверх HTTP/2, deadlines под SLA и идемпотентный retry — параллель к Go-клиенту

Мы разобрали, как клиент на Go держит пул из нескольких H2-соединений и распределяет запросы по least-inflight (см. Go-клиент под нагрузкой), и как JVM-сервис держит ~1000 in-flight на virtual threads (см. JVM-сервис под нагрузкой). Эта статья закрывает серию с другой стороны того же провода: та же модель — соединение переиспользуется, один запрос не равен одному соединению, — но на клиенте JVM, с другим набором API и своими подводными камнями.

Сквозной кейс не меняется: JSON ~8 КБ, 2000–5000 rps, синхронная проверка 100–200 мс, SLA < 300 мс, около 1000 запросов одновременно в полёте. На JVM для роли клиента есть два штатных кандидата — gRPC ManagedChannel и java.net.http.HttpClient — и у второго есть ограничение, которое стоит понять до того, как полагаться на него в проде. Разбор идёт по той же триаде, что и во всей серии: ✅ правильно / ❌ неправильно / ⚠️ неоптимально.

Пул JVM-клиента: несколько gRPC ManagedChannel и HttpClient, каждый несёт множество параллельных вызовов через HAProxy к бэкендам

Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример performance/highload-lowlatency: HAProxy L7 + пул Go/Java-бэкендов + клиент-нагрузчик, docker compose up. JVM-клиент стенда — clients/java/src/main/java/tech/khorost/highload/Client.java — держит пул из CONNS переиспользуемых HttpClient (JDK 21, .version(HTTP_2)), виртуально-поточный параллелизм воркеров и least-inflight выбор соединения; именно его код разбирается ниже. Прогон подтвердил ключевую оговорку следующего раздела: против h2c-фронтенда HAProxy (bind :8080 proto h2, только prior-knowledge) клиент получает <BADREQ>, а через отдельный HTTP/1.1-фронтенд :8081 отчёт честно печатает protocol: HTTP_1_1 — соединение клиент↔HAProxy идёт по HTTP/1.1, и лишь HAProxy↔бэкенд остаётся HTTP/2.

В статье

gRPC ManagedChannel

Для JVM-клиента под эту нагрузку основной рекомендуемый вариант — gRPC поверх ManagedChannel с Netty-транспортом. Причина не в моде на gRPC, а в конкретном техническом факте: Netty-транспорт умеет открывать h2c-соединение с prior-knowledge (клиент сразу шлёт HTTP/2-преамбулу PRI * HTTP/2.0, без промежуточного HTTP/1.1-Upgrade) — это то, что usePlaintext() даёт из коробки. Именно поэтому gRPC-клиент проходит proto h2-фронтенд HAProxy без проблем, в отличие от java.net.http.HttpClient — подробности в следующем разделе.

Это не теория: в стенде gRPC собран как запускаемый пример clients/grpc-java и ходит через отдельный gRPC-фронтенд HAProxy bind :8090 proto h2 на свой пул grpc-backend-1/2. Живой прогон через :8090 даёт транспорт HTTP/2 cleartext (h2c prior-knowledge) и равномерное распределение запросов по grpc-backend-1/grpc-backend-2 (≈50/50) — ровно там, где java.net.http.HttpClient на таком же proto h2-фронтенде получал <BADREQ> (см. следующий раздел). Именно этот контраст на одном стенде и есть довод в пользу gRPC для JVM-клиента под cleartext-HTTP/2.

ManagedChannel сам мультиплексирует множество RPC-вызовов в одно HTTP/2-соединение, как и grpc.ClientConn на Go-стороне. Модель та же: при высокой нагрузке одного канала мало (тот же лимит SETTINGS_MAX_CONCURRENT_STREAMS, что и в Go-статье), поэтому нужен пул из нескольких каналов с выбором по least-inflight, а не один канал на тысячи rps.

// Один член пула: свой ManagedChannel (своё HTTP/2-соединение,
// Netty-транспорт, h2c prior-knowledge) + счётчик in-flight.
final class GrpcPoolMember {
    final ManagedChannel channel;
    final CheckServiceGrpc.CheckServiceBlockingStub stub;
    final AtomicLong inFlight = new AtomicLong();

    GrpcPoolMember(ManagedChannel channel) {
        this.channel = channel;
        this.stub = CheckServiceGrpc.newBlockingStub(channel);
    }
}

static List<GrpcPoolMember> newGrpcPool(String target, int size) {
    List<GrpcPoolMember> pool = new ArrayList<>(size);
    for (int i = 0; i < size; i++) {
        ManagedChannel channel = ManagedChannelBuilder.forTarget(target)
                .usePlaintext()          // h2c prior-knowledge — проходит HAProxy proto h2 (в стенде gRPC-фронтенд :8090)
                .build();
        pool.add(new GrpcPoolMember(channel));
    }
    return pool;
}

static GrpcPoolMember pickLeastInFlight(List<GrpcPoolMember> pool) {
    GrpcPoolMember best = pool.get(0);
    long bestLoad = best.inFlight.get();
    for (GrpcPoolMember m : pool) {
        long load = m.inFlight.get();
        if (load < bestLoad) {
            best = m;
            bestLoad = load;
        }
    }
    return best;
}

static CheckResponse doCheck(GrpcPoolMember m, CheckRequest req) {
    m.inFlight.incrementAndGet();
    try {
        // 280 мс при SLA 300 мс — тот же запас, что и в Go-клиенте (статья 3):
        // на сетевой путь туда-обратно, не на саму проверку 100-200мс.
        return m.stub.withDeadlineAfter(280, TimeUnit.MILLISECONDS).check(req);
    } finally {
        m.inFlight.decrementAndGet();
    }
}

withDeadlineAfter здесь — прямой аналог context.WithTimeout из Go-клиента: deadline меньше SLA с запасом на сеть, задаётся на каждый вызов отдельно, а не на канал целиком. В проде вместо usePlaintext() — TLS с ALPN (.useTransportSecurity()), h2c prior-knowledge внутри доверенного контура стенда — то же допущение, что и у Go-клиента.

⚠️ Проксирование — тихая ловушка gRPC-Java. Netty-клиент gRPC уважает прокси-окружение: GRPC_PROXY_EXP, а также системные -Dhttps.proxyHost/-Dhttps.proxyPort. Причём достаточно даже пустой переменной GRPC_PROXY_EXP=io.grpc.internal.ProxyDetectorImpl считает, что прокси задан, и пытается гнать cleartext-трафик через несуществующий прокси, отчего все вызовы падают (на стенде это выглядело как 0 успехов при полностью живом бэкенде — легко списать на «сеть» или сам gRPC). «Отключить прокси» здесь — это не выставить переменную в пустую строку, а не задавать её вовсе либо явно исключить адрес через NO_PROXY/-Dhttps.nonProxyHosts. Показательно, что Go-клиент (grpc.NewClient) к пустой переменной устойчив — различие поведения между реализациями стоит держать в голове, когда «тот же самый» gRPC ведёт себя по-разному на Go и на JVM.

java.net.http HttpClient

Второй кандидат — встроенный в JDK java.net.http.HttpClient с Version.HTTP_2. Как и grpc.ClientConn/ManagedChannel, один HttpClient переиспользуется между запросами и сам мультиплексирует конкурентные вызовы в одно соединение — создавать новый экземпляр на запрос не нужно и вредно (см. антипаттерны).

Здесь и находится ключевая оговорка статьи, подтверждённая прогоном на стенде. HttpClient.version(HTTP_2) для https:// действительно даёт чистый H2 через TLS ALPN — JDK умеет договориться о протоколе на этапе handshake. Но для http:// (cleartext) HttpClient не умеет h2c prior-knowledge: первый запрос на новом соединении всегда уходит как обычный HTTP/1.1 с заголовками Connection: Upgrade, HTTP2-Settings / Upgrade: h2c (механизм апгрейда из RFC 7540 §3.2), а не как h2c-преамбула. Дальше зависит от сервера на другом конце:

  • Go-бэкенд стенда (golang.org/x/net/http2/h2c.NewHandler) понимает Upgrade-механизм и апгрейдится — следующие запросы на этом соединении реально идут по HTTP/2.
  • Java-бэкенд стенда (Helidon webserver-http2) поддерживает только prior-knowledge: Upgrade-заголовки он молча игнорирует и отвечает 200 по HTTP/1.1 — соединение так и остаётся HTTP_1_1. То есть даже среди двух H2-бэкендов стенда Upgrade срабатывает лишь у одного — лишнее напоминание, что это ненадёжный путь.
  • HAProxy-фронтенд bind :8080 proto h2 принимает на этом listener только h2c prior-knowledge; получив Upgrade-запрос, отвечает <BADREQ> и рвёт соединение. JDK-клиент не может пройти этот фронтенд как есть.

В стенде для JVM-клиента поэтому поднят отдельный HTTP/1.1-фронтенд :8081, и отчёт прогона честно печатает protocol: HTTP_1_1 — клиент↔HAProxy остаётся на HTTP/1.1, HAProxy↔бэкенд уже работает по HTTP/2 отдельно. Пул из нескольких HttpClient и виртуально-поточный параллелизм при этом демонстрируются в любом случае — но это уже не тот же чистый H2 end-to-end, что даёт gRPC.

// PooledClient — один член пула: HttpClient + счётчик in-flight.
// Каждый член — отдельный экземпляр HttpClient (свой транспорт/пул
// внутри JDK), это и даёт несколько независимых соединений.
private static final class PooledClient {
    private final HttpClient client;
    private final AtomicLong inFlight = new AtomicLong();

    private PooledClient(HttpClient client) {
        this.client = client;
    }
}

// ВАЖНО (см. class-javadoc стенда): .version(HTTP_2) для http:// задаёт
// лишь попытку HTTP/1.1-Upgrade на h2c, НЕ prior-knowledge. Фактическая
// версия ответа зависит от сервера на другом конце — см. HttpResponse#version().
private static HttpClient newH2CHttpClient() {
    return HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(2))
            .build();
}

private static RequestOutcome doRequest(PooledClient pc, Config cfg) {
    pc.inFlight.incrementAndGet();
    try {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(cfg.target()))
                .timeout(Duration.ofMillis(cfg.timeoutMs()))  // per-request, не на весь клиент
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofByteArray(cfg.payload()))
                .build();

        HttpResponse<String> response = pc.client.send(
                request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        // response.version() — HTTP_2 против Go-бэкенда с h2c.NewHandler,
        // но HTTP_1_1 против HAProxy :8080 proto h2 (см. текст выше).
        return RequestOutcome.success(/* ... */ response.version());
    } finally {
        pc.inFlight.decrementAndGet();
    }
}

Практический вывод: для настоящего HTTP/2 end-to-end с java.net.http.HttpClient фактически нужен TLS (h2 через ALPN JDK умеет честно) — либо клиент с h2c prior-knowledge, как у gRPC/Netty. Это и есть причина, по которой ManagedChannel в этой статье идёт основным вариантом, а HttpClient — как честно разобранная альтернатива с этим ограничением, а не как равноценный выбор.

Альтернативы

  • OkHttp — H2 из коробки (включая cleartext h2c prior-knowledge через Protocol.H2_PRIOR_KNOWLEDGE), пул соединений встроен и настраивается явно (ConnectionPool). Разумный выбор, если по каким-то причинам не подходит ни gRPC, ни java.net.http — например, нужна библиотека с более гибкими интерсепторами или используется в проекте, где OkHttp уже есть как зависимость (Retrofit и т.п.).
  • Reactor Netty WebClient — реактивный клиент, естественная пара к реактивному JVM-сервису из статьи 4 серии (WebFlux). H2 поддерживается, пул соединений — через ConnectionProvider с явным лимитом. Оправдан, когда клиентский код и так живёт в реактивном конвейере (Mono/Flux) и превращать вызов в блокирующий было бы неестественно; для чисто клиентского кода без реактивного контекста вокруг — это дополнительная сложность без выигрыша, virtual threads с блокирующим java.net.http/gRPC-клиентом проще.

Когда что: gRPC — default для нового highload-клиента на JVM (чистый H2 без TLS-оговорок). java.net.http — если протокол на стороне сервера фиксирован как REST/HTTP и либо есть TLS, либо сервер понимает HTTP/1.1-Upgrade (как Go-бэкенд стенда, но не HAProxy-фронтенд h2). OkHttp — гибкая замена java.net.http с h2c prior-knowledge без TLS. Reactor Netty WebClient — когда клиент уже часть реактивного стека.

Антипаттерны

  • неправильно — создавать новый ManagedChannel или HttpClient на каждый запрос. Каждый новый канал/клиент — это заново поднятое соединение (TCP, и TLS-handshake, если он есть) там, где переиспользуемый экземпляр уже держит его открытым. Под 2000–5000 rps это ощутимая доля CPU и латентности, съеденная впустую, — тот же антипаттерн, что и http.Client{Transport: &http.Transport{}} на каждый вызов в Go-статье.
  • неправильно — самодельный multiplexing поверх WebSocket вместо H2/gRPC: свои message-id, своя очередь ответов, своя корреляция запрос/ответ вручную. И gRPC ManagedChannel, и HttpClient с H2 уже дают мультиплексирование, корреляцию и flow control из коробки — пересобирать то же самое поверх WebSocket добавляет сложность и убирает поддержку со стороны инфраструктуры (HAProxy, observability) без всякого выигрыша.
  • ⚠️ неоптимально — дефолтные таймауты/пулы и один канал/клиент под тысячи rps. Один ManagedChannel или HttpClient без пула упирается в тот же лимит одновременных streams, что и одно H2-соединение на Go-стороне; без явного deadline/timeout зависшие вызовы копятся, а не освобождают слот. Прямая параллель с Go-статьёй: Go-клиент под нагрузкой разбирает те же дефолты как первую точку деградации.

Короткий checklist

  1. Клиент (ManagedChannel / HttpClient) переиспользуется между запросами, а не создаётся заново на каждый вызов?
  2. H2 действительно включён end-to-end — а не тихо откатился на HTTP/1.1 (как java.net.http по cleartext без h2c prior-knowledge)?
  3. При высокой нагрузке настроен пул из нескольких каналов/клиентов с least-inflight выбором, а не один на всё?
  4. Deadline/timeout (withDeadlineAfter, HttpRequest.timeout) меньше SLA с запасом на сетевой путь?
  5. Retry выполняется только для идемпотентных операций, с явным ключом идемпотентности (request_id)?

Что дальше

Серия perf-lowlatency прошла путь от бюджета задержки до конкретного клиентского кода на двух рантаймах. Разложили SLA < 300 мс на составляющие и посчитали по закону Литтла, что 2000–5000 rps с проверкой 100–200 мс держат около 1000 запросов одновременно в полёте (бюджет задержки и выбор транспорта). Разобрались, как HAProxy распределяет HTTP/2-streams между бэкендами на уровне L4/L7 (балансировка L4/L7 и HTTP/2). Прошли клиентскую сторону на Go — пул H2-соединений, таймауты под SLA, идемпотентный retry (Go-клиент под нагрузкой). Разобрали тред-модель JVM-сервиса — почему virtual threads естественно ложатся на этот кейс лучше platform-пула и когда оправдан reactive (JVM-сервис под нагрузкой). И закрыли серию клиентской стороной JVM — gRPC ManagedChannel как основной вариант чистого H2, java.net.http.HttpClient как альтернатива с оговоркой про h2c prior-knowledge.

Общий вывод серии не привязан к конкретному языку: под жёсткий бюджет задержки транспорт и модель конкурентности нужно проектировать явно — от rps × latency, а не от дефолтов фреймворка — на любом рантайме, который встретится в вашем стеке. Начало серии — бюджет задержки и выбор транспорта.

См. также

Обсуждение в Telegram

Присоединиться →

Комментарии