Одним из главных требований к каталогу является возможность быстро искать и находить его элементы по различным критериям.
Существует множество подходов к реализации таких требований. Это и nosql решения и механизмы работы с json в реляционных СУБД. До nosql эпохи, решать такие задачи приходилось средствами реляционных БД.
Основная причина по которой реляционные СУБД плохо подходят для решения таких задач это разнообразие характеристик товаров. Набор характеристик к примеру для одежды и смартфонов будет совершенно разный. В самом деле не создавать же для каждой категории товаров отдельную таблицу с со своим набором реквизитов.
По этой причине в большинстве случаев в реляционных БД используется EAV (Entity Attribute Value) модель данных в тех или иных вариациях.
Недостатков у такой модели множество. Чаще всего EAV модель критикуют чрезмерную сложность, так же за то, что по сути схема данных храниться в самих данных.
Есть мнение, что EAV вообще является анти паттерном, что тоже не лишено оснований, однако надо заметить, что есть и другое мнение, что лучше такая схема, чем вообще отсутствие таковой.
Рискуя навлечь на себя гнев сообщества хочу представить свой вариант реализации каталога. Это не совсем EAV, скорее его по мотивам.
Я использовал его в различных проектах много лет, его достоинства чрезвычайная простота, действительная универсальность и хорошая скорость выборки, что на первый взгляд не кажется очевидным.
Всё описанное далее предполагает использование СУБД Postgresql.
Довольно преамбул, вот упрощённая ER-диаграмма.
Из диаграммы видно, что товар в таблице products ссылается на категорию из таблицы product_categories.
Таблица product_categories может иметь иерархическую структуру.
На таблицу products ссылаются три таблицы:
Цены товаров (product_prices)
Фото товаров (products_images)
Значения свойств характеристик товаров (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.
На самом деле, на практике мне не приходилось иметь дело с таким количеством товаров в каталоге. От нескольких сотен до полутора тысяч максимум, но длинная изоляция сподвигла на тест и статью.
Безусловно, недостатки в таком подходе тоже имеются. Самым очевидным кажется тщательная проработка категорий и их свойств, просто так переместить товары из категории в категорию не получится, это может вылиться в дополнительною головную боль при администрировании данного решения.