Моделирование данных в MongoDB

Автор оригинала: Mike Dirolf
  • Перевод
imageОдна из самых разрекламированных фич MongoDB — это гибкость. Я сам не раз подчеркивал это в бесчисленных разговорах о MongoDB. Однако, гибкость — это палка о двух концах: большая гибкость подразумевает более широкий выбор решений для моделирования данных. Тем не менее, мне нравится гибкость, которую предоставляет MongoDB, просто нужно иметь ввиду некоторые рекомендации, прежде чем начать разрабатывать модель данных.

В этой статье мы рассмотрим, как смоделировать структуру, содержащую списки рассылок и данные о людях, которые входят в эти списки.


Ниже представлены требования:

  • Человек может иметь один или более e-mail адресов;
  • Человек может состоять в любом количестве списков рассылок;
  • Человек может выбрать любое название для любого списка рассылки, в котором состоит.

Стратегия «без встраиваний»


Давайте посмотрим, как будет выглядеть наша модель данных, если никакие данные никуда не встраивать.

У нас есть подписчики People с именами и паролями:
{
    _id: PERSON_ID,
    name: "Василий Пупкин",
    pw: "Хешированный пароль"
}

У нас есть коллекция адресов Adresses, в которой каждый документ содержит e-mail адрес и привязку к конкретному подписчику:
{
    _id: ADDRESS_ID,
    person: PERSON_ID,
    address: "vpupkin@gmail.com"
}

У нас есть группы Groups, каждая из которых содержит только идентификатор группы (она, конечно же, может содержать и другие данные, но мы специально опустим этот момент, дабы сконцентрироваться на подписках)
{
    _id: GROUP_ID
}

И наконец, мы имеем коллекцию подписок Memberships. Каждая Подписка объединяет людей в Группы, кроме этого, содержит название, которое человек выбрал для данной Группы, и ссылку на e-mail адрес, который он хочет использовать для получения рассылки в данной Группе:
{
    _id: MEMBERSHIP_ID,
    person: PERSON_ID,
    group: GROUP_ID,
    address: ADDRESS_ID,
    group_name: "Семья"
}

Такая модель данных понятна, легка в разработке и проста в обслуживании. Мы создали модель, которую удобно использовать в реляционной базе данных. При этом мы совсем не приняли во внимание документо-ориентированный подход MongoDB. Давайте рассмотрим, что мы будем делать, чтобы получить, например, e-mail адреса всех членов одной Группы, имея один известный e-mail адрес и название этой Группы:

  1. В коллекции Addresses по известному e-mail найдем PERSON_ID;
  2. В коллекции Memberships по полученному PERSON_ID и известному названию Группы найдем GROUP_ID;
  3. Опять же в коллекции Memberships по полученному GROUP_ID найдем список Подписок данной Группы;
  4. И наконец из коллекции Addresses по ADDRESS_ID, пройдя по каждой Подписке из полученного списка, получим список e-mail адресов.

Слегка сложновато, не правда ли?

Стратегия «все встроено»


Теперь рассмотрим случай, когда все данные встроены в один документ. Для этого мы возьмем все Подписки Группы и встроим их в модель Группы. Плюсом в каждую Подписку встроим данные об Подписчике и его e-mail адресах:
{
    _id: GROUP_ID,
    memberships: [{
        address: "vpupkin@gmail.com",
        name: "Василий Пупкин",
        pw: "Хешированный пароль",
        person_addresses: ["vpupkin@gmail.com", "vpupkin@mail.ru", ...],
        group_name: "Семья"
    }, ...]
}

Смысл встраивать все связные данные в один документ заключается в том, что теперь некоторые запросы к данным делать намного проще. Запрос из предыдущей части статьи становится совсем простым (помните, нам нужно, имея один известный e-mail адрес и название Группы, узнать e-mail адреса остальных членов этой Группы):

  1. В коллекции Groups найдем Группу, содержащую Подписку, в которой group_name совпадает с известным нам названием Группы и массив person_addresses содержит известный нам e-mail;
  2. Разберем полученный документ для извлечения остальных e-mail адресов.

