Содержание урока · 11 разделов
- Сжатие из коробки: gzip уже внутри
- WithCompressMinBytes: порог, который экономит CPU
- Цена компрессии: когда gzip окупается
- Ручки производительности Connect
- HTTP/2 и соединения: главная экономия не в gzip
- Decompression-бомбы: обещание тринадцатого урока
- Пять правил производительности Connect
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Производительность RPC-сервиса на Connect складывается из четырёх ручек, и все четыре вы уже держали в руках. Кодек и протокол — сравнение трёх жанров из четвёртого урока, где прото-бинарник выигрывал у JSON по размеру и скорости. Лимиты — WithReadMaxBytes из тринадцатого урока, потолок входящего сообщения. Транспорт — http.Client, на котором вы собирали клиента в пятом уроке. Четвёртая ручка — сжатие: 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 действует на сообщение, а не на соединение. В стримах из восьмого урока каждое сообщение конверта сжимается отдельно — мелкие сообщения внутри длинного стрима порог не пройдёт и поедет как есть, крупные сожмутся. Это отличает connect-модель от идеи «сжать весь HTTP-ответ целиком» и делает порог осмысленным на уровне каждого сообщения. И третье: сжатие — не флаг «включить или выключить», а переговоры. Если клиент не анонсировал gzip, сервер не будет жать; если анонсированного алгоритма у сервера нет, уедет identity — сырое сообщение без Content-Encoding. Всю эту механику вы не увидите в логах — только в трафике, поэтому ниже мы закрепим её порогами и тестами.
Порог на сервере, gzip на клиенте
GO// Хендлер: порог сжатия ответов и лимит входящего
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{},
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-ом?
WithCompressMinBytes: порог, который экономит CPU#
Порог называется connect.WithCompressMinBytes: сообщения меньше указанного размера уходят без сжатия, даже если обе стороны gzip анонсировали. По умолчанию порог равен нулю — сжимается всё согласованное, включая сообщения в десятки байт. Почему это плохо: gzip не знает про RPC — он сжимает поток байт, и на крошечном входе его выхлоп исчезает. Сообщение в двести байт после gzip-а занимает примерно столько же или больше: словарь, заголовок блока и минимальный размер сжатого блока съедают выигрыш, а CPU потрачен на оба конца — сжатие на сервере, распаковка на клиенте. На миллионах мелких вызовов это чистый убыток и по CPU, и по латентности: p99 растёт на доли миллисекунды, которых у тикового трафика нет. Отсюда рабочая настройка: WithCompressMinBytes(1<<10) как старт — всё, что меньше килобайта, едет сырое, всё, что больше, имеет шанс окупиться. Точная цифра зависит от профиля: для сервиса, пересылающего логи и вложения, порог смело поднимается; для интерактивного API с мелкими ответами сжатие может не окупаться вовсе — и это тоже валидное решение, зафиксированное в конфигурации. Второй нюанс — чей порог. Опция стоит и на клиенте, и на хендлере, и это два независимых числа: клиентский решает, сжимать ли исходящие запросы, серверный — сжимать ли ответы. В стримах порог применяется к каждому сообщению отдельно, поэтому длинный стрим из восьмого урока ведёт себя как лента: мелкие сообщения едут сырыми, крупные — сжатыми, и это ровно то, чего хочется. И помните про identity из прошлого абзаца: сообщение ниже порога уезжает с identity, а не с ошибкой — это штатный путь, а не деградация сервиса.
Что уедет по сети в ответе?
Прочитайте код и выберите, что он выведет:
// Хендлер: connect.WithCompressMinBytes(1<<10)
// Клиент: обычный NewNoteServiceClient, unary-вызов GetNote
// Хендлер возвращает сообщение в 300 байт прото-бинарника.
// Клиент анонсировал Accept-Encoding: gzip — так делает любой Connect-клиент.Цена компрессии: когда 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). Что они получат?
Ручки производительности Connect#
| Инструмент | Сторона | Что делает | Когда включать |
|---|---|---|---|
| WithCompressMinBytes | клиент и сервер | порог: сообщения меньше него не сжимаются | всегда; старт с 1<<10 |
| WithSendGzip | клиент | сжимает исходящие unary и стримы gzip-ом | когда исходящие payload-ы большие |
| WithCompressionName | клиент | выбирает алгоритм сжатия запросов по имени | если gzip не подходит, например zstd |
| gzip из коробки | обе | сжатие ответов по Accept-Encoding без настройки | по умолчанию |
| WithReadMaxBytes | сервер | потолок входящего объёма, держит decompression-бомбы | всегда — это защита, не тюнинг |
| http.Transport | клиент | пул соединений: MaxIdleConnsPerHost, HTTP/2 | всегда — один клиент на процесс |
Транспорт: один клиент, живой пул соединений
GOvar 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: клиент из тринадцатого урока с TLSClientConfig уже строился на собственном Transport — сюда же добавьте и пул. Третий нюанс — HTTP/2 сам по себе: поверх TLS он включается автоматически, и у двунаправленных стримов из восьмого урока нет выбора — они требуют HTTP/2. По открытому http:// HTTP/2 в Go включается флагом ForceAttemptHTTP2 и требует согласования с сервером; на границе инфраструктуры протокол может рваться на hop-е — это история про ingress и деплой, вернёмся к ней в двадцать втором уроке. Заметьте иерархию: правильный транспорт экономит десятки миллисекунд на вызов и не стоит ничего; gzip экономит килобайты и стоит CPU. Настраивайте в этом порядке.
Под нагрузкой сервис тратит время на новые TCP+TLS рукопожатия при каждом вызове, хотя все вызовы идут к одному хосту. Где ошибка?
Decompression-бомбы: обещание тринадцатого урока#
Обещание тринадцатого урока пора исполнить: у лимита чтения есть вторая роль, кроме DoS-защиты, — decompression-бомбы. Идея атаки проста: тело, которое сжимается в сорок килобайт, распаковывается в сотни мегабайт и гигабайты — gzip-у всё равно, он честно сжимает длинные повторяющиеся последовательности. Атакующий шлёт крошечный сжатый запрос, а сервер обязан где-то удержать распакованный результат до десериализации — классическая амплитудная атака, и сжатие здесь не защита, а оружие: сжатый трафик ещё и дешевле отправлять. Что с этим делает Connect: WithReadMaxBytes из тринадцатого урока ограничивает объём, который хендлер примет и распакует, — ресурсный отказ приходит раньше вашего хендлера, и память сервиса не видит гигантского результата. Механика проверки у протоколов различается: в стриминговых жанрах размер сообщения объявлен в префиксе конверта, и превышение видно сразу; в unary тело читается с ограничением — но гарантия на выходе одна: сколько бы байт ни обещал gzip, дальше лимита вы не прочитаете. Поэтому связка урока — порог сжатия и лимит чтения — не два независимых тюнинга, а пара «экономия и защита»: WithCompressMinBytes бережёт CPU от бессмысленной компрессии, WithReadMaxBytes бережёт память от злонамеренной декомпрессии. Практический вывод: любой сервис, принимающий сжатые запросы, обязан иметь лимит чтения — дефолт «без лимита», как мы говорили в тринадцатом, это потенциальный DoS, а при компрессии цена ошибки растёт в тысячи раз. Проверяется всё это тестом полного стека: тело бомбы уходит сырым POST-ом с Content-Encoding: gzip, тест ожидает ресурсный отказ — тот же приём, которым в шестнадцатом уроке проверяются коды ошибок вообще.
Бомба против лимита
GO// Защита: лимит обязателен на каждом принимающем сжатое хендлере
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{},
connect.WithReadMaxBytes(1<<20), // 1 МиБ на принятое сообщение
)
// Атакующий шлёт НЕ через connect-клиент, а сырым POST-ом
// с Content-Encoding: gzip: 40 КиБ, распаковывающиеся в 500 МиБ.
// Сервер отвечает ресурсным отказом до входа в хендлер —
// память сервиса не растёт: лимит срезал распаковку.Тело атаки сжимается в килобайты, распаковывается в гигабайты — WithReadMaxBytes останавливает чтение раньше, чем память увидит результат. В шестнадцатом уроке этот сценарий станет тестом полного стека: сырой запрос с Content-Encoding: gzip, сервер с лимитом, ожидание ресурсного кода.
Что ответит сервер?
Прочитайте код и выберите, что он выведет:
// Сервер: connect.WithReadMaxBytes(1<<20)
// Атакующий шлёт сырой POST с Content-Encoding: gzip:
// 40 КиБ сжатых данных распаковываются в 500 МиБ нулей.Пять правил производительности Connect#
- Измеряйте до настройки: профиль трафика решает, окупится ли сжатие
- Ставьте WithCompressMinBytes(1<<10): мелочь gzip-ом не сжимать
- WithSendGzip — только когда исходящие сообщения действительно большие
- WithReadMaxBytes обязателен: лимит держит и decompression-бомбы
- Один http.Client на процесс: пул соединений и HTTP/2 — главная экономия
Соберите пороги сжатия и защиту
Восстановите опции хендлера и клиента из урока: пороги сжатия, лимит чтения и gzip для исходящих.
Путь сжатого ответа
Расставьте события одного unary-вызова, в ответ на который ушёл gzip.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Каждый сжатый вызов платит CPU на обоих концах и добавляет латентность до отправки. На горячем пути это заметно даже с умеренными настройками gzip-а: нагрузите сервис тестами и смотрите p99 и загрузку CPU до и после. Откат — одна строка конфигурации, но лучше ловить регрессию бенчмарками в CI, а не жалобами в проде.
Две бесплатные машины для решения: pprof покажет, сколько CPU съел gzip, benchstat — дельту бенчмарков на реалистичных данных. Метрики размеров сообщений и латентностей появятся в семнадцатом уроке — они покажут эффект на живом трафике. Правило: если после включения сжатия вы не можете назвать экономию в килобайтах и цену в CPU — настройка не доказана.
Код урока — настоящий Go: опции хендлера и Transport копируются в проект из третьего урока один в один. Браузерная песочница сайта сетевые вызовы и gzip-трафик не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а пороги сжатия проверяйте curl-ом и клиентом из пятого урока.
Карточки: сжатие и производительность
Что дальше по курсу#
Ручки собраны: порог бережёт CPU, лимит держит бомбы, транспорт держит соединения. Дальше по курсу: пятнадцатый урок — документация API, где в том же buf-конвейере появятся OpenAPI и доки из proto-комментариев — пятнадцатый; метрики семнадцатого покажут, окупилась ли настройка, на живом трафике. Для повторения: лимиты и TLS — в тринадцатом уроке, протоколы и кодеки — в четвёртом, клиент и http.Client — в пятом. Программа всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Производительность 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 и байтов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding