Содержание
Разработчики 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 | |
|---|---|---|---|
| 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 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
- Восторг от универсальности.
- Борьба с производительностью — кэши, железо, DBA.
- Денормализация — flat-таблицы, материализованные view.
- Возврат к специализированным таблицам в ядре.
Именно поэтому опытные архитекторы используют EAV как вспомогательный инструмент — настройки, custom fields, прототипы — но не строят на нём ядро системы, через которую проходят заказы, склад и деньги.
Если вы выбираете схему данных для новой ERP сегодня, задайте один вопрос: «Сможем ли мы через три года объяснить EXPLAIN главного отчёта по продажам начинающему разработчику за час?» На нормализованной модели — да. На EAV в ядре — скорее всего, нет. И это стоит дороже любой сэкономленной миграции на старте.