---
title: "Compression и производительность"
description: "Производительность Connect: gzip с порогом WithCompressMinBytes, цена компрессии для CPU, decompression-бомбы под защитой лимитов и HTTP/2-соединения."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/14-compression/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Compression и производительность

*Курс «Connect: современный RPC от Buf», урок 14 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/14-compression/*

connect производительность складывается из немногих ручек: кодека, сжатия, лимитов и транспорта. Разбираем gzip, который в Connect работает из коробки, порог WithCompressMinBytes против бессмысленной компрессии мелочи, честную цену сжатия и decompression-бомбы, о которых тринадцатый урок оставил спойлер, — плюс переиспользование HTTP/2-соединений, которое даёт больше, чем любой флаг компрессии.

Производительность RPC-сервиса на Connect складывается из четырёх ручек, и все четыре вы уже держали в руках. Кодек и протокол — сравнение трёх жанров из [четвёртого урока](/courses/connect/lessons/04-protokoly/), где прото-бинарник выигрывал у JSON по размеру и скорости. Лимиты — WithReadMaxBytes из [тринадцатого урока](/courses/connect/lessons/13-bezopasnost/), потолок входящего сообщения. Транспорт — http.Client, на котором вы собирали клиента в [пятом уроке](/courses/connect/lessons/05-klienty/). Четвёртая ручка — сжатие: gzip-компрессия тел сообщений, которая в Connect поддержана из коробки и управляется парой опций. Соберём connect производительность целиком — но в честном порядке: сжатие здесь самая переоценённая ручка, а транспорт — самая недооценённая, поэтому начнём с gzip-а и закончим соединениями. Главный принцип урока: сначала измерение, потом настройка. Сжатие экономит байты в сети и платит CPU на обоих концах; для одних профилей трафика это выгодная сделка, для других — чистый убыток. Мультиплексирование HTTP/2 экономит рукопожатия и не платит ничем — но только пока соединения вообще переиспользуются, а это держится на одной строчке конфигурации клиента, которую забывают чаще всего. Лимиты ничего не экономят, зато гарантируют, что оптимизация трафика не обернётся распакованной бомбой. Профиль вашего трафика решает всё: сервис больших выгрузок и сервис тикового потока мелких вызовов настраиваются противоположно, и универсального дефолта не существует — есть порог, который вы выставили осознанно. По дороге продолжится канон курса: проверять настройки будем тестами полного стека из шестнадцатого урока, а увидеть эффект на живом трафике помогут метрики и размеры сообщений, которые появятся в семнадцатом — без них сжатие остаётся предположением, а не инженерным решением.

### Сжатие из коробки: gzip уже внутри

Сжатие grpc-сообщений в классическом стеке — это церемония: компрессор регистрируется на сервере через encoding.RegisterCompressor, клиент выбирает его call-опцией grpc.UseCompressor, и всё это надо не забыть на обеих сторонах. В Connect из коробки работает gzip: хендлер принимает сжатые запросы и умеет сжимать ответы, клиент принимает сжатые ответы — без единой строки конфигурации. Механика — стандартная HTTP-договорённость. Клиент анонсирует, что понимает, заголовком Accept-Encoding: gzip; сервер, решив сжать ответ, ставит Content-Encoding: gzip; клиент распаковывает до десериализации. Для запросов в обратную сторону то же самое: клиент, которому сказали сжимать исходящее, шлёт тело с Content-Encoding: gzip, и хендлер распаковывает его до передачи кодеку. Отсюда первое следствие: чтобы получать сжатые ответы, вам обычно не нужно ничего — gzip в списке поддержанных алгоритмов обеих сторон с первого дня. Чтобы отправлять сжатые запросы, есть опция connect.WithSendGzip: клиент сжимает исходящие сообщения unary-вызовов и стримов. Если алгоритмов хочется больше — например, zstd ради скорости — пул компрессоров подключается опцией WithCompression с именем, а клиент выбирает алгоритм через WithCompressionName; gzip при этом остаётся нулевой ценой входа. Второе следствие важнее: сжатие в Connect действует на сообщение, а не на соединение. В стримах из [восьмого урока](/courses/connect/lessons/08-streaming/) каждое сообщение конверта сжимается отдельно — мелкие сообщения внутри длинного стрима порог не пройдёт и поедет как есть, крупные сожмутся. Это отличает connect-модель от идеи «сжать весь HTTP-ответ целиком» и делает порог осмысленным на уровне каждого сообщения. И третье: сжатие — не флаг «включить или выключить», а переговоры. Если клиент не анонсировал gzip, сервер не будет жать; если анонсированного алгоритма у сервера нет, уедет identity — сырое сообщение без Content-Encoding. Всю эту механику вы не увидите в логах — только в трафике, поэтому ниже мы закрепим её порогами и тестами.

### Порог на сервере, gzip на клиенте

```go
// Хендлер: порог сжатия ответов и лимит входящего
path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithCompressMinBytes(1<<10), // ниже килобайта не жмём
        connect.WithReadMaxBytes(1<<20),     // потолок входящего сообщения
)
mux.Handle(path, handler)

// Клиент: один http.Client на процесс — правило пятого урока
httpc := &http.Client{
        Transport: &http.Transport{
                TLSClientConfig:     &tls.Config{MinVersion: tls.VersionTLS12},
                MaxIdleConnsPerHost: 100, // пул под параллельные вызовы
        },
}
client := notesv1connect.NewNoteServiceClient(httpc, "https://notes.acme.example",
        connect.WithSendGzip(),              // gzip для исходящих unary и стримов
        connect.WithCompressMinBytes(1<<10), // и на клиенте без мелочи
)
```

Опция WithCompressMinBytes стоит на обеих сторонах и работает независимо: серверная регулирует ответы, клиентская — сжатые запросы через WithSendGzip. WithReadMaxBytes обязателен: без него дефолт «без лимита», а при gzip-е на входе это прямая дорога к decompression-бомбам из конца урока.

**Вопрос:** Клиент Connect (Go) вызывает unary-метод, и ответ на него большой. Что нужно, чтобы ответ приехал сжатым gzip-ом?

- ✅ Ничего: gzip поддержан из коробки — сервер сожмёт ответ, если клиент анонсировал Accept-Encoding: gzip и сообщение прошло порог
- Зарегистрировать компрессор на сервере через encoding.RegisterCompressor
- Поставить заголовок Content-Encoding: gzip на клиенте руками
- Отключить HTTP/2 — сжатие работает только на HTTP/1.1

Сжатие ответов — переговоры: gzip поддержан на обеих сторонах с первого дня, клиент анонсирует его автоматически, сервер сжимает, когда сообщение прошло порог WithCompressMinBytes. encoding.RegisterCompressor — это церемония из grpc-go, здесь не нужная; Content-Encoding клиент ставит только на исходящие при WithSendGzip, а HTTP/2 к сжатию не привязан.

### WithCompressMinBytes: порог, который экономит CPU

Порог называется connect.WithCompressMinBytes: сообщения меньше указанного размера уходят без сжатия, даже если обе стороны gzip анонсировали. По умолчанию порог равен нулю — сжимается всё согласованное, включая сообщения в десятки байт. Почему это плохо: gzip не знает про RPC — он сжимает поток байт, и на крошечном входе его выхлоп исчезает. Сообщение в двести байт после gzip-а занимает примерно столько же или больше: словарь, заголовок блока и минимальный размер сжатого блока съедают выигрыш, а CPU потрачен на оба конца — сжатие на сервере, распаковка на клиенте. На миллионах мелких вызовов это чистый убыток и по CPU, и по латентности: p99 растёт на доли миллисекунды, которых у тикового трафика нет. Отсюда рабочая настройка: WithCompressMinBytes(1<<10) как старт — всё, что меньше килобайта, едет сырое, всё, что больше, имеет шанс окупиться. Точная цифра зависит от профиля: для сервиса, пересылающего логи и вложения, порог смело поднимается; для интерактивного API с мелкими ответами сжатие может не окупаться вовсе — и это тоже валидное решение, зафиксированное в конфигурации. Второй нюанс — чей порог. Опция стоит и на клиенте, и на хендлере, и это два независимых числа: клиентский решает, сжимать ли исходящие запросы, серверный — сжимать ли ответы. В стримах порог применяется к каждому сообщению отдельно, поэтому длинный стрим из восьмого урока ведёт себя как лента: мелкие сообщения едут сырыми, крупные — сжатыми, и это ровно то, чего хочется. И помните про identity из прошлого абзаца: сообщение ниже порога уезжает с identity, а не с ошибкой — это штатный путь, а не деградация сервиса.

### Что уедет по сети в ответе?

```go
// Хендлер: connect.WithCompressMinBytes(1<<10)
// Клиент: обычный NewNoteServiceClient, unary-вызов GetNote

// Хендлер возвращает сообщение в 300 байт прото-бинарника.
// Клиент анонсировал Accept-Encoding: gzip — так делает любой Connect-клиент.
```

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

- Сжатый ответ с Content-Encoding: gzip — анонс был, значит жмём всё
- ✅ Несжатый ответ без Content-Encoding: 300 байт меньше порога в один КиБ
- Сжатый ответ с ошибкой: сообщение меньше порога — CodeInvalidArgument
- Ответ с Content-Encoding: identity и обрывом соединения

Порог WithCompressMinBytes работает до компрессора: сообщение меньше порога отправляется как есть, без Content-Encoding. Анонс Accept-Encoding лишь даёт серверу право жать, а не обязанность. Никакой ошибки нет — это штатный путь identity, о котором говорили в разделе про переговоры.

### Цена компрессии: когда gzip окупается

Окупается ли gzip — вопрос про данные, а не про флаги. Прото-бинарник уже плотный: varint-числа, теги полей в один-два байта, никакого XML-заполнителя — повторяющихся шаблонов для словаря gzip-а мало, поэтому типичное сжатие бинарных сообщений скромное, в разы, а не в десятки раз. JSON-кодек из четвёртого урока — другое дело: текстовые имена полей и пробелы жмутся отлично, и на JSON-трафике выигрыш заметнее. Тексты, логи, вложения — лучший случай; маленькие структурные ответы — худший. Вторая сторона сделки — CPU и латентность: сжатие стоит на отправителе, распаковка на получателе, и оба вносят задержку до того, как байты вообще уйдут в сеть. На больших payload-ах сеть доминирует и сделка выгодна; на мелких частых вызовах CPU и латентность доминируют, и сделка убыточна. Третий фактор — загрузка: сжатие конкурирует с вашим хендлером за те же ядра. Если сервис упирается в CPU, gzip на каждом ответе урежет пропускную способность сильнее, чем выиграет сеть; если упирается в сеть — наоборот. Отсюда метод, а не догма: включите порог, прогоните бенчмарки на реалистичных данных — benchstat покажет дельту, pprof укажет, сколько CPU съел gzip, а тест полного стека из шестнадцатого урока зафиксирует поведение порога. В проде доверяйте метрикам: размеры сообщений и латентности из семнадцатого урока покажут, окупился ли эксперимент, — и откатите опцию, если нет: это одна строка конфигурации, а не переписывание кода. Отдельный случай — компрессия на границе: ingress и прокси умеют сжимать сам HTTP-трафик, и иногда правильное место для gzip-а именно там, а не в приложении; это решение двадцать второго урока, где деплой-топология определит, кто жмёт.

**Вопрос:** Сервис шлёт миллионы unary-вызовов по 200-300 байт. Команда включила WithSendGzip и оставила WithCompressMinBytes(0). Что они получат?

- Экономию трафика без потерь: gzip всегда выгоден
- ✅ Рост CPU на оба конца и нулевую экономию байт: gzip на крошечных сообщениях почти не сжимает, а служебные байты и работа стоят
- Ошибку конфигурации: порог ноль запрещён
- Отключение HTTP/2: сжатые вызовы идут только по HTTP/1.1

Порог ноль означает «сжимать всё согласованное» — и на мелких сообщениях gzip проигрывает: сжатый блок не меньше исходного, а CPU потрачен на сжатие и распаковку с обеих сторон. Лечение — WithCompressMinBytes(1<<10) и выше по профилю; на тиковом трафике честным решением бывает и полный отказ от сжатия исходящих.

### Ручки производительности Connect

| Инструмент | Сторона | Что делает | Когда включать |
| --- | --- | --- | --- |
| WithCompressMinBytes | клиент и сервер | порог: сообщения меньше него не сжимаются | всегда; старт с 1<<10 |
| WithSendGzip | клиент | сжимает исходящие unary и стримы gzip-ом | когда исходящие payload-ы большие |
| WithCompressionName | клиент | выбирает алгоритм сжатия запросов по имени | если gzip не подходит, например zstd |
| gzip из коробки | обе | сжатие ответов по Accept-Encoding без настройки | по умолчанию |
| WithReadMaxBytes | сервер | потолок входящего объёма, держит decompression-бомбы | всегда — это защита, не тюнинг |
| http.Transport | клиент | пул соединений: MaxIdleConnsPerHost, HTTP/2 | всегда — один клиент на процесс |

### Транспорт: один клиент, живой пул соединений

```go
var sharedHTTP = &http.Client{
        Transport: &http.Transport{
                MaxIdleConns:        100,
                MaxIdleConnsPerHost: 100, // дефолт 2 — под нагрузкой мало
                IdleConnTimeout:     90 * time.Second,
                ForceAttemptHTTP2:   true, // HTTP/2 и по открытому http://
        },
}
```

MaxIdleConnsPerHost — самый недооценённый параметр Go-транспорта: с дефолтом «два» простаивающие соединения закрываются, и параллельные вызовы платят новыми рукопожатиями. ForceAttemptHTTP2 включает HTTP/2 без TLS; поверх https он и так включается автоматически. Клиент — глобальный, собирается один раз при старте.

### HTTP/2 и соединения: главная экономия не в gzip

Теперь ручка, которая даёт больше, чем всё сжатие вместе: транспорт. Главный источник латентности в RPC — не байты, а рукопожатия: TCP-коннект, затем TLS-рукопожатие на https-адресах — единицы и десятки миллисекунд на каждый новый запрос, если соединения не переиспользуются. HTTP/2 решает это мультиплексированием: множество параллельных вызовов идут в одном TCP-соединении, и повторное использование бесплатно — но только пока соединение живёт. За жизнь соединений отвечает http.Client, и вот где живут самые дорогие баги производительности. Первая ошибка — создавать клиент на каждый запрос: новый http.Client с нулевым Transport-ом не разделяет пул ни с кем, каждое обращение платит рукопожатие заново. Правило канона из пятого урока — один http.Client на процесс, первый аргумент сгенерированного клиента. Вторая ошибка тоньше: дефолтный http.DefaultTransport держит MaxIdleConnsPerHost равным двум — под параллельной нагрузкой простаивающие сверх двух соединения закрываются, и мультиплексирование HTTP/2 начинает соседствовать с новыми рукопожатиями. Лечение — свой Transport с повышенным MaxIdleConnsPerHost: клиент из [тринадцатого урока](/courses/connect/lessons/13-bezopasnost/) с TLSClientConfig уже строился на собственном Transport — сюда же добавьте и пул. Третий нюанс — HTTP/2 сам по себе: поверх TLS он включается автоматически, и у двунаправленных стримов из восьмого урока нет выбора — они требуют HTTP/2. По открытому http:// HTTP/2 в Go включается флагом ForceAttemptHTTP2 и требует согласования с сервером; на границе инфраструктуры протокол может рваться на hop-е — это история про ingress и деплой, вернёмся к ней в двадцать втором уроке. Заметьте иерархию: правильный транспорт экономит десятки миллисекунд на вызов и не стоит ничего; gzip экономит килобайты и стоит CPU. Настраивайте в этом порядке.

**Вопрос:** Под нагрузкой сервис тратит время на новые TCP+TLS рукопожатия при каждом вызове, хотя все вызовы идут к одному хосту. Где ошибка?

- Нужен WithCompressionName на сервере
- ✅ Клиент создаётся на каждый запрос или транспорт оставлен с дефолтом: один общий http.Client с MaxIdleConnsPerHost побольше держит пул соединений
- Надо отключить gzip — он рвёт соединения
- HTTP/2 не мультиплексирует вызовы, нужен HTTP/1.1

Рукопожатия на каждый вызов — признак того, что пул соединений не живёт: либо клиент пересоздаётся и пул теряется, либо MaxIdleConnsPerHost равен двум и простаивающие соединения закрываются под параллельностью. Сжатие тут ни при чём, а HTTP/2 как раз мультиплексирует — если соединение выжило.

### Decompression-бомбы: обещание тринадцатого урока

Обещание тринадцатого урока пора исполнить: у лимита чтения есть вторая роль, кроме DoS-защиты, — decompression-бомбы. Идея атаки проста: тело, которое сжимается в сорок килобайт, распаковывается в сотни мегабайт и гигабайты — gzip-у всё равно, он честно сжимает длинные повторяющиеся последовательности. Атакующий шлёт крошечный сжатый запрос, а сервер обязан где-то удержать распакованный результат до десериализации — классическая амплитудная атака, и сжатие здесь не защита, а оружие: сжатый трафик ещё и дешевле отправлять. Что с этим делает Connect: WithReadMaxBytes из тринадцатого урока ограничивает объём, который хендлер примет и распакует, — ресурсный отказ приходит раньше вашего хендлера, и память сервиса не видит гигантского результата. Механика проверки у протоколов различается: в стриминговых жанрах размер сообщения объявлен в префиксе конверта, и превышение видно сразу; в unary тело читается с ограничением — но гарантия на выходе одна: сколько бы байт ни обещал gzip, дальше лимита вы не прочитаете. Поэтому связка урока — порог сжатия и лимит чтения — не два независимых тюнинга, а пара «экономия и защита»: WithCompressMinBytes бережёт CPU от бессмысленной компрессии, WithReadMaxBytes бережёт память от злонамеренной декомпрессии. Практический вывод: любой сервис, принимающий сжатые запросы, обязан иметь лимит чтения — дефолт «без лимита», как мы говорили в тринадцатом, это потенциальный DoS, а при компрессии цена ошибки растёт в тысячи раз. Проверяется всё это тестом полного стека: тело бомбы уходит сырым POST-ом с Content-Encoding: gzip, тест ожидает ресурсный отказ — тот же приём, которым в шестнадцатом уроке проверяются коды ошибок вообще.

### Бомба против лимита

```go
// Защита: лимит обязателен на каждом принимающем сжатое хендлере
path, handler := notesv1connect.NewNoteServiceHandler(
        &notesServer{},
        connect.WithReadMaxBytes(1<<20), // 1 МиБ на принятое сообщение
)

// Атакующий шлёт НЕ через connect-клиент, а сырым POST-ом
// с Content-Encoding: gzip: 40 КиБ, распаковывающиеся в 500 МиБ.
// Сервер отвечает ресурсным отказом до входа в хендлер —
// память сервиса не растёт: лимит срезал распаковку.
```

Тело атаки сжимается в килобайты, распаковывается в гигабайты — WithReadMaxBytes останавливает чтение раньше, чем память увидит результат. В шестнадцатом уроке этот сценарий станет тестом полного стека: сырой запрос с Content-Encoding: gzip, сервер с лимитом, ожидание ресурсного кода.

### Что ответит сервер?

```go
// Сервер: connect.WithReadMaxBytes(1<<20)
// Атакующий шлёт сырой POST с Content-Encoding: gzip:
// 40 КиБ сжатых данных распаковываются в 500 МиБ нулей.
```

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

- 200: лимит проверяет только несжатые сообщения
- ✅ Ресурсный отказ (ResourceExhausted) до входа в хендлер: память сервиса не растёт
- 500 с паникой и OOM: gzip распакует всё
- 401: сжатые запросы требуют аутентификации

Лимит чтения действует на принимаемый объём независимо от сжатия: распаковка останавливается ресурсным отказом, хендлер не вызывается, память не растёт. Именно поэтому WithReadMaxBytes из тринадцатого урока — обязательная настройка любого сервиса, принимающего сжатые запросы, а не опциональный тюнинг.

### Пять правил производительности Connect

- Измеряйте до настройки: профиль трафика решает, окупится ли сжатие
- Ставьте WithCompressMinBytes(1<<10): мелочь gzip-ом не сжимать
- WithSendGzip — только когда исходящие сообщения действительно большие
- WithReadMaxBytes обязателен: лимит держит и decompression-бомбы
- Один http.Client на процесс: пул соединений и HTTP/2 — главная экономия

### Соберите пороги сжатия и защиту

Восстановите опции хендлера и клиента из урока: пороги сжатия, лимит чтения и gzip для исходящих.

```go
path, handler := notesv1connect.___(
        &notesServer{},
        connect.WithCompressMinBytes(1<<10), // ниже килобайта не жмём
        connect.___(1<<20),                  // потолок входящего сообщения
)

client := notesv1connect.NewNoteServiceClient(httpc, baseURL,
        connect.___(), // gzip для исходящих unary и стримов
        connect.WithCompressMinBytes(1<<10),
)
```

Подсказка: Обёртка хендлера — NewNoteServiceHandler; потолок входящего — WithReadMaxBytes; сжатие исходящих — WithSendGzip.

Правильные ответы в пропусках: `NewNoteServiceHandler`, `WithReadMaxBytes`, `WithSendGzip`

### Путь сжатого ответа

Расставьте события одного unary-вызова, в ответ на который ушёл gzip.

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

1. `Клиент шлёт вызов и анонсирует поддержку gzip в Accept-Encoding`
2. `Сервер сверяет анонс со списком поддержанных компрессоров`
3. `Хендлер отвечает и сериализует сообщение в прото-бинарник`
4. `Размер сообщения сверяется с порогом WithCompressMinBytes`
5. `Сообщение больше порога — пул компрессоров выдаёт gzip-райтер`
6. `Сжатый ответ уходит с заголовком Content-Encoding: gzip`
7. `Клиент видит Content-Encoding и распаковывает тело gzip-ридером`
8. `Распакованные байты десериализуются в структуру ответа`

> **Сжатие — не бесплатный флаг.** Каждый сжатый вызов платит CPU на обоих концах и добавляет латентность до отправки. На горячем пути это заметно даже с умеренными настройками gzip-а: нагрузите сервис тестами и смотрите p99 и загрузку CPU до и после. Откат — одна строка конфигурации, но лучше ловить регрессию бенчмарками в CI, а не жалобами в проде.

> **Измеряйте, а не угадывайте.** Две бесплатные машины для решения: pprof покажет, сколько CPU съел gzip, benchstat — дельту бенчмарков на реалистичных данных. Метрики размеров сообщений и латентностей появятся в семнадцатом уроке — они покажут эффект на живом трафике. Правило: если после включения сжатия вы не можете назвать экономию в килобайтах и цену в CPU — настройка не доказана.

> **Как проходить этот курс.** Код урока — настоящий Go: опции хендлера и Transport копируются в проект из третьего урока один в один. Браузерная песочница сайта сетевые вызовы и gzip-трафик не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а пороги сжатия проверяйте curl-ом и клиентом из пятого урока.

### Карточки: сжатие и производительность

- **gzip в Connect** — Поддержан из коробки на обеих сторонах: сервер сжимает ответы по Accept-Encoding, клиент сжимает запросы через WithSendGzip. Никаких encoding.RegisterCompressor, как в gRPC.
- **WithCompressMinBytes** — Порог сжатия: сообщения меньше порога уходят без Content-Encoding. Дефолт — ноль; ставьте 1<<10 и больше, чтобы не платить CPU за мелочь. На клиенте и сервере — независимые пороги.
- **Сжатие в стримах** — Порог и сжатие применяются к каждому сообщению стрима отдельно: мелкие сообщения внутри стрима едут сырыми, крупные — сжатыми. Это переговоры на уровне сообщений, не соединения.
- **Decompression-бомба** — Крошечное сжатое тело, распаковывающееся в гигабайты. Защита — WithReadMaxBytes: хендлер не прочитает больше лимита, бомба обрезается ресурсным отказом до входа в код.
- **HTTP/2 и http.Client** — HTTP/2 включается автоматически (поверх TLS — всегда), мультиплексирует параллельные вызовы в одном соединении. Один http.Client на процесс; дефолтный MaxIdleConnsPerHost=2 под нагрузкой мал.
- **identity** — Имя алгоритма «без сжатия»: так сервер отвечает на сообщения меньше порога и так выглядит ответ, когда сжимать нечего. Штатный путь, а не ошибка.

### Что дальше по курсу

Ручки собраны: порог бережёт CPU, лимит держит бомбы, транспорт держит соединения. Дальше по курсу: пятнадцатый урок — документация API, где в том же buf-конвейере появятся OpenAPI и доки из proto-комментариев — [пятнадцатый](/courses/connect/lessons/15-dokumentaciya/); метрики семнадцатого покажут, окупилась ли настройка, на живом трафике. Для повторения: лимиты и TLS — в [тринадцатом уроке](/courses/connect/lessons/13-bezopasnost/), протоколы и кодеки — в [четвёртом](/courses/connect/lessons/04-protokoly/), клиент и http.Client — в [пятом](/courses/connect/lessons/05-klienty/). Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

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

- Производительность Connect — четыре ручки: кодек, сжатие, лимиты, транспорт; порядок настройки от недооценённого к переоценённому: транспорт, лимиты, сжатие.
- gzip поддержан из коробки: ответы сжимаются по переговорам Accept-Encoding и Content-Encoding, исходящие — через connect.WithSendGzip.
- WithCompressMinBytes ставит порог: сообщения ниже него едут несжатыми; дефолт ноль — начинайте с 1<<10 и поднимайте по профилю трафика.
- Цена компрессии — CPU на оба конца и латентность: прото-бинарник жмётся скромно, JSON и тексты лучше; решают бенчмарки, pprof и метрики, а не догма.
- Decompression-бомбы держит WithReadMaxBytes: ресурсный отказ приходит раньше хендлера, память сервиса не видит гигантской распаковки.
- Транспорт — главная экономия: один http.Client на процесс, свой Transport с MaxIdleConnsPerHost, HTTP/2 мультиплексирует параллельные вызовы.
- Стримы сжимаются по сообщениям: порог применяется к каждому элементу конверта отдельно, мелкие едут сырыми.

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

Проведите аудит производительности своего notes-сервиса и зафиксируйте решение по каждой ручке урока. Шаг первый: соберите хендлер с WithCompressMinBytes(1<<10) и WithReadMaxBytes(1<<20), клиента — с общим http.Client, Transport-ом с MaxIdleConnsPerHost: 100 и WithSendGzip. Шаг второй — проверка порога: верните из хендлера сначала сообщение в двести байт, потом в десять КиБ, и убедитесь curl-ом или сниффером, что первое уехало без Content-Encoding, а второе — с gzip. Шаг третий — бомба: соберите сжатое тело, распаковывающееся в сотни мегабайт (gzip повторяющихся нулей), пошлите его сырым POST-ом с Content-Encoding: gzip и убедитесь в ресурсном отказе без роста памяти сервиса; затем уберите WithReadMaxBytes и повторите — запомните разницу. Шаг четвёртый — транспорт: замерьте латентность сотни последовательных вызовов с одним клиентом и с новым клиентом на каждый вызов, запишите дельту. Запишите в заметки таблицу из четырёх строк «ручка — решение — как проверили»: она станет отправной точкой, когда в семнадцатом уроке появятся метрики, а в двадцать втором — деплой-топология с ingress.

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

### Почему бы не сжимать всё подряд — байты ведь бесплатные?

Байты могут быть дешёвыми, CPU — нет: сжатие платит на отправителе, распаковка на получателе, а на мелких сообщениях gzip ещё и не сжимает — сжатый блок выходит не меньше исходного. Для тикового трафика из мелких вызовов итог отрицательный по CPU и латентности; для больших выгрузок — положительный. Поэтому порог, а не флаг.

### Чем сжатие в Connect отличается от gRPC?

В grpc-go компрессор регистрируется через encoding.RegisterCompressor и выбирается call-опцией grpc.UseCompressor на клиенте — церемония на обеих сторонах. В Connect gzip поддержан из коробки, исходящие сжимаются опцией WithSendGzip, а порог WithCompressMinBytes экономит CPU на мелочи. Идея та же — HTTP-заголовки и переговоры, — но настройка короче.

### Сжать или сначала сменить кодек на JSON?

Не путайте уровни: JSON крупнее прото-бинарника и потому сжимается сильнее, но пара «прото + gzip» часто меньше и дешевле, чем «JSON + gzip» — бинарь и так плотный. Выбирайте кодек по потребителям и удобству отладки (четвёртый урок), затем меряйте сжатие поверх выбранного кодека на реальных данных — и оставляйте то, что выигрывает по сумме CPU и байтов.
