Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~20 мин +50 XP

$lookup и $unwind: joins в MongoDB

Joins в mongo из Go делает этап $lookup: он подтягивает документы из второй коллекции прямо в конвейер агрегации, а $unwind mongodb-этап разворачивает собранный массив в отдельные документы. Разберём простую форму со связью localField и foreignField, продвинутую с let и pipeline, ловушку пустых массивов и цену джойна — индексы под foreignField и границу 16 МБ. Конвейер по-прежнему собирается в mongo.Pipeline и читается курсором, как в пятнадцатом уроке.

Содержание урока · 10 разделов

Каталог, который доживёт до итогового проекта курса, начинается с классической схемы: заказы лежат в коллекции orders, позиции — в order_items, по одной записи на товар в заказе. Пришедший из SQL с pgx потянется писать JOIN — и окажется прав по смыслу, но не по месту: в MongoDB связывание коллекций выполняет этап $lookup внутри конвейера агрегации, а не оператор в вызове Find. Это одна из главных причин, ради которых агрегации вообще берут: кроме $match и $group из пятнадцатого урока конвейер умеет соединять коллекции и делает это на сервере, не перекачивая миллионы документов в приложение. Схема работы простая: каждому входному документу этап подбирает документы из чужой коллекции и кладёт их в поле-массив. Дальше массив удобно развернуть этапом $unwind — и каждый элемент становится отдельной строкой, по которой можно группировать и считать продажи. Обе коллекции остаются независимыми: позиции живут своей жизнью — их читают склад и аналитика, — а связка собирается только там, где она нужна, одним этапом конвейера, а не вторым запросом со склейкой в Go. В этом уроке соберём заказы с позициями двумя формами $lookup, разберём, куда деваются заказы без позиций после разворота, и посчитаем выручку по товарам одним конвейером — теми же правилами, что и любой другой отчёт.

Зачем join в документной базе#

Документная база подталкивает к вложению: позиции заказа можно положить массивом прямо в документ заказа — и связывание не понадобится вовсе. Так и стоит делать, когда данные живут и умирают вместе: элементы корзины бессмысленны без заказа, и читаются они всегда рядом с ним. У вложения есть границы: позиции растут непредсказуемо, их читают отдельно от заказа — товарные отчёты, поставки, остатки на складе, — или сам документ приближается к лимиту в 16 МБ. Тогда данные разъезжаются по коллекциям, и связывать их приходится этапом $lookup. Отдельное правило, которое удивляет после SQL: Find соединить коллекции не умеет — join в MongoDB существует только внутри конвейера агрегации, и никакая опция вызова это не меняет. Поэтому выборку со связыванием собирают Aggregate, даже если фильтр кажется простым. Форма результата тоже непривычна: этап добавляет к каждому документу поле-массив со всеми совпавшими документами, а не плоские колонки соединённой таблицы. Плоскость возвращает отдельный этап $unwind, и это два решения вместо одного JOIN: сначала собрать связанное, потом решить, что с массивом делать — оставить как поле, развернуть в строки или свернуть счётчиком.

$lookup: простая форма#

Простая форма описывается четырьмя полями. from — имя коллекции в той же базе: сервер не соединяет коллекции из разных баз. localField — поле входного документа, foreignField — поле документа в чужой коллекции, as — имя массива с результатом; связь проверяется на равенство, как JOIN ON a.id = b.order_id. Если совпадений нет, массив останется пустым, а документ не исчезнет: заказ без позиций доедет до выхода с пустым items. Поле as перезаписывается целиком — если имя занято, прежнее значение пропадёт без предупреждения; точечная запись вида items.docs кладёт результат во вложенный документ. Подводный камень после SQL: отсутствующее поле на любой стороне ведёт себя как null, а null совпадает с null — документы без localField найдут чужие документы без foreignField, и связка окажется ложной. Лечение простое: поставить перед $lookup этап $match, который отсеет записи без ключа, или договориться на уровне записи, что поле связи присутствует всегда. Сверять ожидания удобнее в mongosh: тот же конвейер, что уйдёт из Go, выполняется поэтапно, и промежуточный вывод каждого этапа виден глазами. Отдельная мелочь, которая экономит часы отладки: имена полей в описании этапа пишутся строками, и опечатка в foreignField не вызовет ошибку — сервер просто соберёт пустые массивы, поэтому этап связывания проверяют на двух документах раньше, чем вешают на живой трафик.

