Насколько хороши диапазонные типы и btree_gist индекс в PostgreSQL

от автора

В PostgreSQL есть довольно интересный функционал — диапазонные типы данных (range). Они весьма удобны в использовании. Для индексирования этих типов данных существует GIST индекс. Однако на практике часто требуется сочетание BTREE индекса с GIST, что реализуется расширением btree_gist. Насколько эффективно удобство, предоставляемое диапазонными типами данных в сочетании с btree_gist мы и разберем в этой статье.

Для ЛЛ — с производительностью при использовании btree_gist будет плохо.

Предположим нам нужен версионированная по датам таблица. У нас есть некоторый идентификатор, некоторые аналитики и диапазон дат, в течении которых эти аналитики были действительны. Например:

Клиент

Дата с

Дата по

Фамилия

Телефон

Мария

2020-01-01

2020-07-12

Иванова

+74991001010

Мария

2021-05-20

2023-02-28

Петрова

+74991001010

Мария

2023-03-01

2023-09-11

Петрова

+74959990101

Мария

2023-12-22

Сидорова

+74959990101

Пустое значение «Дата по» обозначает, что аналитики действуют с «Даты с» до бесконечности. Диапазоны для каждого клиента не могут пересекаться, но между диапазонами допускаются разрывы на то время, пока с клиентом никаких отношений не было. На самом деле, разрывы между диапазонами мы допускаем потому, что GIST индекс позволяет не допускать пересечения диапазонов, но не позволяет контролировать отсутствие между ними разрывов. А так как контроль за отсутствием разрывов без триггера всё равно не реализовать, то для целей сравнения производительности BTREE и btree_gist это значение не имеет.

Максимально упростим пример, сделав идентификатор (Id) просто integer, а аналитики пусть будут строкой и числом. Это можно представить в виде следующей таблицы.

CREATE TABLE tmp_test_range (   Id    integer       NOT NULL,   Valid daterange     NOT NULL, -- диапазон дат в виде встроенного типа   Code  integer       NOT NULL,   Amt   decimal(16,2) NOT NULL,   CONSTRAINT tmp_test_range_PK_idx     EXCLUDE USING GIST (Id WITH =, Valid WITH &&) -- запрет на пересечение  );

Теперь заполним нашу таблицу тестовыми данными.

INSERT INTO tmp_test_range (Id, Valid, Code, Amt) SELECT G.n / 10 AS Id,   daterange(     ( '2023-01-01'::date       + '1 day'::interval       * (G.n % 10) * 30 )::date,     CASE WHEN G.n % 10 = 9 THEN NULL       ELSE         ( '2023-01-01'::date           + '1 day'::interval           * ( (G.n % 10) * 30                + (G.n % 10 + 1) * 3 ) )::date END,     '[)' ),   G.n AS Code,   G.n*0.5 AS Amt FROM generate_series(0,999999) G(n);
На моем сервере PostgreSQL 15 это стабильно занимает более 45 секунд
Insert on tmp_test_range  (cost=0.00..67500.00 rows=0 width=0) (actual time=45364.169..45364.170 rows=0 loops=1)   ->  Function Scan on generate_series g  (cost=0.00..67500.00 rows=1000000 width=58) (actual time=63.500..1115.834 rows=1000000 loops=1) Planning Time: 0.068 ms Execution Time: 45373.995 ms

Попробуем теперь сделать тоже самое без использования btree_gist и GIST. Создадим таблицу:

CREATE TABLE tmp_test_not_range (   Id         integer       NOT NULL,   ValidFrom  date          NOT NULL,   ValidUntil date          NULL,   Code       integer       NOT NULL,   Amt        decimal(16,2) NOT NULL,   CONSTRAINT tmp_test_not_range_PK_idx     PRIMARY KEY (Id, ValidFrom) INCLUDE (ValidUntil) );

Так как контролировать пересечения диапазонов дат BTREE нам не позволяет, то потребуется выполнять это самим через триггер. Сначала создадим функцию для триггера:

CREATE OR REPLACE FUNCTION   tmp_test_not_range_before_insert_update_tfn() RETURNS TRIGGER AS $func$ <<func>> DECLARE   ValidFrom  date;   ValidUntil date; BEGIN   -- Дата конца диапазона должна быть больше или равна дате его начала   IF NEW.ValidFrom>COALESCE(NEW.ValidUntil,NEW.ValidFrom) THEN     RAISE EXCEPTION 'ValidUntil must be higher or equal ValidFrom';   END IF;    -- Ищем, нет ли пересечений диапазонов   -- При UPDATE исключаем из поиска обновляемую запись   SELECT F.ValidFrom, F.ValidUntil   FROM (     SELECT T.ValidFrom, T.ValidUntil      FROM tmp_test_not_range T     WHERE T.Id=NEW.Id       AND ( OLD.Id IS NULL         OR NOT (T.Id=OLD.Id AND T.ValidFrom=OLD.ValidFrom) )     AND T.ValidFrom<=COALESCE(NEW.ValidUntil,'infinity'::date)     ORDER BY T.ValidFrom DESC     LIMIT 1 ) F   WHERE COALESCE(F.ValidUntil,'infinity'::date)>=NEW.ValidFrom   INTO func.ValidFrom, func.ValidUntil;    -- Если пересечение обнаружено, выводим подробности   IF func.ValidFrom IS NOT NULL THEN     RAISE EXCEPTION       'Id % ValidFrom % intersect with ValidFrom % and ValidUntil %',       NEW.Id, New.ValidFrom, func.ValidFrom, func.ValidUntil;   END IF;    RETURN NEW; END; $func$ LANGUAGE plpgsql;

А теперь создаем и триггер:

CREATE OR REPLACE TRIGGER tmp_test_not_range_before_insert_update_trg   BEFORE INSERT OR UPDATE OF Id, ValidFrom, ValidUntil   ON tmp_test_not_range FOR EACH ROW   EXECUTE FUNCTION tmp_test_not_range_before_insert_update_tfn();

Заполним и эту таблицу теми же самыми тестовыми данными:

INSERT INTO tmp_test_not_range (Id,   ValidFrom, ValidUntil, Code, Amt) SELECT G.n / 10 AS Id,   ( '2023-01-01'::date     + '1 day'::interval     * (G.n % 10) * 30 )::date,   CASE WHEN G.n % 10 = 9 THEN NULL     ELSE       ( '2023-01-01'::date         + '1 day'::interval         * ( (G.n % 10) * 30              + (G.n % 10 + 1) * 3 ) )::date END,   G.n AS Code,   G.n*0.5 AS Amt FROM generate_series(0,999999) G(n);
На моем сервере PostgreSQL 15 это стабильно занимает менее 12 секунд
Insert on tmp_test_not_range  (cost=0.00..65000.00 rows=0 width=0) (actual time=11581.151..11581.151 rows=0 loops=1)   ->  Function Scan on generate_series g  (cost=0.00..65000.00 rows=1000000 width=34) (actual time=83.449..890.624 rows=1000000 loops=1) Planning Time: 0.086 ms Trigger tmp_test_not_range_after_insert_update_trg: time=8429.938 calls=1000000 Execution Time: 11621.851 ms

Получается, что вставка записей в таблицу индексированную btree_gist проигрывает почти в 4 раза вставке записей в таблицу индексированную BTREE плюс издержки на триггере.

Может быть btree_gist даст выигрыш хотя бы на выборке данных? Проверим. Выберем из нашей таблицы с миллионом записей и 100 тыс. различных Id всего 10 тыс записей для разных Id на некоторую дату:

SELECT R.Id, R.Valid, R.Code, R.Amt FROM generate_series(0,999999,10) G(n) JOIN tmp_test_range R ON R.Id=G.n AND R.Valid@>'2023-06-12'::date;
У меня в среднем получается 180 миллисекунд
Hash Join  (cost=11992.91..20109.38 rows=99147 width=28) (actual time=155.387..179.831 rows=10000 loops=1)   Hash Cond: (g.n = r.id)   ->  Function Scan on generate_series g  (cost=0.00..1000.00 rows=100000 width=4) (actual time=7.546..11.391 rows=100000 loops=1)   ->  Hash  (cost=10753.36..10753.36 rows=99164 width=28) (actual time=147.726..147.729 rows=100000 loops=1)         Buckets: 131072  Batches: 1  Memory Usage: 7368kB         ->  Bitmap Heap Scan on tmp_test_range r  (cost=1769.81..10753.36 rows=99164 width=28) (actual time=106.657..132.703 rows=100000 loops=1)               Recheck Cond: (valid @> '2023-06-12'::date)               Heap Blocks: exact=7744               ->  Bitmap Index Scan on tmp_test_range_pk_idx  (cost=0.00..1745.02 rows=99164 width=0) (actual time=105.592..105.592 rows=100000 loops=1)                     Index Cond: (valid @> '2023-06-12'::date) Planning Time: 0.179 ms Execution Time: 180.755 ms

Аналогичный запрос по второй таблице:

SELECT R.Id, R.ValidFrom, R.ValidUntil, R.Code, R.Amt FROM generate_series(0,999999,10) G(n) CROSS JOIN LATERAL (   SELECT T.Id, T.ValidFrom, T.ValidUntil, T.Code, T.Amt   FROM tmp_test_not_range T   WHERE T.Id=G.n AND T.ValidFrom<='2023-06-12'::date   ORDER BY T.ValidFrom DESC   LIMIT 1) R WHERE COALESCE(R.ValidUntil,'infinity'::date)>='2023-06-12'::date;
У меня в среднем получается 150 миллисекунд
Nested Loop  (cost=0.43..124520.84 rows=100000 width=23) (actual time=3.551..148.543 rows=10000 loops=1)   ->  Function Scan on generate_series g  (cost=0.00..1000.00 rows=100000 width=4) (actual time=3.512..8.371 rows=100000 loops=1)   ->  Subquery Scan on r  (cost=0.42..1.23 rows=1 width=23) (actual time=0.001..0.001 rows=0 loops=100000)         Filter: (COALESCE(r.validuntil, 'infinity'::date) >= '2023-06-12'::date)         ->  Limit  (cost=0.42..1.21 rows=1 width=23) (actual time=0.001..0.001 rows=0 loops=100000)               ->  Index Scan Backward using tmp_test_not_range_pk_idx on tmp_test_not_range t  (cost=0.42..5.15 rows=6 width=23) (actual time=0.001..0.001 rows=0 loops=100000)                     Index Cond: ((id = g.n) AND (validfrom <= '2023-06-12'::date)) Planning Time: 0.145 ms Execution Time: 149.595 ms

Увы. Как видим на выборке данных btree_gist тоже проигрывает, хоть и не столь значительно — на 20%. Что, впрочем, тоже немало.

Таким образом, нам удалось выяснить, что btree_gist следует использовать с осторожностью. Да, диапазонные типы данных и btree_gist сокращают время разработки, но цена этого — деградация производительности при вставке записей почти в 4 раза, а на выборке — 20%. Поэтому использовать btree_gist в тех случаях, когда производительность важна, не рекомендуется. Возможно, в будущем эта проблема будет исправлена, но пока что приходится с этим жить.

Спасибо, если дочитали!


ссылка на оригинал статьи https://habr.com/ru/articles/820455/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *