Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~18 мин +50 XP

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

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

Содержание урока · 11 разделов

Производительность 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(
        &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-ом?

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всегда — один клиент на процесс

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

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: клиент из тринадцатого урока с 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(
        &notesServer{},
        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 для исходящих.

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

gzip в Connect

нажмите, чтобы перевернуть
Определение

Поддержан из коробки на обеих сторонах: сервер сжимает ответы по Accept-Encoding, клиент сжимает запросы через WithSendGzip. Никаких encoding.RegisterCompressor, как в gRPC.

1 / 6

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

Ручки собраны: порог бережёт 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Compression и производительность)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding