Соединения: $lookup и $unwind
MongoDB умеет соединять коллекции: $lookup подтягивает связанные документы, $unwind разворачивает массив в отдельные документы.
$lookup — этап агрегации, выполняющий левое внешнее соединение (left outer join): к каждому документу он добавляет массив совпадающих документов из другой коллекции.
MongoDB — документная база, и часто связанные данные хранят вложенно (заказ с массивом позиций внутри). Но иногда сущности живут в разных коллекциях: пользователи и их заказы, товары и категории, статьи и авторы. Чтобы собрать их вместе в одном запросе, нужны соединения. В реляционных базах это JOIN; в MongoDB — $lookup и его спутник $unwind.
Этот урок — про то, как соединять коллекции, разворачивать массивы и, главное, когда соединение лучше заменить денормализацией.
Зачем это на практике
- Заказы с данными пользователя: к каждому заказу добавить имя и email покупателя из коллекции
users. - Товары с названием категории: подтянуть человекочитаемое имя категории по её id.
- Статьи с автором: показать имя автора рядом со статьёй, не дублируя его в каждой статье.
- Отчёт по позициям заказов: «разложить» массив позиций так, чтобы каждая стала отдельной строкой для подсчёта.
Примеры — в mongosh, без кнопки «Запустить» (оболочка MongoDB в браузере не исполняется).
$lookup как левый JOIN
Базовая форма $lookup соединяет две коллекции по равенству полей. Четыре параметра:
from | коллекция, из которой подтягиваем (правая таблица) |
localField | поле текущего документа |
foreignField | поле в коллекции from, с которым сравниваем |
as | имя нового поля-массива с результатами |
db.orders.aggregate([
{ $lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "user"
} }
])
К каждому заказу добавится поле user — массив совпавших пользователей. Даже если совпадение одно, это всё равно массив с одним элементом. Результат выглядит так:
{
"_id": 501,
"userId": 7,
"total": 1200,
"user": [ { "_id": 7, "name": "Алиса", "email": "[email protected]" } ]
}
Это левое соединение: заказ останется в результате, даже если подходящего пользователя нет — тогда user будет пустым массивом []. Документов из правой коллекции, которым нет пары слева, в выводе не будет.
$unwind — разворачивание массива
Поле-массив часто неудобно: хочется один объект вместо массива из одного элемента, или нужно «размножить» документ по элементам массива. Этим занимается $unwind: он превращает документ с массивом из N элементов в N документов, в каждом из которых поле содержит уже один элемент.
db.orders.aggregate([
{ $lookup: {
from: "users", localField: "userId", foreignField: "_id", as: "user"
} },
{ $unwind: "$user" }
])
Теперь user — это объект, а не массив из одного элемента: с ним удобно работать в $project. Это типичная связка «$lookup + $unwind» для отношения «один к одному».
Второй сценарий $unwind — массивы, которые есть в самих документах. Пусть у заказа массив позиций items:
{ "_id": 1, "items": [ { "name": "Книга", "price": 500 }, { "name": "Ручка", "price": 50 } ] }
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.name", sold: { $sum: 1 }, revenue: { $sum: "$items.price" } } }
])
Сначала каждая позиция стала отдельным документом, затем мы сгруппировали по названию товара. Без $unwind посчитать продажи по позициям внутри массива нельзя.
Важная деталь: по умолчанию документы с пустым или отсутствующим массивом $unwind выбрасывает. Чтобы их сохранить, добавляют опцию:
{ $unwind: { path: "$user", preserveNullAndEmptyArrays: true } }
Pipeline-форма $lookup
Простой $lookup соединяет только по равенству. Когда нужно условие посложнее (диапазон дат, дополнительная фильтрация правой коллекции), используют pipeline-форму: внутри $lookup задают свой подконвейер с переменными.
db.users.aggregate([
{ $lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{ $match: { $expr: { $and: [
{ $eq: ["$userId", "$$uid"] },
{ $gt: ["$total", 1000] }
] } } },
{ $project: { _id: 1, total: 1 } }
],
as: "bigOrders"
} }
])
Здесь let объявляет переменную uid (значение _id пользователя), а во вложенном конвейере на неё ссылаются через две доллар-скобки: $$uid. Оператор $expr нужен, чтобы внутри $match сравнивать поля и переменные. Результат — для каждого пользователя массив только крупных заказов. Так в правую коллекцию можно «занести» любую логику: фильтр, сортировку, даже вложенный $lookup.
Как это работает под капотом
$lookup по сути выполняет вложенный поиск: для каждого входного документа MongoDB ищет совпадения в коллекции from. Если foreignField не проиндексирован, каждый такой поиск превращается в полный перебор правой коллекции — а это произведение размеров коллекций. На больших данных незаметная строка $lookup легко становится самым дорогим местом запроса.
Отсюда правило: поле, по которому соединяете (foreignField, а в pipeline-форме — поля из $match), должно быть под индексом. Тогда вместо перебора MongoDB делает индексный поиск по каждому документу. Поэтому соединение по _id правой коллекции почти всегда дёшево (на _id индекс есть всегда), а по произвольному текстовому полю без индекса — дорого.
Когда денормализация лучше
MongoDB спроектирована так, чтобы соединения требовались как можно реже. Если данные читаются вместе почти всегда, их часто денормализуют — хранят копию рядом. Сравним два подхода для «заказ + имя покупателя»:
| Подход | Плюсы | Минусы |
| $lookup при каждом чтении | имя всегда актуально, нет дублей | дороже на чтении, нужен индекс |
Хранить userName прямо в заказе | чтение мгновенное, без соединения | при смене имени надо обновлять копии |
Эвристика: данные, которые читают вместе, храните вместе. Имя покупателя на момент заказа вообще логично «заморозить» в заказе — оно описывает факт на тот момент и не должно меняться задним числом. А вот для гибкой аналитики по живым связям удобнее $lookup. Денормализация ускоряет чтение ценой усложнения записи — выбирайте по тому, чего в вашей нагрузке больше.
Частые ошибки
- Забыли, что результат — массив. После
$lookupполеasвсегда массив; чтобы получить объект, добавьте$unwind. - Нет индекса на foreignField. Соединение по непроиндексированному полю на больших коллекциях работает катастрофически медленно.
- $unwind съел документы. По умолчанию документы с пустым массивом исчезают; если они нужны —
preserveNullAndEmptyArrays: true. - Одна доллар-скобка вместо двух в pipeline. В подконвейере переменные из
letдоступны через$$имя, а поля документа — через$имя; их легко перепутать. - $lookup ради того, что можно денормализовать. Если связь читается всегда и почти не меняется, соединение на каждом чтении — лишняя нагрузка.
Итоги
$lookup— левое соединение: задаётеfrom,localField,foreignField,as; результат складывается в поле-массив.$unwindразворачивает массив в отдельные документы; для «один к одному» идёт сразу после$lookup.$unwindпо умолчанию выбрасывает пустые массивы — спасаетpreserveNullAndEmptyArrays.- Pipeline-форма (
let+pipeline+$expr, ссылки через$$) позволяет соединять по сложным условиям. - Индекс на
foreignFieldобязателен на больших данных; если связь читают вместе и редко меняют — денормализуйте.