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

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

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

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

Батчинг: не спамим кадрами

Батчинг websocket — это сбор мелких событий в пачку и один WriteJSON вместо десятка: когда тикер шлёт пятьдесят обновлений в секунду, а клиентов сотни, каждый кадр превращается в syscall, и производительность ws падает на записи. Здесь добавим к writePump буфер с тикером, разберём цену в латентности и покажем, как клиент разбирает пачки, не замечая их.

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

Начнём с арифметики, которая всплывает внезапно. Канал котировок рассылает подписке двадцать обновлений в секунду; на инстансе сидят двести подписчиков — это четыре тысячи 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

GO
const 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, и клиенты, которые строят состояние на событиях, получают его в честной последовательности: батчинг не переставляет кадры, он только склеивает соседние. Транзитный вопрос — частичная обработка: если один элемент пачки бросил исключение, цикл перебора обязан продолжаться, иначе одно кривое событие съест всю пачку.

Клиент: разбор пачки

GO
socket.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 по потолку.

Отдельная ступень экономии — коалесинг: не складывать похожие события, а оставлять последнее. 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()

Соберите flush

Впишите три пропуска: проверка пустого буфера, поле с пачкой и очистка после записи.

func (c *Client) flush() { if (c.pending) == 0 { return } c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteJSON(&Batch{Type: "batch", Items: c.}) c.pending = }

Соберите метод flush

Расставьте строки: выход на пустом буфере, дедлайн, запись пачки.

Строки

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

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

Карточки: батчинг

Термин

pending и flusher

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

Срез конвертов в Client и тикер writePump: кадры копятся, тикер каждые сто миллисекунд сливает их одним WriteJSON.

1 / 5

Канал событий пережил батчинг: записи в сеть упали на порядок, клиент ничего не заметил, протокол получил один новый конверт. Следующий шаг по плану — проверять всё это тестами: двадцать первый урок поднимает 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 и убедитесь, что в пачке остался один конверт на пользователя.

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

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

Почему окно именно сто миллисекунд?

Значение из практики уведомлений: средняя добавка в пятьдесят миллисекунд незаметна, а записи падают в десять раз. Двести миллисекунд экономят ещё вдвое, но уже видны как sluggish на мобильном; пятьдесят почти не экономят syscall. Это настройка канала, а не константа протокола — меряйте на своей частоте событий.

Не сломает ли пачка старых клиентов?

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

Что произойдёт с незалитой пачкой при отключении клиента?

Буфер умирает вместе с соединением: pending не сохраняется, и события в нём теряются так же, как очередь send из шестнадцатого урока. Для событий, которые нельзя терять, это аргумент за store-and-forward из двенадцатого урока, а не за обещание долить буфер перед close.

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

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

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

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

RabbitMQ в Go: очереди сообщений на amqp091-go

Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.

  • Подключаться к RabbitMQ из Go и управлять connection и channel
  • Объявлять очереди, обменники и привязки с нужными свойствами
  • Публиковать и потреблять сообщения с ручными ack
  • Настраивать prefetch и справедливое распределение между воркерами