Здравствуйте, меня зовут Дмитрий Карловский и я… давно не занимался бэкендом, но на днях вдруг наткнулся на мучения SbWereWolf по натягиванию ужа на ежа и не смог удержаться от соблазна сдуть пыль со своего мульти-инструмента OrientDB да оттяпать им чего-нибудь этакого.


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


Разбирать мы будем вот этот вот реляционный звездолёт:


17 таблиц


А собирать вот такой вот графовый скворечник:


5 классов


Схема базы данных


Все сущности нашего каталога у нас будут иметь следующие поля:


  • slug — человекопонятный идентификатор
  • created — время создания сущности
  • searchable — будет ли сущность находиться при поиске
  • title — человекопонятное название сущности в виде словаря "язык-текст"
  • description — человекопонятное описание сущности в виде словаря "язык-текст"

Чтобы не повторять эти поля в каждой сущности, как это сделано в звездолёте, мы просто создадим абстрактный класс "Object" от которого в дальнейшем будем наследовать остальные сущности:


Create class Object abstract

Create property Object.slug string ( collate ci , notnull true )
Create property Object.created datetime ( readonly true , default sysdate() )
Create property Object.searchable boolean ( default false )

Create property Object.title embeddedmap string ( collate ci )
Create property Object.description embeddedmap string ( collate ci )

slug должен быть уникальным для каждой сущности, а по названиям и описаниям нам потребуется поиск, поэтому добавим на эти поля соответствующие индексы:


Create index Object.slug unique

Create index Object.title on Object( title by value ) fulltext
engine lucene metadata {
    "analyzer" : "org.apache.lucene.analysis.ru.RussianAnalyzer"
}

Create index Object.description on Object( description by value ) fulltext
engine lucene metadata {
    "analyzer" : "org.apache.lucene.analysis.ru.RussianAnalyzer"
}

Славно, что движок полнотекстового поиска уже встроен в СУБД и нам не надо заниматься ручным перекладыванием данных из своей основной базы данных в какой-нибудь ElasticSearch, построенный на том же Lucene. При изменении сущности СУБД сама позаботится об актуализации полнотекстового индекса.


Самое главное в нашем магазине — товары, а каждый товар помимо стандартных свойств имеет ещё и цену:


Create class Product extends Object

Create property Product.price decimal

В звездолёте используется иерархия рубрик в виде дерева, но мы поступим интересней — всю иерархию мы разобьём на 2 типа узлов:


  • Тег — флаг, характеризующий конкретный товар.
  • Аспект — группа взаимоисключающих тегов, образующая характеристику товаров

В иерархии эти два типа узлов идут попеременно:


  • внутри тега могут находиться аспекты, но не теги
  • внутри аспекта могут находиться теги, но не аспекты

Примеры аспектов (а в скобочках — тегов):


  • Вид товара (еда, одежда, техника)
  • Производитель (Плюшкин Инкорпорейтед, Макинтош Лимитед, Экскаватор Трейдинг)
  • Цвет (Красный, Синий, Зелёный)

При этом выбор "цвета" имеет смысл давать только для тегов "одежда" и "техника", но не "еда".


Добавим эти две сущности и провяжем их друг с другом:


Create class Aspect extends Object
Create class Tag extends Object

Create property Aspect.tag linkset Tag
Create property Aspect.tag_sub linkset Tag

Create property Tag.aspect linkset Aspect
Create property Tag.aspect_sub linkset Aspect

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


Create property Product.tag linkset Tag

Например, для товара "Скатерть-самобранка" может быть выставлен тег "техника", который откроет нам аспект "цвет" и, как следствие, возможность выбрать "красный".


Чтобы поиск по тегам у нас не тормозил, мы добавим по ним индекс:


Create index Product.tag notunique

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


Create class Attribute extends Object

Create property Attribute.type string ( default "string" )

Атрибуты у нас будут привязываться не к товарам, как можно было бы подумать, а к тегам:


Create property Tag.attribute linkset Attribute
Create property Attribute.tag linkset Tag

