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

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

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

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

Change Streams: события реального времени

Сценарий, где change streams go-приложений незаменимы, знаком каждому, кто писал цикл опроса: таймер, сканы по индексам, батчи пустых документов и запаздывание, которое всё равно никто не отменял. Подписка на события решает иначе: каждая вставка, правка и удаление приезжает сама, в момент коммита. В драйвере эта механика называется mongo watch: метод Watch есть у клиента, базы и коллекции. Разберём coll.Watch с пустым конвейером и с $match, анатомию события — operationType, documentKey и fullDocument с options.UpdateLookup, — затем resume token, которым поток переживает рестарт приложения, и жизненный цикл горутины: Next, Err и Close. К концу урока у вас будет каркас подписчика, который не боится ни рестартов, ни сетевых осечек.

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

Опрос выглядит невинно, пока запросов немного и запаздывание устраивает. Но таймер на каждую коллекцию, лишние сканы по индексам, горутины, которые ничего не нашли, но перекачали батч пустых документов, — и вот у вас несколько процентов CPU тратится на вопрос «ну что, изменилось?», задаваемый базе десять раз в секунду. Change Streams разворачивает направление потока: не приложение спрашивает базу, а база сообщает приложению. Сервер транслирует события из oplog — того же журнала, на котором держится репликация, — и каждый insert, update и delete доезжает до подписчика документом с деталями операции. Задержка — миллисекунды после коммита, без таймеров и пустых батчей. В этом уроке соберём подписчика на коллекцию заказов: Watch с фильтрующим конвейером, декодирование события в структуру, полный документ для событий update, сохранение resume token и восстановление потока после рестарта. Отдельно проговорим требования: поток событий — привилегия replica set, и механика чтения здесь та же курсорная, что и в седьмом уроке, только курсор бесконечный.

Watch: три уровня подписки#

Подписка строится одним методом с тремя владельцами: client.Watch ловит события всего кластера, db.Watch — всех коллекций одной базы, coll.Watch — единственной коллекции. Область видимости — единственное различие; сигнатура во всех трёх случаях одна и та же: Watch(ctx, pipeline, opts...) и *ChangeStream на выходе. В v2 конвейер описывается значением типа any: подойдёт mongo.Pipeline из bson.D, знакомый по агрегациям, или пустой конвейер для всех событий без фильтра. Требования к окружению жёсткие, и о них стоит помнить до первого запуска: события собираются из oplog, поэтому нужен replica set — на одиночном сервере Watch вернёт ошибку сразу же, при создании потока. Вторая оговорка из документации драйвера: клиент должен работать с read concern majority либо вообще без заданного read concern, иначе сервер откажется создать поток. Сам ChangeStream — обёртка над курсором, и механика чтения из седьмого урока переносится один в один: Next, Decode, Err и Close — те же имена, те же правила завершения. Разница в характере курсора: обычный кончается, когда документы кончились, а поток живёт бесконечно и ждёт новых событий.

Подписка на события коллекции

GO
coll := client.Database("shop").Collection("orders")

cs, err := coll.Watch(ctx, mongo.Pipeline{})
if err != nil {
        return fmt.Errorf("watch orders: %w", err)
}
defer cs.Close(context.Background())

for cs.Next(ctx) {
        var event bson.M
        if err := cs.Decode(&event); err != nil {
                return fmt.Errorf("decode event: %w", err)
        }
        fmt.Printf("событие: %v
", event["operationType"])
}
if err := cs.Err(); err != nil {
        return fmt.Errorf("change stream: %w", err)
}

Watch с пустым конвейером отдаёт все события коллекции: Next блокируется до ближайшего события, Decode распаковывает его документ в bson.M, а Err после выхода из цикла называет причину — отмену контекста или ошибку потока. Close в defer закрывает поток при любом выходе из функции, включая панику по горутине.

Анатомия события: operationType и documentKey#

Событие — обычный BSON-документ, и главные его поля известны заранее. operationType называет операцию: insert, update, replace, delete или invalidate — пять типов, на которые стоит закладываться обработчику. documentKey несёт _id затронутого документа — тот самый ObjectID из двенадцатого урока, по которому событие всегда привязывается к записи, даже когда самого документа уже нет. ns сообщает базу и коллекцию, clusterTime — момент операции. Для insert и replace в событии лежит fullDocument — полный документ после операции; с update иначе: по умолчанию приезжает только updateDescription, список изменённых полей из $set, а полного документа нет. Это экономно для больших записей, но ломает обработчик, рассчитанный на готовый объект. Плечо, которым документ подтягивают целиком, — опция SetFullDocument(options.UpdateLookup) на конструкторе options.ChangeStream(): в каждое событие update вставляется текущее состояние документа. Нюанс, о котором помнят в проде: UpdateLookup читает документ на момент получения события, а не на момент изменения, — между этими мгновениями чужие правки уже могли пролечь, и подписчик видит чуть более свежую версию, чем та, что вызвала событие. Декодировать событие удобнее структурой с bson-тегами из пятого урока: поля operationType, documentKey и fullDocument описываются в типах один раз и живут в коде как обычные объекты. Полный список полей события длиннее — в событиях транзакций появляются lsid и txnNumber, — но перечисленных пяти хватает для подавляющего большинства подписчиков, а остальные стоит открывать только когда базовый обработчик уже работает.

operationType: что приходит в событии#

operationTypeКогда происходитЧто лежит в событии
insertдокумент вставленfullDocument — полный документ
updateUpdateOne или UpdateManyupdateDescription с дельтой; полный документ — только с UpdateLookup
replaceReplaceOnefullDocument — документ после замены
deleteDeleteOne или DeleteManyтолько documentKey: самого документа больше нет
invalidateпоток не может продолжатьсяпосле него нужен новый Watch

Фильтр событий: конвейер на входе Watch#

Первым аргументом Watch принимает конвейер агрегаций — тот же язык этапов, что в пятнадцатом уроке, только применённый к потоку событий, а не к коллекции. Чаще всего хватает одного $match: оставить вставки или события определённого типа, и сервер отдаёт поток уже отфильтрованным — мусор до драйвера не доезжает вовсе. Конвейер на событиях компактен: bson.D с ключом operationType и $in со списком типов, тем же синтаксисом, что в обычных фильтрах. Доступны и другие этапы — $addFields и $project обогащают и сужают событие, — но увлекаться не стоит: тяжёлый конвейер живёт на сервере всё время подписки и ест ресурсы кластера, пока подписчик подключён. Фильтр при этом уместен почти всегда: подписчик, отбрасывающий девять событий из десяти уже в Go, тратит сеть и процессор на события, которые сервер мог бы не отправлять, — тот же $match на входе Watch снимает проблему бесплатно. Этап $project дополнительно сужает событие до нужных полей: обработчику, которому важны только _id и тип операции, не нужен весь документ с десятками полей. На клиенте, базе и коллекции конвейер фильтрует одинаково — разница только в объёме потока, который дойдёт до приложения.

Проверка: событие update по умолчанию

Что содержит событие update при Watch без опции SetFullDocument?

Поток в горутине: Next, Err и Close#

Change Streams живут в выделенной горутине, и жизненный цикл у неё простой, как у любого фонового воркера. Next(ctx) блокируется, пока не придёт событие, не случится ошибка или не истечёт контекст: при отмене ctx метод вернёт false, а cs.Err() отдаст контекстную ошибку — поток умирает вместе со своим контекстом, и это штатный способ остановки: отменили контекст сервиса, горутина вышла, defer cs.Close закрыл поток. Для неблокирующего чтения существует TryNext: он возвращается сразу, событие есть или нет; если false, а Err() пуст, событий просто не было — цикл ждёт и зовёт снова. После false от Next дальнейшие вызовы тоже вернут false: поток в ошибочном состоянии, читайте Err и решайте, пересоздавать Watch или отдавать ошибку наверх. Внутри драйвера поток сам переживает мелкие неприятности — временный обрыв сети и смену узла реплики: резюм по токену встроен, и цикл Next таких качелей обычно даже не замечает. Наружу прорывается то, что драйвер вылечить не может: invalidate, потерянная точка продолжения или длительная смерть кластера. Горутине при этом нужен собственный контекст, производный от контекста сервиса: поделите время жизни так, чтобы отмена сервиса гарантированно гасила подписку, а не оставляла её висеть в фоне с открытым курсором.

Что напечатает цикл после таймаута?

Прочитайте код и выберите, что он выведет:

// подписка cs создана, в коллекцию 2 секунды
// никто ничего не вставлял
ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

for cs.Next(ctx2) {
        fmt.Println("событие")
}
fmt.Println("exit:", cs.Err() != nil)

Resume token: пережить рестарт приложения#

Каждое событие потока несёт резюм-токен: cs.ResumeToken() возвращает bson.Raw с меткой последнего прочитанного события. Токен — точка продолжения, и у драйвера три опции на этот счёт. SetResumeAfter(token) начинает чтение с события после сохранённого токена — канонический способ продолжить с места остановки. SetStartAfter(point) работает похоже, но стартует после указанной точки без чтения её самой, а SetStartAtOperationTime привязывает начало потока к моменту времени через *bson.Timestamp. Драйвер пользуется токенами непрерывно и без вашей помощи: временная ошибка, переключение на другой узел реплики — поток резюмируется внутри, и цикл Next этого не заметит. Ручное сохранение нужно ровно для одного сценария — рестарта приложения: при каждом событии пишите токен в базу или файл, при старте читайте его и передавайте в SetResumeAfter, тогда события за время простоя доедут из oplog-истории. У истории есть глубина: oplog конечен, и если приложение лежало дольше его окна, сервер ответит ошибкой о том, что точка продолжения вышла из oplog, — лечится это полной пересверкой данных, а не доигрыванием событий. Порядок действий при сохранении тоже важен: сначала обработать событие, потом записать токен — при падении посередине событие обработается повторно, и подписчик обязан выдерживать такой повтор без порчи данных.

Сохранение и восстановление токена

GO
// при каждом событии
if token := cs.ResumeToken(); token != nil {
        if err := checkpoints.Save(ctx, token); err != nil {
                return fmt.Errorf("save token: %w", err)
        }
}

// при старте приложения
saved, _ := checkpoints.Load(ctx)
opts := options.ChangeStream().SetFullDocument(options.UpdateLookup)
if saved != nil {
        opts = opts.SetResumeAfter(saved)
}
cs, err := coll.Watch(ctx, mongo.Pipeline{}, opts)

ResumeToken отдаёт точку последнего события как bson.Raw — её сохраняют как есть, без конвертаций. На старте поток создаётся заново: SetFullDocument подтягивает полные документы для событий update, SetResumeAfter продолжает после сохранённого токена. События за время простоя приезжают из oplog-истории, пока она не кончилась.

Проверка: точка продолжения

Какая опция продолжит поток с события, следующего за сохранённым токеном?

Правила Change Streams одним списком#

  • Watch есть у клиента, базы и коллекции — разница только в области событий.
  • Нужны replica set и read concern majority либо отсутствие read concern вовсе.
  • У события update по умолчанию нет полного документа — подтягивайте SetFullDocument(options.UpdateLookup).
  • $match на входе Watch режет события до доставки: фильтруйте конвейером, а не в Go.
  • ResumeToken сохраняйте при каждом событии и отдавайте в SetResumeAfter на старте.
  • Поток умирает вместе с контекстом: Err после false от Next называет причину.

Соберите цикл подписчика

Восстановите каркас подписки: создание потока, гарантированное закрытие и цикл чтения с декодированием.

cs, err := coll._(ctx, mongo.Pipeline{}) if err != nil { return err } defer cs._(context.Background()) for cs._(ctx) { var event bson.M if err := cs._(&event); err != nil { return err } }

Соберите подписчика в горутине

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

Строки

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

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

Карточки: Change Streams

Термин

Change Streams

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

Серверный поток событий из oplog: Watch у клиента, базы или коллекции. События insert, update, replace, delete и invalidate приезжают документами в момент коммита.

1 / 6

Подписка готова: Watch с $match на входе, цикл Next с Decode, токен в хранилище и SetResumeAfter на старте. Логичнее всего связать её с транзакциями: коммит перевода рождает события, и подписчики узнают о деньгах сразу, без опроса и без костылей с очередью. Чтение потока — та же механика курсора седьмого урока: Next, Decode и Err с теми же правилами завершения, только курсор бесконечный. Событие декодируется структурой с bson-тегами пятого урока — типы вместо скролла по bson.M. Проверяйте всё это на живой базе: песочница сайта — тематическая программа на чистом Go, поэтому replica set для транзакций и потоков поднимите локально в Docker, как во втором уроке, — mongod с replSet=rs0 и mongo-go-driver v2 разворачиваются одной командой. И держите фильтр в конвейере: подписчик без $match превращается в насос, качающий всю базу в приложение, а серверных ресурсов у потока мало.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Watch есть у клиента, базы и коллекции: одна сигнатура, разная область событий.
  • Поток требует replica set и read concern majority либо отсутствия read concern.
  • Событие — BSON-документ: operationType, documentKey с _id, ns и clusterTime.
  • У update по умолчанию только updateDescription; SetFullDocument(options.UpdateLookup) подтягивает документ.
  • $match на входе Watch фильтрует события на сервере, до доставки в приложение.
  • Next блокируется до события или отмены ctx; TryNext возвращается сразу без блокировки.
  • ResumeToken сохраняют при каждом событии и передают в SetResumeAfter после рестарта.
  • Драйвер сам переживает временные обрывы; наружу прорывается invalidate и потерянная точка oplog.

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

Соберите подписчика на коллекцию заказов и прогоните три сценария. Первый: пустой конвейер, вставьте документ в mongosh и убедитесь, что событие insert приехало с полным документом. Второй: сделайте UpdateOne без SetFullDocument и с ним — сравните наличие поля fullDocument в событии. Третий: остановите приложение, вставьте три документа, запустите с SetResumeAfter по сохранённому токену и убедитесь, что все три события доигрались. В каждом сценарии логируйте operationType и documentKey, чтобы видеть привязку события к записи.

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

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

Почему Watch не работает на одиночном сервере?

Поток событий собирается из oplog, а oplog есть только у replica set: именно им реплики догоняют первичный узел. Одиночный mongod без реплик отклоняет создание потока сразу. Для разработки узел запускают с replSet=rs0 и инициализируют rs.initiate() — одного члена достаточно и для транзакций, и для Change Streams.

Чем SetResumeAfter отличается от SetStartAfter?

SetResumeAfter продолжает чтение с события, следующего за переданным токеном, — её заполняют сохранённым ResumeToken при рестарте. SetStartAfter стартует после указанной точки, не читая её саму, и обычно применяется, когда точку взяли из чужого события или лога. Для сценария «перезапустились и доиграли» нужна именно SetResumeAfter.

Обязательно ли декодировать событие в структуру?

Нет: Decode принимает любой приёмник — bson.M для разовых скриптов, bson.D, структуру с bson-тегами для сервиса. Разница в удобстве и типобезопасности: структура описывает поля один раз, автодополнение работает, а опечатка в имени поля ловится компилятором, а не продакшен-логом. Для типового подписчика структура с тегами — оправданная инвестиция.

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

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

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

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

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос