Понимание планов запросов

Этот документ представляет базовую структуру и интерпретацию планов запросов, а также предоставляет примеры и пояснения как для однокузовных, так и для параллельных (распределённых) планов запросов.

1 Что такое план запроса?

План запроса (также известный как план выполнения или план выполнения запроса) — это ключевой компонент жизненного цикла запроса базы данных. Он генерируется оптимизатором запросов и предоставляет подробное описание того, как SQL-заявка будет выполнена в базе данных.

В централизованных базах данных (таких как PostgreSQL и другие системы с одним узлом) все данные находятся на одном узле, и весь процесс выполнения запроса происходит локально. Для удовлетворения требований запроса достаточно однокузовного плана.

Однако в распределённых базах данных (таких как Greenplum и YMatrix) данные распределены между несколькими узлами. При создании таблиц пользователь должен определить стратегию распределения и ключ распределения. Данные затем распределяются между несколькими экземплярами MXSegment на основе этого ключа, причём каждый MXSegment содержит лишь часть полного набора данных.

В результате традиционные однокузовные планы запросов оказываются недостаточными. Необходимо применять новый тип параллельного плана запроса (или распределённого плана запроса). Такой план использует распределение данных для обеспечения эффективных параллельных вычислений и гарантирует, что обработка происходит как можно ближе к данным — в идеале непосредственно на экземплярах MXSegment, минимизируя вычисления на MXMaster. Этот подход максимизирует производительность запросов.

2 Как генерируется план запроса?

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

После корректного сбора статистики используйте команду EXPALIN, чтобы просмотреть логические планы запросов, сгенерированные оптимизатором для заданного SQL-запроса. Оптимизатор обычно оценивает несколько потенциальных планов и выбирает наиболее эффективный. Анализ и понимание этих планов помогают разработчикам и администраторам баз данных (DBA) оптимизировать производительность запросов и выявлять потенциальные узкие места.

3 Из чего состоит план запроса?

План запроса имеет древовидную структуру, состоящую из нескольких узлов плана, где каждый узел представляет конкретную операцию, такую как сканирование таблицы, соединение или агрегация, и передаёт свои результаты родительскому узлу для дальнейшей обработки.

Каждый узел содержит детальную информацию о выполнении, включая порядок выполнения, метод доступа, уровень параллелизма и оценку стоимости. Дерево плана запроса начинается с листовых узлов и заканчивается корневым узлом. Обход дерева снизу вверх позволяет проследить полный путь выполнения запроса.

В распределённых планах запросов распространённые типы узлов плана и соответствующие им операторы приведены ниже:

Тип узла плана Описание операции Скалярный оператор Векторизованный оператор
Узлы сканирования Отвечают за сканирование таблиц или индексов SeqScan: Последовательное сканирование всех строк таблицы
IndexScan: Обход индекса для извлечения строк из таблицы
BitmapHeapScan & BitmapIndexScan: Часто используются вместе. Сканирование битовой карты индекса считывает подходящие записи индекса и строит битовую карту (точность зависит от размера индекса). Затем сканирование битовой карты таблицы использует эту карту для доступа к таблице. Примечание: Сканирование индекса извлекает одну запись индекса за раз и немедленно проверяет соответствующую строку таблицы, чередуя доступ к индексу и таблице — подходит для точечных запросов. Сканирование битовой карты разделяет процесс: сначала собирает все совпадающие записи индекса в памяти и сортирует их, затем обращается к таблице — идеально подходит для широких диапазонных запросов.
DynamicSeqScan: Использует функцию выбора партиций для определения нужных партиций
ParallelSeq: При включении параллельного запроса количество параллельных рабочих процессов устанавливается равным числу ядер CPU для максимальной эффективности
MxVScan: Векторизованное последовательное сканирование для таблиц MARS2/MARS3/AOCO
Узлы соединения Объединяют две или более таблиц HashJoin: Создаёт хеш-таблицу из меньшей таблицы, используя столбец соединения в качестве ключа хеширования. Затем сканирует большую таблицу, вычисляет хеш-ключи и проверяет их в хеш-таблице на совпадения. HashCond показывает столбцы, используемые для соединения.
NestedLoopJoin: Перебирает строки одного набора данных и для каждой строки сканирует другой набор. Требует трансляции одной таблицы, если только обе таблицы не имеют одинаковый ключ распределения, позволяя выполнять локальные соединения без трансляции.
MergeJoin: Сортирует оба набора данных и объединяет их
MxVHashJoin: Векторизованное хеш-соединение для таблиц MARS2/MARS3/AOCO
Другие узлы плана Materialize: Кэширует результаты подзапроса; сохраняет выходные кортежи при первом выполнении для повторного использования верхними узлами
Sort: Сортирует кортежи, возвращаемые дочерними узлами
Group: Группирует отсортированные кортежи из нижних узлов
Agg: Выполняет агрегатные функции
Unique: Удаляет дубликаты строк
Hash: Вычисляет хеш-значения; вспомогательный узел для HashJoin
Limit: Обрабатывает условия LIMIT/OFFSET
WindowAgg: Обрабатывает оконные функции
LockRows: Блокирует выбранные строки, опционально отфильтрованные по FOR SHARE/FOR UPDATE
SetOP: Объединяет несколько наборов данных с помощью объединения (UNION), пересечения (INTERSECT) или разности (EXCEPT/MINUS)
SubqueryScan: Выполняет подзапросы
Append: Конкатенирует несколько наборов результатов
Result: Обрабатывает выражения, вычисляемые один раз, или простые операторы VALUES с только INSERT ... VALUES
GroupAgg: Сначала сортирует данные по групповым столбцам, чтобы одинаковые группы были соседними, затем агрегирует при сканировании
HashAgg: Группирует и агрегирует с использованием хеш-таблицы, сортировка не требуется
Broadcast Motion: Каждый MXSegment отправляет свои строки всем остальным, в результате чего на каждом сегменте создаётся полная локальная копия
Redistribute Motion: Каждый MXSegment пересчитывает распределение данных на основе ключа переопределения и отправляет строки на соответствующий целевой сегмент
Gather Motion: Результаты всех MXSegment собираются в единый поток. Для большинства запросов это последний шаг
MxVMotion: Векторизованное Gather Motion для таблиц MARS2/MARS3/AOCO
MxVSort: Векторизованная сортировка для таблиц MARS2/MARS3/AOCO
MxVResult: Векторизованная операция Result
MxVSubqueryScan: Векторизованное сканирование подзапроса
MxVLimit: Векторизованная операция Limit
MxVHashAgg: Векторизованная операция HashAgg
MxVGroupAgg: Векторизованная операция GroupAgg
MxVAppend: Векторизованная операция Append

Эти узлы могут комбинироваться по мере необходимости для формирования полного плана запроса.

4 Как читать план запроса

Из-за его вложенной древовидной структуры план запроса необходимо читать снизу вверх.

Ниже приведены примеры однокузовного и параллельного планов запроса.

4.1 Однокузовной план запроса

Сначала создадим две таблицы. (Примеры таблиц созданы в PostgreSQL 9.2)

=# CREATE TABLE sale (
  cid int,
  pid int,
  pnum int,
  qty float,
  date timestamp
);
=# CREATE TABLE customer (
  cid int,
  cname text,
  cwechat text
);

Вставим тестовые данные.

=# INSERT INTO sale (cid, pid, pnum, qty, date) VALUES
(1, 1, 1, 10.0, '2023-07-20 08:37:06'),
(2, 3, 6, 35.0, '2023-07-15 18:22:00'),
(3, 2, 1, 3.0, '2023-07-17 11:37:00'),
(4, 1, 2, 10.0, '2023-07-20 10:37:09'),
(5, 3, 2, 35.0, '2023-07-17 08:12:06'),
(6, 1, 1, 10.0, '2023-07-02 12:10:23'),
(7, 3, 1, 35.0, '2023-07-07 08:07:00'),
(8, 5, 3, 99.0, '2023-07-20 10:37:06'),
(9, 3, 1, 35.0, '2023-07-20 15:30:00'),
(10, 3, 1, 35.0, '2023-07-20 09:00:00');
=# INSERT INTO customer (cid, cname, cwechat) VALUES
(1, 'kepler', 'kepler1997'),
(2, 'amiee', 'amiee1995'),
(3, 'lila', 'lila2002'),
(4, 'cici', 'cici1982'),
(5, 'damien', 'damien1983'),
(6, 'ariana', 'ariana1990'),
(7, 'kanye', 'kanye1960'),
(8, 'taylor', 'taylor1996'),
(9, 'michael', 'mike2005'),
(10, 'ray', 'ray1957');

Теперь вычислим общую сумму продаж по каждому клиенту, объединив таблицы sale и customer по общему ключу cid, и проанализируем план запроса.

Сначала соберём статистику перед генерацией плана.

=# ANALYZE sale;
=# ANALYZE customer;

Сгенерируем план запроса.

=# EXPLAIN SELECT c.cname, sum(s.pnum * s.qty) AS amount FROM sale s, customer c WHERE s.cid = c.cid GROUP BY c.cname;
                                  QUERY PLAN
------------------------------------------------------------------------------
 HashAggregate  (cost=2.56..2.66 rows=10 width=14)
   Group Key: c.cname
   ->  Hash Join  (cost=1.23..2.46 rows=10 width=18)
         Hash Cond: (s.cid = c.cid)
         ->  Seq Scan on sale s  (cost=0.00..1.10 rows=10 width=16)
         ->  Hash  (cost=1.10..1.10 rows=10 width=10)
               ->  Seq Scan on customer c  (cost=0.00..1.10 rows=10 width=10)
(7 rows)

Этот план запроса содержит два основных узла: Hash Join и HashAggregate. Каждый узел включает три оценки стоимости: стоимость, количество строк (rows) и ширину строки (width). Эти параметры помогают оценить время выполнения, размер результирующего набора и сложность обработки и передачи данных.

Диаграмма дерева плана запроса представлена ниже:

Чтобы интерпретировать план запроса, необходимо понимать его вложенность. Каждый узел представляет подоперацию, и данные передаются сверху вниз от дочернего узла к родительскому. Оптимизатор начинает с последовательного сканирования таблицы customer, стоимость которого составляет 1.10. Затем он создаёт хеш-таблицу в памяти. Одновременно он сканирует таблицу sale, вычисляет хеш-значения и сравнивает их с хеш-таблицей, используя поле cid в качестве ключа соединения. На этом этапе начальная и общая стоимость составляют соответственно 1.23 и 2.46. Наконец, данные группируются и агрегируются по полю cname с использованием хеш-таблицы, при этом общая стоимость запроса составляет 2.66.

Подробное объяснение компонентов:

  • Seq Scan: Последовательное сканирование. Используется, когда индекс отсутствует или требуется прочитать большую часть таблицы.
  • Hash: Создаёт хеш-таблицу. Хотя показан как отдельный узел, он является частью операции Hash Join.
  • Hash Cond: Показывает столбцы, используемые для соединения.
  • Hash Join: См. выше.
  • Group Key: Указывает столбец для группировки.
  • HashAggregate: Эквивалент HashAgg; см. выше.
  • cost: Оценённая стоимость выполнения, разделённая на две части "...":
    • Первая часть: Стоимость запуска — стоимость возврата первой строки. Для Seq Scan она близка к нулю; для Sort выше из-за предварительной обработки.
    • Вторая часть: Общая стоимость — стоимость возврата всех строк. Важно: Стоимость не представляет время.
  • rows: Количество строк, выводимых узлом. Может быть меньше отсканированных строк из-за селективности фильтра. Общая стоимость предполагает извлечение всех строк, хотя ограничения могут изменить это.
  • width: Общая ширина в байтах выходных столбцов.

4.2 Параллельный план запроса

Как и ранее, создадим две таблицы. (Примеры таблиц созданы в YMatrix 5.1 с использованием движка хранения MARS3)

Перед созданием таблиц MARS3 установите расширение matrixts для функциональности временных рядов.

=# CREATE EXTENSION matrixts;
=# CREATE TABLE sale (
  cid int,
  pid int,
  pnum int,
  qty float,
  date timestamp
) USING MARS3
DISTRIBUTED BY (cid,date)
ORDER BY (cid,date);
=# CREATE TABLE customer (
  cid int,
  cname text,
  cwechat text
) USING MARS3
DISTRIBUTED BY (cid)
ORDER BY (cid);

Вставьте те же тестовые данные и соберите статистику.

Теперь сгенерируем план запроса с использованием того же SQL-запроса. Поскольку векторизация включена по умолчанию в YMatrix, появляется множество векторизованных операторов, повышающих производительность.

=# EXPLAIN SELECT c.cname, sum(s.pnum * s.qty) AS amount FROM sale s, customer c WHERE s.cid = c.cid GROUP BY c.cname;
                                                                QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------
 Gather Motion 4:1  (slice1; segments: 4)  (cost=72.26..72.44 rows=10 width=14)
   ->  GroupAggregate  (cost=72.26..72.32 rows=2 width=14)
         Group Key: c.cname
         ->  Sort  (cost=72.26..72.27 rows=2 width=18)
               Sort Key: c.cname
               ->  Redistribute Motion 4:4  (slice2; segments: 4)  (cost=0.00..72.25 rows=2 width=18)
                     Hash Key: c.cname
                     ->  Nested Loop  (cost=0.00..72.20 rows=2 width=18)
                           Join Filter: (s.cid = c.cid)
                           ->  Custom Scan (MxVMotion) Redistribute Motion 4:4  (slice3; segments: 4)  (cost=0.00..36.07 rows=2 width=16)
                                 Hash Key: s.cid
                                 ->  Custom Scan (MxVScan) on sale s  (cost=0.00..36.02 rows=2 width=16)
                           ->  Materialize  (cost=0.00..36.04 rows=2 width=10)
                                 ->  Custom Scan (MxVScan) on customer c  (cost=0.00..36.02 rows=2 width=10)
 Optimizer: Postgres query optimizer
(15 rows)

Этот план содержит шесть основных узлов: Materialize, Redistribute Motion, Nested Loop, Sort, GroupAggregate и Gather Motion. Каждый из них включает оценки стоимости, количества строк и ширины.

Диаграмма дерева плана запроса:

Оптимизатор начинает с операции материализации. Сканирование и материализация таблицы customer занимают примерно 36. Поскольку таблица customer распределена по ключу cid, а таблица sale — по ключу (cid,date), таблица sale должна быть переопределена по ключу cid, чтобы связанные строки находились на одном и том же MXSegment для локального соединения. После переопределения применяется вложенный цикл соединения с фильтром s.cid = c.cid. На этом этапе общая стоимость достигает ~72, что указывает на значительное потребление ресурсов. Поскольку запрос требует группировки по cname, происходит второе переопределение, за которым следует сортировка по cname и агрегирование по группам. Наконец, результаты отправляются на узел Gather Motion. Узел Gather Motion вычисляет итоговую стоимость (72.32) и возвращает результаты на MXMaster.

Детали компонентов:

  • Materialize: См. выше.
  • MxVScan: Векторизованное сканирование для таблиц MARS3.
  • MxVMotion: Векторизованный оператор движения.
  • Hash Key: Столбец, используемый для построения хеш-таблицы.
  • Nested Loop: См. выше.
  • Join Filter: Условие, применяемое во время соединения.
  • Redistribute Motion: Перемещает кортежи между MXSegment для выравнивания данных при соединении.
  • Sort: См. выше.
  • Sort Key: Столбец, используемый для сортировки.
  • GroupAggregate: Агрегирует после группировки.
  • Group Key: Столбец для группировки.
  • Gather Motion: Собирает результаты со всех MXSegment и отправляет их на MXMaster для передачи клиенту. Любой план с Motion неявно разделяется на срезы. Верхний уровень здесь — срез 1. Не все запросы требуют этого — например, CREATE TABLE x AS SELECT... записывает напрямую в новую таблицу, а не отправляет на MXMaster.
  • segments: Количество экземпляров узлов данных.
  • slice: В распределённых системах планы запросов разделяются на срезы, каждый из которых параллельно обрабатывается MXSegment. Срезы разделяются операторами Motion — каждый Motion делится на отправляющую и принимающую стороны.

Расположение срезов для Segment 1 и Segment 2: