6. MapReduce и агрегация

MapReduce — модель обработки, в которой map порождает пары ключ–значение, а reduce объединяет значения одного ключа. В этом разделе на примере просмотров страниц разбирается промежуточный результат и свёртка. Это не рекомендация запускать MongoDB MapReduce в новом приложении: начиная с MongoDB 5.0 он устарел; для рабочей задачи ниже приведён aggregation pipeline.

Модель полезна для понимания преобразования и объединения данных. Возможность распараллеливания зависит от конкретной системы и реализации; сама схема map/reduce не гарантирует ускорения запроса.

Теория и практика

MapReduce — процесс двухступенчатый. Сначала делается map (отображение), затем — reduce (свёртка). На этапе отображения входные документы трансформируются (map) и порождают (emit) пары ключ=>значение (как ключ, так и значение могут быть составными). При свёртке (reduce) на входе получается ключ и массив значений, порождённых для этого ключа, а на выходе получается финальный результат. Посмотрим на оба этапа и на их выходные данные.

В нашем примере мы будем генерировать отчёт по дневному количеству хитов для какого-либо ресурса (например, веб-страницы). Это hello world для MapReduce. Для наших задач мы воспользуемся коллекцией hits с двумя полями: resource и date. Желаемый результат — это отчёт в разрезе ресурса, года, месяца, дня и количества.

Пусть в hits лежат следующие данные:

resource     date
index        Jan 20 2010 4:30
index        Jan 20 2010 5:30
about        Jan 20 2010 6:00
index        Jan 20 2010 7:00
about        Jan 21 2010 8:00
about        Jan 21 2010 8:30
index        Jan 21 2010 8:30
about        Jan 21 2010 9:00
index        Jan 21 2010 9:30
index        Jan 22 2010 5:00

На выходе мы хотим следующий результат:

resource  year   month   day   count
index     2010   1       20    3
about     2010   1       20    1
about     2010   1       21    3
index     2010   1       21    2
index     2010   1       22    1

(Прелесть данного подхода заключается в хранении результатов; отчёты генерируются быстро и рост данных контролируется — для одного ресурса в день будет добавляться максимум один документ.)

Давайте теперь сосредоточимся на понимании концепции. В конце главы в качестве примера будут приведены данные и код.

Первым делом рассмотрим функцию отображения. Задача функции отображения — породить значения, которые в дальнейшем будут использоваться при свёртке. Порождать значения можно ноль или более раз. В нашем случае — как чаще всего бывает — это всегда будет делаться один раз. Представьте, что map в цикле перебирает каждый документ в коллекции hits. Для каждого документа мы должны породить ключ, состоящий из ресурса, года, месяца и дня, и примитивное значение — единицу:

function() {
	var key = {
	    resource: this.resource, 
	    year: this.date.getUTCFullYear(), 
	    month: this.date.getUTCMonth(), 
	    day: this.date.getUTCDate()
	};
	emit(key, {count: 1}); 
}

this ссылается на текущий рассматриваемый документ. Надеюсь, результирующие данные прояснят для вас картину происходящего. При использовании наших тестовых данных, в результате получим:

{resource: 'index', year: 2010, month: 0, day: 20} => [{count: 1}, {count: 1}, {count:1}]
{resource: 'about', year: 2010, month: 0, day: 20} => [{count: 1}]
{resource: 'about', year: 2010, month: 0, day: 21} => [{count: 1}, {count: 1}, {count:1}]
{resource: 'index', year: 2010, month: 0, day: 21} => [{count: 1}, {count: 1}]
{resource: 'index', year: 2010, month: 0, day: 22} => [{count: 1}]

Понимание этого промежуточного этапа даёт ключ к пониманию MapReduce. Порождённые данные собираются в массивы по одинаковому ключу. .NET и Java разработчики могут рассматривать это как тип IDictionary<object, IList<object>> (.NET) или HashMap<Object, ArrayList> (Java).

Давайте изменим нашу map-функцию несколько надуманным способом:

function() {
	var key = {resource: this.resource, year: this.date.getUTCFullYear(), month: this.date.getUTCMonth(), day: this.date.getUTCDate()};
	if (this.resource == 'index' && this.date.getUTCHours() == 4) {
		emit(key, {count: 5});
	} else {
		emit(key, {count: 1}); 
	}
}

Первый промежуточный результат теперь изменится на:

{resource: 'index', year: 2010, month: 0, day: 20} => [{count: 5}, {count: 1}, {count:1}]

Обратите внимание, как каждый emit порождает новое значение, которое группируется по ключу.

Reduce-функция берёт каждое из этих промежуточных значений и выдаёт конечный результат. Вот так будет выглядеть наша функция:

function(key, values) {
	var sum = 0;
	values.forEach(function(value) {
		sum += value['count'];
	});
	return {count: sum};
};

На выходе получим:

{resource: 'index', year: 2010, month: 0, day: 20} => {count: 3}
{resource: 'about', year: 2010, month: 0, day: 20} => {count: 1}
{resource: 'about', year: 2010, month: 0, day: 21} => {count: 3}
{resource: 'index', year: 2010, month: 0, day: 21} => {count: 2}
{resource: 'index', year: 2010, month: 0, day: 22} => {count: 1}

Далее снова используется исходная map-функция с {count: 1} для каждого события; эксперимент с {count: 5} в расчёт итоговой таблицы не входит. Исторически в MongoDB результат выглядел так:

_id: {resource: 'index', year: 2010, month: 0, day: 20}, value: {count: 3}

Это и есть наш конечный результат.

Если вы были внимательны, вы должны были спросить себя: почему мы просто не написали sum = values.length? Это было бы эффективным подходом, если бы мы суммировали массив единиц. На деле reduce не всегда вызывается с полным и совершенным набором промежуточных данных. Например вместо того, чтобы быть вызванным с:

{resource: 'index', year: 2010, month: 0, day: 20} => [{count: 1}, {count: 1}, {count:1}]

Reduce может быть вызван с:

{resource: 'index', year: 2010, month: 0, day: 20} => [{count: 1}, {count: 1}]
{resource: 'index', year: 2010, month: 0, day: 20} => [{count: 2}, {count: 1}]

Конечный результат тот же, но вход reduce может уже содержать результаты предыдущей свёртки. Поэтому значения и результат должны оставаться совместимыми для повторного объединения; для суммирования складываются count, а не длина очередного массива. Точнее говорить о требованиях к ассоциативности и повторной редукции, а не обещать общую «идемпотентность» любой функции reduce.

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

Чистая практика

Сначала подготовьте отдельный учебный набор. Проверка имени базы намеренно останавливает пример вне learn. Очистка затронет все документы только учебной коллекции learn.hits; не выполняйте её с рабочими данными. ISO-даты с Z задают UTC однозначно.

if (db.getName() !== "learn") throw new Error("Выберите учебную базу learn");
db.hits.deleteMany({}); // только учебная коллекция learn.hits
db.hits.insertMany([
  {resource: 'index', date: ISODate('2010-01-20T04:30:00Z')},
  {resource: 'index', date: ISODate('2010-01-20T05:30:00Z')},
  {resource: 'about', date: ISODate('2010-01-20T06:00:00Z')},
  {resource: 'index', date: ISODate('2010-01-20T07:00:00Z')},
  {resource: 'about', date: ISODate('2010-01-21T08:00:00Z')},
  {resource: 'about', date: ISODate('2010-01-21T08:30:00Z')},
  {resource: 'index', date: ISODate('2010-01-21T08:30:00Z')},
  {resource: 'about', date: ISODate('2010-01-21T09:00:00Z')},
  {resource: 'index', date: ISODate('2010-01-21T09:30:00Z')},
  {resource: 'index', date: ISODate('2010-01-22T05:00:00Z')}
]);

Теперь выполните поддерживаемый aggregation pipeline. Он должен вернуть пять групп: index/20 января — 3, about/20 января — 1, about/21 января — 3, index/21 января — 2, index/22 января — 1.

db.hits.aggregate([
  {$group: {
    _id: {
      resource: "$resource",
      day: {$dateToString: {format: "%Y-%m-%d", date: "$date", timezone: "UTC"}}
    },
    count: {$sum: 1}
  }},
  {$sort: {"_id.day": 1, "_id.resource": 1}}
])

$dateToString группирует по дню UTC. Для другого календарного часового пояса нужно выбрать его явно и пересчитать ожидаемые группы.

Исторический эквивалент MapReduce

Следующий код поясняет устройство старого API, который устарел начиная с MongoDB 5.0. Для нового приложения используйте pipeline выше. В историческом ключе месяц нумеруется от 0, поэтому январю соответствует month: 0.

Теперь можно создать map и reduce функции (консоль MongoDB позволяет вводить многострочные конструкции):

var map = function() {
	var key = {resource: this.resource, year: this.date.getUTCFullYear(), month: this.date.getUTCMonth(), day: this.date.getUTCDate()};
	emit(key, {count: 1}); 
};

var reduce = function(key, values) {
	var sum = 0;
	values.forEach(function(value) {
		sum += value['count'];
	});
	return {count: sum};
};

Мы выполним команду mapReduce над коллекцией hits следующим образом:

db.hits.mapReduce(map, reduce, {out: {inline:1}})

Если вы выполните код, приведённый выше, вы увидите ожидаемый результат. Установив out в inline мы указываем, что mapReduce должен непосредственно вернуть результат в консоль. В данный момент размер результата ограничен 16 мегабайтами. Вместо этого мы могли бы написать {out: 'hit_stats'}, и результат был бы сохранён в коллекцию hit_stats:

db.hits.mapReduce(map, reduce, {out: 'hit_stats'});
db.hit_stats.find();

При историческом {out: "hit_stats"} прежнее содержимое выходной коллекции заменяется. Этот вариант допустимо изучать только в отдельной учебной базе; он не должен случайно уничтожить рабочий отчёт. {out: {merge: "hit_stats"}} имеет другую семантику — замену значений совпавших ключей. Не путайте его с накоплением счётчика поверх старого отчёта.

Третий параметр принимает дополнительные значения — например, можно сортировать, фильтровать или ограничивать анализируемые данные. Мы также можем передать метод finalize, который применится к результату возвращённому этапом reduce.

В этой главе

Здесь важны ключ группировки, промежуточные значения и корректная свёртка. Для текущей MongoDB используйте pipeline из этой главы и официальное сопоставление MapReduce и aggregation. Не переносите исторический вывод об особой важности MapReduce в выбор технологии нового проекта.

Дальше в книге7. Индексы и инструменты →
Karl Seguin · перевод @jsmarkus · CC BY-NC 3.0 и авторы