Здравствуйте, меня зовут Дмитрий Карловский и я… давно не занимался бэкендом, но на днях вдруг наткнулся на мучения SbWereWolf по натягиванию ужа на ежа и не смог удержаться от соблазна сдуть пыль со своего мульти-инструмента OrientDB да оттяпать им чего-нибудь этакого.
Итак, мастерить мы сегодня будем базу данных для интернет-магазина с поиском товаров по параметрам, полнотекстовым поиском, локализацией, автоматическим формированием рубрикатора и мастера добавления товара.
Разбирать мы будем вот этот вот реляционный звездолёт:
А собирать вот такой вот графовый скворечник:
Схема базы данных
Все сущности нашего каталога у нас будут иметь следующие поля:
- 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)
sspat
15.03.2017 17:42+5В целом принцип у вас остался тот же, только сменилась терминология и связующие таблицы из реляционного решения превратились в ребра графа. Документная БД конечно гораздно изящнее решает такие задачи, чем EAV и его вариации. Осталось только сравнить эти решения по скорости добавления/чтения на большом обьеме данных. Поиск в данном случае будет очевидно быстрее и более гибким.
lega
15.03.2017 22:50+1Вы OrientDB только для пет проджектов используете? Как она в тяжёлом бою?
Я видел негативные статьи, что OrientDB теряет данные (хотя негативные статьи можно найти про любую популярную БД).
lega
15.03.2017 22:55Можно ли в OrientDB сделать один индекс по двум «коллекциям» (классам)? Например поиск по имени в коллекциях клиент и сотрудник.
vintage
15.03.2017 23:21Да, даём им общий суперкласс (как, например, Object из статьи) и вешаем индекс на него. Множественное наследование поддерживается, если что.
ls18
16.03.2017 07:19А в каком ПО вы пострили ER-схему БД(схема на первом изображении)?
SbWereWolf
16.03.2017 10:19DataGrip, там ручками схему построить нельзя, но можно выбрать один из 10+ вариантов, я обычно выбираю Layout => Directed Orthogonal — самая «прямолинейная» схема получается.
на ру-трекере
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 *картинка с грозящим кулаком мужиком*.
Don_Eric
16.03.2017 12:33похоже на концепт Anchor modeling
https://en.wikipedia.org/wiki/Anchor_modeling
xl0e
16.03.2017 12:47+1Create index Object.description on Object( description by value ) fulltext engine lucene metadata { "analyzer" : "org.apache.lucene.analysis.ru.RussianAnalyzer" }
Почему только RussianAnalyzer? Как с остальными языками быть?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 "Ска*" )
Как определить язык запроса — вопрос отдельный.
iit
Как-то раз я столкнулся со схожей структурой в mysql — где были объект и атрибуты объектов и все на сайте от статей до пользователей было перемешано — собрать один объект было то-еще веселье из кучи JOIN'ов и подзапросов.
Вытащить данные по серии объектов и взаимосвязями для отчета — локальный персональный ад.
Теперь я понял куда смотрели разработчики той системы и как это сделать правильно.
Спасибо!
P.S Пользователи статьи конечно разделили на разные микросервисы c postgres, а товары крутится на mongo.