Содержание урока · 10 разделов
Два UpdateOne подряд — самый частый наивный ответ на задачу перевода: первый списывает, второй зачисляет. Между ними полно точек отказа: сеть моргнула, пода перезапустился, второй запрос упал с duplicate key — и деньги уже списаны, а до получателя не дошли. Починить такое вручную дорого: нужен журнал компенсаций, фоновые сверщики и вечный страх рассинхрона. Транзакция решает ту же задачу одним свойством: группа операций фиксируется атомарно, либо целиком, либо никак — промежуточное состояние никто, включая другие процессы, не увидит. В этом уроке соберём перевод средств честно: сессия через client.UseSession, старт транзакции с опциями majority и snapshot, операции по сессионному контексту и явный коммит. Разберём, что происходит при забытом коммите, почему колбэк WithTransaction обязан возвращать ошибки наружу и какие лимиты ставит сервер. Требование к окружению озвучим сразу: многодокументные транзакции исполняет только replica set, поэтому всё, что вы знали о standalone-сервере с первых уроков, здесь придётся переосмыслить.
Сессия: рамка для группы операций#
Сессия в mongo-driver — клиентский объект, который группирует операции и держит серверный контекст исполнения; без неё транзакций не бывает вовсе. В v2 основной вход — метод client.UseSession(ctx, fn): драйвер создаёт сессию, прикрепляет её к контексту и вызывает ваш колбэк, а по выходу из колбэка закрывает сессию сам. Подпись колбэка честная: fn получает обычный context.Context — отдельного типа SessionContext, знакомого по статьям про первую версию драйвера, в v2 больше нет. Сессия достаётся из контекста функцией mongo.SessionFromContext(ctx), которая вернёт *Session или nil вне сессионного контекста. Старый путь с ручным управлением тоже легален: client.StartSession() без колбэка возвращает сессию напрямую, и тогда EndSession зовёте сами через defer — UseSession просто оборачивает эту связку в удобную обёртку. Драйвер предупреждает прямо в документации: контекст колбэка не потокобезопасен, и пользоваться им из нескольких горутин нельзя. Практическое следствие простое: на один HTTP-запрос — одна сессия, и все операции запроса исполняются через её контекст. За неявным закрытием прячется полезная работа: EndSession при выходе сначала откатит оставшуюся незакоммиченной транзакцию через AbortTransaction и лишь потом закроет сессию — серверные ресурсы не текут. Если сессии нужны нестандартные уровни чтения и записи по умолчанию, у UseSession есть старший брат UseSessionWithOptions: он принимает конструктор options.Session() с дефолтными ReadConcern, WriteConcern и ReadPreference, а транзакция при старте вправе переопределить их своими опциями.
Перевод средств в одной транзакции
GOfunc TransferFunds(ctx context.Context, client *mongo.Client,
from, to primitive.ObjectID, amount int64) error {
return client.UseSession(ctx, func(sctx context.Context) error {
sess := mongo.SessionFromContext(sctx)
opts := options.Transaction().
SetWriteConcern(writeconcern.Majority()).
SetReadConcern(readconcern.Snapshot())
if err := sess.StartTransaction(opts); err != nil {
return err
}
coll := client.Database("bank").Collection("accounts")
if _, err := coll.UpdateOne(sctx, bson.M{"_id": from},
bson.M{"$inc": bson.M{"balance": -amount}}); err != nil {
return err
}
if _, err := coll.UpdateOne(sctx, bson.M{"_id": to},
bson.M{"$inc": bson.M{"balance": amount}}); err != nil {
return err
}
return sess.CommitTransaction(sctx)
})
}UseSession создаёт сессию и передаёт её колбэку через контекст sctx: все операции внутри используют именно его. SessionFromContext достаёт объект сессии для StartTransaction и CommitTransaction. Опции задают канон majority плюс snapshot. Оба UpdateOne идут по сессионному контексту и попадают в транзакцию; ошибка выходит из колбэка, а сделанный без коммита набор драйвер откатит при закрытии сессии.
Опции транзакции: majority и snapshot#
Транзакция настраивается конструктором options.Transaction(): SetWriteConcern, SetReadConcern и SetReadPreference — три доступных ручки, и их хватает. Канон, который рекомендует документация MongoDB: WriteConcern majority — запись подтверждается большинством узлов реплики, ReadConcern snapshot — операции внутри видят согласованный снимок данных на момент старта. Пакеты mongo/writeconcern и mongo/readconcern дают готовые значения: writeconcern.Majority() и readconcern.Snapshot() — связка из двух строк в конструкторе. Сами уровни не уникальны для транзакций — их же ставят обычным Find и UpdateOne через опции коллекции; внутри транзакции они просто важнее вдвойне, потому что защищают не один запрос, а всю группу. Почему именно snapshot: внутри транзакции вы читаете текущий баланс и тут же пишете новый; без изоляции снимка между этими двумя шагами вклинится чужая запись, и вся атомарность станет декорацией. С snapshot драйвер читает то же состояние, на которое опирается запись, — либо обе операции легли согласованно, либо конфликт откатил всё вместе. Опции передаются аргументом в StartTransaction и действуют на всю транзакцию целиком. Для типового сервиса их достаточно объявить один раз пакетной константой и переиспользовать в каждом переводе. ReadPreference пригодится в геораспределённых кластерах, где чтение хочется вести с ближайшего узла; в обычном develop-кластере из одного узла это уже тонкость, о которой можно не вспоминать.
Первый же запуск на машине с обычным mongod без реплик падает на StartTransaction с ошибкой Transaction numbers are only allowed on a replica set member or mongos. Если вести разработку на одиночном сервере, то многодокументные транзакции недоступны в принципе: сервер их не исполняет, и никакие опции драйвера это не изменят. Лечение известное: запустить узел с параметром replSet=rs0 и выполнить rs.initiate() — MongoDB начнёт считать себя репликой из одного члена, транзакции заработают без второго экземпляра базы.
Завершение: Commit, Abort и неявный откат#
Транзакция завершается одним из двух способов: CommitTransaction(ctx) фиксирует все изменения разом, AbortTransaction(ctx) откатывает. Коммит — обычная операция с ошибкой в возвращаемом значении: пробросить её наружу через fmt.Errorf с контекстом — обязанность вашего кода, драйвер сам никуда её не положит. Явный Abort уместен при понятной ошибке приложения: счёт заблокирован, лимит превышен, проверка не прошла. Третий путь, о котором часто забывают: если из колбэка UseSession выйти, а коммит не сделан, EndSession вызовет AbortTransaction сам — незакоммиченные изменения исчезнут без следа. Это удобно, но коварно: забытый коммит выглядит как код, который отработал и ничего не сделал, и ищут такой дефект по странному поведению данных, а не по ошибкам. В v2 автоматики в другую сторону нет: сам драйвер коммитит только по вашей команде, автоматически работает лишь откат. Отдельная история — повторы. Retryable writes, которые молча перечитывают одиночную запись при сетевой осечке, на транзакцию как целое не распространяются; вместо этого драйвер предлагает WithTransaction, повторяющий весь колбэк по меткам ошибок, — о нём дальше. И нюанс коммита: если CommitTransaction упал с сетевой ошибкой, судьба изменений остаётся неизвестной — повторный коммит безопасен, а вот AbortTransaction после неудачного коммита драйвер прямо называет опасным шагом, потому что откат может исполниться параллельно с фактически прошедшим коммитом на другом узле.
WithTransaction: рекомендуемый путь
GOsess, err := client.StartSession()
if err != nil {
return err
}
defer sess.EndSession(ctx)
opts := options.Transaction().
SetWriteConcern(writeconcern.Majority()).
SetReadConcern(readconcern.Snapshot())
_, err = sess.WithTransaction(ctx, func(sctx context.Context) (any, error) {
if _, err := coll.UpdateOne(sctx, bson.M{"_id": from},
bson.M{"$inc": bson.M{"balance": -amount}}); err != nil {
return nil, err
}
if _, err := coll.UpdateOne(sctx, bson.M{"_id": to},
bson.M{"$inc": bson.M{"balance": amount}}); err != nil {
return nil, err
}
return nil, nil
}, opts)StartSession и EndSession — ручной вариант рамки: defer гарантирует неявный откат при любом выходе. Вся остальная механика внутри WithTransaction: драйвер сам вызывает StartTransaction, делает коммит и при сбое повторяет колбэк по меткам TransientTransactionError и UnknownTransactionCommitResult в пределах 120 секунд. Колбэк обязан возвращать ошибки наружу и быть идемпотентным — он может исполниться дважды.
Правила транзакций одним списком#
- UseSession кладёт сессию в контекст колбэка; SessionFromContext достаёт её для StartTransaction.
- Канон опций: WriteConcern majority плюс ReadConcern snapshot через options.Transaction().
- Каждая операция внутри обязана идти по сессионному контексту: чужой контекст уводит запись мимо транзакции.
- Коммит всегда явный: без CommitTransaction изменения откатятся при закрытии сессии.
- Транзакции исполняет только replica set; лимиты — 60 секунд и 16 МБ oplog-записи.
- Для повторов берите WithTransaction: колбэк идемпотентен и возвращает ошибки наружу.
Что станет с балансом Анны?
Прочитайте код и выберите, что он выведет:
_ = client.UseSession(ctx, func(sctx context.Context) error {
sess := mongo.SessionFromContext(sctx)
if err := sess.StartTransaction(); err != nil {
return err
}
_, err := coll.UpdateOne(sctx, bson.M{"login": "anna"},
bson.M{"$inc": bson.M{"balance": 500}})
return err // CommitTransaction вызвать забыли
})Сервис гонял перевод через WithTransaction, логировал ошибку записи внутри колбэка и возвращал nil, чтобы не падать. Если проглотить ошибку операции и вернуть из колбэка nil, то драйвер не сможет понять, прервалась транзакция или ещё жива, и будет повторять блок снова и снова: по документации колбэк обязан после любой обработки вернуть ошибку наружу. На практике это выглядит как неожиданные повторные записи в базе и крутящийся цикл повторов, пока не истекут 120 секунд таймаута метода.
Проверка: контекст мимо транзакции
Внутри колбэка UseSession операция UpdateOne вызвана с исходным ctx вместо сессионного sctx. Что произойдёт?
API сессий и транзакций v2#
| Метод | Что делает | На что смотреть |
|---|---|---|
| client.UseSession(ctx, fn) | создаёт сессию, кладёт в контекст колбэка, закрывает по выходу | контекст колбэка не потокобезопасен |
| mongo.SessionFromContext(ctx) | достаёт *Session из контекста | nil вне сессионного контекста |
| sess.StartTransaction(opts...) | открывает транзакцию с опциями options.Transaction() | ошибка на сервере без replica set |
| sess.CommitTransaction(ctx) | фиксирует все изменения разом | после неудачного коммита Abort звать опасно |
| sess.AbortTransaction(ctx) | откатывает транзакцию целиком | по незакрытой транзакции вызовется сам |
| sess.WithTransaction(ctx, fn) | старт, коммит и ретраи по меткам до 120 секунд | колбэк идемпотентен и возвращает ошибки |
Лимиты: 60 секунд и 16 мегабайт#
Две границы сервера стоит знать до прода, а не после инцидента. Первая — время: транзакция по умолчанию живёт 60 секунд, параметр transactionLifetimeLimitSeconds, после чего сервер убивает её сам, и колбэк получает ошибку. Порог поднимается администратором на сервере, но строить архитектуру на этой ручке — плохая идея: длинные транзакции дольше держат серверные блокировки и мешают всем остальным процессам. Значит, внутри транзакции не место внешним HTTP-вызовам, тяжёлым расчётам и ожиданию ввода пользователя: транзакция — короткая атомарная запись, а не обёртка для бизнес-процесса. Вторая граница — размер: весь набор изменений транзакции ложится в одну oplog-запись с общим лимитом 16 МБ; транзакция, меняющая сотни тысяч крупных документов, упрётся в потолок и упадёт на коммите со странной на первый взгляд ошибкой. Требование к топологии то же, что и при отладке: транзакции исполняют replica set и шардированный кластер, где mongos проксирует их на шарды; одиночный mongod отклоняет операцию сразу. Отсюда прикладное правило: транзакция должна закрывать маленькую согласованную запись — пару коллекций и десяток операций. Если атомарности требует большой объём, данные моделируют иначе — встраивают, денормализуют, режут на этапы, — а не растягивают транзакцию до предела.
Проверка: канонические уровни
Какая пара уровней записи и чтения считается канонической для транзакций?
Когда транзакция не нужна#
Транзакции — тяжёлая артиллерия, и чаще всего она стреляет мимо цели. Запись одного документа в MongoDB атомарна сама по себе: UpdateOne с $inc и Upsert из десятого урока закрывают счётчики, лимиты и синхронизацию без всяких сессий и опций. Если логика меняет одну коллекцию, поместите данные в один документ — встроенный subdocument обновляется атомарно, и разводить поля по коллекциям ради красивой схемы не нужно. Многодокументная транзакция оправдана, когда согласованность между разными коллекциями обязательна и обойти её нельзя: деньги, остатки на складе, квоты и дедупликация по двум хранилищам. А если реакция на изменение не обязана быть мгновенно согласованной, очередь или подписка на события дешевле и живучее — об этом девятнадцатый урок. Слой репозитория двадцать первого урока здесь не посторонний гость: методы репозитория принимают context.Context, поэтому сессионный контекст протекает внутрь без переделок, и транзакция оборачивает вызовы репозитория, а не дублирует их SQL-подобным кодом. Прежде чем писать UseSession, честно ответьте: действительно ли двум записям нужно умирать и оживать вместе. Дешёвая проверка перед транзакцией: попробуйте закрыть задачу одним документом со вложенными полями — в документной базе это чаще всего и есть правильное моделирование, а не уродливый компромисс.
Соберите рамку транзакции
Восстановите каркас: вход в сессию, извлечение сессии из контекста, старт транзакции и коммит.
Соберите метод перевода
Расставьте строки метода по порядку: сессия, старт транзакции, две операции и коммит.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: транзакции
Рамка готова: UseSession плюс StartTransaction, операции по сессионному контексту и явный CommitTransaction — либо WithTransaction, где коммит и ретраи уже встроены. Дальше по курсу ошибки внутри транзакции перестают быть теоретическими: duplicate key и таймауты драйвера разбирает следующий урок, и именно они чаще всего и прерывают коммит. Событие после успешного перевода удобно отдавать наружу подписчикам — этим занимается Change Streams, а ручная рамка со StartSession без колбэка ещё пригодится в тестах двадцать третьего урока. Проверяйте себя на живой базе: песочница сайта — тематическая программа на чистом Go, поэтому реплику для транзакций поднимите локально в Docker по второму уроку — mongod с replSet=rs0 и mongo-go-driver v2 разворачиваются одной командой. И помните про одиночные документы: половина «необходимых транзакций» закрывается обычным Upsert из десятого урока, атомарным по определению, а фильтры, которыми транзакция находит счета, должны опираться на индексы — это тема соседнего урока.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сессия — рамка транзакций: client.UseSession кладёт её в контекст колбэка, SessionFromContext достаёт.
- Канон опций — WriteConcern majority и ReadConcern snapshot через options.Transaction().
- Операция попадает в транзакцию только по сессионному контексту: чужой ctx уводит запись мимо.
- Коммит всегда явный; без него EndSession выполнит неявный AbortTransaction при закрытии сессии.
- WithTransaction повторяет колбэк по меткам ошибок до 120 секунд — колбэк идемпотентен и честен с ошибками.
- Транзакции требуют replica set и живут не дольше 60 секунд с oplog-записью не тяжелее 16 МБ.
- Одноатомарный документ — работа для UpdateOne с $inc и Upsert, а не для транзакции.
Домашнее задание
Соберите перевод средств между двумя счетами на живой базе и прогоните три сценария. Первый — счастливый путь: оба UpdateOne и коммит, затем сверьте балансы в mongosh. Второй — забытый коммит: уберите CommitTransaction и убедитесь, что балансы не изменились, потому что EndSession откатил транзакцию. Третий — ошибка посередине: сделайте второй UpdateOne с заведомо невалидным фильтром-документом несовместимого типа и проверьте, что списание откатилось. После каждого сценария логируйте ошибку, которую вернул колбэк, и объясните себе, на каком этапе она возникла.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли стартовать транзакцию без UseSession?
Можно: client.StartSession() возвращает сессию напрямую, дальше StartTransaction и CommitTransaction на ней, а EndSession зовётся через defer. UseSession — удобная обёртка над этим же набором, которая закрывает сессию за вас. Колбэк-вариант предпочтительнее: он не оставляет путей забыть EndSession и гарантирует неявный откат.
Почему операция внутри колбэка по старому ctx не попадает в транзакцию?
Транзакция привязана к сессии, а сессия к контексту. Сессионный контекст из колбэка несёт сессию, и драйвер помечает каждую операцию идентификатором транзакции; исходный контекст этой информации не имеет. Операция по нему исполняется как обычная одиночная запись и фиксируется сразу, вне зависимости от судьбы транзакции.
Что вернёт WithTransaction, если колбэк упал трижды?
Драйвер повторяет колбэк, пока ошибка несёт метку TransientTransactionError и не истёк 120-секундный таймаут метода. Если повтор не удался или метки нет, наружу уйдёт последняя ошибка, а начатая транзакция будет откатена. Поэтому колбэк пишут идемпотентным: он обязан одинаково отработать и первый, и пятый запуск.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос