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

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

Главное решение — embed (вложить) или reference (сослаться). Дальше — индексы и валидация. Разбираем на тех же двух сущностях: category (3 записи) и product (7 записей), что и в статье про ACID.

Embed vs reference: шесть правил

Embed — вложить связанные данные внутрь документа

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

Плюсы:

  • Один запрос — все данные.
  • Атомарная запись без транзакций (MongoDB гарантирует атомарность одного документа).
  • Простая дешевая выборка.

Минусы:

  • Дублирование: «Сладости» хранятся в каждом продукте этой категории.
  • Обновление имени категории требует обхода всех продуктов.
  • Документ ограничен 16 MB — массив дочерних объектов не должен расти бесконечно.

Reference — сослаться по id

{ _id: 3, name: "Конфеты", price: 150, categoryId: 1 }
// + отдельная коллекция category

Плюсы:

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

Минусы:

  • Чтение «продукт с категорией» — два запроса или $lookup (агрегационный JOIN).
  • При шардинге продукта и категории на разных шардах $lookup идёт через сеть.

Правила выбора

  1. Зависят друг от друга в чтении → embed. Если 80% запросов «получи продукт + категорию» — embed.
  2. Категория меняется редко, продукт часто → reference со снапшотом критичного поля. Например, categoryName денормализован в продукт, но обновляется фоновым джобом, а каноничная категория живёт отдельно.
  3. Связь one-to-few (товар → 2–5 фото) → embed массив в товар.
  4. Связь one-to-many (товар → 50–200 отзывов) → reference, может быть с denormalized counter (reviewCount в продукте).
  5. Связь one-to-millions (продукт → лог изменений цен) → reference. Лог растёт без границ, нельзя embed.
  6. Двунаправленный доступ (товар знает свою категорию, категория показывает свои товары) → reference + индекс по categoryId в product.

Антипаттерн: массив без границ

// ОЧЕНЬ ПЛОХО
{
    _id: 1,
    name: "Сладости",
    products: [/* 50 000 объектов */]
}

Документ упрётся в лимит 16 MB. Даже до этого — каждое чтение категории грузит весь массив; каждое добавление продукта переписывает весь документ. WiredTiger при росте документа делает relocate, страница фрагментируется.

Любой массив, который может вырасти больше 100–200 элементов или общим объёмом больше 100 KB, — кандидат вынести в отдельную коллекцию через reference.

Промежуточный паттерн: bucket pattern

Когда нужен «массив с границами» — например, последние 100 цен продукта — используют bucket pattern: складываем по N записей в документ, переходим к новому документу при заполнении.

// priceHistory сгруппированы по 100 в один bucket
{
    productId: 3,
    bucketStart: ISODate("2026-01-01"),
    count: 100,
    prices: [
        { ts: ISODate("2026-01-01T..."), price: 150 },
        { ts: ISODate("2026-01-02T..."), price: 148 },
        // ...
    ]
}

Размер документа предсказуем, чтение последних N значений — один запрос, никакого взрыва.

Применяем к нашему домену

Категория меняется редко, продуктов в категории — десятки или сотни, продукт читается чаще, чем категория. Решение:

// product: reference + denormalized critical fields
{
    _id: 3,
    name: "Конфеты",
    price: 150,
    categoryId: 1,
    categoryName: "Сладости"   // снапшот для отображения без $lookup
}

// category: каноничный источник
{ _id: 1, name: "Сладости", productCount: 3 }

Когда категория переименовывается — фоновый джоб проходит по product с categoryId = X и обновляет categoryName. Если переименование редкое (раз в месяц) — нормально. Если меняется ежедневно — лучше не денормализовать или хранить только id и делать $lookup.

Schema validation через JSON Schema

MongoDB без схемы — миф. Можно (и нужно) задать JSON Schema validator на коллекции:

db.createCollection("product", {
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["name", "price"],
            properties: {
                _id:          { bsonType: ["int", "long"] },
                name:         { bsonType: "string", minLength: 1, maxLength: 200 },
                price:        { bsonType: "number", minimum: 0 },
                categoryId:   { bsonType: ["int", "long", "null"] },
                categoryName: { bsonType: ["string", "null"] }
            },
            additionalProperties: false
        }
    },
    validationLevel: "strict",       // отклонять невалидные документы
    validationAction: "error"
});
  • validationLevel: "strict" — все вставки и обновления проверяются.
  • validationLevel: "moderate" — проверяются только документы, уже валидные по существующей схеме (полезно при миграциях).
  • validationAction: "warn" — невалидное логируется, но проходит. Применяется при добавлении новой валидации на существующую коллекцию.

Версионирование схемы — хранить schemaVersion: 2 в документе и в коде уметь читать обе версии. Миграции делать lazy: при апдейте документа поднимать его версию.

Индексы

Без индексов MongoDB читает коллекцию целиком (COLLSCAN) — на любой коллекции от 10 000 документов это уже больно. Шесть основных типов.

Single field

Самый простой. По одному полю, по возрастанию или убыванию.

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

Compound (составной)

По нескольким полям. Порядок важен: используется по правилу 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 не указан

Составной индекс заменяет single field { categoryId: 1 } (используется prefix), но не { price: -1 }. Если у вас три похожих compound индекса с разным порядком — обычно лишнее, MongoDB всё равно использует только один.

Multikey

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

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

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

Минус: размер multikey-индекса пропорционален суммарному количеству элементов. Массив из 100 строк на каждом из 10M документов — индекс на миллиард записей.

Partial index

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

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

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

TTL index

Документы автоматически удаляются по времени. Идеально для сессий, логов, временных кэшей.

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

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

Unique

Гарантирует уникальность значения.

db.product.createIndex({ name: 1 }, { unique: true });
// insertOne с уже существующим name → DuplicateKeyError

В sharded cluster unique работает только если ключ содержит shard key — иначе уникальность гарантировать невозможно без проверки на всех шардах.

Стоимость индексов

Каждый индекс — это:

  • Диск: 10–30% от размера данных за каждый средний индекс.
  • Запись: каждый INSERT/UPDATE обновляет все индексы по затрагиваемым полям. На write-heavy таблице 5 индексов = 5× стоимости записи.
  • RAM: для эффективной работы индекс должен помещаться в wiredTigerCacheSize.

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

db.product.aggregate([{ $indexStats: {} }]).forEach(s => {
    print(s.name, s.accesses.ops, "ops since", s.accesses.since);
});
// _id_ 12000000  // системный, не трогаем
// categoryId_1 8500000  // востребован
// price_-1 145          // 145 запросов за месяц → удалить

FAQ для Java/Spring команд

Spring Data MongoDB сам создаёт индексы? По умолчанию — да, по @Indexed и @CompoundIndex аннотациям. Это опасно в production: автоматическое создание индекса на огромной коллекции может на часы заблокировать запись. С 4.x это поведение по умолчанию отключено (spring.data.mongodb.auto-index-creation=false), индексы создаём через миграции (Mongock, скрипты в CI).

Чем заменить JPA @OneToMany? Прямого аналога нет. Решения:

  • Embed массив, если связь маленькая и читается вместе — @DBRef НЕ использовать (deprecated, неэффективен).
  • Reference по categoryId + @DocumentReference (Spring Data MongoDB 4.0+) для автоматической подгрузки. Под капотом — отдельные запросы, как n+1 в JPA.
  • Для production-нагрузки — обычно явный $lookup в репозитории.

Можно ли хранить тип Java enum в документе? Да, как строку — @Field с конвертером. Главное: при миграции добавления нового значения в enum приложение не должно падать на старых документах. Использовать try-catch + fallback на UNKNOWN.

Какой ID для документа: ObjectId, UUID, или BIGSERIAL?

  • ObjectId — дефолт MongoDB, 12 байт, монотонный. Для shardingа по _id — хешировать.
  • UUID — глобально уникальный, хорош для распределённых систем. 16 байт, занимает больше места в индексах.
  • BIGSERIAL — нужен внешний sequencer (атомарный counter в коллекции counters), монотонный. В шардированной коллекции — антипаттерн.

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

  • MongoDB: Schema Design Patterns — официальный обзор паттернов (bucket, schema versioning, computed, и др.).
  • MongoDB: Indexes — официальная документация по индексам.
  • ACID и согласованность в MongoDB — почему правильный embed снижает потребность в транзакциях.
  • Репликация и шардинг в MongoDB — как schema design влияет на стоимость шардинга.