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. Никогда не храните деньги вdouble—0.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
Это решение определяет производительность на годы вперёд и меняется тяжело. Формулируется через три вопроса:
- Читаются ли данные вместе? Если 95% запросов к заказу нужны позиции — встраиваем.
- Обновляются ли вместе? Если нужна атомарность — встраиваем (иначе платим транзакцией).
- Ограничена ли сторона «многие»? Если массив может расти неограниченно — никогда не встраиваем.
Третий пункт — самая частая и самая дорогая ошибка. Массив comments внутри posts работает прекрасно до того дня, когда пост попадает в топ и набирает 40 000 комментариев. Дальше происходит всё сразу: документ упирается в 16 МБ; каждый $push заставляет WiredTiger переписать документ целиком; индекс по comments.authorId становится multikey с 40 000 ключей на документ; чтение поста тянет мегабайты в кэш ради заголовка.
жёстким потолком?"} B -->|"нет, растёт без предела"| R["Ссылки: отдельная коллекция"] B -->|"да, десятки элементов"| C{"Читаются вместе
с родителем?"} C -->|"нет, редко и отдельно"| R C -->|"да, почти всегда"| D{"Дочерние документы
меняются чаще родителя?"} D -->|"да, высокая частота"| E["Ссылки + Extended Reference:
дублируем 2-3 поля для отображения"] D -->|"нет"| F["Встраивание"] R --> G{"Нужен ли список
«последние N» на горячем пути?"} G -->|"да"| H["Subset: встроить последние N,
полный список — в отдельной коллекции"] G -->|"нет"| I["Чистые ссылки + индекс по parentId"]
Паттерны схем, которые стоит знать наизусть
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.
Три требования к shard key, все обязательные:
- Высокая кардинальность — иначе чанк неделим (jumbo chunk) и застревает навсегда.
- Равномерное распределение записи — монотонно возрастающий ключ (
ObjectId,createdAt) шлёт 100% вставок в последний чанк, то есть в один шард. Лечитсяhashed-ключом или составным{ tenantId: 1, ts: 1 }. - Присутствие в фильтре запросов — иначе каждый запрос становится 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.
Подводные камни, за которые платят продом
- Неограниченные массивы. Главная причина инцидентов. Всегда ограничивайте
maxItemsв валидаторе и применяйте Subset/Outlier. - Рабочее множество больше кэша WiredTiger. Нелинейная деградация, а не плавная. Мониторьте eviction, а не только «использовано RAM».
$lookupна горячем пути. Вложенный цикл, который на глазах превращается вO(n·m).- Отсутствие
maxTimeMS. Один запрос без ограничения времени держит курсоры и память; при повторении — деградация кластера. w: 1в финансовых операциях. Молчаливая потеря при failover.- Сборка индексов в пике. С 4.2 сборка гибридная и не блокирует коллекцию, но всё равно жжёт IO и CPU. Стройте на secondary по очереди или в окне.
- Upsert-гонки. Два параллельных
updateOne(..., {upsert: true})без уникального индекса создают дубликаты. Уникальный индекс обязателен всегда, upsert — не защита сам по себе. ObjectIdкак публичный идентификатор. Первые 4 байта — timestamp: вы бесплатно раскрываете время создания и, при переборе, порядок сущностей.mongodumpкак бэкап продакшена. Он не даёт point-in-time консистентности между коллекциями и восстанавливается мучительно долго. Настоящий бэкап — снапшоты тома + oplog для PITR или managed-бэкап Atlas.- Отсутствие пула соединений. Каждое соединение — поток и ~1 МБ стека на сервере; тысяча воркеров без пулинга кладёт mongod.
$whereи серверный JavaScript. Медленно, небезопасно, не индексируется. Никогда.- Незакрытые курсоры в агрегациях. Держат снимок и мешают чекпоинтам.
Минимальный прод-чеклист: 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:1Mongo кажется быстрее, но это сравнение разных гарантий долговечности. Сравнивайте при одинаковых гарантиях, иначе вы меряете конфигурацию, а не движок.
Про лицензию
С октября 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: структуры данных, персистентность, кластер, паттерны кэширования.