Например, если установлен тег "еда", то для товара становится доступен атрибут "срок годности". Само значение атрибута конкретного товара мы будем хранить в самом же товаре. В схему мы это выносить не будем, так как у каждого товара фактически может быть свой набор атрибутов в зависимости от того, какие теги ему были установлены.


Пользовательские сценарии


Полнотекстовой поиск


Если пользователь ввёл поисковой запрос, то мы сразу ищем все объекты, которые ему соответствуют:


Select from Object
where searchable = true
    and ( title lucene "Ска*" or description lucene "Ска*" )

Но если выдача получится слишком большой, то разумно будет предложить ему детализировать запрос по тегам из товаров в выдаче. Для этого запросим вместе с собственно найденными объектами ещё и связанные с ними теги и связанные с последними атрибуты и аспекты:


Select from Object
where searchable = true
    and ( title lucene "Ска*" or description lucene "Ска*" )
fetchplan *:0 slug:0 title:0 tag.slug:0 tag.title:0 tag.aspect.title:0 tag.attribute.title:0 tag.attribute.type:0

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


Select from Object
where searchable = true
    and ( title lucene "Ска*" or description lucene "Ска*" )
    and ( tag in ( Select from Tag where slug = "tag=tech" and slug="color=red" ) )
    and ( weight between 100 and 200 )
fetchplan *:0 slug:0 title:0 tag.slug:0 tag.title:0 tag.aspect.title:0 tag.attribute.title:0 tag.attribute.type:0


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


Select from Aspect
where ( tag is null )
fetchplan *:0 title:0 tag_sub.slug:0 tag_sub.title:0

Дальнейшее путешествие пользователя аналогично случаю полнотекстового поиска, но без собственно полнотекстового поиска:


Select from Product
where searchable = true
    and ( tag in ( Select from Tag where slug = "tag=tech" and slug="color=red" ) )
    and ( weight between 100 and 200 )
fetchplan *:0 slug:0 title:0 tag.slug:0 tag.title:0 tag.aspect.title:0 tag.attribute.title:0 tag.attribute.type:0

Как можно заметить, погружаясь всё глубже в кроличью нору, мы не переходим с одной рубрики на другую (вложенную), а добавляем в фильтрацию дополнительный тег. Например, на странице "красные ботинки" мы будем искать по тегам "одежда", "обувь", "ботинки", "красные", а на странице "красненькие ноутбуки" — "техника", "компьютеры", "ноутбуки", "красные". В обоих случаях "красные" — это один и тот же тег.


Создание товара


При создании товара, нет никакого смысла выводить для заполнения все возможные для товаров параметры. Например, параметр "соотношение сигнал/шум" совершенно бессмысленно для "ботинок". Поэтому точно так же, как с каталогом, мы выводим лишь корневые аспекты, а дополнительные аспекты становятся доступными лишь по мере выбора тегов пользователем, добавляющим товар. Список доступных атрибутов и аспектов c их тегами по списку выбранных тегов получается достаточно тривиально:


Select from Aspect
where ( tag is null )
    or ( tag in ( Select from Tag where slug = "tag=tech" and slug="color=red" ) )
fetchplan *:0 title:0 tag_sub.slug:0 tag_sub.title:0 tag_sub.attribute.title:0 tag_sub.attribute.type:0

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


Создадим, например, аспект "Вид товара":


Insert into Aspect set slug = "aspect=kind" , title = { "ru" : "Вид товара" }

Теперь добавим к нему, например, тег "Одежда":


Insert into Tag
set
    slug = "tag=wear" ,
    searchable = true ,
    title = { "ru" : "Одежда" } ,
    aspect = ( Select from Aspect where slug = "aspect=kind" )

Update Aspect
add tag_sub = ( Select from tag where slug = "tag=wear" )
where slug = "aspect=kind"

Другие теги добавляются аналогично. Добавление вложенного в тег аспекта аналогично. Например, добавим аспект "Цвет" в теги "Одежда" и "Техника":


Insert into Aspect
set
    slug = "aspect=color" ,
    title = { "ru" : "Цвет" } ,
    tag = ( Select from Tag where slug = "tag=wear" or slug = "tag=tech" )

