RE:NODE

Транзакции MongoDB на одном узле: replica set и лимиты

Автор Команда RE:NODE
15 мин чтения


MongoDB умеет выполнять ACID-транзакции из нескольких документов, но только на replica set или на шардированном кластере. Обычный standalone mongod отвечает отказом: Transaction numbers are only allowed on a replica set member or mongos. На одной машине выход - replica set из одного участника: это одна строка в конфигурации и одна разовая команда, после чего работают транзакции, change streams и тот же код, который вы позже запустите в продакшене. Второй ответ, часто лучший, - спроектировать данные так, чтобы транзакция вообще не понадобилась: запись в один документ всегда атомарна, а хорошо построенный документ держит в одном месте всё, что должно меняться вместе.

В этой статье: что нужно транзакции, что вместо неё делает standalone-сервер, как превратить одиночный узел в replica set из одного участника, какие лимиты вас заденут (60 секунд жизни по умолчанию, конфликты записи, что нельзя делать внутри транзакции) и какие есть шаблоны для случаев, когда транзакцию использовать нельзя или не хочется. Версии указаны там, где это важно: проверяйте руководство для своей версии сервера, потому что поведение транзакций менялось между 4.0, 4.2, 4.4 и 5.0.

Что нужно транзакции MongoDB#

Транзакция - это группа чтений и записей, которые либо применяются все, либо не применяются вовсе, и другие клиенты никогда не видят их наполовину выполненными. Для одного документа MongoDB давала такую гарантию всегда. Транзакции из нескольких документов появились в версии 4.0 для replica set и в 4.2 для шардированных кластеров. Чтобы транзакция началась, должны выполняться три условия:

  • Replica set или шардированный кластер. Механизм транзакций пишет в oplog, журнал репликации, и использует его временные метки, чтобы дать каждой транзакции согласованный снимок. У standalone-сервера oplog нет.
  • Сессия. Каждая транзакция живёт внутри клиентской сессии. Драйверы скрывают почти всё это, но startSession() вы увидите в каждом примере.
  • Разумное время. Транзакция должна завершиться в пределах лимита жизни, по умолчанию это 60 секунд, а на практике она должна занимать миллисекунды.

Обычная форма - короткая функция, которая меняет связанные данные вместе: списать со счёта и зачислить на другой, уменьшить остаток и добавить заказ, пометить задачу взятой и записать аудит. Если две коллекции должны согласоваться, транзакция - самый прямой инструмент. Цена - дополнительная задержка, дополнительная память под снимок и логика повторов в приложении.

У транзакций есть свой словарь, который стоит знать:

ТерминЗначение
readConcern: "snapshot"Все чтения внутри видят одно согласованное состояние на момент времени
writeConcern: "majority"Коммит ждёт, пока запись надёжно сохранится на большинстве
TransientTransactionErrorВсю транзакцию можно повторить с самого начала

Почему standalone отказывает#

Standalone mongod - это сервер, запущенный без каких-либо настроек репликации. Он хранит данные и ведёт журнал записей, и это всё. Oplog он не ведёт, понятий primary и secondary у него нет, поэтому транзакции из нескольких документов не на чем держать. Попробуйте начать такую, и вы получите вот это:

code
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos

Ошибка приходит от первой операции внутри транзакции, а не от самого startTransaction(), поэтому в стеке приложения она часто всплывает в неожиданном месте. Это код 20, IllegalOperation. То же ограничение действует для change streams: collection.watch() тоже требует oplog и на standalone падает с похожим сообщением.

Стоит прояснить две вещи. Во-первых, записи в один документ на standalone-сервере по-прежнему атомарны. updateOne, меняющий пять полей одного документа, меняет либо все пять, либо ни одного; читатель никогда не увидит документ, в котором изменены три из них. Во-вторых, со standalone ничего не сломано. Это более простой режим сервера, в котором возможности репликации обменяны на то, что меньше нужно поддерживать. Многие рабочие нагрузки в продакшене используют его, обычно вместе с регулярными дампами, как описано в статье про mongodump и mongorestore.

Строки подключения отражают разницу: standalone - это один хост без параметра replicaSet, а replica set называет свой набор. Подробности - в статьях про строки подключения MongoDB и справочнике по формату URI.

Replica set из одного узла#

Replica set с одним участником - полностью допустимая конфигурация. Он сам выбирает себя primary, ведёт oplog, поддерживает транзакции и change streams и ведёт себя как больший набор, который ещё не вырос. Чего он не даёт - избыточности: с одним участником нет ни отказоустойчивости, ни второй копии. w: "majority" на наборе из одного участника означает "этот узел подтвердил запись", то есть та же надёжность, что у standalone с журналированием, и не больше.

Преобразование одинаково для нового сервера и для существующего standalone, а файлы данных остаются как есть. На машине, которой вы управляете, задайте имя replica set в mongod.conf:

mongod.conf
replication:  replSetName: rs0

Или передайте его в командной строке для быстрой локальной проверки:

bash
$ mongod --dbpath ./data --replSet rs0 --bind_ip localhost --port 27017

Перезапустите сервер, подключитесь через mongosh и один раз инициируйте набор:

javascript
rs.initiate({  _id: "rs0",  members: [{ _id: 0, host: "localhost:27017" }]})

В host укажите имя, которое ваши клиенты могут разрешить и до которого могут достучаться: драйвер находит участников по именам, сохранённым в конфигурации набора, а не по адресу, который вы набрали. Набор, инициированный с localhost, работает для приложения на той же машине и не работает для всех остальных. Через несколько секунд rs.status() должен показать участника как PRIMARY:

javascript
rs.status().members.map(m => ({ name: m.name, state: m.stateStr }))

Нужно учесть несколько последствий:

  • Аутентификация. Replica set с включённым контролем доступа требует внутреннего механизма аутентификации между участниками, обычно файла ключа, указанного в security.keyFile. Даже с одним участником следуйте шагам из руководства по аутентификации replica set, а не угадывайте.
  • Диск. Oplog - это capped-коллекция, и она занимает реальное место. Размер по умолчанию вычисляется из свободного места на диске, поэтому проверьте его через db.getReplicationInfo() и задайте явно, если диск небольшой.
  • Строки подключения. Добавьте replicaSet=rs0, чтобы драйвер говорил по протоколу replica set, или directConnection=true, чтобы пропустить обнаружение и обращаться к одному указанному хосту.
  • Перезапуски. После перезапуска набор поднимается как primary без дополнительных команд. Инициировать нужно один раз.
startTransactionwritescommit entryПриложениесессия драйвераmongod rs0единственный PRIMARYOplogжурнал репликацииКоллекцииWiredTiger
Что затрагивает транзакция в наборе из одного участника

Доступно ли это в хостинге баз данных RE:NODE?#

Хостинг баз данных RE:NODE предлагает MongoDB: учётные данные генерируются для каждого сервера, подключение идёт напрямую на хост и порт, указанные в тарифе. На странице тарифа мы не утверждаем, что сервер работает как replica set, и вам не стоит этого предполагать. MongoDB-сервер, который вы арендуете по тарифу, даёт вам подключение, а не mongod.conf для правки, поэтому вопрос "работают ли транзакции" - это свойство сервера, а не вашего кода.

Узнать можно так: спросить, а затем проверить.

  1. Откройте тикет из панели и спросите, работает ли ваш сервер MongoDB как replica set и поддерживаются ли транзакции из нескольких документов. Не полагайтесь на эту статью как на ответ.
  2. Выполните короткую проверку из следующего раздела на своём подключении. Standalone падает с описанной выше ошибкой на первой же записи; replica set выполняет коммит.
  3. Если ответ отрицательный, используйте шаблоны с атомарностью одного документа и двухфазным коммитом из этой статьи - они работают на любой MongoDB. Если нужны настоящие транзакции, запустите собственный mongod как replica set из одного участника на машине, которой вы управляете, например на VDS, и раздел выше относится к вам напрямую.

В разделе о базах данных в руководствах объяснено, как в панели работают учётные данные баз данных. Загляните туда, прежде чем отлаживать подключение, которое на самом деле является проблемой аутентификации; типичные ловушки разобраны в статье про authentication failed и authSource.

Как написать транзакцию#

Рекомендуемая форма - callback API драйвера. Он начинает транзакцию, выполняет вашу функцию, делает коммит и при временной ошибке повторяет всё заново, так что вашему коду этого делать не нужно. Этот пример на Node.js переводит деньги между двумя счетами и пишет строку в журнал - три записи, которые должны согласоваться:

javascript
const { MongoClient } = require("mongodb");const client = new MongoClient(process.env.MONGODB_URI);await client.connect();const db = client.db("bank");async function transfer(from, to, amount) {  const session = client.startSession();  try {    await session.withTransaction(async () => {      const debit = await db.collection("accounts").updateOne(        { _id: from, balance: { $gte: amount } },        { $inc: { balance: -amount } },        { session }      );      if (debit.modifiedCount !== 1) throw new Error("insufficient funds");      await db.collection("accounts").updateOne(        { _id: to }, { $inc: { balance: amount } }, { session });      await db.collection("ledger").insertOne(        { from, to, amount, at: new Date() }, { session });    }, {      readConcern: { level: "snapshot" },      writeConcern: { w: "majority" }    });  } finally {    await session.endSession();  }}

Важны три привычки. Передавайте { session } в каждую операцию внутри, потому что операция без него выполняется вне транзакции и фиксируется сама по себе. Не допускайте в callback побочных эффектов вроде отправки писем или вызовов API, потому что он может выполниться больше одного раза. И выбрасывайте исключение, чтобы отменить: исключение из callback отменяет транзакцию, а withTransaction пробрасывает его дальше.

Быстрая проверка из mongosh, которая заодно служит тестом вашего сервера:

javascript
const session = db.getMongo().startSession();const coll = session.getDatabase("test").getCollection("t");session.startTransaction();coll.insertOne({ probe: 1 });session.commitTransaction();session.endSession();

На standalone insertOne выбросит ошибку Transaction numbers are only allowed. На replica set запись зафиксируется, и пробный документ потом можно удалить.

Лимиты, на которые вы наткнётесь#

Транзакции задуманы короткими и небольшими. Ниже лимиты, которые важны на практике; на сервере, которым вы управляете, многие из них настраиваются.

ЛимитПо умолчанию
Время жизни транзакции60 секунд
Ожидание блокировки при конфликте5 миллисекунд
Размер одного документа16 MB

Первый - transactionLifetimeLimitSeconds, по умолчанию 60. Фоновый процесс отменяет любую транзакцию, которая работает дольше, и следующая операция в ней падает с NoSuchTransaction. Второй - maxTransactionLockRequestTimeoutMillis: сколько транзакция ждёт взятия блокировки, прежде чем сдаться. На сервере, которым вы не управляете, менять ни то ни другое нельзя, так что проектируйте с оглядкой на них.

Что ещё нужно знать:

  • Конфликты записи падают сразу. Если две транзакции меняют один и тот же документ, вторая немедленно получает ошибку WriteConflict, а не ждёт. У ошибки есть метка TransientTransactionError, означающая "повторите всю транзакцию". Callback API делает это за вас; с основным API метку нужно ловить самостоятельно.
  • Результат коммита может быть неизвестен. Если соединение оборвалось во время коммита, драйвер выбросит UnknownTransactionCommitResult, и коммит мог как состояться, так и нет. Повторить коммит безопасно; повторять всю транзакцию небезопасно, если ваши записи не идемпотентны.
  • Размер. В 4.0 вся транзакция должна была уместиться в одну запись oplog размером 16 MB. Начиная с 4.2 транзакция может занимать несколько записей oplog, так что этот единый предел больше не действует, но 16 MB на каждый документ остаются, а большие транзакции по-прежнему стоят памяти и времени блокировок. Если вы приближаетесь к прежнему пределу, вы используете транзакцию как пакетное задание.
  • Коллекции. До 4.4 коллекции и индексы должны были существовать до начала транзакции. Начиная с 4.4 их можно создавать внутри неё. Транзакции не могут обращаться к базам admin, config и local или писать в capped-коллекции.
  • Read concern. Чтения внутри используют снимок транзакции. Они не видят записей, зафиксированных другими после её начала, - в этом и смысл, и это же причина не держать транзакцию открытой, пока вы ждёте пользователя.
  • Память. Снимок хранит в кэше старые версии изменённых данных, пока жива транзакция. Долгая транзакция не только медленная, она вытесняет из памяти другую работу.

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

Атомарность без транзакции#

Удивительно много задач не требует транзакции, потому что запись в один документ атомарна. MongoDB применяет все изменения в одном updateOne или findOneAndUpdate как единое целое, включая изменения во вложенных документах и массивах внутри него. Если вы можете устроить так, чтобы данные, которые должны меняться вместе, жили в одном документе, вы получаете атомарность на любом сервере, включая standalone, без логики повторов и без снимка.

Инструменты - операторы обновления и фильтр, кодирующий предусловие:

javascript
// Reserve one seat: the filter is the check, the update is the actiondb.flights.findOneAndUpdate(  { _id: "LH100", seatsLeft: { $gt: 0 } },  { $inc: { seatsLeft: -1 }, $push: { passengers: { name: "Ada", at: new Date() } } },  { returnDocument: "after" })

Если подходящего документа нет, ничего не меняется, и вызов возвращает null, так что продать лишнее невозможно. Проверку и запись не может разделить другой клиент. Тот же шаблон работает для счётчиков остатков, ограничения частоты запросов, захвата задач ({ status: "queued" } превращается в { status: "running", worker: id }) и оптимистичной блокировки с полем version, которое фильтр сравнивает, а обновление увеличивает.

Надёжность держится на двух правилах. Ставьте условие в фильтр, а не в код приложения, который сначала читает, а потом пишет, потому что именно в этом промежутке и возникает гонка. И используйте операторы обновления вроде $inc и $set, а не заменяйте весь документ устаревшей копией.

Встраивание и проектирование схемы#

Следующий вопрос - где должны жить данные, и от него зависит, как часто вам нужна атомарность между документами. В реляционной модели заказ, его строки и платёж - это три таблицы и транзакция. В MongoDB их часто можно сделать одним документом:

json
{  "_id": "order-1042",  "customer": "c-77",  "status": "paid",  "lines": [    { "sku": "A-100", "qty": 2, "price": 1800 },    { "sku": "B-220", "qty": 1, "price": 950 }  ],  "payment": { "method": "card", "paidAt": "2026-10-22T09:14:00Z" },  "total": 4550}

Такой документ записывается и читается одной операцией, а его изменения атомарны. Встраивание подходит, когда части читаются вместе, принадлежат одному родителю и ограничены по размеру. Оно подходит плохо, когда встроенный массив растёт без предела (комментарии к популярному посту), когда дочерний элемент общий для многих родителей или когда документ приблизится к лимиту в 16 MB. Тогда вы ссылаетесь по _id и соглашаетесь на две коллекции.

Есть и промежуточный путь: держать источник истины в одном документе и копировать несколько полей, нужных другим запросам. Копия может разойтись с оригиналом, поэтому решите, какое поле главное, и обновляйте копии от него. Компромиссы вместе с индексами, которые их поддерживают, разобраны в статье про индексы и схему данных MongoDB, а вопрос о том, подходит ли вам документное хранилище вообще, - в статье Postgres или MongoDB. Если ваши данные реляционные и вам нужны транзакции как режим по умолчанию, а не особый случай, SQL-сторона описана в статье про транзакции, блокировки и взаимоблокировки в MySQL.

Двухфазные шаблоны для нескольких документов#

Когда данные действительно лежат в разных документах, а транзакций нет, устоявшийся подход - двухфазный коммит, который вы строите сами. Руководство MongoDB описывает его на примере перевода денег. Вы добавляете коллекцию transactions, где записано намерение и его состояние, а каждый затронутый документ помнит, какая незавершённая транзакция его касалась.

  1. Вставьте запись транзакции с state: "initial", источником, получателем и суммой.
  2. Переведите запись в pending, затем примените изменение к каждому счёту, добавляя _id транзакции в массив pendingTransactions этого счёта. Фильтр гарантирует, что каждое применение произойдёт один раз.
  3. Переведите запись в applied.
  4. Удалите _id транзакции из массивов pendingTransactions обоих счетов.
  5. Переведите запись в done.
javascript
// Step 2, source account: only if not already applieddb.accounts.updateOne(  { _id: "A", pendingTransactions: { $ne: tid }, balance: { $gte: 100 } },  { $inc: { balance: -100 }, $push: { pendingTransactions: tid } })

Если процесс умирает в любой момент, задание восстановления просматривает коллекцию transactions в поисках записей, застрявших в pending или applied, и либо доводит их до конца, либо откатывает по тем же состояниям. Шаблон идемпотентен по построению: проверка $ne: tid означает, что повторный запуск шага ничего не меняет. Именно это свойство вы и строите, и оно полезно даже тогда, когда настоящие транзакции есть, потому что повторный запрос или упавший воркер нуждаются в такой же защите.

Кода и состояний здесь заметно больше, чем в транзакции. Используйте шаблон, когда транзакцию получить нельзя или когда операция пересекает системы, которые не охватить транзакцией базы данных, например платёжного провайдера. Для всего, что лежит на одном replica set, callback API короче, и в нём труднее ошибиться.

Как выбирать#

Короткий путь решения покрывает большинство случаев:

  • Всё, что должно меняться вместе, умещается в одном документе: используйте атомарные обновления одного документа. Replica set не нужен.
  • Два-три документа, короткая операция, сервер под вашим управлением: replica set из одного участника и withTransaction.
  • Включить replica set нельзя (арендованный сервер, где поддержка сказала "нет"): встраивайте, где можно, используйте двухфазный шаблон, где нельзя, и делайте каждую запись идемпотентной.
  • Работа - долгая пакетная или затрагивает тысячи документов: это не транзакция. Используйте bulk-записи частями, фиксируйте прогресс и делайте задание перезапускаемым.

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

FAQ#

Можно ли использовать транзакции на standalone MongoDB?

Нет. Транзакциям из нескольких документов нужен replica set или шардированный кластер. Standalone-сервер отклоняет их с ошибкой Transaction numbers are only allowed on a replica set member or mongos. Обновления одного документа на standalone атомарны, и поверх них можно построить двухфазный шаблон.

Делает ли replica set из одного узла мои данные безопаснее?

Нет. Он включает транзакции и change streams, но это по-прежнему одна машина с одной копией данных, то есть ни отказоустойчивости, ни избыточности. Защищают вас резервные копии.

Почему моя транзакция упала примерно через минуту?

По умолчанию transactionLifetimeLimitSeconds равен 60. Транзакция, всё ещё открытая после этого, отменяется, и следующая операция падает с NoSuchTransaction. Сократите транзакцию, вынеся медленную работу, например сетевые вызовы, за её пределы.

Есть ли ограничение на размер транзакции?

Каждый документ по-прежнему ограничен 16 MB. В 4.0 вся транзакция ещё и должна была умещаться в одну запись oplog на 16 MB; начиная с 4.2 она может занимать несколько. Тем не менее большие транзакции медленные и занимают память, поэтому разбивайте массовую работу на части.

Нужны ли транзакции для счётчика или уменьшения остатка?

Обычно нет. Один updateOne или findOneAndUpdate с фильтром вроде { stock: { $gte: 1 } } и $inc атомарен и не позволит продать лишнее. К транзакции стоит обращаться, только когда должны согласоваться несколько документов.

Поддерживает ли мой сервер MongoDB на RE:NODE транзакции?

На странице тарифа не сказано, что сервер работает как replica set, поэтому не предполагайте этого. Откройте тикет из панели и спросите, а затем выполните описанную выше проверку через mongosh на своём подключении.

Это было полезно?