Мы разобрали, как клиент на 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 — и у второго есть ограничение, которое стоит понять до того, как полагаться на него в проде. Разбор идёт по той же триаде, что и во всей серии: ✅ правильно / ❌ неправильно / ⚠️ неоптимально.
Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример
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
- java.net.http HttpClient
- Альтернативы
- Антипаттерны
- Короткий checklist
- Что дальше
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
- Клиент (
ManagedChannel/HttpClient) переиспользуется между запросами, а не создаётся заново на каждый вызов? - H2 действительно включён end-to-end — а не тихо откатился на HTTP/1.1 (как
java.net.httpпо cleartext без h2c prior-knowledge)? - При высокой нагрузке настроен пул из нескольких каналов/клиентов с least-inflight выбором, а не один на всё?
- Deadline/timeout (
withDeadlineAfter,HttpRequest.timeout) меньше SLA с запасом на сетевой путь? - 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, а не от дефолтов фреймворка — на любом рантайме, который встретится в вашем стеке. Начало серии — бюджет задержки и выбор транспорта.
См. также
- Go-клиент под нагрузкой: пул HTTP/2-соединений и потоки
- JVM-сервис под нагрузкой: virtual threads, пулы и reactive для 1000 in-flight
- Выбор API: REST, gRPC, GraphQLСкоро
Комментарии