Совет дня №32
В Postgres индексы бывают разных типов. Самый базовый и нужный называется btree, который основан на одноименной структуре данных – дереве B-Tree. Оно состоит из распределительных узлов, и на последнем уровне находятся пары вида (значение, страница).
Btree поддерживает равенство и сравнение. Это значит, в нем можно искать по точному значению и диапазонам, а еще он подходит для сортировки, потому что упорядочен.
В случаях, когда нужно частичное сравнение, индекс btree не подходит. Вместо него используют Gin или Gist (отдавая предпочтение первому). Эти индексы сложнее и в целом медленнее btree. Поэтому пока возможно, используйте btree.
Есть, однако, лазейка, чтобы использовать btree для частичного сравнения. Если
применить его к строке с особой опцией, можно искать по началу строки оператором
(i)like и регулярным выражением.
Предположим, в таблице demo хранятся пути к файлам:
create table demo(
id integer primary key,
path text not null
);
Сгенерируем миллион файлов. Пути начинаются с года, месяца, дня и часа. Даты разнообразны в рамках десяти лет:
insert into demo
select
x,
format('%s/%s/picture.jpeg', to_char((now() - interval '10 years' * random()), 'yyyy/MM/DD/HH'), gen_random_uuid())
from
generate_series(1, 999999) as seq(x);
Навесим индекс на путь. Опция text_pattern_ops нужна, чтобы зафиксировать
локаль. С ней сравнение строк не зависит от текущей локали:
create index path_btree on demo
using btree
(path text_pattern_ops);
Выберем файлы за октябрь 2023 года:
select * from demo
where path like '2023/10/12%'
limit 100;
Справа от like стоит шаблон, а не простой текст. Символ % означает любые символы. Вот что вернет запрос:
┌────────┬─────────────────────────────────────────┐
│ id │ path │
├────────┼─────────────────────────────────────────┤
│ 213875 │ 2023/10/12/01/00ec...af263/picture.jpeg │
│ 670319 │ 2023/10/12/01/04e2...67afd/picture.jpeg │
│ 34516 │ 2023/10/12/01/063a...ebddc/picture.jpeg │
│ 122843 │ 2023/10/12/01/3237...6ffd7/picture.jpeg │
│ 227530 │ 2023/10/12/01/5a48...c4ec0/picture.jpeg │
│ 141900 │ 2023/10/12/01/6a78...da021/picture.jpeg │
│ 663626 │ 2023/10/12/01/9d6d...84da7/picture.jpeg │
│ 154138 │ 2023/10/12/01/a02e...5570c/picture.jpeg │
│ 37955 │ 2023/10/12/01/a465...47a7a/picture.jpeg │
│ 864446 │ 2023/10/12/01/ae47...943d9/picture.jpeg │
│ 196849 │ 2023/10/12/01/cd15...25874/picture.jpeg │
Но гораздо интересней план:
explain analyze
select * from demo
where path like '2023/10/12%'
limit 100;
│ Limit (cost=0.55..8.57 rows=100 width=68)
│ (actual time=0.054..0.364 rows=100 loops=1)
│ -> Index Scan using path_btree on demo
│ (cost=0.55..8.57 rows=100 width=68)
│ Index Cond: ((path ~>=~ '2023/10/12'::text)
│ AND (path ~<~ '2023/10/13'::text))
│ Filter: (path ~~ '2023/10/12%'::text)
│ Planning Time: 0.375 ms
│ Execution Time: 0.400 ms
└────────────────────────────────────────────────────
Видим, что Postgres поступает хитро: заключает путь в диапазон 2023/10/12 и 2023/10/13. Цифра 3 на конце получилась как следующий символ за 2.
Попадаем в индекс, стоимость копеечная.
Если поставить в начало шаблона %, индекс не сработает. Другими словами, с
таким подходом нельзя искать пути по вхождению.
Индекс и оператор like позволяют срезать пути по лидирующей строке. При этом
не обязательно с точностью до папки. Например, шаблон может быть таким:
2025/10/23/%cat%.jpeg
Так мы получим все фотографии в папке 2025/10/23/… со словом cat в названии
и расширением jpeg. Кроме шаблонов like, индекс поддерживает регулярки, и с
ними критерий поиска можно описать точнее.
Чем больше лидирующих символов мы задали, тем быстрее будет поиск.
Еще один трюк: что если нужно искать с конца строки? Например, имеется таблица товаров с серийными номерами:
create table demo2(
id integer primary key,
serial_number text not null
);
Вставим миллион серийных номеров:
insert into demo2
select
x,
to_char(random() * 1000000000000, '0000-0000-0000')
from
generate_series(1, 999999) as seq(x);
┌────────┬─────────────────┐
│ id │ serial_number │
├────────┼─────────────────┤
│ 1 │ 7084-1644-4953 │
│ 2 │ 2694-3476-5136 │
│ 3 │ 3274-5880-3765 │
│ 4 │ 9346-6945-3461 │
│ 5 │ 3352-3981-1479 │
│ 6 │ 7103-7860-4732 │
│ 7 │ 2634-9208-2518 │
│ 8 │ 1485-3601-0146 │
│ 9 │ 8068-5128-1222 │
│ 10 │ 5832-3252-5642 │
Сотрудники ищут продукцию по последним числам серийного номера. Чтобы сработал
индекс btree, сделаем функциональный индекс на выражение
reverse(serial_number). В результате в индексе будут храниться перевернутые
номера:
create index path_btree_rev on demo2
using btree
(reverse(serial_number) text_pattern_ops);
Чтобы искать по ним, выражение поиска тоже нужно перевернуть. Давайте найдем все номера, которые заканчиваются на -0500:
select * from demo2
where reverse(serial_number) like reverse('%-0500')
limit 100;
Результат:
┌────────┬─────────────────┐
│ id │ serial_number │
├────────┼─────────────────┤
│ 237074 │ 5088-3410-0500 │
│ 231067 │ 0411-7020-0500 │
│ 728812 │ 3489-6520-0500 │
│ 618494 │ 3972-6720-0500 │
│ 486950 │ 4871-5330-0500 │
│ 634762 │ 8038-7440-0500 │
│ 733815 │ 8856-2250-0500 │
│ 451464 │ 3640-0450-0500 │
│ 868649 │ 7289-2270-0500 │
│ 485383 │ 7016-0370-0500 │
│ 782061 │ 4076-8080-0500 │
│ 234715 │ 4279-2180-0500 │
│ 36948 │ 9033-7280-0500 │
│ 460010 │ 5881-8090-0500 │
Самое интересное – план:
│ Limit (cost=0.42..8.45 rows=100 width=16) (actual
│ -> Index Scan using path_btree_rev on demo2 (co
│ Index Cond: ((reverse(serial_number) ~>=~ '
│ Filter: (reverse(serial_number) ~~ '0050-%'
│ Planning Time: 0.286 ms
│ Execution Time: 1.088 ms
└────────────────────────────────────────────────────
Работает!
Повторю, что btree и text_pattern_ops работают только для начала
строки. Если нужен поиск посередине, обратитесь к расширению pg_tgrm
(триграммы). Этот индекс, однако, требует Gin.
У text_pattern_ops есть еще один полезный сценарий, но о нем – в следующий
раз.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter