Начнём с арифметики, которая всплывает внезапно. Канал котировок рассылает подписке двадцать обновлений в секунду; на инстансе сидят двести подписчиков — это четыре тысячи WriteJSON в секунду. Каждый такой вызов — маршалинг структуры в JSON, формирование заголовка кадра и запись в сокет, то есть syscall на каждый чих. При тысячах событий в секунду процессор сгорает не на рассылке, а на системных вызовах и копировании в ядро, и профиль показывает это безошибочно. Отдельная мелочь, которая усугубляет картину: TCP с малыми записями уходит в микропакеты, и сетевые задержки на канале с тысячами маленьких кадров заметно выше, чем на десятке больших. Батчинг websocket меняет уравнение: собрать всё, что накопилось за окно, и отправить одним кадром — десять записей вместо четырёх тысяч. Производительность ws на высокочастотных каналах держится не на магии, а на честной экономии вызовов.
Батчинг websocket: где живёт буфер и тикер#
Место для буфера определено архитектурой насосов: писать в conn имеет право только writePump, значит только он и собирает пачку. В Client появляется срез pending, в select writePump — третья ветка: тикер flusher с периодом сто миллисекунд. Кадры из send копятся в pending; по тику буфер сливается одним WriteJSON в конверт с типом batch и очищается. Правило потолка: если pending дорос до шестидесяти четырёх элементов, flush случается немедленно, не дожидаясь тика, — иначе пиковый кадр раздул бы буфер и добавил лишнюю латентность. Классический пример gorilla решает ту же задачу иначе: между пингами он сливает сообщения в один фрейм через NextWriter, и в девятом уроке об этом была обещана подробность — идея та же, копить и писать одним куском, явный буфер просто даёт контроль над окном и потолком. Flush живёт рядом с SetWriteDeadline: дедлайн ставится перед единственной записью пачки, как и учил девятый урок.
writePump с буфером и тикером flush
GOconst flushPeriod = 100 * time.Millisecond
// в select writePump:
case env := <-c.send:
c.pending = append(c.pending, env)
if len(c.pending) >= 64 {
c.flush() // потолок: пишем, не дожидаясь тика
}
case <-c.flusher.C:
c.flush()
func (c *Client) flush() {
if len(c.pending) == 0 {
return
}
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
c.conn.WriteJSON(&Batch{Type: "batch", Items: c.pending})
c.pending = nil
}
type Batch struct {
Type string
Items []*Envelope
}Три ветки select: кадр копится и по потолку вызывает flush немедленно, тикер сливает накопленное раз в сто миллисекунд. Flush — единственное место записи: дедлайн перед WriteJSON, пачка одним кадром, буфер в nil. Поле Items содержит конверты десятого урока без изменений, поэтому протокол чата не перестраивается — у него появляется конверт верхнего уровня.
Клиентская сторона обязана знать о пачках, и это единственная цена схемы. Формат прозрачен: конверт batch несёт массив Items, и обработчик клиента перебирает его тем же handleEvent, что и одиночные кадры. Обратная совместимость стоит одну ветку: старые клиенты без поддержки batch получат конверт с неизвестным типом и пропустят его — поэтому появление нового типа кадра стоит согласовать с версионированием протокола из десятого урока, а не вводить его молча. Порядок внутри пачки — порядок поступления в send, и клиенты, которые строят состояние на событиях, получают его в честной последовательности: батчинг не переставляет кадры, он только склеивает соседние. Транзитный вопрос — частичная обработка: если один элемент пачки бросил исключение, цикл перебора обязан продолжаться, иначе одно кривое событие съест всю пачку.
Клиент: разбор пачки
GOsocket.onmessage = function (event) {
const data = JSON.parse(event.data);
if (data.type === "batch") {
for (let i = 0; i < data.items.length; i = i + 1) {
handleEvent(data.items[i]);
}
return;
}
handleEvent(data);
};Разбор пачки — цикл по items с тем же обработчиком, что и для одиночных кадров: состояние интерфейса не знает о существовании батчинга. Ветка batch добавляется до обработки одиночных конвертов, и старые кадры продолжают приходить без изменений. Если сервер понижает частоту до батчей, клиент ничего не переписывает — только включённая ветка.
Цена в латентности и где батчить нельзя#
У окна в сто миллисекунд есть цена: событие, пришедшее сразу после flush, ждёт до ста миллисекунд, в среднем — пятьдесят. Для чата, уведомлений и presence это ниже порога восприятия; для игр и кооп-редакторов, где каждый кадр — состояние курсора, пятьдесят миллисекунд ощущаются как тягучесть, и там батчинг по тикеру не ставят. Выбор окна — тот же компромисс, что pongWait: меньше — больше записей, больше — заметнее задержка. Сто миллисекунд — типичное значение для уведомлений, двести — для фоновых счётчиков. Не батчат управляющие кадры: ping и close идут сами по себе, они не через pending. И не батчат кадры, после которых клиент обязан среагировать мгновенно: команда разлогинить соединение или ответ на авторизацию летят отдельным WriteJSON мимо буфера — ради редких срочных кадров и существует ветка немедленного flush по потолку.
Я не ставлю батчинг в первый день: до сотен событий в секунду на соединение экономия мала, а отладка дорожает — в логах пачки вместо кадров, и каждый баг расследуется через распаковку Items. Показания к внедрению простые: процессор горит на syscall в профиле, клиенты жалуются на лаги при всплесках, или сеть перегружена микропакетами. Пока ничего из этого нет, буфер — преждевременное усложнение, и честнее держать его в запасе, чем в проде.
Отдельная ступень экономии — коалесинг: не складывать похожие события, а оставлять последнее. Presence из девятнадцатого урока — идеальный клиент этой идеи: если за окно десять раз сменился статус одного пользователя, интересен только итог. Буфер превращается из среза в карту по ключу: userID на последний конверт, flush сливает её значениями. Тот же приём работает для котировок — подписчику важна последняя цена инструмента, а не вся история колебаний за сто миллисекунд, — и для typing-индикаторов. Копить в карте имеет смысл только события, которые перезаписывают друг друга; сообщения чата так не живут, и для них остаётся честный срез с потолком. Разница между срезом и картой — это разница между «доставить всё» и «доставить итог», и выбор между ними — решение о том, что на самом деле значит событие. Часто схема гибридная: срез для сообщений, карта для статусов — оба буфера живут в одном writePump и сливаются соседними кадрами.
Когда пачка не успевает: переполнение буфера#
Батчинг меняет поведение при перегрузке, и лучше продумать это заранее. Если клиент читает медленнее, чем сервер присылает, pending растёт между тиками, и потолок в шестьдесят четыре держит его в узде: при переполнении writePump действует по правилам восьмого урока — закрывает send и соединение, потому что клиент, не успевающий за пачками, уже неинтерактивен. Тонкая разница с прямым WriteJSON: без батчинга провинившегося ловит select с default в хабе и отключает немедленно; с батчингом вина накапливается в буфере и диагноз приходит на один тик позже. Хуже — убрать потолок: пачка без предела превращает медленного клиента в пожирателя памяти сервера, и один такой клиент испортит день всему инстансу. Решение о судьбе переполненного клиента принимает тот же владелец: либо отключить, как все, либо перейти в деградацию — присылать только итог по ключам, а хвосты выбрасывать. Второй вариант стоит отдельных флагов в протоколе, и оправдан он не раньше, чем появится продукт, живущий на этих хвостах.
Батчить или нет#
| Канал событий | Частота на клиента | Решение |
|---|---|---|
| Сообщения чата | единицы в секунду | без батчинга, редкость не стоит окна |
| Уведомления и presence | десятки в секунду на инстанс | батчинг с тикером сто миллисекунд |
| Котировки | сотни в секунду | батчинг плюс коалесинг последней цены |
| Typing-индикаторы | десятки в секунду | батчинг, потеря безопасна |
| Игровые состояния | кадр на тик | без батчинга: латентность важнее вызовов |
Проверка: место для буфера
Где правильно собирать пачку перед отправкой?
Проверка: цена окна
Тикер flush стоит сто миллисекунд. Какова средняя дополнительная задержка события?
Сколько кадров увидит клиент
Прочитайте код и выберите, что он выведет:
// writePump: send успел накопить пять конвертов,
// тикер flush ещё не тикнул, потолок — 64
case env := <-c.send:
c.pending = append(c.pending, env)
if len(c.pending) >= 64 {
c.flush()
}
case <-c.flusher.C:
c.flush()Соберите writePump с буфером на локальном Go и напишите генератор: горутина шлёт в broadcast сто конвертов в секунду. Откройте вкладку с devtools на фильтре WS и посчитайте кадры до и после: без буфера — сотня в секунду, с окном в сто миллисекунд — около десяти. Сравните в профиле процессора число системных вызовов записи. Цифры на вашем железе убеждают сильнее любого абзаца.
Соберите flush
Впишите три пропуска: проверка пустого буфера, поле с пачкой и очистка после записи.
Соберите метод flush
Расставьте строки: выход на пустом буфере, дедлайн, запись пачки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: батчинг
Канал событий пережил батчинг: записи в сеть упали на порядок, клиент ничего не заметил, протокол получил один новый конверт. Следующий шаг по плану — проверять всё это тестами: двадцать первый урок поднимает ws-клиент поверх httptest, и пачку там легко поймать кадром. Механика насосов и обещание про NextWriter — в девятом уроке, конверты и типы кадров — в десятой теме про JSON-протокол, а пачки presence-событий — в девятнадцатом про статусы онлайн. Тикеры, таймеры и точность интервалов разобраны в продвинутом курсе Go в уроке про time. Когда инстансов станет несколько, пачки начнут ходить и между серверами — об этом двадцать третий урок про масштабирование. Померьте кадры на локальном Go до того, как профилировать в проде. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каждый WriteJSON — маршалинг, заголовок кадра и syscall: на тысячах событий в секунду процессор горит на записях.
- Буфер pending и тикер flush живут в writePump: единственный писатель соединения собирает единственную пачку.
- Потолок в шестьдесят четыре конверта вызывает немедленный flush: пиковый кадр не раздувает окно.
- Цена окна — половина периода в среднем: сто миллисекунд тикера дают пятьдесят миллисекунд задержки.
- Клиент разбирает пачку тем же обработчиком: ветка batch перебирает items, порядок внутри пачки сохранён.
- Коалесинг картой по ключу доставляет итог вместо истории — для presence и котировок это и нужно.
Домашнее задание
Добавьте в writePump буфер pending, тикер flush на сто миллисекунд и потолок в шестьдесят четыре конверта, а в протокол — конверт batch и ветку разбора в клиенте. Затем соберите генератор событий на сотню конвертов в секунду и сравните число кадров в devtools и число syscall записи в профиле до и после. Для presence перепишите буфер в карту по userID и убедитесь, что в пачке остался один конверт на пользователя.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему окно именно сто миллисекунд?
Значение из практики уведомлений: средняя добавка в пятьдесят миллисекунд незаметна, а записи падают в десять раз. Двести миллисекунд экономят ещё вдвое, но уже видны как sluggish на мобильном; пятьдесят почти не экономят syscall. Это настройка канала, а не константа протокола — меряйте на своей частоте событий.
Не сломает ли пачка старых клиентов?
Клиент без ветки batch получит конверт с неизвестным типом и по правилам десятого урока пропустит его — события потеряются молча. Поэтому новый тип вводят вместе с версионированием: сервер шлёт пачки только клиентам, которые заявили поддержку в рукопожатии, остальным — одиночные кадры.
Что произойдёт с незалитой пачкой при отключении клиента?
Буфер умирает вместе с соединением: pending не сохраняется, и события в нём теряются так же, как очередь send из шестнадцатого урока. Для событий, которые нельзя терять, это аргумент за store-and-forward из двенадцатого урока, а не за обещание долить буфер перед close.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Тесты: ws-клиент в httptest
- Отладка: wscat, curl и логи соединений
- Масштабирование: Redis Pub/Sub между инстансами
- Сценарии: уведомления, тикер, кооп-редактор
- Presence: кто сейчас онлайн
- Отвалы: чистка мёртвых соединений
- Reconnect: клиент, который возвращается
- Graceful shutdown для WS-сервера
- Все курсы по Go
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами