← Все статьи

Почему EAV кажется хорошей идеей, а потом уничтожает производительность ERP

Entity–Attribute–Value на старте выглядит идеально: гибкость без миграций. Почему через годы JOIN, индексы и отчёты ломают ERP — и где EAV уместен, а где нет.

Содержание

Разработчики ERP снова и снова приходят к одной и той же идее: вместо десятков таблиц с колонками — три универсальные сущности, и любое поле добавляется без миграции. Паттерн EAV (Entity–Attribute–Value) — «сущность, атрибут, значение» — на демо выглядит почти идеально. Карточка клиента собирается за минуту. Новый справочник — одна строка в метаданных. Архитектор получает одобрение, команда радуется отсутствию ALTER TABLE.

Через два–три года та же архитектура превращается в узкое место: карточки открываются секунды, отчёты считаются часами, DBA живут в планах выполнения запросов, а разработчики боятся трогать «универсальный движок». Проблема не в том, что EAV «плохой». Проблема в том, что он решает задачу гибкости ценой производительности, сложности разработки и роста стоимости сопровождения — и эта цена становится видна только когда данных уже много.

Эта статья — углублённый разбор EAV в контексте ERP. Если вас интересует более широкий взгляд на универсальность как архитектурную ошибку, см. самую дорогую ошибку в архитектуре ERP. Здесь фокус на механике: почему паттерн притягивает, как он ломает БД и что использовать вместо него.

Ключевые выводы

EAV — ловушка отложенной сложности. На старте выигрываете в скорости изменений схемы; на горизонте миллионов строк проигрываете в каждом SELECT.

Каждое поле — дополнительный JOIN. Десять атрибутов — десять соединений; пятьдесят — запрос, который оптимизатор не стабилизирует.

Индексы и типизация ломаются в одной колонке value. Поиск по телефону и фильтр по цене требуют разных стратегий; универсальный индекс не существует.

Отчёты и аналитика — слабое место EAV. BI-инструменты и регламентированная отчётность ожидают таблицы с колонками, а не разворот атрибутов.

EAV уместен на периферии, не в ядре. Настройки, редкие пользовательские поля, прототипы — да. Заказы, остатки, проводки — нет.

EAV слишком хорош на этапе проектирования. Именно поэтому опытные архитекторы не строят на нём ядро ERP.


Введение: почему EAV возвращается снова и снова

ERP живёт в мире, где требования меняются быстрее релизного цикла. Сегодня нужен артикул, завтра — серийный номер, послезавтра — температура хранения и ещё пятьдесят полей «только для этого клиента». Классическая реляционная модель отвечает миграциями: ALTER TABLE, деплой, регрессия. EAV обещает никогда не трогать схему — только добавлять строки в таблицу значений.

Для архитектора, который видел, как интеграторы месяцами согласовывали добавление колонки в продакшене, это звучит как спасение. Для бизнеса на презентации — как «система настраивается без программистов». Для разработчика на первом спринте — как элегантная абстракция. Именно поэтому EAV возрождается в каждом поколении ERP-проектов, несмотря на горы постмортемов предыдущих команд.

EAV простыми словами: вместо того чтобы хранить клиента в строке таблицы customers с колонками name, phone, email, вы храните идентификатор сущности и набор пар «имя атрибута → значение» в отдельных строках. Одна таблица описывает всё. Гибкость бесконечна — до первого тяжёлого отчёта.


Глава 1. Что такое EAV

Классическая реляционная модель

Таблица клиентов — образец, который все понимают:

id name phone email
1 Иван Петров +380501234567 [email protected]

Преимущества:

  • Простые запросыSELECT name, phone FROM customers WHERE id = 1.
  • Индексы работают предсказуемоINDEX(phone) ускоряет поиск по телефону.
  • Понятная структура — схема видна в \d customers, в IDE, в документации.

СУБД оптимизирована под такую модель десятилетиями. Тип столбца известен, статистика осмысленна, план выполнения стабилен.

Модель EAV

Вместо колонок — строки значений:

entity_id attribute value
1 name Иван Петров
1 phone +380501234567
1 email [email protected]

В реальных системах схема чуть сложнее: отдельные таблицы entities, attributes, attribute_values с типами и ссылками. Но идея та же: структура данных описывается данными, а не языком определения схемы.

Почему разработчики считают это гениальным:

  • Можно добавлять поля без изменения схемы БД — новая строка в attributes, не ALTER TABLE.
  • Можно создавать любые сущностиentity_type = 'product', 'order', 'warehouse_cell'.
  • Не нужны миграции на каждое поле клиента (в теории).
  • Можно строить универсальный конструктор объектов — UI читает метаданные и рисует форму.

На бумаге проблема «бизнес просит ещё одно поле» решена навсегда.


Глава 2. Почему EAV выглядит идеальным решением для ERP

Требования бизнеса постоянно меняются

Типичная эволюция карточки товара:

Когда Что просят
Сегодня Артикул, производитель
Завтра Серийный номер, цвет
Через месяц Срок годности, температура хранения
Через год Ещё 50 полей под отрасль, филиал, регулятора

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

Иллюзия бесконечной гибкости

Архитектор думает:

«Мы создадим одну универсальную таблицу и больше никогда не будем менять структуру базы.»

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

Мечта о полностью универсальной ERP

На слайде:

  • любой справочник;
  • любой документ;
  • любой бизнес-процесс;
  • любой набор полей —

всё хранится одинаково. Один движок сохранения, один движок отчётов, один API getField('...'). Кажется, что архитектурная проблема ERP решена навсегда.

На практике вы не решили проблему — вы отложили её в слой запросов, индексов и отладки. Подробнее о цене универсальности — в статье про универсальную архитектуру ERP.


Глава 3. Первый удар по производительности

Простой SELECT превращается в кошмар

В обычной модели:

SELECT name, phone, email
FROM customers
WHERE id = 1;

Одно обращение по первичному ключу. Микросекунды.

В EAV для тех же трёх полей:

SELECT
    MAX(CASE WHEN a.code = 'name'  THEN av.value_string END) AS name,
    MAX(CASE WHEN a.code = 'phone' THEN av.value_string END) AS phone,
    MAX(CASE WHEN a.code = 'email' THEN av.value_string END) AS email
FROM entities e
JOIN attribute_values av ON av.entity_id = e.id
JOIN attributes a ON a.id = av.attribute_id
WHERE e.id = 1
  AND e.entity_type = 'customer'
GROUP BY e.id;

Уже три атрибута — JOIN, GROUP BY, условный агрегат (разворот). Альтернатива — отдельный JOIN на каждое поле:

SELECT v_name.value  AS name,
       v_phone.value AS phone,
       v_email.value AS email
FROM entities e
JOIN attribute_values v_name  ON v_name.entity_id  = e.id
JOIN attributes a_name        ON a_name.id = v_name.attribute_id  AND a_name.code = 'name'
JOIN attribute_values v_phone ON v_phone.entity_id = e.id
JOIN attributes a_phone       ON a_phone.id = v_phone.attribute_id AND a_phone.code = 'phone'
JOIN attribute_values v_email ON v_email.entity_id = e.id
JOIN attributes a_email       ON a_email.id = v_email.attribute_id AND a_email.code = 'email'
WHERE e.id = 1;

Для каждого поля — дополнительное соединение. Карточка клиента с 20 полями — 20 JOIN только чтобы показать форму.

Рост количества соединений

Полей на объекте Характер запроса
10 Терпимо на малых объёмах
50 Заметная нагрузка на CPU, сложные планы
200 Отдельный инженерный проект, кэши, денормализация

Стоимость чтения растёт линейно с числом атрибутов в худшем случае и хуже при фильтрации по нескольким полям одновременно.

Оптимизатор БД начинает страдать

