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

283aa916e513220e373fc953d69d7bb2

В 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$
<>
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 в тех случаях, когда производительность важна, не рекомендуется. Возможно, в будущем эта проблема будет исправлена, но пока что приходится с этим жить.

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

© Habrahabr.ru