9 марта 2012 в 10:55

Новый aggregation framework в MongoDB 2.1

В релизе 2.1 было заявлена реализация такой функциональности, как новый фреймворк агрегирования данных. Хотелось бы рассказать о первых впечатлениях от этой весьма интересной штуки. Данный функционал должен позволить в некоторых местах отказаться от Map/Reduce и написания кода на JavaScript в пользу достаточно простых конструкций, предназначенных для группировки полей почти как в SQL.



Документация по новшествам расположена в соответствующем разделе официального сайта. Сначала давайте разберем то, как же это работает и какие конструкции MongoDB нам помогут.

Итак, самая главная сложность в выборке данных из MongoDB это работа с массивами и данными, содержащимися внутри каких-то отдельных элементов. Да, мы можем их выбрать как и в SQL, но не можем агрегировать по ним непосредственно при выборке. Новый фреймвок представляет собой декларативный способ работы с такими данными, основываясь на цепочке специальных операторов (их всего 7 штук). Данные выборки передаются из выхода одного оператора на вход другого, совсем как в unix. Отчасти при помощи новых операторов можно повторить уже существующие. Пусть коллекция test — это коллекция для хранения данных о людях. Стандартная выборка:

db.test.find({name: "Ivan"});

будет аналогична

db.test.aggregate({$match: {name: "Ivan"}});

Но все немного интереснее, потому что во втором примере мы можем строить цепочку обработки данных, перечисляя операторы через запятую. Для сортировки предназначен оператор $sort, например:

db.test.aggregate({$match: {name: "Ivan"}}, {$sort: {age: 1}});

Так мы веберем всех людей с именем «Ivan» и отсортируем выборку по возрасту. А для того, что бы выбрать самого старшего Ивана нам надо отсечь выборку одним элементом:

db.test.aggregate({$match: {name: "Ivan"}}, {$sort: {age: -1}}, {$limit: 1});

Вы скажете, что это повторение уже существующей фукциональности. В какой-то мере да, но мы так и не рассмотрели новые операторы, позволяющие более гибко работать с выборками. Разберем их подробнее.

Оператор $project


Предназначен для манипулирования полями, может добавлять новые, удалять и переименовывать их в документах, поступающих ему на вход. Следующая конструкция включит в поток документов (отфильтрует) только имена и возвраст пользователей:

{$project: {name: 1, age: 1}}

На вход следующего оператора попадут все документы только с двумя полями, других полей в потоке не будет (за исключением поля _id, что бы его исключить надо специально указать _id: 0). Цифра 1 включает, цифра 0 исключает передачу поля. Кроме того этот оператор позволяет переименовывать поля, «доставать» поля из вложенного объекта какого-либо поля или же добавлять новые поля на основе каких-либо вычислений.

Оператор $unwind


На мой взгляд это самый интересный оператор. Он позволяет «разворачивать» вложенные массивы на каждый элемент выборки документов. Например, пускай у нас есть следующая база людей:

db.test.insert({name: "Ivan", likes: ["Maria", "Anna"]});
db.test.insert({name: "Serge", likes: ["Anna"]});

Пусть поле likes означает какие девочки нравятся какому мальчику. Применим оператор $unwind:

db.test.aggregate({$unwind: "$likes"});

{
        "result" : [
                {
                        "_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
                        "name" : "Ivan",
                        "likes" : "Maria"
                },
                {
                        "_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
                        "name" : "Ivan",
                        "likes" : "Anna"
                },
                {
                        "_id" : ObjectId("4f598e086a8f8bc74573e9fe"),
                        "name" : "Serge",
                        "likes" : "Anna"
                }
        ],
        "ok" : 1
}

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

Оператор $group


Для удобства дополним выборку еще одним полем заполненным цифрой 1 (так проще будет суммировать):

db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}});

{
        "result" : [
                {
                        "_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
                        "name" : "Ivan",
                        "likes" : "Maria",
                        "count" : 1
                },
                {
                        "_id" : ObjectId("4f598de76a8f8bc74573e9fd"),
                        "name" : "Ivan",
                        "likes" : "Anna",
                        "count" : 1
                },
                {
                        "_id" : ObjectId("4f598e086a8f8bc74573e9fe"),
                        "name" : "Serge",
                        "likes" : "Anna",
                        "count" : 1
                }
        ],
        "ok" : 1
}

Это позволит нам использовать оператор агрегирования $sum. То есть теперь мы просто добавляем в поле number значение поля count каждый раз и группируем всю выборку по полю likes, содержающую имя девочки.

db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}}, {$group: {_id: "$likes", number: {$sum: "$count"}}});

{
        "result" : [
                {
                        "_id" : "Anna",
                        "number" : 2
                },
                {
                        "_id" : "Maria",
                        "number" : 1
                }
        ],
        "ok" : 1
}

Осталось отсортировать и ограничить вывод только одним документом:

db.test.aggregate({$unwind: "$likes"}, {$project: {name:1, likes:1, count: {$add: [1]}}}, {$group: {_id: "$likes", number: {$sum: "$count"}}}, {$sort: {number: -1}}, {$limit: 1});


{ "result" : [ { "_id" : "Anna", "number" : 2 } ], "ok" : 1 }


Наша самая популярная девочка — это Анна.

А теперь конкретный пример.



Для того, что бы чисто конкретно проникнуться новыми возможностями предположим что у нас есть коллекция, хранящая данные о животных в зоопарке и решим несколько задач по агрегированию данных. Вот наши лапы и хвосты:

db.zoo.insert({name: "Lion", ration: [{meat: 20}, {fish: 1}, {water: 30}], holidays: [1,4], staff: {like:  ["Petrovich", "Mihalich"], dislike: "Maria"}});
db.zoo.insert({name: "Tiger", ration: [{meat: 15}, {water: 25}], holidays: [6], staff: {like:  ["Petrovich", "Maria"]}});
db.zoo.insert({name: "Monkey", ration: [{banana: 15}, {water: 10}, {nuts: 1}], holidays: [2], staff: {like:  ["Anna"], dislike: "Petrovich"}});
db.zoo.insert({name: "Panda", ration: [{bamboo: 15}, {dumplings: 50}, {water: 3}], staff: {like:  ["Petrovich", "Mihalich", "Maria", "Anna"]}});

Поле name хранит имя, поле ration это массив объектов хранящих сколько и какой еды требуется зверю ежедневно, holidays это дни в которые зверь отдыхает и не показывается посетителям, staff.like — смотрители, которые ему нравятся (панды, очаровашки, любят вапще всех-всех), staff.dislike — не нравятся.

Начнем с простой выборки — только названия животных, что бы директор зоопарка не забывал кого как зовут. Тут все просто:

db.zoo.aggregate({$project: {name: 1}});

{
        "result" : [
                {
                        "_id" : ObjectId("4f58b7f627f86b11258dc70c"),
                        "name" : "Lion"
                },
                {
                        "_id" : ObjectId("4f58b86027f86b11258dc70d"),
                        "name" : "Tiger"
                },
                {
                        "_id" : ObjectId("4f58b90c27f86b11258dc70e"),
                        "name" : "Monkey"
                },
                {
                        "_id" : ObjectId("4f58b98727f86b11258dc70f"),
                        "name" : "Panda"
                }
        ],
        "ok" : 1
}


Каких зверей надо бояцца?


Бояться надо хищников. А хищник это тот, у кого в рационе есть мясо. Давайте их найдем. Для начала отфильтруем поток и выделим только два поля в документах — имя и рацион.

db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}});

{
        "result" : [
                {
                        "name" : "Lion",
                        "ration" : [
                                {
                                        "meat" : 20
                                },
                                {
                                        "fish" : 1
                                },
                                {
                                        "water" : 30
                                }
                        ]
                },
                {
                        "name" : "Tiger",
                        "ration" : [
                                {
                                        "meat" : 15
                                },
                                {
                                        "water" : 25
                                }
                        ]
                },
                {
                        "name" : "Monkey",
                        "ration" : [
                                {
                                        "banana" : 15
                                },
                                {
                                        "water" : 10
                                },
                                {
                                        "nuts" : 1
                                }
                        ]
                },
                {
                        "name" : "Panda",
                        "ration" : [
                                {
                                        "bamboo" : 15
                                },
                                {
                                        "dumplings" : 50
                                },
                                {
                                        "water" : 3
                                }
                        ]
                }
        ],
        "ok" : 1
}


Затем развернем массив рациона на элементы основного массива:

db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"});

{
        "result" : [
                {
                        "name" : "Lion",
                        "ration" : {
                                "meat" : 20
                        }
                },
                {
                        "name" : "Lion",
                        "ration" : {
                                "fish" : 1
                        }
                },
                {
                        "name" : "Lion",
                        "ration" : {
                                "water" : 30
                        }
                },
                {
                        "name" : "Tiger",
                        "ration" : {
                                "meat" : 15
                        }
                },
                {
                        "name" : "Tiger",
                        "ration" : {
                                "water" : 25
                        }
                },
                {
                        "name" : "Monkey",
                        "ration" : {
                                "banana" : 15
                        }
                },
                {
                        "name" : "Monkey",
                        "ration" : {
                                "water" : 10
                        }
                },
                {
                        "name" : "Monkey",
                        "ration" : {
                                "nuts" : 1
                        }
                },
                {
                        "name" : "Panda",
                        "ration" : {
                                "bamboo" : 15
                        }
                },
                {
                        "name" : "Panda",
                        "ration" : {
                                "dumplings" : 50
                        }
                },
                {
                        "name" : "Panda",
                        "ration" : {
                                "water" : 3
                        }
                }
        ],
        "ok" : 1
}


Далее отфильтруем выборку только по тем полям, где есть поле ration.meat

db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"}, {$match: {"ration.meat": {$exists: true}}});

{
        "result" : [
                {
                        "name" : "Lion",
                        "ration" : {
                                "meat" : 20
                        }
                },
                {
                        "name" : "Tiger",
                        "ration" : {
                                "meat" : 15
                        }
                }
        ],
        "ok" : 1
}


И окончательный вывод только имени хищника

db.zoo.aggregate({$project: {name: 1, _id: 0, ration: 1}}, {$unwind: "$ration"}, {$match: {"ration.meat": {$exists: true}}}, {$project: {name: 1, _id: 0}});

{
        "result" : [
                {
                        "name" : "Lion"
                },
                {
                        "name" : "Tiger"
                }
        ],
        "ok" : 1
}


В какие дни отдыхает хотя бы один зверь?


Для этого «расслоим» массив holidays на весь массив зверей (панда как обычно доступна всем и всегда).

db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"});

{
        "result" : [
                {
                        "_id" : ObjectId("4f58b7f627f86b11258dc70c"),
                        "name" : "Lion",
                        "holidays" : 1
                },
                {
                        "_id" : ObjectId("4f58b7f627f86b11258dc70c"),
                        "name" : "Lion",
                        "holidays" : 4
                },
                {
                        "_id" : ObjectId("4f58b86027f86b11258dc70d"),
                        "name" : "Tiger",
                        "holidays" : 6
                },
                {
                        "_id" : ObjectId("4f58b90c27f86b11258dc70e"),
                        "name" : "Monkey",
                        "holidays" : 2
                },
                {
                        "_id" : ObjectId("4f58b98727f86b11258dc70f"),
                        "name" : "Panda"
                }
        ],
        "ok" : 1
}


И отфильтруем только те, где поле holidays это число большее -1 (ну или 0, кому как удобнее)

db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"},{$match: {holidays : {$gt: -1}}});

{
        "result" : [
                {
                        "_id" : ObjectId("4f58b7f627f86b11258dc70c"),
                        "name" : "Lion",
                        "holidays" : 1
                },
                {
                        "_id" : ObjectId("4f58b7f627f86b11258dc70c"),
                        "name" : "Lion",
                        "holidays" : 4
                },
                {
                        "_id" : ObjectId("4f58b86027f86b11258dc70d"),
                        "name" : "Tiger",
                        "holidays" : 6
                },
                {
                        "_id" : ObjectId("4f58b90c27f86b11258dc70e"),
                        "name" : "Monkey",
                        "holidays" : 2
                }
        ],
        "ok" : 1
}


Уберем все лишнее.

db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"},{$match: {holidays : {$gt: -1}}}, {$project: {holidays: 1, _id: 0}});

{
        "result" : [
                {
                        "holidays" : 1
                },
                {
                        "holidays" : 4
                },
                {
                        "holidays" : 6
                },
                {
                        "holidays" : 2
                }
        ],
        "ok" : 1
}


Сколько продуктов в день необходимо закупать.


Самая интересная, на мой взгляд, задача. Для ее реализации вспомним, что $project умеет создавать поля и создадим поле meat со значением свойства meat.

db.zoo.aggregate({$project: {ration: 1, _id: 0}}, {$unwind: "$ration"}, {$project: {ration: 1, meat: "$ration.meat", _id: 0}});


Если этого поля в свойствах рациона животного нет, то оно создано не будет. Вот пример части выборки:

{
        "result" : [
                {
                        "ration" : {
                                "meat" : 20
                        },
                        "meat" : 20
                },
                {
                        "ration" : {
                                "fish" : 1
                        }
                },
                {
                        "ration" : {
                                "water" : 30
                        }
                },
...
}


Поступим таким образом для всех типов еды и уберем вывод самого объекта ration:

db.zoo.aggregate({$project: {ration: 1}}, {$unwind: "$ration"}, {$project: {ration: 0, _id: 0, meat: "$ration.meat", fish: "$ration.fish", water: "$ration.water", banana: "$ration.banana", bamboo: "$ration.bamboo", nuts: "$ration.nuts", dumplings: "$ration.dumplings", _id: 0}});


в результате получим

{
        "result" : [
                {
                        "_id" : ObjectId("4f58e58227f86b11258dc713"),
                        "meat" : 20
                },
                {
                        "_id" : ObjectId("4f58e58227f86b11258dc713"),
                        "fish" : 1
                },
                {
                        "_id" : ObjectId("4f58e58227f86b11258dc713"),
                        "water" : 30
                },
                {
                        "_id" : ObjectId("4f58e5e127f86b11258dc714"),
                        "meat" : 15
                },
                {
                        "_id" : ObjectId("4f58e5e127f86b11258dc714"),
                        "water" : 25
                },
                {
                        "_id" : ObjectId("4f58e60027f86b11258dc715"),
                        "banana" : 15
                },
                {
                        "_id" : ObjectId("4f58e60027f86b11258dc715"),
                        "water" : 10
                },
                {
                        "_id" : ObjectId("4f58e60027f86b11258dc715"),
                        "nuts" : 1
                },
                {
                        "_id" : ObjectId("4f58e64a27f86b11258dc716"),
                        "bamboo" : 15
                },
                {
                        "_id" : ObjectId("4f58e64a27f86b11258dc716"),
                        "dumplings" : 50
                },
                {
                        "_id" : ObjectId("4f58e64a27f86b11258dc716"),
                        "water" : 3
                }
        ],
        "ok" : 1
}


Осталось лишь сложить/сгруппировать все это дело при помощи функции $group. Указание поля _id в группировке здесь обязательно, но нам оно в принципе не нужно, поэтому пусть это будет какая-нибудь ерунда. Для каждого типа еды создаем соответствующее поле для суммирования отдельных рационов каждого животного:

db.zoo.aggregate({$project: {ration: 1}}, {$unwind: "$ration"}, {$project: {ration: 0, _id: 0, meat: "$ration.meat", fish: "$ration.fish", water: "$ration.water", banana: "$ration.banana", bamboo: "$ration.bamboo", nuts: "$ration.nuts", dumplings: "$ration.dumplings"}}, {$group: {_id: "s", sum_meat: {$sum: "$meat"}, sum_fish: {$sum: "$fish"}, sum_water: {$sum: "$water"}, sum_banana: {$sum: "$banana"}, sum_nuts: {$sum: "$nuts"}, sum_bamboo: {$sum: "$bamboo"}, sum_dumplings: {$sum: "$dumplings"}}});

{
        "result" : [
                {
                        "_id" : "s",
                        "sum_meat" : 35,
                        "sum_fish" : 1,
                        "sum_water" : 68,
                        "sum_banana" : 15,
                        "sum_nuts" : 1,
                        "sum_bamboo" : 15,
                        "sum_dumplings" : 50
                }
        ],
        "ok" : 1
}


Самый любимый смотритель


Фильтруем по полям и разматываем массив staff.like:

db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"});

Вспоминаем, что $project умеет поднимать поле на уровень вверх:

db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like"}});

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

{
        "result" : [
                {
                        "name" : "Petrovich"
                },
                {
                        "name" : "Mihalich"
                },
                {
                        "name" : "Petrovich"
                },
                {
                        "name" : "Maria"
                },
                {
                        "name" : "Anna"
                },
                {
                        "name" : "Petrovich"
                },
                {
                        "name" : "Mihalich"
                },
                {
                        "name" : "Maria"
                },
                {
                        "name" : "Anna"
                }
        ],
        "ok" : 1
}


Теперь необходимо просуммировать эти поля. Но так просто это не сделать, так как у нас нет поля для суммирования, поэтому создаем это поле при уже известной фишки.

db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}});

В результате к каждому объекту добавится еще одно поле count со значением 1. Группируем и суммируем:

db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}}, {$group: {_id: "$name", num: {$sum: "$count"}}});

Сортируем и ограничиваем вывод самым первым элементом

db.zoo.aggregate({$project: {name: 1, _id: 0, "staff.like": 1}}, {$unwind: "$staff.like"}, {$project: {_id: 0, name: "$staff.like", count: {$add: [1]}}}, {$group: {_id: "$name", num: {$sum: "$count"}}}, {$sort: {num: -1}}, {$limit: 1});

И получим следующее:

{ "result" : [ { "_id" : "Petrovich", "num" : 3 } ], "ok" : 1 }


Вот собственно и все. Для интересующихся есть два простеньких доклада на английском по этой теме: раз и два.

