Базы данных MongoDB и документные БД: модель, агрегации, транзакции, подводные камни
0%

MongoDB и документные БД: модель, агрегации, транзакции, подводные камни

MongoDB и документные БД: модель, агрегации, транзакции, подводные камни

MongoDB — самая неправильно понятая база данных индустрии. Её продавали как «БД без схемы, которая масштабируется», покупали как «Postgres, но не надо писать миграции», а получали как «Postgres, но без джойнов, без ограничений целостности и с падениями по OOM». Половина ненависти к MongoDB — это ненависть к решениям, принятым в 2013 году по маркетинговым брошюрам.

При этом документная модель — это не костыль и не деградация реляционной. Это другая точка на кривой «стоимость чтения против стоимости записи и согласованности», и в ряде задач она объективно выигрывает. Разберёмся, где именно, и, что важнее, где нет.

Предполагается, что вы уже прочли обзор NoSQL-ландшафта — таксономию, CAP и BASE повторять не будем.

Документ как единица агрегации

Начнём с первопринципа. Реляционная модель (см. реляционную модель) декомпозирует сущность на плоские отношения, чтобы устранить избыточность. Цена — реконструкция сущности при каждом чтении: чтобы показать заказ, нужно склеить orders, order_items, addresses, payments. Планировщик сделает это хорошо, но это всё равно 4 обхода индекса и 4 группы случайных чтений.

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

Это ровно то же понятие агрегата, что в DDD (см. агрегаты и границы транзакций): граница консистентности, которая обновляется целиком. MongoDB даёт атомарность на уровне одного документа бесплатно и навсегда — не как настройку, а как свойство движка. Если ваша граница транзакции совпала с границей документа, вам вообще не нужны транзакции.

Четыре таблицы выше в MongoDB схлопываются в один документ:

// коллекция orders — весь агрегат «заказ» одним куском
{
  _id: ObjectId("66a3f1c4e8b2a91d4c7f0012"),
  customerId: 42,
  status: "paid",
  total: NumberDecimal("1490.00"),      // НЕ double: деньги только Decimal128
  createdAt: ISODate("2026-07-16T09:12:03Z"),
  items: [
    { sku: "A-11", qty: 2, price: NumberDecimal("300.00") },
    { sku: "B-07", qty: 1, price: NumberDecimal("890.00") }
  ],
  shipTo: { city: "Казань", zip: "420015" },
  payments: [
    { provider: "acquirer-1", amount: NumberDecimal("1490.00"), at: ISODate("2026-07-16T09:12:40Z") }
  ]
}

db.orders.findOne({_id: ...}) — один поиск по индексу, одно чтение страницы, готовый объект приложения без ORM-склейки.

BSON, а не JSON

Документ хранится в BSON — бинарном формате с длинами полей и типами (bsonspec.org). Это важно практически:

  • Есть типы, которых нет в JSON: ObjectId, Date, Decimal128, BinData, Int32/Int64, Regex. Никогда не храните деньги в double0.1 + 0.2 != 0.3 ударит по сверке платежей.
  • Длины полей позволяют пропускать поддокументы без разбора, но имена полей хранятся в каждом документе. Коллекция из 500 млн документов с полем transactionTimestamp тратит ~10 ГБ только на это имя (до сжатия). Snappy/zstd съедают большую часть, но в RAM-кэше WiredTiger документы лежат распакованными.
  • Жёсткий лимит документа — 16 МБ, вложенность — 100 уровней. Это не тюнинг-параметр, это константа протокола.

Первое практическое правило: короткие имена полей в горячих коллекциях (ts вместо eventTimestamp) — это не микрооптимизация, а десятки процентов объёма на телеметрии.

Схема есть всегда. Вопрос — где она записана

«Schemaless» — маркетинговая ложь. Схема есть: она в коде приложения, в головах разработчиков и в предположениях аналитиков. MongoDB лишь переносит её проверку с записи на чтение (schema-on-read). Плюс — миграции не блокируют; минус — через два года в коллекции сосуществуют пять поколений документов, и каждый читатель обязан знать все пять.

Взрослый ответ — явные валидаторы, они есть с версии 3.2 и умеют JSON Schema:

db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["schemaVersion", "customerId", "status", "total", "createdAt"],
      properties: {
        schemaVersion: { bsonType: "int", minimum: 3 },   // паттерн Schema Versioning
        customerId:    { bsonType: "long" },
        status:        { enum: ["created", "paid", "shipped", "cancelled"] },
        total:         { bsonType: "decimal" },
        items: {
          bsonType: "array", maxItems: 200,               // ограничиваем массив ЯВНО
          items: {
            bsonType: "object",
            required: ["sku", "qty"],
            properties: { sku: { bsonType: "string" }, qty: { bsonType: "int", minimum: 1 } }
          }
        }
      }
    }
  },
  validationLevel: "moderate",   // strict — все; moderate — только уже валидные документы
  validationAction: "error"      // error — отклонить; warn — записать в лог и пропустить
})

Промышленный порядок ввода валидатора в живую коллекцию: сначала validationAction: "warn", неделю смотрим лог, чиним источники, потом переключаем на "error". Ставить strict/error сразу на legacy-коллекцию — гарантированный инцидент.

Поле schemaVersion в каждом документе — не бюрократия, а единственный способ мигрировать без даунтайма: приложение умеет читать v2 и v3, писать только v3, а фоновый воркер лениво доводит остаток.

Главное решение проекта: embed или reference

Это решение определяет производительность на годы вперёд и меняется тяжело. Формулируется через три вопроса:

  1. Читаются ли данные вместе? Если 95% запросов к заказу нужны позиции — встраиваем.
  2. Обновляются ли вместе? Если нужна атомарность — встраиваем (иначе платим транзакцией).
  3. Ограничена ли сторона «многие»? Если массив может расти неограниченно — никогда не встраиваем.

Встраивание против ссылок в MongoDB

Третий пункт — самая частая и самая дорогая ошибка. Массив comments внутри posts работает прекрасно до того дня, когда пост попадает в топ и набирает 40 000 комментариев. Дальше происходит всё сразу: документ упирается в 16 МБ; каждый $push заставляет WiredTiger переписать документ целиком; индекс по comments.authorId становится multikey с 40 000 ключей на документ; чтение поста тянет мегабайты в кэш ради заголовка.

Паттерны схем, которые стоит знать наизусть

MongoDB опубликовала каталог паттернов (Building with Patterns); практически полезны шесть:

Паттерн Задача Реализация Цена
Subset Документ пухнет, а нужен хвост Встроить последние 10 комментариев, остальное — в comments Дублирование, надо синхронизировать
Extended Reference $lookup на каждом экране Скопировать authorName, authorAvatar в пост Обновление автора → фоновая раздача
Bucket Телеметрия по одной точке = документ Один документ = час данных, массив из 3600 замеров Сложнее апдейты и запросы
Computed Агрегаты считаются на каждом чтении Хранить totalSpent, orderCount в клиенте Расхождение, нужна сверка
Outlier 1% документов ломает модель (звезда с 10 млн подписчиков) Флаг hasOverflow: true + переполнение в отдельную коллекцию Две ветки кода
Schema Versioning Миграция без даунтайма Поле schemaVersion + ленивый апгрейд Читатели знают N версий

Про Bucket отдельно: с версии 5.0 для временных рядов есть встроенные time series collections, которые делают бакетирование прозрачно, колоночно сжимают и автоматически ставят кластерный индекс по времени. Самодельный Bucket нужен только там, где встроенные не подходят по семантике. Подробнее о специфике временных рядов — в статье про TimescaleDB и InfluxDB.

Что под капотом: WiredTiger

С 3.2 движок по умолчанию — WiredTiger, и почти всё «странное» поведение MongoDB в проде объясняется его устройством.

  • Хранение: B+tree на документ-ориентированных страницах (LSM тоже поддерживается движком, но в MongoDB практически не используется). Про сравнение B-tree и LSM — индексы и планы.
  • Конкурентность: MVCC со snapshot-изоляцией и оптимистичным контролем на уровне документа. Конфликт двух записей в один документ — не блокировка, а WriteConflict с автоматическим ретраем внутри сервера (для обычных операций) или наружу как TransientTransactionError (внутри транзакции).
  • Долговечность: журнал (WAL) сбрасывается на диск каждые 100 мс или при j: true; чекпоинт — раз в 60 секунд. Между чекпоинтами восстановление идёт по журналу.
  • Кэш: wiredTigerCacheSizeGB по умолчанию = max(0.5 * (RAM - 1GB), 256MB). Это не весь бюджет памяти: сверху ложатся файловый кэш ОС (там лежат сжатые страницы), буферы соединений, сортировки и агрегации.
  • Сжатие: коллекции — snappy по умолчанию (быстро, ~2–3x), опционально zstd (медленнее на 10–20% по CPU, но на 20–40% плотнее — почти всегда выгодно на IO-bound нагрузке). Индексы — префиксное сжатие.

Ключевой прод-факт: когда рабочее множество перестаёт помещаться в кэш WiredTiger, деградация нелинейная. Пока eviction успевает вытеснять фоном, всё хорошо; когда доля «грязных» страниц переваливает за пороги (eviction_dirty_target 5%, eviction_dirty_trigger 20%), в вытеснение начинают втягиваться прикладные потоки, и p99 улетает с 5 мс в секунды. Метрики, за которыми нужно следить постоянно: wiredTiger.cache.bytes currently in the cache, tracked dirty bytes, pages read into cache, application threads page read time.

Индексы и планы выполнения

Индексы MongoDB — те же B-tree, что в реляционных БД, с несколькими особенностями.

Тип Что делает Когда брать Подводный камень
Single field Ключ по одному полю Точечный поиск Не покрывает сортировку по другим полям
Compound До 32 полей, порядок значим Основной рабочий инструмент Порядок полей решает всё (правило ESR)
Multikey Автоматически для массивов Поиск по элементам массива Нельзя два массива в одном compound; размер = число элементов
Partial partialFilterExpression Индексируем 2% «активных» Планировщик использует, только если фильтр запроса гарантирует подмножество
TTL Автоудаление по дате Сессии, кэши, логи Фоновый сборщик раз в 60 с, не мгновенно; на secondary не удаляет сам
Unique Ограничение целостности Единственный доступный вам constraint На шардированной коллекции — только с префиксом shard key
Wildcard {"attrs.$**": 1} Пользовательские атрибуты Толстый и медленный на запись; не замена продуманным индексам
Text / Atlas Search Полнотекст Простой поиск Встроенный text слабый; серьёзный поиск — Elasticsearch/Atlas Search
2dsphere Гео Радиус, полигоны Требует GeoJSON, не пары чисел

Правило ESR

Порядок полей в составном индексе строится как Equality → Sort → Range (документация). Для запроса

db.orders.find({ status: "paid", createdAt: { $gte: since } })
         .sort({ total: -1 }).limit(20)

правильный индекс — { status: 1, total: -1, createdAt: 1 }, а вовсе не «в порядке появления в запросе». Почему: равенство сужает диапазон до одной точки; сортировка внутри этой точки уже упорядочена индексом, поэтому стадия SORT исчезает; диапазон идёт последним, потому что он «расфокусирует» дальнейшие ключи. Ошибка в порядке даёт формально работающий индекс — и стадию SORT в памяти, которая при превышении 32 МБ просто убивает запрос ошибкой.

Читаем explain честно

db.orders.find({ status: "paid", createdAt: { $gte: ISODate("2026-07-01") } })
         .sort({ total: -1 }).limit(20)
         .explain("executionStats")

Плохой план (индекс {status: 1, createdAt: 1}):

executionStats:
  nReturned: 20
  totalKeysExamined: 412903        // ← прочитано 412 тыс. ключей
  totalDocsExamined: 412903        // ← и столько же документов подтянуто с диска
  executionTimeMillis: 1841
  stage: LIMIT
    stage: SORT                    // ← сортировка в памяти
      memUsage: 28311552           // ← 27 МБ, на волосок от лимита 32 МБ
      stage: FETCH
        stage: IXSCAN  { status: 1, createdAt: 1 }

Хороший план (индекс {status: 1, total: -1, createdAt: 1}):

executionStats:
  nReturned: 20
  totalKeysExamined: 96            // ← индекс отдал уже отсортированное
  totalDocsExamined: 20
  executionTimeMillis: 2
  stage: LIMIT
    stage: FETCH
      stage: IXSCAN  { status: 1, total: -1, createdAt: 1 }

Три числа, которые нужно смотреть всегда, в этом порядке:

  • totalKeysExamined / nReturned — селективность индекса. Идеал 1.0, тревога > 10.
  • totalDocsExamined — если равен нулю, запрос покрывающий (весь ответ собран из индекса, без обращения к документам): нужен projection без _id и все поля в индексе.
  • наличие стадий SORT и COLLSCAN — почти всегда баг проектирования.

