Многодокументные транзакции

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

Многодокументная транзакция — группа операций над несколькими документами (и даже коллекциями), которые фиксируются как единое целое: при commit применяются все, при abort или сбое не применяется ни одна. Это полноценные ACID-гарантии поверх нескольких документов.

Зачем на практике

Большинство задач решает атомарность одного документа из прошлого урока. Но есть сценарии, где сущности по природе раздельны и менять их нужно вместе: перевод средств между счетами, перенос товара со склада в заказ другой коллекции, бухгалтерская проводка по двум счетам. Если списать с одного и упасть до зачисления на другой, деньги «испарятся». Транзакция гарантирует, что такого промежуточного состояния не будет видно никому.

Технически транзакции в MongoDB живут на репликасете (или шардированном кластере): движок умеет откатывать изменения и согласованно применять их разом. На одиночном mongod без репликасета транзакции недоступны.

Как выглядит код

Транзакция всегда привязана к сессии. Команды драйвера/оболочки ниже показаны как текст — это не самодостаточный исполняемый сниппет, а каркас для чтения.

const session = db.getMongo().startSession()
session.startTransaction({
  readConcern:  { level: "snapshot" },
  writeConcern: { w: "majority" }
})
try {
  const accounts = session.getDatabase("bank").accounts
  // списываем, только если хватает средств
  const ok = accounts.updateOne(
    { _id: "acc_A", balance: { $gte: 100 } },
    { $inc: { balance: -100 } }
  )
  if (ok.modifiedCount !== 1) throw new Error("недостаточно средств")
  accounts.updateOne({ _id: "acc_B" }, { $inc: { balance: 100 } })
  session.commitTransaction()   // обе правки фиксируются разом
} catch (e) {
  session.abortTransaction()    // ни одна правка не применяется
  throw e
} finally {
  session.endSession()
}

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

Когда транзакции реально нужны

СитуацияНужна транзакция?
Правка одного документа (заказ + позиции внутри)Нет — атомарность документа
Счётчик, остаток, добавление в массивНет — $inc / $push
Перевод между двумя независимыми документамиДа
Согласованная запись в две коллекцииДа
Денежные проводки, инвентарь, биллингДа

Цена транзакций

Транзакции не бесплатны, и злоупотреблять ими не стоит.

  • Производительность. Транзакция держит снимок данных и ресурсы до коммита; это снижает пропускную способность по сравнению с одиночными операциями.
  • Лимит времени. По умолчанию транзакция не должна жить дольше 60 секунд (transactionLifetimeLimitSeconds), иначе сервер её прервёт. Долгие транзакции — антипаттерн.
  • Контеншн. Конкурирующие транзакции, трогающие одни документы, конфликтуют по записи (WriteConflict) и вынуждены повторяться.
  • Память. Снимок и неподтверждённые изменения держатся в кэше WiredTiger.

Практическое правило: сначала попробуйте перепроектировать данные под один документ. Транзакция — инструмент для действительно раздельных сущностей, а не способ обойти моделирование.

Как это работает под капотом

Внутри транзакция использует snapshot-изоляцию WiredTiger: все чтения видят согласованный снимок данных на момент старта, а записи откладываются. Коммит проходит двухфазно и подтверждается с учётом write concern (например, majority — большинством узлов репликасета). На шардированном кластере координатор согласует фиксацию между шардами. Если в момент коммита сменился primary или возник временный сбой, сервер помечает ошибку специальной меткой — и тогда всю транзакцию положено повторить.

Ретраи — обязательная часть

Писать транзакцию без ретраев нельзя: в распределённой системе нормальны временные ошибки. Драйверы различают две метки.

  • TransientTransactionError — транзакцию можно безопасно повторить целиком с самого начала (новая попытка всей логики).
  • UnknownTransactionCommitResult — повторять следует именно commitTransaction: результат коммита неизвестен, но повтор идемпотентен.
// упрощённый каркас повтора всей транзакции
while (true) {
  try {
    runTransfer(session)      // вся логика + commit внутри
    break
  } catch (e) {
    if (e.hasErrorLabel("TransientTransactionError")) continue
    throw e
  }
}

Многие драйверы дают готовую обёртку withTransaction, которая сама делает эти ретраи — предпочитайте её ручному циклу.

Частые ошибки

  • Транзакции вместо моделирования. Если данные логично уложить в один документ — делайте так, это и быстрее, и проще.
  • Нет ретраев. Без обработки TransientTransactionError приложение будет иногда падать на ровном месте.
  • Долгие транзакции. Тяжёлая работа, сетевые вызовы и ожидания внутри транзакции упираются в лимит 60 секунд.
  • Операции мимо сессии. Чтения/записи внутри транзакции должны идти через ту же session, иначе они не входят в транзакцию.
  • Транзакции на одиночном mongod. Нужен репликасет (хотя бы из одного узла, инициализированный как RS).

Итоги

  • Многодокументная транзакция даёт ACID поверх нескольких документов: всё или ничего.
  • Структура — startTransaction → операции через сессию → commitTransaction или abortTransaction.
  • Реально нужны для раздельных сущностей: переводы, биллинг, запись в две коллекции.
  • Стоят дороже одиночных операций, ограничены ~60 секундами и конфликтуют при контеншне.
  • Ретраи на TransientTransactionError обязательны; удобнее всего обёртка withTransaction.
Проверьте себя
1. В каком из сценариев многодокументная транзакция действительно оправдана?
AУвеличение счётчика лайков у одного поста
BПеревод суммы между двумя независимыми документами-счетами в коллекции accounts
CДобавление товара в массив items внутри одного документа корзины
DОбновление вложенного поля адреса у одного пользователя
2. Почему транзакцию в MongoDB нужно оборачивать в логику ретраев?
AИначе транзакция никогда не зафиксируется
BПотому что в распределённой системе возможны временные ошибки (TransientTransactionError), и транзакцию положено безопасно повторить
CРетраи ускоряют запись на диск
DБез ретраев MongoDB не разрешает вызвать commitTransaction