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 — сравнение подходов двух баз.