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

QHB может разрабатывать планы запросов, которые способны задействовать несколько ЦП, чтобы быстрее получить ответы на эти запросы. Эта функциональность называется параллельным выполнением запросов. Для многих запросов параллельное выполнение не дает никаких преимуществ, либо из-за ограничений текущей реализации, либо из-за принципиального отсутствия плана запроса, который был бы быстрее последовательного. Тем не менее для запросов, способных выиграть от этого, ускорение вследствие параллельного выполнения зачастую весьма значительно. Многие запросы таким образом могут выполняться более чем вдвое быстрее, а некоторые — быстрее в четыре и даже более раз. Обычно наибольший выигрыш получают запросы, обрабатывающие большой объем данных, но возвращающие пользователю всего несколько строк. В этой главе рассматриваются некоторые подробности того, как работают параллельные запросы и в каких ситуациях их можно использовать, чтобы пользователи, желающие их применять, могли понять, чего ожидать.



Как работают параллельные запросы

Когда оптимизатор определяет, что параллельное выполнение является самой быстрой стратегией выполнения конкретного запроса, он создает план запроса, включающий узел Gather (Сбор) или Gather Merge (Сбор со слиянием). Вот простой пример:

EXPLAIN SELECT * FROM pgbench_accounts WHERE filler LIKE '%x%';
                                     QUERY PLAN                                      
-------------------------------------------------------------------​------------------
 Gather  (cost=1000.00..217018.43 rows=1 width=97)
   Workers Planned: 2
   ->  Parallel Seq Scan on pgbench_accounts  (cost=0.00..216018.33 rows=1 width=97)
         Filter: (filler ~~ '%x%'::text)
(4 rows)

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

Используя EXPLAIN, вы можете узнать количество рабочих процессов, выбранное планировщиком. Когда во время выполнения запроса достигается узел Gather, процесс, реализующий сеанс пользователя, запрашивает фоновые рабочие процессов в этом количестве. Количество фоновых рабочих процессов, которое планировщик выберет для использования, ограничено значением max_parallel_workers_per_gather. Общее количество фоновых рабочих процессов, которые могут существовать одновременно, ограничено параметрами max_worker_processes и max_parallel_workers. Таким образом, вполне возможно, что параллельный запрос будет выполняться с меньшим количеством рабочих процессов, чем планировалось, или даже вообще без дополнительных рабочих процессов. Оптимальность плана может зависеть от количества доступных рабочих процессов, поэтому их нехватка может привести к низкой производительности запроса. Если это происходит часто, имеет смысл увеличить max_worker_processes и max_parallel_workers, чтобы одновременно могло выполняться больше рабочих процессов, или, как вариант, уменьшить max_parallel_workers_per_gather, чтобы планировщик запрашивал меньше рабочих процессов.

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

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



Когда может использоваться параллельное выполнение запросов?

Существует несколько значений параметров, которые могут заставить планировщик ни при каких обстоятельствах не генерировать параллельные планы запросов. Чтобы такие планы вообще генерировались, необходимо установить следующие параметры указанным образом.

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

Кроме того, система не должна работать в однопользовательском режиме. Поскольку в такой ситуации вся СУБД работает как один процесс, фоновые рабочие процессы будут недоступны.

