Соединения: $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 обязателен на больших данных; если связь читают вместе и редко меняют — денормализуйте.
Проверьте себя
1. Какого типа поле, заданное в параметре as у $lookup?
AМассив совпавших документов из правой коллекции, даже если совпадение всего одно
BОдин объект — первый совпавший документ
CСтрока с _id связанного документа
DБулево значение: есть совпадение или нет
2. Зачем нужен $unwind в связке с $lookup при отношении «один к одному»?
AЧтобы превратить массив из одного элемента в обычный объект, с которым удобно работать дальше
BЧтобы отсортировать связанные документы
CЧтобы $lookup вообще сработал — без $unwind он не выполняется
DЧтобы удалить связанные документы из результата
3. Что произойдёт с документом, у которого разворачиваемое поле — пустой массив, при обычном $unwind без опций?
AДокумент будет исключён из результата
BДокумент останется, а поле станет null
CБудет ошибка выполнения
DДокумент продублируется