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

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

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

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

Транзакции: сессии и атомарность

Перевод денег между счетами — классический сценарий, где mongo транзакции go-кода незаменимы: списать баланс у отправителя, зачислить получателю и не оставить деньги в воздухе, если процесс упадёт посередине. Рамка для такой группы операций — mongo session: объект драйвера, внутри которого открывается транзакция, исполняются операции и звучит коммит. Разберём client.UseSession, переработанную в v2 относительно первой версии, канонические опции WriteConcern majority и ReadConcern snapshot, неявный Abort при закрытии сессии и помощника WithTransaction, который повторяет блок сам. Заодно проговорим границы технологии: транзакции живут только на replica set и измеряются секундами, поэтому одиночным документам они не нужны вовсе.

Содержание урока · 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, а транзакция при старте вправе переопределить их своими опциями.

Перевод средств в одной транзакции

GO
func 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-кластере из одного узла это уже тонкость, о которой можно не вспоминать.

Завершение: Commit, Abort и неявный откат#

Транзакция завершается одним из двух способов: CommitTransaction(ctx) фиксирует все изменения разом, AbortTransaction(ctx) откатывает. Коммит — обычная операция с ошибкой в возвращаемом значении: пробросить её наружу через fmt.Errorf с контекстом — обязанность вашего кода, драйвер сам никуда её не положит. Явный Abort уместен при понятной ошибке приложения: счёт заблокирован, лимит превышен, проверка не прошла. Третий путь, о котором часто забывают: если из колбэка UseSession выйти, а коммит не сделан, EndSession вызовет AbortTransaction сам — незакоммиченные изменения исчезнут без следа. Это удобно, но коварно: забытый коммит выглядит как код, который отработал и ничего не сделал, и ищут такой дефект по странному поведению данных, а не по ошибкам. В v2 автоматики в другую сторону нет: сам драйвер коммитит только по вашей команде, автоматически работает лишь откат. Отдельная история — повторы. Retryable writes, которые молча перечитывают одиночную запись при сетевой осечке, на транзакцию как целое не распространяются; вместо этого драйвер предлагает WithTransaction, повторяющий весь колбэк по меткам ошибок, — о нём дальше. И нюанс коммита: если CommitTransaction упал с сетевой ошибкой, судьба изменений остаётся неизвестной — повторный коммит безопасен, а вот AbortTransaction после неудачного коммита драйвер прямо называет опасным шагом, потому что откат может исполниться параллельно с фактически прошедшим коммитом на другом узле.

WithTransaction: рекомендуемый путь

GO
sess, 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 вызвать забыли
})

Проверка: контекст мимо транзакции

Внутри колбэка 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, честно ответьте: действительно ли двум записям нужно умирать и оживать вместе. Дешёвая проверка перед транзакцией: попробуйте закрыть задачу одним документом со вложенными полями — в документной базе это чаще всего и есть правильное моделирование, а не уродливый компромисс.

Соберите рамку транзакции

Восстановите каркас: вход в сессию, извлечение сессии из контекста, старт транзакции и коммит.

err := client._(ctx, func(sctx context.Context) error { sess := mongo._(sctx) if err := sess._(); err != nil { return err } // операции по sctx return sess._(sctx) })

Соберите метод перевода

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

Строки

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

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

Карточки: транзакции

Термин

Сессия (mongo session)

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

Клиентский объект, группирующий операции. В v2 — client.UseSession(ctx, fn): сессия живёт в контексте колбэка; SessionFromContext достаёт её.

1 / 6

Рамка готова: 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 с заведомо невалидным фильтром-документом несовместимого типа и проверьте, что списание откатилось. После каждого сценария логируйте ошибку, которую вернул колбэк, и объясните себе, на каком этапе она возникла.

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

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

Можно ли стартовать транзакцию без UseSession?

Можно: client.StartSession() возвращает сессию напрямую, дальше StartTransaction и CommitTransaction на ней, а EndSession зовётся через defer. UseSession — удобная обёртка над этим же набором, которая закрывает сессию за вас. Колбэк-вариант предпочтительнее: он не оставляет путей забыть EndSession и гарантирует неявный откат.

Почему операция внутри колбэка по старому ctx не попадает в транзакцию?

Транзакция привязана к сессии, а сессия к контексту. Сессионный контекст из колбэка несёт сессию, и драйвер помечает каждую операцию идентификатором транзакции; исходный контекст этой информации не имеет. Операция по нему исполняется как обычная одиночная запись и фиксируется сразу, вне зависимости от судьбы транзакции.

Что вернёт WithTransaction, если колбэк упал трижды?

Драйвер повторяет колбэк, пока ошибка несёт метку TransientTransactionError и не истёк 120-секундный таймаут метода. Если повтор не удался или метки нет, наружу уйдёт последняя ошибка, а начатая транзакция будет откатена. Поэтому колбэк пишут идемпотентным: он обязан одинаково отработать и первый, и пятый запуск.

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

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

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

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

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

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

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