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

MongoDB долгое время имела репутацию «БД, которая быстрая, но теряет данные». Это было правдой для ранних версий: журнал не был включён по умолчанию, не было многодокументных транзакций, подтверждения записи по умолчанию не требовалось. С тех пор изменилось почти всё — и сейчас MongoDB даёт полноценные гарантии ACID, если знать, как ими пользоваться.

В этой статье разберём, что именно MongoDB гарантирует сегодня, на каком уровне, и где чаще всего ошибаются.

Атомарность: один документ — всегда атомарен

В реляционных базах «атомарная операция» — это транзакция, которую нужно явно открыть. В MongoDB атомарность встроена на уровне одного документа по умолчанию.

Что это значит: операции updateOne(), findOneAndUpdate(), replaceOne() применяются целиком или никак. Даже если документ сложный и обновляется несколько полей сразу — промежуточного состояния не существует.

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

Операции над массивами внутри документа — $push, $pull, $set — тоже атомарны.

Когда одного документа недостаточно: если нужно атомарно обновить два разных документа (например, перенести товар в другую категорию и записать это в журнал), одной операцией не обойтись. Вот тут нужны транзакции — о них ниже.

Долговечность: как MongoDB не теряет данные

Раньше MongoDB могла терять последние записи при сбое — журнал (аналог WAL в PostgreSQL) не был включён по умолчанию. Сейчас движок WiredTiger пишет изменения в журнал перед применением к страницам данных.

По умолчанию журнал сбрасывается на диск каждые 100 мс. Это компромисс: при сбое можно потерять до 100 мс работы, но скорость записи выше.

Чтобы полностью исключить потерю: используйте j: true в write concern — тогда драйвер получает успех только после того, как данные физически записаны на диск.

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

Это ключевой параметр для надёжности. Write concern задаёт условие, при котором драйвер считает операцию завершённой.

В replica set он состоит из двух частей:

  • w — сколько реплик должны подтвердить запись;
  • j — нужно ли ждать сброса журнала на диск.
НастройкаЧто гарантируетКогда использовать
{ w: 1 }подтверждено основным узломлоги, метрики, некритичные данные
{ w: "majority" }подтверждено большинством репликлюбая бизнес-логика
{ w: "majority", j: true }большинством + журнал на дискеплатежи, аудит
{ w: 0 }без подтвержденияметрики, где потеря допустима

Главная ловушка с w: 1: драйвер получает «успех», но если основной узел упадёт до того, как запись реплицируется, — при выборе нового лидера эта запись откатится. Клиент думает, что данные сохранены. Это не так.

// Запись на основной узел с w:1
db.product.insertOne({ _id: 8, name: "Шоколад" }, { writeConcern: { w: 1 } });
// → получаем "успех"

// Основной узел падает до репликации → при выборе нового лидера запись исчезает

С w: "majority" такой ситуации нет: запись подтверждается только после распространения на большинство реплик.

Настроить write concern на уровне клиента:

MongoClientSettings settings = MongoClientSettings.builder()
    .applyConnectionString(new ConnectionString("mongodb://localhost:27017"))
    .writeConcern(WriteConcern.MAJORITY.withJournal(true))
    .build();

MongoClient client = MongoClients.create(settings);

Read concern — что мы видим при чтении

Read concern отвечает на вопрос: «какие данные считать видимыми для этого чтения?» Это особенно важно в replica set, где чтение может идти с любой реплики.

Уровней пять:

local — последнее известное состояние локальной реплики, включая ещё нерепликированные данные. Самое быстрое чтение, но при сбое и failover могут попасться данные, которые потом откатятся.

available — похож на local, но в шардированном кластере может вернуть «осиротевшие» документы, которые логически уже переехали на другой шард. Использовать только если скорость важнее точности.

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

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

linearizable — самый строгий уровень. Гарантирует, что чтение увидит результат всех предыдущих успешных записей с w: majority. Работает только с основным узлом и медленнее остальных — под капотом перед чтением посылается пустая запись для синхронизации. Нужен в ситуациях «проверить лимит перед списанием».

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

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

С MongoDB 4.0 в replica set и с 4.2 в шардированном кластере доступны транзакции: несколько операций над несколькими документами применяются атомарно — «всё или ничего».

Пример: переносим товар из «без категории» в нужную категорию и одновременно фиксируем это в журнале изменений.

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

try {
    const products = session.getDatabase("shop").product;
    const journal  = session.getDatabase("shop").categoryChangeLog;

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

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

Та же транзакция через Java-драйвер:

TransactionOptions txOpts = TransactionOptions.builder()
    .readConcern(ReadConcern.SNAPSHOT)
    .writeConcern(WriteConcern.MAJORITY.withJournal(true))
    .build();

try (ClientSession session = client.startSession()) {
    session.withTransaction(() -> {
        MongoCollection<Document> products = client
            .getDatabase("shop").getCollection("product");
        MongoCollection<Document> journal = client
            .getDatabase("shop").getCollection("categoryChangeLog");

        products.updateOne(session,
            new Document("_id", 6),
            new Document("$set", new Document("categoryId", 3)));

        journal.insertOne(session, new Document()
            .append("productId", 6)
            .append("from", null)
            .append("to", 3)
            .append("ts", new java.util.Date()));
        return null;
    }, txOpts);
}

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

  • Время жизни по умолчанию — 60 секунд. Длинные транзакции MongoDB отменяет автоматически.
  • Максимальный размер — 16 МБ в журнале операций. Для больших пакетных операций транзакции не подходят.
  • При конфликте MongoDB возвращает ошибку TransientTransactionError — это сигнал повторить операцию. Метод withTransaction() в большинстве драйверов делает повтор автоматически.
  • Транзакции в MongoDB дороже по производительности, чем в PostgreSQL. Если задачу можно решить одной атомарной операцией над одним документом — это предпочтительнее.

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

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

Causal consistency решает это. Драйвер передаёт после каждой записи временну́ю метку, и следующий read ждёт, пока реплика догонит эту метку. Работает через токен сессии, без глобального влияния на производительность.

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

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

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

Через Java-драйвер:

ClientSessionOptions sessionOpts = ClientSessionOptions.builder()
    .causallyConsistent(true)
    .build();

try (ClientSession session = client.startSession(sessionOpts)) {
    var products = client.getDatabase("shop").getCollection("product");

    products.updateOne(session,
        new Document("_id", 3),
        new Document("$set", new Document("price", 180)));

    // Чтение увидит обновление:
    Document updated = products.find(session, new Document("_id", 3)).first();
}

Включается per-session с MongoDB 3.6+. Если приложение читает данные сразу после собственной записи — это нужный режим.

Согласованность и проверка данных

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

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

  • JSON Schema validators — если на коллекцию повешен валидатор, документ с нарушением схемы отклоняется.
  • Уникальные индексы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 всегда атомарен — никакой промежуточный код не увидит частичное обновление.
  • Несколько документов атомарно — только через транзакции (MongoDB 4.0+).
  • Write concern w: "majority" — правильный выбор для бизнес-данных; w: 1 может потерять подтверждённую запись при сбое основного узла.
  • j: true добавляет гарантию физической записи журнала на диск — нужно для платежей и аудита.
  • Read concern majority — данные, которые не исчезнут при failover; linearizable — для критичных проверок.
  • Causal consistency — решает проблему «только что записал, но не вижу» при чтении с реплики.
  • Транзакции в MongoDB дороже, чем в PostgreSQL; если задачу решает одна атомарная операция — так и делайте.
  • Внешние ключи MongoDB не проверяет — целостность ссылок держит либо embed-структура, либо приложение.

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

  • Репликация и шардинг в MongoDB — как работает replica set, какие read concern имеют смысл на вторичных узлах, транзакции в шардированном кластере.
  • Моделирование документов — почему в MongoDB часто можно обойтись без транзакций через вложенные документы.
  • ACID и уровни изоляции в PostgreSQL — сравнение подходов двух баз.