Содержание урока · 10 разделов
Отчёт для панели менеджера: сумма продаж по категориям за месяц. Первая мысль — прочитать все заказы курсором из седьмого урока и посчитать в Go. На тысяче документов работает, на миллионе превращается в перекачку десятков мегабайт по сети и в гору полей, выброшенных сразу после суммирования. Агрегации решают задачу иначе: конвейер этапов исполняется на сервере, и до приложения доезжает только результат — несколько строк отчёта вместо миллиона заказов. В этом уроке — базовая пара этапов, которая закрывает большинство отчётных задач: $match сужает выборку тем же синтаксисом, что и фильтры Find, $group сворачивает документы в суммы, средние и подсчёты, а $sort придаёт отчёту форму. Соберём конвейер в mongo.Pipeline, прочитаем результат через cursor.All и разберём места, где конвейер ошибается молча. Агрегации заодно отделяют «умею CRUD» от «умею в базу»: конвейер ещё вернётся дважды — в Change Streams девятнадцатого урока фильтром событий и в разборе стоимости запросов двадцать четвёртого.
Конвейер: этапы вместо одного запроса#
Конвейер агрегации — список этапов, где выход одного становится входом следующего. В драйвере он выражается типом mongo.Pipeline — срезом bson.D: каждый элемент — упорядоченный документ с одним ключом-этапом, и именно упорядоченность делает bson.D здесь обязательным. Вызов coll.Aggregate(ctx, pipeline) возвращает знакомый *Cursor из седьмого урока — читается он теми же Next, All и Err, поэтому всё, что вы знаете о курсорах, работает. Порядок этапов значим: $match до $group — это WHERE до GROUP BY, $match после — аналог HAVING. Пришедшим из SQL с pgx карта соответствий знакома: фильтр — WHERE, группировка — GROUP BY, накопители — агрегатные функции SUM и AVG, сортировка этапа — ORDER BY. Ключевое отличие — место вычислений: свёртка происходит на сервере базы, поэтому конвейер проектируют от объёмов данных: сначала сузить, потом сворачивать и только в конце придавать форму отчёту. Тип mongo.Pipeline — псевдоним обычного среза bson.D: собрать конвейер можно и без псевдонима, разницы для драйвера нет — имя существует ради читаемости. На каждый документ конвейера действует правило одного ключа: попытка уложить два этапа в один bson.D упадёт с ошибкой сервера «этап обязан содержать ровно одно поле». Карту соответствий полезно держать в голове и при чтении чужих конвейеров: увидел $group с _id — это GROUP BY, увидел накопитель с долларом — вызов функции над группой.
Отчёт по категориям одним конвейером
GOcoll := client.Database("shop").Collection("orders")
pipeline := mongo.Pipeline{
bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}},
bson.D{{Key: "$group", Value: bson.M{
"_id": "$category",
"orders": bson.M{"$sum": 1},
"revenue": bson.M{"$sum": "$price"},
"avg": bson.M{"$avg": "$price"},
}}},
bson.D{{Key: "$sort", Value: bson.D{{Key: "revenue", Value: -1}}}},
}
cursor, err := coll.Aggregate(ctx, pipeline)
if err != nil {
return fmt.Errorf("category report: %w", err)
}mongo.Pipeline — срез bson.D: каждый этап сам по себе упорядоченный документ. $match сужает выборку до оплаченных заказов и опирается на индексы; $group собирает по категории: количество через $sum: 1, выручка и средний чек — через ссылки на поля с $. $sort выстраивает отчёт по выручке.
$match: фильтр на входе конвейера#
Этап $match использует тот же синтаксис, что и фильтры из восьмого и четырнадцатого уроков: равенства, $gt, $in, $or — учить новый язык не надо. Разница в позиции и в последствиях. $match, поставленный первым, сужает выборку до группировки, и сервер может опираться на индексы — тот же план исполнения, что у обычного Find. Двигать фильтр после $group бессмысленно и дорого: сначала свернули миллион документов, потом отфильтровали итог — вся работа сделана впустую. Законен и второй $match после группировки — так выглядит HAVING из SQL: «только категории с выручкой больше ста тысяч». Практическое правило mongo aggregation go-кода: каждый $match в начале конвейера экономит работу всех последующих этапов, поэтому условия выносят как можно раньше — из всех оптимизаций агрегаций эта даёт самый предсказуемый эффект и не требует ничего, кроме перестановки этапов. Условия внутрь $match уезжают без изменений: $in, $or и $regex из прошлых уроков работают здесь же. «Только оплаченные за март» — один $match с $gte и $lt по дате и равенством статуса — и уже после него группировка считает в разы меньше. Оптимизатор сервера умеет сам протаскивать $match ближе к началу конвейера, когда это безопасно, но рассчитывать на это не стоит: перестановка этапов руками бесплатна и предсказуема.
Накопители $group на каждый день#
| Накопитель | Что считает | Пример значения |
|---|---|---|
| $sum | сумма или счётчик при 1 | bson.M{"$sum": "$price"} / {"$sum": 1} |
| $avg | среднее по числовым значениям | bson.M{"$avg": "$price"} |
| $min / $max | минимум и максимум группы | bson.M{"$max": "$price"} |
| $push | собирает значения в массив | bson.M{"$push": "$sku"} |
| $first / $last | значение из первого или последнего документа группы | bson.M{"$first": "$created_at"} |
$group: ключ и накопители#
Этап $group описывается документом, где _id — ключ группировки, а остальные поля — накопители. Ссылка на поле входного документа начинается с $: _id: "$category" сворачивает заказы по категориям. nil в _id — одна глобальная группа: все входные документы становятся единственным итогом, так считают общую выручку или средний чек по выборке. Накопители — операции свёртки: $sum и $avg для арифметики, $min и $max для границ, $push для сбора значений в массив. Конструкция $group в mongodb повторяет GROUP BY с одной оговоркой: накопитель с $sum: 1 — привычный счётчик документов, единица за каждый вход. Типовая ошибка — забыть $ перед именем поля: {"$sum": "price"} получает литеральную строку вместо ссылки, $sum игнорирует нечисловые значения, и отчёт молча печатает нули. Имя накопителя вы выбираете сами: revenue, avg, orders — оно станет ключом выходного документа и полем отчётной структуры. Порядок полей в этапе отражается в выходном документе: _id первым — удобно и в логах, и при просмотре в mongosh. Отдельный сахар — этап $count: {$count: "total"} заменяет связку $group с _id: nil и $sum: 1, когда нужно просто число совпадений. Это именно этап, а не накопитель: внутрь $group его не положишь. Родственник $push — $addToSet: собирает те же значения в массив, но дубликаты пропускает.
В отчёте выручка всегда показывала ноль. Причина — одна буква: {"$sum": "price"} вместо {"$sum": "$price"}. Накопитель получил строковый литерал, а $sum игнорирует нечисловые значения — сервер вернул ноль без ошибки и предупреждения. Отладка конвейеров начинается с проверки каждого $: ссылка на поле обязана начинаться с доллара, иначе это просто строка. Хорошая привычка — автотест на минимальном наборе из двух документов: отчёт из двух строк проверяется за миллисекунды и ловит все забытые доллары.
Результат: курсор и структуры отчёта#
Aggregate возвращает (*Cursor, error) — живой курсор с батчами, как у Find. Для отчётов чаще хватает cursor.All: он декодирует весь результат в срез и закрывает курсор сам — механика седьмого урока без изменений. Форму результата задают этапы: _id несёт ключ группировки, накопители — свои имена. Для стабильных отчётов заводите структуру с bson-тегами из пятого урока: тег _id у поля Category, теги revenue и avg у накопителей — компилятор, автодополнение и типы вместо скролла по bson.M. Для разовых скриптов bson.M честнее: отчёт меняется каждую неделю, и структуры за ним не угоняются. Типы при декодировании стоят отдельного внимания: $avg возвращает float64 даже для целых входов, поэтому поле отчёта — float64, иначе Decode вернёт ошибку несовместимости; счётчик $sum: 1 сервер отдаёт целым числом, и поле int64 принимает его без потерь — драйвер декодирует и int32, и int64. Ошибка Decode при несовместимости типов поднимается из cursor.All и падает наружу с контекстом через fmt.Errorf. Для длинных отчётов курсор можно читать построчно через Next — механика седьмого урока: строка декодировалась, обработалась, забылась; для типовых панельных отчётов All проще и безопаснее.
Чтение отчёта через cursor.All
GOtype CategoryReport struct {
Category string `bson:"_id"`
Orders int64 `bson:"orders"`
Revenue int64 `bson:"revenue"`
Avg float64 `bson:"avg"`
}
var report []CategoryReport
if err := cursor.All(ctx, &report); err != nil {
return fmt.Errorf("decode report: %w", err)
}
for _, r := range report {
fmt.Printf("%s: %d заказов, %.2f средний
", r.Category, r.Orders, r.Avg)
}Результат конвейера — курсор, как у Find: cursor.All декодирует всё в срез отчётных структур и закрывает курсор сам. Ключ группировки приезжает в поле _id, поэтому тег _id стоит у Category. bson.M без структуры тоже законен для разовых отчётов — но теги дают типы и автодополнение.
Правила конвейера одним списком#
- $match первым: индексы работают только до группировки.
- Внутри $group ключ — _id, ссылка на поле начинается с $: "$category".
- $sum: 1 считает документы; $sum без $ в ссылке молча считает ноль.
- Результат — курсор: All для отчётов, цикл Next для потоковой обработки.
- Составной ключ _id: bson.M{...} даёт статистику по двум и более полям.
- $sort, $limit и $project — следующие этапы, придающие отчёту форму.
Проверка: _id со значением nil
Что означает _id: nil в этапе $group?
Проверка: позиция $match
Почему этап $match стараются ставить первым в конвейере?
Что напечатает итоговая группа?
Прочитайте код и выберите, что он выведет:
// три оплаченных заказа: price 100, 200, 700
pipeline := mongo.Pipeline{
bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}},
bson.D{{Key: "$group", Value: bson.M{
"_id": nil,
"count": bson.M{"$sum": 1},
"total": bson.M{"$sum": "$price"},
}}},
}
// cursor.All декодировал отчёт
fmt.Println(report[0].Count, report[0].Total)Составной ключ и память конвейера#
Ключ _id: bson.M{"category": "$category", "day": "$day"} строит статистику по пересечению полей — отчёт по категориям за каждый день. Порядок полей составного ключа не меняет смысл группировки, но отражается в виде выходного документа. Двумя границами сервера стоит знать заранее. Первая: документ группы — обычный BSON-документ с лимитом 16 МБ; суммам и средним это не грозит, а вот $push, собирающий все sku огромной группы, до потолка доберётся. Промежуточные документы конвейера подчиняются тому же лимиту в 16 МБ, что и хранимые, — поэтому длинный $push доберётся до границы раньше, чем память этапа. Лечится гранулярностью: в группу агрегируйте числа, а списки режьте проекцией с $slice после группировки. Вторая: память конвейера по умолчанию ограничена сотней мегабайт на этап — крупные свёртки падают с ошибкой, и тогда включают разрешение на диск: options.Aggregate().SetAllowDiskUse(true). Это не «сделать быстрее», а «сделать возможным»: обмен с временными файлами медленнее памяти, и лучшее лечение — сузить $match, чтобы большие свёртки вообще не возникали.
options.Aggregate().SetAllowDiskUse(true) разрешает этапам писать временные файлы, когда сотни мегабайт на этап не хватило. Включайте для тяжёлых отчётных задач, а не для панельных запросов: обмен с диском заметно медленнее, и частый allowDiskUse — сигнал добавить индекс под $match или пересмотреть гранулярность группировки. Поведение зависит и от версии сервера: тяжёлый отчёт тестируйте на той версии, что стоит на проде, а не на ближайшей под рукой.
Соберите конвейер отчёта
Восстановите этапы: фильтр оплаченных, группировка по категории и сумма по цене.
Соберите метод отчёта
Расставьте строки метода по порядку: конвейер, вызов Aggregate, обработка ошибки, декодирование отчёта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: агрегации
Базовая пара этапов готова: $match сужает, $group сворачивает, cursor.All доставляет отчёт в структуры. Дальше конвейер растёт вглубь: в уроке про $lookup и $unwind соберём join заказов с позициями — то, ради чего агрегации в MongoDB и берут. Результат конвейера живёт в курсоре — правила урока про Find и курсор работают без поправок, а условия $match — это фильтры сложного уровня без изменений. Тем, кто пришёл из SQL, полезно прогнать один отчёт параллельно: то же GROUP BY в pgx и тот же $group здесь — разница в месте вычислений станет наглядной. Составные конвейеры проверяйте поэтапно: добавьте один этап в mongosh, посмотрите промежуточный вывод, добавьте следующий — сервер назовёт этап, на котором ошибся, и виновник находится сразу. Проверять конвейеры удобнее на живой базе: песочница сайта — тематическая программа на чистом Go, поэтому соберите отчёт локально на десятке документов и сравните вывод с ручным подсчётом в mongosh.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Конвейер — mongo.Pipeline из bson.D: каждый этап один ключ, порядок этапов значим.
- $match первым опирается на индексы; $match после $group — аналог HAVING.
- В $group ключ — _id: ссылка на поле с $ или nil для глобальной группы.
- $sum: 1 считает документы, $sum: "$price" складывает поле; забытый $ даёт молчаливый ноль.
- $avg возвращает float64 даже для целых входов — типы отчётной структуры подбираются под накопители.
- Результат — курсор: cursor.All декодирует срез и закрывает курсор сам.
- Лимит памяти конвейера — сотня мегабайт на этап; тяжёлые свёртки требуют SetAllowDiskUse(true).
- $count — отдельный этап-сахар над $group с $sum: 1, когда нужно только число.
Домашнее задание
Соберите два отчёта на живой базе: выручка по категориям с сортировкой по убыванию и статистика по дням с составным ключом категория плюс дата. Затем сломайте конвейер нарочно: уберите $ из ссылки на поле и посмотрите, как отчёт станет нулевым без ошибки, — после этого проверка долларов в $group станет рефлексом. Для каждого отчёта выведите первый элемент среза и сверьте с ручным подсчётом в mongosh — расхождение ловится на десятке документов.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли считать агрегацию в Go вместо конвейера?
Можно — прочитать выборку курсором и свернуть циклом. На малых объёмах это честный вариант, на больших — перекачка данных, которые сервер отфильтровал бы и свернул сам. Правило границ: если результат меньше выборки на порядок, конвейер выгоден всегда. Промежуточные данные, которые сервис сам пересчитывает, можно забирать сырыми — отчёт для человека собирайте на сервере.
Чем $group в mongodb отличается от GROUP BY?
Смысл тот же: сведение строк по ключу с агрегатными функциями. Отличия в деталях: ключ — это _id в этапе $group, накопители пишутся документами ({"$sum": "$price"}), а не функциями, и свёртка всегда происходит на сервере базы, а не в приложении.
Почему отчёт по $avg не декодируется в int64?
$avg возвращает float64 даже когда входы целые: среднее по 1 и 2 — 1.5. Поле отчётной структуры должно быть float64, иначе Decode вернёт ошибку несовместимости типов. Для отображения округляйте на слое представления, а не в базе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос