← назад к разделу

MongoDB долго имела репутацию «БД, которая теряет данные, но зато быстрая». Это было правдой в 2.x (2012–2014): не было журнала по умолчанию, write concern w:1 без подтверждения, никаких многодокументных транзакций. С тех пор изменилось почти всё. В 3.6 появилась causal consistency, в 4.0 — multi-document транзакции в replica set, в 4.2 — transactions в sharded cluster. На WiredTiger journaling включён по умолчанию.

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

// db.category
{ _id: 1, name: "Сладости" }
{ _id: 2, name: "Мясо" }
{ _id: 3, name: "Молочные продукты" }

// db.product
{ _id: 1, categoryId: 1,    price: 50,  name: "Печенье" }
{ _id: 2, categoryId: 1,    price: 70,  name: "Мармелад" }
{ _id: 3, categoryId: 1,    price: 150, name: "Конфеты" }
{ _id: 4, categoryId: 2,    price: 150, name: "Куриная грудка" }
{ _id: 5, categoryId: null, price: 400, name: "Свинина" }
{ _id: 6, categoryId: null, price: 100, name: "Молоко" }
{ _id: 7, categoryId: null, price: 120, name: "Кефир" }

categoryId: null — те же осиротевшие товары, что и в PostgreSQL: ссылка на категорию есть, но не привязана.

ACID в MongoDB — что и на каком уровне

Атомарность

На уровне одного документа — всегда атомарна. updateOne(), findOneAndUpdate(), replaceOne() либо применяются целиком, либо не применяются. Атомарны и операции над массивом внутри одного документа$push, $pull, $set с dot-notation.

db.product.updateOne(
    { _id: 3 },
    { $set: { price: 180 }, $push: { priceHistory: { ts: new Date(), price: 150 } } }
);
// Либо обновлены и price, и priceHistory — либо ни одно.

На уровне нескольких документов — атомарности нет по умолчанию. Если вы делаете два updateOne(), между ними может произойти что угодно (сбой, чужая запись, чтение). Чтобы получить «либо оба, либо ни одно» — нужны транзакции (см. ниже).

Согласованность (Consistency)

MongoDB не проверяет внешние ключи: запись с categoryId: 99 пройдёт, даже если категории с _id: 99 нет. Это дизайн-решение, а не баг — документная модель предполагает, что согласованность обеспечивается схемой документа (embed) или приложением.

Что MongoDB всё-таки проверяет:

  • JSON Schema validators — если вы повесили validator на коллекцию, документ, не подходящий под схему, отклоняется.
  • Уникальные индексыunique: true гарантирует уникальность значения поля.
  • Тип _id — поле обязательно и уникально в коллекции.
db.runCommand({
    collMod: "product",
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["price", "name"],
            properties: {
                price: { bsonType: "number", minimum: 0 },
                name:  { bsonType: "string", minLength: 1 }
            }
        }
    },
    validationLevel: "strict"
});

Изоляция

Изоляция в MongoDB задаётся парой read concern + write concern, а не одним «уровнем изоляции» как в PostgreSQL. Read concern определяет, какие данные мы готовы прочитать (только зафиксированные? только зафиксированные большинством реплик?). Write concern — когда считать запись успешной (достаточно одной реплики или нужно подтверждение большинства?). К ним детально вернёмся ниже.

Долговечность (Durability)

WiredTiger пишет изменения в journal (примерно как WAL в PostgreSQL) перед применением к страницам. По умолчанию journal сбрасывается на диск каждые 100 мс. Это компромисс: при сбое можно потерять последние ~100 мс работы, но throughput выше.

Чтобы гарантировать durability «не теряем подтверждённую запись» — пишем с j: true:

db.product.insertOne(
    { categoryId: 1, price: 80, name: "Зефир" },
    { writeConcern: { w: "majority", j: true } }
);
// Возвращается успех только после fsync журнала на большинстве реплик.

Write concern — когда считать запись успешной

Write concern — это контракт «при каких условиях драйвер вернёт успех». В replica set он состоит из двух частей:

  • w — сколько реплик должны подтвердить запись;
  • j — нужно ли ждать сброса journal на диск.
Write concernЗначениеКогда брать
{ w: 1 } (default до 5.0)подтверждена primaryЛоги доступа, метрики, недокритичные счётчики
{ w: "majority" } (default с 5.0)подтверждена большинствомЛюбая бизнес-логика — баланс, заказ, профиль
{ w: "majority", j: true }большинством + journal на дискеПлатежи, аудит, где «потерянная транзакция» = ущерб
{ w: 0 }вообще без подтвержденияТолько метрики, где потеря — норма

Главное правило: w: 1 означает, что подтверждённая запись может пропасть при отказе primary и rollback'е. Драйвер вернёт «успех», но фактически записи не будет.

// T1 пишет на primary
db.product.insertOne({ _id: 8, name: "Шоколад" }, { writeConcern: { w: 1 } });
// → драйверу вернулось OK

// Primary падает до репликации на secondary
// При выборе нового primary запись id=8 откатывается
// Клиент думает, что Шоколад в БД. Он там был и пропал.

С w: "majority" такого не бывает: запись подтверждается только после её распространения на большинство реплик, и rollback её уже не достанет.

Read concern — что мы готовы прочитать

Пять уровней. Каждый отвечает на отдельный вопрос «какие данные считать видимыми».

local

Возвращает последнее состояние локальной реплики, включая ещё нерепликированные записи. Самое быстрое чтение, но возможны фантомы при failover'е.

db.product.find({ categoryId: 1 }).readConcern("local");
// Видим всё, что primary знает в этот момент.

available

Похож на local, но в sharded cluster может возвращать orphaned documents (строки, которые «логически» уехали на другой шард после миграции чанков, но физически ещё лежат на источнике). Использовать только если важна скорость и orphan'ы не критичны.

majority

Возвращает данные, подтверждённые большинством реплик. Гарантирует, что прочитанное не пропадёт при failover. Это правильный default для бизнес-логики на чтении.

db.product.find({ categoryId: 1 }).readConcern("majority");

linearizable

Самый строгий. Гарантирует, что чтение увидит результат всех предыдущих успешных majority-записей в реальном времени. Реализовано через посылку пустой записи перед чтением — поэтому медленно. Применяется только при w: "majority" и только на primary.

db.category.findOne({ _id: 1 }, { readConcern: "linearizable" });

Подходит для операций, где нужно «увидеть всё, что произошло раньше во вселенной» — например, проверка лимита перед списанием.

snapshot

Используется только внутри транзакций. Возвращает данные на момент начала транзакции, как REPEATABLE READ снимок в PostgreSQL.

Multi-document транзакции

С MongoDB 4.0 в replica set, с 4.2 в sharded cluster доступны транзакции в стиле «либо вся серия операций применилась, либо ни одна».

Пример: переносим товар «Молоко» (id=6) из «осиротевших» в новую категорию «Молочные продукты» (id=3) и одновременно фиксируем это в журнале изменений:

const session = db.getMongo().startSession();
session.startTransaction({
    readConcern:  { level: "snapshot" },
    writeConcern: { w: "majority", j: true }
});

try {
    const productsColl = session.getDatabase("shop").product;
    const journalColl  = session.getDatabase("shop").categoryChangeLog;

    productsColl.updateOne({ _id: 6 }, { $set: { categoryId: 3 } });
    journalColl.insertOne({
        productId: 6, from: null, to: 3, ts: new Date()
    });

    session.commitTransaction();
} catch (e) {
    session.abortTransaction();
    throw e;
} finally {
    session.endSession();
}

Что важно знать про транзакции:

  • Время жизни — 60 секунд по умолчанию (transactionLifetimeLimitSeconds). Длинные транзакции отменяются.
  • Размер транзакции — до 16 MB oplog'а. Огромные batch-операции должны идти вне транзакции.
  • При конфликтах MongoDB возвращает TransientTransactionError — это сигнал «повтори». Драйверы (включая Spring Data MongoDB) умеют делать автоматический retry, но логику нужно строить идемпотентной.
  • На производительность: транзакция в MongoDB дороже чем в PostgreSQL. Если задачу можно решить через атомарную операцию над одним документом (embed-структура, $inc, $push) — обычно так и стоит делать.

Causal consistency — «прочитай то, что я только что записал»

Типичный сценарий: пользователь обновляет профиль, тут же открывает страницу профиля и видит старые данные. Причина — UPDATE ушёл на primary, SELECT — на secondary, реплика отстаёт.

Causal consistency решает это через session token: драйвер передаёт после каждой записи timestamp, и следующий read ждёт, пока реплика догонит этот timestamp.

const session = db.getMongo().startSession({ causalConsistency: true });

session.getDatabase("shop").product.updateOne(
    { _id: 3 }, { $set: { price: 180 } }
);

// Этот find увидит обновление, даже если идёт на secondary:
session.getDatabase("shop").product.findOne({ _id: 3 });

С 3.6+ causal consistency включается per-session, без глобального performance hit. Если приложение читает данные после собственной записи — это почти всегда нужный режим.

FAQ для Java/Spring команд

Какой write concern по умолчанию у Spring Data MongoDB? Берётся из MongoClientSettings. С драйвером 5.x и MongoDB 5.0+ default — w: "majority". Явно прописать: @Document(collection = "product") + mongoTemplate.setWriteConcern(WriteConcern.MAJORITY.withJournal(true)). Для критичных операций можно переопределить на уровне репозитория через @Query или aggregation.

Можно ли использовать @Transactional в Spring Data MongoDB? Да, с 4.0+. Сконфигурировать MongoTransactionManager, дальше @Transactional будет работать как в JPA — открывать сессию, объединять операции в транзакцию, делать commit/abort. Важно: не оборачивать в одну транзакцию операции к разным базам, и помнить про лимит 60 секунд.

Как обрабатывать TransientTransactionError? Драйвер бросает MongoCommandException с label TransientTransactionError. В Spring это конвертируется в UncategorizedMongoDbException. Через @Retryable(retryFor = UncategorizedMongoDbException.class, maxAttempts = 3, backoff = @Backoff(delay = 100)) — обычное решение. Логика внутри транзакции должна быть идемпотентной по input'у.

Зачем j: true если есть w: "majority"? w: "majority" гарантирует, что запись распространилась по памяти большинства реплик, но journal на диск каждой реплики ещё мог не быть сброшен (100 мс задержка). Если все реплики упадут одновременно (одна стойка, общий выключатель питания) — w: "majority" потеряется, j: true нет. Для платежей и аудита берут обе.

Что лучше для уникальности — unique индекс или транзакция? Уникальный индекс. Дёшево, всегда консистентно, не требует ретраев. Транзакция нужна, когда вы проверяете уникальность по производному условию: «нет другого активного заказа от этого клиента» — индекс с partial filter тут работает, но иногда логика сложнее.

Что почитать дальше

  • MongoDB: Read/Write Concerns — официальная документация.
  • Репликация и шардинг в MongoDB — какие read concern имеют смысл на secondary, как ведёт себя транзакция в sharded cluster.
  • Моделирование документов — почему в MongoDB часто можно обойтись без транзакций через embed.
  • ACID и уровни изоляции в PostgreSQL — сравнение подходов.
  • Distributed Patterns Style Guide — saga, idempotency, outbox: применимо к MongoDB так же, как и к PostgreSQL.