Симптомы:

  • Сложные планы выполнения — десятки узлов, вложенные циклы и хеш-соединения на миллионах строк в attribute_values.
  • Нестабильные запросы — после ANALYZE план меняется, вчера быстрый отчёт сегодня «висит».
  • Неожиданные деградации — добавили один атрибут, и запрос списка заказов замедлился в пять раз, потому что оптимизатор выбрал другой порядок JOIN.

Реляционные СУБД отлично оптимизируют фиксированные схемы. EAV превращает каждый SELECT в динамическую головоломку.


Глава 4. Индексы перестают спасать

В обычной таблице индекс очевиден

CREATE INDEX idx_customers_phone ON customers(phone);

SELECT * FROM customers WHERE phone = '+380501234567';

Сканирование по индексу, тысячи строк в секунду. DBA счастлив.

В EAV всё хранится в универсальных столбцах

Логически:

attribute = 'phone'
value = '+380501234567'

Физически — миллионы строк в attribute_values, где в value_string лежат имена, телефоны, даты, JSON и числа вперемешку.

Проблемы:

  • Индекс только по value бесполезен для селективности — половина таблицы «Иван» и «красный».
  • Индекс по (attribute_id, value_string) помогает одному атрибуту, но раздувается с каждым новым типом сущности.
  • Композитные индексы множатся: для phone, для sku, для status — отдельные стратегии, потому что типы и кардинальность разные.

Индексов становится слишком много

Для каждой «горячей» группы атрибутов DBA добавляет частичный или составной индекс. Итог:

  • рост размера БД — индексы сопоставимы с данными;
  • рост потребления RAM — рабочий набор данных не помещается в память;
  • замедление записи — каждая вставка значения обновляет несколько B-tree.

В нормализованной таблице одна запись заказа — одна строка, несколько индексов. В EAV одна запись заказа с 30 полями — 30 INSERT и каскад обновлений индексов.


Глава 5. Фильтрация становится дорогой

Бизнес хочет простой отчёт

«Покажи товары красного цвета дороже 100 евро.»

В нормальной схеме:

SELECT id, name, price
FROM products
WHERE color = 'red'
  AND price > 100;

Два условия на типизированных столбцах. Индекс по (color, price) или битовый план — стандартная задача.

В EAV — цепочка JOIN

Нужно найти сущности, у которых одновременно:

  • есть атрибут color со значением 'red';
  • есть атрибут price со значением > 100.

Типичный паттерн:

SELECT e.id
FROM entities e
WHERE e.entity_type = 'product'
  AND e.id IN (
      SELECT av1.entity_id
      FROM attribute_values av1
      JOIN attributes a1 ON a1.id = av1.attribute_id
      WHERE a1.code = 'color' AND av1.value_string = 'red'
  )
  AND e.id IN (
      SELECT av2.entity_id
      FROM attribute_values av2
      JOIN attributes a2 ON a2.id = av2.attribute_id
      WHERE a2.code = 'price' AND av2.value_number > 100
  );

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

Сложные фильтры — катастрофа

Особенно болезненно, когда в одном отчёте:

  • десятки атрибутов в SELECT;
  • фильтры по пяти–десяти полям;
  • GROUP BY и агрегации;
  • сортировка по нетипизированному value.

Каждое условие — ещё ветка в плане. Пользователь ждёт. Бизнес спрашивает, почему «простой список» не работает.


Глава 6. Отчёты становятся самым слабым местом

ERP живёт отчётами

Операционный учёт — формы и карточки. Но ценность ERP для руководства — в сводках: продажи по регионам, остатки на дату, дебиторка, себестоимость, план-факт. Эти запросы:

  • трогают большие объёмы данных;
  • выполняются регулярно и параллельно;
  • не терпят деградации «в пять раз медленнее с понедельника».

EAV плохо подходит для аналитики

Причины:

  • много JOIN — каждый столбец отчёта разворачивается из атрибутов;
  • много преобразованийCAST, CASE, приведение типов из value_string;
  • сложная агрегацияSUM(price) требует сначала собрать price в строку на каждую сущность.

Частый исход: отчёты уезжают в ночной ETL в отдельное хранилище с нормализованными витринами. Ядро ERP на EAV остаётся для ввода; аналитика живёт в копии данных. Вы платите за две модели вместо одной.

Почему BI-системы не любят EAV

Power BI, Metabase, внутренние конструкторы ожидают:

  • таблицы с именами;
  • колонки с типами;
  • связи по внешним ключам.

EAV заставляет либо писать представления с сотнями строк, либо настраивать семантический слой, который эмулирует нормальную схему поверх EAV. По сути вы строите нормализованную модель второй раз — поверх первой, чтобы исправить архитектурный выбор.


Глава 7. Типизация начинает разрушаться

В одной колонке хранится всё

Типичная универсальная ячейка value_string (или несколько колонок value_*):

100
150.5
Иван Петров
2025-01-01
true
{"nested": "json"}

Что такое значение?

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

Каждая операция требует преобразования

WHERE CAST(av.value_string AS NUMERIC) > 100
  • CAST убивает использование индекса по значению.
  • Ошибки типов всплывают в рантайме: 'N/A' в поле цены ломает отчёт.
  • Сравнение дат как строк даёт неверную сортировку без строгого формата.

Постоянные CAST, CONVERT, проверки в приложении — дополнительный CPU на каждой строке и источник тихих багов в отчётности.


Глава 8. Объём данных растёт взрывным образом

Простая математика

Объектов Полей на объект Строк в attribute_values
100 000 100 10 000 000
1 000 000 50 50 000 000
500 000 заказов 80 полей 40 000 000 только по заказам

В нормализованной модели 100 000 клиентов — 100 000 строк. В EAV — 100 000 × N атрибутов.

Что происходит дальше

Растут:

  • таблицы — резервное копирование и восстановление измеряются часами;
  • индексы — пересборка и очистка становятся плановым проектом;
  • репликация — отставание реплики для чтения заметно операторам;
  • стоимость облака — больше диска, больше операций ввода-вывода, больше RAM.

Каждое новое поле увеличивает объём

Даже если атрибут заполнен у 1% записей, в горизонтальной EAV-модели пустое значение часто всё равно хранится или усложняет запросы с внешним соединением. Добавление «редкого» поля в конфигураторе не бесплатно для инфраструктуры.


Глава 9. Почему разработка тоже становится сложнее

Запросы трудно читать

Простой список заказов за неделю в EAV — страница SQL с подзапросами, алиасами av_status, a_status, av_total. Код-ревью превращается в археологию. Новый разработчик не может ответить на вопрос «какие поля у заказа?» без запроса к attributes.

Ошибки становятся дороже

  • Логика распределена между метаданными, универсальным сервисом и SQL.
  • Данные неочевидны — опечатка в code атрибута ломает отчёт для одного клиента.
  • Сложно отлаживать — баг «не тот статус» требует пройти цепочку сущность → значения → переопределение клиента 17.

Зависимость от «магов»

Появляются два–три человека, которые «понимают движок». Они уходят — онбординг занимает месяцы. Это не зрелая архитектура, а критическая зависимость от единицы специалистов. См. также почему ERP превращаются в монолитов — EAV ускоряет тот же процесс.


Глава 10. Реальная история большинства ERP на EAV

Типичная траектория — не теория, а паттерн из десятков проектов.

Этап 1. Восторг

«Мы создали универсальную платформу. Любой справочник за день. Конкуренты делают миграции — мы делаем конфиг.»

Этап 2. Рост

Появляются клиенты, документы, миллионы строк в attribute_values. Функциональность растёт. Всё ещё терпимо на мощном железе.

Этап 3. Замедление

Жалобы пользователей:

  • долго открываются карточки;
  • медленно работают отчёты;
  • тормозит поиск по справочникам.

