Одним из главных требований к каталогу является возможность быстро искать и находить его элементы по различным критериям.

Существует множество подходов к реализации таких требований. Это и nosql решения и механизмы работы с json в реляционных СУБД. До nosql эпохи, решать такие задачи приходилось средствами реляционных БД.

Основная причина по которой реляционные СУБД плохо подходят для решения таких задач это разнообразие характеристик товаров. Набор характеристик к примеру для одежды и смартфонов будет совершенно разный. В самом деле не создавать же для каждой категории товаров отдельную таблицу с со своим набором реквизитов.

По этой причине в большинстве случаев в реляционных БД используется EAV (Entity Attribute Value) модель данных в тех или иных вариациях.

Недостатков у такой модели множество. Чаще всего EAV модель критикуют чрезмерную сложность, так же за то, что по сути схема данных храниться в самих данных.

Есть мнение, что EAV вообще является анти паттерном, что тоже не лишено оснований, однако надо заметить, что есть и другое мнение, что лучше такая схема, чем вообще отсутствие таковой.

Рискуя навлечь на себя гнев сообщества хочу представить свой вариант реализации каталога. Это не совсем EAV, скорее его по мотивам.

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

Всё описанное далее предполагает использование СУБД Postgresql.

Довольно преамбул, вот упрощённая ER-диаграмма.

Из диаграммы видно, что товар в таблице products ссылается на категорию из таблицы product_categories.

Таблица product_categories может иметь иерархическую структуру.

На таблицу products ссылаются три таблицы:

  1. Цены товаров  (product_prices)

  2. Фото товаров (products_images)

  3. Значения свойств характеристик товаров (property_values).

Все значения характеристик всех товаров хранятся целочисленном поле таблицы property_values.

Целочисленное поле в данном случае позволяет хранить разумеется численные, а кроме того булевы и ссылочные значения характеристик.

Текстовое представление ссылочных значений хранятся в таблице property_values_references.

Эта таблица не принимает участие в основных выбирающих запросах и нужна только для формирования в клиентском приложении выпадающих списков возможных вариантов отбора.

Она ссылается на таблицу свойств категорий (product_category_properties) полем property_id.

Таблица свойств категорий (product_category_properties) полем parent_id ссылается на саму себя, что позволяет реализовать в клиентском приложении каскадные фильтры неограниченной вложенности.

Скрипт создания таблиц и индексов каталога
BEGIN;

CREATE TABLE IF NOT EXISTS product_categories
(
    id serial NOT NULL,
    parent_id integer REFERENCES product_categories (id),
    title character varying(64),
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS products
(
    id serial NOT NULL,
    category_id integer NOT NULL REFERENCES product_categories (id),
    title character varying(128) NOT NULL,
    description text NOT NULL,
    PRIMARY KEY (id)
);

CREATE INDEX IF NOT EXISTS id_and_category_id
    ON products USING btree
    (id ASC NULLS LAST, category_id ASC NULLS LAST);
    
DO $$ /* вот так, поскольку CREATE TYPE не поддерживает IF NOT EXISTS параметр */
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'property_value_type') THEN
        CREATE TYPE property_value_type AS enum ('boolean', 'integer', 'reference');
    END IF;
END
$$;

CREATE TABLE IF NOT EXISTS product_category_properties
(
    id serial NOT NULL,
    parent_id integer REFERENCES product_category_properties (id),
    category_id integer NOT NULL REFERENCES product_categories (id),
    title character varying(64) NOT NULL,
    property_type property_value_type NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS property_values
(
    property_id integer NOT NULL REFERENCES product_category_properties (id),
    product_id integer NOT NULL REFERENCES products (id),
    value smallint NOT NULL,
    PRIMARY KEY (property_id, product_id)
);

CREATE INDEX IF NOT EXISTS property_id_and_value_and_product_id
    ON property_values USING btree
    (property_id ASC NULLS LAST, value ASC NULLS LAST, product_id ASC NULLS LAST);

CREATE TABLE IF NOT EXISTS product_prices
(
    product_id integer NOT NULL REFERENCES products (id),
    period timestamp NOT NULL,
    price integer NOT NULL,
    PRIMARY KEY (product_id, period)
);

CREATE INDEX IF NOT EXISTS product_id_and_price_and_period
    ON product_prices USING btree
    (product_id ASC NULLS LAST, price ASC NULLS LAST, period ASC NULLS LAST);

CREATE TABLE IF NOT EXISTS product_images
(
    id serial NOT NULL,
    product_id integer NOT NULL REFERENCES products (id),
    title character varying(64) NOT NULL,
    filename character varying(64) NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS property_value_references
(   
    id integer NOT NULL,
    property_id integer NOT NULL REFERENCES product_category_properties (id),
    value character varying(128) NOT NULL,
    PRIMARY KEY (id, property_id)
);

END;

У предложенной схемы есть положительный side-эффект, ничто не мешает нам наследовать свойства вышестоящих категорий.

К примеру, у корневой или любой категории в иерархии есть некоторые свойства.

Следующий запрос вернёт все свойства указанной категории плюс свойства всех его родительских категорий.

Будет полезен при формировании формы фильтра на клиенте.

WITH RECURSIVE r AS (
    SELECT * FROM product_categories
    WHERE id = ?
		UNION ALL
    SELECT product_categories.*
    FROM product_categories JOIN r ON r.parent_id = product_categories.id
)
SELECT pcp.id, pcp.parent_id, pcp.category_id, pcp.property_type, pcp.title
FROM product_category_properties pcp
WHERE pcp.category_id IN (SELECT id FROM r)

Следующий запрос вернёт все свойства, как заполненные значениями так и пустые.

Будет полезен при динамическом формировании формы создания, редактирования и просмотра карточки товара.

WITH RECURSIVE r AS (
    SELECT * FROM product_categories
		WHERE id = (
    		SELECT category_id FROM products
    		WHERE id = ?
		)
		UNION ALL
    SELECT product_categories.*
    FROM product_categories JOIN r ON r.parent_id = product_categories.id
)
SELECT pcp.id, pcp.parent_id, pcp.category_id, pcp.value_type, pcp.title, pv.value
FROM product_category_properties pcp
LEFT JOIN property_values pv ON pcp.id = pv.property_id AND product_id = ?
WHERE pcp.category_id IN (SELECT id FROM r)

Следующий запрос вернёт наименование, описание и последнюю цену товара.

SELECT id, title, description, price
FROM products p
LEFT JOIN product_prices pp ON p.id = pp.product_id AND pp.period = (
    SELECT MAX(period)
    FROM product_prices
    WHERE pp.product_id = product_prices.product_id
) 
WHERE p.id = ?

Вариантов основного для каталога выбирающего запроса, множество, но все они - разновидности двух подходов: правильного, и не правильного :).

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

SELECT id
FROM products
WHERE id IN (
    SELECT product_id
    FROM product_prices pp
    WHERE period = (
        SELECT max(period)
        FROM product_prices
        WHERE pp.product_id = product_prices.product_id 
    ) AND ("price" >= ? AND "price" <= ?) AND product_id IN (
        SELECT product_id 
        FROM property_values
        WHERE (property_id = ? AND value = ?) 
        OR (property_id = ? AND value = ?) 
        OR (property_id = ? AND value = ?) 
        OR (property_id = ? AND value = ?)
        OR (property_id = ? AND value = ?)
        OR (property_id = ? AND value = ?)
        OR (property_id = ? AND value = ?)
        GROUP BY product_id HAVING COUNT(*) = ? /* здесь количество условий, в данном случае должно быть 7 */
    )
) AND category_id = ?

На первый взгляд выглядит элегантным, однако, такой запрос не может полноценно использовать индекс и выполняется не самым быстрым образом. Следующий вариант гораздо быстрее.

SELECT id 
FROM products p
JOIN property_values pv ON (p.id = pv.product_id AND pv.property_id = 1 AND pv.value >= ?) 
JOIN property_values pv2 ON (pv.product_id = pv2.product_id AND pv2.property_id = ? AND pv2.value = ?)
JOIN property_values pv3 ON (pv.product_id = pv3.product_id AND pv3.property_id = ? AND pv3.value = ?)
JOIN property_values pv4 ON (pv.product_id = pv4.product_id AND pv4.property_id = ? AND pv4.value = ?)
JOIN property_values pv5 ON (pv.product_id = pv5.product_id AND pv5.property_id = ? AND pv5.value = ?) 
JOIN property_values pv6 ON (pv.product_id = pv6.product_id AND pv6.property_id = ? AND pv6.value = ?)
JOIN property_values pv7 ON (pv.product_id = pv7.product_id AND pv7.property_id = ? AND pv7.value = ?)
JOIN product_prices pp ON p.id = pp.product_id AND pp.period = (
    SELECT MAX(period)
    FROM product_prices
    WHERE pp.product_id = product_prices.product_id AND "price" >= ? AND "price" <= ?
) 
WHERE p.category_id = ?

Но самым быстрым оказался следующий вариант запроса.

SELECT id 
FROM products p
JOIN (
    SELECT pv.product_id 
    FROM property_values pv 
    JOIN property_values pv2 ON pv.product_id = pv2.product_id AND pv2.property_id = ? AND pv2.value = ? 
    JOIN property_values pv3 ON pv.product_id = pv3.product_id AND pv3.property_id = ? AND pv3.value = ?
    JOIN property_values pv4 ON pv.product_id = pv4.product_id AND pv4.property_id = ? AND pv4.value = ?
    JOIN property_values pv5 ON pv.product_id = pv5.product_id AND pv5.property_id = ? AND pv5.value = ? 
    JOIN property_values pv6 ON pv.product_id = pv6.product_id AND pv6.property_id = ? AND pv6.value = ?
    JOIN property_values pv7 ON pv.product_id = pv7.product_id AND pv7.property_id = ? AND pv7.value = ?
    WHERE pv.property_id = ? AND pv.value >= ? 
) AS pv ON p.id = pv.product_id 
JOIN product_prices pp ON p.id = pp.product_id AND pp.period = (
    SELECT MAX(period)
    FROM product_prices
    WHERE pp.product_id = product_prices.product_id AND "price" >= ? AND "price" <= ?
) 
WHERE category_id = ?

Очень просто, не правда ли?

В последней строке запросов проверяется вхождение выбранных товаров в целевую категорию, это условие здесь не обязательное, его стоит добавить в запросы в случае если ни каких других условий не задано, что бы избежать выборки всех значений.

Такой запрос легко генерируется алгоритмически используя параметры из url вида:

?filter[price][gte]=100&filter[price][lte]=1000&filter[property][1][eq]=1&filter[property][2][gte]=1&filter[property][3][lte]=1 и т.д. и позволяет накладывать любые фильтры ( >, <, >=, <=, =, <>, in, not in) в любых комбинациях.

Используя индексы созданные по полям property_id, value, product_id таблицы property_values и product_id, price, period таблицы product_prices получилось не только избавиться от полного сканирования таблиц, а добиться "index scan only" на всех этапах составления плана запроса оптимизатором.

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

Поэтому, чтобы указать планировщику, что бы он не пытался искать оптимальный порядок соединений можно уменьшить join_collapse_limit параметр конфигурации postgresql, по умолчанию он равен 8.

Тестирование

Стало любопытно, после какого количества товаров и значений их свойств, время исполнения запросов станет непозволительно большим и для этих целей набросал скрипт генерации данных.

Скрипт генерации тестовых данных
-- Сгенерировать 5 корневых категорий
TRUNCATE product_categories  RESTART IDENTITY CASCADE;
INSERT INTO product_categories(id, parent_id, title)
VALUES (1, null, 'Product category 1'),
       (2, null, 'Product category 2'),
       (3, null, 'Product category 3'),
       (4, null, 'Product category 4'),
       (5, null, 'Product category 5');
       
-- 25 дочерних категорий 
INSERT INTO product_categories(id, parent_id, title)
SELECT id+1, (id/5), 'Product category ' || id+1
FROM generate_series(5, 29) id;

-- 300 свойств категорий
TRUNCATE product_category_properties  RESTART IDENTITY CASCADE;
INSERT INTO product_category_properties(id, title, parent_id, category_id, value_type)
SELECT id+1, 'Product category property ' || id+1, null, (SELECT * FROM generate_series(6, 30, 1) LIMIT 1 OFFSET (id/12)),
(array['boolean', 'boolean', 'boolean', 'boolean', 'boolean', 'integer', 'reference'])[ceil(random() * 7)]::property_value_type
FROM generate_series(0, 299) id;

-- 1 млн. товаров
TRUNCATE products  RESTART IDENTITY CASCADE;
INSERT INTO products(id, title, description, category_id)
SELECT id+1 as id, 'Product title ' || id+1, 'Product title ' || id+1, (SELECT * FROM generate_series(6, 30, 1) LIMIT 1 OFFSET id/40000) as category_id
FROM generate_series(0, 999999) id;

-- 2 млн. цен товаров
TRUNCATE product_prices;
INSERT INTO product_prices(product_id, price, period)
SELECT (id/2)+1, floor(random()*(10000-1+1))+1, now() -  (
    interval '1 day' * round(random() * 100) + interval '1 hours' * round(random() * 100) +    interval '1 minutes' * round(random() * 100) +
    interval '1 seconds' * round(random() * 100) +    interval '1 milliseconds' * round(random() * 100)) as timestamp
FROM generate_series(0, 1999999) id;

/* 12 млн. значений свойств товаров */
TRUNCATE property_values;
INSERT INTO property_values(product_id, property_id, value)
SELECT  
    (
        SELECT id FROM products WHERE id = 
        (
            SELECT CASE 
                WHEN mod((pid+1)::numeric, (1000000)::numeric) = 0 THEN 1000000
                WHEN (pid+1/1000000)::numeric > 0 THEN ((((pid+1)::numeric) / (1000000::numeric) - (pid+1)/1000000) * 1000000)::integer
                ELSE pid+1
            END
        )
    ) AS product_id,
    (
        SELECT id FROM product_category_properties WHERE category_id = ( 
            SELECT category_id FROM products WHERE id = (
                SELECT CASE 
                    WHEN mod((pid+1)::numeric, (1000000)::numeric) = 0 THEN 1000000
                    WHEN (pid+1/1000000)::numeric > 0 THEN ((((pid+1)::numeric) / (1000000::numeric) - (pid+1)/1000000) * 1000000)::integer
                    ELSE pid+1
                END
            )
        ) ORDER BY id LIMIT 1 OFFSET pid/1000000
    ) AS property_id,
    (
        SELECT 
            CASE 
                WHEN (
                    SELECT value_type FROM product_category_properties WHERE category_id = ( 
                        SELECT category_id FROM products WHERE id = (
                            SELECT CASE 
                                WHEN mod((pid+1)::numeric, (1000000)::numeric) = 0 THEN 1000000
                                WHEN (pid+1/1000000)::numeric > 0 THEN ((((pid+1)::numeric) / (1000000::numeric) - (pid+1)/1000000) * 1000000)::integer
                                ELSE pid+1
                            END
                        )
                    ) ORDER BY id LIMIT 1 OFFSET pid/1000000
                ) = 'reference' THEN ceil(random()*10)
                WHEN (
                    SELECT value_type FROM product_category_properties WHERE category_id = ( 
                        SELECT category_id FROM products WHERE id = (
                            SELECT CASE 
                                WHEN mod((pid+1)::numeric, (1000000)::numeric) = 0 THEN 1000000
                                WHEN (pid+1/1000000)::numeric > 0 THEN ((((pid+1)::numeric) / (1000000::numeric) - (pid+1)/1000000) * 1000000)::integer
                                ELSE pid+1
                            END
                        )
                    ) ORDER BY id LIMIT 1 OFFSET pid/1000000
                ) = 'integer' THEN ceil(random()*100)
                ELSE 1
            END
    ) AS value
FROM generate_series(0, 11999999) pid;
-- Таким образом все товары в одинаковых категориях имеют полный набор значений всех доступных свойств

-- Теперь удалить половину всех значений свойств, чтобы обеспечить правдоподобное распределение
DELETE FROM property_values WHERE ctid = ANY (
    SELECT ctid FROM property_values TABLESAMPLE BERNOULLI(50)
);

Выводы

На базе заполненной случайными данными в количестве: 1 млн. товаров в 25 категориях (+5 корневые), 300 свойств категорий, 6 млн. значений свойств категорий и весьма скромной машине Intel Pentium G6400 4GHz + 16Gb + HDD, такой запрос отрабатывает за 35 - 60 ms.

На самом деле, на практике мне не приходилось иметь дело с таким количеством товаров в каталоге. От нескольких сотен до полутора тысяч максимум, но длинная изоляция сподвигла на тест и статью.

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

Комментарии (8)


  1. abutorin
    14.12.2021 10:54
    +2

    Не хочу вас расстраивать. Но вы изобрели велосипед.

    Такой подход к хранению "произвольных" свойств используется в той же 1С уже лет 15.


  1. Russell-s-Teapot Автор
    14.12.2021 11:00
    +2

    Не хочу вас расстраивать. Но вы изобрели велосипед.

    Такой подход к хранению "произвольных" свойств используется в той же 1С уже лет 15.

    Вы меня не расстроили, я не утверждал, что открыл миру что то новое, но 1с генерирует структуру в тёмную, я в статье показал как это можно использовать в web проектах.


    1. abutorin
      14.12.2021 11:12
      +2

      но 1с генерирует структуру в тёмную

      Нет, я не про структуру которую делает платформа 1С. Я про типовые решения от 1С. Там структура полностью открыта и понятна.

      Основная проблема такого решения в том, что когда нужно прочитать все свойства одного объекта, физических запросов к таблице скорее всего будет больше чем 1. А еще накладываются ограничения на тип хранимых значений. Для разных типов нужно делать отдельные колонки Value. Дальше помнить для каждого свойства из какой колонки брать значение. В результате писать запросы к такой таблице вручную становиться утомительно. Нужно "городить" отдельные функции для генерации текста запроса.

      Появление новой категории это ведь не такое уж и частое событие. Кто мешает "наладить" процесс создания необходимых таблиц?


      1. Russell-s-Teapot Автор
        14.12.2021 11:21

        Вы статью то прочли? Предложенное решение как раз минует весь огород c разными типами в разных таблицах, и весь поиск идёт по двум целочисленным полям одной таблицы, что обеспечивает приемлемую скорость и простоту запросов, поскольку для нужд каталога большего и не требуется.


        1. abutorin
          14.12.2021 11:45
          +2

          Значения свойств могут быть не только числовые. Они могут быть еще строковые, ссылки на другие таблицы, могут быть доп. картинки где уже может быть удобно хранить blob (а может и не удобно, все зависит от размера).

          Поиск может и идёт по 2 целочисленным полям в одной таблице, но сколько раз? Вы ведь сами обратили внимание что при большим количество условий построение плана запроса становится уже ощутимым.

          А писать каждый раз 30 раз join чтобы вытянуть все поля по нескольким товарам вы называете не сложным?

          Таким подходом можно вообще всю БД превратить в набор из 3-х таблиц.

          Прежде чем городить такой огород, вы задавались вопросом как часто происходит добавление новых категорий товаров? Где будет больше сэкономлено ресурсов? Не кажется ли вам что заставлять СУБД при каждом запросе тратить время на построение плана запроса (которые кстати не сильно будут отличаться друг от друга) в итоге потратит больше энергии чем добавление отдельной таблицы для категории?


          1. Russell-s-Teapot Автор
            14.12.2021 12:03
            +2

            Уважаемый @abutorin,вы наверное не совсем поняли суть предложенной схемы, загляните в любой каталог, что вы увидите в отборах? в категории телефоны, это будут: Бренд, Диагональ, мощность батареи, цветовое оформление, количество ядер, поддержка быстрой зарядки, наверное что то ещё.

            Так вот Бренд - в нашем случае, это ссылочное значение, в выпадающем списке вываляться Эппл, Хуавей, и.т.д., Цвет тоже: Розовый, голубой, чёрный. Диагональ, ёмкость батареи и числовые значения и могут вводиться в виде диапазона, поддержка быстрой зарядки - булево.

            То о чём написали вы это классический EAV, "универсальное решение для всего", я писал статью о каталоге и только о нём. Все приведённые запросы имеют практически конечную форму, каждое условие добавляет один предсказуемый join.


          1. Russell-s-Teapot Автор
            14.12.2021 12:47

            Поиск может и идёт по 2 целочисленным полям в одной таблице, но сколько раз? Вы ведь сами обратили внимание что при большим количество условий построение плана запроса становится уже ощутимым.

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


  1. iskateli
    14.12.2021 23:42
    +1

    Это же так называемый "join bomb" где реляционный подход пасует. Если изначально будет предполагаться, что кол-во условий будет большим, то лучше использовать графовую БД, причём она как раз безсхемная, ну или многомодельную БД, которая поддерживает как графовую так и реляционную модели (AgensGraph)