Все уроки
ДанныеСредний12 мин чтенияПолезно знать

MongoDB

Документная база данных: храните данные так, как их читает ваше приложение.

  • Documents
  • Aggregation
  • Indexes
  • Replica sets
Шаг 1/5
В SQL один заказ разбит на строки в разных таблицах: orders, items, customers. Чтобы его показать, база склеивает их обратно через JOIN.

За 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-вызовы, затем сформируйте документы так, чтобы каждый обслуживался одним чтением.

js
// 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: если первое намного больше, индекс неправильный или отсутствует.

js
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 в начале, чтобы уменьшить объём до дорогих стадий.

js
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. Включите валидацию схемы на уровне коллекции, чтобы кривой деплой не смог записать мусор.

ts
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-хостинге.

Лучшие практики

  1. Проектируйте документы от запросов: сначала перечислите чтения, затем сформируйте документы так, чтобы каждое было одним поиском по индексу.
  2. Следуйте правилу ESR для составных индексов и проверяйте каждый важный запрос через explain("executionStats").
  3. Держите массивы ограниченными; всё, что может расти бесконечно (комментарии, события), должно жить в отдельной коллекции.
  4. Включите валидацию JSON Schema на коллекциях, храните деньги в Decimal128, а даты — как Date в UTC.
  5. Запускайте replica set минимум из трёх участников и оставляйте write concern majority по умолчанию для важных данных.
  6. Включите аутентификацию и TLS, слушайте только приватные интерфейсы и никогда не открывайте порт 27017 в интернет.
  7. Отслеживайте медленные запросы через 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 балансировщик ориентируется на объём данных, а не на число чанков, но перешардирование всё равно дорогое, поэтому выбирайте внимательно.

js
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 развёртывания.

js
// 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" })

Реальные ситуации

01

Каталог товаров с очень разными атрибутами

Проблема

Магазин продаёт одежду, электронику и мебель. У каждой категории свои атрибуты, и реляционная модель превратилась в таблицу entity-attribute-value, которая медленная и неудобная для запросов.

Как решить

Храните каждый товар как один документ: общие поля наверху (sku, name, price, categoryId), а специфичные для категории атрибуты — в массиве attrs из пар { k, v }. Создайте составной индекс { "attrs.k": 1, "attrs.v": 1 } (паттерн attribute), чтобы любой фильтр по атрибуту использовал один индекс, а не по индексу на каждое поле. Добавьте валидатор JSON Schema для общих полей, а правила категорий проверяйте в приложении. Храните цены в Decimal128. Если нужен фасетный полнотекстовый поиск, синхронизируйте товары в поисковый движок через change stream, а не перегружайте MongoDB текстовыми запросами.

02

Горячий шард после запуска

Проблема

Коллекция событий была шардирована по createdAt. После всплеска трафика один шард получает почти все записи, его CPU на 100%, а остальные простаивают.

Как решить

Ключ-timestamp монотонен, поэтому все новые документы попадают в последний чанк на одном шарде. Запланируйте перешардирование на ключ, который распределяет записи и при этом позволяет адресные запросы, например { tenantId: 1, createdAt: 1 }, если запросы идут по тенанту, или hashed-ключ по полю с высокой кардинальностью, если нет. Используйте reshardCollection: он работает онлайн, но копирует данные, поэтому выберите тихое окно, проверьте свободное место на диске (нужно примерно вдвое больше) и следите за прогрессом через currentOp. Тем временем группируйте вставки в пачки и убедитесь, что балансировщик работает, чтобы снизить давление.

03

Заказы пропадают после 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
Возобновляемый поток событий вставки, обновления и удаления в коллекции или базе.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.