---
title: "Батчинг: не спамим кадрами"
description: "Батчинг websocket на Go: writePump собирает события в буфер и шлёт один WriteJSON по тикеру — меньше syscall без потери протокола. Код хаба и клиента."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/20-batching/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 20 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/20-batching/*

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

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

> **Спорное: батчинг не нужен вам сегодня.** Я не ставлю батчинг в первый день: до сотен событий в секунду на соединение экономия мала, а отладка дорожает — в логах пачки вместо кадров, и каждый баг расследуется через распаковку Items. Показания к внедрению простые: процессор горит на syscall в профиле, клиенты жалуются на лаги при всплесках, или сеть перегружена микропакетами. Пока ничего из этого нет, буфер — преждевременное усложнение, и честнее держать его в запасе, чем в проде.

Отдельная ступень экономии — коалесинг: не складывать похожие события, а оставлять последнее. Presence из девятнадцатого урока — идеальный клиент этой идеи: если за окно десять раз сменился статус одного пользователя, интересен только итог. Буфер превращается из среза в карту по ключу: userID на последний конверт, flush сливает её значениями. Тот же приём работает для котировок — подписчику важна последняя цена инструмента, а не вся история колебаний за сто миллисекунд, — и для typing-индикаторов. Копить в карте имеет смысл только события, которые перезаписывают друг друга; сообщения чата так не живут, и для них остаётся честный срез с потолком. Разница между срезом и картой — это разница между «доставить всё» и «доставить итог», и выбор между ними — решение о том, что на самом деле значит событие. Часто схема гибридная: срез для сообщений, карта для статусов — оба буфера живут в одном writePump и сливаются соседними кадрами.

### Когда пачка не успевает: переполнение буфера

Батчинг меняет поведение при перегрузке, и лучше продумать это заранее. Если клиент читает медленнее, чем сервер присылает, pending растёт между тиками, и потолок в шестьдесят четыре держит его в узде: при переполнении writePump действует по правилам восьмого урока — закрывает send и соединение, потому что клиент, не успевающий за пачками, уже неинтерактивен. Тонкая разница с прямым WriteJSON: без батчинга провинившегося ловит select с default в хабе и отключает немедленно; с батчингом вина накапливается в буфере и диагноз приходит на один тик позже. Хуже — убрать потолок: пачка без предела превращает медленного клиента в пожирателя памяти сервера, и один такой клиент испортит день всему инстансу. Решение о судьбе переполненного клиента принимает тот же владелец: либо отключить, как все, либо перейти в деградацию — присылать только итог по ключам, а хвосты выбрасывать. Второй вариант стоит отдельных флагов в протоколе, и оправдан он не раньше, чем появится продукт, живущий на этих хвостах.

### Батчить или нет

| Канал событий | Частота на клиента | Решение |
| --- | --- | --- |
| Сообщения чата | единицы в секунду | без батчинга, редкость не стоит окна |
| Уведомления и presence | десятки в секунду на инстанс | батчинг с тикером сто миллисекунд |
| Котировки | сотни в секунду | батчинг плюс коалесинг последней цены |
| Typing-индикаторы | десятки в секунду | батчинг, потеря безопасна |
| Игровые состояния | кадр на тик | без батчинга: латентность важнее вызовов |

### Проверка: место для буфера

**Вопрос:** Где правильно собирать пачку перед отправкой?

- В хабе, при рассылке каждому клиенту
- ✅ В writePump конкретного соединения
- В readPump, перед отправкой в send
- В браузере, перед разбором onmessage

Писать в conn имеет право только writePump — это правило владения из девятого урока, и буфер принадлежит ему же. Хаб, собирающий пачки для каждого получателя, размножил бы одну и ту же работу на всех; readPump вообще не пишет в сеть. Единственный писатель собирает единственную пачку.

### Проверка: цена окна

**Вопрос:** Тикер flush стоит сто миллисекунд. Какова средняя дополнительная задержка события?

- ✅ Около пятидесяти миллисекунд
- Ровно сто миллисекунд
- Около двухсот миллисекунд
- Нисколько: тикер ничего не добавляет

Событие равномерно попадает в окно: везунчики уезжают следующим flush, неудачники ждут полный период, среднее — половина. Сто миллисекунд окна дают пятьдесят средней добавки — незаметно для чата, ощутимо для игр. Именно среднее, а не максимум: потолок в сто достигается только для события сразу после flush.

### Сколько кадров увидит клиент

```go
// 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()
```

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

- Пять кадров по одному конверту
- ✅ Один кадр batch с пятью конвертами внутри
- Один кадр batch с шестьюдесятью четырьмя конвертами
- Ни одного: flush ждёт тикер бесконечно

Пять конвертов меньше потолка в шестьдесят четыре, немедленный flush не сработал, и их сольёт тикер: один WriteJSON с Items из пяти элементов. Клиент разберёт пачку циклом и получит события в порядке поступления. Сообщений в сети станет пять раз меньше — в этом весь смысл окна.

> **Посчитайте кадры локально.** Соберите writePump с буфером на локальном Go и напишите генератор: горутина шлёт в broadcast сто конвертов в секунду. Откройте вкладку с devtools на фильтре WS и посчитайте кадры до и после: без буфера — сотня в секунду, с окном в сто миллисекунд — около десяти. Сравните в профиле процессора число системных вызовов записи. Цифры на вашем железе убеждают сильнее любого абзаца.

### Соберите flush

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

```go
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 = ___
}
```

Подсказка: Пустой буфер проверяет len, пачка передаётся полем pending, а после записи срезу возвращают nil, чтобы следующий тик не отправил старое.

Правильные ответы в пропусках: `len`, `pending`, `nil`

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

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

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

1. `func (c *Client) flush() {`
2. `if len(c.pending) == 0 {`
3. `return`
4. `}`
5. `c.conn.SetWriteDeadline(time.Now().Add(writeWait))`
6. `c.conn.WriteJSON(&Batch{Type: "batch", Items: c.pending})`

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

- **pending и flusher** — Срез конвертов в Client и тикер writePump: кадры копятся, тикер каждые сто миллисекунд сливает их одним WriteJSON.
- **Потолок пачки** — Шестьдесят четыре элемента: при достижении flush немедленный, без ожидания тика. Защита от раздувания буфера и лишней латентности на пиках.
- **Цена окна** — Средняя добавка — половина периода: сто миллисекунд окна дают пятьдесят средней задержки. Чату незаметно, играм ощутимо.
- **Коалесинг** — Буфер-карта по ключу вместо среза: хранится последнее событие каждого ключа. Для presence и котировок доставляет итог, а не историю.
- **Что не батчим** — Управляющие кадры ping и close и срочные команды вроде разлогина — они идут мимо pending отдельным WriteJSON.

Канал событий пережил батчинг: записи в сеть упали на порядок, клиент ничего не заметил, протокол получил один новый конверт. Следующий шаг по плану — проверять всё это тестами: [двадцать первый урок](/courses/websocket/lessons/21-testy-ws/) поднимает ws-клиент поверх httptest, и пачку там легко поймать кадром. Механика насосов и обещание про NextWriter — в [девятом уроке](/courses/websocket/lessons/09-readpump-writepump/), конверты и типы кадров — в [десятой теме про JSON-протокол](/courses/websocket/lessons/10-json-soobscheniya/), а пачки presence-событий — в [девятнадцатом про статусы онлайн](/courses/websocket/lessons/19-onlajn-statusy/). Тикеры, таймеры и точность интервалов разобраны в [продвинутом курсе Go](/courses/go-advanced/) в [уроке про time](/courses/go-advanced/lessons/23-time/). Когда инстансов станет несколько, пачки начнут ходить и между серверами — об этом [двадцать третий урок про масштабирование](/courses/websocket/lessons/23-masshtabirovanie/). Померьте кадры на локальном Go до того, как профилировать в проде. План курса — на странице [курса WebSocket](/courses/websocket/).

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

- Каждый 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.
