MongoDB не «таблицы без схемы» — это документная модель со своей внутренней логикой, и тот, кто проектирует схему в Mongo так же, как проектировал бы таблицы в реляционной БД (нормализация вперёд, join на потом), обычно получает медленные запросы и разросшиеся документы. Здесь схема проектируется от запроса: сначала — какие вопросы задаёт приложение, потом — как документ должен быть устроен, чтобы отвечать на них одним обращением к базе. Ниже — BSON и его типы, embedding против referencing, что происходит с документом при росте, и первая веха сквозной нити серии: где документ MongoDB — честная альтернатива JSONB в PostgreSQL, а где это самообман, подкреплённые числами живого стенда.
Это первая, обзорная статья серии «MongoDB: глубокое погружение». Дальше — WiredTiger как движок хранения (там раскроется, что физически происходит с документом при обновлении, — здесь мы только упрёмся в потолок), а сиблинги-флагманы других моделей данных собраны в хабе карта хранилищ и подходовготовится, с 22 сентября. Здесь — фундамент: как документ отличается от строки, где embedding честно выигрывает, а где превращается в отложенную проблему, и первая веха сквозной нити серии — сравнение с JSONB в PostgreSQL.
Числа ниже — с живого стенда mongodb/modeling: replica set из трёх узлов mongo:8.2.11 и контрастный postgres:18, один детерминированный датасет интернет-магазина (seed=42: 50 000 пользователей, 5000 товаров, 200 000 заказов), загруженный в обе системы. Стенд целиком — в публичном репозитории примеров (digital-cookbook, каталог mongodb/modeling).
В статье
- BSON: документ, а не строка
- Embedding против referencing
- Числа стенда: embed, наивный reference и
$lookup - Денормализация: когда выигрывает, когда стреляет в ногу
- Рост документа и лимит 16 МиБ
- Паттерны: bucket, outlier, computed
- Анти-паттерны: безграничные массивы и распухание
- PG-нить: документ MongoDB против
JSONB
BSON: документ, а не строка
Единица хранения в MongoDB — документ, а физический формат этого документа — BSON (Binary JSON). Это не «JSON-файл на диске»: BSON — бинарная сериализация, спроектированная так, чтобы её было быстро сканировать и обходить, не разбирая текст. Каждое поле в BSON хранит свой тип явно, каждый вложенный документ и массив несёт префикс длины — благодаря этому драйвер может пропустить ненужное поддерево, не читая его целиком, а сервер — добраться до нужного поля, не парся весь документ как строку.
BSON типизирован богаче, чем текстовый JSON. Помимо привычных строк, булевых и вложенных документов есть отдельные числовые типы (int32, int64, double, decimal128 для денег — без потерь на округлении double), Date (миллисекунды от эпохи, а не строка ISO), ObjectId (12-байтовый идентификатор со встроенной временной меткой), Binary, Timestamp для внутренних нужд репликации. Тип — часть значения, а не колонки: два документа одной коллекции могут хранить в одноимённом поле число и строку, и сервер это допустит (что одновременно и гибкость, и источник ошибок, если полагаться на тип бездумно).
Ключевое отличие от строки реляционной таблицы — не «нет схемы», а где живёт структура. В реляционной модели структура задана таблицей: колонки, их типы и связи объявлены в DDL, а строка — плоский кортеж значений под эту декларацию; вложенность выражается внешними ключами и JOIN. В документной модели структура живёт в самом документе: массив позиций заказа лежит внутри документа заказа, а не в отдельной таблице order_items, связанной по ключу. Один документ может содержать то, что в реляционной схеме разложено по трём-четырём таблицам, и прочитаться одним обращением к базе без единого join.
Отсюда вырастает главный сдвиг мышления. Реляционную схему проектируют от данных: нормализуют сущности, убирают дубли, а запросы пишут потом под готовую структуру. Документную схему проектируют от запроса: сначала формулируют, какие вопросы задаёт приложение в проде и как часто, и только потом решают, что положить внутрь документа, а что вынести наружу. Тот же датасет, разложенный «нормализованным рефлексом», и он же, разложенный «под запрос», — это разница между одним чтением и сотнями, и стенд ниже показывает эту разницу в миллисекундах.
Embedding против referencing
Два базовых способа связать сущности в MongoDB — вложить (embedding) или сослаться (referencing).
Embedding — положить связанные данные прямо внутрь документа. Позиции заказа как массив items внутри документа orders; адрес как вложенный поддокумент внутри users. Читается всё одним обращением, обновляется атомарно (запись одного документа атомарна в MongoDB всегда, без транзакции), и не нужен join. Цена — дублирование (название товара, скопированное в каждый заказ, живёт во многих документах) и связанность: чтобы обновить продублированное поле, придётся пройти по всем документам, где оно осело.
Referencing — хранить в документе идентификатор связанной сущности, а саму сущность держать в отдельной коллекции. orders хранит product_id, товары лежат в products, соединяются либо на стороне приложения (по одному запросу за каждый товар), либо на сервере через $lookup (серверный аналог left join в aggregation pipeline). Данные не дублируются, обновляются в одном месте — но за чтение «заказ вместе с товарами» надо платить дополнительными обращениями или join’ом.
Вот те же позиции заказа в обоих подходах. Сначала — embedded-модель со стенда, как её отдаёт коллекция orders:
{
"_id": 200000000042,
"user_id": 12345,
"status": "paid",
"created_at": { "$date": "2026-03-14T10:22:00Z" },
"total": 4820.00,
"items": [
{ "product_id": 812, "product_name": "USB-C кабель 2 м", "qty": 2, "price": 460.00 },
{ "product_id": 1503, "product_name": "SSD 1 ТБ NVMe", "qty": 1, "price": 3900.00 }
]
}Чтение «покажи заказ с его позициями» здесь — один findOne по _id. Название товара продублировано внутрь позиции сознательно: на витрине заказа оно нужно ровно таким, каким было в момент покупки (историческая точность важнее «свежести» — переименование товара в каталоге не должно задним числом менять уже оформленный заказ).
Та же связь через referencing выглядит иначе: заказ хранит только ссылки, товары — отдельно.
// orders_ref
{
"_id": 200000000042,
"user_id": 12345,
"status": "paid",
"total": 4820.00,
"items": [
{ "product_id": 812, "qty": 2 },
{ "product_id": 1503, "qty": 1 }
]
}
// products (отдельная коллекция)
{ "_id": 812, "product_name": "USB-C кабель 2 м", "category": "accessories", "price": 460.00 }
{ "_id": 1503, "product_name": "SSD 1 ТБ NVMe", "category": "storage", "price": 3900.00 }Чтобы собрать здесь тот же «заказ с названиями товаров», нужно либо для каждой позиции сходить в products отдельным findOne (наивный путь), либо соединить сервером через $lookup. Сколько это стоит — измерено ниже.
Числа стенда: embed, наивный reference и $lookup
Стенд берёт случайную выборку 500 заказов ($sample) и собирает «заказ вместе с товарами» тремя путями, считая сетевые обращения (round-trips) и суммарную латентность на всю выборку.
| Путь | round-trips на выборку | латентность на выборку |
|---|---|---|
embedded (orders, позиции денормализованы) |
500 (1 на заказ) | 123.6 ms |
referenced, наивно (orders_ref + findOne в products на каждую позицию) |
2019 (1 + N на заказ, N = 1519 позиций) | 477.3 ms |
referenced, $lookup (1 агрегатный вызов на всю выборку) |
2 (1 aggregate + 1 getMore) |
18.7 ms |
Читать эти числа надо как магнитуды, а не как точное соревнование. Стенд честно оговаривает: $sample случаен от прогона к прогону, а embed/наивный путь и $lookup-путь считались в разных прогонах и по разной единице — embed и наивный reference меряются на заказ (500 и 2019 обращений накопительно по выборке), а $lookup — это один агрегатный вызов на всю выборку сразу, поэтому его «2 round-trip» (первый батч курсора aggregate плюс один getMore на добор остатка — драйвер возвращает первый батч ~101 документ, остальное дочитывает одним getMore) несопоставимы один-к-одному с 500 и 2019. Число round-trips у $lookup здесь измерено реально, через монитор команд драйвера, а не захардкожено.
Но порядок величины виден однозначно и педагогически честен именно против наивного referencing: собирать связку «в коде приложения, по обращению на каждую позицию» — это 2019 обращений и 477 ms там, где embedding обходится 500 обращениями и 123 ms. Это и есть типичный анти-паттерн, который embedding устраняет: N+1 запросов, размазанных по сети.
Важная честность: $lookup тоже сводит referenced-чтение к единицам round-trip (2 против 2019 у наивного пути — тот же порядок, что и у embedded). Он не «медленный по определению» — он перекладывает N соединений на сервер, а не устраняет их. Во что обходится сама серверная работа $lookup на больших объёмах (nested-loop join по документу на строку, даже с индексом) — тема статьи про aggregation в этой серии; здесь достаточно вывода: наивное соединение в приложении — худший из трёх путей, а embedding и $lookup оба сводят обращения к единицам, решая задачу с разных сторон.
Денормализация: когда выигрывает, когда стреляет в ногу
Денормализация в документной модели — не «костыль, потому что нет join», а осознанный проектный приём: данные сознательно дублируют туда, где их читают, платя за это местом и связанностью вместо времени ответа. Вопрос не «нормализовать или нет», а «что читается вместе и что меняется вместе».
Embedding выигрывает, когда:
- связанные данные читаются вместе и почти всегда целиком (заказ — всегда со своими позициями);
- вложенное принадлежит родителю и не имеет самостоятельной жизни вне него (позиция заказа не существует без заказа);
- дубль — это снимок на момент, а не «живая» ссылка (название и цена товара в оформленном заказе — исторические, не должны меняться задним числом);
- число вложенных элементов ограничено и предсказуемо (позиций в заказе — единицы-десятки, не миллионы).
Embedding стреляет в ногу, когда:
- вложенное живёт своей жизнью и читается отдельно от родителя (профиль товара нужен на странице каталога сам по себе — дублировать его в каждый заказ бессмысленно);
- дубль надо держать свежим: если продублированное поле должно меняться синхронно во всех копиях, каждое обновление превращается в проход по всем документам, куда оно затекло;
- массив растёт без границы — и вот здесь embedding не просто неэффективен, а упирается в физический потолок.
Практический ориентир: embed то, что читается и меняется как одно целое; ссылайся на то, что имеет самостоятельную идентичность или разделяется многими. Товар в каталоге — самостоятельная сущность, на него ссылаются; название товара в конкретном заказе — снимок, его встраивают. Одна и та же пара сущностей может требовать обоих решений одновременно, и это нормально.
Рост документа и лимит 16 МиБ
Самый жёсткий предел embedding — рост документа. Стенд берёт документ и пушит в его массив пачки по 100 элементов ($push $each, ~2000 байт полезной нагрузки на пачку), наблюдая, что происходит.
Происходит следующее: 82 пачки прошли успешно (8200 элементов), размер документа монотонно рос с 202 930 до 16 653 130 байт, а на 83-й пачке сервер твёрдо отказал:
// growth_last_size_bytes = 16653130 (после 82 успешных пачек)
// пачка 83 — отказ сервера:
write exception: write errors: [Plan executor error during update ::
caused by :: Resulting document after update is larger than 16777216]
// 16777216 = 16 МиБ — жёсткий лимит размера одного BSON-документа
16 777 216 байт — это 16 МиБ, жёсткий лимит размера одного BSON-документа в MongoDB, в который рост упёрся ровно. Это не настройка и не «мягкий порог» — документ физически не может быть больше.
Здесь важна честная поправка к распространённому мифу. В старом движке MMAPv1 растущий документ, переросший выделенное ему место, физически перемещался на диске (с переаллокацией и padding’ом), и это было ощутимой проблемой производительности. WiredTiger — движок современной MongoDB — так не делает. У него нет фиксированных слотов и padding’а: каждое обновление документа — это read-modify-write всего документа в B-дереве под MVCC, и никакого отдельного «перемещения» снаружи не наблюдается. Поэтому рассуждать про «документ переезжает» на актуальной MongoDB неверно.
Что реально наблюдается при неограниченном росте массива — две вещи:
- Латентность на пачку растёт вместе с размером документа: среднее по первым 10% успешных пачек — 9.0 ms, по последним 10% — 40.2 ms (примерно ×4.4). Рост плавный, не скачком — что как раз согласуется с «весь документ перезаписывается целиком на каждое обновление»: чем документ толще, тем дороже каждый
$push. - Рост монотонно продолжается ровно до 16 МиБ, после чего сервер отказывает записи. Это и есть настоящий, наблюдаемый потолок embedding неограниченно растущих массивов — не деградация «из-за перемещения записи», а плавно дорожающая перезапись плюс твёрдая стена лимита.
Практический вывод: любой массив внутри документа, который растёт с течением времени без естественной границы (лента событий, лог действий пользователя, показания датчика), embedding’ом моделировать нельзя — он гарантированно либо станет неприлично дорогим в обновлении, либо упрётся в 16 МиБ. Что делать вместо этого — следующий раздел. Внутренняя механика того самого read-modify-write в B-дереве, кеш и checkpoint — в статье про WiredTiger.
Паттерны: bucket, outlier, computed
Три паттерна закрывают большую часть случаев, где наивный embedding ломается.
Bucket (ведро). Вместо одного растущего массива — серия документов-«вёдер» фиксированной ёмкости: показания датчика не пушатся бесконечно в один документ, а группируются по часу (или по 200 замеров) в отдельные документы-вёдра. Каждое ведро ограничено по размеру, до 16 МиБ не дорастает, а запросы «данные за интервал» читают несколько компактных вёдер вместо одного гигантского документа. Это прямое лекарство от проблемы предыдущего раздела: границу массиву задаёт сам паттерн.
Outlier (исключение). Массив обычно мал, но у редких документов взрывается: у 99% пользователей десяток подписчиков, у одной знаменитости — миллионы. Держать всех подписчиков embedding’ом ради общего случая — значит подставиться под 16 МиБ на исключениях. Паттерн: обычные случаи embed’ят, а для «выбросов» ставят флаг (has_extras: true) и выносят хвост в отдельную коллекцию, обрабатывая эти документы особым путём. Общий случай остаётся быстрым, редкий — не ломает модель.
Computed (вычисленное). Если приложение постоянно считает одно и то же на лету (сумма заказа, счётчик комментариев, средний рейтинг), значение считают один раз при записи и хранят готовым в документе, вместо того чтобы агрегировать на каждом чтении. Поле total в документе заказа со стенда — ровно этот паттерн: сумма позиций посчитана и сохранена, а не пересчитывается при каждом открытии заказа. Цена — обязанность поддерживать вычисленное поле в согласии с исходными данными; выигрыш — чтение не платит за агрегацию.
Все три — вариации одной идеи: подстроить форму документа под то, как его читают и как он растёт, а не под то, как «правильно» разложить данные нормализацией.
Анти-паттерны: безграничные массивы и распухание
Зеркало паттернов — типовые ошибки «реляционного рефлекса», перенесённого в документную модель.
Безграничный массив (unbounded array). Массив внутри документа, который растёт вместе со временем жизни сущности и не имеет естественного потолка: все события пользователя, все сообщения чата, все показания датчика — в одном документе. Работает на демо, ломается в проде: сначала дорожают обновления (весь документ перезаписывается на каждый $push, ×4.4 по стенду к концу роста), потом упор в 16 МиБ и отказ записи. Лечится bucket’ом или referencing’ом — но не «а давайте потом разберёмся».
Распухание документа под редкий запрос. Встроить в документ всё, что когда-нибудь может понадобиться, — значит таскать этот вес в кеше и по сети на каждом чтении, включая те 95%, которым нужны три поля. Толстый документ вытесняет из кеша больше полезных данных и дольше едет по проводу. Если поле нужно редко и отдельно — ему место в референсе, а не внутри.
Массив-для-поиска вместо документа-на-элемент. Складывать разнородные сущности в один массив ради «одного документа» и потом искать по нему — проигрыш и по индексации (multikey-индекс по большому массиву дороже), и по атомарности обновлений отдельного элемента. Если элементы запрашиваются и меняются поодиночке — это, скорее всего, отдельные документы, а не массив.
Общий диагноз всех трёх: схему спроектировали от «как красиво разложить данные», а не от «как приложение будет их читать и как они будут расти». Документная модель наказывает за это быстрее и жёстче реляционной — граница в 16 МиБ не абстрактна.
PG-нить: документ MongoDB против JSONB
Здесь начинается сквозная нить всей серии — честное сравнение с PostgreSQL. И первый же вопрос: если PostgreSQL умеет JSONB (бинарный документный тип с индексацией по вложенным полям), зачем вообще документная СУБД? Иногда — незачем, и об этом стоит говорить прямо.
Стенд грузит тот же документ заказа в обе системы (в Mongo — коллекция orders, в PG — таблица с колонкой doc jsonb) и меряет две вещи на выборке 500 заказов: средний размер документа и латентность чтения одного поля по ключу.
| Метрика | MongoDB (orders, BSON, $bsonSize) |
PostgreSQL (jsonb, pg_column_size) |
|---|---|---|
| средний размер документа | 380 байт | 575 байт |
латентность чтения поля status по ключу (среднее) |
264.7 µs | 175.6 µs |
Оба результата контринтуитивны, и оба надо подать с оговоркой о методологии, а не как «X победил Y».
Размер (575 > 380). Наивное ожидание «документ есть документ, размер одинаков» — неверно, но и вывод «PG раздутее» тоже неверен: числа мерены разными единицами. $bsonSize — логический размер BSON-представления (без учёта сжатия и служебного оверхеда хранения WiredTiger). pg_column_size — реальный размер сохранённого значения колонки jsonb на диске, который может включать TOAST-оверхед и сжатие PostgreSQL. Это разные линейки: одна меряет логику, другая — физику хранения. Честный вывод — «документ ≠ документ, единицы измерения разные», а не «BSON компактнее jsonb вообще».
Латентность key-read (PG быстрее: 175.6 µs против 264.7 µs). Тоже против «здравого смысла» (документная БД должна быть быстрее на документном доступе) — и тоже не аргумент «Postgres быстрее Mongo». Стенд — docker-compose на одной машине, без сетевой задержки между контейнерами; единичный lookup по первичному ключу в PG — предельно оптимизированный путь, а Mongo findOne идёт через полный wire-протокол и BSON-декодирование в драйвере. Это единичный прогон без прогрева, повторов и перцентилей p95/p99 — честное «не всё так просто», а не общий бенчмарк. (Однохостовая топология размывает сетевые различия в нескольких местах серии — это сквозная оговорка стендов.)
Так где документ MongoDB — честная альтернатива JSONB, а где самообман?
JSONBв PostgreSQL честно хватает, когда документное — лишь часть данных: основная модель реляционная (со строгими схемами, внешними ключами,JOINи транзакциями через несколько таблиц), аjsonb-колонка держит гибкий довесок (атрибуты товара, настройки, метаданные). Тащить ради этого вторую СУБД — оверинжиниринг.- Документная СУБД честно выигрывает, когда документное — это вся модель: приложение думает документами, читает и пишет их целиком, а горизонтальное шардирование и репликация из коробки — часть требований. Тогда встроенность документной модели, aggregation pipeline и шардинг перевешивают удобство одной знакомой PostgreSQL.
Граница проходит по вопросу «документ — это ядро модели или её угол?», а не по «какая БД быстрее на микробенчмарке». Дальше в серии эта нить продолжится на уровне движка (WiredTiger против heap+btree PostgreSQL), индексов, репликации и шардинга. А вопросы изоляции и многодокументных транзакций — отдельная граница: их разбирает серия «Транзакции и изоляция» (что «транзакция» значит в KV и документных БД), здесь мы намеренно их не касаемся.
Весь код и данные этого разбора — в стенде mongodb/modeling: датасет, embedded- и referenced-модели, замеры round-trips, демонстрация роста до 16 МиБ и PG-контраст воспроизводятся одной командой.
Комментарии