Каталог, который доживёт до итогового проекта курса, начинается с классической схемы: заказы лежат в коллекции 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 не вызовет ошибку — сервер просто соберёт пустые массивы, поэтому этап связывания проверяют на двух документах раньше, чем вешают на живой трафик.
Заказы с позициями одним конвейером
GOtype 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 | $unwind | true — не выбрасывать документы с пустым или отсутствующим массивом |
let и pipeline: продвинутая форма#
Простая форма проверяет только равенство двух полей. Как только условие становится сложнее — позиции за период, совпадение по двум полям сразу, агрегат по чужой коллекции, — включается форма с let и pipeline. let объявляет переменные из входного документа: let: bson.M с парой order и $_id. Внутренний pipeline — настоящий конвейер над чужой коллекцией, и переменные доступны в нём через двойной доллар: $order. Первым этапом внутри обычно стоит $match с $expr — оператор сравнивает поля чужого документа с объявленными переменными, потому что обычный синтаксис фильтров переменные не видит. Дальше — что угодно: $project отрежет лишние поля до пересылки, $group свернёт позиции прямо внутри связки и вернёт по одной строке на заказ вместо тысячи элементов массива. Экономия ощутимая на обоих концах: меньше памяти на выходе и меньше работы на последующих этапах. Ограничения той же природы, что у простой формы: from в текущей базе, граница 16 МБ на документ-результат, а сложные сравнения $expr оптимизатор исполняет не всегда предсказуемо — профиль лучше проверить explain-ом, чем угадывать на глаз. Переменные let принимают любые выражения над входным документом: дату из поля, элемент массива по индексу, результат конкатенации — всё, что сервер умеет вычислить до запуска внутреннего конвейера.
Дневной отчёт стабильно показывал на сотню заказов меньше, чем платёжка. Виновник — $unwind без опции: заказы, оформленные до добавления позиций, имели пустой items и выбрасывались этапом ещё до группировки. Правило для ревью: если после $unwind цифры занижены, то первым делом проверяйте, куда делись документы с пустым массивом, — и ставьте preserveNullAndEmptyArrays: true либо $match, который честно объявит, что заказы без позиций в отчёте не нужны. Оба решения законны, а молчаливое выпадение — нет: тест на наборе из двух документов, один из которых пустой, ловит его за миллисекунды.
Продажи по товарам: три этапа в связке
GOpipeline := 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 — считать сумму и среднее по позициям можно ещё в связке, — либо переносом связывания на этап записи, то есть вложением, когда жизненные циклы данных это позволяют. Джойн в документной базе реален, просто оплачивается в другом месте — и лучше до продакшена. Число для калибровки простое: подзапрос с индексом стоит спуск по дереву, без — чтение всей чужой коллекции; на сотне документов разницы нет, на миллионе она измеряется секундами.
Панель заказов открывалась восемь секунд при пяти тысячах документов. Профиль показал: $lookup подбирает позиции из order_items, на котором нет индекса по order_id, — каждый подзапрос сканировал коллекцию целиком. Если после добавления $lookup запрос деградировал, то начинайте с explain в mongosh: в плане будет виден скан вместо поиска по дереву, а лечится это индексом на foreignField — тем же CreateOne, что и для обычных фильтров.
Соберите этап связывания
Свяжите заказы с коллекцией позиций order_items по полю order_id и соберите результат в массив items.
Соберите метод со связыванием
Расставьте строки метода: конвейер со связыванием, запуск агрегации, обработка ошибки, декодирование.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: joins в MongoDB
Связывание готово: простая форма для равенства, 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, чтобы сервер возвращал по одной свёрнутой строке на заказ, и сравните размер декодированных структур.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли сделать join через Find, без агрегации?
Нет: связывание коллекций в MongoDB существует только внутри конвейера агрегации. Если связь нужна на каждый запрос и данные живут вместе, вложите документы — массив позиций внутри заказа снимает вопрос вовсе. Для независимых коллекций выбор один: $lookup на сервере или два запроса и склейка в Go, что дороже по сети и по коду.
Чем $lookup отличается от JOIN в SQL?
Смысл тот же, форма другая: результат — массив в поле as, а не плоские строки, и оптимизатор не протаскивает джойн через конвейер. Проверять существование связи база не станет: внешних ключей в MongoDB нет, целостность держит приложение или уникальные индексы из следующего урока.
Почему после $unwind счётчик заказов завышен?
$unwind дублирует заказ на каждую позицию, и $sum: 1 считает строки, а не заказы. Считайте заказы до разворота — отдельным конвейером без $unwind, — либо собирайте идентификаторы через $addToSet и берите размер массива уже на стороне приложения.
Термины курса
Понятия, на которых строится этот курс, — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылок на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос