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

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 — тогда драйвер получает успех только после того, как данные физически легли в журнал на диске. На чьём именно диске — решает w: с w: 1 это журнал одного узла, с w: "majority" — журнал большинства узлов набора. j: true сам по себе «на всех узлах сразу» не обещает. С 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<>());
        nodes.get(0).add("price=180");
        int replicasCaughtUp = acksNeeded - 1;
        for (int i = 1; i <= replicasCaughtUp; i++) {
            nodes.get(i).add("price=180");
        }
        List<String> newPrimary = nodes.get(1);
        String after = newPrimary.contains("price=180") ? "запись на месте" : "записи нет";
        return concern + " — успех после " + acksNeeded + " из " + nodes.size()
                + " узлов; упал основной, новым стал второй: " + after;
    }
}
Запустить

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

Запись живёт ровно там, где успела оказаться к моменту ответа: при w: 1 это один узел, и после его падения полученный клиентом успех оказывается неправдой. Механизм при этом важно не перепутать: копии основной узел рассылает всем репликам всегда, w управляет не рассылкой, а тем, скольких подтверждений дожидается клиент. При 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);

Сколько стоят гарантии

w: majority ждёт подтверждения от большинства узлов набора. В наборе из трёх это значит «primary плюс одна secondary», то есть задержка равна сетевому обмену с ближайшей репликой — внутри одной зоны доступности доли миллисекунды, между зонами единицы миллисекунд, между регионами десятки. Отсюда практическое следствие: набор, растянутый на регионы, делает каждую запись с этой настройкой дороже в разы, и именно поэтому реплики держат рядом, а не «для географии».

j: true требует, чтобы запись попала в журнал на диск, а не только в память. Это дополнительный сброс на диск, и на нагруженной записи он ощутимо дороже; выгода — переживание внезапного выключения питания, а не отказа узла (от последнего защищает большинство).

Один узел из трёх лежит. Большинство ещё собирается (два из трёх), запись с majority продолжает работать. Упали два — большинства нет, primary понижается до secondary, и запись останавливается до восстановления: набор осознанно выбирает недоступность вместо расхождения. Чтение с primary тоже перестаёт работать, а с secondary продолжает отдавать устаревшие данные.

Отсюда практический вывод про размер набора: три узла — минимум, который переживает отказ одного; пять — переживает два и позволяет обслуживать узлы по одному, не теряя запас. Набор из двух узлов не переживает ничего.

Изоляция транзакций и откуда берутся конфликты

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

Практические следствия три. Транзакцию надо писать так, чтобы её можно было выполнить заново целиком (никаких внешних эффектов внутри). Повтор нужен с ограничением числа попыток и разбросом паузы — драйверы умеют это сами, если использовать их обёртку для транзакций. И горячий документ, который правят все (счётчик, общий остаток), в транзакции лучше не трогать: конфликты станут постоянными, и повторы будут работать вместо дела. Для счётчиков есть атомарные операторы обновления, которые конфликтов не создают.

Что происходит с откатившимися записями

Слово «откатится» скрывает механику, которую стоит знать. Если primary успел принять запись, но не успел передать её большинству, и после этого потерял роль, эта запись в кластере не существует — новый primary о ней не знает. А на бывшем primary она уже применена, поэтому при возвращении в набор он обязан её убрать.

Убранные записи не исчезают бесследно: сервер складывает их в отдельные файлы откатов на диске (или отправляет в специальную коллекцию — зависит от версии и настроек). Никто их автоматически не возвращает: это дампы для ручного разбора. То есть данные, записанные без majority, могут не только «потеряться» — они могут оказаться в файлах, о которых никто не знает.

Практический вывод простой: для всего, что нельзя потерять, w: majority обязателен, а мониторинг стоит настроить на появление файлов откатов — это признак, что записи теряются и что настройки записи выбраны неверно.

Когда транзакция не нужна

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

Одна операция над одним документом уже атомарна — даже если она меняет десять полей и добавляет элементы в массивы. Половина транзакций в коде появляется потому, что автор об этом не знал.

Модель можно собрать в один документ. Заказ вместе с позициями в одном документе не требует транзакции вовсе — это и есть главный аргумент за вложение, разобранный в статье про моделирование.

Достаточно идемпотентности и повторов. Две записи в разные коллекции, которые не обязаны примениться одновременно, но обязаны примениться обе, часто дешевле сделать через outbox и повтор, чем транзакцией: транзакция стоит снимка, конфликтов и ограничения по времени (по умолчанию транзакция дольше минуты прерывается).

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

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

Тут два разных переключателя, и их легко перепутать. readPreference отвечает на вопрос «с какого узла читать»: по умолчанию primary, то есть все чтения идут на основной, и на реплики они попадают только если вы сами это попросили. Read concern отвечает на другой вопрос — «что на выбранном узле считать видимым». Первый выбирает собеседника, второй — насколько свежему ответу верить.

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

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 в шардированном кластере доступны транзакции: несколько операций над несколькими документами применяются атомарно — «всё или ничего».

снимок на старте две правки startTransaction commit обе видны сразу abort не видно ни одной updateOne product insertOne journal

Внизу две правки, которые транзакция вносит на одном снимке, а вверху два её исхода: commit делает обе видимыми разом, abort не оставляет ни одной, и промежуточного состояния снаружи не видит никто.

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

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. Если задачу можно решить одной атомарной операцией над одним документом — это предпочтительнее.
Дополнительно: при первом чтении можно пропустить

Глубже: как проверить, что настройки действительно применилисьрасширенное

Три проверки, которые занимают минуту и снимают половину вопросов.

Действующая настройка кластера. db.adminCommand({ getDefaultRWConcern: 1 }) показывает, какие уровни записи и чтения применяются по умолчанию, — включая случаи, когда администратор кластера задал их на сервере, а не в приложении. С MongoDB 5.0 значение по умолчанию для записи это majority, но в старых кластерах и в управляемых сервисах бывает иначе.

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

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

Глубже: 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 — обязательно и уникально в коллекции. В шардированном кластере — с оговоркой: если _id не входит в ключ шардинга, база держит его уникальность только внутри шарда, и два документа с одним _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"
});

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

живой пример

db.orders.aggregate([
  { $project: { totalAmount: 1, поПозициям: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.quantity", "$$i.unitPrice"] } } } } } },
  { $match: { $expr: { $ne: ["$totalAmount", "$поПозициям"] } } }
])
Запустить

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

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

Коротко

  • Один документ в MongoDB всегда атомарен — частичного обновления не увидит никто; несколько документов атомарно — только через транзакции (4.0+).
  • Write concern w: "majority" — выбор для бизнес-данных: такая запись переживает падение основного узла, а w: 1 может потеряться уже после «успеха».
  • j: true добавляет гарантию физической записи журнала на диск — нужно для платежей и аудита.
  • Read concern majority — данные, которые не исчезнут при failover; linearizable — для проверок вроде «хватает ли остатка перед списанием».
  • Causal consistency решает «только что записал, но не вижу» при чтении с реплики и включается на уровне сессии.
  • Транзакция дороже одной атомарной операции над документом, а внешние ключи MongoDB не проверяет: целостность ссылок держит либо embed-структура, либо приложение.
  • w: majority стоит сетевого обмена с репликой (десятки миллисекунд между регионами), j: true — сброса на диск; при потере большинства запись останавливается осознанно.
  • Транзакция работает на снимке, поэтому чужая правка того же документа прерывает её временной ошибкой: повтор обёрткой драйвера, никаких внешних эффектов внутри и никаких горячих документов.
  • Записи, не доехавшие до большинства, при смене primary уезжают в файлы откатов и автоматически не возвращаются — их появление стоит мониторить.
  • Проверить настройки можно за минуту: getDefaultRWConcern, журнал команд драйвера и идентификатор транзакции в профилировщике; половина транзакций не нужна вовсе.

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