Гораздо проще. Но что будет, если Подписчик захочет поменять имя или пароль? Нам придется менять его имя или пароль в каждой встроенной Подписке каждой Группы, в которой состоит этот Подписчик. Это также касается добавления нового или удаление существующего e-mail адреса из массива person_addresses. Такие моменты говорят нам об определенном характере данной модели: она хорошо подходит для специфичных запросов (потому что все необходимые данные уже внутри, типа pre-join), но может стать кошмаров в долгосрочной перспективе в плане сопровождения.

Стратегия «частичного встраивания»


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

Например, несолько e-mail адресов из коллекции Addresses принадлежат одному Подписчику (они также участвуют в модели Подписки) и обычно меняются не так часто. Поэтому мы объединим их в массив и добавим в нашу модель Подписчика, сделав её чуточку схожей с ментальной моделью.

Каждая Подписка связана с конкретным Подписчиком и конкретной Группой, поэтому можно встроить Подписки как в модель Подписчика, так и в модель Группы. В подобных случаях важно думать и о модели доступа к данным и о размере встраиваемых данных. Мы ожидаем, что люди вряд ли подпишутся на рассылку более чем из 1000 разных групп, и отдельно взятая группа в свою очередь также вряд ли наберет более 1000 подписчиков. В данном случае, цифры нам ничего полезного не говорят. Однако, наша модель доступа к данным, напротив, говорит нам, что при выводе на экран необходимо видеть все подписки конкретного человека. Для упрощения запроса мы встроим Подписки в модель Подписчика. Преимущество ещё и в том, что список e-mail адресов Подписчика находятся в модели Подписчика, а в Подписке используется один из адресов этого списка, и если нам нужно изменить или удалить e-mail адрес, это можно сделать в одном месте.

Теперь наша модель данных выглядит так:
{
    _id: PERSON_ID,
    name: "Василий Пупкин",
    pw: "Хешированный пароль",
    addresses: ["vpupkin@gmail.com", "vpupkin@mail.ru", ...],
    memberships: [{
        address: "vpupkin@gmail.com",
        group_name: "Семья",
        group: GROUP_ID
    }, ...]
}

Это модель Подписчика, кроме неё есть ещё модель Группы, которая идентична той, что рассмотрна в описании стратегии «без встраивания».

Запрос, который мы обсуждали выше, теперь будет выглядеть так:

  1. В коллекции People найдем Подписчика с искомым e-mail адресом, среди Подписок которого есть Подписка с искомым названием;
  2. Используя GROUP_ID найденной Подписки, найдем в коллекции People других Подписчиков этой Группы и возьмем их e-mail адреса непосредственно из Подписки.

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

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

Похожие публикации

Комментарии 44

    +2
    Спасибо, очень актуально. Никак не могу «привыкнуть» к денормализации, и прочим гибкостям. В связи с чем мне пока достаточно сложно строить модели данных в Mongo.
      0
      Хорошая статья, буду давать ссылку всем тем кто никак не может отвыкнуть от SQL подхода и использует реляционную модель в документных базах.
        +1
        Кстати, у автора оригинала в конечной структуре вроде бы ошибка.
        У Подписчика присутствует коллекция memberships, а должна быть subscriptions.
          +2
          Автор вообще не оперирует понятием подписка, он как раз оперирут понятием membership. Я им не смог адекватно оперировать в переводе, поэтому заменил подходящим по смыслу термином.
            +1
            memberships == членство же, нет?
              +2
              Верно. Им я и не смог адекватно оперировать.
              +1
              Да, понятно. Но по-моему стоит это как раз-таки разделить. Согласитесь, почти в любом другом примере уже не удалось бы одинаково назвать коллекции и в той и в другой сущности.
              Допустим если отношение много ко многим между проектами и сотрудниками. У сотрудника может быть коллекция проектов, а у проекта коллекция сотрудников.
              На мой взгляд что даже если есть возможность именовать одинаково с обоих сторон, лучше придумать разные названия которые будут лучше отражать направление связи.
                0
                Т.е. вы предлагаете называть коллекцию проектов одним названием, а коллекцию сотрудников — другим? Может пример приведете, а то у меня ощущение, что я не очень понимаю.
                  +2
                  Ну смотрите, пусть у вас в системе есть две сущности проекты и сотрудники.
                  Они состоят в отношении многие ко многим.
                  Получается если строить модель со вложенными элементами а не нормализированную, то у вас может быть два варианта:
                  1) Коллекция Сотрудников у которых вложенная коллекция Проектов
                  2) Коллекция Проектов с вложенной коллекцией Сотрудников

                  Вы уже не можете в обоих этих случаях одинаково назвать вложенную коллекцию как получилось в примере группами и участниками (и то только на английском языке).
                  Я бы вообще в этом случае или назвал бы коллекцию участников в группе members, или коллекцию групп у юзера назвал бы groups/subscriptions.
                    0
                    Понял. Соглашусь. Однако, для примера все же думаю правилнее оперировать одним и тем же понятием. Думаю, грамотный человек и так поймет, как лучше, когда будет делать свою схему.
                      0
                      Для Вашего примера надо так: «сотрудники проекта» и «проекты сотрудника».
                      0
                      Возможно стоит написать имена коллекций в коде моделей.
                      Для стратегии «все встроено» — это Groups, а для стратегии «частичного встраивания» — это Users.
                        0
                        Точнее не Users а People.
              +4
              Ну да, стандартная проблема Embedding vs Linking, которая на сайте монги поднимается.
              Правда, я почему-то не увидел здесь минусов встраивания, и примеров когда Linking более актуален и логичен, а без этого статья немного неполна.
                0
                А где вы видели про плюсы и минусы встраивания и линкинга? Можете дать ссылку, хочу почиать?

                Я уже третий день пытаюсь определиться со схемой для своего проекта, понять плюсы и минусы, но так до сих пор и не нашел правильного решения.

                Сергей Матвеенко на вопрос в гугловских группах, что и в каких случаях лучше встраивать, а на что ссылаться при моделировании схемы, посоветовал:
                Критерии примерно следующие:
                1. (Самый важный) какие запросы вы будете делать. Данные, которые в
                  SQL присоединяются джойнами, можно складывать внутрь документа, для
                  которого они вам нужны.
                2. (Похож на первый) отображение. Какие данные вы хотите показать
                  вместе с этим объектом? Их и надо пихать внутрь него. Идеальный
                  вариант в вакууме: одна страница интерфейса — один и только один
                  документ.
                3. Как вы будете вставлять и модифицировать данные. Тут важно нужно ли
                  вам в каком-то месте обеспечить транзакционность.
                4. Масштабируемость. Здесь важен выбор ключа шардинга, но его можно
                  впихнуть потом отдельным полем (если данных будет не слишком много),
                  но можно и сразу завести отдельное поле, например, «shkey», и уже
                  писать в него что-нибудь осмысленное. Так легче будет поменять логику
                  шардинга в будущем.

                  0
                  Не видел, опираюсь на опыт и полигональные испытания :)
                  Критерии у меня по-проще:
                  1. Удобство использования (== количество и сложность запросов).
                  2. Необходимость client-side обработки частей составного отображения. (== нужно ли как-то хитро фильтровать данные перед join'ом).
                  3. Производительность (== вставки + апдейты + выборки).
                  4. Размер данных. Если нужно хранить, скажем, миллиард записей, а нужно при выборки 10-100 строк, то каждое лишнее поле при embedding'e будет больно бить по размеру коллекции.
                  В таком порядке.
                  Но опять же иногда сходу невозможно оценить, и нужно попробовать несколько вариантов.
                    +1
                    Все проще: Linking для справочных данных и Embedding для событий или тех, чья информационная целостность критична.

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

                    Справочные данные наоборот, являются «строительными кирпичами» событий. Они могут образовывать множество связей между собой и это негативно скажется на попытке поддежания актуальности информации во всех связанных документах, если будет использован Embedding подход. Так же справочные элементы не являются каким либо событием, а просто набором атрибутов для некоего абстрактного объекта, тип которого так же закладывается в этих атрибутах.
                      0
                      О как… Опять вы меня смущаете. Задам вам тогда вопрос, на который отвечал Сергей:
                      Я собираюсь использовать MongoDB для сбора диагностических данных c промышленных объектов. Основная сущность — пром.объект. У этого объекта есть такие характеристики, как: принадлежность к стране, региону, предприятию, филиалу этого предприятия, эксплуатирующей организации, которая находится в подчинении у филиала, географическое положение и.т.п. В реляционной модели я бы сделал все это в разных таблицах. А как лучше сделать в MongoDB? Плюсом в БД будут стекаться динамические данные с этих пром.объектов, примерно раз в секунду что-то типа среза всех данных по объекту.

                      Тут как раз, получется, есть справочные данные (характеристики объекта) и события (срез данных по объекту на момент времени). Т.е. исходя из вашего утверждения, данные о принадлежности к стране, региону, предприятию и т.п. нужно хранить отдельно, оставляя в документе ссылки, а срезы данных встраивать в внутрь?
                        0
                        Да, и во всяком случае я бы так поступил. Пром. объект содержал бы Embedded данные справочных документов(страна, филиал, организация и т.п.) на момент своего создания, а все справочные данные были бы отдельным документами, из которых можно собрать еще один пром. объект. А то, что организация находится в стране Б, можно оставить ссылку на эту страну — в контексте задачи это все справочные данные. Если организация Foo передает пром.объект организации Bar, то это событие стоит зафиксировать отдельным документом — как бонус получите историю «движения» пром. объекта по организациям.
                          0
                          Ещё одна вещь, которую стоит заранее знать — по слинкованным данным нет поиска, поэтому линковка — это отнюдь не замена джойнам и придётся так или иначе обращаться к Map/Reduce или городить каскады запросов, что не айс. Хотя, возможно ситуация меняется с новым фреймворком запросов, но я пока не изучал его досконально, чтобы ответить на этот вопрос.
                            0
                            Как это нет? вы же делаете линковку в обе стороны — найти все группы где в участниках есть такой-то _id.
                              0
                              Возможно под поиском мы понимаем что-то различное? Или я чего-то таки не знаю, что наверно, скорее.
                              Не могли бы Вы привести пример запроса по слинкованным данным?

                              Хотя, Вы говорите об _id, но обычно _id при поиске интересует в последнюю очередь, а поиск ведётся по определённым характеристикам связанного объекта — именно это я имел в виду, когда говорил, что поиск по слинкованным данным невозможен. Например, найти всех участников общих со мной групп, старше 18 — без дублирования информации или каскада запросов — трудно представить, как такое сделать.
                                0
                                Эммм, для такие запросов есть Aggregation Framework . А так да, чтобы сделать поиск (Query) такого типа сначала надо будет получить все записи (всех участников общих со мной групп), а потом по ним пройтись (старше 18).
                      0
                      Допустим форум. Вся информация о пользователе встроена, есть массив DBRef'ов на его сообщения. Есть ветка форума. Все сообщения в ней сделаны через DBRef, чтобы избежать лока. Я использую так: если документ обновляется редко и только одним пользователем — встраиваю, если документ можнт обновляться из разных мест и часто (посты в ветку пишут разные пользователи, а профаил обновляется только владельцем) — линкую.
                      0
                      Спасибо.
                      Недавно я натыкался на текст, в котором было написано что MongoDB не годится для heavy write приложений из-за однопоточной записи.
                      Действительно это так?
                        0
                        Что-то такое есть. На сайте монги в какой-то презентации говорится, что при записи блокируется база данных. При этом оговоривается, что это занимает ничтожно малый промежуток вермени, потому что «mongodb is extremely fast», и мол это почти сказывается на работе.

                        Тимофей Миронов, который использует монгу 2 года в продакшне в своем проекте timeliner, говорил на недавней конференции Дамп, что это бич монги и разработчики пытаются его всячески преодолеть и, якобы, в предстоящем релизе 2.2 блокирование будет на уровне коллекции.
                          0
                          Да, есть global lock во время апдейта, на весь монго, по всему кластеру :( даже если там реально разные БД. Сейчас занимаются исправлением этой ситуации, начиная с 2.2. В начале database lock, потом collection lock. Как я понимаю в 2.2 ожидается только уровня database.
                        0
                        Как при этом получить список всех адресов для рассылки по GROUP_ID?
                          0
                          Уточню вопрос. Вот мы делаем «user.find({'memberships.group':42}, {memberships:1})», в результате получаем список юзеров, в каждом есть массив из подписок, одна из которых является искомой. Как из списка юзеров сформировать список email адресов для собственно рассылки? Руками сканировать каждый массив?
                            0
                            У меня сейчас, к сожалению, нет под рукой консоли, предположу, что так:
                            user.find({'memberships.group':42}, {'memberships.adress':1})
                            
                              0
                              Эта операция вернёт все адреса всех подписок пользователей, которые подписаны на рассылку №42. Кроме того, нам нужно ещё и оригинальное имя.
                                0
                                Видимо, исходя из таких вот вопросов и нужно проектировать модель данных в монге. Когда дело идет чуть дальше заполонивших интернет примеров про блог с комментариями, появляется куча вопросов. Поэтому я уже третий день не могу разработать нормальную схему, у меня тоже вокруг крутится куча «если».
                              0
                              Да, монга всегда поднимает весь документ полностью, как вариант можете использовать map-reduce или aggregation framework.
                                0
                                Как вариант, можно вместо списка использовать словарь:
                                > db.user.insert({ name:'Jo', memberships:{ 1:{ email:'x@y', grname:'blabla' }, 5:{ email:'Jo@gmail', grname:'for me' } } })
                                > db.user.insert({ name:'Jo2', memberships:{ 2:{ email:'x@y', grname:'blabla' }, 5:{ email:'Jo2@gmail', grname:'for me2' } } })
                                > db.user.insert({ name:'Jo3', memberships:{ 2:{ email:'x@y', grname:'blabla' }, 4:{ email:'Jo3@gmail', grname:'for me3' } } })
                                > GROUP = 5
                                > query = {}
                                > query['memberships.'+GROUP] = { $exists:true }
                                > fields = {}
                                > fields['memberships.'+GROUP] = 1
                                > db.user.find(query, fields)
                                { "_id" : ObjectId("4fc50d81151b7c848b19d110"), "memberships" : { "5" : { "email" : "Jo@gmail", "grname" : "for me" } } }
                                { "_id" : ObjectId("4fc50ee1151b7c848b19d111"), "memberships" : { "5" : { "email" : "Jo2@gmail", "grname" : "for me2" } } }

                                Код группы (желательно) продублировать в массив и проиндексировать.

                                А ещё с 2,1 есть такой функционал для сложных запросов.
                                  0
                                  Вариант. Только не понятно, как по этим словарям строить индексы, для каждой группы по индексу жирновато будет, даже если частичный.
                                    0
                                    >Код группы (желательно) продублировать в массив и проиндексировать.

                                    Например: «groups» — индексированный массив, список подписанных групп, поиск по этому же ключу.
                                    по одной команде для подписки: db.user.update({ _id:_id }, { $addToSet:{ groups:GROUP }, $set:{ memberships:… } })
                                    для отписки: db.user.update({ _id:_id }, { $pull:{ groups:GROUP }, $unset:{ memberships:… } })
                                0
                                Ну просто поиск по документам, а затем клиентская фильтрация:
                                db.People.find({«memberships.group»: XXX})
                                Или если хочется только монгу, то простой map-reduce легко с этим справляется.
                                  0
                                  Замечательно, таким образом мы узнали список юзеров, подписанных на эту рассылку. Но ведь нам надо узнать адрес и название этой рассылки для каждого пользователя, верно? Сканировать массивы в MapReduce, (а следовательно, переливать в отдельную коллекцию, потом ходить по ней, и рассылат письма) — выглядит как-то не очень лаконично. Что-то не так с этой моделью.
                                    0
                                    > (а следовательно, переливать в отдельную коллекцию, потом ходить по ней, и рассылат письма)
                                    {inline: 1} придёт на помощь :)
                              • НЛО прилетело и опубликовало эту надпись здесь
                                  +1
                                  Добавлю еще, что нужно учитывать то, что для запросов наподобие:

                                  user.find({'a.b':42}, {'b.c':1})


                                  нельзя создать составной индекс если a и b это массивы

                                  user.ensureIndex({'a.b':1, 'b.c':1})
                                  cannot index parallel arrays [b] [a]
                                  


                                    0
                                    вот это тоже может помочь

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

                                  Самое читаемое