Skip to content

Обзор соединений в PostgreSQL

Пересказ статьи Everett Berry. Inspecting Joins in PostgreSQL


PostgreSQL использует различные алгоритмы для реализации JOIN в зависимости от запроса. Мы можем исследовать план запроса, чтобы выяснить, какой тип был использован.

Введение


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

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

Вкратце о планировании запросов


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

Установка данных


Чтобы понять, как PostgreSQL готовит планы для запросов с соединением, нам потребуется установить данные для экспериментов.

Схема


Пусть у нас имеется две таблицы, которые имеют следующую структуру:



Эти таблицы предназначены, главным образом, для тестирования и сами объясняют, что в них хранится. Они соединяются по столбцу id, и имеют связь один-к-одному. Запросы создания таблиц приведены ниже:

CREATE TABLE IF NOT EXISTS user_info(
id text,
phone text,
name text,
job text,
address text
);
CREATE TABLE IF NOT EXISTS payment_info(
id text,
account_number text,
intl_account_number text,
bank_country text
);

Генерация данных


Мы собираемся использовать библиотеку faker в Питоне для генерации некоторого количества фейковых пользовательских данных. Ниже код, используемый для генерации CSV файлов для данных:

from faker import Faker
import uuid
faker = Faker()
# Измените этот диапазон на требуемый
ROW_COUNT = 1000000
u_info = open('user_info.csv', 'w')
acc_info = open('account_info.csv', 'w')
for i in range(0, ROW_COUNT):
user_id = uuid.uuid4()
phone_number = faker.phone_number()
name = faker.name()
job = faker.job().replace(',', '')
address = faker.address().replace(',', '').replace('\n', '')
bank_country = faker.bank_country()
account_number = faker.bban()
intl_account_number = faker.iban()
user_info = f"'{user_id}','{phone_number}','{name}','{job}','{address}' \n"
account_info = f"'{user_id}','{account_number}','{bank_country}','{intl_account_number}' \n"
u_info.write(user_info)
acc_info.write(account_info)
u_info.close()
acc_info.close()

Загрузка данных


Я создал миллион строк с помощью скрипта выше, и загрузил данные в PostgreSQL, используя следующие команды:

COPY user_info(id, phone, name, job, address) FROM '/path/to/csv' DELIMITER ',';
COPY payment_info(id, account_number, bank_country, intl_account_number) FROM '/path/to/csv' DELIMITER ',';




Алгоритмы JOIN


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

SET max_parallel_workers_per_gather = 0;
Наконец, мы посмотрим на параллелизацию в соединениях в образовательных целях.

Hash Join (хэш-соединение)


Давайте выполним простой запрос, который соединяет данные из двух таблиц.

SELECT
*
from
user_info
JOIN payment_info on user_info.id = payment_info.id
LIMIT
10

Выполнение explain для этого запроса сгенерирует hash join.



Как и предполагает имя, хэш-соединение строит хэш-таблицу на основе ключа соединения. В нашем примере мы создадим хэш-таблицу на столбце id таблицы user_info. Затем мы будем использовать эту таблицу для сканирования внешней таблицы payment_info. Построение хэш-таблицы добавляет стоимость, но помните, что поиск (построенный на хорошей хэш-функции) имеет эффективность O(1) в терминах асимптотической сложности. Планировщик обычно решает выполнить хэш-соединение, когда таблицы имеют более или менее одинаковый размер, и хэш-таблица может разместиться целиком в памяти. Индекс мало чем может помочь в этом случае, поскольку построение хэш-таблицы реализуется последовательным сканированием (сканированием всех строк, имеющихся в таблице).

Nested Loop (вложенные циклы)


Теперь построим запрос, в котором мы хотим выбрать все данные из обеих таблиц при условии, что ID таблицы user_info меньше, чем ID таблицы payment_info. Этот запрос может не иметь смысла в реальном мире, но вы сможете провести параллели, где может потребоваться соединение на базе такого условия. Это обычно называют "декартовым произведением".

SELECT
*
from
user_info, payment_info
WHERE user_info.id < payment_info.id
LIMIT
10
OFFSET 200

Это приводит к соединению вложенными циклами.



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

for id as id_outer in user_info:
for id as id_inner in payment_info:
if id_outer == id_inner:
return true
else
return false

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

  • Условие соединения не использует оператор = .

  • Внешняя таблица соединения меньше, чем внутренняя таблица.


Эти сценарии обычно имеют место при отношении многие-к-одному и итерации внутреннего цикла небольшие.

Merge Join (соединение слиянием)


Давайте создадим индексы на обеих таблицах на ключе соединения.

CREATE index id_idx_usr on user_info using btree(id);
CREATE index id_idx_payment on payment_info using btree(id);

Если выполнить тот же запрос, который мы использовали в примере хэш-соединения, то получим следующее:

SELECT
*
from
user_info
JOIN payment_info on user_info.id = payment_info.id
LIMIT
10



План может показаться знакомым, если вы прочитали блог о сканировании ранее, поскольку он использует то, что называется index scan (сканирование индекса), но давайте сфокусируемся на алгоритме соединения. Поскольку мы имеем индексы на обеих таблицах, и индекс BTree сортирован по умолчанию, планировщик может выбирать строки из индекса в отсортированном порядке и выполнять слияние, как показано в узле плана Merge Cond. Это значительно быстрей, чем любой другой метод соединения из-за индекса.

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


Давайте прервем сессию базы данных (чтобы отменить настройку max_parallel_workers_per_gather), а затем удалим все индексы для запуска исходного запроса. Это приведет к параллельному хэш-соединению, которое является параллельной версией первоначального хэш-соединения.



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

Понимание настройки рабочей памяти


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

Имеются друге настройки, например, hash_mem_multiplier и logical_decoding_work_mem, которые могут оказать влияние на производительность соединения, но в типичных производственных настройках все ключи/столбцы соединения должны быть проиндексированы, а настройка work_mem должна быть установлена в подходящее значение для обслуживания рабочих нагрузок приложений.

Заключение


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

  • Следует создавать индексы BTree на ключах соединений для ускорения операций соединения.

  • Если индексирование недоступно, то попытайтесь оптимизировать установку work_mem с тем, чтобы хэш-соединения могли полностью выполняться в памяти, а не сбрасывали данные на диск.

  • Соединения вложенными циклами должны быть последним из вариантов, и они обычно содержат сравнения типа < , > для ключей соединения.

  • Планировщик может комбинировать индексы для других операций типа сканирования (Scans), и существенно ускоряет запросы. Индексы BTree - очень мощное средство, имеющее широкий диапазон применения.


Обратные ссылки

Нет обратных ссылок

Комментарии

Показывать комментарии Как список | Древовидной структурой

Нет комментариев.

Автор не разрешил комментировать эту запись

Добавить комментарий

Enclosing asterisks marks text as bold (*word*), underscore are made via _word_.
Standard emoticons like :-) and ;-) are converted to images.

To prevent automated Bots from commentspamming, please enter the string you see in the image below in the appropriate input box. Your comment will only be submitted if the strings match. Please ensure that your browser supports and accepts cookies, or your comment cannot be verified correctly.
CAPTCHA

Form options

Добавленные комментарии должны будут пройти модерацию прежде, чем будут показаны.