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

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

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

Embed: вложить всё в один документ

Представьте товар и его категорию. Самый простой способ — хранить категорию прямо внутри товара:

{
    _id: 3,
    name: "Конфеты",
    price: 150,
    category: {
        _id: 1,
        name: "Сладости"
    }
}

Плюс очевидный: один запрос возвращает всё. MongoDB гарантирует атомарность записи одного документа, поэтому никакие транзакции не нужны.

Минус тоже очевиден: название «Сладости» хранится в каждом товаре этой категории. Если категорию переименовали, надо обновить тысячи документов. И ещё одно ограничение — документ в MongoDB не может быть больше 16 МБ, поэтому вкладывать массивы, которые могут расти неограниченно, нельзя.

Reference: хранить отдельно и ссылаться по id

Второй подход — хранить категорию отдельно, а в товаре оставить только её идентификатор:

// товар
{ _id: 3, name: "Конфеты", price: 150, categoryId: 1 }

// отдельная коллекция с категориями
{ _id: 1, name: "Сладости" }

Никакого дублирования: переименование категории — одна операция. Документы компактные, их может быть миллионы.

Но за товар с категорией теперь нужно два запроса, либо агрегация с $lookup — это аналог JOIN в MongoDB. В шардированном кластере, если товары и категории живут на разных узлах, $lookup идёт через сеть.

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

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

  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: массив с контролируемым размером

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

Денормализация: скопировать для скорости

Иногда оба подхода комбинируют. Хранят ссылку и одновременно копируют нужные поля в документ, чтобы не делать $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: {
                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 документов это ощутимо. Есть шесть основных типов индексов.

Индекс по одному полю

db.product.createIndex({ categoryId: 1 });   // по возрастанию
db.product.createIndex({ price: -1 });       // по убыванию

Составной индекс

По нескольким полям сразу. Порядок полей важен — работает правило ESR: сначала поля равенства (Equality), затем сортировки (Sort), затем диапазонов (Range):

db.product.createIndex({ categoryId: 1, price: -1 });
// работает для: find({ categoryId: 1 }).sort({ price: -1 })
// работает для: find({ categoryId: 1, price: { $gt: 100 } })
// не работает для: find({ price: { $gt: 100 } }) — первое поле не задано

Составной индекс автоматически покрывает запросы по его префиксу — отдельный индекс { categoryId: 1 } в таком случае дублирует работу.

Multikey-индекс

Создаётся автоматически при индексировании массива. Каждый элемент массива получает свою запись в индексе:

// товар с тегами
{ _id: 3, name: "Конфеты", tags: ["сладкое", "детям", "праздник"] }

db.product.createIndex({ tags: 1 });
// find({ tags: "детям" }) — использует индекс

Важно: если в каждом документе в массиве по 100 элементов, а документов 10 миллионов — индекс хранит миллиард записей. Нужно следить за размером.

Частичный индекс

Индексируются только документы, удовлетворяющие условию. Экономит место и ускоряет запись:

// индексируем только активные товары (90% коллекции — архив)
db.product.createIndex(
    { categoryId: 1 },
    { partialFilterExpression: { active: true } }
);

MongoDB использует такой индекс только если в запросе есть то же условие: find({ active: true, categoryId: 1 }).

TTL-индекс

Документы удаляются автоматически по истечении времени. Удобно для сессий, логов, временных данных:

db.session.createIndex(
    { createdAt: 1 },
    { expireAfterSeconds: 86400 }  // 24 часа
);

Фоновый процесс удаляет документы каждые 60 секунд, поэтому точность — до минуты, не до секунды.

Уникальный индекс

db.product.createIndex({ name: 1 }, { unique: true });
// вставка дубликата → DuplicateKeyError

В шардированном кластере уникальный индекс работает только если ключ индекса включает ключ шардинга — иначе уникальность невозможно гарантировать без проверки на всех узлах.

Цена индексов

Индексы ускоряют чтение, но замедляют запись. Каждый INSERT или UPDATE обновляет все индексы по затронутым полям. На коллекции с интенсивной записью пять индексов — это пятикратная стоимость каждой записи.

Ещё индексы занимают место на диске (10–30% от размера данных за средний индекс) и в оперативной памяти — для эффективной работы индекс должен помещаться в кэш WiredTiger.

Неиспользуемые индексы стоит удалять. Проверить статистику использования:

db.product.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 или отдельный сервис). Монотонный счётчик в шардированной коллекции — антипаттерн: все вставки идут в один диапазон ключей, создавая горячую точку.

Коротко

  • Главный выбор — embed или reference. Embed хорош для данных, которые читаются вместе. Reference — для данных с разной частотой изменений и большими связанными коллекциями.
  • Антипаттерн — массив без ограничений в документе. Всё, что может вырасти сверх 100–200 элементов, выносят в отдельную коллекцию.
  • Bucket pattern — способ хранить ограниченный массив для временных рядов.
  • Денормализация (скопировать поле в документ) ускоряет чтение, но требует обновления копий при изменении источника.
  • JSON Schema на коллекции — явная защита структуры вместо неявных договорённостей.
  • Индексы ускоряют чтение, но замедляют запись и занимают память. Неиспользуемые индексы удаляют.
  • Правило ESR (Equality → Sort → Range) определяет порядок полей в составном индексе.
  • Создавать индексы в промышленной среде — через явные миграции, не при старте приложения.

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

  • ACID и согласованность в MongoDB — почему правильный embed снижает потребность в транзакциях.
  • Репликация и шардинг в MongoDB — как структура документов влияет на стоимость шардинга.