Даже когда в целом создание параллельных планов запросов возможно, планировщик не будет генерировать их для конкретного запроса при наличии одного из следующих обстоятельств:

  • Запрос записывает данные или блокирует строки в базе данных. Если запрос содержит операцию, модифицирующую данные на верхнем уровне или внутри CTE, параллельные планы для этого запроса генерироваться не будут. В виде исключения параллельный план для нижележащей команды SELECT внутри запроса могут использовать следующие команды, создающие таблицу и наполняющие ее:

    • CREATE TABLE ... AS
    • SELECT INTO
    • CREATE MATERIALIZED VIEW
    • REFRESH MATERIALIZED VIEW
  • Запрос может быть приостановлен в процессе выполнения. В любой ситуации, когда система думает, что может произойти частичное или инкрементальное выполнение, параллельный план не генерируется. Например, курсор, созданный командой DECLARE CURSOR, никогда не будет использовать параллельный план. Аналогично цикл PL/pgSQL вида FOR x IN query LOOP .. END LOOP никогда не будет использовать параллельный план, поскольку система параллельных запросов не способна проверить, безопасен ли для выполнения код внутри цикла во время параллельного выполнения запроса.

  • Запрос использует функцию, помеченную как PARALLEL UNSAFE (небезопасна для параллельного выполнения). Большинство системных функций помечены как PARALLEL SAFE (безопасны для параллельного выполнения), но пользовательские функции по умолчанию помечаются как PARALLEL UNSAFE. См. описание в разделе Безопасность параллельного выполнения.

  • Запрос выполняется внутри другого запроса, уже параллельного. Например, если функция, вызванная параллельным запросом, сама выполняет запрос SQL, этот запрос никогда не будет использовать параллельный план. Это ограничение текущей реализации, но убирать его, вероятно, нежелательно, поскольку это может привести к использованию одним запросом огромного количества процессов.

Даже когда для определенного запроса генерируется параллельный план, имеется ряд обстоятельств, при которых этот план будет невозможно выполнить в параллельном режиме во время выполнения. Если это происходит, ведущий процесс выполняет часть плана ниже узла Gather полностью самостоятельно, как если бы узла Gather не было. Это произойдет только при выполнении одного из следующих условий:

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

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

  • Клиент передает сообщение Execute с ненулевым количеством выбираемых кортежей. См. описание расширенного протокола запросов. Поскольку в настоящее время libpq не предоставляет способа передавать такие сообщения, это может произойти только при использовании клиента, не задействующего libpq. Если это происходит часто, имеет смысл установить в max_parallel_workers_per_gather ноль в сеансах, где такой вариант возможен, во избежание создания планов запросов, которые могут быть неоптимальны при последовательном выполнении.



Параллельные планы

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


Параллельные сканирования

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

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

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

  • При параллельном сканировании по индексу или параллельном сканировании только индекса сотрудничающие процессы читают данные из индекса по очереди. В настоящее время параллельное сканирование по индексу поддерживается только для индексов B-деревьев. Каждый процесс затребует один блок индекса и будет сканировать и возвращать все кортежи, на которые тот ссылается; другие процессы могут в то же время возвращать кортежи из другого блока индекса. От каждого рабочего процесса результаты параллельного сканирования B-дерева возвращаются в отсортированном порядке.

В будущем может появиться поддержка параллельного выполнения и для других типов сканирования, например, сканирования индексов, отличных от B-деревьев.


Параллельные соединения

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

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

  • В соединении со слиянием внутренняя сторона всегда является непараллельным планом и поэтому выполняется полностью. Это может быть неэффективно, если нужно произвести сортировку, поскольку работа и результирующие данные дублируются в каждом сотрудничающем процессе.

  • В соединении по хэшу (без приставки «параллельное»), внутренняя сторона выполняется полностью каждым сотрудничающим процессом, чтобы собрать идентичные копии хэш-таблицы. Это может быть неэффективно, если хэш-таблица очень большая или план дорогой. В параллельном соединении по хэшу внутренней стороной является параллельное хэширование, при котором работа по созданию общей хэш-таблицы распределяется между сотрудничающими процессами.


Параллельное агрегирование

QHB поддерживает параллельное агрегирование, проводя агрегирование в два этапа. Сначала каждый процесс в параллельной части плана выполняет этап агрегирования, выдавая частичный результат для каждой группы, о которой известно этому процессу. В плане это отражается как узел Partial Aggregate (Частичное агрегирование). Затем частичные результаты передаются ведущему процессу через узел Gather или Gather Merge. И наконец, ведущий процесс заново агрегирует результаты всех рабочих процессов, чтобы выдать итоговый результат. В плане это отражается как узел Finalize Aggregate (Итоговое агрегирование).

Поскольку узел Finalize Aggregate выполняется в ведущем процессе, запросы, выдающие относительно большое количество групп по сравнению с количеством входных строк, будут считаться планировщиком запросов менее предпочтительными. Например, при худшем сценарии количество групп, обнаруженных узлом Finalize Aggregate, может равняться количеству входных строк, обнаруженных всеми рабочими процессами на этапе Partial Aggregate. Очевидно, что в таких случаях при использовании параллельного агрегирования производительность не повысится. Планировщик запросов учитывает это в процессе планирования и вряд ли в такой ситуации выберет параллельное агрегирование.

Параллельное агрегирование поддерживается не во всех ситуациях. Для этого каждый агрегат должен быть безопасен для распараллеливания и иметь комбинирующую функцию. Если переходное состояние агрегата имеет тип internal, то он должен также иметь функции сериализации и десериализации. Подробную информацию см. на справочной странице команды CREATE AGGREGATE. Параллельное агрегирование не поддерживается, если вызов агрегатной функции содержит предложение DISTINCT или ORDER BY, а также не поддерживается для сортирующих агрегатов или когда в запросе есть предложение GROUPING SETS. Оно может применяться, только когда все задействованные в запросе соединения также входят в параллельную часть плана.


Параллельное присоединение

Когда QHB нужно объединить строки из нескольких источников в один результирующий набор, она использует узел плана Append (Присоединение) или MergeAppend (Присоединение со слиянием). Обычно это происходит при реализации UNION ALL или сканировании партиционированной таблицы. Такие узлы можно использовать как в параллельных планах, так и в любых других. Однако в параллельном плане планировщик может вместо них применить узел Parallel Append (Параллельное присоединение).

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

Кроме того, в отличие от обычного узла Append, в котором при использовании внутри параллельного плана могут быть только частичные дочерние планы, в узле Parallel Append могут быть как частичные, так и не частичные дочерние планы. Не частичные дочерние планы будут сканироваться всего одним процессом, поскольку при многократном сканировании будут выдаваться дублирующиеся результаты. Таким образом, для планов, включающих соединение нескольких результирующих наборов, можно добиться крупномодульного распараллеливания, даже когда эффективные частичные планы недоступны. К примеру, рассмотрим запрос к партиционированной таблице, который может быть эффективно реализован только с помощью индекса, не поддерживающего параллельное сканирование. Планировщик может выбрать Parallel Append для параллельного присоединения обычных планов Index Scan; каждое отдельное сканирование индекса будет полностью выполняться одним процессом, но разные сканирования могут проводиться одновременно разными процессами.

Для отключения этой функциональности можно воспользоваться параметром enable_parallel_append.


Советы по параллельным планам

Если ожидается, что для запроса будет выдан параллельный план, но этого не происходит, можно попробовать уменьшить значение parallel_setup_cost или parallel_tuple_cost. Конечно, этот план может оказаться медленнее, чем последовательный, который предпочитает планировщик, но не всегда. Если вы не получаете параллельный план даже с очень маленькими значениями этих параметров (например, после установки обоих в ноль), возможно, имеется какая-то причина тому, что планировщик не способен сгенерировать для вашего запроса параллельный план. Информацию о возможных причинах этого см. в разделах Когда может использоваться параллельное выполнение запросов? и Безопасность параллельного выполнения.

При выполнении параллельного плана можно воспользоваться командой EXPLAIN (ANALYZE, VERBOSE), чтобы просмотреть статистику по рабочим процессам для каждого узла плана. Это может быть полезно для определения того, равномерно ли распределяется работа между всеми узлами плана, а на более общем уровне — для понимания характеристик производительности плана.



Безопасность параллельного выполнения

Планировщик классифицирует операции, задействованные в запросе, как либо безопасные для параллельного выполнения, либо с ограниченным параллельным выполнением, либо небезопасные для параллельного выполнения. Безопасная для параллельного выполнения операция — это операция, которая не конфликтует с выполняющимся параллельным запросом. Операция с ограниченным параллельным выполнением — это операция, которая не может выполняться в параллельном рабочем процессе, но может выполняться в ведущем процессе во время выполнения параллельного запроса. Таким образом, операции с ограниченным параллельным выполнением никогда не проводятся ниже узла Gather или Gather Merge, но могут встречаться в других местах плана, содержащего такой узел. Небезопасная для параллельного выполнения операция — это операция, которая не может осуществляться во время выполнения параллельного запроса, даже в ведущем процессе. Когда запрос содержит что-то небезопасное для параллельного выполнения, такое выполнение для этого запроса полностью выключается.

