← назад к разделу

В реляционной базе данных структура диктуется нормализацией: каждый факт — ровно в одном месте, связи — через внешние ключи. В MongoDB такого правила нет. Для одних и тех же данных существует несколько рабочих моделей, и выбор между ними определяет скорость чтения, сложность обновлений и масштабируемость.

Главный вопрос при проектировании — вложить связанные данные в документ (embed) или хранить отдельно и ссылаться (reference). Вокруг этого выбора строится всё остальное.

категорию «Сладости» переименовали в «Десерты» embed: имя категории в каждом товаре reference: имя категории один раз product 3 · Конфеты category.name: Сладости product 3 · Конфетыcategory.name:Десерты product 5 · Печенье category.name: Сладости product 5 · Печеньеcategory.name:Десерты product 8 · Зефир category.name: Сладости product 8 · Зефирcategory.name:Десерты перезаписано документов: 1 перезаписано документов: 2 перезаписано документов: 3 product 3 · Конфеты categoryId: 1 product 5 · Печенье categoryId: 1 product 8 · Зефир categoryId: 1 category 1 name: Сладости category 1name:Десерты перезаписано документов: 1 чтение: 1 запрос, имя уже в товаречтение: товар, затем $lookup за именем

Слева имя категории продублировано в каждом товаре: переименование переписывает три документа, а если товаров тысяча — тысячу. Справа имя лежит в одном документе, товары его не касаются, зато за именем нужен второй запрос или $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 в реальных конвейерах.

Как выбрать: шесть правил

Универсального ответа нет, но критерии чёткие:

  1. Данные почти всегда читаются вместе → embed. Если 80% запросов «получи товар с категорией» — вложить категорию в товар разумнее.
  2. Данные меняются с разной частотой → reference или частичная денормализация. Категория меняется редко, товар — часто: можно хранить categoryId как ссылку, а categoryName скопировать в товар для быстрого отображения.
  3. Связь один-к-немногим (товар → 2–5 фотографий) → embed массив.
  4. Связь один-ко-многим (товар → 50–200 отзывов) → reference, можно добавить счётчик reviewCount прямо в товаре.
  5. Связь один-к-миллионам (товар → история изменений цены) → только reference. Вложить миллион записей в документ нельзя.
  6. Нужен доступ с обеих сторон (товар знает категорию, категория показывает список товаров) → 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 значений — один запрос, без взрывного роста.

массив без границ 1 документ +1 000 точек +10 000 точек 16 МБ, отказ ведра по 100 ведро 1: 100 ведро 2: 100 ведро 3: 24 размер известен

Сверху массив, куда дописывают без конца и который каждое обновление переписывает целиком, пока он не упрётся в предел 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 1 цена 250 цена 200 цена 150 цена 100 categoryId 2 цена 250 цена 200 цена 150 цена 100 categoryId 3 цена 250 цена 200 цена 150 цена 100

Индекс по паре (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 — вложенный цикл с пределом памяти этапа, а не полноценное соединение.

Что почитать дальше