MongoDB долгое время имела репутацию «БД, которая быстрая, но теряет данные». Это было правдой для ранних версий: журнал не был включён по умолчанию, не было многодокументных транзакций, подтверждения записи по умолчанию не требовалось. С тех пор изменилось почти всё — и сейчас MongoDB даёт полноценные гарантии ACID, если знать, как ими пользоваться.
С 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 в шардированном кластере доступны транзакции: несколько операций над несколькими документами применяются атомарно — «всё или ничего».
Внизу две правки, которые транзакция вносит на одном снимке, а вверху два её исхода: 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, журнал команд драйвера и идентификатор транзакции в профилировщике; половина транзакций не нужна вовсе.
Что почитать дальше
- Репликация и шардинг в MongoDB — как работает replica set, какие read concern имеют смысл на вторичных узлах, транзакции в шардированном кластере.
- Моделирование документов — почему в MongoDB часто можно обойтись без транзакций через вложенные документы.
- ACID и уровни изоляции в PostgreSQL — сравнение подходов двух баз.
- PostgreSQL или MongoDB — когда гарантии одной базы важнее гибкости другой.