Если честно то MongoDB мне очень нравится, хотя мы использовали его только на части проекта для хранения разрозненных данных. Те же Map/Reduce для меня всегда были чем-то страшным и непонятным, но новая штука агрегирования данных позволяет частично исключить JavaScript, потому что так или иначе он язык интерпретируемый, а потому медленный и заменить его уже готовыми, а значит быстрыми, элементами языка.

P.S. Стоит отметить что версия 2.1 пока что достаточно сырая. Я постоянно получал всякие исключения по assertion failed. Но я думаю, что в 2.2 это наконец-то будет стабильно и клево.
+65
10942
216
deadkrolik 32,6

Комментарии (16)

+1
1999, #
Осталось узнать какова производительность этого, гм, фреймворка. Пока чем-то напоминает как раз тот самый reduce.
Думаю вы зря так про js с Map-Reduce, правда я его использую в CouchDB и там не ad-hoc.
0
Zelgadis, #
В кауче божественный map-reduce!
+6
karellen, #
Производительность относительно M/R выше. Пока еще достаточно много ограничений, но по моим тестам на моих данных быстрее от 2 до 10 раз. Хотя заметно, что кое-где пока что не использует индексы. Впрочем, от distinct мне тоже не удалось добиться консистентной работы с индексами.
0
osypchuk, #
c текущим map/reduce еще ведь/ проблема что одновременно выполняется только 1 map reduce, а если новые функции не используют javascript то такого ограничения там не будет.
0
Stdit, #
А если в процессе агрегации какой-то элемент изменяет свои данные, как она себя ведёт?
0
vorbiz, #
Думаю, агрегация вешает write lock.
+2
romanmir, #
Главное использовать подходящие инструменты в любой ситуации, иначе получается вот это:

Must. Use. Mongo. DB. It. Is. Web. Scale. www.youtube.com/watch?v=b2F-DItXtZs
0
how, #
habrahabr.ru/post/204392/
вот перевод
+5
ComodoHacker, #
Я вот думаю: а не проще ли было реализовать SQL синтаксис. Он многим знаком, привычен.

У меня есть сомнения насчет читабельности и сопровождаемости вот такого:
db.zoo.aggregate({$project: {name: 1, holidays: 1}}, {$unwind: "$holidays"},{$match: {holidays : {$gt: -1}}}, {$project: {holidays: 1, _id: 0}});
В реальных проектах это будет в 10 раз сложнее.
+2
kost_bebix, #
Как раз наоборот, каждый шаг прост и понятен. А SQL обычно превращается в гигантский «blob-запрос», который нельзя трогать руками иначе всё сломается. Ну и здесь еще соль в том, что всё поддаётся концепции map/reduce (по сути, этот язык — надстройка над типичными операциями map/reduce), а потому все данные проходят через некоторые пайпы, а значит всё прозрачно с точки зрения памяти, необходимой для обработки данных.
+4
cerriun, #
Читаемость sql, вы шутите?
Не самый удачный пример sql запроса, но такое тоже бывает:

select left_id, right_id from
(
select max(W.COUNTS.TIME_START) maxtime, W.COUNTS.left_id, W.COUNTS.right_id  from
(
select correct.* from
(
select sub1.* from
(
select W.COUNTS.left_id, W.COUNTS.right_id from
W.COUNTS, W.ORDER, W.CLIENT
where W.CLIENT.NET = 2
and W.ORDER.ID = W.CLIENT.ID
and W.COUNTS.ID = W.ORDER.ID
group by W.COUNTS.left_id, W.COUNTS.right_id
) sub1, W.CHANGES
where W.CHANGES.left_id = sub1.left_id
and W.CHANGES.right_id = sub1.right_id
and (W.CHANGES.TYPE = 1 or W.CHANGES.TYPE = 2 or W.CHANGES.TYPE = 0)
and [CHTIME] > '2010-1-1' and [CHTIME] < '2010-9-1'
)correct, W.EMPLOYEES
where W.EMPLOYEES.left_id = correct.left_id
and W.EMPLOYEES.right_id = correct.right_id
and W.EMPLOYEES.STATE <= 3
)sub2, W.COUNTS
where W.COUNTS.left_id = sub2.left_id
and W.COUNTS.right_id = sub2.right_id
group by W.COUNTS.left_id, W.COUNTS.right_id
)wtime
where maxtime < '2012-2-27'
НЛО прилетело и опубликовало эту надпись здесь
НЛО прилетело и опубликовало эту надпись здесь
+2
Deepwalker, #
Такие структуры кстати проще будет генерировать, чем SQL.
0
nod, #
Спасибо за статью.
+1 в карму ;)
0
A1ekcandr, #
спасибо автору, очень полезная статья

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.