Меня зовут Дмитрий Бабчук, я делаю О’Полку — доску объявлений: авто, недвижимость, электроника, одежда, услуги. Проект пишем небольшой командой, стек — FastAPI + SQLModel + PostgreSQL 16 на бэке и Next.js 14 (App Router) на фронте.
В какой-то момент любой каталог-агрегатор упирается в один и тот же вопрос: как хранить характеристики, если у автомобиля это «пробег, коробка, привод», у кроссовок — «размер, сезон», а у сплит-системы — «мощность, площадь охлаждения»? У каждой категории свой набор полей, их сотни, и они постоянно меняются. Ниже — как мы решили это через EAV, где это выстрелило, а где отдаёт болью. Будет код из боевого репозитория.
Почему не колонки
Первое, что приходит в голову — таблица listing с колонками под всё: mileage, shoe_size, power_kw… Это тупик:
Колонок станут сотни, 99% из них
NULLдля любой конкретной строки.Каждая новая категория или атрибут — это
ALTER TABLEи миграция на проде.Продуктовая команда не может завести «Тип застёжки» у ботинок, не дёргая бэкендера.
Второй вариант — JSONB-колонка attributes с произвольным словарём. Уже лучше, гибко, но: нет справочника допустимых значений (каждый пишет размер как хочет — «42», «42.0», «42-й»), тяжело строить фасетные фильтры с типами и единицами измерения, и валидация целиком на приложении.
Мы выбрали классический EAV (Entity–Attribute–Value) — но с важной оговоркой, к которой вернусь: сами категории и их поля у нас тоже данные, а не схема.
Три таблицы
Вся модель каталога — это Category, Attribute и ListingAttributeValue. На SQLModel (это тонкая обёртка над SQLAlchemy + Pydantic):
class AttrType(str, Enum): string = "string" number = "number" enum = "enum" multi_enum = "multi_enum" boolean = "boolean" class Category(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) parent_id: Optional[int] = Field(default=None, foreign_key="category.id", index=True) name: str slug: str = Field(index=True) sort: int = 0 class Attribute(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) category_id: int = Field(foreign_key="category.id", index=True) code: str # машинное имя: "mileage", "shoe_size" name: str # человеческое: "Пробег", "Размер обуви" type: AttrType = Field(default=AttrType.string) required: bool = False options: Optional[list] = Field(default=None, sa_column=Column(JSON)) # для enum group: Optional[str] = None # секция формы: "Технические характеристики" unit: Optional[str] = None # единица: "км", "л.с." sort: int = 0 class ListingAttributeValue(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) listing_id: int = Field(foreign_key="listing.id", index=True) attribute_id: int = Field(foreign_key="attribute.id", index=True) value: str # ВСЁ хранится строкой — к этому ещё вернёмся
Category— дерево черезparent_id(self-FK): «Электроника → Телефоны → Мобильные телефоны».Attribute— описание поля: тип, обязательность, дляenum— списокoptions, плюсgroup(в какую секцию формы) иunit(единица измерения). Это и есть метаданные, из которых фронт сам рисует форму подачи и панель фильтров.ListingAttributeValue— собственно значения: по строке на каждый заполненный атрибут объявления. Значение всегда строка — эта деталь потом выйдет и плюсом, и минусом.
Обратите внимание: Attribute.type — не для БД, а для UI и валидации. БД про типы ничего не знает, value для неё всегда text.
Категории — это данные, а не схема
Ключевой инвариант проекта, который мы прямо прописали в правилах репозитория:
Новая категория = записи в
seed.py, а не колонки в БД. Новые колонки под категории не добавлять.
Весь каталог описан в идемпотентном сид-скрипте. Пакет атрибутов для категории выглядит как список кортежей, а функция seed_attr_pack апсертит их при старте — существующие обновляет, новые создаёт:
def seed_attr_pack(session, category_id, attrs): for i, (code, name, atype, required, options, group, unit) in enumerate(attrs): a = session.exec( select(Attribute).where( Attribute.category_id == category_id, Attribute.code == code, ) ).first() if a: # уже есть — обновляем a.name, a.type, a.required = name, atype, required a.options, a.group, a.unit, a.sort = options, group, unit, i else: # нет — создаём a = Attribute(category_id=category_id, code=code, name=name, type=atype, required=required, options=options, group=group, unit=unit, sort=i) session.add(a) session.commit()
Практический бонус: чтобы, скажем, обновить список марок телефонов до актуального, я меняю один список опций в seed.py, а при рестарте api сид сам перезаписывает options у существующего атрибута — без миграции и без ручного SQL на проде. Схема БД при этом неизменна: миграции (Alembic) мы гоняем только когда реально меняется таблица, а не когда добавляется категория.
Наследование атрибутов
Атрибуты висят на разных уровнях дерева. «Состояние» логично задать один раз на «Электронике», а «Пробег» — только на «Автомобилях». Чтобы у объявления собрать полный набор полей, нужна вся цепочка от категории до корня:
def _category_chain(session, category_id): """Категория и все её родители — вверх по дереву до корня.""" chain, cur = [], category_id while cur: chain.append(cur) cur = session.get(Category, cur).parent_id return chain
Форма подачи запрашивает атрибуты по всей цепочке, группирует по Attribute.group и рисует нужный виджет по Attribute.type: enum → выпадашка из options, boolean → чекбокс, number → поле с unit сбоку, и так далее. Ни строчки хардкода под конкретную категорию — форма целиком data-driven.
Фасетные фильтры: где EAV окупается
Самое приятное в EAV — фильтры. Пользователь на странице категории «Женская обувь» тыкает «размер 38», «сезон: демисезон», «цена до 5000» — и всё это ложится в один построитель запроса. Точечный фильтр по значению — это подзапрос IN:
# attr=code:value, повторяемый: ?attr=shoe_size:38&attr=season:Демисезон groups: dict[str, list[str]] = {} for item in attr: code, val = item.split(":", 1) groups.setdefault(code, []).append(val) for code, vals in groups.items(): ids = attr_ids(code) # id атрибута по коду в ветке категории stmt = stmt.where(Listing.id.in_( select(ListingAttributeValue.listing_id).where( ListingAttributeValue.attribute_id.in_(ids), ListingAttributeValue.value.in_(vals), ) ))
Два нюанса, которые важно не проглядеть.
Резолв кода в ветке, а не глобально. Код size есть и у одежды, и у обуви, и это разные атрибуты с разными id. Поэтому код резолвится в области видимости конкретной категории — объединении цепочки родителей и всего поддерева:
scope = set(_category_chain(session, category_id)) | set(_subtree_ids(session, category_id)) def attr_ids(code): return [a.id for a in session.exec( select(Attribute).where( Attribute.code == code, Attribute.category_id.in_(scope), ) ).all()]
Числовые диапазоны поверх строк. Раз value — строка (там и «54», и «2.7», и «90 000 км»), для диапазона «год от…до» приходится на лету чистить строку до цифр и точки и кастовать в NUMERIC. И обязательно защищаться от мусора вроде «1.2.3», иначе кривое значение уронит весь запрос — поэтому CASE: некастуемое становится NULL, а не исключением:
cleaned = func.regexp_replace(ListingAttributeValue.value, r"[^0-9.]", "", "g") numexpr = case( (cleaned.op("~")(r"^\d+(\.\d+)?$"), cast(cleaned, Numeric)), else_=None, ) sub = select(ListingAttributeValue.listing_id).where( ListingAttributeValue.attribute_id.in_(ids) ) if lo is not None: sub = sub.where(numexpr >= lo) if hi is not None: sub = sub.where(numexpr <= hi) stmt = stmt.where(Listing.id.in_(sub))
Некрасиво? Да. Но это цена за схему, в которой БД не знает типов. И один и тот же билдер обслуживает и ленту каталога, и счётчик результатов, и точки на карте.
Побочный суперспособность: «много значений у одного атрибута»
Недавно мы добавляли варианты товара — когда бизнес продаёт кроссовки в размерах 36–41 одной карточкой (как размерная сетка на маркетплейсах). Возник вопрос: как сделать так, чтобы такая карточка находилась по фильтру «размер 38»?
И тут выяснилось, что ничего доделывать в фильтре не надо. Схема ListingAttributeValue — это строка-на-значение, и ничто не мешает объявлению иметь несколько строк одного атрибута. Мы просто пишем каждый размер-вариант отдельной строкой в атрибут shoe_size:
def _index_variant_sizes(session, listing, variants, written): attr = _size_attr(session, listing.category_id) # shoe_size / size if not attr: return for v in variants: pair = (attr.id, v["label"]) if pair in written: # без дублей continue written.add(pair) session.add(ListingAttributeValue( listing_id=listing.id, attribute_id=attr.id, value=v["label"], ))
Фильтр из прошлой главы — attribute_id IN (...) AND value IN ('38') — матчит объявление, если у него есть хоть одна подходящая строка. Multi-value заработал бесплатно, ровно потому что модель изначально «одна строка = одно значение». Приятный случай, когда старое архитектурное решение окупается спустя полгода.
Где EAV честно болит
Чтобы статья не выглядела рекламой EAV, вот цена, которую мы платим:
Всё — строка. Отсюда канонизация чисел при сохранении (
«03» → «3»,«3.0» → «3»,«2,5» → «2.5»), иначе точечные фильтры-чекбоксы не совпадут. Отсюда же регэкспы иCASTв диапазонах. Типобезопасности на уровне БД нет — вся ответственность на приложении.Сортировка
enum-опций. В БДoptionsлежат в порядке вставки. Ходовые размеры хочется показать первыми, а в развёрнутом списке — по возрастанию, включая половинки (37.5, 38.5). Эту сортировку делает фронт, потому что «41 и больше» строкой не отсортируешь.JOIN-ы вместо колонок. Каждый фильтр — это подзапрос по
ListingAttributeValue. На наших объёмах индексов по(attribute_id, value)иlisting_idхватает с запасом, но на «Авито-масштабе» здесь пришлось бы думать про денормализацию или вынос фасетов в поисковый движок (у нас для полнотекста уже стоит OpenSearch, и фасеты — очевидный следующий кандидат туда).Нельзя выразить «значение зависит от другого поля» на уровне схемы — только соглашениями в коде.
Когда это оправдано
EAV — не серебряная пуля, а осознанный размен: гибкость каталога в обмен на сложность запросов и потерю типов в БД. Для доски объявлений, где категорий десятки, поля у них радикально разные и меняются еженедельно силами продукта, а не бэкенда, — размен выгодный. Для сервиса с 3–5 стабильными типами сущностей я бы взял обычные колонки и не выдумывал.
Главный урок для нас оказался даже не про EAV как таковой, а про принцип «категории и их поля — это данные, а не схема». Именно он позволил обновлять каталог сид-скриптом без миграций и получать вот такие бесплатные бонусы вроде multi-value фильтра.
Если интересно посмотреть, как это выглядит живьём — opolka.ru. Вопросы и «а вот у нас было иначе» — очень welcome в комментариях, расскажу подробнее про любой кусок.
Дмитрий Бабчук, О’Полка