Самый больной вопрос для любого разработчика, которому приходится вычитывать данные из базы: "Как сделать мой запрос быстрее?". Классический ответ - необходимо создать подходящий индекс. Но куда именно его стоит "накатывать", да и как вообще он должен выглядеть?..
Мы научили наш сервис визуализации планов PostgreSQL отвечать на эти вопросы, и под катом расскажем, чем именно он руководствуется в своих рекомендациях.
Вообще, разного рода рекомендации мы умеем раздавать уже давно - о некоторых из них можно прочитать в статье "Рецепты для хворающих SQL-запросов". Сегодня мы подробно рассмотрим некоторые из них, когда правильный индекс действительно может помочь улучшить производительность запроса.
И "правильный" - тут ключевое слово, потому что насоздавать неправильных индексов, которые нормально работать все равно не будут - наука невелика. А вот чтобы их потом вычислить и зачистить, уже придется использовать методы из статьи "DBA: находим бесполезные индексы".
Чтобы не начинать с самых азов, договоримся, что пользоваться EXPLAIN все умеют, и что такое Seq Scan
, Index Scan
и Bitmap Index Scan
знают. А если нет - стоит почитать переводы хороших постов Hubert 'depesz' Lubaczewski.
Проблема #1: Seq Scan
Давайте создадим модельную табличку из знакомой всем таблицы pg_class
, и будем проводить эксперименты над ее копией:
CREATE TABLE pg_class_copy AS TABLE pg_class;
Посмотрим, как реагирует база на попытку найти идентификаторы всех последовательностей (sequence), которые есть у нас в базе:
explain (analyze, buffers, costs off, verbose)
SELECT oid FROM pg_class_copy WHERE relkind = 'S';
Поскольку ни одного индекса на нашей таблице нет, то получим самый простой вариант, который может встретиться в плане Seq Scan
:
Seq Scan on pg_class_copy (actual time=0.017..0.105 rows=2 loops=1)
Output: oid
Filter: (pg_class_copy.relkind = 'S'::"char")
Rows Removed by Filter: 427
Buffers: shared hit=11
Filter
- это как раз то самое условие, которое заставило PostgreSQL из 429 прочитанных записей отбросить 427 и оставить для нас только 2. И это плохо - мы прочитали в 200 раз больше необходимого количества записей!
Способы индексации
Индекс по полю/выражению
Очевидно, что сразу напрашивается индекс по значению поля pg_class_copy(relkind)
:
CREATE INDEX ON pg_class_copy USING btree(relkind);
И мы видим, что вместо фильтрации наше выражение теперь ушло в условие индексного поиска Index Cond
, а сам узел превратился в Index Scan
:
Index Scan using pg_class_copy_relkind_idx on pg_class_copy (actual time=0.010..0.011 rows=2 loops=1)
Output: oid
Index Cond: (pg_class_copy.relkind = 'S'::"char")
Buffers: shared hit=2
Индекс с условием
С другой стороны, мы можем все выражение вынести в WHERE-условие индекса, а его поля использовать под что-то более насущное - например, под тот самый oid
, который мы вычитываем:
CREATE INDEX ON pg_class_copy USING btree(oid) -- индексируемое поле
WHERE relkind = 'S'; -- условие применения индекса
Обратите внимание, что узел превратился в Index Only Scan
, а вот вынесенное нами условие исчезло из плана вовсе:
Index Only Scan using pg_class_copy_oid_idx on pg_class_copy (actual time=0.012..0.013 rows=2 loops=1)
Output: oid
Heap Fetches: 0
Buffers: shared hit=2
Такое поведение характерно только для узлов Index [Only] Scan
- в Seq Scan
нам и так видны все условия сразу, а в Bitmap Heap Scan
мы увидим условия дополнительной проверки записей страниц в строке Recheck Cond
.
Неиндексируемые выражения
Но далеко не все выражения можно проиндексировать, а только сводящиеся к IMMUTABLE
в терминах категорий изменчивости функций.
Применительно к индексам это означает, что выражение должно быть вычислимо с использованием только полей записи и IMMUTABLE
-функций, выдающих всегда один и тот же результат.
Если на совсем простых примерах:
Условие |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
На что обратить внимание:
Из всех этих вариантов условий, только первый допускает создание индекса
(...) WHERE ts >= '2022-04-01 00:00:00'::timestamp
, во всех остальных случаях правая часть не-иммутабельна.-
Хотя все три последних варианта математически эквивалентны, но...
первый может использовать наиболее общий индекс и не зависит от константы
второй требует специального индекса с фиксацией константы
третий не может быть проиндексирован из-за вариативности
now()
и является примером одной из классических ошибок при использовании индексов
Типы индексов
Пока мы использовали только btree
- самый простой и "дефолтный" из всех типов индексов, которые PostgreSQL поддерживает "из коробки", его даже можно можно опускать при описании индекса:
CREATE INDEX ON pg_class_copy(relkind);
Но типов индексов в PostgreSQL гораздо больше, и каждый из них обладает своими возможностями и ограничениями, но основное отличие - поддерживаемые типы полей (на самом деле, конечно, произвольных IMMUTABLE
-выражений от них) и операторы.
Некоторые из них могут быть модифицированы с использованием дополнительных расширений, поэтому я приведу только базовые возможности доступные "из коробки".
btree
Операторы линейного порядка (<, <=, =, >=, >
):
числовые (
smallint, integer, bigint, numeric, real, double precision
)хронологические (
date, time, time without time zone, time with time zone, timestamp, timestamp without time zone, timestamp with time zone
)uuid
текстовые (
varchar, text
)
Операторы префиксного поиска (~<~, ~<=~, ~, ~>=~, ~>~
) с использованием дополнительных классов операторов:
текстовые (
varchar, text
)
[подробнее: обзор]
hash
Индекс может содержать только одно поле и поддерживает только один оператор =
, поэтому в реальной работе малоприменим.
[подробнее: обзор]
gist
Операторы геометрических отношений (<<, &<, &>, >>, <@, @>, ~=, &&, <<|, &<|, |&>, |>>, ~, @
):
геометрические (
box, circle, poly, point
)
Операторы для сетевых адресов (<<, <<=, >>, >>=, =, <>, <, <=, >, >=, &&
):
сетевые адреса (
inet
)
Операторы для диапазонов (=, &&, @>, <@, <<, >>, &<, &>, -|-
):
диапазоны числовые (
int4range, int8range, numrange
)диапазоны хронологические (
daterange, tsrange, tstzrange
)
Операторы текстового поиска (<@, @>, @@
) :
FTS-типы (
tsquery, tsvector
)
[подробнее: документация, обзор]
Дополнительно:
поддерживает оператор упорядочивания
<->
, который позволяет осуществлять индексный kNN-поискпри использовании расширения btree_gist поддерживает операторы и типы
btree
spgist
Индекс может содержать только одно поле и поддерживает те же возможности, что и у gist
, включая оператор упорядочивания <->
, но с большей оптимизацией для пространственных данных.
[подробнее: документация, обзор]
gin
Операторы для массивов (&&, @>, <@, =
):
массивы (
anyarray
)
Операторы jsonb-ключей (@>, @?, @@, ?, ?|, ?&
):
jsonb
Операторы jsonb-путей (@>, @?, @@
):
jsonb
Операторы текстового поиска (@@, @@@
) :
tsvector
[подробнее: документация, обзор]
Дополнительно:
при использовании расширения btree_gin поддерживает операторы и типы
btree
brin
Блочный индекс с возможностями btree
.
С одной стороны, позволяет эффективно индексировать большие блоки данных, с другой - неэффективен для небольших, поскольку всегда получается Bitmap Heap Scan
.
[подробнее: обзор]
Условия применимости
Из приведенных выше особенностей следует достаточно простой алгоритм, когда и какой тип можно попробовать использовать, а когда не стоит:
-
поддержка нужного оператора;
не стоит пытаться использовать
btree
-индекс, если у вас условие с<>
, но если у вас~>=~
, не забудьтеtext_pattern_ops
. -
поддержка нужного типа;
хотите что-то искать в
jsonb
- толькоgin
, если надо сочетать<, =, >
и<@
- смотрим наbtree_gist/btree_gin
. -
поддержка нескольких полей;
если необходима, то
hash
иspgist
сразу отпадают. -
количество данных;
если возвращается мало записей, то
brin
вам не нужен.
Теперь, зная все это, посмотрим, что нам порекомендует explain.tensor.ru:
Наиболее релевантным для данного случая сервис выбрал обычный btree-индекс по полю фильтрации:
Комментарий содержит описание вычисленных типов для всех индексируемых полей и использованные с ними в условии операторы. Итоговый SQL-запрос для создания такого индекса можно в один клик скопировать в буфер обмена.
Проблема #2: BitmapAnd
Для демонстрации второй проблемы давайте возьмем табличку из прошлой статьи про способы работы с EAV-структурой:
CREATE TABLE tst_eav AS
SELECT
(random() * 1e4)::integer e -- 10k объектов
, (random() * 1e2)::integer a -- 100 характеристик
, (random() * 1e2)::integer v -- 100 вариантов значений
FROM
generate_series(1, 1e6); -- 1M записей о значениях
CREATE INDEX ON tst_eav(a);
CREATE INDEX ON tst_eav(v);
Пробуем отобрать сразу по двум ключам:
explain (analyze, buffers, costs off)
SELECT * FROM tst_eav WHERE a = 1 AND v = 1;
Bitmap Heap Scan on tst_eav (actual time=1.495..1.603 rows=98 loops=1)
Recheck Cond: ((a = 1) AND (v = 1))
Heap Blocks: exact=97
Buffers: shared hit=119
-> BitmapAnd (actual time=1.466..1.466 rows=0 loops=1)
Buffers: shared hit=22
-> Bitmap Index Scan on tst_eav_a_idx (actual time=0.651..0.651 rows=10036 loops=1)
Index Cond: (a = 1)
Buffers: shared hit=11
-> Bitmap Index Scan on tst_eav_v_idx (actual time=0.627..0.627 rows=9963 loops=1)
Index Cond: (v = 1)
Buffers: shared hit=11
Очевидно, что каждый из Bitmap Index Scan
"наметил" к дальнейшей проверке по 10K записей, а всего нам их оказалось нужно 98. Посмотрим внимательно на Recheck Cond
- там два условия, которые мы можем комбинировать как в варианте с Seq Scan
:
(a, v)
(a) WHERE v = 1
(v) WHERE a = 1
(?) WHERE a = 1 AND v = 1
Попробуем первый вариант с составным индексом как наиболее типовой:
Bitmap Heap Scan on tst_eav (actual time=0.036..0.117 rows=98 loops=1)
Recheck Cond: ((a = 1) AND (v = 1))
Heap Blocks: exact=97
Buffers: shared hit=100
-> Bitmap Index Scan on tst_eav_a_v_idx (actual time=0.021..0.021 rows=98 loops=1)
Index Cond: ((a = 1) AND (v = 1))
Buffers: shared hit=3
Теперь вместо 22 страниц данных мы прочитали всего 3 - и это хорошо!
Что же нам посоветуют сделать авторекомендации explain?..
Тут мы видим сразу всю "цепочку" узлов, которая демонстрирует нам необходимость именно таких индексов.
Проблема #3: Limit - Sort - Scan
Давайте чуть модифицируем запрос, и найдем запись с минимальным v
для конкретного заданного a
:
explain (analyze, buffers, costs off)
SELECT * FROM tst_eav WHERE a = 1 ORDER BY v LIMIT 1;
Limit (actual time=49.178..49.178 rows=1 loops=1)
Buffers: shared hit=3048 read=99
-> Gather Merge (actual time=49.177..55.782 rows=1 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=5425 read=99
-> Sort (actual time=30.746..30.746 rows=1 loops=3)
Sort Key: v
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=5425 read=99
Worker 0: Sort Method: top-N heapsort Memory: 25kB
Worker 1: Sort Method: top-N heapsort Memory: 25kB
-> Parallel Seq Scan on tst_eav (actual time=0.023..30.286 rows=3345 loops=3)
Filter: (a = 1)
Rows Removed by Filter: 329988
Buffers: shared hit=5307 read=99
"Параллельность" Seq Scan
не должна нас смущать, и для условия a = 1
мы ровно как в первом случае можем порекомендовать варианты индексов:
(a)
(?) WHERE a = 1
Но если мы поднимемся выше, то увидим, что Sort
-узел хранит информацию о дополнительно используемых полях: Sort Key: v
. Так почему бы нам не расширить индексы ключом сортировки?
(a, v)
(v) WHERE a = 1
Попробуем первый из них (a, v)
- тем более, он же попал и в рекомендации предыдущей проблемы:
Limit (actual time=0.023..0.023 rows=1 loops=1)
Buffers: shared hit=4
-> Index Scan using tst_eav_a_v_idx on tst_eav (actual time=0.021..0.021 rows=1 loops=1)
Index Cond: (a = 1)
Buffers: shared hit=4
Наш запрос ускорился больше чем в 2000 раз! Но при дальнейших оптимизациях надо быть много аккуратнее - в плане теперь вообще не фигурирует условие сортировки по v
.
Замечу, что такое расширение индекса имеет смысл только в случае использования оператора =
или IS NULL
для всех остальных полей, иначе это не сможет использоваться эффективно. То есть, например, для условия a > 1
- увы, оптимизация не даст эффекта.
Посмотрим, что нам присоветует сервис:
И прямо-таки ровно то, что мы и ожидали!
А еще explain.tensor.ru научился замечать, когда...
запросу не хватило worker'ов для максимальной эффективности параллельного выполнения
план имеет аномальное время выполнения существенно больше всех узлов
время планирования запроса превышает само его выполнение, что критично для частых и быстрых запросов, где ее лучше решать через
PREPARE
Комментарии (24)
a14e
28.04.2022 08:49Ещё добавил бы, что индекс hash не поддерживает WAL, поэтому
они не crash safe. Их в случае сбоев надо их перестраивать (но, понятное дело никто после падения об этом не встромнит и после перезапуска пользователи будут жить с закорапченым индексом)
при физической репликации индексы не будут обновляться (тоже ничего хорошего не будет после сбоев)
K_Chicago
из вышеприведенного для наших запросов критично выполнение IN или ANY.
Что-нибудь можно сделать в плане индексирования/ускорения запросов где эти операторы являются bottleneck?
Kilor Автор
Такие советы удобнее всего давать на примере конкретного плана - дайте ссылочку.
K_Chicago
Не понимаю. В статье выше сказано, что IN, ANY не индексируются, и для этого утверждения ссылка не нужна. А для ответа на очевидный вопрос - "так что же тогда делать?" требуют ссылку. По-моему это просто способ ухода от ответа.
Хорошо, тогда так, вот псевдокод:
DO $$
DECLARE
l_emp_id bigint[];
l_refc REFCURSOR;
BEGIN
... array l_emp_id populated through LOOP FETCH... with some emp_ids
OPEN l_refc FOR
SELECT emp_name from employee where emp_id=ANY(l_emp_id);
END;
Kilor Автор
В статье нигде не сказано, что IN/ANY не индексируются - вполне себе индексируются. Разве что два ANY в одном условии не всегда индексируются хорошо.
А чтобы устранять хоть какой-то bottleneck, надо хотя бы одинаково представлять, о чем именно идет речь в плане производительности. Для этого снимите план запроса, закиньте его на наш сервис - и обсудим видимые проблемы.
K_Chicago
План запроса несколько сотен строк. Запрос на две страницы текста.
Я же не прошу сделать за меня мою работу, просто интересовался общими рекомендациями.
Kilor Автор
Общая рекомендация - снять план с максимумом информации и посмотреть на него:
SET track_io_timing = TRUE;
EXPLAIN (ANALYZE, BUFFERS) { SELECT | INSERT | UPDATE | DELETE } …
Там причин может быть вагон: от неактуальной статистики по таблице до небыстрой проверки присутствия ключа в большом массиве, от неудачно выбранного плана с
Merge Join
и полной вычиткой индекса до неприлично раздувшейся отUPDATE
'ов таблицы.Но без пары планов для сравнения это все не более чем гадание на кофейной гуще.
K_Chicago
а не подскажете (не требуя плана запроса), что за странную проблему я наблюдаю:
функция возвращает refcursor;
после вызова мне нужно весь recordset этого курсора фетчить в переменную типа record, там примерно 1000 записей всего возвращается;
Так вот когда я делаю первый fetch, это занимает уйму времени, 3 секунды, затем я делаю fetch в цикле, и остальные 1000 записей фетчатся моментально.
Что это за дела?
Мне казалось, что когда функция вызывается и возвращает refcursor, то запрос этого курсора уже выполнен и его recordset типа ждет уже где-то там в памяти, и каждый fetch просто передвигает указатель в памяти на следующую запись и считывает ее. А тут выглядит так будто сам запрос (он довольно сложный) выполняется только при первом fetch, и только тогда готовит все результаты в памяти. Но так же не бывает?
псевдокод примерно такой:
выполняю это все в DBeaver 21.3.2
Функция my_function объявлена как
если это имеет значение...
Kilor Автор
Типовых вариантов два:
долгое планирование - особенно, если в таблицах много секций
непрогретый кэш данных, когда первое обращение поднимает солидный кусок индекса с диска (shared read) в память, а последующие запросы обращаются к нему же уже в памяти (shared hit)
Kilor Автор
Нет, наоборот. https://postgrespro.ru/docs/postgresql/14/plpgsql-cursors
K_Chicago
И дальше по ссылке написано:
Пользователям PL/pgSQL не нужно об этом беспокоиться, так как циклы
FOR
автоматически используют курсоры, чтобы избежать проблем с памятью.Так как же мне "настроить курсор"??
Внутри функции OUT параметр REFCURSOR открывается простым
OPEN <cursor name> FOR SELECT field1,field2... FROM...;
Kilor Автор
Так они их "используют" в том смысле, что не формируют и не извлекают всю выборку целиком.
Фактически, надо получить план (например, подключив auto_explain) происходящего при первом FETCH и последующих. Поскольку OPEN стоит отдельно и тормозит не на нем (а план формируется именно в этот момент), то верна именно гипотеза №2 про shared read и чтение с носителя.
K_Chicago
и если верна эта "гипотеза" то как пофиксить??
и кстати как мне получить план для каждого отдельного FETCH в цикле?
Kilor Автор
Если дело именно в чтении "холодных" данных, то pg_prewarm, например.
Конечно, всегда есть вариант, что сначала читается 100500 неподходящих под какую-нить фильтрацию записей, а потом идет каждая - нужная.
K_Chicago
в какое место (например, в приведенном псевдокоде) и как именно мне вставить это pg_prewarm??
Kilor Автор
https://postgrespro.ru/docs/postgresql/14/pgprewarm
Будет что-то типа
SELECT pg_prewarm('my_table_name'::regclass);
, только не внутри этой функции, а где-то при начале работы.K_Chicago
что значит "при начале работы"? Это огромное online приложение которое работает непрерывно.
вот здесь написано что для того чтобы сделать manual prewarm для простого SELECT из одной таблицы:
У меня функция котора открывает REFCURSOR:
Сколько вешать в граммах?Куда вставлять PG_PREWARM?Kilor Автор
Когда-то же оно стартует ведь?
K_Chicago
есть 2 способа использования PG_PREWARM.
Добавление в shared_preload_libraries в конфигурацию сервера.
Добавление на уровне выполнения запроса.
Мне кажется вы пытаетесь говорить про первую ситуацию, т.е. добавить это на уровне сервера и перезапустить сервер. В моей ситуации это исключено. Я пытаюсь выполнить второй вариант, привел вам выше ссылку на описание того, как это сделать для select из одно таблицы. Почитайте пожалуйста ссылку.
Я не понимаю как это сделать для открытия курсора.
Kilor Автор
Очевидно, чтобы эффект "прогрева" уже сказывался при чтении из курсора, он должен быть заказан где-то раньше. Как вариант - при старте самого приложения.
Наши умозрительные эксперименты "посоветуй то, не знаю что, тогда, не знаю когда" уже ушли слишком далеко от темы статьи, поэтому предлагаю продолжать в личке, чтобы не оффтопить тут дальше.
K_Chicago
да нет смысла, как я понимаю вы не знаете, что ответить.
Спасибо за попытку:)
AKimovd
Подозреваю, что вам нужно что-то вроде: