Содержание урока · 10 разделов
- Real-time сценарии: одно слово, три продукта
- Уведомления websocket: fan-out, badge и приоритеты
- Badge и приоритеты рассылки
- Классы уведомлений и их гарантии
- Тикер цен: снапшот, дельты и троттлинг
- Кооп-редактор: версии и presence
- Три сценария и их жертвы
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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, оффлайн — в очередь
GOfunc (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 раз в секунду — обновление дисплея дважды в секунду глазу достаточно, и коалинг из двадцатого урока держит последнее значение по ключу инструмента. Цифры всегда с условием: для ленты новостей хватит одной секунды, для стакана — сотни миллисекунд, и это настройка канала, а не константа протокола.
Подписка на инструмент: снапшот и троттлинг
GOcase "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. Что делать?
Проверка: уведомление спящему получателю
Пользователь оффлайн, серверу нужно доставить уведомление о входе с нового устройства. Что делает правильный обработчик?
Курсор в кооп-редакторе
Прочитайте код и выберите, что он выведет:
// writePump клиента с коалингом из двадцатого урока:
// буфер — карта по ключу, тикер flush раз в сто миллисекунд
// один пользователь шлёт позицию курсора 60 раз в секунду
for i := 0; i < 10; i = i + 1 {
c.send <- env // десять конвертов cursor за окно
}
// сколько кадров уйдёт в сеть за окно?Кейс из практики: команда подключила клиентов к ленте сделок напрямую, по событию на сделку. Двести обновлений в секунду на инструмент, пятьсот подписчиков — сто тысяч WriteJSON в секунду, и инстанс горит на syscall, о которых шёл разговор в двадцатом уроке. Диагноз поставился по профилю за минуты, лечение — троттлинг на подписчика: тикер сто миллисекунд и коалинг по инструменту. Записи упали в двести раз, графики на клиенте не изменились, потому что дисплеи всё равно обновляются с частотой кадров, а не с частотой сделок. Урок один: частота источника и частота восприятия — разные вещи, и между ними обязана стоять настройка.
Я скептически отношусь к websocket в панелях со статистикой: цифры меняются раз в минуту, и polling раз в тридцать секунд дешевле в коде, инфраструктуре и отладке, а для сервер-клиент без обратной связи дешевле SSE. Граница проста: двусторонний обмен и частые обновления — websocket, редкие однонаправленные события — polling или SSE. Все три сценария из урока собираются на локальном Go из материалов курса: песочница умеет только fmt, strings, strconv, math, sort, errors и time, сети в ней нет, поэтому тикер и курсоры проверяйте на своём железе с двумя вкладками.
Соберите подписку тикера
Впишите четыре пропуска: тип снапшота, поле тикера, чтение книги и номер состояния.
Соберите порядок подписки тикера
Расставьте строки обработчика: снапшот до дельт.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: сценарии
Карта применения заполнена: уведомлениям нужен fan-out с классами важности и хранилищем, тикеру — снапшот с дельтами и троттлинг, кооп-редактору — версии и lossy-курсоры. Всё это вариации деталей, которые курс уже собрал: конверты, насосы, presence, батчинг. Финальная точка — двадцать пятый урок, где всё складывается в проект чата с комнатами. Личные сообщения и store-and-forward описаны в двенадцатом уроке, коалинг и батчинг — в двадцатом, presence и курсоры — в девятнадцатом, reconnect — в семнадцатом уроке. Тикеры и точность интервалов разобраны в продвинутом курсе Go в теме про time. Прогоните один сценарий на локальном Go с двумя вкладками — карта из абстрактной станет вашей. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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-маршрут, классы важности и обязательное хранение критичных. Смешивать их в один поток стоит, только когда продукт маленький: позже маркетинговые рассылки в канале чата разъедут с личными событиями, и делить будет дороже.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами