---
title: "Сценарии: уведомления, тикер, кооп-редактор"
description: "Real-time сценарии на websocket: уведомления с fan-out и badge, тикер цен со снапшотом и дельтами, кооп-редактор с версиями. Карта применения."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/24-scenarii-real-time/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Сценарии: уведомления, тикер, кооп-редактор

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

Real-time сценарии на websocket различаются сильнее, чем кажется: уведомления живут на fan-out и бейджах, тикер цен — на снапшоте с дельтами, кооп-редактор — на версиях документа, и перенос протокола одного продукта в другой ломает его. Здесь собрана карта применения: три продукта, их конверты, их жертвы и готовые приёмы из курса.

### Real-time сценарии: одно слово, три продукта

«Нам нужен real-time» — самая растяжимая формулировка, которую слышит бэкендер. Под ней скрываются продукты с противоположными требованиями: сервис уведомлений готов терпеть задержку в полсекунды, но не теряет ни одного события; биржевой тикер готов терять промежуточные цены, но шлёт сотни обновлений в секунду; кооп-редактор не может терять ни правки, ни порядок правок, а его клиенты одновременно редактируют один документ. Инфраструктура — тот же hub, насосы и heartbeat из первых уроков — у всех трёх общая, и это заслоняет главное: протокол и правила деградации у каждого свои. Real-time сценарии стоит проговорить до первого коммита: что считается потерей, что можно коалесить, кто платит за reconnect. Дальше — три модели по очереди, с приёмами, которые курс уже дал. У каждого сценария есть и цена ошибки, и её форма: у уведомлений это молчание интерфейса при живой инфраструктуре, у тикера — растущий процессор, у редактора — расхождение документов, которое потом не склеить, и эта разница определяет, где первыми писать тесты.

### Уведомления websocket: fan-out, badge и приоритеты

Канал уведомлений — это fan-out на userID: событие адресовано человеку, где бы он ни был подключён, и маршрут через ws:dm из двенадцатого урока уже умеет такое. Отличие от чата — в дисциплине потерь: лайк можно потерять безнаказанно, а уведомление о входе в аккаунт с нового устройства — нет. Поэтому уведомления websocket делят на классы важности и каждому назначают свою гарантию: критичные дублируются хранением и повтором, обычные едут пачками. Вторая особенность — badge: счётчик непрочитанных, который интерфейс показывает на иконке колокольчика. Шлёть его после каждого события расточительно — счётчик коалесится как presence из девятнадцатого урока: за окно считается итог, и клиент получает одно число. Оффлайн-получателя спасает store-and-forward из двенадцатого урока: событие лежит в очереди, клиент забирает его при reconnect из семнадцатого.

### notify: онлайн уезжает в send, оффлайн — в очередь

```go
func (h *Hub) notify(userID string, n *Notice) {
        env := &Envelope{Type: "notification", User: userID, Body: n}

        if c := h.clients[userID]; c != nil {
                select {
                case c.send <- env:
                        return // онлайн: конверт ушёл в очередь соединения
                default:
                        close(c.send) // клиент не читает: соединение мертво
                }
        }
        h.store.EnqueueOffline(userID, n) // оффлайн: очередь из двенадцатого урока
}
```

Select с default — тот же предохранитель медленного клиента, что и в рассылке хаба: конверт не переполняет очередь, а живое соединение встречает его мгновенно. Если получателя нет в карте или его очередь переполнена, событие не теряется, а оседает в хранилище: клиент заберёт его после reconnect. Приоритет события живёт в Notice и решает, повторять ли доставку.

### Badge и приоритеты рассылки

Классификация важности — единственное, что делает уведомления управляемыми. Критичный класс — разлогин, смена пароля, вход с нового устройства: доставка с повтором, через хранилище, и без батчинга: команда разлогинить соединение из двадцатого урока летит отдельным кадром мимо pending. Обычный класс — лайки, комментарии, реакции: их можно коалесить по ключу автора, десяток лайков за окно превращаются в одно событие с итогом. Маркетинговый класс живёт в отдельном канале и по отдельной подписке: клиент сам решает, слушать ли его, и сервер не смешивает рекламу с личным. Badge-счётчик обновляется последним событием окна: пятьдесят уведомлений и один кадр с числом вместо пятидесяти кадров — экономия видна на любом инстансе с тысячами активных пользователей. Повторная доставка критичного события означает, что клиент может увидеть его дважды, поэтому обработчики уведомлений строятся идемпотентно: конверт несёт идентификатор события, интерфейс хранит десяток последних обработанных, и повтор молча сворачивается. Без этого двойная доставка критичного уведомления превращается в двойное всплытие баннера на весь экран.

### Классы уведомлений и их гарантии

- Критичные — разлогин, смена пароля, чужой вход: повтор доставки, хранение в очереди, мимо батчинга.
- Безопасность — вход с нового устройства и новые сессии: хранение без повтора, отдельный тип конверта.
- Обычные — лайки, комментарии, ответы: коалинг по ключу автора, доставка пачкой с badge.
- Маркетинг — рассылки и акции: отдельный канал и подписка, клиент слушает добровольно.

### Тикер цен: снапшот, дельты и троттлинг

Котировки — обратный край спектра: событий много, каждое невелико, а клиенту важна последняя цена, не история. Прямая рассылка каждой сделки тонет: инструмент движется двести обновлений в секунду, и тысяча подписчиков превращает это в двести тысяч WriteJSON на инстанс. Тикер строится на трёх приёмах. Снапшот при входе: подписчик получает текущее состояние, потому что события потока не восстанавливают прошлое. Дельты: дальше летят только изменения цены, не весь документ котировки. Троттлинг на сервере: клиенту отдают не чаще N раз в секунду — обновление дисплея дважды в секунду глазу достаточно, и коалинг из двадцатого урока держит последнее значение по ключу инструмента. Цифры всегда с условием: для ленты новостей хватит одной секунды, для стакана — сотни миллисекунд, и это настройка канала, а не константа протокола.

### Подписка на инструмент: снапшот и троттлинг

```go
case "subscribe":
        snap := book.Get(cmd.Symbol)
        c.conn.SetWriteDeadline(time.Now().Add(writeWait))
        c.conn.WriteJSON(&Envelope{
                Type:  "snapshot",
                Sym:   cmd.Symbol,
                Price: snap.Price,
                Seq:   snap.Seq,
        })
        c.instrument = cmd.Symbol

case <-c.quoteTick.C: // тикер подписчика, сто миллисекунд
        q := book.Get(c.instrument)
        c.conn.SetWriteDeadline(time.Now().Add(writeWait))
        c.conn.WriteJSON(&Envelope{Type: "quote", Sym: c.instrument, Price: q.Price, Seq: q.Seq})
```

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

### Кооп-редактор: версии и presence

Совместное редактирование — самый требовательный сценарий: правки не теряются и не меняют порядок, а два человека могут править одну строку одновременно. Ядро протокола — версии: каждая правка несёт baseVersion, версию документа, на которой она сделана, и сервер присваивает правкам монотонные номера. Клиент, увидев чужую версию после своей, понимает, что отстал, и заново синхронизируется. Разрешение конфликтов — отдельная наука: операционные преобразования, на которых вырос Google Docs, и CRDT, на которых работают библиотеки вроде Yjs и Automerge, дают сходимость без центрального арбитра; писать OT с нуля в продукте не стоит — берут готовую библиотеку и держат на сервере тонкий шлюз с нумерацией версий. Presence из девятнадцатого урока придаёт живости: курсоры и выделения летят отдельным lossy-каналом с коалингом по ключу пользователя — потерянная позиция курсора не страшна, следующий тик её исправит. Синхронизация при входе идёт снапшотом документа и текущим номером версии: правки старше снапшота клиент задним числом не получит, и то же поведение спасает при reconnect после минут офлайна — клиент отдаёт свою версию, сервер досылает хвост правок.

### Три сценария и их жертвы

| Сценарий | Ядро протокола | Чем жертвуем |
| --- | --- | --- |
| Уведомления | fan-out на userID, классы важности, badge-итоги | низкой ценой потерь: критичные дублируются хранением |
| Тикер цен | снапшот при входе, дельты, троттлинг, Seq | промежуточными ценами: важна последняя |
| Кооп-редактор | baseVersion у правок, номера версий, presence | латентностью: сходимость дороже скорости |
| Чат этого курса | комнаты, presence, батчинг, store-and-forward | историей при оффлайне без хранилища |

### Проверка: разрыв последовательности

**Вопрос:** Клиент тикера получил кадр с Seq 41, а предыдущий был 45. Что делать?

- Пропустить кадр: порядок в потоке цен не важен
- ✅ Запросить снапшот: разрыв последовательности означает потерю состояния
- Переоткрыть соединение целиком и подписаться заново
- Игнорировать: сервер перепосылает пропущенные дельты сам

Seq существует именно для обнаружения пропусков: клиент, увидевший разрыв, больше не может считать своё состояние достоверным, и запрос снапшота — самый дешёвый путь к достоверности. Переоткрытие соединения избыточно: подписка и транспорт в порядке, потерялись данные, а не связь. Сервер по протоколу at-most-once перепосылки не делает.

### Проверка: уведомление спящему получателю

**Вопрос:** Пользователь оффлайн, серверу нужно доставить уведомление о входе с нового устройства. Что делает правильный обработчик?

- Пропустить: как только пользователь зайдёт, всё узнает сам
- Писать в канал ws:dm: снапшот пришлёт всё при входе
- ✅ Положить в очередь store-and-forward и доставить при reconnect
- Опубликовать в Redis Pub/Sub: сообщение дождётся подписчика

Критичное уведомление нельзя терять, и его судьба не связана с состоянием соединения: хранилище из двенадцатого урока держит событие, клиент забирает его при reconnect. Живой поток Redis не хранит историю, поэтому публикация не спасает, а соединения оффлайн-пользователя не существует в принципе.

### Курсор в кооп-редакторе

```go
// writePump клиента с коалингом из двадцатого урока:
// буфер — карта по ключу, тикер flush раз в сто миллисекунд
// один пользователь шлёт позицию курсора 60 раз в секунду

for i := 0; i < 10; i = i + 1 {
        c.send <- env // десять конвертов cursor за окно
}
// сколько кадров уйдёт в сеть за окно?
```

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

- Десять кадров: коалинг не трогает cursor
- ✅ Один кадр с последней позицией: буфер-карта держит итог по ключу
- Один кадр с первой позицией: карта хранит первый элемент
- Ни одного: lossy-канал выбрасывает cursor

Коалинг по ключу оставляет последнее событие каждого ключа: десять обновлений курсора за окно превращаются в один кадр с итогом. Для presence-каналов это ровно то, что нужно: важна актуальная позиция, а не траектория. Сообщения чата так не живут — им нужен честный срез с потолком из двадцатого урока.

> **Тикер без троттлинга съел инстанс.** Кейс из практики: команда подключила клиентов к ленте сделок напрямую, по событию на сделку. Двести обновлений в секунду на инструмент, пятьсот подписчиков — сто тысяч WriteJSON в секунду, и инстанс горит на syscall, о которых шёл разговор в двадцатом уроке. Диагноз поставился по профилю за минуты, лечение — троттлинг на подписчика: тикер сто миллисекунд и коалинг по инструменту. Записи упали в двести раз, графики на клиенте не изменились, потому что дисплеи всё равно обновляются с частотой кадров, а не с частотой сделок. Урок один: частота источника и частота восприятия — разные вещи, и между ними обязана стоять настройка.

> **Спорное: не каждому продукту нужен websocket.** Я скептически отношусь к websocket в панелях со статистикой: цифры меняются раз в минуту, и polling раз в тридцать секунд дешевле в коде, инфраструктуре и отладке, а для сервер-клиент без обратной связи дешевле SSE. Граница проста: двусторонний обмен и частые обновления — websocket, редкие однонаправленные события — polling или SSE. Все три сценария из урока собираются на локальном Go из материалов курса: песочница умеет только fmt, strings, strconv, math, sort, errors и time, сети в ней нет, поэтому тикер и курсоры проверяйте на своём железе с двумя вкладками.

### Соберите подписку тикера

Впишите четыре пропуска: тип снапшота, поле тикера, чтение книги и номер состояния.

```go
case "subscribe":
        snap := book.Get(cmd.Symbol)
        c.conn.WriteJSON(&Envelope{Type: "___", Sym: cmd.Symbol, Price: snap.Price})

        c.instrument = cmd.Symbol

case <-c.quoteTick.___:
        q := book.___(c.instrument)
        c.conn.WriteJSON(&Envelope{Type: "quote", Sym: c.instrument, Price: q.Price, Seq: q.___})
```

Подсказка: Первый кадр называется snapshot, поле тикера из пакета time — C, состояние книги достаётся методом Get, а номер состояния живёт в Seq.

Правильные ответы в пропусках: `snapshot`, `C`, `Get`, `Seq`

### Соберите порядок подписки тикера

Расставьте строки обработчика: снапшот до дельт.

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

1. `case "subscribe":`
2. `snap := book.Get(cmd.Symbol)`
3. `c.conn.SetWriteDeadline(time.Now().Add(writeWait))`
4. `c.conn.WriteJSON(&Envelope{Type: "snapshot", Sym: cmd.Symbol, Price: snap.Price})`
5. `c.instrument = cmd.Symbol`
6. `c.quoteTick.Reset(throttlePeriod)`

### Карточки: сценарии

- **Fan-out уведомлений** — Рассылка на userID через ws:dm: событие адресовано человеку, а не комнате. Оффлайн-получателя держит store-and-forward.
- **Badge-счётчик** — Число непрочитанных: коалится за окно, клиент получает итог, а не поток. Пятьдесят событий — один кадр с числом.
- **Снапшот и дельты** — Подписчик получает текущее состояние при входе и дальше только изменения. События потока не восстанавливают прошлое.
- **Seq и разрыв** — Номер состояния в кадре: разрыв последовательности означает потерю, и клиент запрашивает снапшот заново.
- **Версии в кооп-редакторе** — Правка несёт baseVersion, сервер выдаёт монотонные номера. Конфликты решают готовые OT или CRDT-библиотеки, presence курсоров — lossy-каналом.

Карта применения заполнена: уведомлениям нужен fan-out с классами важности и хранилищем, тикеру — снапшот с дельтами и троттлинг, кооп-редактору — версии и lossy-курсоры. Всё это вариации деталей, которые курс уже собрал: конверты, насосы, presence, батчинг. Финальная точка — [двадцать пятый урок](/courses/websocket/lessons/25-itogovyj-proekt-ws/), где всё складывается в проект чата с комнатами. Личные сообщения и store-and-forward описаны в [двенадцатом уроке](/courses/websocket/lessons/12-lichnye-soobscheniya/), коалинг и батчинг — в [двадцатом](/courses/websocket/lessons/20-batching/), presence и курсоры — в [девятнадцатом](/courses/websocket/lessons/19-onlajn-statusy/), reconnect — в [семнадцатом уроке](/courses/websocket/lessons/17-reconnect/). Тикеры и точность интервалов разобраны в [продвинутом курсе Go](/courses/go-advanced/) в [теме про time](/courses/go-advanced/lessons/23-time/). Прогоните один сценарий на локальном Go с двумя вкладками — карта из абстрактной станет вашей. План курса — на странице [курса WebSocket](/courses/websocket/).

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

- Real-time сценарии различаются требованиями к потерям и порядку: протокол одного продукта не переносится в другой механически.
- Уведомления делятся на классы важности: критичные с повтором и хранением, обычные с коалингом, маркетинг в отдельной подписке.
- Badge-счётчик коалится за окно: пятьдесят уведомлений уезжают одним кадром с итогом.
- Тикер строится на снапшоте при входе, дельтах и троттлинге: двести обновлений в секунду превращаются в десять кадров.
- Seq в кадре показывает разрывы: клиент, увидевший пропуск, запрашивает снапшот заново.
- Кооп-редактор держит baseVersion у правок и монотонные номера: конфликты решают готовые OT или CRDT-библиотеки.

## Домашнее задание

Соберите notify с двумя ветками: онлайн-получателю конверт в send через select с default, оффлайн — в EnqueueOffline. Добавьте badge-счётчик с коалингом по userID и проверьте на двух вкладках, что десять событий дают один кадр с числом. Затем сделайте тикер: подписка со снапшотом и Seq, тикер на сто миллисекунд с чтением последней цены, и убедитесь, что при разрыве Seq клиент запрашивает снапшот повторно.

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

### Почему для кооп-редактора не пишем OT сами?

Операционные преобразования требуют формальной модели и обширных тестов на сходимость: ошибка превращается в расхождение документов у двух пользователей, которое нельзя потом склеить. Библиотеки вроде Yjs или Automerge дают CRDT с проверенной сходимостью, а сервер остаётся тонким шлюзом: нумерация версий, рассылка правок, presence. Это тот случай, где готовое надёжнее самодельного.

### Как выбрать частоту троттлинга тикера?

От продукта, а не от источника: дисплеи обновляются с частотой кадров, поэтому чаще двух-пяти кадров в секунду глаз не различает, а ленте новостей хватает одной секунды. Для стакана и графика живой цены ставят сто миллисекунд. Меряйте на своём инстансе: частота — настройка канала, и она обязана быть конфигом, а не константой в коде.

### Нужен ли отдельный протокол для уведомлений, если уже есть чат?

Транспорт и hub общие, но конверты и гарантии разные: у чата одна комната-адресат и store-and-forward по желанию, у уведомлений — userID-маршрут, классы важности и обязательное хранение критичных. Смешивать их в один поток стоит, только когда продукт маленький: позже маркетинговые рассылки в канале чата разъедут с личными событиями, и делить будет дороже.
