За 30 секунд
MongoDB хранит данные в виде JSON-подобных документов (BSON), сгруппированных в коллекции, а не строк в таблицах. Один документ может содержать вложенные объекты и массивы, поэтому одно чтение часто возвращает всё, что нужно экрану. Масштабируется MongoDB через replica set для доступности и шардирование для объёма. Актуальная линейка — MongoDB 8.x, в своей инфраструктуре или как облачный сервис Atlas.
Не в моём ежедневном стеке — я понимаю принципы и изучу детали, когда это понадобится проекту.
Почему это важно
- Это важно, когда данные по своей природе похожи на документы: товар с переменными атрибутами, профиль пользователя с вложенными настройками, дерево контентных блоков. Если загнать это в множество таблиц, появятся JOIN-ы, но пользы не прибавится.
- Команды выбирают её, когда схема часто меняется на ранних этапах продукта. Поля можно добавлять без блокирующей миграции, а валидация схемы всё равно держит данные в порядке.
- Replica set из коробки даёт автоматический failover, а шардирование распределяет очень большие коллекции по машинам — такое сложно прикрутить задним числом к одному реляционному серверу.
- Это хороший контраст к реляционным базам вроде MySQL или PostgreSQL: зная обе модели, вы выбираете по паттерну доступа, а не по привычке. Сильные связи и отчёты по многим таблицам по-прежнему за SQL.
Как это работает
01Идея: документы вместо строк
Представьте реляционную базу как набор таблиц, которые вы соединяете между собой, а MongoDB — как картотеку, где в каждой папке лежит всё об одной сущности. Документ — это BSON-объект (бинарный JSON с дополнительными типами: даты, десятичные числа, ObjectId). Документы живут в коллекциях, коллекции — в базе данных. У каждого документа есть уникальный _id. Документы одной коллекции не обязаны иметь одинаковые поля, но на практике должны, и это обеспечивается валидатором JSON Schema. Максимальный размер одного документа — 16 MB, и это подсказка: документы предназначены для ограниченных данных, а не для бесконечных списков.
- BSON: бинарный формат, в котором MongoDB хранит данные; поддерживает типы, которых нет в JSON, например Date и Decimal128.
- Коллекция: группа документов, примерно как таблица.
- ObjectId: 12-байтный _id по умолчанию, примерно упорядоченный по времени создания.
02Моделирование: встраивать или ссылаться
Главный вопрос проектирования — класть ли связанные данные внутрь родительского документа (embed) или в отдельную коллекцию с id-ссылкой на них (reference). Встраивайте, когда дочерние данные принадлежат родителю, читаются вместе с ним и имеют ограниченный размер: заказ и его позиции, пользователь и его адреса. Используйте ссылку, когда данные общие, растут без ограничений или обновляются независимо: комментарии к популярному посту, товары, на которые ссылаются многие заказы. Правило из официальных рекомендаций MongoDB: «данные, к которым обращаются вместе, нужно хранить вместе». Проектируйте от запросов: сначала перечислите экраны и API-вызовы, затем сформируйте документы так, чтобы каждый обслуживался одним чтением.
// One order document: line items embedded, customer referenced
{
_id: ObjectId("665f1c2a9b1e8a0012345678"),
customerId: ObjectId("665f1b009b1e8a0011112222"),
status: "paid",
total: NumberDecimal("59.90"),
items: [
{ sku: "TSHIRT-M", qty: 2, price: NumberDecimal("19.95") },
{ sku: "CAP-01", qty: 1, price: NumberDecimal("20.00") }
],
createdAt: ISODate("2026-11-05T10:00:00Z")
}
03Запросы, индексы и правило ESR
Запросы пишутся документами-фильтрами ({ status: "paid" }) с операторами вроде $gt, $in и $elemMatch. Без индекса MongoDB сканирует каждый документ (COLLSCAN). Индексы — это B-деревья, как в SQL-базах, а составные индексы следуют правилу ESR: сначала поля, по которым идёт сравнение на равенство (Equality), затем поля сортировки (Sort), затем поля с фильтром по диапазону (Range). Индекс { customerId: 1, createdAt: -1 } идеально обслуживает «последние заказы этого клиента». Всегда проверяйте запрос через explain("executionStats") и сравнивайте totalDocsExamined с nReturned: если первое намного больше, индекс неправильный или отсутствует.
db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1 })
db.orders
.find({ customerId: id, status: "paid", createdAt: { $gte: since } })
.sort({ createdAt: -1 })
.limit(20)
.explain("executionStats")
04Aggregation pipeline
Агрегация — ответ MongoDB на GROUP BY и отчёты. Pipeline — это массив стадий: каждая преобразует поток документов и передаёт дальше, как на конвейере. $match фильтрует (ставьте его первым, чтобы он мог использовать индекс), $group считает итоги, $sort сортирует, $project меняет форму, $lookup соединяет с другой коллекцией, а $unwind превращает массив в отдельный документ на каждый элемент. Pipeline мощные, но могут быть тяжёлыми: $group по миллионам документов расходует память, а стадии, превышающие 100 MB, сбрасывают данные на диск. Держите $match и $project в начале, чтобы уменьшить объём до дорогих стадий.
db.orders.aggregate([
{ $match: { status: "paid", createdAt: { $gte: ISODate("2026-10-01") } } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.sku",
units: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
} },
{ $sort: { revenue: -1 } },
{ $limit: 10 }
])
05Replica set, write concern и транзакции
Replica set — это группа серверов mongod с одинаковыми данными: один primary принимает записи, secondary копируют его журнал операций (oplog). Если primary падает, оставшиеся участники за секунды выбирают нового. Write concern определяет, сколько участников должны подтвердить запись; начиная с MongoDB 5.0 по умолчанию это «majority», то есть подтверждённая запись переживёт failover. Read concern и read preference управляют тем, что и откуда вы читаете. Многодокументные ACID-транзакции есть (в replica set с 4.0, в шардированных кластерах с 4.2), но они стоят производительности; при хорошей модели документов они нужны редко, потому что запись в один документ и так атомарна.
- В продакшене используйте минимум трёх участников с данными, распределённых по зонам.
- Подключайтесь полной строкой подключения replica set, чтобы драйверы находили нового primary при failover.
- Чтение с secondary может вернуть немного устаревшие данные; используйте его только там, где это допустимо.
06Использование из приложения
Официальные драйверы есть для Node.js, PHP, Python, Go, Java и других языков. В Node.js многие команды добавляют Mongoose для схем и валидации, но нативный драйвер тоже хорош и легче. Создавайте один клиент на процесс и переиспользуйте его: клиент держит пул соединений. Валидируйте входные данные на границе, храните деньги в Decimal128 (никогда не во float) и сохраняйте даты как настоящие значения Date в UTC. Включите валидацию схемы на уровне коллекции, чтобы кривой деплой не смог записать мусор.
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI!); // reuse per process
const orders = client.db("shop").collection("orders");
export async function latestOrders(customerId: ObjectId) {
return orders
.find({ customerId }, { projection: { items: 0 } })
.sort({ createdAt: -1 })
.limit(20)
.toArray();
}
Когда применять
- Данные с естественной документной формой и переменными атрибутами: каталоги, контент CMS, профили пользователей, показания IoT.
- API с преобладанием чтения, где один документ обслуживает один экран без JOIN-ов.
- Нагрузки, которые перерастут одну машину и выиграют от встроенного шардирования и автоматического failover.
- Быстро развивающиеся продукты, где схема меняется каждую неделю, — вместе с валидацией схемы для безопасности.
Когда не стоит
- Сильно реляционные данные со связями «многие ко многим» и ad-hoc отчётами по сущностям; реляционная база вроде PostgreSQL или MySQL подходит лучше.
- Финансовые журналы или процессы, которым почти на каждую запись нужны транзакции по нескольким строкам.
- Когда команде нужна небольшая база для приложения и она уже хорошо знает SQL; переход добавит затраты на обучение без явной выгоды.
Альтернативы
- PostgreSQL (с JSONB)
Реляционное ядро плюс индексируемые JSON-колонки; часто лучшее из двух миров для смешанных нагрузок.
- MySQL
Зрелая реляционная база с типом JSON; сильна для классических транзакционных приложений.
- DynamoDB
Полностью управляемое key-value и документное хранилище в AWS; отлично масштабируется, но требует строгого проектирования под паттерны доступа.
- Couchbase / Firestore
Другие документные хранилища со своими компромиссами в синхронизации, мобильных сценариях и managed-хостинге.
Лучшие практики
- Проектируйте документы от запросов: сначала перечислите чтения, затем сформируйте документы так, чтобы каждое было одним поиском по индексу.
- Следуйте правилу ESR для составных индексов и проверяйте каждый важный запрос через explain("executionStats").
- Держите массивы ограниченными; всё, что может расти бесконечно (комментарии, события), должно жить в отдельной коллекции.
- Включите валидацию JSON Schema на коллекциях, храните деньги в Decimal128, а даты — как Date в UTC.
- Запускайте replica set минимум из трёх участников и оставляйте write concern majority по умолчанию для важных данных.
- Включите аутентификацию и TLS, слушайте только приватные интерфейсы и никогда не открывайте порт 27017 в интернет.
- Отслеживайте медленные запросы через profiler или Atlas Query Insights и регулярно пересматривайте индексы.
Частые ошибки
- Понимать «schemaless» как «без схемы»: несогласованные имена и типы полей в итоге ломают запросы и отчёты.
- Моделировать как в SQL — множество мелких коллекций и $lookup повсюду, теряя главное преимущество документов.
- Неограниченные массивы, которые раздувают документы до лимита 16 MB, и каждое обновление переписывает огромный документ.
- Отсутствующие индексы: запросы скатываются в полное сканирование коллекции, которое нормально выглядит в разработке и рушится в продакшене.
- Открытые в интернет инстансы без аутентификации — причина множества реальных утечек данных.
Глубже
07WiredTiger: как на самом деле устроено хранение
WiredTiger — движок хранения MongoDB. Он использует управление конкурентностью на уровне документа, так что два писателя блокируют друг друга, только если трогают один и тот же документ. Данные и индексы — это B-деревья, сжатые на диске (по умолчанию snappy, опционально zstd). WiredTiger держит кэш в RAM, по умолчанию примерно 50% от (RAM минус 1 GB); производительность зависит от того, помещается ли туда рабочий набор (часто используемые документы плюс индексы). Записи сначала попадают в журнал и периодически сбрасываются в файлы данных через checkpoint, поэтому после сбоя восстановление идёт от последнего checkpoint плюс журнал. Когда рабочий набор не помещается, вы видите давление на вытеснение из кэша, а чтения с диска начинают доминировать в задержке — это момент добавить RAM, урезать индексы или шардировать.
- Следите за db.serverStatus().wiredTiger.cache: dirty и evicted байты.
- Для быстрых запросов индексы должны помещаться в RAM; проверяйте размеры через db.collection.stats().
08Шардирование и выбор shard key
Шардирование делит коллекцию между несколькими replica set. Роутер (mongos) отправляет каждый запрос на те шарды, которые владеют нужными диапазонами shard key. Shard key решает всё: у него должна быть высокая кардинальность, он должен равномерно распределять записи и присутствовать в большинстве запросов. Монотонно растущий ключ вроде timestamp или ObjectId отправляет все вставки на один шард (горячий шард); hashed-шардирование исправляет распределение, но запросы по диапазону разлетаются по всем шардам. Составной ключ вроде { tenantId: 1, createdAt: 1 } часто хорошо работает для multi-tenant приложений. С 5.0 коллекцию можно перешардировать онлайн, а с 6.0 балансировщик ориентируется на объём данных, а не на число чанков, но перешардирование всё равно дорогое, поэтому выбирайте внимательно.
sh.enableSharding("shop")
sh.shardCollection("shop.events", { tenantId: 1, createdAt: 1 })
// Queries that include tenantId are targeted to one shard
db.events.find({ tenantId: "t_42", createdAt: { $gte: since } })
09Регуляторы согласованности: write concern, read concern, causal sessions
MongoDB позволяет выбирать согласованность для каждой операции. Write concern w: "majority" ждёт, пока запись окажется у большинства участников, поэтому она переживёт failover; w: 1 быстрее, но запись может откатиться, если primary упадёт до репликации. Read concern "majority" возвращает только данные, подтверждённые большинством, "linearizable" даёт самую сильную гарантию для чтения одного документа ценой большей стоимости, а "snapshot" используется внутри транзакций. Causally consistent sessions гарантируют, что клиент читает собственные записи даже при чтении с secondary. Ошибка — смешивать настройки не думая: запись с w: 1 и чтение с secondary могут показать пользователям данные, которые потом исчезнут.
10Change streams, TTL и time series
Change streams позволяют приложению подписаться на вставки, обновления и удаления в коллекции, базе или кластере; они построены на oplog. Их можно возобновить по resume token, что делает их надёжным источником для синхронизации поискового индекса или кэша либо для публикации событий (часто вместе с паттерном outbox). TTL-индексы автоматически удаляют документы через заданное время — удобно для сессий и временных токенов. Коллекции time series эффективно хранят измерения в бакетах с автоматическим сжатием, что подходит для метрик и данных IoT. Atlas также добавляет Atlas Search (полнотекстовый поиск на базе Lucene) и Vector Search, а последние релизы Community и Enterprise начали приносить поиск и векторный поиск в self-managed развёртывания.
// Expire sessions 24 hours after createdAt
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 })
// React to new paid orders
const stream = db.orders.watch([{ $match: { "fullDocument.status": "paid" } }],
{ fullDocument: "updateLookup" })
Реальные ситуации
Каталог товаров с очень разными атрибутами
Проблема
Магазин продаёт одежду, электронику и мебель. У каждой категории свои атрибуты, и реляционная модель превратилась в таблицу entity-attribute-value, которая медленная и неудобная для запросов.
Как решить
Храните каждый товар как один документ: общие поля наверху (sku, name, price, categoryId), а специфичные для категории атрибуты — в массиве attrs из пар { k, v }. Создайте составной индекс { "attrs.k": 1, "attrs.v": 1 } (паттерн attribute), чтобы любой фильтр по атрибуту использовал один индекс, а не по индексу на каждое поле. Добавьте валидатор JSON Schema для общих полей, а правила категорий проверяйте в приложении. Храните цены в Decimal128. Если нужен фасетный полнотекстовый поиск, синхронизируйте товары в поисковый движок через change stream, а не перегружайте MongoDB текстовыми запросами.
Горячий шард после запуска
Проблема
Коллекция событий была шардирована по createdAt. После всплеска трафика один шард получает почти все записи, его CPU на 100%, а остальные простаивают.
Как решить
Ключ-timestamp монотонен, поэтому все новые документы попадают в последний чанк на одном шарде. Запланируйте перешардирование на ключ, который распределяет записи и при этом позволяет адресные запросы, например { tenantId: 1, createdAt: 1 }, если запросы идут по тенанту, или hashed-ключ по полю с высокой кардинальностью, если нет. Используйте reshardCollection: он работает онлайн, но копирует данные, поэтому выберите тихое окно, проверьте свободное место на диске (нужно примерно вдвое больше) и следите за прогрессом через currentOp. Тем временем группируйте вставки в пачки и убедитесь, что балансировщик работает, чтобы снизить давление.
Заказы пропадают после failover
Проблема
После падения primary несколько заказов, которые клиенты видели подтверждёнными, исчезли. Приложение пишет с w: 1 ради скорости.
Как решить
С w: 1 primary подтверждает запись до репликации; если он падает, новый primary этих записей не видел, а старый откатывает их при возвращении в набор. Переведите важные записи на w: "majority" (по умолчанию с 5.0) и добавьте разумный wtimeout. Потерянные записи восстановите из rollback-файлов на старом primary. Убедитесь, что приложение подключается через URI replica set и с включёнными retryable writes (по умолчанию в современных драйверах), чтобы запись, бывшая в полёте во время failover, безопасно повторилась один раз. Примите небольшую цену в задержке: правильные заказы стоят дороже миллисекунд.
Шпаргалка
- Подключиться через mongosh
mongosh "mongodb://your_user@localhost:27017/shop?authSource=admin"- Создать составной индекс
db.orders.createIndex({ customerId: 1, createdAt: -1 })- Explain для запроса
db.orders.find({ status: "paid" }).explain("executionStats")- Список индексов и их использование
db.orders.aggregate([{ $indexStats: {} }])- TTL-индекс
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })- Статус replica set
rs.status()- Включить profiler для медленных операций
db.setProfilingLevel(1, { slowms: 100 })- Бэкап базы данных
mongodump --uri="$MONGODB_URI" --db=shop --out=/backups/2026-11-05- Добавить валидацию схемы
db.runCommand({ collMod: "orders", validator: { $jsonSchema: { required: ["status"] } } })
Вопросы и ответы
Q1Когда выбрать MongoDB, а не PostgreSQL?
Я бы выбрал MongoDB, когда данные по природе иерархические, их форма отличается от записи к записи, а большинство чтений получают один агрегат за раз — например, каталог товаров с очень разными атрибутами по категориям или дерево контента. Она также помогает, если ожидается горизонтальное шардирование. Я бы остался на PostgreSQL, когда данные реляционные, когда отчётам нужны гибкие JOIN-ы или когда строгие транзакции между сущностями — центральная часть системы. JSONB в PostgreSQL покрывает многие полуструктурированные случаи, так что честный ответ такой: выбирайте MongoDB под документные паттерны доступа, а не просто чтобы не писать миграции.
Q2Как вы решаете, встраивать данные или ссылаться на них?
Я смотрю на три вещи: как данные читаются, насколько они могут вырасти и как часто меняются независимо. Если дочерние данные всегда читаются с родителем, ограничены по размеру и принадлежат только этому родителю, я их встраиваю, как позиции заказа. Если они общие для нескольких родителей, растут без ограничений или обновляются отдельно, я использую ссылку, как для комментариев или товаров. Есть и гибридные паттерны: встроить краткую сводку (название, цена) и ссылаться на полную запись, принимая некоторое дублирование. Главный принцип: данные, к которым обращаются вместе, нужно хранить вместе.
Q3Поддерживает ли MongoDB транзакции?
Да. Запись в один документ всегда атомарна, включая вложенные поля и массивы, и при хорошей модели это покрывает большинство случаев. Многодокументные ACID-транзакции доступны в replica set с версии 4.0 и в шардированных кластерах с 4.2. Они работают со snapshot isolation и фиксируются с write concern. Цена — дополнительная задержка, блокировки на время транзакции и лимит в 60 секунд по умолчанию. Если транзакции нужны почти на каждую запись, это сигнал, что данные стоит смоделировать иначе или что реляционная база подойдёт лучше.
Q4Что такое replica set и зачем нужны три участника?
Replica set — это группа серверов MongoDB с одинаковыми данными. Один primary принимает записи, а secondary реплицируют его oplog. Если primary падает, остальные проводят выборы и повышают одного из secondary, обычно за секунды. Для выборов нужно большинство голосующих участников, поэтому при двух участниках потеря одного оставляет набор без большинства, и он становится доступен только для чтения. Три участника могут потерять одного и всё равно выбрать primary. Третьим голосом может быть arbiter, но он не хранит данных и ослабляет гарантии majority-записи, поэтому три участника с данными — более надёжный вариант по умолчанию.
Q5Как найти и исправить медленный запрос?
Сначала я его нахожу: profiler базы или лог медленных запросов, либо Atlas Query Insights показывают запросы выше порога. Затем запускаю запрос с explain("executionStats") и смотрю на выигравший план. Стадия COLLSCAN или большой разрыв между totalDocsExamined и nReturned означает отсутствующий или неправильно упорядоченный индекс. Я проектирую составной индекс по правилу equality, sort, range, проверяю, что сортировка покрыта индексом и нет стадии SORT в памяти, и добавляю projection, чтобы возвращать меньше данных. В конце удаляю неиспользуемые индексы, потому что каждый индекс замедляет запись.
Q6MongoDB действительно schemaless?
На практике нет. База не навязывает фиксированную схему, поэтому документы в одной коллекции могут отличаться, и это полезно во время изменений. Но у вашего приложения схема есть всегда, записана она или нет. Хорошие команды делают её явной: валидатор JSON Schema на коллекции, типизированные модели в коде (например, Mongoose или типы TypeScript) и скрипты миграции, когда поле меняет смысл. Гибкость — в том, как выкатываются изменения: можно добавить поле и постепенно заполнить его, вместо блокирующего ALTER TABLE на большой таблице.
Вопросы уровня senior
S1Как бы вы спроектировали multi-tenant SaaS на MongoDB?
Есть три модели. База на тенанта даёт сильную изоляцию и простой бэкап по тенанту, но тысячи баз нагружают сервер множеством файлов и соединений. Коллекция на тенанта имеет похожие ограничения. Общая коллекция с полем tenantId в каждом документе масштабируется лучше всего: каждый индекс начинается с tenantId, каждый запрос обязан его содержать (это обеспечивается слоем доступа к данным, а не договорённостью), а при шардировании tenantId идёт первым в shard key, чтобы большинство запросов попадали на один шард. Крупных тенантов можно вынести в выделенные зоны через zone sharding. Компромисс — изоляция: одна ошибка в фильтре может привести к утечке данных, поэтому я бы добавил тесты и слой репозитория, который отклоняет запросы без тенанта.
S2Каковы компромиссы многодокументных транзакций в шардированном кластере?
Распределённые транзакции используют двухфазный коммит между шардами, который координирует один из шардов. Они дают snapshot isolation и атомарность, но каждый участвующий шард держит блокировки и хранит старые версии в кэше до коммита, поэтому длинные транзакции увеличивают давление на кэш и задержку для всех. Кроме того, они прерываются при конфликтах записи и должны повторяться приложением; драйверы помогают хелпером withTransaction. Время жизни по умолчанию — 60 секунд. Мой подход: проектировать так, чтобы инварианты жили внутри одного документа, использовать транзакции только для редких междокументных случаев вроде денежных переводов, держать их короткими и маленькими и добиваться, чтобы транзакция затрагивала как можно меньше шардов, выбирая shard key с учётом этого пути.
S3Как держать MongoDB и поисковый индекс или кэш согласованными?
Двойная запись из приложения (записать в MongoDB, затем в Elasticsearch) частично падает и со временем расходится. Лучше change data capture: воркер читает change stream, надёжно сохраняет resume token после каждой обработанной пачки и идемпотентно обновляет индекс по _id документа. Если воркер перезапускается, он продолжает с токена; если он отстаёт слишком сильно и окно oplog проходит, нужна полная переиндексация, поэтому размер oplog рассчитывайте на худший простой и ставьте алерт на отставание. Для публикации доменных событий в другие сервисы хорошо работает паттерн outbox: записывайте событие в коллекцию outbox в той же транзакции, что и изменение, и пересылайте его оттуда.
Глоссарий
- Документ
- Одна запись, хранящаяся в BSON — бинарной форме JSON с дополнительными типами, размером до 16 MB.
- Коллекция
- Группа документов в базе данных, примерно сравнимая с таблицей.
- Replica set
- Несколько серверов с одинаковыми данными; один primary принимает записи, а secondary берут его роль при сбое.
- Oplog
- Журнал операций на primary, который secondary воспроизводят, чтобы оставаться синхронными.
- Write concern
- Сколько участников replica set должны подтвердить запись, чтобы она считалась успешной.
- Shard key
- Поле или поля, по которым данные коллекции делятся между шардами.
- Aggregation pipeline
- Последовательность стадий, которые фильтруют, группируют и преобразуют документы, как GROUP BY и JOIN в SQL.
- Правило ESR
- Порядок полей составного индекса: Equality, затем Sort, затем Range — для самых эффективных запросов.
- Change stream
- Возобновляемый поток событий вставки, обновления и удаления в коллекции или базе.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.