---
title: "Агрегации: $match и $group"
description: "Агрегации в mongo из Go: конвейер $match и $group в coll.Aggregate, накопители $sum и $avg, составные ключи $group mongodb и чтение результата через cursor.All."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/15-agregacii-bazovye/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Агрегации: $match и $group

*Курс «MongoDB в Go: официальный драйвер mongo-driver v2», урок 15 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/mongo-driver/lessons/15-agregacii-bazovye/*

Агрегации в mongo из Go — это конвейер: этап $match фильтрует документы, этап $group сворачивает их в суммы, средние и подсчёты. Разберём coll.Aggregate с mongo.Pipeline, порядок этапов, накопители $sum и $avg, составные ключи — и как читать результат через cursor.All. Заодно возьмём приём, который экономит часы отладки: конвейер сначала гоняют в mongosh и лишь потом переносят в Go без изменений.

Отчёт для панели менеджера: сумма продаж по категориям за месяц. Первая мысль — прочитать все заказы курсором из седьмого урока и посчитать в 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, увидел накопитель с долларом — вызов функции над группой.

### Отчёт по категориям одним конвейером

```go
coll := 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 без доллара.** В отчёте выручка всегда показывала ноль. Причина — одна буква: {"$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

```go
type 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?

- Группировка по полю _id документов
- ✅ Одна глобальная группа: все документы свернутся в единственный итог
- Ошибка: ключ _id не может быть nil
- Пропуск группировки вовсе

nil в ключе собирает все входные документы в одну группу — так считают общую выручку или средний чек по выборке. Для разбиения по полю ключом становится ссылка: _id: "$category".

### Проверка: позиция $match

**Вопрос:** Почему этап $match стараются ставить первым в конвейере?

- Синтаксис $group не работает без $match
- ✅ Первый $match опирается на индексы и режет объём до группировки
- Порядок этапов в конвейере не важен
- $match после $group запрещён сервером

Первый $match по форме совпадает с обычным фильтром Find, и сервер использует для него индексы. После $group данные уже свёрнуты: второй $match работает как HAVING по агрегатам, но индексов там уже нет — фильтруйте до, а не после.

### Что напечатает итоговая группа?

```go
// три оплаченных заказа: 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)
```

**Что выведет программа?**

- 1 1000
- ✅ 3 1000
- 3 700
- 1 700

$sum: 1 считает документы — три заказа; $sum: "$price" складывает значения поля — 1000. Глобальная группа с _id: nil даёт ровно один элемент отчёта, поэтому в срезе одна строка с двумя накопителями.

### Составной ключ и память конвейера

Ключ _id: bson.M{"category": "$category", "day": "$day"} строит статистику по пересечению полей — отчёт по категориям за каждый день. Порядок полей составного ключа не меняет смысл группировки, но отражается в виде выходного документа. Двумя границами сервера стоит знать заранее. Первая: документ группы — обычный BSON-документ с лимитом 16 МБ; суммам и средним это не грозит, а вот $push, собирающий все sku огромной группы, до потолка доберётся. Промежуточные документы конвейера подчиняются тому же лимиту в 16 МБ, что и хранимые, — поэтому длинный $push доберётся до границы раньше, чем память этапа. Лечится гранулярностью: в группу агрегируйте числа, а списки режьте проекцией с $slice после группировки. Вторая: память конвейера по умолчанию ограничена сотней мегабайт на этап — крупные свёртки падают с ошибкой, и тогда включают разрешение на диск: options.Aggregate().SetAllowDiskUse(true). Это не «сделать быстрее», а «сделать возможным»: обмен с временными файлами медленнее памяти, и лучшее лечение — сузить $match, чтобы большие свёртки вообще не возникали.

> **allowDiskUse: когда включать.** options.Aggregate().SetAllowDiskUse(true) разрешает этапам писать временные файлы, когда сотни мегабайт на этап не хватило. Включайте для тяжёлых отчётных задач, а не для панельных запросов: обмен с диском заметно медленнее, и частый allowDiskUse — сигнал добавить индекс под $match или пересмотреть гранулярность группировки. Поведение зависит и от версии сервера: тяжёлый отчёт тестируйте на той версии, что стоит на проде, а не на ближайшей под рукой.

### Соберите конвейер отчёта

Восстановите этапы: фильтр оплаченных, группировка по категории и сумма по цене.

```go
pipeline := mongo.Pipeline{
        bson.D{{Key: "$___", Value: bson.M{"status": "paid"}}},
        bson.D{{Key: "$___", Value: bson.M{
                "_id":   "$___",
                "total": bson.M{"$___": "$price"},
        }}},
}
```

Подсказка: Первый этап — $match, свёртка — $group; ключ группировки — ссылка "$category", накопитель суммы — $sum.

Правильные ответы в пропусках: `match`, `group`, `category`, `sum`

### Соберите метод отчёта

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

Правильный порядок строк:

1. `func (r *OrderRepo) RevenueByCategory(ctx context.Context) ([]CategoryReport, error) {`
2. `  pipeline := mongo.Pipeline{`
3. `    bson.D{{Key: "$match", Value: bson.M{"status": "paid"}}},`
4. `    bson.D{{Key: "$group", Value: bson.M{"_id": "$category", "total": bson.M{"$sum": "$price"}}}},`
5. `  }`
6. `  cursor, err := r.coll.Aggregate(ctx, pipeline)`
7. `  if err != nil { return nil, err }`
8. `  var out []CategoryReport`
9. `  return out, cursor.All(ctx, &out)`
10. `}`

### Карточки: агрегации

- **Конвейер агрегации** — Список этапов, выход одного — вход следующего. В Go — mongo.Pipeline ([]bson.D); каждый этап — bson.D с одним ключом вроде $match или $group.
- **$match** — Тот же синтаксис, что и фильтры Find. Первым этапом опирается на индексы; после $group превращается в аналог HAVING.
- **$group** — Документ с _id-ключом группировки и накопителями: _id: "$category", накопители $sum, $avg, $min, $max, $push. _id: nil — глобальная группа.
- **$sum и $avg** — $sum: 1 — счётчик документов, $sum: "$price" — сумма поля; $avg возвращает float64 всегда. Без $ перед именем поля накопитель считает ноль.
- **cursor.All** — Декодирует результат Aggregate в срез структур с bson-тегами и закрывает курсор сам. _id несёт ключ группировки — тег _id у поля структуры.
- **SetAllowDiskUse(true)** — Опция Aggregate: разрешает этапам писать временные файлы при превышении лимита памяти. Для тяжёлых отчётов, а не для горячих путей.

Базовая пара этапов готова: $match сужает, $group сворачивает, cursor.All доставляет отчёт в структуры. Дальше конвейер растёт вглубь: в уроке про [$lookup и $unwind](/courses/mongo-driver/lessons/16-agregacii-truby/) соберём join заказов с позициями — то, ради чего агрегации в MongoDB и берут. Результат конвейера живёт в курсоре — правила [урока про Find и курсор](/courses/mongo-driver/lessons/07-find-kursor/) работают без поправок, а условия $match — это фильтры [сложного уровня](/courses/mongo-driver/lessons/14-slozhnye-filtry/) без изменений. Тем, кто пришёл из SQL, полезно прогнать один отчёт параллельно: то же GROUP BY в [pgx](/courses/pgx/) и тот же $group здесь — разница в месте вычислений станет наглядной. Составные конвейеры проверяйте поэтапно: добавьте один этап в mongosh, посмотрите промежуточный вывод, добавьте следующий — сервер назовёт этап, на котором ошибся, и виновник находится сразу. Проверять конвейеры удобнее на живой базе: песочница сайта — тематическая программа на чистом Go, поэтому соберите отчёт локально на десятке документов и сравните вывод с ручным подсчётом в mongosh.

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

- Конвейер — 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 вернёт ошибку несовместимости типов. Для отображения округляйте на слое представления, а не в базе.
