Раздел 36 · MongoDB с нуля

$facet, $bucket, $graphLookup

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы соединяли коллекции и разворачивали массивы. Каждый конвейер давал один отчёт. Сегодня три этапа для задач, которые обычным конвейером решаются неуклюже: несколько отчётов за один проход, раскладка значений по диапазонам и обход дерева произвольной глубины. Примеры на учебной базе магазина.

Витрина магазина

Открой каталог любого интернет-магазина. Слева фильтры: жанры с числом книг в каждом, ценовые диапазоны «до 500», «от 500 до 1000». Наверху «найдено 9 книг». В центре сами книги. Вверху страницы путь «Книги → Художественная → Фантастика».

Всё это разные запросы к одним и тем же данным. Можно отправить серверу четыре запроса, можно один. Сегодняшние этапы как раз для такой витрины.

$facet: несколько отчётов за один проход

$facet берёт поток документов и пускает его сразу в несколько подконвейеров. Каждый подконвейер работает со своей копией потока и не видит соседей. На выходе один документ, где под каждым именем лежит массив с результатом своего подконвейера.

Данные для витрины каталога:

db.books.aggregate([
  { $facet: {
      total: [{ $count: 'books' }],
      byGenre: [{ $group: { _id: '$genre', count: { $sum: 1 } } }, { $sort: { count: -1, _id: 1 } }],
      cheapest: [
        { $match: { price: { $exists: true } } },
        { $sort: { price: 1 } },
        { $limit: 2 },
        { $replaceWith: { title: '$title', price: '$price' } }
      ]
  } }
])
[
  {
    total: [ { books: 9 } ],
    byGenre: [
      { _id: 'повесть', count: 3 },
      { _id: 'роман', count: 2 },
      { _id: 'учебник', count: 2 },
      { _id: 'фантастика', count: 2 }
    ],
    cheapest: [
      { title: 'Шинель', price: 250 },
      { title: 'Собачье сердце', price: 390 }
    ]
  }
]

Три отчёта, один запрос, один проход по коллекции. Обрати внимание: даже total, где одно число, лежит в массиве. Каждое поле $facet это результат конвейера, а конвейер всегда возвращает список.

Три правила $facet:

  • Общий фильтр ставь до него. Если на витрине включён фильтр «только российские авторы», $match идёт перед $facet, и все подконвейеры получат уже отобранные книги.
  • Внутри индексы не работают. Подконвейеры получают документы от предыдущего этапа, а не читают коллекцию сами. Тяжёлые выборки выгоднее делать отдельными запросами с индексами.
  • Результат это один документ. А документ в MongoDB не может быть больше 16 МБ, как мы помним из урока про встраивание. Не выводи через $facet все книги каталога, только верх списка.

$bucket: раскладка по корзинам

Ценовой фильтр на витрине: сколько книг до 500 рублей, сколько от 500 до 1000, сколько дороже. Одним $group это не решить: группировка идёт по точному значению, а нам нужны диапазоны.

$bucket раскладывает документы по диапазонам, которые ты задаёшь массивом границ:

db.books.aggregate([
  { $bucket: {
      groupBy: '$price',
      boundaries: [0, 500, 1000, 5000],
      default: 'без цены',
      output: { books: { $sum: 1 }, titles: { $push: '$title' } }
  } }
])
[
  {
    _id: 0,
    books: 3,
    titles: [ 'Собачье сердце', 'Пикник на обочине', 'Шинель' ]
  },
  {
    _id: 500,
    books: 3,
    titles: [ 'Мастер и Маргарита', 'Трудно быть богом', 'Мёртвые души' ]
  },
  {
    _id: 1000,
    books: 2,
    titles: [ 'Грокаем алгоритмы', 'Совершенный код' ]
  },
  { _id: 'без цены', books: 1, titles: [ 'Нос' ] }
]

Разберём параметры:

  • boundaries: [0, 500, 1000, 5000] даёт три корзины: от 0 до 500, от 500 до 1000, от 1000 до 5000. Нижняя граница входит в корзину, верхняя нет. Книга ровно за 500 рублей попала бы во вторую.
  • _id у корзины это её нижняя граница.
  • default собирает всё, что не попало ни в одну корзину. Здесь это «Нос» без цены. Без default такой документ вызвал бы ошибку.
  • output работает как аккумуляторы в $group. Если его не указать, будет только count.

$bucket годится не только для цен. Книги по векам:

db.books.aggregate([
  { $bucket: { groupBy: '$year', boundaries: [1800, 1900, 2000, 2100], default: 'неизвестно', output: { books: { $sum: 1 } } } }
])
[
  { _id: 1800, books: 3 },
  { _id: 1900, books: 5 },
  { _id: 2000, books: 1 }
]

Когда границы придумывать не хочется, есть $bucketAuto: ты говоришь, сколько корзин нужно, а сервер подбирает границы так, чтобы документы легли поровну:

db.books.aggregate([
  { $bucketAuto: { groupBy: '$pages', buckets: 3 } }
])
[
  { _id: { min: 48, max: 224 }, count: 3 },
  { _id: { min: 224, max: 352 }, count: 3 },
  { _id: { min: 352, max: 896 }, count: 3 }
]

Корзины вышли некруглыми, зато по три книги в каждой. Для фильтра на витрине чаще берут $bucket с понятными людям границами, а $bucketAuto для аналитики.

$bucket и $facet часто идут вместе: ценовые корзины становятся одним из подконвейеров витрины.

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.