Многодокументные транзакции
Когда одного атомарного документа уже мало: переводим деньги между двумя счетами так, чтобы либо обе записи применились, либо ни одной.
Многодокументная транзакция — группа операций над несколькими документами (и даже коллекциями), которые фиксируются как единое целое: при
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.