---
title: "Производительность и тюнинг"
description: "Производительность gRPC: keepalive и ловушка too_many_pings, лимиты MaxRecvMsgSize и MaxSendMsgSize, окна HTTP/2 и BDP, MaxConcurrentStreams, idle-таймауты."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/22-tuning/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 20
---

# Производительность и тюнинг

*Курс «gRPC-Go: высокопроизводительный RPC», урок 22 из 25. ~20 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/22-tuning/*

Двадцать первый урок закончил обещанием: тест на bufconn зелёный, а продакшен тормозит — значит, дело не в корректности, а в настройках транспорта, и в трубах есть что тюнить, ведь HTTP/2 и сериализация там настоящие. Сегодня — производительность grpc на настоящем проводе: как keepalive отличает живое соединение от полумёртвого и почему без пингов NAT и балансировщики режут простаивающие коннекты; где проходит граница лимитов размера сообщений и почему потолок приёма — четыре мебибайта, а отправки — math.MaxInt32; как окна HTTP/2 и BDP-оценщик сами разгоняют поток; зачем существует MaxConcurrentStreams и что делают idle-таймауты по обе стороны соединения. Ручек много, и каждая умеет навредить: keepalive-пинг чаще разрешения сервера — и соединения рвутся с GOAWAY too_many_pings. Поэтому настоящий тюнинг grpc начинается не с настроек, а с дисциплины из [урока про бенчмарки и профилирование](/courses/go-advanced/lessons/17-benchmarki/): сначала замер, потом одна ручка, снова замер. Запрос «производительность grpc» обычно гуглят, когда latency в проде подрос, а код хендлеров не менялся, — ниже карта всех транспортных ручек grpc-go с дефолтами из исходников, ловушками и порядком применения.

### производительность grpc: сначала замер, потом ручки

У производительности gRPC три метрики, и тюнят их по-разному. Первая — latency: время одного вызова, причём важны не средние, а хвосты — p95 и p99, потому что именно хвосты формируют пользовательский опыт. Вторая — пропускная способность: сколько RPC в секунду выдерживает сервис при заданном latency. Третья — память: gRPC-транспорт буферизует сообщения и фреймы, и каждый поднятый лимит — это возможные мегабайты в куче под пиком нагрузки. Прежде чем крутить любую ручку из этого урока, зафиксируйте все три цифры: микробенчмарки testing.B и профили pprof вы строили в [уроке про бенчмарки](/courses/go-advanced/lessons/17-benchmarki/), нагрузку гоняют хотя бы grpcurl или скриптом из прошлого урока на bufconn — зелёный тест не отвечает на вопрос «быстро ли», но отвечает на вопрос «чёй-то сломалось после тюнинга». Правило одной ручки — то же, что в бенчмаркинге: за итерацию меняем ровно одну настройку и пересчитываем цифры, иначе не узнаете, что именно помогло. И запомните, где живут все ручки курса: DialOption у grpc.NewClient, ServerOption у grpc.NewServer и CallOption на отдельный вызов — ни одна из них не в proto-схеме и не в коде хендлеров.

### Карта ручек: где живут настройки транспорта

Все настройки транспорта grpc-go собираются в одном месте — в опциях конструктора. Сервер: grpc.NewServer(grpc.MaxRecvMsgSize(...), grpc.KeepaliveParams(...), ...) — каждая функция возвращает ServerOption. Клиент: grpc.NewClient(grpc.WithKeepaliveParams(...), grpc.WithDefaultCallOptions(...)) — DialOption. На один вызов: CallOption вроде grpc.MaxCallRecvMsgSize, который можно передать и точечно, и через WithDefaultCallOptions как дефолт канала. Сами параметры keepalive вынесены в отдельный пакет google.golang.org/grpc/keepalive — его документация лаконична: «Package keepalive defines configurable parameters for point-to-point healthcheck» — настраиваемые параметры точка-точка-проверки живости. Пять семейств ручек этого урока: размеры сообщений (MaxRecvMsgSize, MaxSendMsgSize и клиентские CallOption-близнецы), keepalive-пинги (ClientParameters, ServerParameters, EnforcementPolicy), окна HTTP/2 (InitialWindowSize и BDP-оценщик), MaxConcurrentStreams и idle-таймауты (MaxConnectionIdle, MaxConnectionAge, WithIdleTimeout). Важное следствие из прошлых уроков: все эти настройки — deploy-конфигурация, а не бизнес-логика, поэтому интеграционные тесты на bufconn из двадцать первого урока их не проверяют по умолчанию — тестовый сервер собирается с дефолтными опциями, и это честно: проверять надо контракт, а не конфиг. Конфиг проверяется экспериментом и замером.

### Сервер: тюнинг-конфиг целиком

```go
// cmd/orders-server/main.go — все транспортные ручки сервера в одном месте
package main

import (
        "net"
        "time"

        "google.golang.org/grpc"
        "google.golang.org/grpc/keepalive"

        shopv1 "github.com/example/shop/gen/shopv1"
)

func main() {
        lis, err := net.Listen("tcp", ":50051") // слушатель из четвёртого урока
        if err != nil {
                panic(err)
        }

        srv := grpc.NewServer(
                // размеры сообщений: приём ограничиваем осознанно
                grpc.MaxRecvMsgSize(8*1024*1024),  // было 4 МБ — под любой легальный запрос
                grpc.MaxSendMsgSize(16*1024*1024), // дефолт math.MaxInt32, ставим явно
                // предохранитель: не больше 1000 одновременных RPC на соединение
                grpc.MaxConcurrentStreams(1000),
                // keepalive и max-age: как сервер держит соединения
                grpc.KeepaliveParams(keepalive.ServerParameters{
                        MaxConnectionIdle: 5 * time.Minute,  // простаивающее соединение — GoAway
                        MaxConnectionAge:  30 * time.Minute, // перекат соединений (джиттер ±10%)
                        Time:              2 * time.Minute,  // пингуем клиента раз в 2 минуты
                        Timeout:           10 * time.Second, // нет ответа на пинг — разрыв
                }),
                // политика чужих пингов: что разрешено клиенту
                grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
                        MinTime:             30 * time.Second, // чаще — страйк
                        PermitWithoutStream: true,             // пинги без активных RPC разрешены
                }),
        )
        shopv1.RegisterOrderServiceServer(srv, &v1Handler{orders: loadOrder})
        if err := srv.Serve(lis); err != nil { // блокирующий Serve из четвёртого урока
                panic(err)
        }
}
```

Разбор по строкам документации grpc-go. MaxRecvMsgSize: «MaxRecvMsgSize returns a ServerOption to set the max message size in bytes the server can receive. If this is not set, gRPC uses the default 4MB» — то есть 1024 * 1024 * 4 из server.go, четыре мебибайта. MaxSendMsgSize: «If this is not set, gRPC uses the default math.MaxInt32» — отправку gRPC по умолчанию не ограничивает вовсе. MaxConcurrentStreams: «MaxConcurrentStreams returns a ServerOption that will apply a limit on the number of concurrent streams to each ServerTransport» — лимит стримов на транспорт, а дефолт в defaultServerOptions — math.MaxUint32, то есть «без ограничения». KeepaliveParams: «KeepaliveParams returns a ServerOption that sets keepalive and max-age parameters for the server». KeepaliveEnforcementPolicy: «KeepaliveEnforcementPolicy returns a ServerOption that sets keepalive enforcement policy for the server». Обе функции принимают структуры из пакета keepalive — ServerParameters и EnforcementPolicy, — их поля и дефолты разбираем ниже по тексту. Заметьте: хендлер в этом сниппете тот же, что и в тестах прошлого урока, — тюнинг транспорта не трогает бизнес-код ни на строчку.

### Клиент: зеркало настроек

```go
// cmd/orders-client/main.go — ручки клиента: keepalive и лимиты вызовов
conn, err := grpc.NewClient(
        "dns:///orders.example.com:443",
        // keepalive: пинг каждые 30 секунд, даже когда RPC нет
        grpc.WithKeepaliveParams(keepalive.ClientParameters{
                Time:                30 * time.Second, // тишина 30s — отправляем PING
                Timeout:             10 * time.Second, // нет ответа — рвём соединение
                PermitWithoutStream: true,            // пингуем и без активных RPC
        }),
        // лимиты размеров — дефолты всех вызовов канала
        grpc.WithDefaultCallOptions(
                grpc.MaxCallRecvMsgSize(16*1024*1024), // зеркалим MaxSendMsgSize сервера
                grpc.MaxCallSendMsgSize(8*1024*1024),  // зеркалим MaxRecvMsgSize сервера
        ),
        grpc.WithTransportCredentials(creds), // TLS из двенадцатого урока
)
if err != nil {
        panic(err)
}
client := shopv1.NewOrderServiceClient(conn)
```

Документация WithKeepaliveParams: «WithKeepaliveParams returns a DialOption that specifies keepalive parameters for the client transport. Keepalive is disabled by default» — клиентский keepalive по умолчанию выключен: в internal/transport/defaults.go константа defaultClientKeepaliveTime = infinity. Первый же подводный камень спрятан в этой опции: если Time меньше KeepaliveMinPingTime (10 * time.Second), grpc-go не ошибётся, а поправит вас предупреждением в логе — «Adjusting keepalive ping interval to minimum period of 10s». Документация поля Time из keepalive.go повторяет то же самое: «If set below 10s, a minimum value of 10s will be used instead». Timeout описан так: «After having pinged for keepalive check, the client waits for a duration of Timeout and if no activity is seen even after that the connection is closed» — дефолт 20 секунд (defaultClientKeepaliveTimeout = 20 * time.Second). Лимиты вызовов: «MaxCallRecvMsgSize returns a CallOption which sets the maximum message size in bytes the client can receive. If this is not set, gRPC uses the default 4MB»; «MaxCallSendMsgSize ... If this is not set, gRPC uses the default math.MaxInt32». Through WithDefaultCallOptions эти CallOption становятся дефолтами всех вызовов канала — то, что в девятнадцатом уроке мы делали с grpc.UseCompressor.

### Проверка: асимметрия лимитов send/recv

**Вопрос:** Сервер с полностью дефолтными настройками возвращает отчёт на 100 МБ одним сообщением. Где сломается вызов?

- ✅ На клиенте: серверная Send пройдёт — MaxSendMsgSize по умолчанию math.MaxInt32, а клиентский Recv отбросит сообщение лимитом MaxCallRecvMsgSize в 4 МБ со статусом ResourceExhausted
- На сервере: Send проверяет тот же лимит 4 МБ, что и Recv, — отправка упадёт раньше
- Соединение оборвётся: HTTP/2 запрещает передавать сообщения больше окна в 64 КиБ
- Нигде: gRPC автоматически поднимает лимиты под размер отправляемого сообщения

Лимиты приёма и отправки — независимые константы, и дефолты у них зеркально разные: приём — 1024 * 1024 * 4 (defaultServerMaxReceiveMessageSize на сервере, defaultClientMaxReceiveMessageSize на клиенте), отправка — math.MaxInt32. Сервер спокойно закодирует и утащит в сеть стомегабайтное сообщение, а клиент, получив рамку, откажется её разбирать: ResourceExhausted и «grpc: received message larger than max (… vs. 4194304)». Окно HTTP/2 тут ни при чём: окно регулирует темп передачи через WINDOW_UPDATE, но не запрещает большие сообщения — их просто передают по частям. Автоподнятия лимитов в gRPC нет — это осознанная политика против случайной заливки гигабайтов в память.

### keepalive: зачем пинговать соединение

TCP-соединение может тихо умереть: NAT-таблица маршрутизатора выкинула запись, балансировщик закрыл простой, пир упал вместе с сетевым кабелем — FIN не придёт, и соединение будет выглядеть живым, пока вы не попробуете в него написать. Дедлайны из четырнадцатого урока ловят это на уровне одного RPC, а keepalive — на уровне соединения: клиент сам, без вашего кода, отправляет PING-фрейм HTTP/2 — восьмибайтовый payload без данных, — и если ответа нет, закрывает соединение. Плюс пинги не дают инфраструктуре выкинуть «простаивающий» коннект: NAT и балансировщики смотрят на трафик, а пинг — это трафик. Всю механику задаёт пакет keepalive; документация ClientParameters предупреждает заранее: «Make sure these parameters are set in coordination with the keepalive policy on the server, as incompatible settings can result in closing of connection» — несовместимые настройки по обе стороны соединения приводят к разрывам. Дефолты асимметричны: сервер пингует сам (ServerParameters.Time по умолчанию 2 * time.Hour), а клиент выключен — включать его нужно осознанно, и делать это нужно парой с серверной политикой, иначе получите самую знаменитую ловушку курса — GOAWAY too_many_pings, до которой дойдём через два блока.

### keepalive.ClientParameters: Time, Timeout, PermitWithoutStream

Три поля, три дефолта, три решения. Time — документация: «After a duration of this time if the client doesn't see any activity it pings the server to see if the transport is still alive» — столько тишины, после чего клиент шлёт PING. Дефолт — infinity, то есть выключено. С практическим значением помогает простое правило: ставьте Time меньше, чем таймаут простоя у NAT и балансировщика на пути, — обычно это минуты, а не секунды. Timeout: «After having pinged for keepalive check, the client waits for a duration of Timeout and if no activity is seen even after that the connection is closed» — сколько ждать PING ACK; дефолт 20 секунд, разумно 5–10. PermitWithoutStream: «If true, client sends keepalive pings even with no active RPCs. If false, when there are no active RPCs, Time and Timeout will be ignored and no keepalive pings will be sent» — пинговать ли соединение, на котором сейчас нет ни одного RPC. Включайте, только если сервер разрешает (EnforcementPolicy.PermitWithoutStream), — иначе пинги без стримов превращаются в страйки. И ещё одна строка документации, которую стоит выучить наизусть: «Note that gRPC servers have a default EnforcementPolicy.MinTime of 5 minutes (which means the client shouldn't ping more frequently than every 5 minutes)». Хорошая новость сразу за ней: «Time will automatically double if the server disconnects due to its enforcement policy» — клиент grpc-go умеет сам отходить назад, удваивая интервал, когда сервер порвал соединение за нарушение политики; это поведение из пропозала A8 client-side keepalive.

### Пинг без стримов против дефолтного сервера

```go
// клиент:
grpc.WithKeepaliveParams(keepalive.ClientParameters{
        Time:                30 * time.Second,
        Timeout:             10 * time.Second,
        PermitWithoutStream: true,
}),
// сервер: keepalive-настройки не тронуты, то есть
// EnforcementPolicy.MinTime = 5 минут, PermitWithoutStream = false
// RPC на соединении сейчас нет.
// Клиент пингует раз в 30 секунд. Что произойдёт?
```

**Что выведет программа?**

- ✅ Сервер засчитает страйки за пинги без активных стримов; после третьего шлёт GOAWAY с too_many_pings и рвёт соединение
- Ничего: клиент соблюдает свой минимум в 10 секунд, значит сервер обязан принимать пинги
- Каждый пинг сервер отвечает статусом ResourceExhausted
- Сервер молча игнорирует пинги, соединение живёт сколь угодно долго

Механика из http2_server.go: пинг пришёл, активных стримов меньше одного (ns < 1), а EnforcementPolicy.PermitWithoutStream выключен — в этой ветке сервер сверяет пинг не с MinTime, а с defaultPingTimeout = 2 часа: любой пинг чаще раза в два часа — страйк (pingStrikes++). Пинги едут раз в 30 секунд, значит каждый — страйк; константа maxPingStrikes = 2, и условие pingStrikes > maxPingStrikes срабатывает на третьем пинге: в очередь уходит GOAWAY с кодом ErrCodeEnhanceYourCalm и debug-данными «too_many_pings», соединение закрывается с ошибкой «got too many pings from the client». Клиент со своей стороны обязан логировать это видно: «Client received GoAway with error code ENHANCE_YOUR_CALM and debug data equal to ASCII "too_many_pings"» — и, по правилам A8, удваивать KEEPALIVE_TIME для новых соединений канала. Лечение — согласовать стороны: серверу EnforcementPolicy{MinTime: 30 * time.Second, PermitWithoutStream: true} или клиенту Time не меньше пяти минут.

### keepalive.ServerParameters и EnforcementPolicy: серверная сторона

ServerParameters — документация пакета: «ServerParameters is used to set keepalive and max-age parameters on the server-side». Пять полей. MaxConnectionIdle: «a duration for the amount of time after which an idle connection would be closed by sending a GoAway. Idleness duration is defined since the most recent time the number of outstanding RPCs became zero or the connection establishment» — сколько соединение простаивает без единого RPC, прежде чем сервер отправит GoAway. MaxConnectionAge: «a duration for the maximum amount of time a connection may exist before it will be closed by sending a GoAway. A random jitter of +/-10% will be added to MaxConnectionAge to spread out connection storms» — принудительный перекат соединений, причём джиттер ±10% добавлен специально, чтобы все коннекты, поднятые одновременно, не умирали одной волной. MaxConnectionAgeGrace: «an additive period after MaxConnectionAge after which the connection will be forcibly closed» — льгота на дообработку начатых RPC. Time: «After a duration of this time if the server doesn't see any activity it pings the client» — дефолт 2 часа, и минимум с подстраховкой: «If set below 1s, a minimum value of 1s will be used instead» (константа KeepaliveMinServerPingTime = time.Second; меньшее значение grpc-go поправит с предупреждением «Adjusting keepalive ping interval to minimum period of 1s»). Timeout — 20 секунд. EnforcementPolicy (опция grpc.KeepaliveEnforcementPolicy) — «used to set keepalive enforcement policy on the server-side. Server will close connection with a client that violates this policy»: MinTime по умолчанию 5 минут (defaultKeepalivePolicyMinTime = 5 * time.Minute), PermitWithoutStream — «If true, server allows keepalive pings even when there are no active streams(RPCs). If false, and client sends ping when there are no active streams, server will send GOAWAY and close the connection». Отдельная деталь механики: счётчик страйков обнуляется, когда сервер шлёт клиенту данные или заголовки — комментарий поля resetPingStrikes: «This is set whenever data or header frames are sent» — живой трафик реабилитирует даже нарушителя.

### Проверка: чем лечится too_many_pings

**Вопрос:** Клиенты жалуются: соединения рвутся, в логах сервера мелькает GOAWAY с too_many_pings. Какое действие проблеме НЕ поможет?

- ✅ Увеличить grpc.MaxRecvMsgSize на сервере
- Клиенту поднять Time до значения не меньше серверного EnforcementPolicy.MinTime
- Серверу ослабить EnforcementPolicy: уменьшить MinTime и включить PermitWithoutStream
- Согласовать PermitWithoutStream по обе стороны соединения

too_many_pings — конфликт политик пингов, а не лимитов сообщений: клиент пингует чаще, чем разрешает EnforcementPolicy сервера. Лечится с двух сторон: клиентом (Time больше или равен MinTime сервера) либо сервером (MinTime меньше, PermitWithoutStream: true, если пинги идут без RPC). Лимит MaxRecvMsgSize отвечает за размер сообщений — он вообще не участвует в keepalive-механике, его изменение не даст ничего. Диагностика тут простая: GOAWAY с ErrCodeEnhanceYourCalm и debug-данными too_many_pings в логе сервера, а у клиента — ошибка соединения и предупреждение «Client received GoAway with error code ENHANCE_YOUR_CALM...»; помните ещё, что клиент grpc-go автоматически удваивает Time для новых соединений после такого разрыва — то самое «Time will automatically double» из документации.

### Message size: какие значения ставить

Дефолты и ошибку ResourceExhausted с текстом «grpc: received message larger than max (%d vs. %d)» вы уже видели в [уроке про проводной формат](/courses/grpc-go/lessons/19-proto-opt/) — четыре мебибайта на приём, math.MaxInt32 на отправку, декомпрессия режется на границе лимита. Вопрос этого урока — какие значения ставить, когда легальный бизнес-объект не влезает. Первое: лимит приёма — защита памяти от чужих данных. Каждый принимаемый байт попадает в буферы до того, как вы разберёте поля, а буферы транспорта идут из пула (mem.BufferPool) и переиспользуются; потолок в гигабайт «на всякий случай» означает, что один недоброжелатель (или один баг клиента) в сто параллельных вызовах съест память сервера. Ставьте чуть выше самого большого легального сообщения — например, 8 МБ при реальном максимуме в 6. Второе: прежде чем поднимать потолок, спросите, не должен ли это быть стрим. Отчёт на двенадцать мегабайт — это кандидатов в server streaming по образцу WatchOrders из седьмого урока: каждый элемент стрима проходит лимит отдельно, память размазывается по времени, клиент начинает обрабатывать данные до конца передачи. Третье: лимиты ставятся парой на обоих концах — сервер отправил, клиент обязан принять. Четвёртое, одной фразой про компрессию: сжатие gzip из девятнадцатого урока экономит байты провода, но не спасает от лимита — gRPC применяет потолок и к результату декомпрессии. И потолок protobuf в два гибибайта из того же урока напоминает: физический предел вы всё равно не поднимете.

### Большой отчёт: настраиваем оба конца

```go
// Сервер: GetReport отдаёт сводку одним сообщением (~12 МБ)
func (h *v1Handler) GetReport(ctx context.Context,
        req *shopv1.ReportRequest) (*shopv1.ReportResponse, error) {
        o, err := h.orders(ctx, req.GetOrderId()) // канон: зависимость — поле-функция
        if err != nil {
                return nil, status.Errorf(codes.NotFound, "заказ %q: %v", req.GetOrderId(), err)
        }
        return buildReport(o), nil // сериализация уложится в 16 МБ с запасом
}

// main сервера — среди опций:
grpc.MaxSendMsgSize(16 * 1024 * 1024), // уходим выше дефолтных 4 МБ на приём у клиента

// main клиента — дефолт всех вызовов канала:
grpc.WithDefaultCallOptions(
        grpc.MaxCallRecvMsgSize(16 * 1024 * 1024), // иначе Recv вернёт ResourceExhausted
),
client := shopv1.NewOrderServiceClient(conn)
rep, err := client.GetReport(ctx, &shopv1.ReportRequest{OrderId: "42"})
```

Три числа в этом сниппете — 16 МБ у отправителя, 16 МБ у получателя и 12 МБ фактического сообщения: между потолком и реальностью оставлен запас на сериализационный оверхед и рост данных. Обратите внимание на симметрию ролей: MaxSendMsgSize у сервера — это CallOption-близнец MaxCallRecvMsgSize у клиента, и в конфиге они живут парами, зеркаля друг друга. Приёмная сторона сервера в этом сценарии не тронута: запрос крошечный, MaxRecvMsgSize 4 МБ хватает с запасом. И ещё раз про альтернативу: если такие отчёты летают регулярно, правильнее объявить server-streaming метод, который отдаёт сводку порциями, — лимиты тогда останутся дефолтными, а память сервиса не будет прыгать на десятки мегабайт под каждым вызовом.

### Кто забыл зеркальную настройку

```go
// сервер: MaxSendMsgSize НЕ задан — по умолчанию math.MaxInt32
// клиент: MaxCallRecvMsgSize НЕ задан — по умолчанию 4 МБ (4194304)
rep, err := client.GetReport(ctx, &shopv1.ReportRequest{OrderId: "42"})
// rep сериализуется в ~10 МБ. Что вернёт вызов?
```

**Что выведет программа?**

- ✅ err с кодом ResourceExhausted у клиента: сервер ушлёт сообщение, но Recv откажется разбирать рамку — «grpc: received message larger than max (10000000 vs. 4194304)»
- Сервер вернёт Internal: Send проверит лимит 4 МБ и упадёт на середине
- Всё пройдёт: лимиты согласовываются автоматически через HTTP/2 SETTINGS при установке соединения
- Соединение оборвётся: сообщение больше окна HTTP/2 физически не передаётся

На отправке лимита фактически нет: math.MaxInt32 — «If this is not set, gRPC uses the default math.MaxInt32», сервер спокойно кодирует десятомегабайтное сообщение и пишет его в соединение по частям. На приёме лимит строг: клиентский Recv видит в рамке длину 10000000, сверяет с потолком 4194304 и возвращает ResourceExhausted с дословным «grpc: received message larger than max (10000000 vs. 4194304)» — две цифры в тексте это фактический размер и текущий лимит, по ним за секунду видно, какая сторона отстала. SETTINGS HTTP/2 обмениваются окнами и MAX_CONCURRENT_STREAMS, но лимиты сообщений gRPC между концами не согласует. И окно тут не при чём: WINDOW_UPDATE регулирует темп передачи, сообщение любого размера едет по частям.

### Проверка: почему приём ограничен, а отправка нет

**Вопрос:** Почему дефолтный лимит приёма — 4 МБ, а отправки — math.MaxInt32?

- ✅ Приём — поверхность атаки: чужие данные занимают память до разбора полей; отправку ограничивать нечего — вы сами отвечаете за свои данные
- Историческая особенность proto2: большие входящие сообщения тогда не поддерживались
- Окно HTTP/2 равно 64 КиБ, поэтому больше четырёх мегабайт принять нельзя
- Так требует спецификация gRPC: 4 MiB приём, 2 GiB отправка

Лимит приёма — единственная защита сервера от «большого трафика в память»: рамка с длиной приезжает раньше содержимого, но буферы под содержимое выделяются и заполняются до того, как хендлер проверит хоть одно поле. У отправителя такой проблемы нет — данные свои, и gRPC сознательно не лезет со своим лимитом (math.MaxInt32 — технический максимум int32, фактический предел всё равно ставит protobuf: «Protos must be smaller than 2 GiB when serialized», вы это проходили в девятнадцатом уроке). Вариант про окно путает flow control с лимитами приложения; вариант про спецификацию выдумывает цифры, которых в спеке gRPC нет. Отсюда и практическое правило: поднимая MaxRecvMsgSize, вы расширяете поверхность атаки — делайте это с осознанной причиной и измеренным потолком.

### Окна HTTP/2 и BDP: динамика лучше ручек

Flow control HTTP/2 защищает от ситуации, когда быстрый отправитель топит медленного получателя: у каждого направления есть окна на двух уровнях — стрима и соединения; получатель продвигает их WINDOW_UPDATE-фреймами по мере чтения. Стартовое окно задаёт спецификация HTTP/2 — в defaults.go это defaultWindowSize = 65535 с комментарием «The default value of flow control window size in HTTP2 spec»: 64 КиБ. Но grpc-go не сидит на этом дефолте: транспорт носит BDP-оценщик — измеритель bandwidth-delay product, произведения пропускной способности канала на время его задержки. Как работает: транспорт отправляет служебный PING с восемью байтами данных {2, 4, 16, 16, 9, 14, 7, 7} — в исходниках это easter-egg, комментарий приглашает: «Easter-egg: what does the ping message say?» (байты читаются как буквы B-D-P-P-I-N-G-G), — и меряет, сколько данных успело приехать за один RTT. Оценка растёт множителем gamma = 2, сглаживается alpha = 0.9, включается при beta = 0.66, и потолок задан константой bdpLimit = (1 << 20) * 16 с комментарием: «bdpLimit is the maximum value the flow control windows will be increased to. TCP typically limits this to 4MB, but some systems go up to 16MB. Since this is only a limit, it is safe to make it optimistic» — то есть окна автоматически растут от 64 КиБ до 16 МиБ. Ручки под это есть: grpc.InitialWindowSize и grpc.InitialConnWindowSize у сервера, grpc.WithInitialWindowSize и grpc.WithInitialConnWindowSize у клиента — «The lower bound for window size is 64K and any value smaller than that will be ignored». Но документация StaticStreamWindowSize честно остужает энтузиазм: «Most users should not configure static flow control windows unless operating in a memory-constrained environment» — статические окна отключают BDP-динамику, и обычному приложению лезть туда не нужно. Правило курса: окна — последняя ручка, которую вы трогаете, и только под замером.

### MaxConcurrentStreams: предохранитель мультиплексирования

HTTP/2 мультиплексирует все RPC одного соединения в независимые стримы — десятки параллельных вызовов едут по одному TCP-сокету без головной блокировки на установке соединений. Оборотная сторона: без лимита один жадный клиент открывает столько стримов, сколько серверу придётся обслуживать, — соединение становится вектором перегрузки. Лимит и есть SETTINGS MAX_CONCURRENT_STREAMS; серверная ручка — grpc.MaxConcurrentStreams(n), документация: «MaxConcurrentStreams returns a ServerOption that will apply a limit on the number of concurrent streams to each ServerTransport» — на каждый транспорт, то есть на каждое соединение, а не на процесс в целом. Дефолт сервера — math.MaxUint32 из defaultServerOptions: не ограничено; ноль в вызове опции превращается в тот же MaxUint32. У клиента свой внутренний потолок — defaultMaxStreamsClient = 100 из internal/transport/defaults.go с механикой streamQuota: сверх квоты стримы ждут на соединении, а SETTINGS сервера обновляют клиентский лимит на лету (обработчик SETTINGS перечитывает MAX_CONCURRENT_STREAMS и двигает t.maxConcurrentStreams). Практика: ставьте явный MaxConcurrentStreams, когда за соединением стоит неуправляемый клиент или когда за сервером — медленный бэкенд (база, которую затопит тысяча параллельных GetOrder); лимит превращает лавину в аккуратную очередь и даёт клиенту сигнал уходить на другие соединения. Но помните связку с балансировщиком из шестнадцатого урока: лимит стримов — предохранитель, а не замена раскладке нагрузки.

### Idle-таймауты: закалка соединений

Продакшен любит соединения с истекающим сроком годности — они сами раскладывают нагрузку и вымывают утечки. Серверные ручки вы уже видели в ServerParameters. MaxConnectionIdle закрывает GoAway-ом простаивающее соединение — «idleness duration is defined since the most recent time the number of outstanding RPCs became zero»; MaxConnectionAge ограничивает максимальный возраст: «a duration for the maximum amount of time a connection may exist before it will be closed by sending a GoAway. A random jitter of +/-10% will be added to MaxConnectionAge to spread out connection storms»; MaxConnectionAgeGrace — «an additive period after MaxConnectionAge after which the connection will be forcibly closed». Все три дефолтно равны infinity. Клиентская сторона — DialOption grpc.WithIdleTimeout, и документация описывает два уровня: «If the channel is idle for the configured timeout, i.e there are no ongoing RPCs and no new RPCs are initiated, the channel will enter idle mode and as a result the name resolver and load balancer will be shut down. The channel will exit idle mode when the Connect() method is called or when an RPC is initiated» — канал уходит в idle и глушит резолвер с балансировщиком; «A default timeout of 30 minutes will be used if this dial option is not set at dial time and idleness can be disabled by passing a timeout of zero». Разница уровней важна: MaxConnectionAge — принудительный перекат транспорта (GoAway, клиент молча переоткроет соединение), idle-канал — экономия ресурсов клиента, когда RPC нет вовсе; дедлайны же из четырнадцатого урока — про тайм-аут отдельного вызова, это третья, независимая ось времени.

### Соберите тюнинг-конфиг сервера

Вставьте пропущенные имена опций и структур: лимит приёма, серверные keepalive-параметры, политика пингов и её поле-минимум.

```go
srv := grpc.NewServer(
        grpc.___(10 * 1024 * 1024), // потолок приёма: 10 МБ вместо 4
        grpc.MaxConcurrentStreams(1000),
        grpc.KeepaliveParams(keepalive.___{ // keepalive и max-age сервера
                MaxConnectionIdle: 5 * time.Minute,
                Time:              time.Minute,
        }),
        grpc.KeepaliveEnforcementPolicy(keepalive.___{ // политика чужих пингов
                ___: 30 * time.Second, // пинги чаще — страйк и TOO_MANY_PINGS
                PermitWithoutStream: true,
        }),
)
```

Подсказка: Одна ServerOption для лимита, две структуры пакета keepalive с суффиксами Parameters и Policy, поле-длительность в политике.

Правильные ответы в пропусках: `MaxRecvMsgSize`, `ServerParameters`, `EnforcementPolicy`, `MinTime`

### Порядок тюнинга: от замера до выката

Соберите дисциплину тюнинга из девяти шагов — ровно в том порядке, в котором её проходил бы инженер с бенчмарками из семнадцатого урока.

Правильный порядок строк:

1. `зафиксировать baseline: latency p95/p99, throughput, память на реалистичной нагрузке`
2. `построить профиль: pprof по CPU и куче — где теряется время и память`
3. `сформулировать гипотезу: какая ручка (keepalive, лимит, окно) и почему изменит метрику`
4. `изменить ровно одну ручку за итерацию — значение с комментарием-обоснованием`
5. `повторить замер тем же способом и той же нагрузкой`
6. `сравнить с baseline инструментально (benchstat), а не «на глаз»`
7. `прогнать тесты: bufconn-набор прошлого урока обязан остаться зелёным`
8. `выкатить под мониторингом и сверить прод-метрики с ожиданиями`
9. `зафиксировать результат в коде и описать решение в ревью`

### Шпаргалка ручек тюнинга grpc-go

| Ручка | Сторона | Дефолт | Когда крутить |
| --- | --- | --- | --- |
| grpc.MaxRecvMsgSize | сервер | 4 MiB (1024 * 1024 * 4) | легальные большие запросы; помнить про память и поверхность атаки |
| grpc.MaxSendMsgSize | сервер | math.MaxInt32 | почти никогда — отправка и так не ограничена |
| grpc.MaxCallRecvMsgSize | клиент | 4 MiB | сервер отдаёт большие сообщения; зеркально серверному MaxSendMsgSize |
| grpc.MaxCallSendMsgSize | клиент | math.MaxInt32 | почти никогда |
| ClientParameters.Time | клиент | infinity — keepalive выключен | NAT/LB режут простаивающие соединения; не меньше 10s |
| ClientParameters.Timeout | клиент | 20 секунд | жёстче — быстрее обнаружение мёртвых коннектов |
| ServerParameters.Time / Timeout | сервер | 2 часа / 20 секунд | сервер сам проверяет живость клиентов |
| EnforcementPolicy.MinTime | сервер | 5 минут | разрешить клиентам пинговать чаще; иначе too_many_pings |
| MaxConnectionIdle / Age / Grace | сервер | infinity | перекат соединений, ре-балансировка за LB |
| grpc.MaxConcurrentStreams | сервер | math.MaxUint32 | предохранитель от лавины стримов на соединение |
| InitialWindowSize (окна) | обе | 65535, BDP поднимает до 16 MiB | почти никогда — BDP-оценщик справляется сам |
| grpc.WithIdleTimeout | клиент | 30 минут | глушить резолвер и LB при простое канала |

> **Два разных минимума: 10s у клиента, 1s у сервера.** Легко перепутать два клампа keepalive-интервалов. Клиент: WithKeepaliveParams проверяет Time против KeepaliveMinPingTime = 10 * time.Second — меньше не выйдет, в лог уйдёт «Adjusting keepalive ping interval to minimum period of 10s» (и документация поля соглашается: «If set below 10s, a minimum value of 10s will be used instead»). Сервер: KeepaliveParams проверяет Time против KeepaliveMinServerPingTime = time.Second — минимум секунда, предупреждение «Adjusting keepalive ping interval to minimum period of 1s». Оба молча правят вашу конфигурацию: вы просили 3s — получили 10s, а тайминг рассинхронизировался с серверной политикой. Вывод практиков: не ставьте Time ниже разрешённого сервером MinTime и читайте предупреждения grpc-go при старте — они честно говорят, что вашу цифру поправили.

### Чек-лист тюнинга gRPC

- Сначала замер (baseline: latency, throughput, память), потом одна ручка за итерацию, потом повторный замер
- Лимиты сообщений: приём — чуть выше самого большого легального сообщения, отправка — зеркально на обоих концах
- Большой ответ чаще должен быть server streaming, чем поднятым MaxSendMsgSize
- keepalive включается парой: клиентский Time/PermitWithoutStream обязаны согласовываться с EnforcementPolicy сервера
- Клиентский Time не ниже 10s (кламп KeepaliveMinPingTime), серверный — не ниже 1s (KeepaliveMinServerPingTime)
- too_many_pings в логах — конфликт политик пингов: ослабить MinTime сервера или замедлить Time клиента
- Окна HTTP/2 не трогать без замера: BDP-оценщик сам растит окна от 64 КиБ до 16 МиБ
- MaxConcurrentStreams и MaxConnectionAge/Idle — предохранители продакшена; дефолты у них «бесконечность»

### Практика: три эксперимента локально

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — gRPC-сервер и клиент в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет; каждое упражнение делается локально за несколько минут, а проверяют вас quiz, predict, fill, order и карточки. Эксперимент первый, самый зрелищный: поднимите дефолтный сервер из четвёртого урока, а клиент соберите с Time: 30 * time.Second и PermitWithoutStream: true — и молчите; в логах клиента появится «Client received GoAway with error code ENHANCE_YOUR_CALM and debug data equal to ASCII "too_many_pings"», а сервер зафиксирует разрыв. Затем добавьте серверу EnforcementPolicy{MinTime: 30 * time.Second, PermitWithoutStream: true} и убедитесь, что соединение стало вечным. Эксперимент второй: поднимите MaxRecvMsgSize на сервере до 8 МБ, но забудьте про клиентский MaxCallRecvMsgSize, отправьте 6-мегабайтный ответ и разберите ResourceExhausted по цифрам в тексте ошибки — какая сторона отстала видно мгновенно. Эксперимент третий: задайте MaxConnectionAge: 5 * time.Second на сервере и понаблюдайте в подробных логах, как соединения перекатываются GoAway-ами — и как джиттер разбрасывает моменты разрыва.

### Что дальше: много сервисов, много настроек

Курс подходит к развязке: у вас есть транспорт, безопасность, наблюдаемость, тесты и теперь — карта ручек производительности. Двадцать третий урок — gRPC в микросервисной архитектуре — поднимает масштаб: gateway-паттерны, облако и обзор service mesh, где половину ручек этого урока перекладывают на прокси вроде Envoy, а keepalive-политика из хитрого эксперимента превращается в контракт между командами, записанный в манифестах деплоя. Имеет смысл перечитать этот урок как справочник, а не как рассказ: таблица-шпаргалка выше — то, что вы будете открывать при каждом «прод тормозит». Напоследок три связки. Полный маршрут курса — в [оглавлении gRPC-курса](/courses/grpc-go/); форматы и gzip вы разбирали в [уроке про проводной формат](/courses/grpc-go/lessons/19-proto-opt/); и перед любым тюнингом прогоняйте bufconn-набор из [урока про тестирование](/courses/grpc-go/lessons/21-testirovanie/) — зелёный тест после изменения настроек транспорта значит, что контракт цел, а выиграли вы или проиграли покажет только замер.

### Карточки урока

- **keepalive.ClientParameters.Time** — Тишина, после которой клиент шлёт PING. Дефолт infinity — keepalive выключен («Keepalive is disabled by default»); минимум 10s: «If set below 10s, a minimum value of 10s will be used instead». Без PermitWithoutStream пинги идут только при активных RPC.
- **too_many_pings** — GOAWAY с ErrCodeEnhanceYourCalm и debug-данными too_many_pings: сервер рвёт соединение после pingStrikes > maxPingStrikes (= 2). Клиент логирует ENHANCE_YOUR_CALM и удваивает Time для новых соединений. Лечение — согласовать Time клиента с EnforcementPolicy.MinTime сервера (дефолт 5 минут).
- **MaxRecvMsgSize / MaxCallRecvMsgSize** — Лимит приёма: сервер и клиент по умолчанию 4 MB (1024 * 1024 * 4 = 4194304). Превышение — ResourceExhausted «grpc: received message larger than max (%d vs. %d)». Ставить чуть выше максимального легального сообщения и зеркально на обоих концах.
- **MaxSendMsgSize / MaxCallSendMsgSize** — Лимит отправки: по умолчанию math.MaxInt32 — gRPC свои исходящие сообщения не ограничивает; фактический потолок ставит protobuf (2 GiB сериализации). Настраивать почти никогда, но помнить о памяти под буферы при пиках.
- **Окна HTTP/2 и BDP** — Flow control: окно стрима и соединения, дефолт 65535 из спецификации. BDP-оценщик grpc-go меряет bandwidth-delay product PING-ом с payload B-D-P-P-I-N-G-G и растит окна до bdpLimit 16 MiB. Ручки InitialWindowSize — последняя надежда, «Most users should not configure static flow control windows».
- **MaxConcurrentStreams и max-age** — MaxConcurrentStreams — лимит одновременных стримов на транспорт: сервер дефолтно math.MaxUint32, клиент внутренний defaultMaxStreamsClient = 100. MaxConnectionIdle/MaxConnectionAge закрывают простаивающие и старые соединения GoAway-ом с джиттером ±10% «to spread out connection storms»; клиентский WithIdleTimeout по умолчанию 30 минут.

## Коротко: что запомнить

- Производительность grpc начинается с замера: baseline по latency p95/p99, throughput и памяти, потом одна ручка за итерацию и повторный замер — дисциплина бенчмаркинга из go-advanced.
- Лимиты сообщений: приём по умолчанию 4 MiB (ResourceExhausted «grpc: received message larger than max»), отправка math.MaxInt32; значения ставить парой на обоих концах и чуть выше легального максимума.
- keepalive включается парой: клиентские Time/Timeout/PermitWithoutStream против серверных ServerParameters и EnforcementPolicy; несовместимость — GOAWAY too_many_pings.
- Два минимума интервалов: клиентский Time не ниже 10s (KeepaliveMinPingTime), серверный — не ниже 1s (KeepaliveMinServerPingTime); grpc-go молча поправит с предупреждением.
- Окна HTTP/2: дефолт 65535, BDP-оценщик сам растит их до 16 MiB — статические окна трогать только под замером и в памяти-констрейнт среде.
- MaxConcurrentStreams (сервер — math.MaxUint32, клиент — внутренние 100) и MaxConnectionIdle/Age с джиттером ±10% — предохранители продакшена от лавин и ржавых соединений.

## Домашнее задание

Соберите тюнинг-конфиг для сервиса магазина: сервер с MaxRecvMsgSize 8 МБ, MaxConcurrentStreams 1000, ServerParameters{MaxConnectionIdle: 5 минут, MaxConnectionAge: 30 минут, Time: 2 минуты, Timeout: 10 секунд} и EnforcementPolicy{MinTime: 30 секунд, PermitWithoutStream: true}; клиент с зеркальными лимитами вызовов и ClientParameters{Time: 30 секунд, Timeout: 10 секунд, PermitWithoutStream: true}. Затем проведите цикл тюнинга по порядку из урока: baseline-замер latency на bufconn-тесте, одна ручка, повторный замер. Отдельно воспроизведите too_many_pings: дефолтный сервер против клиента с PermitWithoutStream и пингами раз в 30 секунд — и почините обеими сторонами по очереди, сравнив поведение в логах.

## Вопросы и ответы по уроку

### Какой keepalive-интервал выставить клиенту?

Меньше, чем таймаут простоя у NAT и балансировщика на пути, но не меньше EnforcementPolicy.MinTime сервера. Дефолтный сервер разрешает пинг не чаще раза в 5 минут; если нужно чаще — ослабьте политику сервера (MinTime меньше, PermitWithoutStream: true) и только затем ускоряйте клиента. Ниже 10 секунд клиентский интервал всё равно не уедет — grpc-go поднимет его до минимума с предупреждением.

### Почему соединения рвутся с too_many_pings и как это чинить?

Клиент пингует чаще, чем разрешает серверная EnforcementPolicy: без активных стримов и без PermitWithoutStream сервер засчитывает страйки за каждый пинг и после третьего шлёт GOAWAY с ErrCodeEnhanceYourCalm и too_many_pings. Лечится согласованием сторон: увеличьте клиентский Time, уменьшите серверный MinTime или включите PermitWithoutStream на сервере. Клиент grpc-go после такого разрыва сам удваивает Time для новых соединений.

### Насколько поднимать MaxRecvMsgSize и MaxSendMsgSize?

Приём — чуть выше самого большого легального сообщения, с запасом на сериализационный оверхед: каждый поднятый мегабайт означает потенциальную память под буферы при пике. Отправка по умолчанию math.MaxInt32 и настраивается редко. Если большие ответы регулярны — переходите на server streaming: лимит остаётся на элемент потока, память размазывается по времени.

### Стоит ли настраивать окна HTTP/2 вручную?

Обычно нет: grpc-go носит BDP-оценщик, который меряет пропускную способность и задержку канала и сам растит окна от 65535 до 16 MiB. Ручки InitialWindowSize и InitialConnWindowSize существуют, нижняя граница 64 КиБ (меньшее значение игнорируется), но документация прямо предупреждает: большинство пользователей не должны настраивать статические окна вне памяти-констрейнт окружений — динамика справляется лучше.