Для агрегаций — db.collection.explain("executionStats").aggregate([...]). Диагностика в живой системе: включить профайлер на медленные операции (db.setProfilingLevel(1, { slowms: 100, sampleRate: 0.1 })), затем читать system.profile; в Atlas — Performance Advisor, который предлагает индексы по реальному трафику.

Aggregation framework

Aggregation pipeline — это функциональный конвейер, где каждая стадия принимает поток документов и отдаёт поток документов. Идея та же, что в реляционных операторах, только вместо декларативного SQL — явный порядок операций.

// Выручка по городам за квартал: топ-10 с долей повторных клиентов
db.orders.aggregate([
  // 1) $match ПЕРВЫМ — единственная стадия, которая умеет использовать индекс
  { $match: {
      status: "paid",
      createdAt: { $gte: ISODate("2026-04-01"), $lt: ISODate("2026-07-01") }
  }},
  // 2) сузили документы до нужных полей — меньше памяти на всех следующих стадиях
  { $project: { customerId: 1, total: 1, "shipTo.city": 1 } },
  { $group: {
      _id: "$shipTo.city",
      revenue:   { $sum: "$total" },
      orders:    { $sum: 1 },
      customers: { $addToSet: "$customerId" }   // осторожно: растёт в памяти
  }},
  { $addFields: {
      uniqueCustomers: { $size: "$customers" },
      avgCheck: { $round: [ { $divide: ["$revenue", "$orders"] }, 2 ] }
  }},
  { $project: { customers: 0 } },               // выкинули тяжёлый массив
  { $sort: { revenue: -1 } },
  { $limit: 10 }
], { allowDiskUse: true, maxTimeMS: 30000, comment: "q3-revenue-by-city" })

Что здесь принципиально:

  • Только $match/$sort в самом начале конвейера используют индекс. После первого $group или $unwind вы работаете с потоком в памяти — индексов больше нет.
  • Оптимизатор умеет переставлять стадии ($match протаскивается вверх сквозь $project, $limit — сквозь $sort превращаясь в top-k), но не творит чудес. Проверяйте фактический план, а не намерения.
  • Лимит памяти на стадию — 100 МБ; allowDiskUse: true разрешает выплеск на диск (медленно, но лучше ошибки). В аналитических конвейерах ставьте всегда.
  • maxTimeMS — обязателен. Без него один кривой конвейер держит курсор и память часами.
  • comment попадает в профайлер и currentOp — бесценно при разборе «кто это тут ест CPU».

$lookup — это не JOIN

$lookup выполняется как вложенный цикл: для каждого документа левой стороны идёт поиск в правой. С индексом на поле связи это O(n · log m), без индекса — O(n · m) и катастрофа. Slot-based engine с 6.0 умеет в отдельных случаях hash join, но полагаться на это без explain нельзя.

Практические правила: $lookup уместен в аналитике и отчётах; на горячем пути API — почти никогда. Если он понадобился на каждом запросе, это сигнал, что схема спроектирована реляционно в нереляционной БД. И отдельно: $lookup, где обе стороны шардированы, — источник многочасовых расследований.

{ $lookup: {
    from: "customers",
    localField: "customerId",
    foreignField: "_id",          // на _id есть индекс — обязательное условие
    as: "customer",
    pipeline: [ { $project: { name: 1, tier: 1 } } ]   // тянем 2 поля, не документ целиком
}}

$facet, $unionWith, $merge

  • $facet — несколько агрегаций по одному входу за один проход (типично: список + счётчик для пагинации + фасеты фильтров). Экономит проходы, но каждая ветка держит свои 100 МБ.
  • $merge (4.2+) — материализация результата конвейера в коллекцию с семантикой upsert. Это готовый механизм инкрементальных материализованных представлений: ночной конвейер обновляет витрину, API читает витрину за миллисекунды. Про подход в целом — инженерия данных.
  • $unionWith (4.4+) — UNION ALL между коллекциями.

Транзакции: есть, работают, стоят денег

До 4.0 транзакция в MongoDB была только на уровне документа. С 4.0 появились многодокументные ACID-транзакции на replica set, с 4.2 — на шардированных кластерах (там это распределённый двухфазный коммит).

const session = client.startSession();
try {
  await session.withTransaction(async () => {
    // списываем со счёта
    const r = await accounts.updateOne(
      { _id: from, balance: { $gte: amount } },   // условие в фильтре — защита от отрицательного баланса
      { $inc: { balance: -amount } },
      { session }
    );
    if (r.modifiedCount !== 1) throw new Error("insufficient funds");

    await accounts.updateOne({ _id: to }, { $inc: { balance: amount } }, { session });
    await ledger.insertOne({ from, to, amount, at: new Date() }, { session });
  }, {
    readConcern:  { level: "snapshot" },
    writeConcern: { w: "majority", wtimeout: 5000 },
    readPreference: "primary"     // читать в транзакции можно только с primary
  });
} finally {
  await session.endSession();
}

withTransaction в драйверах сам ретраит TransientTransactionError и UnknownTransactionCommitResult — писать этот цикл руками не надо, но важно понимать, что тело коллбэка может выполниться несколько раз: никаких побочных эффектов вне сессии внутри него (отправка письма, вызов платёжного API) — это классический источник двойных списаний.

Честная цена транзакций:

Ограничение Значение Что ломается на практике
Время жизни 60 с (transactionLifetimeLimitSeconds) Пакетная обработка «одной транзакцией» падает
Память Снимок держится в кэше WiredTiger Длинные транзакции давят кэш и роняют p99 всей БД
Конфликты Оптимистично, WriteConflict при пересечении Горячий документ (счётчик) → шторм ретраев
Шарды Двухфазный коммит между шардами Задержка кратно выше, отказ участника блокирует диапазон
Изоляция Snapshot (аналог REPEATABLE READ), не serializable Возможен write skew — см. транзакции и изоляцию
Курсоры getMore внутри транзакции ограничен Большие выборки в транзакции неудобны

Правило: транзакции в MongoDB — аварийный выход, а не архитектура. Если вы обнаружили, что 30% операций идут в транзакциях, вы построили реляционную схему поверх документной БД, и вам объективно дешевле взять PostgreSQL.

Согласованность: write concern и read concern

Здесь MongoDB даёт настраиваемую точку на спектре согласованности, и дефолты за годы менялись — проверяйте фактические.

Write concern — сколько узлов подтвердили запись:

Значение Гарантия Задержка Риск
w: 0 Никакой, fire-and-forget ~0 Потеря молча
w: 1 Только primary в памяти +0 Rollback при failover: запись исчезает
w: 1, j: true Primary записал в журнал +0–100 мс Потеря при отказе самого primary
w: "majority" Большинство узлов +RTT до второго узла Дефолт с 5.0, правильный выбор
w: "majority", j: true Большинство + журнал +RTT +fsync Для денег

Read concern — какую версию данных мы видим:

Уровень Что видно Применение
local Всё, что есть на узле, включая незакоммиченное большинством Дефолт, быстро
available Как local, но на шардах без проверки orphan-документов Почти никогда
majority Только зафиксированное большинством — не откатится Финансы, идемпотентность
snapshot Согласованный снимок на момент времени Транзакции, консистентная аналитика
linearizable Строгая линеаризуемость на одном документе Очень дорого, только primary

Классическая продовая ловушка: readPreference: "secondaryPreferred" для «разгрузки primary». Вы получаете stale reads: пользователь сохранил профиль, следующий GET ушёл на отставшую реплику и вернул старые данные. Лечится либо readPreference: primary для read-after-write, либо causal consistency — сессия с causalConsistency: true протаскивает operationTime, и реплика ждёт, пока догонит:

const session = client.startSession({ causalConsistency: true });
await orders.insertOne(doc, { session });
// чтение в той же сессии увидит собственную запись даже с secondary
const back = await orders.findOne({ _id: doc._id }, { session, readPreference: "secondary" });

Репликация и выборы

Replica set — это набор из нечётного числа узлов с одним primary. Реплика применяет oplog — идемпотентную capped-коллекцию local.oplog.rs, в которой операции записаны в форме, безопасной для повторного применения ({$inc: 1} превращается в «установить конкретное значение»). Выборы — Raft-подобный протокол (см. репликацию и шардирование).