Update Tag
add aspect_sub = ( Select from Aspect where slug = "aspect=color" )
where slug = "tag=wear"

Update Tag
add aspect_sub = ( Select from Aspect where slug = "aspect=color" )
where slug = "tag=tech"

Ну и, наконец, самое главное — добавление товара. Для примера, добавим "Скатерть-самобранку" красного цвета:


Insert into Product
set
    slug = "product=2" ,
    searchable = true ,
    title = { "ru" : "Скатерть-самобранка" } ,
    price = 999 ,
    tag = ( Select from Tag where slug = "tag=tech" or slug = "tag=red" )

Удаление товара


Удаление товара вовсе не должно приводить к удалению записи об этом товаре из базы данных, так в дальнейшем может потребоваться восстановить этот товар или найти данные о нём по идентификатору из какого-нибудь лога. Да даже чтобы выдавать 410(Gone) вместо 404(Not found) нужно, чтобы какая-то запись о товаре всё же оставалась. Кроме того есть такая сложная проблема как обеспечение того, чтобы никакая другая запись не ссылалась на удаляемую. Поэтому лучшее решение — изменять запись так, чтобы она исключалась из определённых процессов. Например, чтобы товар не находился ни в глобальном поиске, ни в каталоге, достаточно изменить флаг searchable на false. Именно поэтому во всех поисковых запросах мы указывали дополнительное условие where searchable = true.


Update Product set searchable = false where slug = "product=2"

Другой вариант "удаления" — удаление ссылок на сущность, вместо удаления самой сущности. Например, список тегов аспекта у нас хранится в свойстве tag_sub. Если мы хотим, чтобы больше нельзя было выбирать тег "Серобуромолиновый" в аспекте "Цвет", то просто удаляем его из tag_sub, но связь от тега к аспекту оставляем нетронутой. Таким образом, при просмотре товара с этим странным цветом ничего не сломается — будет показываться "Цвет: Серобуромалиновый", но при создании нового товара выбрать этот цвет будет невозможно.


Update Aspect
remove tag_sub = ( Select from Tag where slug = "tag=gray-brown-magenta" )
where slug = "aspect=color"

Резюме


Итого, у нас получилось 4 сущности: товар, тег, аспект и атрибут. Между собой они имеют 8 типов связей. И всего этого достаточно, чтобы реализовать свой Яндекс.Маркет с поиском, фильтрами и волшебницами всего за один беспокойный вечер.

Поделиться с друзьями
-->

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


  1. iit
    15.03.2017 16:24
    +1

    Как-то раз я столкнулся со схожей структурой в mysql — где были объект и атрибуты объектов и все на сайте от статей до пользователей было перемешано — собрать один объект было то-еще веселье из кучи JOIN'ов и подзапросов.


    Вытащить данные по серии объектов и взаимосвязями для отчета — локальный персональный ад.


    Теперь я понял куда смотрели разработчики той системы и как это сделать правильно.


    Спасибо!


    P.S Пользователи статьи конечно разделили на разные микросервисы c postgres, а товары крутится на mongo.


  1. babylon
    15.03.2017 16:47
    -5

    Если сделать еще абстрактнее, и без танцев с SQL..., но с JSON Path. XML уже не актуально. Ну хотя кому, что по вкусу… Вообщем можете снова покрыть пылью до лучших времён.ууу


    1. vintage
      15.03.2017 17:02
      +2

      Где вы тут XML-то усмотрели?


  1. SbWereWolf
    15.03.2017 17:01
    -1

    тоже способ :)


  1. sspat
    15.03.2017 17:42
    +5

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


  1. lega
    15.03.2017 22:50
    +1

    Вы OrientDB только для пет проджектов используете? Как она в тяжёлом бою?
    Я видел негативные статьи, что OrientDB теряет данные (хотя негативные статьи можно найти про любую популярную БД).


    1. vintage
      15.03.2017 23:16

      В стартапе использвал, но до тяжёлого боя он, к сожалению, не дожил.


  1. lega
    15.03.2017 22:55

    Можно ли в OrientDB сделать один индекс по двум «коллекциям» (классам)? Например поиск по имени в коллекциях клиент и сотрудник.


    1. vintage
      15.03.2017 23:21

      Да, даём им общий суперкласс (как, например, Object из статьи) и вешаем индекс на него. Множественное наследование поддерживается, если что.


  1. ZOXEXIVO
    15.03.2017 23:57

    В MongoDB 3.4 появился Faceted search как раз для поисков как в «Яндекс Маркете»


    1. vintage
      16.03.2017 00:05
      +3

      Завезли бы туда лучше транзакции и перекрёстные ссылки между документами.


  1. ls18
    16.03.2017 07:19

    А в каком ПО вы пострили ER-схему БД(схема на первом изображении)?


    1. vintage
      16.03.2017 09:38

      Отвечает Александр SbWereWolf Друзь.


      1. ls18
        16.03.2017 09:40

        М? Не понял ответа.


        1. alprk
          16.03.2017 09:51

          Могу предположить что в черном ящике JetBrains DataGrip


        1. heleo
          16.03.2017 10:11

          Схема взята из статьи другого пользователя, перечитайте первый абзац.


    1. SbWereWolf
      16.03.2017 10:19

      DataGrip, там ручками схему построить нельзя, но можно выбрать один из 10+ вариантов, я обычно выбираю Layout => Directed Orthogonal — самая «прямолинейная» схема получается.

      на ру-трекере


      1. grossws
        16.03.2017 10:33

        Нахрена качать DataGrip с рутрекера, если можно взять с официального сайта?


  1. msts2017
    16.03.2017 10:08

    Эх, в итоге все равно, при достижении определенных нагрузок, подобные конструкции замещаются, с матами, на звездолеты + движок полнотекстового поиска, людям надо и быстро манипулировать данными и быстро искать одновременно.
    А все потому что имя таблицы и поля, в sql нельзя использовать как параметр, в том числе в виде списка, хотя чисто технически это вполне возможно, + добавить оптимизации аналогичные полнотекстовому поиску, вроде индекса объединяющего несколько таблиц.
    типа:

    select t.primarykey from (select table from tables where table_name like ...) t where (select field from t.fields where field_name in (..)) like ...
    


    а не Javaу пихать в RDBMS *картинка с грозящим кулаком мужиком*.


    1. vintage
      16.03.2017 11:00

      Где вы тут Java-то усмотрели?


      1. msts2017
        16.03.2017 11:04
        +1

        это не про orientdb, а вообще, в частности про оракл


      1. grossws
        16.03.2017 14:05

        А в слове Lucene как же?


        1. vintage
          16.03.2017 14:44

          Только, если сделать в нём 6 опечаток :-)


          1. grossws
            16.03.2017 14:48

            Если абстрагироваться от начала треда, то orientdb весь на java написан и использование Apache Lucene на это намекало. Имел ввиду исключительно это.


  1. Don_Eric
    16.03.2017 12:33

    похоже на концепт Anchor modeling
    https://en.wikipedia.org/wiki/Anchor_modeling


  1. xl0e
    16.03.2017 12:47
    +1

    Create index Object.description on Object( description by value ) fulltext
    engine lucene metadata {
        "analyzer" : "org.apache.lucene.analysis.ru.RussianAnalyzer"
    }
    

    Почему только RussianAnalyzer? Как с остальными языками быть?


    1. vintage
      16.03.2017 13:12

      Да, по хорошему для каждого языка нужен свой полнотекстовой индекс:


      Create property Object.title_en string ( collate ci )
      
      Create index Object.title_en fulltext
      engine lucene metadata {
          "analyzer" : "org.apache.lucene.analysis.ru.EnglishAnalyzer"
      }

      Create property Object.title_ru string ( collate ci )
      
      Create index Object.title_ru fulltext
      engine lucene metadata {
          "analyzer" : "org.apache.lucene.analysis.ru.RussianAnalyzer"
      }

      Select from Object
      where searchable = true
          and ( title_ru lucene "Ска*" or description_ru lucene "Ска*" )

      Как определить язык запроса — вопрос отдельный.