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

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: драйвер получает «успех», но если основной узел упадёт до того, как запись реплицируется, — при выборе нового лидера эта запись откатится. Клиент думает, что данные сохранены. Это не так. С MongoDB 5.0 значение по умолчанию — w: "majority", так что в эту ловушку попадают, когда w: 1 выставлен явно «ради скорости» или унаследован от старого конфига.

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

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

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

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

j := true
wc := writeconcern.Majority()
wc.Journal = &j

client, err := mongo.Connect(ctx, options.Client().
	ApplyURI("mongodb://localhost:27017").
	SetWriteConcern(wc))

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 доступен и для отдельных find, aggregate и distinct вне транзакции, когда нужно прочитать несколько коллекций согласованно, не открывая транзакцию.

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();
}

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

txOpts := options.Transaction().
	SetReadConcern(readconcern.Snapshot()).
	SetWriteConcern(wc) // wc — тот же { w: "majority", j: true }

session, err := client.StartSession()
if err != nil {
	return err
}
defer session.EndSession(ctx)

_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
	products := client.Database("shop").Collection("product")
	journal := client.Database("shop").Collection("categoryChangeLog")

	if _, err := products.UpdateOne(sc,
		bson.M{"_id": 6},
		bson.M{"$set": bson.M{"categoryId": 3}}); err != nil {
		return nil, err
	}
	if _, err := journal.InsertOne(sc, bson.M{
		"productId": 6, "from": nil, "to": 3, "ts": time.Now(),
	}); err != nil {
		return nil, err
	}
	return nil, nil
}, txOpts)

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

  • Время жизни по умолчанию — 60 секунд. Длинные транзакции MongoDB отменяет автоматически.
  • В 4.0 вся транзакция должна была уместиться в одну запись oplog на 16 МБ; с 4.2 этого лимита нет, но большая транзакция держит снимок WiredTiger и давит на память — пакетные операции всё равно режут на порции.
  • При конфликте 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 });

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

sessOpts := options.Session().SetCausalConsistency(true)

session, err := client.StartSession(sessOpts)
if err != nil {
	return err
}
defer session.EndSession(ctx)

err = mongo.WithSession(ctx, session, func(sc mongo.SessionContext) error {
	products := client.Database("shop").Collection("product")

	if _, err := products.UpdateOne(sc,
		bson.M{"_id": 3},
		bson.M{"$set": bson.M{"price": 180}}); err != nil {
		return err
	}

	// Чтение увидит обновление:
	var updated bson.M
	return products.FindOne(sc, bson.M{"_id": 3}).Decode(&updated)
})

Доступна с MongoDB 3.6+, и в Go-драйвере сессии причинно-согласованы по умолчанию — SetCausalConsistency(true) здесь показан для наглядности. Если приложение читает данные сразу после собственной записи — это нужный режим.

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

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

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

  • JSON Schema validators — если на коллекцию повешен валидатор, документ с нарушением схемы отклоняется.
  • Уникальные индексы — unique: true гарантирует уникальность значения поля.
  • Поле _id — обязательно и уникально в коллекции.

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

живой пример

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

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

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

db.runCommand({
    collMod: "product",
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["price", "name"],
            properties: {
                price: { bsonType: "number", minimum: 0 },
                name:  { bsonType: "string", minLength: 1 }
            }
        }
    },
    validationLevel: "strict"
});
Дополнительно: при первом чтении можно пропустить

Глубже: две версии Go-драйверарасширенное

Примеры выше написаны под драйвер v1 (go.mongodb.org/mongo-driver). В 2025 году вышел v2 (go.mongodb.org/mongo-driver/v2), и код на границе меняется: mongo.Connect больше не принимает контекст, колбэк session.WithTransaction получает обычный context.Context вместо mongo.SessionContext, а сессия в контекст кладётся через mongo.NewSessionContext. Смысл не меняется — те же write и read concern, те же WithTransaction с автоповтором TransientTransactionError, — но копировать пример из статьи в проект на v2 без правок не получится.

Ещё две детали, которые ловят в первую неделю. FindOne(...).Decode(&v) на пустом результате возвращает mongo.ErrNoDocuments, и это не сбой, а «нет такого документа» — проверяют через errors.Is. И каждый вызов драйвера принимает ctx: без context.WithTimeout запрос к недоступной реплике будет висеть столько, сколько позволит serverSelectionTimeout (30 секунд по умолчанию).

Коротко

  • Один документ в MongoDB всегда атомарен — никакой промежуточный код не увидит частичное обновление.
  • Несколько документов атомарно — только через транзакции (MongoDB 4.0+).
  • Write concern w: "majority" — правильный выбор для бизнес-данных; w: 1 может потерять подтверждённую запись при сбое основного узла.
  • j: true добавляет гарантию физической записи журнала на диск — нужно для платежей и аудита.
  • Read concern majority — данные, которые не исчезнут при failover; linearizable — для критичных проверок.
  • Causal consistency — решает проблему «только что записал, но не вижу» при чтении с реплики.
  • Транзакции в MongoDB дороже, чем в PostgreSQL; если задачу решает одна атомарная операция — так и делайте.
  • Внешние ключи MongoDB не проверяет — целостность ссылок держит либо embed-структура, либо приложение.
  • С MongoDB 5.0 w: "majority" — значение по умолчанию, а snapshot доступен и вне транзакций; Go-драйвер v2 меняет сигнатуры Connect и колбэков сессии, ErrNoDocuments проверяют через errors.Is.

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