Что нужно знать про это на проде:

  • Размер oplog решает всё при восстановлении. Если реплика была недоступна дольше окна oplog, она не сможет догнать и потребует полной пересинхронизации (часы на терабайтах). Смотрите rs.printReplicationInfo() и держите окно ≥ 48–72 ч.
  • Failover — это 5–15 секунд ошибок, если приложение не готово. Retryable writes (включены по умолчанию в современных драйверах) и retryable reads закрывают большую часть, но только для идемпотентно-безопасных операций.
  • w:1 + failover = молчаливая потеря данных. Откатившиеся операции складываются в rollback-файлы на бывшем primary, и никто на них не смотрит. Отсюда легенды «MongoDB теряет данные» — это была потеря по конфигурации, а не по багу.
  • Арбитр (arbiter) — соблазн сэкономить на третьем узле. С ним w: "majority" в кластере из 2 данных + арбитр деградирует до фактического w:2 без отказоустойчивости хранения. Не используйте арбитры, это официальная рекомендация с 5.x.

Шардирование и цена ключа

Горизонтальное масштабирование строится на shard key. Данные делятся на диапазоны (chunks/ranges, по умолчанию порядка 128 МБ), балансировщик двигает их между шардами, mongos маршрутизирует запросы по карте чанков из config servers.

Targeted query против scatter-gather

Три требования к shard key, все обязательные:

  1. Высокая кардинальность — иначе чанк неделим (jumbo chunk) и застревает навсегда.
  2. Равномерное распределение записи — монотонно возрастающий ключ (ObjectId, createdAt) шлёт 100% вставок в последний чанк, то есть в один шард. Лечится hashed-ключом или составным { tenantId: 1, ts: 1 }.
  3. Присутствие в фильтре запросов — иначе каждый запрос становится scatter-gather по всем шардам: задержка = максимум по шардам, отказ любого шарда = ошибка запроса.

Требования 2 и 3 конфликтуют: hashed даёт идеальное распределение записи и убивает диапазонные запросы. Универсального ответа нет — есть выбор под доминирующий паттерн.

sh.shardCollection("app.events", { tenantId: 1, ts: 1 })       // мультитенантность: targeted по тенанту
sh.shardCollection("app.sessions", { _id: "hashed" })          // чистый KV: равномерно, но только по _id

До 5.0 shard key был неизменяем — ошибка означала полную выгрузку и перезалив коллекции. С 5.0 есть reshardCollection (онлайн-решардинг с копированием), с 8.0 он существенно быстрее, но всё равно требует свободного места под вторую копию и часов работы. Проектируйте ключ так, будто изменить его нельзя.

Когда шардировать: не раньше, чем один replica set перестал справляться. Порядок величин, при которых стоит задумываться, — объём данных существенно больше RAM самого большого доступного инстанса, либо пик записи, который не тянет один primary. Шардированный кластер — это минимум 3 shard × 3 узла + 3 config server + слой mongos: другая эксплуатация, другая стоимость, другие классы отказов.

Change streams: БД как источник событий

Change streams (3.6+) — типизированный поток изменений поверх oplog с возможностью возобновления по resumeToken. Это фундамент CDC, кэш-инвалидации и интеграции с очередями и стримингом.

const pipeline = [
  { $match: { "fullDocument.status": "paid", operationType: { $in: ["insert", "update"] } } }
];
const stream = db.collection("orders").watch(pipeline, {
  fullDocument: "updateLookup",          // подтянуть документ целиком, не только дельту
  resumeAfter: savedToken,               // возобновление после падения консьюмера
  maxAwaitTimeMS: 1000
});
for await (const change of stream) {
  await handle(change);                  // обработчик ОБЯЗАН быть идемпотентным
  await saveToken(change._id);           // токен сохраняем ПОСЛЕ обработки: at-least-once
}

Ограничения, о которые спотыкаются: поток живёт, пока resumeToken находится в окне oplog (консьюмер лежал сутки при окне 12 ч — поток невозобновляем); fullDocument: "updateLookup" делает дополнительное чтение и возвращает текущее состояние документа, а не состояние на момент события (для строгого «до/после» нужны pre/post images, включаемые на коллекции с 6.0); гарантия доставки — at-least-once.