Заказы с позициями одним конвейером

GO
type OrderItem struct {
        SKU string `bson:"sku"`
        Qty int    `bson:"qty"`
}

type OrderWithItems struct {
        ID     string      `bson:"_id"`
        Status string      `bson:"status"`
        Items  []OrderItem `bson:"items"`
}

pipeline := mongo.Pipeline{
        bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}},
        bson.D{{Key: "$lookup", Value: bson.M{
                "from":         "order_items",
                "localField":   "_id",
                "foreignField": "order_id",
                "as":           "items",
        }}},
}

cursor, err := coll.Aggregate(ctx, pipeline)
if err != nil {
        return fmt.Errorf("orders with items: %w", err)
}

var orders []OrderWithItems
if err := cursor.All(ctx, &orders); err != nil {
        return fmt.Errorf("decode orders: %w", err)
}

Этап $lookup описан четырьмя полями: from — коллекция в той же базе, localField — ключ заказа, foreignField — ссылка в позиции, as — имя массива. Каждому оплаченному заказу сервер собирает подходящие позиции в items. cursor.All декодирует результат целиком: поле Items принимает массив позиций, и bson-тег items у структуры обязателен, иначе массив потеряется при декодировании.

$unwind: разворот массива#

Этап $unwind принимает ссылку на массив и разворачивает его: по одному выходному документу на каждый элемент, с копией всех остальных полей родителя. Заказ с тремя позициями превращается в три документа с одинаковым номером и разными items; до полей элемента ссылка дотягивается точечной записью — $items.sku работает так же, как в $group. Правило, на котором ломаются отчёты: документ с пустым массивом или вовсе без поля выбрасывается из конвейера. Заказ, оформленный до добавления позиций, из отчёта молча пропадёт — это не ошибка, а семантика этапа. Опция preserveNullAndEmptyArrays: true сохраняет такие документы: массивное поле в выход не попадает, но сам документ продолжает путь по конвейеру и участвует в последующих этапах. После разворота меняется смысл группировки: $group по sku считает позиции по отдельности, и $sum: 1 даёт число строк, а не заказов — счётчик заказов собирается до разворота или через $addToSet с подсчётом уникальных идентификаторов. Понимание того, что unwind mongodb-этап именно разворачивает, а не сплющивает, экономит часы споров с отчётами: каждая цифра после $unwind отвечает за элементы массива, а не за исходные документы. Если в отчёте нужны и заказы, и позиции одновременно, делают два прохода: связка с разворотом для товарной статистики и отдельный конвейер без разворота для заказной — у каждого своя семантика счётчиков.

Поля $lookup и $unwind под рукой#

Поле или опцияГде живётЧто делает
from$lookupимя коллекции для связи — только в той же базе
localField / foreignField$lookupполя связи на входе и в чужой коллекции; сравнение на равенство
as$lookupимя массива с результатом; перезаписывает существующее значение
let / pipeline$lookupпродвинутая форма: переменные входа и внутренний конвейер
path$unwindссылка на массив: строка вида $items
preserveNullAndEmptyArrays$unwindtrue — не выбрасывать документы с пустым или отсутствующим массивом

let и pipeline: продвинутая форма#

Простая форма проверяет только равенство двух полей. Как только условие становится сложнее — позиции за период, совпадение по двум полям сразу, агрегат по чужой коллекции, — включается форма с let и pipeline. let объявляет переменные из входного документа: let: bson.M с парой order и $_id. Внутренний pipeline — настоящий конвейер над чужой коллекцией, и переменные доступны в нём через двойной доллар: $order. Первым этапом внутри обычно стоит $match с $expr — оператор сравнивает поля чужого документа с объявленными переменными, потому что обычный синтаксис фильтров переменные не видит. Дальше — что угодно: $project отрежет лишние поля до пересылки, $group свернёт позиции прямо внутри связки и вернёт по одной строке на заказ вместо тысячи элементов массива. Экономия ощутимая на обоих концах: меньше памяти на выходе и меньше работы на последующих этапах. Ограничения той же природы, что у простой формы: from в текущей базе, граница 16 МБ на документ-результат, а сложные сравнения $expr оптимизатор исполняет не всегда предсказуемо — профиль лучше проверить explain-ом, чем угадывать на глаз. Переменные let принимают любые выражения над входным документом: дату из поля, элемент массива по индексу, результат конкатенации — всё, что сервер умеет вычислить до запуска внутреннего конвейера.

Продажи по товарам: три этапа в связке

GO
pipeline := mongo.Pipeline{
        bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}},
        bson.D{{Key: "$lookup", Value: bson.M{
                "from":         "order_items",
                "localField":   "_id",
                "foreignField": "order_id",
                "as":           "items",
        }}},
        bson.D{{Key: "$unwind", Value: bson.M{
                "path":                       "$items",
                "preserveNullAndEmptyArrays": true,
        }}},
        bson.D{{Key: "$group", Value: bson.M{
                "_id":   "$items.sku",
                "units": bson.M{"$sum": "$items.qty"},
        }}},
        bson.D{{Key: "$sort", Value: bson.D{{Key: "units", Value: -1}}}},
}

cursor, err := coll.Aggregate(ctx, pipeline)

Связка трёх этапов: $lookup собирает позиции в items, $unwind разворачивает массив с опцией preserveNullAndEmptyArrays, $group сворачивает по sku. Ссылка $items.sku читает поле уже развёрнутого документа. Заказы без позиций дают группу с ключом nil — отфильтруйте её следующим $match, если она в отчёте не нужна.

Правила связывания одним списком#

  • $lookup живёт только в конвейере агрегации: Find связывать коллекции не умеет.
  • from — коллекция той же базы; поле as перезаписывается целиком.
  • Пустой массив после $unwind выбрасывает документ; спасает preserveNullAndEmptyArrays: true.
  • Сложное условие связи — форма с let и pipeline; переменные читаются через двойной доллар.
  • После разворота счётчики считают элементы массива, а не исходные документы.
  • Индекс на foreignField в чужой коллекции — первый пункт оптимизации джойна.

Проверка: форма результата $lookup

Что именно кладёт этап $lookup в поле as?

Проверка: пустой массив и $unwind

Заказ с пустым массивом items проходит этап $unwind без preserveNullAndEmptyArrays. Что с ним станет?

Сколько документов доедет до выхода?

Прочитайте код и выберите, что он выведет:

// orders: o1 — две позиции в order_items, o2 — ни одной
pipeline := mongo.Pipeline{
        bson.D{{Key: "$lookup", Value: bson.M{
                "from": "order_items", "localField": "_id",
                "foreignField": "order_id", "as": "items",
        }}},
        bson.D{{Key: "$unwind", Value: "$items"}},
}
// cursor.All декодировал срез out
fmt.Println(len(out))

Цена джойна: индексы и границы#

$lookup стоит дороже, чем выглядит в коде. На каждый входной документ сервер выполняет запрос в чужую коллекцию, поэтому сотни тысяч заказов превращаются в сотни тысяч подзапросов; без индекса на foreignField каждый подзапрос сканирует чужую коллекцию целиком. Первое лечение — индекс из семнадцатого урока; второе — сузить вход до связывания: $match и $limit перед $lookup режут число подзапросов, потому что оптимизатор не вынесет джойн наверх, как это делает планировщик SQL. Память конвейера та же, что в пятнадцатом уроке: сотня мегабайт на этап, и тяжёлым формам с pipeline включают разрешение на временные файлы через allowDiskUse. Отдельная граница — 16 МБ на сам выходной документ: массив items с тысячами позиций добирается до потолка BSON, и отчёт падает с ошибкой вместо результата. Лечится свёрткой внутри формы с pipeline — считать сумму и среднее по позициям можно ещё в связке, — либо переносом связывания на этап записи, то есть вложением, когда жизненные циклы данных это позволяют. Джойн в документной базе реален, просто оплачивается в другом месте — и лучше до продакшена. Число для калибровки простое: подзапрос с индексом стоит спуск по дереву, без — чтение всей чужой коллекции; на сотне документов разницы нет, на миллионе она измеряется секундами.

Соберите этап связывания

Свяжите заказы с коллекцией позиций order_items по полю order_id и соберите результат в массив items.

pipeline := mongo.Pipeline{ bson.D{{Key: "$", Value: bson.M{ "from": "", "localField": "_id", "foreignField": "", "as": "", }}}, }

Соберите метод со связыванием

Расставьте строки метода: конвейер со связыванием, запуск агрегации, обработка ошибки, декодирование.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: joins в MongoDB

Термин

$lookup

нажмите, чтобы перевернуть
Определение

Этап конвейера, присоединяющий документы из чужой коллекции той же базы. Простая форма: from, localField, foreignField, as; результат — массив в поле as.

1 / 6

Связывание готово: простая форма для равенства, let и pipeline для всего остального, $unwind для плоских строк отчёта. Дальше по плану — индексы: урок про CreateOne и текстовый поиск закрывает и цену джойна, и уникальность связи на уровне базы, а урок про производительность сведёт счёт за подзапросы в общую картину вместе с пулом соединений. Конвейер здесь тот же, что в уроке про $match и $group, и курсор читается по правилам урока про Find и курсор — новые этапы ложатся на старую механику без поправок. Песочница сайта не исполняет драйвер: MongoDB поднимается локально через Docker одной командой, mongo-go-driver v2 ставится go get — соберите orders и order_items на десяти документах и сравните вывод с ручным прогоном того же конвейера в mongosh. Разницу между простой формой и let с pipeline на глаз не заметить — проверьте обе на живых данных и посмотрите на размер массива items в декодированной структуре.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • $lookup присоединяет документы из чужой коллекции в поле-массив as; join существует только в конвейере агрегации.
  • Простая форма: from, localField, foreignField, as — равенство двух полей.
  • Форма с let и pipeline даёт сложные условия и свёртку чужой коллекции; переменные читаются через двойной доллар.
  • $unwind разворачивает массив в документы; пустой массив выбрасывает документ без preserveNullAndEmptyArrays: true.
  • После разворота счётчики отвечают за элементы массива, а не за исходные документы.
  • Индекс на foreignField превращает подзапрос из скана в поиск по дереву.
  • Граница 16 МБ относится и к join-массиву: сворачивайте чужую коллекцию внутри pipeline.
  • $match и $limit перед $lookup режут число подзапросов.

Домашнее задание

Соберите связку orders и order_items в Docker-контейнере с MongoDB: десять заказов, у двух — пустые позиции. Выведите отчёт по товарам через $lookup, $unwind и $group и сверьте число строк с ручным подсчётом. Затем уберите preserveNullAndEmptyArrays и посмотрите, как отчёт занизится без единой ошибки, — после этого проверка пустых массивов перед разворотом станет рефлексом. В конце перепишите связывание формой с let и pipeline, чтобы сервер возвращал по одной свёрнутой строке на заказ, и сравните размер декодированных структур.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · $lookup и $unwind: joins в MongoDB)

Вопросы и ответы по уроку

Можно ли сделать join через Find, без агрегации?

Нет: связывание коллекций в MongoDB существует только внутри конвейера агрегации. Если связь нужна на каждый запрос и данные живут вместе, вложите документы — массив позиций внутри заказа снимает вопрос вовсе. Для независимых коллекций выбор один: $lookup на сервере или два запроса и склейка в Go, что дороже по сети и по коду.

Чем $lookup отличается от JOIN в SQL?

Смысл тот же, форма другая: результат — массив в поле as, а не плоские строки, и оптимизатор не протаскивает джойн через конвейер. Проверять существование связи база не станет: внешних ключей в MongoDB нет, целостность держит приложение или уникальные индексы из следующего урока.

Почему после $unwind счётчик заказов завышен?

$unwind дублирует заказ на каждую позицию, и $sum: 1 считает строки, а не заказы. Считайте заказы до разворота — отдельным конвейером без $unwind, — либо собирайте идентификаторы через $addToSet и берите размер массива уже на стороне приложения.

Термины курса

Понятия, на которых строится этот курс, — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылок на практику.

Следующая ступень учебного пути

zerolog25 уроков~12 чпрактика25 уроков уже доступны

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос