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

d5aedc196b8138a7417ff63b4721d29c.jpg

В этом переводе к старту курса по 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.

image-loader.svg

Data Science и Machine Learning

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

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

Java и C#

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

А также:

© Habrahabr.ru