Подводные камни, за которые платят продом

  1. Неограниченные массивы. Главная причина инцидентов. Всегда ограничивайте maxItems в валидаторе и применяйте Subset/Outlier.
  2. Рабочее множество больше кэша WiredTiger. Нелинейная деградация, а не плавная. Мониторьте eviction, а не только «использовано RAM».
  3. $lookup на горячем пути. Вложенный цикл, который на глазах превращается в O(n·m).
  4. Отсутствие maxTimeMS. Один запрос без ограничения времени держит курсоры и память; при повторении — деградация кластера.
  5. w: 1 в финансовых операциях. Молчаливая потеря при failover.
  6. Сборка индексов в пике. С 4.2 сборка гибридная и не блокирует коллекцию, но всё равно жжёт IO и CPU. Стройте на secondary по очереди или в окне.
  7. Upsert-гонки. Два параллельных updateOne(..., {upsert: true}) без уникального индекса создают дубликаты. Уникальный индекс обязателен всегда, upsert — не защита сам по себе.
  8. ObjectId как публичный идентификатор. Первые 4 байта — timestamp: вы бесплатно раскрываете время создания и, при переборе, порядок сущностей.
  9. mongodump как бэкап продакшена. Он не даёт point-in-time консистентности между коллекциями и восстанавливается мучительно долго. Настоящий бэкап — снапшоты тома + oplog для PITR или managed-бэкап Atlas.
  10. Отсутствие пула соединений. Каждое соединение — поток и ~1 МБ стека на сервере; тысяча воркеров без пулинга кладёт mongod.
  11. $where и серверный JavaScript. Медленно, небезопасно, не индексируется. Никогда.
  12. Незакрытые курсоры в агрегациях. Держат снимок и мешают чекпоинтам.

Минимальный прод-чеклист: w:"majority" по умолчанию, валидаторы схем на всех коллекциях, maxTimeMS в каждом запросе из приложения, профайлер на slowms=100, окно oplog ≥ 48 ч, TLS + authorization: enabled (незащищённые Mongo в интернете — многолетний источник утечек), алерты на replication lag, cache dirty %, page faults и число соединений, регулярные учения по восстановлению из бэкапа.

Честное сравнение с альтернативами

Критерий MongoDB PostgreSQL + JSONB DynamoDB Couchbase Elasticsearch
Модель Документы, BSON Реляционная + JSONB-колонки KV + документы Документы + KV + SQL++ Документы, инвертированный индекс
Схема Опциональный валидатор Строгая + гибкая в JSONB Только ключ Опциональная Динамический маппинг (и его проблемы)
Транзакции Многодокументные, дорогие Полный ACID, дешёвые Ограниченные, до 100 элементов Многодокументные Нет
Джойны $lookup, вложенный цикл Полноценный планировщик Нет ANSI JOIN в SQL++ Нет (denormalize)
Масштабирование записи Шардирование из коробки Ручное (Citus/партиции) Автоматическое, прозрачное Автоматическое Шарды индекса
Эксплуатация Средняя; managed — простая Простая на одном узле, сложная в HA Нулевая (serverless) Средне-высокая Высокая (маппинги, heap, шарды)
Стоимость Инстансы/Atlas Дёшево Оплата за запросы: дёшево на низком трафике, кусается на высоком Инстансы Дорого по RAM
Лицензия SSPL PostgreSQL (BSD-like) Проприетарная BSL/проприетарная Elastic License / SSPL
Когда НЕ брать Сложные ad-hoc джойны, строгая целостность, финансовый учёт Экстремальный write-throughput с шардированием Нужны гибкие запросы не по ключу Небольшая команда без опыта Как основное хранилище истины

Отдельно про PostgreSQL с JSONB — это главный конкурент и главный вопрос на собеседованиях. Честно:

  • Postgres даёт документы (JSONB + GIN-индексы) и реляционную целостность и дешёвые транзакции и нормальный планировщик. Для 80% проектов, которые «выбирают MongoDB», Postgres — лучший выбор, и это надо признать прямо.
  • MongoDB объективно выигрывает там, где нужно: горизонтальное шардирование записи из коробки (в Postgres это отдельный проект), операторы работы с массивами и вложенными документами ($push, $pull, позиционный $[]) как первоклассные примитивы, ad-hoc эволюция схемы на масштабе, где ALTER TABLE — событие, и managed-платформа с готовым поиском, векторами и шардированием.
  • Замеры «Mongo быстрее/медленнее Postgres» почти всегда меряют не то. На точечном чтении агрегата одним документом Mongo обычно быстрее за счёт отсутствия джойнов; на аналитике с группировками Postgres выигрывает за счёт зрелого планировщика; на записи с w:1 Mongo кажется быстрее, но это сравнение разных гарантий долговечности. Сравнивайте при одинаковых гарантиях, иначе вы меряете конфигурацию, а не движок.

Про лицензию

