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

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

набор реплик из трёх узлов: что именно ждёт клиент, задаёт write concern 1. w: 1 — успех сразу после основного узла 2. w: majority — успех после большинства узлов приложение insertOne price=180 успех после 1 узла успех после 2 узлов основной узел упал, идут выборы записи нет — откат запись на месте узел 1 основной узел 2 реплика новый основной узел 3 реплика репликация price=180 price=180 price=180

С w: 1 клиент получает успех, когда запись легла только на основной узел. Если узел падает до репликации, новый основной выбирается из тех, у кого этой записи нет, — и подтверждённая запись откатывается. С w: majority ответ приходит только после двух узлов из трёх: любой новый основной обязан быть из этого большинства, поэтому запись переживает сбой. Третий узел догоняет позже — на гарантию это уже не влияет.

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

В реляционных базах «атомарная операция» — это транзакция, которую нужно явно открыть. В 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 — тогда драйвер получает успех только после того, как данные физически записаны на диск. С MongoDB 6.1 журнал вообще нельзя отключить: настройки --nojournal больше нет.

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

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

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

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

С MongoDB 5.0 write concern по умолчанию — majority. То есть w: 1 сегодня получают только там, где его выставили явно, или в наборе с арбитрами, где большинство из узлов с данными не набирается.

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

db.product.insertOne({ _id: 8, name: "Шоколад" }, { writeConcern: { w: 1 } });

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

живой пример

import java.util.ArrayList;
import java.util.List;

public class WriteConcernDemo {

    public static void main(String[] args) {
        System.out.println(failover("w: 1       ", 1));
        System.out.println(failover("w: majority", 2));
    }

    static String failover(String concern, int acksNeeded) {
        List<List<String>> nodes = List.of(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
        int acked = 0;
        for (List<String> node : nodes) {
            if (acked == acksNeeded) {
                break;
            }
            node.add("price=180");
            acked++;
        }
        List<String> newPrimary = nodes.get(1);
        String after = newPrimary.contains("price=180") ? "запись на месте" : "записи нет";
        return concern + " — успех после " + acked + " из " + nodes.size()
                + " узлов; упал основной, новым стал второй: " + after;
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →

Запись живёт ровно там, где успела оказаться к моменту ответа: при 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 — возвращает данные на определённый момент, как снимок. Чаще всего его используют внутри транзакций, но с MongoDB 5.0 он доступен и обычным запросам на чтение — удобно, когда несколько запросов подряд должны видеть согласованную картину.

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

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