Поддержка ссылается на «большой объём данных». DBA впервые открывают EXPLAIN ANALYZE.

Этап 4. Костыли

  • кэши карточек и списков в Redis;
  • материализованные представления для топ-10 отчётов;
  • денормализованные таблицы orders_flat для UI;
  • отдельный поисковый индекс (Elasticsearch, OpenSearch) для справочников.

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

Этап 5. Фактический отказ от EAV

Система обрастает специализированными таблицами: orders, order_lines, inventory_movements. EAV остаётся для «кастомных полей» и устаревших конфигураций. Получается гибрид — самая честная архитектура, но дорогая: вы платите за оба мира, пока не вырежете старое ядро.


Глава 11. Когда EAV действительно полезен

EAV — не антипаттерн everywhere. Это инструмент с узкой зоной применения.

Настройки системы

Конфигурация модулей, флаги функций, параметры интеграций — мало записей, редкие изменения, нет тяжёлой аналитики.

Метаданные

Когда структура заранее неизвестна и объём мал: описание полей формы, схема импорта, маппинг внешней системы.

Редко используемые дополнительные поля

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

Небольшой объём и некритичная производительность

Прототип, внутренний инструмент, платформа без кода — пользователь осознанно меняет гибкость на скорость запросов.

Правило большого пальца: если от поля зависят остатки, деньги, налоги или регламентированный отчёт — это колонка в типизированной таблице, не строка в EAV.


Глава 12. Что использовать вместо EAV

Классические нормализованные таблицы

Основа большинства успешных ERP. Заказ — таблица orders, строки — order_lines, внешние ключи, инварианты в коде или CHECK. Скучно, предсказуемо, быстро.

JSON-поля для редких атрибутов

PostgreSQL JSONB, MySQL JSON — гибкость без JOIN на каждое поле:

ALTER TABLE products ADD COLUMN extras JSONB;
-- extras->>'color', индекс GIN при необходимости

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

Гибридная архитектура

Самый популярный компромисс в зрелых системах:

Слой Хранение
Ядро (заказ, товар, остаток) Типизированные колонки
Расширения (кастом клиента) JSONB или отдельная EAV-таблица с малым объёмом
Настройки UI Метаданные

80% запросов бьют по нормальным таблицам; 20% терпят гибкость.

Entity Engine с физическими таблицами

Метаданные описывают сущность, но при публикации схемы система генерирует реальные таблицы (ALTER или отдельная схема на арендатора). Гибкость на этапе проектирования, производительность нормальной БД в рантайме. Сложность — в инструменте генерации и миграциях, но это честная сложность, а не скрытая в JOIN.


Заключение

Главная проблема EAV

Не в том, что это «плохая идея». В том, что это слишком хорошая идея на этапе проектирования.

Архитектор на доске видит:

  • гибкость;
  • универсальность;
  • отсутствие миграций на каждое поле.

Он не видит (или откладывает):

  • миллионы строк в таблице значений;
  • лавину JOIN в каждом отчёте;
  • разрушение индексов в одной колонке value;
  • боль аналитики и BI;
  • рост стоимости сопровождения и зависимость от немногих «знающих» людей.

Одинаковый путь большинства крупных ERP

  1. Восторг от универсальности.
  2. Борьба с производительностью — кэши, железо, DBA.
  3. Денормализация — flat-таблицы, материализованные view.
  4. Возврат к специализированным таблицам в ядре.

Именно поэтому опытные архитекторы используют EAV как вспомогательный инструмент — настройки, custom fields, прототипы — но не строят на нём ядро системы, через которую проходят заказы, склад и деньги.

Если вы выбираете схему данных для новой ERP сегодня, задайте один вопрос: «Сможем ли мы через три года объяснить EXPLAIN главного отчёта по продажам начинающему разработчику за час?» На нормализованной модели — да. На EAV в ядре — скорее всего, нет. И это стоит дороже любой сэкономленной миграции на старте.