В реляционной БД схема диктуется нормализацией: один факт хранится в одном месте, связи — через внешние ключи. В документной БД нет «правильного» способа — для одних и тех же данных есть несколько работающих моделей, и выбор между ними определяет всё остальное: скорость чтения, сложность обновлений, стоимость шардинга.
Главное решение — 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идёт через сеть.
Правила выбора
- Зависят друг от друга в чтении → embed. Если 80% запросов «получи продукт + категорию» — embed.
- Категория меняется редко, продукт часто → reference со снапшотом критичного поля. Например,
categoryNameденормализован в продукт, но обновляется фоновым джобом, а каноничная категория живёт отдельно. - Связь one-to-few (товар → 2–5 фото) → embed массив в товар.
- Связь one-to-many (товар → 50–200 отзывов) → reference, может быть с denormalized counter (
reviewCountв продукте). - Связь one-to-millions (продукт → лог изменений цен) → reference. Лог растёт без границ, нельзя embed.
- Двунаправленный доступ (товар знает свою категорию, категория показывает свои товары) → 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 влияет на стоимость шардинга.