Имеющими ограниченное параллельное выполнение всегда являются следующие операции:

  • Сканирование общих табличных выражений (CTE).

  • Сканирование временных таблиц.

  • Сканирование сторонних таблиц, если только обертка сторонних данных не имеет API IsForeignScanParallelSafe, который указывает на безопасность параллельного выполнения.

  • Узлы плана, к которым присоединен узел InitPlan.

  • Узлы плана, ссылающиеся на связанный узел SubPlan.


Пометки параллельности для функций и агрегатов

Планировщик не может автоматически определить, является ли пользовательская обычная или агрегатная функция безопасной ограниченной или небезопасной для параллельного выполнения, поскольку это потребовало бы предсказания каждой операции, которую такая функция могла бы осуществить. В целом, это равнозначно решению проблемы остановки, а следовательно, невозможно. Даже для простых функций, где это гипотетически возможно, мы не пытаемся это делать, поскольку это было бы затратно и не защищено от ошибок. Вместо этого все пользовательские функции считаются небезопасными для параллельного выполнения, если не отмечено обратное. При выполнении команды CREATE FUNCTION или ALTER FUNCTION можно при необходимости установить метку, указав PARALLEL SAFE (безопасна для параллельного выполнения), PARALLEL RESTRICTED (с ограниченным параллельным выполнением) или PARALLEL UNSAFE (небезопасные для параллельного выполнения). При выполнении команды CREATE AGGREGATE для параметра PARALLEL можно задать SAFE, RESTRICTED или UNSAFE в качестве соответствующего значения.

Функции и агрегаты должны помечаться как PARALLEL UNSAFE, если они пишут в базу данных, обращаются к последовательностям, меняют состояние транзакции, даже временно (например, функция PL/pgSQL, устанавливающая блок EXCEPTION для перехвата ошибок), или производят постоянные изменения параметров. Схожим образом функции должны помечаться как PARALLEL RESTRICTED, если они обращаются к временным таблицам, состоянию клиентского подключения, курсорам, подготовленным операторам или разнообразному локальному состоянию обслуживающего процесса, которое система не может синхронизировать между рабочими процессами. Например, по этой последней причине ограниченно параллельными являются функции setseed и random.

В целом, если функция помечена как безопасная, когда в действительности она ограниченно используемая или небезопасная, или если она помечена как ограниченно используемая, когда в действительности она небезопасная, такая функция может вызывать ошибки или выдавать неверные ответы при использовании в параллельном запросе. Функции на языке C/RUST при неправильной пометке теоретически могут проявлять полностью неопределенное поведение, поскольку система никак не может защитить себя от произвольного кода C, но в большинстве случаев результат будет не хуже, чем у любой другой функции. При возникновении сомнений лучше всего помечать функции как UNSAFE.

Если функция, выполняемая в параллельном рабочем процессе, требует блокировки, которыми не владеет ведущий процесс, например, запрашивая таблицу, не фигурирующую в запросе, эти блокировки будут освобождены после завершения рабочего процесса, а не в конце транзакции. Если вы пишете функции, которая это делает, и для вас важна эта разница в поведении, помечайте такие функции как PARALLEL RESTRICTED, чтобы они гарантированно выполнялись только в ведущем процессе.

Обратите внимание, что планировщик запросов не рассматривает отсроченное вычисление ограниченно параллельных обычных или агрегатных функций, задействованных в запросе, чтобы получить лучший план. Поэтому, к примеру, если предложение WHERE, применяемое к конкретной таблице, является ограниченно параллельным, планировщик запроса не будет рассматривать проведение сканирования этой таблицы в параллельной части плана. В некоторых случаях возможно (и, вероятно, даже эффективно) включить сканирование этой таблицы в параллельную часть запроса и отложить вычисление предложения WHERE, чтобы оно происходило выше узла Gather. Однако планировщик этого не делает.