Как MongoDB хранит массивы внутри документов
Массив в MongoDB это поле BSON-документа с типом array. Значения лежат в той же записи, что и остальные поля, и при чтении отдаются вместе с документом. Отдельной таблицы связей, как в реляционных СУБД, здесь нет, поэтому связи «один-ко-многим» моделируются прямо внутри документа.
Жёсткая граница одна: размер BSON-документа не может превышать 16 МБ. В лимит входят все поля, включая массив. Когда список упирается в предел, обновления падают с ошибкой, а чтение и резервное копирование замедляются. Максимальную длину массива стоит оценить до того, как в коллекции появятся первые сотни тысяч документов.
Оба базовых вида массивов показаны в одном документе: список скаляров в поле tags и список поддокументов в поле comments.
{
_id: ObjectId("66d1f0a1c2b3d4e5f6a7b8c9"),
title: "Разбор массивов в MongoDB",
tags: ["mongodb", "nosql", "database"],
comments: [
{ author: "admin", text: "Проверено на 7.0", rating: 5 },
{ author: "devops", text: "Как ведут себя индексы?", rating: 4 }
]
}
Разберём отличия этих двух видов, операторы для каждого из них и то, как на массивы реагируют индексы.
Массив скаляров: когда достаточно простого списка
Список строк или чисел закрывает задачи маркировки и прав доступа. В документе пользователя это roles: ["read", "write"], в документе статьи tags: ["mongodb", "nosql", "database"].
db.users.updateOne(
{ login: "ivan" },
{ $addToSet: { roles: "admin" } }
)
Плюсы практичны. Запрос db.users.find({ roles: "admin" }) читается с листа, поле занимает мало места, а $addToSet защищает от дублей без дополнительных проверок в коде приложения: повторный вызов с тем же значением не меняет документ.
Ограничение тоже простое: у элемента нет своих полей. Нельзя записать, кто добавил тег и когда, нельзя хранить вес или срок действия. Как только такие метаданные понадобились, список скаляров превращается в список поддокументов.
Массив вложенных документов: моделирование связей «один-ко-многим»
Элементом массива может быть документ с собственными полями. Так хранят комментарии поста, позиции заказа, этапы выполнения задачи.
db.posts.insertOne({
title: "Разбор массивов",
comments: [
{ author: "admin", text: "Проверено на 7.0", rating: 5, created_at: ISODate("2026-09-01") },
{ author: "devops", text: "Как ведут себя индексы?", rating: 4, created_at: ISODate("2026-09-02") }
]
})
Каждый элемент получает свой набор полей, поэтому документ растёт быстрее, чем при массиве скаляров: поддокумент с четырьмя полями занимает в разы больше места, чем одна строка.
Выборка по нескольким полям одного элемента требует $elemMatch. Запрос db.posts.find({ "comments.author": "admin", "comments.text": "Как ведут себя индексы?" }) вернёт документ и тогда, когда автора и текст содержат разные комментарии: точечная нотация проверяет условия по массиву в целом. Вариант db.posts.find({ comments: { $elemMatch: { author: "admin", text: "Как ведут себя индексы?" } } }) сработает, только если оба условия выполнены внутри одного поддокумента.
В одном массиве технически могут лежать строка, число и поддокумент одновременно. На практике это ломает читаемость запросов и мешает индексам, поэтому держите элементы однородными.
Операторы запросов и обновлений для массивов
Операторы делятся на две группы: одни ищут документы по содержимому массива, другие меняют сам массив. Таблица связывает задачу с оператором.
| Задача | Оператор | Тип |
|---|---|---|
| Найти элемент по нескольким условиям | $elemMatch | запрос |
| Найти по точной длине массива | $size | запрос |
| Добавить элемент | $push | обновление |
| Добавить элемент без дубликатов | $addToSet | обновление |
| Удалить элементы по условию | $pull | обновление |
Синтаксис и набор модификаторов сверяйте с документацией именно своей версии MongoDB: часть поведения операторов зависит от релиза.
$elemMatch: поиск по нескольким условиям внутри одного элемента
Оператор ограничивает проверку одним элементом массива. Это единственный корректный способ выразить условие «в одном поддокументе выполнено и то, и другое».
db.posts.find({ comments: { $elemMatch: { author: "admin", rating: { $gte: 4 } } } })
Для массива скаляров $elemMatch тоже полезен, когда нужен интервал значений внутри одного элемента:
db.orders.find({ items: { $elemMatch: { $gt: 100, $lt: 200 } } })
Запрос вернёт заказы, в которых есть хотя бы один элемент между 100 и 200. Без $elemMatch такое условие не выразить: запись db.orders.find({ items: { $gt: 100, $lt: 200 } }) вернёт пусто, потому что проверяет весь массив как единое значение.
Типичная ошибка: использовать $elemMatch для одного условия. Тогда оператор только мешает планировщику, хотя и работает.
$push и $addToSet: добавление элементов и защита от дубликатов
$push добавляет элемент всегда, даже если такой уже есть. $addToSet добавляет только при отсутствии совпадения. Для тегов, ролей и списков подписок выбор очевиден: $addToSet.
Оба оператора поддерживают модификаторы, которые сдерживают рост массива:
db.posts.updateOne(
{ _id: postId },
{ $push: { comments: {
$each: [newComment],
$sort: { created_at: -1 },
$slice: -50
} } }
)
Здесь $each передаёт несколько элементов за одну операцию, $sort держит порядок, $slice оставляет последние 50 элементов и отбрасывает всё, что старше. MongoDB применяет модификаторы в порядке $each, потом $sort, потом $slice. Для журнальных массивов $push без $slice опасен: документ растёт на каждой записи.
Подводный камень $addToSet: сравнение идёт по документу целиком, и порядок полей влияет на результат. Поддокументы { author: "admin", rating: 5 } и { rating: 5, author: "admin" } считаются разными, и оператор добавит второй, хотя логически это дубль. Задавайте единый порядок полей или сравнивайте по скалярному ключу.
$pull и $size: удаление по условию и проверка длины
$pull удаляет все элементы, которые подходят под условие:
db.users.updateOne({ login: "ivan" }, { $pull: { tags: "legacy" } })
db.posts.updateOne(
{ title: "Разбор массивов" },
{ $pull: { comments: { author: "spam" } } }
)
db.posts.updateOne(
{ title: "Разбор массивов" },
{ $pull: { comments: { $elemMatch: { author: "spam", rating: { $lt: 3 } } } } }
)
Оператор не возвращает удалённые элементы. Если нужно знать, что именно пропало, используйте findOneAndUpdate с returnDocument: "before".
$size сравнивает точную длину массива:
db.posts.find({ tags: { $size: 3 } })
Диапазоны $size не поддерживает и индекс по массиву он не использует. Для условия «длиннее пяти элементов» нужен $expr:
db.posts.find({ $expr: { $gte: [ { $size: "$comments" }, 5 ] } })
Такой запрос выполняет сканирование коллекции. Если фильтр по длине нужен в горячем пути, храните длину отдельным числовым полем, например comments_count, и обновляйте его вместе с массивом: обычный индекс по числу сработает, а $size останется для редких административных проверок.
Multikey-индексы: как индексируются поля-массивы
Индекс по полю, которое содержит массив, называется multikey. MongoDB создаёт отдельную индексную запись на каждый элемент. Документ с 20 тегами даёт 20 записей в индексе по полю tags, документ с десятью комментариями даёт десять записей в индексе по comments.author.
db.posts.createIndex({ tags: 1 })
db.posts.createIndex({ "comments.author": 1 })
db.posts.createIndex({ "comments.author": 1, created_at: -1 })
Размер индекса растёт пропорционально числу элементов, а не числу документов. Обновление документа с массивом на 20 элементов обновляет 20 ключей, и это дополнительный ввод-вывод на каждой записи. В explain() такой индекс помечается полем isMultiKey, а в db.collection.stats() у него появляется признак multikey.
Когда multikey-индекс ускоряет запрос, а когда только увеличивает индекс
| Запрос | Поведение multikey-индекса |
|---|---|
| { tags: "mongodb" } | используется, стадия IXSCAN |
| { "comments.author": "admin" } | используется, стадия IXSCAN |
| { comments: { $elemMatch: { author: "admin", rating: { $gt: 4 } } } } | используется частично, по условию равенства |
| { tags: { $size: 3 } } | не используется |
| { $expr: { $gte: [ { $size: "$comments" }, 5 ] } } | не используется, сканирование коллекции |
Ограничение, о которое спотыкаются чаще всего: в составном индексе только одно поле может быть массивом. Индекс { tags: 1, "comments.author": 1 } при двух массивах MongoDB отвергает с ошибкой cannot index parallel arrays. Составной индекс по одному массиву и одному скалярному полю работает: { tags: 1, created_at: -1 } ускоряет выборку по тегу с сортировкой по дате.
Что проверить на своей коллекции: план запроса через explain("executionStats") и наличие IXSCAN вместо COLLSCAN, фактическое использование индексов через db.posts.aggregate([{ $indexStats: {} }]) и размер каждого индекса в db.posts.stats().indexSizes. Индекс по массиву на сотни элементов легко перерастает саму коллекцию, поэтому неиспользуемые индексы лучше удалять. Как читать планы выполнения и безопасно создавать индексы на работающем кластере, разобрано в руководстве по индексам и анализу запросов.
Типичные ошибки проектирования схем с массивами
Неограниченный рост массива: почему это ломает производительность
Главный антипаттерн один: массив, длина которого не ограничена приложением. Логи, события, история действий, все заказы пользователя в одном документе.
Механика деградации выглядит так. Пока документ помещается в страницу WiredTiger, точечное обновление дешёвое. После роста документ переписывается целиком и может переехать на другое место в файле коллекции, что даёт дополнительный ввод-вывод и фрагментацию. Параллельно раздувается multikey-индекс: каждое $push обновляет столько ключей, сколько элементов в массиве. Лимит 16 МБ добивает картину: массив событий, растущий на тысячи элементов в день, доводит документ до ошибки записи, а разбор такого инцидента занимает часы.
Признаки проблемы в мониторинге: рост average object size, рост размера индексов, увеличение времени записи при неизменном числе операций. Действия: добавить $slice в $push для журнальных массивов, разбить документ по времени или вынести элементы в отдельную коллекцию. Общая настройка продакшн-инстанса, включая мониторинг метрик и ролевую модель доступа, собрана в гайде по MongoDB в продакшн.
Дубликаты и несогласованность при обновлениях
$push не проверяет дубли, и в накопленном массиве легко получить три одинаковых тега. $addToSet решает задачу в пределах одного документа и работает атомарно: параллельные обновления одного документа MongoDB выполняет последовательно, поэтому два одновременных $addToSet не создадут дубликат.
Дубли появляются в других местах. Замена документа целиком (replaceOne или save в старых драйверах) перезаписывает массив тем состоянием, которое было в приложении. Один и тот же логический объект, записанный в два разных документа, $addToSet не увидит. Порядок полей в поддокументе превращает логический дубль в «новый» элемент.
Контроль на уровне коллекции даёт уникальный индекс: он запрещает двум документам хранить одно и то же значение элемента. Повтор внутри одного массива он не запрещает, за это отвечает $addToSet. Там, где дубли недопустимы по бизнес-логике, элементы выносят в отдельную коллекцию и ставят уникальный индекс по паре полей, например post_id и author_id.
Вложенный массив или отдельная коллекция: критерии выбора
Практические пороги и паттерны доступа
Ориентиры по количеству элементов: до 100 элементов на документ массив обычно безопасен, 1000 и больше почти всегда означают отдельную коллекцию, между 100 и 1000 решает паттерн доступа.
Четыре вопроса дают ответ для конкретной схемы. Читаете ли вы массив всегда вместе с родительским документом? Нужно ли пагинировать и сортировать элементы? Бывают ли запросы по элементам без родителя? Как часто массив обновляется? Если хотя бы на два вопроса ответ «нет», вложенный массив становится обузой.
Отдельная коллекция добавляет ссылку post_id в каждый элемент и второй запрос или $lookup для сборки ответа. Взамен появляются индексы под любые фильтры, пагинация и агрегации по элементам, а родительский документ перестаёт расти. Пример разграничения: комментарии к посту, которых десятки, живут массивом; заказы пользователя с фильтрами по статусу и дате живут в отдельной коллекции.
Перенос массива в отдельную коллекцию делают агрегацией с $unwind и $out, а черновик пайплайна можно собрать с помощью языковой модели: доступ к GPT, Gemini и Claude с оплатой в рублях даёт агрегатор AiTunnel. Приёмы для больших выборок с пагинацией и фильтрами по элементам описаны в материале про ускорение загрузки данных из базы: индексы под конкретный запрос, пагинация без OFFSET, кэширование.
Если база живёт не на своём железе, готовые облачные базы данных и серверы для MongoDB разворачиваются в Timeweb Cloud: ресурсы меняются по мере роста, отдельный кластер под тесты и продакшн поднимается за минуты.
Чек-лист: массивы в MongoDB без ошибок
- Оцените максимальный размер массива на горизонте года. Если он не ограничен сценарием, планируйте отдельную коллекцию.
- Выберите оператор под задачу: $elemMatch для поиска по нескольким условиям внутри одного элемента, $addToSet вместо $push там, где дубли недопустимы, $pull для удаления по условию, $size только для точной длины.
- Ограничьте рост массива: $push с $slice и $sort для журналов и списков последних N записей.
- Проверьте индексы: multikey-индекс должен покрывать реальные запросы, а не все поля массива. План смотрите через explain("executionStats"), использование через $indexStats, размеры в indexSizes.
- Убедитесь, что в составном индексе массив только один: два параллельных массива MongoDB не проиндексирует.
- Сверьте синтаксис и лимиты по документации вашей версии MongoDB: 16 МБ на документ, поведение модификаторов $push, правила сравнения поддокументов в $addToSet.
- Посчитайте цену решения: вложенный массив экономит один запрос на чтение, отдельная коллекция даёт пагинацию, гибкие индексы и предсказуемый размер документа.
Массив в MongoDB подходит для ограниченных и тесно связанных с родителем данных: теги, роли, десятки комментариев. Для неограниченных списков, журналов и сущностей, которые живут своей жизнью, отдельная коллекция дешевле в поддержке с первых месяцев эксплуатации.