Использование JSONB-полей вместо EAV в PostgreSQL

от автора

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

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

У многих бизнес-объектов системы есть разновидности со специфичными только для них атрибутами. Приведем в пример клиентов. Они бывают физическими (ФЛ) или юридическими лицами (ЮЛ), а могут быть и более экзотическими, такими как «лид» или «контактное лицо». У каждого подвида разный набор атрибутов. Скажем, у ФЛ – ФИО и паспорт, у ЮЛ – название юрлица и ИНН. При этом часть атрибутов может совпадать, например, адрес и email. Как же их подружить? Рассмотрим, как это сделали мы, и сравним с классическим подходом EAV.

Мы исходим из того, что нам важно сохранить основные объекты в одной таблице, поэтому не рассматриваем случаи, когда мы еще и создаем отдельно таблицы «Клиент ФЛ» и «Клиент ЮЛ». Тогда в классической схеме EAV наш MVP будет выглядеть примерно так:

Пока у нас минимум контроля над данными. Мы можем только проверить, что к клиенту не добавили несвойственный ему атрибут, и соответствие типу данных. Тут есть нюансы:

  • Тип клиента (ФЛ, ЮЛ) будет находиться в «Сущности».

  • Создаем таблицу «Атрибут сущности», потому что некоторые атрибуты могут принадлежать к нескольким типам клиента. Это также позволит контролировать принадлежность атрибута к «Сущности» при сохранении значения.

  • Поле «Значение атрибута» вынуждено быть текстовым (или json, но мы же вроде не хотим иметь дела с json 🙂 ). Это само по себе несколько осложняет производительную работу запросов, а ведь основной аргумент против jsonb — проблемы поиска на больших масштабах. Здесь есть и другое решение:

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

Теперь рассмотрим нашу схему с использованием jsonb.

Отличия пока минимальные.

Значение поля «Параметры» – это всегда JSON-объект, который строится по принципу: {“Название атрибута”: <Значение атрибута>, …}. Например, {“ Фамилия”: “Иванов”, “Возраст”: 28}. Понятно, что для именования атрибутов мы используем латинские символы, для этого у нас в «Атрибуте» есть отдельная колонка, но тут для простоты мы будем использовать просто колонку с «Названием».

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

1. Сделаем поля с ограниченным выбором из списка. Любой комбобокс, например, пол.

2. Сделаем больше ограничений/проверок содержимого поля. В качестве примера возьмем поле email. Проверим, что в нем хранится именно электронная почта, а не произвольный текст.

3. Сделаем древовидную систему сущностей, чтобы наследовать общие атрибуты. Так, сущность «Клиент» делится на подвиды «ФЛ» и «ЮЛ». У «Клиента» при этом могут быть свои атрибуты, например, «Номер договора», который в итоге будет использован и для ФЛ, и для ЮЛ.

4. Добавим возможность настраивать обязательность атрибутов и дефолтное значение.

Решение.

1. Для комбобоксов создаем таблицы для хранения справочных данных типа «ключ-значение» и добавляем в таблицу «Атрибут» ссылку на этот справочник.

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

  • «Тип данных» можно поменять на справочник, а можно и оставить целочисленным, если у справочника целочисленный ИД.

  • В конечном счете, хранить на «Клиенте» мы будем именно ИД значения, и для интерфейса находить по нему само значение и показывать.

2. Для дополнительных ограничений делаем отдельную таблицу «Домен». На нее опять же ссылаемся из «Атрибута».

  • В «Домене» храним всякие настройки: размерность, маску ввода, регулярное выражение для проверки и т.п. Можно даже «Тип данных» туда перенести при желании, но это может быть контрпродуктивно.

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

3. Добавляем в «Сущность» поле «ИД родителя». Делаем представление, которое будет по ИД сущности возвращать все атрибуты, включая родительские. Делаем проверку, что по всей цепочке атрибуты не повторяются.

4. В таблицу «Атрибут сущности» добавляем поля «Признак обязательности» и «Значение по умолчанию».

Выглядеть это будет так для обоих решений:

А вот со следующими пунктами у EAV-модели начинаются сложности. Рассмотрим фичи, влияющие на выбранный способ хранения.

1. Привязка сущностей к другим объектам системы.

2. Поля массивов. Чекбоксы с выбором нескольких значений, например, категории рассылок, на которые клиент дал согласие. Или просто массивы, не обязательно списочные.

3. Составные поля. Например, адрес.

Решение!

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

  • Просто добавляем поле «Параметры» другому объекту системы и пользуемся. Однако, если мы хотим не допустить путаницы и не дать записывать в объекты сущности, не относящиеся к ним, добавим в таблицу «Сущность» указание на таблицу, для которой она создана.

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

2. Чтобы сделать массив в таблицу «Атрибут сущности», добавим поле «Признак массива». Именно сюда, потому что одни и те же атрибуты могут быть массивами в одних сущностях и не быть ими в других.

  • Теперь такое поле в «Параметрах» будет выглядеть так: {“Атрибут 1”: [<Значение 1>, …, <Значение N>], …}

  • Для EAV нам, помимо признака, нужно будет расширять первичный ключ у значения. Добавим поле «Порядковый номер». Если оно заполнено, это массив. Тут у EAV даже есть небольшое преимущество, потому что любой массив будет с идентификатором значений, а, значит, можно править их по отдельности. JSON этого не позволяет, там редактирование будет состоять из удаления старого значения и вставки нового, но все можно обмазать синтаксическим сахаром.

3. Чтобы сделать вложенные объекты, заведем новый «Тип данных» — «Сущность». И для этого типа данных мы в «Атрибуте» прописываем ИД сущности, на которую он будет ссылаться. Теперь, если продолжать пример с «Адресом», у нас в «Сущностях» помимо «ФЛ» и «ЮЛ»появится еще и «Адрес», который содержит собственный набор атрибутов внутри.

  • Клиент с таким атрибутом в JSON будет заполнен так: {“Адрес”: {“Полный адрес”: “г. Москва, ул. Ленина д. 1”, “Населенный пункт”: “Москва”, …}, …}

  • Для EAV мы вновь вынуждены расширять первичный ключ таблицы значений. Добавим поле «ИД родителя», если оно заполнено, это составной атрибут.

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

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

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

  • Для EAV, если с фронта прилетит JSON, его нужно будет распарсить, точно так же проверить и сохранить атомарно. Если же анкету будут присылать частями или отдельными элементами, сложно представить, как заставить это работать. У EAV тут только одно преимущество — «Порядковый номер». Он позволяет работать точечно с элементами массива.

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

JSON

EAV

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

Что еще мы не сделали, но могли бы для полноты функциональности:

  • Можно перенести связь таблицы значений с «Атрибута» на «Атрибут сущности», чтобы появилась возможность один атрибут привязывать под разными именами к одной сущности. Например, адрес прописки и проживания. И тут мы бы снова увидели, что для EAV сделать это на порядок сложнее, так как пришлось бы переделать все таблицы значений. А JSONB-поле даже трогать бы не пришлось, просто существующим связям прописать те же имена, что и в атрибуте.

  • Можно сделать возможность идентификации составного атрибута. Если у нас составной массив, это по сути вложенная таблица. Хотелось бы понимать, какой набор атрибутов в ней уникален. Для этого в «Атрибуте сущности» необходимо добавить «Признак ключа». Тут наоборот, у EAV преимущество, потому что ее поля «ИД атрибута» + «ИД родителя» + «Порядковый номер» уже создают в рамках клиента уникальную строку. Поэтому данному решению уже вообще ничего не надо для идентификации нужного элемента массива.

В завершение — немного о производительности. Мы никогда не делали вариант с EAV, но реализация с JSONB работает у нас на нагруженной системе. JSON-поля хранят бизнес-данные всех основных таблиц, таких как «Клиенты», «Услуги», «Заказы».

  • В системе более 13 миллионов клиентов

  • И более 300 миллионов оказанных услуг

Поиск произвольного набора клиентов по строчным данным, например, по фамилии или по адресу проживания укладывается в 40 секунд. При этом он успевает поискать и у нас в базе, и по API в интегрированных системах.

Все это работает благодаря gin индексу на json-полях.

Однако для контекстного поиска по адресам, которые не всегда точно и в правильной последовательности передаются с фронта, разработан особый gin-индекс. Он формируется непосредственно по адресным атрибутам из JSON-а, которые преобразовываются в tsvector с весами.

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


ссылка на оригинал статьи https://habr.com/ru/articles/890668/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *