В реляционной базе данных структура диктуется нормализацией: каждый факт — ровно в одном месте, связи — через внешние ключи. В MongoDB такого правила нет. Для одних и тех же данных существует несколько рабочих моделей, и выбор между ними определяет скорость чтения, сложность обновлений и масштабируемость.
Главный вопрос при проектировании — вложить связанные данные в документ (embed) или хранить отдельно и ссылаться (reference). Вокруг этого выбора строится всё остальное.
Слева имя категории продублировано в каждом товаре: переименование переписывает три документа, а если товаров тысяча — тысячу. Справа имя лежит в одном документе, товары его не касаются, зато за именем нужен второй запрос или $lookup. Это и есть обмен: embed платит за запись, reference — за чтение.
Embed: вложить всё в один документ
Представьте товар и его категорию. Самый простой способ — хранить категорию прямо внутри товара:
{
_id: 3,
name: "Конфеты",
price: 150,
category: {
_id: 1,
name: "Сладости"
}
}
Плюс очевидный: один запрос возвращает всё. MongoDB гарантирует атомарность записи одного документа, поэтому транзакции не нужны.
Так устроены заказы в учебной песочнице: позиции лежат внутри самого заказа массивом items. Запустите и посмотрите, как выглядит вложение в живом документе:
живой пример
db.orders.find({ _id: "ord-01" })
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Минус тоже очевиден: название «Сладости» хранится в каждом товаре этой категории. Если категорию переименовали, надо обновить тысячи документов. И ещё одно ограничение — документ в MongoDB не может быть больше 16 МБ, поэтому вкладывать массивы, которые могут расти неограниченно, нельзя.
Reference: хранить отдельно и ссылаться по id
Второй подход — хранить категорию отдельно, а в товаре оставить только её идентификатор:
// товар
{ _id: 3, name: "Конфеты", price: 150, categoryId: 1 }
// отдельная коллекция с категориями
{ _id: 1, name: "Сладости" }
Никакого дублирования: переименование категории — одна операция. Документы компактные, их может быть миллионы.
Но за товар с категорией теперь нужно два запроса, либо агрегация с $lookup — это аналог JOIN в MongoDB. В шардированном кластере, если товары и категории живут на разных узлах, $lookup идёт через сеть.
В песочнице по ссылке связаны заказ и покупатель: в заказе лежит только customerId. Вот тот самый $lookup, который собирает их вместе, — поменяйте ord-01 на любой другой заказ:
живой пример
db.orders.aggregate([
{ $match: { _id: "ord-01" } },
{ $lookup: { from: "customer", localField: "customerId", foreignField: "_id", as: "покупатель" } },
{ $project: { status: 1, totalAmount: 1, "покупатель.firstName": 1 } }
])
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Массив в поле покупатель — не случайность: $lookup всегда возвращает список совпадений, даже когда совпадение одно. Отсюда и $unwind в реальных конвейерах.
Как выбрать: шесть правил
Универсального ответа нет, но критерии чёткие:
- Данные почти всегда читаются вместе → embed. Если 80% запросов «получи товар с категорией» — вложить категорию в товар разумнее.
- Данные меняются с разной частотой → reference или частичная денормализация. Категория меняется редко, товар — часто: можно хранить
categoryIdкак ссылку, аcategoryNameскопировать в товар для быстрого отображения. - Связь один-к-немногим (товар → 2–5 фотографий) → embed массив.
- Связь один-ко-многим (товар → 50–200 отзывов) → reference, можно добавить счётчик
reviewCountпрямо в товаре. - Связь один-к-миллионам (товар → история изменений цены) → только reference. Вложить миллион записей в документ нельзя.
- Нужен доступ с обеих сторон (товар знает категорию, категория показывает список товаров) → reference с индексом по
categoryIdв коллекции товаров.
Антипаттерн: массив без ограничений
Распространённая ошибка — хранить список дочерних объектов в родительском документе без контроля роста:
// плохо
{
_id: 1,
name: "Сладости",
products: [/* 50 000 объектов */]
}
Такой документ упрётся в лимит 16 МБ. Ещё до этого — каждое чтение категории загружает весь массив, каждое добавление товара переписывает весь документ. И чем больше документ, тем дороже обходится каждая такая перезапись: изменили одно поле — движок всё равно записывает документ целиком, а сжимает и разжимает он данные не по документу, а страницами, так что в работу попадают и соседи по странице.
Правило: любой массив, который может вырасти больше 100–200 элементов или суммарно превысить 100 КБ, лучше вынести в отдельную коллекцию через reference.
Bucket pattern: массив с контролируемым размером
Сначала оговорка: если у вас именно временной ряд, руками его группировать уже не надо. С MongoDB 5.0 есть коллекции временных рядов (timeseries: { timeField: ... }) — база сама раскладывает точки по корзинам и сжимает их, а вы пишете и читаете как обычно. Приём ниже нужен тогда, когда такая коллекция не подходит: скажем, точки надо часто менять или ряд не единственное, что лежит в документе.
Сам приём — bucket pattern: массив с контролируемым размером. Записи группируют по N штук в один документ, при заполнении заводят новый:
{
productId: 3,
bucketStart: ISODate("2026-01-01"),
count: 100,
prices: [
{ ts: ISODate("2026-01-01T10:00:00Z"), price: 150 },
{ ts: ISODate("2026-01-02T10:00:00Z"), price: 148 }
// ... ещё 98 записей
]
}
Документ предсказуемого размера, последние N значений — один запрос, без взрывного роста.
Сверху массив, куда дописывают без конца и который каждое обновление переписывает целиком, пока он не упрётся в предел 16 МБ, снизу тот же поток по ведрам фиксированного размера: документ остаётся предсказуемым, а при заполнении заводится следующий.
Денормализация: скопировать для скорости
Иногда оба подхода комбинируют. Хранят ссылку и одновременно копируют нужные поля в документ, чтобы не делать $lookup при каждом чтении:
// товар: ссылка + денормализованное имя категории
{
_id: 3,
name: "Конфеты",
price: 150,
categoryId: 1,
categoryName: "Сладости" // скопировано для быстрого отображения
}
// категория: единственный источник истины
{ _id: 1, name: "Сладости", productCount: 3 }
При переименовании категории фоновый процесс обходит все товары с categoryId = 1 и обновляет categoryName. Если категории переименовываются раз в месяц — это нормально. Если ежедневно — денормализация не даст выигрыша.
JSON Schema: структура не только в голове
MongoDB работает без схемы по умолчанию, но это не значит, что схемы не должно быть. На коллекции можно задать JSON Schema валидатор — MongoDB будет отклонять документы, не соответствующие правилам:
db.createCollection("product", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "price"],
properties: {
_id: { bsonType: "int" },
name: { bsonType: "string", minLength: 1, maxLength: 200 },
price: { bsonType: "number", minimum: 0 },
categoryId: { bsonType: ["int", "long", "null"] }
},
additionalProperties: false
}
},
validationLevel: "strict",
validationAction: "error"
});
Два уровня строгости:
validationLevel: "strict"— проверяются все вставки и обновления.validationLevel: "moderate"— проверяются только документы, уже соответствующие схеме. Удобно при добавлении новых правил к существующей коллекции, чтобы не блокировать работу.
validationAction: "warn" вместо "error" — нарушение логируется, документ проходит. Полезно при первоначальном внедрении валидации, пока данные ещё не приведены к нужному виду.
Для управления версиями схемы хранят schemaVersion в каждом документе и обновляют её при следующем изменении документа — это называют «ленивой миграцией».
Индексы
Без индексов MongoDB читает всю коллекцию при каждом запросе. На коллекции от 10 000 документов это ощутимо. Чаще всего встречаются шесть: три вида индекса — по одному полю, составной, multikey — и три свойства, которые к нему добавляют: частичность, время жизни, уникальность. Список не исчерпывающий: есть ещё hashed (им шардируют по хешу ключа), text для поиска по словам и 2dsphere для геоданных — но начинают с этих шести.
Индекс по одному полю
Создать индекс в учебной песочнице нельзя: она открыта только на чтение и
принимает find, aggregate, countDocuments и distinct. Примеры ниже —
для чтения глазами; проверить их можно на своей базе.
db.products.createIndex({ categoryId: 1 }); // по возрастанию
db.products.createIndex({ price: -1 }); // по убыванию
Составной индекс
По нескольким полям сразу. Порядок полей важен — работает правило ESR: сначала поля равенства (Equality), затем сортировки (Sort), затем диапазонов (Range):
db.products.createIndex({ categoryId: 1, price: -1 });
// работает для: find({ categoryId: 1 }).sort({ price: -1 })
// работает для: find({ categoryId: 1, price: { $gt: 100 } })
// не работает для: find({ price: { $gt: 100 } }) — первое поле не задано
Составной индекс сам обслуживает запросы по своему префиксу — отдельный индекс { categoryId: 1 } в таком случае дублирует работу.
Индекс по паре (categoryId, price) лежит тремя кусками, по куску на категорию, и внутри куска цены отсортированы: с известной категорией база входит в свой кусок и читает подряд, а по одной цене входить не по чему и приходится пройти все три.
Почему префикс работает, а второе поле в одиночку — нет, видно на модели индекса. Это отсортированный список пар (categoryId, price): записи одной категории лежат подряд, и до начала такого куска база доходит спуском по дереву. Задана только цена — подходящие записи разбросаны по всему списку, и его читают целиком:
живой пример
import java.util.ArrayList;
import java.util.List;
public class CompoundIndex {
record Entry(int categoryId, int price) {}
public static void main(String[] args) {
List<Entry> index = new ArrayList<>();
for (int categoryId = 1; categoryId <= 3; categoryId++) {
for (int price : new int[] {250, 200, 150, 100}) {
index.add(new Entry(categoryId, price));
}
}
int start = 0;
int high = index.size();
while (start < high) {
int mid = (start + high) / 2;
if (index.get(mid).categoryId() < 2) start = mid + 1; else high = mid;
}
int read = 0;
while (start + read < index.size() && index.get(start + read).categoryId() == 2) read++;
System.out.println("всего записей в индексе: " + index.size());
System.out.println("find({categoryId: 2}) читает записей: " + read);
System.out.println("find({price: {$gt: 150}}) читает записей: " + index.size());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
type entry struct{ categoryID, price int }
func main() {
var index []entry
for categoryID := 1; categoryID <= 3; categoryID++ {
for _, price := range []int{250, 200, 150, 100} {
index = append(index, entry{categoryID, price})
}
}
start, high := 0, len(index)
for start < high {
mid := (start + high) / 2
if index[mid].categoryID < 2 {
start = mid + 1
} else {
high = mid
}
}
read := 0
for start+read < len(index) && index[start+read].categoryID == 2 {
read++
}
fmt.Println("всего записей в индексе:", len(index))
fmt.Println("find({categoryId: 2}) читает записей:", read)
fmt.Println("find({price: {$gt: 150}}) читает записей:", len(index))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const index = [];
for (let categoryId = 1; categoryId <= 3; categoryId++) {
for (const price of [250, 200, 150, 100]) index.push({ categoryId, price });
}
let start = 0, high = index.length;
while (start < high) {
const mid = Math.floor((start + high) / 2);
if (index[mid].categoryId < 2) start = mid + 1; else high = mid;
}
let read = 0;
while (start + read < index.length && index[start + read].categoryId === 2) read++;
console.log('всего записей в индексе: ' + index.length);
console.log('find({categoryId: 2}) читает записей: ' + read);
console.log('find({price: {$gt: 150}}) читает записей: ' + index.length);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
index = [(category_id, price) for category_id in (1, 2, 3) for price in (250, 200, 150, 100)]
start, high = 0, len(index)
while start < high:
mid = (start + high) // 2
if index[mid][0] < 2:
start = mid + 1
else:
high = mid
read = 0
while start + read < len(index) and index[start + read][0] == 2:
read += 1
print(f"всего записей в индексе: {len(index)}")
print(f"find({{categoryId: 2}}) читает записей: {read}")
print(f"find({{price: {{$gt: 150}}}}) читает записей: {len(index)}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На миллионе документов такая разница — точечное попадание против полного прохода по индексу.
Multikey-индекс
Создаётся автоматически при индексировании массива. Каждый элемент массива получает свою запись в индексе:
// товар с тегами
{ _id: 3, name: "Конфеты", tags: ["сладкое", "детям", "праздник"] }
db.products.createIndex({ tags: 1 });
// find({ tags: "детям" }) — использует индекс
Важно: если в каждом документе в массиве по 100 элементов, а документов 10 миллионов — индекс хранит миллиард записей. Нужно следить за размером.
Частичный индекс
Индексируются только документы, удовлетворяющие условию. Экономит место и ускоряет запись:
// индексируем только активные товары (90% коллекции — архив)
db.products.createIndex(
{ categoryId: 1 },
{ partialFilterExpression: { active: true } }
);
MongoDB возьмёт такой индекс, только если из условия запроса следует условие индекса: find({ active: true, categoryId: 1 }) — подходит. Буквального совпадения не требуется: если в индексе стоит { rating: { $gt: 3 } }, то запрос rating > 5 тоже годится, потому что все его документы гарантированно внутри индекса. А вот запрос, в котором про active не сказано ничего, частичный индекс не возьмёт вовсе — база не знает, не окажется ли ответ за его пределами.
TTL-индекс
Документы удаляются автоматически по истечении времени. Удобно для сессий, логов, временных данных:
db.session.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400 } // 24 часа
);
Фоновый процесс удаляет документы каждые 60 секунд, поэтому точность — до минуты, не до секунды.
Уникальный индекс
db.products.createIndex({ name: 1 }, { unique: true });
// вставка дубликата → DuplicateKeyError
В шардированном кластере уникальный индекс работает только если он начинается с ключа шардинга — иначе уникальность невозможно гарантировать без проверки на всех узлах. Отсюда неочевидное следствие про _id: если он не входит в ключ шардинга, его уникальность держится только внутри шарда, и два документа с одинаковым _id на разных шардах база не заметит.
Проверить, что индекс работает
Создать индекс легко, а убедиться, что запрос его берёт, — отдельный шаг, и без него половина индексов оказывается бесполезной.
живой пример
db.orders.find({ customerId: "cus-01", status: "PAID" })
.sort({ createdAt: -1 })
.explain("executionStats")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Смотреть надо на три вещи. winningPlan.stage: IXSCAN означает, что индекс сработал, COLLSCAN — что коллекция прочитана целиком. totalDocsExamined против nReturned: если просмотрено сто тысяч документов, а вернулось двадцать, индекс отбирает плохо (или его нет). И SORT в плане: его наличие означает, что сортировка делается в памяти — а она ограничена, и на большом результате запрос упадёт с ошибкой о превышении лимита сортировки. Правильный индекс даёт сортировку «бесплатно», потому что данные уже лежат в нужном порядке.
Быстрая проверка вместо разбора плана: explain("executionStats").executionStats.totalDocsExamined должно быть близко к числу возвращённых документов. Расхождение в разы — задача на индекс.
Покрывающий запрос
Самая дешёвая оптимизация в MongoDB: если все поля, которые нужны запросу (и в условии, и в выдаче), есть в индексе, документ читать не придётся вовсе — ответ собирается из индекса.
db.orders.createIndex({ customerId: 1, createdAt: -1, status: 1 })
db.orders.find({ customerId: "cus-01" }, { _id: 0, createdAt: 1, status: 1 })
.sort({ createdAt: -1 })
Обратите внимание на _id: 0: поле _id возвращается по умолчанию, а его в индексе нет — поэтому без явного исключения запрос перестаёт быть покрывающим. В плане это видно по totalDocsExamined: 0. Приём особенно полезен для списков и автодополнения, где нужны два-три поля из большого документа.
Откуда границы «100–200 элементов или 100 КБ»
Числа выглядят произвольными, а за ними механика.
Предел документа — 16 МБ, и это жёсткая граница: документ, переросший её, просто не запишется, причём обычно в самый неудачный момент. Массив, растущий без ограничений, идёт к этому пределу гарантированно.
Документ читается и пишется целиком. Массив на десять тысяч элементов означает, что каждое чтение тянет их все, даже если нужен один; каждое обновление одного элемента переписывает весь документ на диске и в журнале. Отсюда и практическая граница: пока массив в сотни элементов и десятки килобайт, накладные расходы незаметны; на тысячах элементов они начинают доминировать.
Обновление раздувает хранилище. Растущий документ перестаёт помещаться на своё место и переезжает, оставляя дыру, — это та же история, что с версиями строк в реляционной базе, и лечится она тем, что документы не растут бесконечно.
Отсюда и правило выбора: вложение годится, когда число элементов ограничено по природе (адреса покупателя, позиции одного заказа), и не годится, когда оно растёт со временем (сообщения в чате, события устройства). Для второго случая есть шаблон с ведрами, разобранный ниже, или отдельная коллекция.
Ленивая миграция версий документа
Поле schemaVersion полезно ровно тем, что позволяет не переписывать коллекцию целиком. Работает это так: читающий код понимает все живые версии и приводит документ к текущей модели, а при следующей записи сохраняет уже в новом формате.
public Order toOrder(Document doc) {
int version = doc.getInteger("schemaVersion", 1);
return switch (version) {
case 1 -> fromV1(doc); // адрес строкой
case 2 -> fromV2(doc); // адрес объектом
default -> throw new IllegalStateException("неизвестная версия схемы: " + version);
};
}
func toOrder(doc bson.M) (Order, error) {
version, _ := doc["schemaVersion"].(int32)
if version == 0 {
version = 1
}
switch version {
case 1:
return fromV1(doc), nil // адрес строкой
case 2:
return fromV2(doc), nil // адрес объектом
default:
return Order{}, fmt.Errorf("неизвестная версия схемы: %d", version)
}
}
function toOrder(doc) {
const version = doc.schemaVersion ?? 1;
switch (version) {
case 1: return fromV1(doc); // адрес строкой
case 2: return fromV2(doc); // адрес объектом
default: throw new Error(`неизвестная версия схемы: ${version}`);
}
}
def to_order(doc: dict) -> Order:
match doc.get("schemaVersion", 1):
case 1:
return from_v1(doc) # адрес строкой
case 2:
return from_v2(doc) # адрес объектом
case version:
raise ValueError(f"неизвестная версия схемы: {version}")
Три правила, без которых приём вредит. Поддержку старой версии нельзя убирать, пока в коллекции есть документы этой версии, — а сколько их осталось, считают запросом (countDocuments({ schemaVersion: 1 })) и следят за числом. Запись всегда идёт в текущем формате: иначе старые версии не вымываются никогда. И неизвестная версия — это ошибка, а не «попробуем разобрать как получится»: молчаливое чтение документа из будущего (его записала новая версия приложения) даёт неверные данные.
Когда документов в старом формате много и они не читаются годами, добавляют фоновое задание, которое переписывает их порциями, — но ленивая миграция обычно справляется сама.
Цена индексов
Индексы ускоряют чтение, но замедляют запись. Каждый INSERT или UPDATE обновляет все индексы по затронутым полям. На коллекции с интенсивной записью пять индексов — это пятикратная стоимость каждой записи.
Ещё индексы занимают место на диске (10–30% от размера данных за средний индекс) и в оперативной памяти — для эффективной работы индекс должен помещаться в кэш WiredTiger.
Неиспользуемые индексы стоит удалять. Проверить статистику использования:
db.products.aggregate([{ $indexStats: {} }]).forEach(s => {
print(s.name, s.accesses.ops, "ops since", s.accesses.since);
});
// categoryId_1 8 500 000 — востребован
// price_-1 145 — 145 запросов за месяц, кандидат на удаление
Как создавать индексы
Автоматическое создание индексов при старте приложения опасно в промышленной среде: построение индекса на большой коллекции может на часы замедлить запись. Надёжный подход — явные миграции через Mongock или скрипты в CI, запускаемые отдельно от деплоя приложения. Если используемый фреймворк поддерживает автоматическое создание индексов по аннотациям модели — в промышленной среде эту опцию отключают.
Как выбрать тип идентификатора
- ObjectId — стандарт MongoDB: 12 байт, монотонный по времени. При шардинге по
_idлучше хешировать, чтобы нагрузка распределялась равномерно. - UUID — удобен в распределённых системах, где идентификатор генерируется без обращения к базе. 16 байт, чуть больше места в индексах.
- Числовой счётчик — требует внешнего источника (атомарный счётчик в коллекции
countersили отдельный сервис). Монотонный счётчик в шардированной коллекции — антипаттерн: все вставки идут в один диапазон ключей, создавая горячую точку.
Глубже: агрегационный конвейер: $match, $group, $unwind, $lookup и $facetрасширенное
$lookup выше появился как аналог JOIN, а на самом деле это одна ступень механизма, который в MongoDB заменяет и GROUP BY, и подзапросы, и отчёты. Конвейер это список ступеней, документы проходят их по очереди, и каждая ступень отдаёт следующей уже преобразованный поток.
Ступени, без которых не обойтись. $match отбирает документы условием, как find, и стоит первым, потому что только первый $match умеет пользоваться индексом. $project и $addFields меняют форму документа: оставляют поля, считают новые. $group собирает документы по ключу и считает $sum, $avg, $max, $push. $sort, $limit, $skip делают то, что и в запросе. $unwind разворачивает массив: документ с тремя позициями заказа превращается в три документа по одной позиции, и после него позиции можно группировать как строки.
живой пример
db.orders.aggregate([
{ $match: { status: "PAID", createdAt: { $gte: ISODate("2026-09-01") } } },
{ $unwind: "$items" },
{ $group: { _id: "$items.productId", qty: { $sum: "$items.qty" }, revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } },
{ $sort: { revenue: -1 } },
{ $limit: 10 },
{ $lookup: { from: "product", localField: "_id", foreignField: "_id", as: "product" } },
{ $unwind: "$product" },
{ $project: { _id: 0, name: "$product.name", qty: 1, revenue: 1 } }
])
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это «десять самых продаваемых товаров за месяц»: отобрали оплаченные заказы, развернули позиции, сгруппировали по товару, отсортировали, взяли десять и только потом подтянули названия, потому что $lookup на десять документов дешевле, чем на все. Порядок ступеней это и есть оптимизация: $match и $limit как можно раньше, $lookup как можно позже.
$facet запускает несколько подконвейеров над одним входом и отдаёт их результаты одним документом: список товаров для страницы и одновременно счётчики по категориям и диапазонам цен, то есть фасеты витрины за один запрос вместо пяти.
Ограничения, о которых узнают из ошибок. Ступень держит до 100 МБ в памяти, и $group или $sort по большой коллекции падает с сообщением про превышение, пока не разрешить сброс на диск: aggregate(pipeline, { allowDiskUse: true }), что медленно и означает, что нужен индекс под $sort или более ранний $match. $lookup по коллекции без индекса на foreignField это полный обход на каждый входной документ. И конвейер для отчётов на боевой базе конкурирует с записью; тяжёлые считают на secondary с readPreference: secondary или выносят в аналитическое хранилище. Результат конвейера можно записать в коллекцию ступенью $merge, и так на MongoDB строят предрассчитанные витрины по расписанию.
Про соединение стоит добавить три ограничения, из-за которых им не стоит увлекаться.
Оно выполняется по одному. Для каждого документа левой коллекции сервер выполняет поиск в правой — то есть это вложенный цикл. Без индекса по полю соединения в правой коллекции это полный её просмотр на каждый документ; с индексом — по поиску на документ. На тысяче документов слева это тысяча поисков, и время растёт линейно.
Шардированная коллекция. Исторически соединение с шардированной коллекцией было запрещено; в актуальных версиях оно разрешено, но выполняется дороже (данные собираются с шардов) и с ограничениями на вложенные конвейеры. Практическое правило остаётся: соединение на шардированном кластере — признак того, что модель стоит пересмотреть.
Предел памяти на этап. Каждый этап конвейера ограничен по памяти (порядка ста мегабайт), и при превышении этап падает, пока не разрешена выгрузка на диск (allowDiskUse). Соединение, которое вытягивает массив вложенных документов на каждый результат, упирается в это быстро.
Отсюда общий вывод, который отличает MongoDB от реляционной базы: соединение здесь не основной инструмент, а исключение. Данные, которые читают вместе, кладут вместе или дублируют, а соединение оставляют для отчётов, где время ответа не критично.
Коротко
- Главный выбор — embed или reference. Embed хорош для данных, которые читаются вместе. Reference — для данных с разной частотой изменений и большими связанными коллекциями.
- Антипаттерн — массив без ограничений: предел документа 16 МБ жёсткий, а документ читается и пишется целиком, поэтому всё, что растёт со временем, выносят в отдельную коллекцию или в bucket pattern.
- Денормализация (скопировать поле в документ) ускоряет чтение, но требует обновления копий при изменении источника.
- JSON Schema на коллекции — явная защита структуры вместо неявных договорённостей.
- Индексы ускоряют чтение, но замедляют запись и занимают память; неиспользуемые удаляют. Что индекс реально работает, проверяют
explain("executionStats"): нуженIXSCAN, близкиеtotalDocsExaminedиnReturned, отсутствиеSORT. - Порядок полей в составном индексе задаёт правило ESR (Equality → Sort → Range): сначала равенство, потом сортировка, потом диапазон.
- Индексы в промышленной среде создают явными миграциями, а не при старте приложения: построение на большой коллекции надолго замедляет запись.
- Агрегационный конвейер:
$matchпервым (только он использует индекс),$unwindразворачивает массивы,$groupсчитает,$lookupв конце на малом наборе,$facetдаёт витрину за один запрос; лимит 100 МБ на ступень иallowDiskUse. - Покрывающий запрос вообще не читает документы (
totalDocsExamined: 0), но требует исключить_idиз выдачи — самая дешёвая оптимизация. - Смешанные версии документов читают по
schemaVersionи пишут всегда в новом формате, считая остаток старых;$lookup— вложенный цикл с пределом памяти этапа, а не полноценное соединение.
Что почитать дальше
- ACID и согласованность в MongoDB — почему правильный embed снижает потребность в транзакциях.
- Репликация и шардинг в MongoDB — как структура документов влияет на стоимость шардинга.
- PostgreSQL или MongoDB — когда документная модель уместнее реляционной.
- Составные индексы — то же правило префикса в PostgreSQL, на планах запросов.