Пришла задача: написать магазин, большой в перспективе. А-ля Фотос, Хотлайн. Ну и стандартная задача для таких площадок — это фильтр.
Как это делаются в обычных «движках»? Верно: параметры = фильтры. Ну все понятно, что это просто, но не всегда красиво, особенно когда нужны фильтры с диапазонами (например: диагональ 10"-11"). Да и тогда приходится думать о том, что фильтры — это независимая от параметров сущность. В моей версии фильтры «вяжутся» к параметрам. Не знаю, как это сделано на том же хотлайне (есть подозрение, что там фильтры вяжутся непосредственно на товары), но такой вариант требует много человеческого внимания. Не буду вдаваться в детали архитектуры, это тема другого поста. Но рассматривать будем именно упрощенный вариант, дабы не потеряться.
Проблема в таких фильтрах — это их построение. Просчитать количество товаров при выбранных (или не выбранных ещё) фильтрах.
Итак…
Создаем таблицу для товаров:
CREATE TABLE public.products (
id SERIAL,
title VARCHAR NOT NULL,
CONSTRAINT products_pkey PRIMARY KEY(id)
);
Таблицу фильтров:
CREATE TABLE public.filters (
id SERIAL,
title VARCHAR NOT NULL,
CONSTRAINT filters_pkey PRIMARY KEY(id)
);
Таблицу для связей между filters и products:
CREATE TABLE public.products_ref_filters (
id_product INTEGER NOT NULL,
id_filter INTEGER NOT NULL,
CONSTRAINT products_ref_filters_pkey PRIMARY KEY(id_product, id_filter),
CONSTRAINT products_ref_filters_fk FOREIGN KEY (id_product)
REFERENCES public.products(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT products_ref_filters_fk1 FOREIGN KEY (id_filter)
REFERENCES public.filters(id)
ON DELETE CASCADE
ON UPDATE CASCADE
);
И получается стандартный вариант. Тривиально.
Дальше…
В таблицу товаров добавляем поле filters:
ALTER TABLE public.products
ADD COLUMN filters INTEGER[] DEFAULT ARRAY[]::integer[] NOT NULL;
Как вы заметили, это массив чисел, тут будут дублироваться айдишки фильтров, да, это денормализация. Для целостности пишем процедуры и вешаем на триггера. Вставка:
CREATE OR REPLACE FUNCTION public.products_ref_filters__insert_tr ()
RETURNS trigger AS'
BEGIN
UPDATE products
SET filters = filters + NEW.id_filter --push element onto array
WHERE id = NEW.id_product;
RETURN NEW;
END;
'LANGUAGE 'plpgsql';
CREATE TRIGGER products_ref_filters__insert_tr
AFTER INSERT
ON public.products_ref_filters FOR EACH ROW
EXECUTE PROCEDURE public.products_ref_filters__insert_tr();
Удаление:
CREATE OR REPLACE FUNCTION public.products_ref_filters__delete_tr ()
RETURNS trigger AS'
BEGIN
UPDATE products
SET filters = filters - OLD.id_filter --remove entries matching right argument from array
WHERE id = OLD.id_product;
RETURN OLD;
END;
'LANGUAGE 'plpgsql';
Обновление:
CREATE OR REPLACE FUNCTION public.products_ref_filters__update_tr ()
RETURNS trigger AS'
BEGIN
UPDATE products
SET filters = filters - OLD.id_filter
WHERE id = OLD.id_product;
UPDATE products
SET filters = filters + NEW.id_filter
WHERE id = NEW.id_product;
RETURN NEW;
END;
'LANGUAGE 'plpgsql';
CREATE TRIGGER products_ref_filters__update_tr
AFTER UPDATE
ON public.products_ref_filters FOR EACH ROW
EXECUTE PROCEDURE public.products_ref_filters__update_tr();
Очистка:
CREATE OR REPLACE FUNCTION public.products_ref_filters__truncate_tr ()
RETURNS trigger AS'
BEGIN
UPDATE products SET filters = ARRAY[]::INTEGER[];
RETURN NULL;
END;
'LANGUAGE 'plpgsql';
CREATE TRIGGER products_ref_filters__truncate_tr
AFTER TRUNCATE
ON public.products_ref_filters FOR EACH STATEMENT
EXECUTE PROCEDURE public.products_ref_filters__truncate_tr();
Теперь, при вставке, обновлении, удалении, данных из таблицы связей будет записываться массив фильтров в таблицу товаров. И мы избавились от JOIN. Теперь не нужно клеить таблицы в запросе. Это дает много плюсов, легче строить запросы, они быстрее, меньше памяти и т.д. Но статья ведь о intarray? Да.
Устанавливаем расширение:
CREATE EXTENSION intarray;
После исполнения этой команды в базе появятся функции, индексы, операторы для работы с массивами типа INTEGER. Теперь работать будет намного быстрее и удобнее.
Наполняем нашу БД. Фильтров 10 000. Товаров 100 000. Каждому товару по 10 фильтров. Итого: промежуточная таблица 1 000 000 строк. Этот блок запросов у меня исполнялся 8 мин. Так что дождитесь.
INSERT INTO filters (title) SELECT 'filter_' || num FROM generate_series(1, 10000) as num;
INSERT INTO products (title) SELECT 'product_' || num FROM generate_series(1, 100000) as num;
DO $$
DECLARE
idp INTEGER;
BEGIN
FOR idp IN SELECT id FROM products
LOOP
INSERT INTO products_ref_filters
SELECT idp, f.id FROM filters f ORDER BY random() LIMIT 10;
END LOOP;
END$$;
Создаем индекс на массив чисел:
CREATE INDEX products_idx ON public.products
USING gin (filters public.gin__int_ops);
Итого, наша БД заполнена. Давайте посмотрим, что теперь с этим делать. Давайте найдем самые популярные фильтры и запомним их.
SELECT id_filter
FROM products_ref_filters
GROUP BY id_filter
ORDER BY count(*) DESC
LIMIT 10
Мой результат такой: 7267, 4889, 6364, 5376, 3556, 7292, 11188, 2643, 9005, 10235.
Найдем товары с определенным фильтром, пусть будет 7267:
-- обычный JOIN
SELECT t1.* FROM products t1, products_ref_filters t2
WHERE t1.id = t2.id_product
AND t2.id_filter = 7267
-- 140 rows returned (execution time: 125 ms; total time: 141 ms)
-- SUB SELECT
SELECT * FROM products
WHERE id IN ( SELECT id_product FROM products_ref_filters WHERE id_filter = 7267 )
-- 140 rows returned (execution time: 125 ms; total time: 141 ms)
-- поиск по массиву
SELECT * FROM products WHERE filters @> ARRAY[7267]
-- 140 rows returned (execution time: 0 ms; total time: 0 ms)
Один из фильтров
-- JOIN
SELECT DISTINCT t1.* FROM products t1, products_ref_filters t2
WHERE t1.id = t2.id_product
AND t2.id_filter IN (7267,4889,6364,5376,3556,7292,11188,2643,9005,10235)
-- 1347 rows returned (execution time: 297 ms; total time: 297 ms)
-- SUB SELECT
SELECT * FROM products
WHERE id IN ( SELECT id_product FROM products_ref_filters WHERE id_filter IN (7267,4889,6364,5376,3556,7292,11188,2643,9005,10235) )
-- 1347 rows returned (execution time: 234 ms; total time: 250 ms)
-- INTARRAY
SELECT * FROM products WHERE filters && ARRAY[7267,4889,6364,5376,3556,7292,11188,2643,9005,10235]
-- 1347 rows returned (execution time: 16 ms; total time: 16 ms)
Лень составлять другие запросы, уж простите. Но когда нужно найти товары с несколькими фильтрами, тут вообще этот подход оставляет джойны и сабселекты далеко позади.
-- JOIN
-- ЛЕНЬ, но тут отрыв ещё больше будет ;)
-- Товары содержащие оба фильтра
SELECT * FROM products WHERE filters @> ARRAY[9844,9957];
-- 1 rows returned (execution time: 0 ms; total time: 16 ms)
Оф. дока тут.
Обратите внимание на операторы, это просто сказка! Где-то читал, что даже есть патч, установив который, можно внешние ключи строить на массив, и база сама будет следить за целостностью. Еще где-то читал, что разработчики даже планируют это сделать без всяких патчей. Да, здорово. В свое время, когда я нашел (для себя) это решение, радости было…
Посмотрев, что есть небольшой интерес к теме.
Решил немного добавить немного тестов на объемах.
Попробуем на 10 000 000 товарах.
Немного изменим запрос, по заполнению псевдотоваров.
-- фильтров как и было 1000
INSERT INTO filters (title) SELECT 'filter_' || num FROM generate_series(1, 10000) as num;
--Товаров 10 000 000
INSERT INTO products (title) SELECT 'product_' || num FROM generate_series(1, 10000000) as num;
-- т.к. целостность нас в тестах не интересует, заполним только ID-шки
DO $$
DECLARE
idp INTEGER;
BEGIN
FOR idp IN SELECT id FROM products
LOOP
UPDATE products
SET filters = (SELECT array_agg(id) FROM (SELECT id FROM filters OFFSET random()*1000 LIMIT 10) as foo)
WHERE id = idp;
END LOOP;
END$$;
-- этот запрос отрабатывает ~20мин
Размер БД ~2.9Гб
Индекс BTREE:
CREATE INDEX products_idx ON public.products USING btree (filters);
-- execution time: 00:02:36
-- DB size ~ 3.8Gb
SELECT * FROM products WHERE filters @> '{842}'::INTEGER[];
-- Seq Scan on public.products (cost=0.00..357908.00 rows=10000 width=80) ...
-- 99798 rows returned (execution time: 5,594 sec; total time: 5,594 sec)
SELECT * FROM products WHERE filters = '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=487.94..32940.13 rows=9726 width=80)
-- 9940 rows returned (execution time: 46 ms; total time: 62 ms)
SELECT * FROM products WHERE filters && '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[]
-- Seq Scan on public.products (cost=0.00..357908.00 rows=10000 width=80)
-- 189853 rows returned (execution time: 6,281 sec; total time: 6,296 sec)
Индекс GIST:
CREATE INDEX products_idx ON public.products USING gist (filters public.gist__int_ops);
-- execution time: 00:26:55;
-- DB size ~ 4.5Gb
SELECT * FROM products WHERE filters @> '{842}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=833.92..34097.44 rows=10000 width=80)
-- 99798 rows returned (execution time: 2,234 sec; total time: 2,234 sec)
SELECT * FROM products WHERE filters = '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=811.79..33263.99 rows=9726 width=80)
-- 9940 rows returned (execution time: 234 ms; total time: 234 ms)
SELECT * FROM products WHERE filters && '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=833.92..34097.44 rows=10000 width=80)
-- 189853 rows returned (execution time: 5,234 sec; total time: 5,234 sec)
Индекс GIN:
CREATE INDEX products_idx ON public.products USING gin (filters public.gin__int_ops);
-- execution time: 56,344 sec;
SELECT * FROM products WHERE filters @> '{842}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=97.50..33361.02 rows=10000 width=80)
-- 99798 rows returned (execution time: 2,204 sec; total time: 2,219 sec)
SELECT * FROM products WHERE filters = '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=211.37..32663.57 rows=9726 width=80)
-- 9940 rows returned (execution time: 297 ms; total time: 312 ms)
SELECT * FROM products WHERE filters && '{842,843,844,845,846,847,848,849,850,851}'::INTEGER[];
-- Bitmap Heap Scan on public.products (cost=213.50..33477.02 rows=10000 width=80)
-- 189853 rows returned (execution time: 4,500 sec; total time: 4,515 sec)
Да, для мега больших баз, это особо не спасет. Но учитывая что такой поиск на практике не бывает, а всегда есть дополнительный фильтр, например по категории, то все же метод хорош. В любом случае это в 100 раз быстрее JOIN.
Комментарии (22)
DoctorChaos
30.10.2015 14:44Я так понимаю, проблему range-фильтров intarray не помогает решить?
anydasa
30.10.2015 15:22Если взять в пример, хотлайн или фотос, то там фильтр это запись в бд. Это может быть range или нет, это вообще не важно. Фильтр для товара, на уровне базы, это просто метка. На проекте где я реализовал такой поход, есть дополнительные обработчики, которые определяют в какой фильтр должен входить товар и автоматически назначают его.
DoctorChaos
30.10.2015 15:47Не совсем понимаю схему. Я имел в виду нечто вроде ползунков на маркете, от 1 до 10 000 с шагом 1.
Подробнее ниже: http://habrahabr.ru/post/269823/#comment_8634827
xobotyi
30.10.2015 15:27+2Вот тут, на мой взгляд, не стоит городить огород и использовать стандартное ложкой-в-лоб решение:
SELECT * FROM products WHERE width>=$w_min AND width <=$w_max;
Если какой-то более упоротый рейндж, в котором не все подряд значения, то с применением intarray выходит что-то а-ля:
SELECT *FROM products WHERE idx('{1,2,3,15,100500}', width)::boolean;
Правда такой вариант работает в районе 40-50 раз медленнее. И тогда уже лучше использовать опять же ортодоксальный IN
SELECT *FROM products WHERE width IN (1,2,3,15,100500);
который работает с той же скорость что>= AND <=
DoctorChaos
30.10.2015 15:42С таким вариантом проблема, когда продукты разнородные и у них есть не только width, но и height, depth, length, diagonal, density, volume, etc.
Каждый фильтр = столбец.
На счет второго и третьего — такое иногда бывает нужно, но хуже, когда range слишком большой, а шаг у него слишком мелкий.
Типа, от 1 до 10 000 с шагом 1. Такое в intarray не положить.xobotyi
30.10.2015 16:06Типа, от 1 до 10 000 с шагом 1. Такое в intarray не положить.
Ну, вот такое, опять же, просто решить:
SELECT * FROM products WHERE width>=1 AND width<=10000 AND (width%2)::boolean;
А что касается этого:
С таким вариантом проблема, когда продукты разнородные и у них есть не только width, но и height, depth, length, diagonal, density, volume, etc.
Каждый фильтр = столбец.
Надо по-просту верно составлять запрос, выставляя фильтры в порядке убывания фактора сокращения выборки, самые тяжелые фильры оставляя «на потом». А после этого прогнать запрос черезEXPLAIN (ANALYSE, buffers)
и поиграться с расположением фильтров.
alekciy
30.10.2015 15:51Как я понимаю помогут только через пресеты как тут: "Невозможность закодировать фильтры типа range… фильтр по ценам представлял из себя просто пять вариантов"
anydasa
30.10.2015 16:08Вы поняли верно.
Вообще если говорить о фильтре для каталога магазина, то эти ползунки менее привлекательны, т.к. много движений делать нужно.
Если магазин токовый, то набор фильтров для определенной категории, тщательно продумывается.
Для пользователя гораздо легче просто кликнуть чем думать о том что указать. Тем более если он не в теме.
velvetcat
30.10.2015 22:50Если я ничего не путаю (не спец в этой области), то для решения таких задач хорошо подходят битмап-индексы.
anydasa
30.10.2015 23:44Спасибо за вопрос. Он меня натолкнул на размышление. Вообще я не гуру, и даже не спец SQL. Просто данное решение работает очень быстро. Что мне собственно и нужно. Хотелось бы все знать, но увы. Человеческий ресурс.
Вопрос к знатокам.
Делаю запрос, поле filters, которое как помним INTEGER[].
SELECT * FROM products WHERE filters @> ARRAY[7267]
Если индекса, нет
Seq Scan on public.products (cost=0.00..20.38 rows=1 width=68) Output: id, title, filters Filter: (products.filters @> '{7267}'::integer[])
Индекс, GIST (gist__int_ops)
Index Scan using products_idx on public.products (cost=0.14..8.16 rows=1 width=68) Output: id, title, filters Index Cond: (products.filters @> '{7267}'::integer[])
Индекс, GIN (gin__int_ops)
Bitmap Heap Scan on public.products (cost=8.01..12.02 rows=1 width=68) Output: id, title, filters Recheck Cond: (products.filters @> '{7267}'::integer[]) -> Bitmap Index Scan on products_idx (cost=0.00..8.01 rows=1 width=0) Index Cond: (products.filters @> '{7267}'::integer[])
Не тот ли это битмап, о котором вы спрашиваете?Chpock
31.10.2015 03:14Я далеко не профессионал в PostgreSQL, но как-то пришлось переезжать с v8 на v9 и наблюдать, с моего взгляда, совершенно иррациональное поведение постгреса с индексами для intarray и огромными таблицами. Гугл мне ничем не помог, пришлось просто методом тыка определить что быстрее всего будет для v9 и именно с моими таблицами. Что бы не забыть почему я в конечном итоге сделал так, а не иначе, результаты своих экспериментов сохранял — goo.gl/TbOePV. Может чем-нибудь поможет.
velvetcat
01.11.2015 13:02Не тот ли это битмап, о котором вы спрашиваете
Да, это оно. Круто, что Постгресс именно так и работает.
KAndy
Полнотесктовые движки вроди солра и елестика справляются с такими задачами на ура. У меня на ~ 1М продуктов и всреднем 7 атрибутах (фильтрах) запросы выполняются порядка 20 мс при этом еще возращается информация о агригациях.
xobotyi
агрЕгациях, вать машу!!!!*аштрисёт* (??Д?)?????И речь вообще не про эластик, а про методики ускорения запросов с параметрической фильтрацией посредством intarray, каким боком сюда затесались полнотекстовые солр и эластик?
anydasa
Полнотекстовым поиском конечно можно решить эту задачу. Как можно и отверткой копать землю.
vitalif
Вы зря, это вполне нормальный подход, во многих крупных проектах применяется.
Смысл в том, что если фильтров много, их выгодно загнать в один инвертированный индекс. GIN — это инвертированный индекс и полнотекстовый индекс — это тоже инвертированный индекс, да и задача фактически одна и та же — взять несколько терминов, взять наборы документов, их содержащие, и пересечь. Полнотекстовый индекс получится один общий, поэтому будет даже ещё немного шустрее. Плюс туда же можно загнать и непосредственно текстовые описания, и тогда шустро работать будет ещё и поиск по тексту + фильтрам :-)
Да, плюс полнотекстовые движки умеют заодно и информацию о пресловутых агрегациях выдавать, их фасетами ещё называют, т.е. это ответ на вопрос «какие значения фильтров есть у найденных товаров?»
anydasa
Я не спорю в целом. А конкретно в данном случае, фильтры это записи в таблице. т.е. у них есть ID:INTEGER.
Поиск по инту быстрее чем по строке, пусть и полнотекстовым поиском со всеми умными индексами.
а что до «какие значения фильтров есть у найденных товаров?», ну тут мы тоже имеем айдишки на выходе.
foxmuldercp
а можно несколько нескромно влезть в тредик?
а если, допустим, часть полей описания товара, те поля, которые динамические, и в т.ч список его фильтров, хранить в jsonb?
anydasa
можно и так, только для чего? вопрос не полон.
alekciy
Автор явно заявил атрибут (параметр) != фильтр
anydasa
Kandy, ваш вариант тоже предполагает что набор фильтров может быть динамическим, и с любым количеством?