С октября 2018 MongoDB под SSPL — лицензией, которую OSI не признала открытой. Для подавляющего большинства компаний это не имеет практических последствий: SSPL бьёт только по тем, кто предлагает MongoDB как сервис третьим лицам. Но это блокирует включение в репозитории ряда дистрибутивов и заставляет юристов задавать вопросы. Альтернативы с wire-совместимостью: FerretDB (прокси, транслирующий протокол MongoDB в PostgreSQL, Apache 2.0) и DocumentDB-совместимые предложения облаков — с существенно неполным покрытием возможностей, что нужно проверять по своему набору операций, а не по маркетингу.

Про стоимость

Порядок величин для ориентировки (проверяйте актуальные прайсы):

  • Self-hosted replica set из трёх узлов с 32 ГБ RAM в облаке — заметно дешевле по счёту за железо, но требует человека, который умеет чинить failover в три часа ночи. FTE дороже инстансов почти всегда.
  • MongoDB Atlas: младшие тарифы (dedicated-инстансы малого размера) — десятки долларов в месяц, средний прод-кластер уровня 8 ГБ RAM на узел — сотни долларов в месяц, плюс трафик и бэкапы. Serverless-модель удобна на непредсказуемой нагрузке и внезапно дорога на ровном высоком трафике.
  • Скрытая статья расходов — RAM. Правило планирования: рабочее множество (горячие данные + горячие индексы) должно помещаться в кэш. Как только вы упираетесь, единственный быстрый рычаг — более дорогой инстанс.

Когда документная БД — правильный выбор, а когда нет

Берите MongoDB, если:

  • Данные естественно агрегатны: заказ, профиль, конфигурация устройства, карточка товара с произвольными атрибутами, документ CMS.
  • Схема реально разнородна и эволюционирует (каталоги с тысячами типов атрибутов, интеграции с внешними системами).
  • Нужно шардирование записи и вы готовы вложиться в проектирование shard key.
  • Команда пишет на JS/TS/Python и выигрывает от отсутствия ORM-слоя между объектом и хранилищем.

Не берите, если:

  • Данные сильно связаны и запросы непредсказуемы: аналитика, отчётность, ad-hoc джойны по 5 таблицам.
  • Нужна строгая целостность на уровне БД: внешние ключи, каскады, проверки на нескольких таблицах, serializable.
  • Финансовый учёт и двойная запись — берите реляционку, это её родная задача.
  • Нагрузка аналитическая по большому объёму: там нужен ClickHouse.
  • Основной сценарий — кэш и структуры данных в памяти: там нужен Redis.
  • Причина выбора звучит как «не хочу писать миграции». Через год миграции будут, но в коде приложения и без транзакционного DDL.

Мини-итог

Документная модель — это осознанный обмен: вы платите отсутствием дешёвых джойнов и целостности на уровне БД, а покупаете совпадение единицы хранения с единицей доступа, гибкую схему и встроенное горизонтальное масштабирование. MongoDB реализует этот обмен зрело: WiredTiger с MVCC на уровне документа, настраиваемая согласованность, честные ACID-транзакции (дорогие — и это правильно, что они дорогие), шардирование, change streams.

Три вещи, которые определяют, будет ли ваш проект на MongoDB успешным: решение embed/reference, shard key и дисциплина write concern. Всё остальное правится по ходу; эти три — стоят дороже, чем весь остальной код.

Источники

  • MongoDB Manual — официальная документация, единственный надёжный источник по поведению версий.
  • Building With Patterns: A Summary — каталог схемных паттернов.
  • Equality, Sort, Range Rule — правило порядка полей в составных индексах.
  • WiredTiger Architecture Guide — устройство движка, кэш и eviction.
  • BSON Specification — формат хранения.
  • Transactions in MongoDB — ограничения и семантика ретраев.
  • Causal Consistency and Read/Write Concerns — гарантии сессий.
  • Martin Kleppmann, Designing Data-Intensive Applications, гл. 2 и 5 — сравнение документной и реляционной моделей, репликация (dataintensive.net).
  • Jepsen: MongoDB 4.2.6 — независимый анализ гарантий согласованности и найденные аномалии; показателен как пример того, как читать заявления вендоров.
  • FerretDB — открытая wire-совместимая альтернатива поверх PostgreSQL.

Что дальше

Разобравшись с документным хранилищем, логично перейти к другому полюсу NoSQL — хранилищу структур данных в памяти, где задержки измеряются микросекундами, а долговечность становится настраиваемым компромиссом: Redis: структуры данных, персистентность, кластер, паттерны кэширования.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов