Как мы использовали расширенную статистику Postgres и ускорились в 2850 раз

от автора

В этом переводе к старту курса по Fullstack-разработке на Python напоминаем о том, насколько важно знать технологии в деталях, грамотно применять их и планировать работу в целом. Цифра 2850 в заголовке — не преувеличение: ранее занимавший две минуты запрос в базе данных компании Affinity сегодня выполняется за 42 миллисекунды. Подробности, как всегда, под катом. А если вам нужен план развития навыков с большим количеством практики, вы можете обратить внимание на наши курсы.


Подобно отделу транспортных средств [в США], планировщик запросов PostgreSQL — это мощная, таинственная сущность, которой мы полуслепо доверяем своё благополучие. На ней лежит важнейшая ответственность за выбор наиболее эффективного плана выполнения каждого запроса. Вот пример медленного запроса с нашего веб-сервера и выбранный Postgres неэффективный план:

SELECT count(*) AS "count" FROM (   SELECT "lists_entries".*   FROM "lists_entries"   WHERE (     ("lists_entries"."org_id" = ?) AND     ("lists_entries"."list_id" = ?) AND     (       "lists_entries"."company_id" IN (         SELECT "company_id"         FROM "entity_values"         WHERE (           ("entity_attribute_id" = ?) AND           (("value_location")."country" = 'United States') AND           ("org_id" = ?)         )       )     ) AND     (       "lists_entries"."id" IN (         SELECT "list_entry_id"         FROM "entity_values"         WHERE (           ("entity_attribute_id" = ?) AND           ("value_dropdown_option_id" = ?) AND           ("org_id" = ?)         )       )     )   )   OFFSET 0 ) AS "t1" LIMIT 1;
QUERY PLAN -------------------------------------------------------------------- Limit  (cost=41.70..41.71 rows=1 width=8) (actual time=4122081.782..122081.792 rows=1 loops=1)   ->  Aggregate  (cost=41.70..41.71 rows=1 width=8) (actual time=122081.79..122081.79 rows=1 loops=1)     ->  Nested Loop Semi Join  (cost=1.57..41.69 rows=1 width=44) (actual time=0.818..122081.782 rows=33 loops=1)       ->  Nested Loop Semi Join  (cost=1.01..25.07 rows=1 width=4) (actual time=0.079..122074.806 rows=1958 loops=1)         ->  Index Scan using lists_entries_org_id_list_id_index on lists_entries  (cost=0.43..8.46 rows=1 width=8) (actual time=0.015..5.564 rows=13769 loops=1)               Index Cond: ((org_id = ?) AND (list_id = ?))         ->  Index Scan using entity_values_org_id_entity_attribute_id_company_id_index on entity_values  (cost=0.57..8.59 rows=1 width=4) (actual time=0.014..8.865 rows=0 loops=13769)               Index Cond: ((org_id = ?) AND (entity_attribute_id = ?) AND (company_id = lists_entries.company_id))               Filter: ((value_location).country = 'United States'::text)               Rows Removed by Filter: 1       ->  Index Scan using entity_values_org_id_list_entry_id_index on entity_values entity_values_1  (cost=0.56..8.59 rows=1 width=4) (actual time=0.003..0.003 rows=0 loops=1958)             Index Cond: ((org_id = 4796) AND (list_entry_id = lists_entries.id))             Filter: ((entity_attribute_id = ?) AND (value_dropdown_option_id = ?))             Rows Removed by Filter: 2 Planning Time: 1.828 ms Execution Time: 122081.792 ms (16 rows)Copied successfully! 

Самый расточительный шаг — второе вложенное соединение циклов:

Nested Loop Semi Join (cost=1.01..25.07 rows=1 width=4) (actual time=0.079..122074.806 rows=1958 loops=1). 

Postgres подсчитала, что этот шаг вернёт около 1 строки, что было дикой недооценкой — на самом деле он вернул 1958 строк и занял около 122 секунд. Благодаря грамотному использованию статистики Postgres мы сократили время выполнения этого запроса с 2 минут до 42 миллисекунд — почти в 3000 раз, но до погружения в настройки статистики для понимания расскажем, как работает планировщик Postgres.

Статистика и планы запросов Postgres

Основы

Статистика — это собранные для подбора плана запросов данные Postgres. Postgres из коробки делает выборку возможных значений каждого столбца каждой таблицы, чтобы создать гистограммы и список самых частых значений. Эти значения оценивают, сколько строк получится после применения некоторого набора фильтров к таблице.

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

ALTER TABLE table ALTER column SET STATISTICS {-1 ..10000}

где -1 устанавливает значение по умолчанию 100 (документация). Это число задаёт количество корзин в гистограмме и количество сохраняемых встречающихся чаще всего значений. Недостаток увеличения значений собираемой статистики столбца заключается в том, что в pg_statistic хранится больше данных и ANALYZE таблицы столбца замедляется. Подробнее об этом рассказывается в документации Postgres.

Расширенная статистика

Расширенная статистика — это пользовательские объекты, которые указывают Postgres собирать определённые виды данных относительно наборов столбцов, а не для отдельных столбцов. Без расширенной статистики Postgres рассматривает влияние фильтров на таблицу независимо. 

Пример: база данных содержит 10 записей об артистах, каждая из которых имеет 10 ссылающихся на неё записей об альбомах, а каждая запись об альбоме имеет 10 ссылающихся на неё записей о песнях. В общей сложности это 10 исполнителей, 100 альбомов и 1 000 песен. Если выполнить запрос ниже:

SELECT * FROM songs WHERE (artists_id = 1 and album_id = 1);

то при идеальной выборке план может выглядеть так:

Index Scan using songs_artists_id_album_id_index on songs  (cost=0.28..6.05 rows=1 width=159) (actual time=5.555..5.562 rows=10 loops=1)    Index Cond: ((artists_id = 1) AND (album_id = 1))  Planning Time: 311.482 ms  Execution Time: 9.266 ms (4 rows) 

(cost=0.28...6.05 rows=1 width=159) — оценка планировщика, (actual time=5.555...5.562 rows=10 loops=1) — фактические результаты выполнения плана. Предполагалось, что будет возвращена 1 строка, но на самом деле их было 10.

  • Планировщик вычислил оценку строк, сначала взяв общее количество Songs (1000), затем рассмотрев фильтр artists_id.

  • 10% песен имеют artists_id = 1, таким образом, остаётся 100 песен.

  • Далее рассматривается фильтр album_id. 1% песен имеют album_id = 1, поэтому остаётся 1 песня.

Postgres упускает ключевой момент: artist_id и album_id сильно коррелируют. На самом деле знание album_id однозначно определяет artist_id. Если бы Postgres знала об этом, то в оценке могла бы задействовать только фильтр album_id = 1 и получить правильный результат — 10 песен.

Такая корреляция в Postgres указывается с помощью статистики зависимостей. Эта статистика отражает частоту, с которой каждый столбец однозначно определяет другой столбец. Статистика зависимости для (artist_id, album_id) может дать следующее:

CREATE STATISTICS album_id_artist_id_dep_stt (dependencies) ON album_id, artist_id FROM songs;ANALYZE songs;SELECT stxname, stxkeys, stxddependencies   FROM pg_statistic_ext join pg_statistic_ext_data on (oid = stxoid)   WHERE stxname = 'stts';  stxname | stxkeys |             stxddependencies              ---------+---------+------------------------------------------  stts    | 1 5     | {"1 => 5": 0.1, "5 => 1": 1.0} (1 row) 

1 и 5 в stxkeys и stxddependencies относятся к 1 и 5 столбцам таблицы songs, это artist_id и album_id, соответственно. Значение для «1 => 5» составляет 0,1, поскольку artist_id определяет 10% album_id.

Поскольку album_id всегда определяет artist_id, значение «5 => 1» равно 1.0. Когда Postgres фильтрует по столбцам с соответствующей статистикой зависимости, она может воспользоваться статистикой для уточнения оценки. Конечно, есть другие виды расширенной статистики, но для такого типа распределения данных статистика зависимости — самый здравый выбор.

Далее — об одной из особенностей расширенной статистики. Postgres умеет использовать её только при фильтрации именно по тем столбцам, на которые ссылается статистика, а также при фильтрации с использованием простых условий равенства, например, artist_id = 5, а не artist_id IN (5, 6) или artist_id < 10.

Использование расширенной статистики может привести к неинтуитивному выбору индексов. Если статистика зависимости указывает Postgres на избыточность фильтра столбцов, как в случае artist_id и album_id, она может задействовать ссылающийся только на один из столбцов индекс. В случае songs она может использовать индекс только на (album_id), а не на (artist_id, album_id), когда есть оба столбца.

Стратегии соединения (Join)

В Postgres есть три варианта объединения таблиц:

  1. Соединение вложенных циклов. Используя эту стратегию объединения, Postgres просматривает каждую строку в левом отношении и сканирует правое отношение в поисках строк, удовлетворяющих условию объединения, при этом в идеале используя индекс. Стратегия эффективна в случаях, когда в левом отношении очень мало строк.

  2. Соединение слияния. Из документации: «каждое отношение сортируется по атрибутам соединения до начала операции. Затем два отношения сканируются параллельно, и совпадающие строки объединяются, образуя объединённые строки. Этот вид объединения более привлекателен, поскольку каждое отношение сканируется только один раз. Требуемая сортировка достигается либо явным шагом сортировки, либо сканированием отношения в нужном порядке с использованием индекса по ключу соединения».

  3. Хеш-соединение. Из документации: «нужное отношение сначала сканируется и загружается в хеш-таблицу с использованием присоединяемых атрибутов в качестве хеш-ключей. Далее сканируется левое отношение, и соответствующие значения каждой найденной строки используются в качестве хеш-ключей, чтобы найти совпадающие строки в таблице».

Для наших задач главное отметить, что, по сравнению с другими стратегиями соединения, накладные расходы на вложенные циклы очень малы. Однако это объединение может пойти не так, когда в левом отношении много строк. Например, предположим, что в левом отношении имеется 1 000 строк, а для доступа к правому отношению Postgres использует индекс. Если каждый доступ к индексу занимает 4 мс, то всё соединение займёт 4 секунды, для ответа на запрос пользователя это слишком медленно.

Статистика PostgreSQL на практике

Теперь, когда мы понимаем различные типы соединений, давайте вернёмся к соединению вложенных циклов, которое показалось нам проблематичным. Не вдаваясь в подробности модели данных Affinity, достаточно знать, что в наших таблицах entity_values и lists_entries столбец org_id однозначно определяется list_id или entity_attribute_id. Это означает, что в оценке выборки набора фильтров по этим столбцам фильтры не следует рассматривать по отдельности.

Запросы замедлялись из-за того, что Postgres недооценила количество получаемых в результате фильтрации строк, а значит из-за недооценки решала использовать соединение вложенных циклов.

Принятые меры

Вернёмся к проблемному запросу. Самым затратным шагом было обращение к индексу entity_values_org_id_entity_attribute_id_company_id_index 13 769 раз.

Чтобы побудить планировщика использовать другую стратегию соединения, нам нужно было улучшить его оценки для фильтров на lists_entries и entity_values. Исходя из фильтров, среди других таблиц и столбцов различных шаблонов запросов мы достигли максимального значения статистики по столбцам для:

lists_entries: - org_id - list_identity_values: - org_id - entity_attribute_id

А также, поскольку и list_id, и entity_attribute_id однозначно определяют org_id, то в других статистиках зависимостей для других таблиц и столбцов добавили статистики по зависимостям:

lists_entries (list_id, org_id) entity_values (entity_attribute_id, org_id)

После этого Postgres выбрала такой план запроса:

QUERY PLAN --------------------------------------------------------------------  Limit  (cost=466.34..466.35 rows=1 width=8) (actual time=37.736..37.737 rows=1 loops=1)    ->  Aggregate  (cost=466.34..466.35 rows=1 width=8) (actual time=37.736..37.736 rows=1 loops=1)          ->  Nested Loop Semi Join  (cost=404.32..466.33 rows=1 width=44) (actual time=23.545..37.727 rows=36 loops=1)                ->  Hash Semi Join  (cost=403.76..458.04 rows=1 width=4) (actual time=22.076..28.610 rows=1972 loops=1)                      Hash Cond: (lists_entries.company_id = entity_values.company_id)                      ->  Bitmap Heap Scan on lists_entries  (cost=362.16..380.21 rows=13744 width=8) (actual time=2.579..7.395 rows=13887 loops=1)                            Recheck Cond: ((org_id = ?) AND (company_id IS NOT NULL) AND (list_id = ?))                            Heap Blocks: exact=3141                            ->  BitmapAnd  (cost=362.16..362.16 rows=9 width=0) (actual time=2.212..2.212 rows=0 loops=1)                                  ->  Bitmap Index Scan on lists_entries_org_id_company_id_index  (cost=0.00..135.52 rows=8145 width=0) (actual time=1.142..1.143 rows=15166 loops=1)                                        Index Cond: (org_id = ?)                                  ->  Bitmap Index Scan on lists_entries_list_id_index  (cost=0.00..219.51 rows=13744 width=0) (actual time=0.944..0.944 rows=13887 loops=1)                                        Index Cond: (list_id = ?)                      ->  Hash  (cost=40.10..40.10 rows=120 width=4) (actual time=19.484..19.484 rows=4914 loops=1)                            Buckets: 8192 (originally 1024)  Batches: 1 (originally 1)  Memory Usage: 237kB                            ->  Index Scan using entity_values_org_id_entity_attribute_id_company_id_index on entity_values  (cost=0.57..40.10 rows=120 width=4) (actual time=0.019..18.791 rows=4914 loops=1)                                  Index Cond: ((org_id = ?) AND (entity_attribute_id = ?))                                  Filter: ((value_location).country = 'United States'::text)                                  Rows Removed by Filter: 19190                ->  Index Scan using entity_values_org_id_list_entry_id_index on entity_values entity_values_1  (cost=0.56..4.42 rows=1 width=4) (actual time=0.004..0.004 rows=0 loops=1972)                      Index Cond: ((org_id = ?) AND (list_entry_id = lists_entries.id))                      Filter: ((entity_attribute_id = ?) AND (value_dropdown_option_id = ?))                      Rows Removed by Filter: 2  Planning Time: 3.559 ms  Execution Time: 37.809 ms (25 rows) Time: 42.189 ms

Здесь оценки намного точнее, и планировщик для внутреннего соединения (INNER JOIN) выбрал хеш-соединение. Вместо 2 минут запрос занял 42 миллисекунды.

Заключение

Увеличение статистики по столбцам и добавление статистики зависимостей очень помогло, но это только начало прогресса. Как вы могли заметить, планировщик по-прежнему недооценивает количество получаемых в результате внутреннего соединения строк.

Хотя в этот раз OUTER JOIN вложенного цикла не занял много времени, нетрудно представить себе запрос, в котором INNER JOIN приводит к получению большого количества строк, а OUTER JOIN (внешнее соединение) становится узким местом.

Так планирование влияет на результаты. Именно поэтому программы наших курсов-профессий — не просто упорядоченный набор знаний. Это планы приобретения навыков, составленные практикующими экспертами, которых достаточно, чтобы вы устроились на новую работу, поэтому вы можете смело начинать карьеру с наших специализаций или прокачивать у нас навыки, если вам интересна Fullstack-разработка на Python, Frontend или другие направления IT.

Data Science и Machine Learning

Python, веб-разработка

Мобильная разработка

Java и C#

От основ — в глубину

А также:


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