Лет так 6 назад, когда слоник был только в 8.0, а я плотно сидел на MySql, часто слышал призывы сменить DB. Я помню как это было болезненно начать. Но после того, как решился, ни разу не жалел и на мускул уже вряд ли вернусь. Уж очень много тут плюсов, но пост не об этом.

Пришла задача: написать магазин, большой в перспективе. А-ля Фотос, Хотлайн. Ну и стандартная задача для таких площадок — это фильтр.

Как это делаются в обычных «движках»? Верно: параметры = фильтры. Ну все понятно, что это просто, но не всегда красиво, особенно когда нужны фильтры с диапазонами (например: диагональ 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)


  1. KAndy
    30.10.2015 11:52
    -1

    Полнотесктовые движки вроди солра и елестика справляются с такими задачами на ура. У меня на ~ 1М продуктов и всреднем 7 атрибутах (фильтрах) запросы выполняются порядка 20 мс при этом еще возращается информация о агригациях.


    1. xobotyi
      30.10.2015 12:03
      +4

      агрЕгациях, вать машу!!!! *аштрисёт* (??Д?)?????
      И речь вообще не про эластик, а про методики ускорения запросов с параметрической фильтрацией посредством intarray, каким боком сюда затесались полнотекстовые солр и эластик?


    1. anydasa
      30.10.2015 13:52

      Полнотекстовым поиском конечно можно решить эту задачу. Как можно и отверткой копать землю.


      1. vitalif
        30.10.2015 14:06
        +2

        Вы зря, это вполне нормальный подход, во многих крупных проектах применяется.
        Смысл в том, что если фильтров много, их выгодно загнать в один инвертированный индекс. GIN — это инвертированный индекс и полнотекстовый индекс — это тоже инвертированный индекс, да и задача фактически одна и та же — взять несколько терминов, взять наборы документов, их содержащие, и пересечь. Полнотекстовый индекс получится один общий, поэтому будет даже ещё немного шустрее. Плюс туда же можно загнать и непосредственно текстовые описания, и тогда шустро работать будет ещё и поиск по тексту + фильтрам :-)
        Да, плюс полнотекстовые движки умеют заодно и информацию о пресловутых агрегациях выдавать, их фасетами ещё называют, т.е. это ответ на вопрос «какие значения фильтров есть у найденных товаров?»


        1. anydasa
          30.10.2015 14:18

          Я не спорю в целом. А конкретно в данном случае, фильтры это записи в таблице. т.е. у них есть ID:INTEGER.
          Поиск по инту быстрее чем по строке, пусть и полнотекстовым поиском со всеми умными индексами.

          а что до «какие значения фильтров есть у найденных товаров?», ну тут мы тоже имеем айдишки на выходе.


      1. foxmuldercp
        03.11.2015 01:32

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


        1. anydasa
          03.11.2015 11:07

          можно и так, только для чего? вопрос не полон.


    1. alekciy
      30.10.2015 15:47

      всреднем 7 атрибутах

      Автор явно заявил атрибут (параметр) != фильтр


    1. anydasa
      31.10.2015 14:02

      Kandy, ваш вариант тоже предполагает что набор фильтров может быть динамическим, и с любым количеством?


  1. DoctorChaos
    30.10.2015 14:44

    Я так понимаю, проблему range-фильтров intarray не помогает решить?


    1. anydasa
      30.10.2015 15:22

      Если взять в пример, хотлайн или фотос, то там фильтр это запись в бд. Это может быть range или нет, это вообще не важно. Фильтр для товара, на уровне базы, это просто метка. На проекте где я реализовал такой поход, есть дополнительные обработчики, которые определяют в какой фильтр должен входить товар и автоматически назначают его.


      1. DoctorChaos
        30.10.2015 15:47

        Не совсем понимаю схему. Я имел в виду нечто вроде ползунков на маркете, от 1 до 10 000 с шагом 1.
        Подробнее ниже: http://habrahabr.ru/post/269823/#comment_8634827


        1. anydasa
          30.10.2015 15:56

          ползунки не получится.


    1. 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 <=


      1. DoctorChaos
        30.10.2015 15:42

        С таким вариантом проблема, когда продукты разнородные и у них есть не только width, но и height, depth, length, diagonal, density, volume, etc.
        Каждый фильтр = столбец.
        На счет второго и третьего — такое иногда бывает нужно, но хуже, когда range слишком большой, а шаг у него слишком мелкий.
        Типа, от 1 до 10 000 с шагом 1. Такое в intarray не положить.


        1. 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) и поиграться с расположением фильтров.


    1. alekciy
      30.10.2015 15:51

      1. anydasa
        30.10.2015 16:08

        Вы поняли верно.

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

        Если магазин токовый, то набор фильтров для определенной категории, тщательно продумывается.

        Для пользователя гораздо легче просто кликнуть чем думать о том что указать. Тем более если он не в теме.


  1. velvetcat
    30.10.2015 22:50

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


    1. 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[])
      

      Не тот ли это битмап, о котором вы спрашиваете?


      1. Chpock
        31.10.2015 03:14

        Я далеко не профессионал в PostgreSQL, но как-то пришлось переезжать с v8 на v9 и наблюдать, с моего взгляда, совершенно иррациональное поведение постгреса с индексами для intarray и огромными таблицами. Гугл мне ничем не помог, пришлось просто методом тыка определить что быстрее всего будет для v9 и именно с моими таблицами. Что бы не забыть почему я в конечном итоге сделал так, а не иначе, результаты своих экспериментов сохранял — goo.gl/TbOePV. Может чем-нибудь поможет.


      1. velvetcat
        01.11.2015 13:02

        Не тот ли это битмап, о котором вы спрашиваете

        Да, это оно. Круто, что Постгресс именно так и работает.