Содержание урока · 10 разделов
- Watch: три уровня подписки
- Анатомия события: operationType и documentKey
- operationType: что приходит в событии
- Фильтр событий: конвейер на входе Watch
- Поток в горутине: Next, Err и Close
- Resume token: пережить рестарт приложения
- Правила Change Streams одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Опрос выглядит невинно, пока запросов немного и запаздывание устраивает. Но таймер на каждую коллекцию, лишние сканы по индексам, горутины, которые ничего не нашли, но перекачали батч пустых документов, — и вот у вас несколько процентов 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 — те же имена, те же правила завершения. Разница в характере курсора: обычный кончается, когда документы кончились, а поток живёт бесконечно и ждёт новых событий.
Подписка на события коллекции
GOcoll := 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, — но перечисленных пяти хватает для подавляющего большинства подписчиков, а остальные стоит открывать только когда базовый обработчик уже работает.
Обработчик уведомлений ждал в событии update готовый документ, но поле fullDocument каждый раз оказывалось пустым — тесты на вставке проходили, а прод падал. Если не выставить SetFullDocument(options.UpdateLookup), то в событии update приезжает только дельта updateDescription, и структура с тегом fullDocument декодируется в nil. Проверяйте поток событиями обоих типов: вставка и правка отдают разные наборы полей, и пустота одного поля — не баг драйвера, а штатное поведение сервера по умолчанию.
operationType: что приходит в событии#
| operationType | Когда происходит | Что лежит в событии |
|---|---|---|
| insert | документ вставлен | fullDocument — полный документ |
| update | UpdateOne или UpdateMany | updateDescription с дельтой; полный документ — только с UpdateLookup |
| replace | ReplaceOne | fullDocument — документ после замены |
| delete | DeleteOne или 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)Сервис-подписчик перезапустился после деплоя и молча пропустил заказы за время простоя: новый Watch без опций начинает с текущего момента, а не с прошлого события. Если не сохранять ResumeToken при каждом событии, то все события, случившиеся между остановкой и запуском, пропадут навсегда — oplog хранит историю, но драйвер без токена даже не попытается её читать. Токен — одна строка в базе или файле, и экономит она часы сверки данных вручную.
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 называет причину.
Соберите цикл подписчика
Восстановите каркас подписки: создание потока, гарантированное закрытие и цикл чтения с декодированием.
Соберите подписчика в горутине
Расставьте строки метода по порядку: создание потока, закрытие в defer, цикл с декодированием и обработкой вставок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: Change Streams
Подписка готова: 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, чтобы видеть привязку события к записи.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Watch не работает на одиночном сервере?
Поток событий собирается из oplog, а oplog есть только у replica set: именно им реплики догоняют первичный узел. Одиночный mongod без реплик отклоняет создание потока сразу. Для разработки узел запускают с replSet=rs0 и инициализируют rs.initiate() — одного члена достаточно и для транзакций, и для Change Streams.
Чем SetResumeAfter отличается от SetStartAfter?
SetResumeAfter продолжает чтение с события, следующего за переданным токеном, — её заполняют сохранённым ResumeToken при рестарте. SetStartAfter стартует после указанной точки, не читая её саму, и обычно применяется, когда точку взяли из чужого события или лога. Для сценария «перезапустились и доиграли» нужна именно SetResumeAfter.
Обязательно ли декодировать событие в структуру?
Нет: Decode принимает любой приёмник — bson.M для разовых скриптов, bson.D, структуру с bson-тегами для сервиса. Разница в удобстве и типобезопасности: структура описывает поля один раз, автодополнение работает, а опечатка в имени поля ловится компилятором, а не продакшен-логом. Для типового подписчика структура с тегами — оправданная инвестиция.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос