Перейти к содержимому

Оптимизация запросов

Планирование и выполнение запросов, методы доступа, соединения, статистика и практическая оптимизация запросов.

Курс сохраняет официальный порядок тем. Русские материалы извлечены из PDF в читаемый нативный Markdown для дальнейшей редакторской работы.

Состояние материалов: 官方 PDF 已提取为原生 Markdown

Базовый язык: Базовый язык: English. Текущий английский архив используется как базовый; русская версия сохранена для сверки глав.

О курсе

Планирование и выполнение запросов, методы доступа, соединения, статистика и практическая оптимизация запросов.

Состояние материалов

ЯзыкТекущий архивВерсияТемыСтраница курса
EnglishQPT-handouts-16-en-20251018.zip16-2025101817HTTP 200
РусскийQPT-handouts-16.zip1617HTTP 200

Содержание

Дополнительные материалы

  • demodb.pdf
  • qpt_new_features.pdf
  • qpt_student_guide.pdf

Редакторский статус

Русский комплект доступен в виде нативного Markdown. Если английский комплект не найден, английский маршрут оставлен для будущего источника.

00 Занятие 00: PostgreSQL 16 / Оптимизация запросов

Исходная страница в нативном Markdown: PostgreSQL 16 / Оптимизация запросов
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_00_introduction.html Archive: QPT-handouts-16.zip Страниц PDF: 9

Page 1: PostgreSQL 16 / Оптимизация запросов

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Администраторам

Source slide page 2: Администраторам{#source-figure-002 caption=“Source slide page 2: Администраторам”}

  • DBA2, 4 дня

Комментарии преподавателя

Для прохождения всех курсов необходимо знание основ языка SQL. Курса по SQL в нашей линейке нет, но имеется множество книг и образовательных ресурсов для освоения SQL. Мы рекомендуем книгу Евгения Моргунова «PostgreSQL. Основы языка SQL»:

https://postgrespro.ru/education/books/sqlprimer

Для администраторов мы предлагаем следующие курсы.

Курс DBA1 – базовый, он дает сведения об архитектуре PostgreSQL, об установке, настройке и управлении сервером. Приводятся обзоры разграничения доступа, резервного копирования и репликации.

В курсе DBA2 обсуждается настройка конфигурационных параметров исходя из понимания внутренней организации сервера; говорится о мониторинге сервера с использованием обратной связи для итеративной настройки параметров.

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

Курс DBS рассказывает про настройку подключений клиентов к СУБД и про управление доступом внутри самой СУБД.

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

В курсе PGPRO рассматриваются дополнительные возможности, которые предоставляет СУБД Postgres Pro Enterprise.

Page 3: Разработчикам

Source slide page 3: Разработчикам{#source-figure-003 caption=“Source slide page 3: Разработчикам”}

  • DEV2, 4 дня

Комментарии преподавателя

Для разработчиков серверной части приложений мы предлагаем следующие курсы.

Базовым курсом для разработчиков является курс DEV1, в котором даются общие сведения об архитектуре PostgreSQL и использовании основных объектов базы данных; изучается программирование на стороне сервера на языках SQL и PL/pgSQL с использованием основных типов данных (включая составной тип и массивы), а также рассматривается взаимодействие с клиентской частью.

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

Курсы DBS, QPT и PGPRO являются общими для разработчиков и администраторов.

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

https://postgrespro.ru/education/courses

Page 4: О курсе QPT

Продолжительность: 3 дня

  • Предварительные знания
    • знакомство с ОС Unix
    • уверенное владение SQL (знакомство с PL/pgSQL не обязательно,
    • но полезно)
    • PostgreSQL в объеме курса DBA1 или DEV1
  • Какие навыки будут получены
    • детальное понимание механизмов планирования и выполнения запросов
    • настройка параметров экземпляра, связанных с производительностью
    • поиск проблемных запросов и их оптимизация

Page 5: Используемые материалы

Подготовленная виртуальная машина

  • ОС Xubuntu 24.04
  • PostgreSQL 16 с документацией на русском языке
  • демонстрационная база данных «Авиаперевозки»
  • DBeaver
  • Учебные материалы
    • руководство слушателя
    • презентации, демонстрации, практические задания и их решение
    • (в форматах html и pdf)
    • схема демонстрационной базы данных

Комментарии преподавателя

Если вы проходите курс самостоятельно, обязательно начните со знакомства с Руководством слушателя. В числе прочего в нем написано, где скачать и как использовать виртуальную машину курса и прочие материалы. Все материалы курса доступны по адресу:

https://postgrespro.ru/education/courses/QPT

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

Учебные материалы (презентации, демонстрации, практические задания и их решения) доступны в двух форматах. Формат html удобен для онлайн-работы, он позволяет копировать фрагменты текста и кода. Документ в формате pdf разбит на страницы и удобен для печати.

Дополнительные справочные материалы помогут быстро найти нужную информацию.

В виртуальной машине установлен DBeaver. В курсе мы используем только psql, но при желании можно воспользоваться и графической средой.

Page 6: Организационные вопросы

День: ~8 академических часов + обед (1час)

  • Каждая тема, как правило, состоит из
    • презентации и демонстраций: ~30–60 мин
    • практических заданий: ~20–30 мин, включая перерыв

Page 7: День 1

Демонстрационная база данных

    1. Авиаперевозки
  • Выполнение запросов
      1. Планирование и выполнение
  • Доступ к данным
      1. Методы доступа
      1. Параллельный доступ

Page 8: День 2

Методы доступа (продолжение)

    1. Типы индексов
  • Сортировка и группировка
      1. Сортировка
      1. Группировка
  • Способы соединения
      1. Соединение вложенным циклом
      1. Соединение хешированием
      1. Соединение слиянием

Page 9: День 3

Статистика

    1. Базовая статистика
    1. Расширенная статистика
  • Оптимизация запросов
      1. Профилирование
      1. Материализация
      1. Функции
      1. Подходы к настройке

01 Занятие 01: Демонстрационная база данных / Авиаперевозки

Исходная страница в нативном Markdown: Демонстрационная база данных / Авиаперевозки
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_01_demodb_bookings.html Archive: QPT-handouts-16.zip Страниц PDF: 27

Page 1: Демонстрационная база данных / Авиаперевозки

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Цели и задачи
  • Предметная область и общая схема демобазы
  • Подробное описание объектов

Page 3: Авиаперевозки

Source slide page 3: Авиаперевозки{#source-figure-003 caption=“Source slide page 3: Авиаперевозки”}

Комментарии преподавателя

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

● самостоятельное изучение языка запросов SQL;

● подготовка книг, пособий и учебных курсов по языку SQL;

● демонстрация возможностей PostgreSQL в статьях и заметках.

При разработке демонстрационной базы данных мы преследовали несколько целей:

● схема данных должна быть достаточно простой, чтобы быть понятной без особых пояснений;

● в то же время схема данных должна быть достаточно сложной, чтобы позволять строить осмысленные запросы;

● база данных должна быть наполнена данными, напоминающими реальные, с которыми будет интересно работать.

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

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

В данной теме рассматривается версия демобазы от 15.08.2017.

https://postgrespro.ru/education/demodb

Page 4: Общая схема

Source slide page 4: Общая схема{#source-figure-004 caption=“Source slide page 4: Общая схема”}

Комментарии преподавателя

Основной сущностью является бронирование (bookings).

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

Билет включает один или несколько перелетов (ticket_flights). Несколько перелетов могут включаться в билет в случаях, когда нет прямого рейса, соединяющего пункты отправления и назначения (полет с пересадками), либо когда билет взят «туда и обратно».

Каждый рейс (flights) следует из одного аэропорта (airports) в другой. Рейсы с одним номером имеют одинаковые пункты вылета и назначения, но будут отличаться датой отправления.

При регистрации на рейс пассажиру выдается посадочный талон (boarding_passes), в котором указано место в самолете. Пассажир может зарегистрироваться только на тот рейс, который есть у него в билете. Комбинация рейса и места в самолете уникальна.

Количество мест (seats) в самолете и их распределение по классам обслуживания зависит от модели самолета (aircrafts), выполняющего рейс. Предполагается, что каждая модель самолета имеет только одну компоновку салона.

На приведенной схеме отмечены только столбцы, соответствующие первичным и внешним ключам. Далее мы рассмотрим основные объекты демонстрационной базы данных подробнее.

Page 5: Бронирования

Bookings

  • пассажир заранее (за месяц) бронирует билет себе и, возможно,
  • нескольким другим пассажирам
  • book_ref
  • номер бронирования (комбинация букв и цифр)
  • book_date
  • дата бронирования
  • total_amount
  • общая стоимость включенных в бронирование билетов

Комментарии преподавателя

Пассажир заранее (book_date, максимум за месяц до рейса) бронирует билет себе и, возможно, нескольким другим пассажирам. Бронирование идентифицируется номером (book_ref, шестизначная комбинация букв и цифр).

Поле total_amount хранит общую стоимость включенных в бронирование перелетов всех пассажиров.

   Столбец    |      Тип      | Модификаторы |         Описание
--------------+---------------+--------------+---------------------------
 book_ref     | char(6)       | not null     | Номер бронирования
 book_date    | timestamptz   | not null     | Дата бронирования
 total_amount | numeric(10,2) | not null     | Полная сумма бронирования
Индексы:
    PRIMARY KEY, btree (book_ref)
Ссылки извне:
    TABLE "tickets" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)

Page 6: Бронирование

Начнем с бронирования и выберем какое-нибудь одно:

=> SELECT * FROM bookings b WHERE b.book_ref = '0824C5';
 book_ref |       book_date        | total_amount
----------+------------------------+--------------
 0824C5   | 2017-07-25 23:36:00+03 |    112400.00
(1 row)

Мы видим дату бронирования и общую сумму. Если сравнивать дату с текущей, то бронирование сделано довольно давно:

=> SELECT now();
              now
-------------------------------
 2026-04-10 14:32:35.308587+03
(1 row)

Но для демобазы «текущим» моментом является другая дата:

=> SELECT bookings.now();
          now
------------------------
 2017-08-15 18:00:00+03
(1 row)

Так что «на самом деле» билеты забронированы 20 дней назад:

=> SELECT bookings.now() - b.book_date
FROM bookings b
WHERE b.book_ref = '0824C5';
     ?column?
------------------
 20 days 18:24:00
(1 row)

Page 7: Билеты

Tickets

  • билет выдается на одного пассажира и может включать несколько
  • перелетов
  • ни идентификатор пассажира, ни имя не являются постоянными;
  • нельзя однозначно найти все билеты одного и того же пассажира
  • ticket_no
  • номер билета
  • book_ref
  • номер бронирования
  • passenger_id
  • идентификатор пассажира (номер документа)
  • passenger_name
  • имя пассажира
  • contact_data
  • контактные данные пассажира

Комментарии преподавателя

Билет имеет уникальный номер (ticket_no), состоящий из 13 цифр.

Билет содержит идентификатор пассажира (passenger_id) — номер документа, удостоверяющего личность, — его фамилию и имя (passenger_name) и контактную информацию (contact_data).

Ни идентификатор пассажира, ни имя не являются постоянными (можно поменять паспорт, можно сменить фамилию), поэтому однозначно найти все билеты одного и того же пассажира невозможно.

     Столбец    |     Тип     | Модификаторы |          Описание
----------------+-------------+--------------+-----------------------------
 ticket_no      | char(13)    | not null     | Номер билета
 book_ref       | char(6)     | not null     | Номер бронирования
 passenger_id   | varchar(20) | not null     | Идентификатор пассажира
 passenger_name | text        | not null     | Имя пассажира
 contact_data   | jsonb       |              | Контактные данные пассажира
Индексы:
    PRIMARY KEY, btree (ticket_no)
Ограничения внешнего ключа:
    FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
Ссылки извне:
    TABLE "ticket_flights" FOREIGN KEY (ticket_no) REFERENCES
tickets(ticket_no)

Page 8: Билеты

Посмотрим, какие билеты включены в выбранное бронирование:

=> SELECT t.*
FROM bookings b
  JOIN tickets t ON t.book_ref = b.book_ref
WHERE b.book_ref = '0824C5';
   ticket_no   | book_ref | passenger_id |  passenger_name   |       contact_data
---------------+----------+--------------+-------------------+---------------------------
 0005435126781 | 0824C5   | 7247 393204  | ALEKSANDR MATVEEV | {"phone": "+70095062310"}
 0005435126782 | 0824C5   | 1745 826066  | NINA KRASNOVA     | {"phone": "+70876976071"}
(2 rows)

Летят два человека; на каждого оформляется собственный билет с информацией о пассажире.

Page 9: Перелеты

Ticket\_flights

  • перелет соединяет билеты с рейсами
  • ticket_no
  • номер билета
  • flight_id
  • идентификатор рейса
  • fare_conditions
  • класс обслуживания
  • amount
  • стоимость перелета

Комментарии преподавателя

Перелет соединяет билет с рейсом и идентифицируется их номерами.

Для каждого перелета указываются его стоимость (amount) и класс обслуживания (fare_conditions).

     Столбец     |     Тип       | Модификаторы |    Описание
-----------------+---------------+--------------+---------------------
 ticket_no       | char(13)      | not null     | Номер билета
 flight_id       | integer       | not null     | Идентификатор рейса
 fare_conditions | varchar(10)   | not null     | Класс обслуживания
 amount          | numeric(10,2) | not null     | Стоимость перелета
Индексы:
    PRIMARY KEY, btree (ticket_no, flight_id)
Ограничения-проверки:
    CHECK (amount >= 0)
    CHECK (fare_conditions IN ('Economy', 'Comfort', 'Business'))
Ограничения внешнего ключа:
    FOREIGN KEY (flight_id) REFERENCES flights(flight_id)
    FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no)
Ссылки извне:
    TABLE "boarding_passes" FOREIGN KEY (ticket_no, flight_id)
        REFERENCES ticket_flights(ticket_no, flight_id)

Page 10: Перелеты

По какому маршруту летят пассажиры? Добавим в запрос перелеты.

=> SELECT tf.*
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.ticket_no = '0005435126781';
   ticket_no   | flight_id | fare_conditions |  amount
---------------+-----------+-----------------+----------
 0005435126781 |     22566 | Economy         | 11700.00
 0005435126781 |     71439 | Economy         |  3200.00
 0005435126781 |     74643 | Economy         |  8800.00
 0005435126781 |     94335 | Economy         | 11700.00
 0005435126781 |     95726 | Economy         |  3200.00
 0005435126781 |    206625 | Business        | 26400.00
(6 rows)

Здесь мы смотрим только на один билет — все маршруты в одном бронировании всегда совпадают. Видим, что в билете 6 перелетов; из них один бизнес-классом, другие — экономом.

Page 11: Рейсы

Flights

  • рейс выполняется по расписанию из одного аэропорта в другой
  • естественный ключ — номер рейса и дата отправления,
  • но используется суррогатный ключ
  • flight_id
  • идентификатор рейса
  • flight_no
  • номер рейса
  • scheduled_departure/arrival
  • вылет и прилет по расписанию
  • actual_departure/arrival
  • фактический вылет и прилет
  • departure/arrival_airport
  • аэропорты отправления и прибытия
  • status
  • статус рейса
  • aircraft_code
  • код самолета

Комментарии преподавателя

Рейс соединяет аэропорты вылета и прибытия. Если нет прямого рейса, в билет включаются несколько рейсов.

       Столбец       |     Тип     | Модификаторы |          Описание
---------------------+-------------+--------------+----------------------------
 flight_id           | integer     | not null     | Идентификатор рейса
 flight_no           | char(6)     | not null     | Номер рейса
 scheduled_departure | timestamptz | not null     | Время вылета по расписанию
 scheduled_arrival   | timestamptz | not null     | Время прилёта по расписанию
 departure_airport   | char(3)     | not null     | Аэропорт отправления
 arrival_airport     | char(3)     | not null     | Аэропорт прибытия
 status              | varchar(20) | not null     | Статус рейса
 aircraft_code       | char(3)     | not null     | Код самолета, IATA
 actual_departure    | timestamptz |              | Фактическое время вылета
 actual_arrival      | timestamptz |              | Фактическое время прилёта
Индексы:
    PRIMARY KEY, btree (flight_id)
    UNIQUE CONSTRAINT, btree (flight_no, scheduled_departure)
Ограничения-проверки:
    CHECK (scheduled_arrival > scheduled_departure)
    CHECK ((actual_arrival IS NULL)
       OR  ((actual_departure IS NOT NULL AND actual_arrival IS NOT NULL)
            AND (actual_arrival > actual_departure)))
    CHECK (status IN ('On Time', 'Delayed', 'Departed',
                      'Arrived', 'Scheduled', 'Cancelled'))
Ограничения внешнего ключа:
    FOREIGN KEY (aircraft_code) REFERENCES aircrafts_data(aircraft_code)
    FOREIGN KEY (arrival_airport) REFERENCES airports_data(airport_code)
    FOREIGN KEY (departure_airport) REFERENCES airports_data(airport_code)

Page 12: Рейсы

Теперь разберемся, какие рейсы скрываются за выбранными перелетами.

=> SELECT f.flight_id, f.scheduled_departure,
  f.departure_airport dep, f.arrival_airport arr,
  f.status, f.aircraft_code aircraft
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE t.ticket_no = '0005435126781'
ORDER BY f.scheduled_departure;
 flight_id |  scheduled_departure   | dep | arr |  status   | aircraft
-----------+------------------------+-----+-----+-----------+----------
     22566 | 2017-08-12 11:00:00+03 | VKO | PEE | Arrived   | 773
     95726 | 2017-08-12 15:30:00+03 | PEE | SVX | Arrived   | SU9
     74643 | 2017-08-13 11:30:00+03 | SVX | SGC | Arrived   | SU9
    206625 | 2017-08-15 14:45:00+03 | SGC | SVX | Departed  | SU9
     71439 | 2017-08-16 08:50:00+03 | SVX | PEE | On Time   | SU9
     94335 | 2017-08-16 18:55:00+03 | PEE | VKO | Scheduled | 773
(6 rows)

Видим три рейса «туда» и три «обратно». «Туда» все рейсы уже совершены (Arrived), а в настоящее время пассажир летит «обратно» (Departed). Следующий рейс будет по расписанию (On Time), а на последний еще не открыта регистрация (Scheduled). Посмотрим внимательнее на все столбцы одного из рейсов.

=> SELECT * FROM flights f WHERE f.flight_id = 22566 \gx
-[ RECORD 1 ]-------+-----------------------
flight_id           | 22566
flight_no           | PG0412
scheduled_departure | 2017-08-12 11:00:00+03
scheduled_arrival   | 2017-08-12 12:25:00+03
departure_airport   | VKO
arrival_airport     | PEE
status              | Arrived
aircraft_code       | 773
actual_departure    | 2017-08-12 11:01:00+03
actual_arrival      | 2017-08-12 12:25:00+03

Реальное время может отличаться от времени по расписанию (обычно не сильно). Номер flight_no одинаков для всех рейсов, следующих по одному маршруту по расписанию:

=> SELECT f.flight_id, f.flight_no, f.scheduled_departure
FROM flights f
WHERE f.flight_no = 'PG0412'
ORDER BY f.scheduled_departure
LIMIT 10;
 flight_id | flight_no |  scheduled_departure
-----------+-----------+------------------------
     22784 | PG0412    | 2016-08-15 11:00:00+03
     22746 | PG0412    | 2016-08-16 11:00:00+03
     22721 | PG0412    | 2016-08-17 11:00:00+03
     22691 | PG0412    | 2016-08-18 11:00:00+03
     22749 | PG0412    | 2016-08-19 11:00:00+03
     22508 | PG0412    | 2016-08-20 11:00:00+03
     22493 | PG0412    | 2016-08-21 11:00:00+03
     22496 | PG0412    | 2016-08-22 11:00:00+03
     22483 | PG0412    | 2016-08-23 11:00:00+03
     22501 | PG0412    | 2016-08-24 11:00:00+03
(10 rows)

Page 13: Аэропорты

Airports

  • город не выделен в отдельную таблицу
  • реализация: многоязычное представление над airports_data
  • airport_code
  • код аэропорта
  • airport_name
  • название аэропорта
  • city
  • город
  • coordinates
  • координаты аэропорта (долгота и широта)
  • timezone
  • часовой пояс

Комментарии преподавателя

Аэропорт идентифицируется трехбуквенным кодом (airport_code) и имеет свое имя (airport_name).

Для города не предусмотрено отдельной сущности, но введено поле с названием города (city), позволяющее найти аэропорты одного города. Это представление также включает координаты аэропорта (coordinates) и часовой пояс (timezone).

Значения полей airport_name и city определяются в зависимости от выбранного в конфигурационном параметре bookings.lang языка.

   Столбец    |   Тип   | Модификаторы |                 Описание
--------------+---------+--------------+---------------------------------------
 airport_code | char(3) | not null     | Код аэропорта
 airport_name | text    | not null     | Название аэропорта
 city         | text    | not null     | Город
 coordinates  | point   | not null     | Координаты аэропорта
 timezone     | text    | not null     | Часовой пояс аэропорта
Определение представления:
 SELECT ml.airport_code,
    ml.airport_name ->> lang() AS airport_name,
    ml.city ->> lang() AS city,
    ml.coordinates,
    ml.timezone
   FROM airports_data ml;

Page 14: Аэропорты

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

=> SELECT * FROM airports WHERE airport_code = 'VKO' \gx
-[ RECORD 1 ]+------------------------------
airport_code | VKO
airport_name | Внуково
city         | Москва
coordinates  | (37.2615013123,55.5914993286)
timezone     | Europe/Moscow

Помимо названия и города, хранятся координаты аэропорта и часовой пояс. Теперь мы можем расшифровать сведения о рейсах:

=> SELECT f.scheduled_departure,
  dep.airport_code || ' ' || dep.city || ' (' || dep.airport_name || ')' departure,
  arr.airport_code || ' ' || arr.city || ' (' || arr.airport_name || ')' arrival
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  JOIN airports dep ON dep.airport_code = f.departure_airport
  JOIN airports arr ON arr.airport_code = f.arrival_airport
WHERE t.ticket_no = '0005435126781'
ORDER BY f.scheduled_departure;
  scheduled_departure   |          departure          |           arrival
------------------------+-----------------------------+-----------------------------
 2017-08-12 11:00:00+03 | VKO Москва (Внуково)        | PEE Пермь
(Пермь)
 2017-08-12 15:30:00+03 | PEE Пермь (Пермь)           | SVX
Екатеринбург (Кольцово)
 2017-08-13 11:30:00+03 | SVX Екатеринбург (Кольцово) | SGC
Сургут (Сургут)
 2017-08-15 14:45:00+03 | SGC Сургут (Сургут)         | SVX
Екатеринбург (Кольцово)
 2017-08-16 08:50:00+03 | SVX Екатеринбург (Кольцово) | PEE
Пермь (Пермь)
 2017-08-16 18:55:00+03 | PEE Пермь (Пермь)           | VKO Москва
(Внуково)
(6 rows)

Чтобы не выписывать каждый раз подобный запрос, существует представление flights_v:

=> SELECT * FROM flights_v f WHERE f.flight_id = 22566 \gx
-[ RECORD 1 ]-------------+-----------------------
flight_id                 | 22566
flight_no                 | PG0412
scheduled_departure       | 2017-08-12 11:00:00+03
scheduled_departure_local | 2017-08-12 11:00:00
scheduled_arrival         | 2017-08-12 12:25:00+03
scheduled_arrival_local   | 2017-08-12 14:25:00
scheduled_duration        | 01:25:00
departure_airport         | VKO
departure_airport_name    | Внуково
departure_city            | Москва
arrival_airport           | PEE
arrival_airport_name      | Пермь
arrival_city              | Пермь
status                    | Arrived
aircraft_code             | 773
actual_departure          | 2017-08-12 11:01:00+03
actual_departure_local    | 2017-08-12 11:01:00
actual_arrival            | 2017-08-12 12:25:00+03
actual_arrival_local      | 2017-08-12 14:25:00
actual_duration           | 01:24:00

Здесь видим и местное время в часовых поясах городов отправления и прибытия, длительность полета, названия аэропортов.

Page 15: Page 15

Поскольку в демобазе маршруты не меняются со временем, из таблицы рейсов можно выделить информацию, которая не зависит от конкретной даты вылета. Такая информация собрана в представлении routes:

=> SELECT * FROM routes r WHERE r.flight_no = 'PG0412' \gx
-[ RECORD 1 ]----------+----------------
flight_no              | PG0412
departure_airport      | VKO
departure_airport_name | Внуково
departure_city         | Москва
arrival_airport        | PEE
arrival_airport_name   | Пермь
arrival_city           | Пермь
aircraft_code          | 773
duration               | 01:25:00
days_of_week           | {1,2,3,4,5,6,7}

Видно, что рейсы выполняются ежедневно (массив days_of_week).

Page 16: Самолеты

Aircrafts

  • модели самолетов, выполняющие рейсы
  • реализация: многоязычное представление над aircrafts_data
  • aircraft_code
  • код самолета
  • model
  • модель самолета
  • range
  • максимальная дальность полета, км

Комментарии преподавателя

Каждая модель воздушного судна идентифицируется своим трехзначным кодом (aircraft_code). Указывается также название модели (model) и максимальная дальность полета в километрах (range).

Значение поля model определяется в зависимости от выбранного в конфигурационном параметре bookings.lang языка.

    Столбец    |   Тип   | Модификаторы |             Описание
---------------+---------+--------------+-----------------------------------
 aircraft_code | char(3) | not null     | Код самолета, IATA
 model         | text    | not null     | Модель самолета
 range         | integer | not null     | Максимальная дальность полета, км
Определение представления:
 SELECT ml.aircraft_code,
    ml.model ->> lang() AS model,
    ml.range
   FROM aircrafts_data ml;

Page 17: Самолеты

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

=> SELECT a.*
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
WHERE f.flight_id = 22566;
 aircraft_code |     model     | range
---------------+---------------+-------
 773           | Боинг 777-300 | 11100
(1 row)

Page 18: Места

Seats

  • места определяют схему салона
  • все самолеты одной модели имеют одну и ту же компоновку салона
  • aircraft_code
  • код самолета
  • seat_no
  • номер места
  • fare_conditions
  • класс обслуживания

Комментарии преподавателя

Места определяют схему салона каждой модели. Каждое место определяется своим номером (seat_no) и имеет закрепленный за ним класс обслуживания (fare_conditions) — Economy, Comfort или Business.

     Столбец     |     Тип     | Модификаторы |      Описание
-----------------+-------------+--------------+--------------------
 aircraft_code   | char(3)     | not null     | Код самолета, IATA
 seat_no         | varchar(4)  | not null     | Номер места
 fare_conditions | varchar(10) | not null     | Класс обслуживания
Индексы:
    PRIMARY KEY, btree (aircraft_code, seat_no)
Ограничения-проверки:
    CHECK (fare_conditions IN ('Economy', 'Comfort', 'Business'))
Ограничения внешнего ключа:
    FOREIGN KEY (aircraft_code)
        REFERENCES aircrafts(aircraft_code) ON DELETE CASCADE

Page 19: Места

В демобазе все самолеты одной модели имеют одинаковую конфигурацию салона. Посмотрим на первый ряд:

=> SELECT s.*
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
  JOIN seats s ON s.aircraft_code = a.aircraft_code
WHERE f.flight_id = 22566
AND s.seat_no ~ '^1.$';
 aircraft_code | seat_no | fare_conditions
---------------+---------+-----------------
 773           | 1A      | Business
 773           | 1C      | Business
 773           | 1D      | Business
 773           | 1G      | Business
 773           | 1H      | Business
 773           | 1K      | Business
(6 rows)

Это бизнес-класс. А вот общее число мест различных классов обслуживания:

=> SELECT s.fare_conditions, count(*)
FROM seats s
WHERE s.aircraft_code = '733'
GROUP BY s.fare_conditions;
 fare_conditions | count
-----------------+-------
 Business        |    12
 Economy         |   118
(2 rows)

Page 20: Посадочные талоны

Boarding\_passes

  • посадочный талон выдается при регистрации на рейс
  • ticket_no
  • номер билета
  • flight_id
  • идентификатор рейса
  • boarding_no
  • номер посадочного талона (в порядке регистрации)
  • seat_no
  • номер места

Комментарии преподавателя

При регистрации на рейс, которая возможна за сутки до плановой даты отправления, пассажиру выдается посадочный талон. Он идентифицируется так же, как и перелет — номером билета и номером рейса.

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

   Столбец   |    Тип     | Модификаторы |         Описание
-------------+------------+--------------+--------------------------
 ticket_no   | char(13)   | not null     | Номер билета
 flight_id   | integer    | not null     | Идентификатор рейса
 boarding_no | integer    | not null     | Номер посадочного талона
 seat_no     | varchar(4) | not null     | Номер места
Индексы:
    PRIMARY KEY, btree (ticket_no, flight_id)
    UNIQUE CONSTRAINT, btree (flight_id, boarding_no)
    UNIQUE CONSTRAINT, btree (flight_id, seat_no)
Ограничения внешнего ключа:
    FOREIGN KEY (ticket_no, flight_id)
        REFERENCES ticket_flights(ticket_no, flight_id)

Page 21: Посадочные талоны

На каких местах сидел наш пассажир? Для этого надо заглянуть в посадочный талон, который выдается при регистрации на рейс:

=> SELECT f.status, bp.*
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  LEFT JOIN boarding_passes bp
    ON bp.ticket_no = tf.ticket_no AND bp.flight_id = tf.flight_id
WHERE t.ticket_no = '0005435126781'
ORDER BY f.scheduled_departure;
  status   |   ticket_no   | flight_id | boarding_no | seat_no
-----------+---------------+-----------+-------------+---------
 Arrived   | 0005435126781 |     22566 |           4 | 22A
 Arrived   | 0005435126781 |     95726 |          64 | 19D
 Arrived   | 0005435126781 |     74643 |          42 | 8D
 Departed  | 0005435126781 |    206625 |          11 | 3F
 On Time   |               |           |             |
 Scheduled |               |           |             |
(6 rows)

На два оставшихся рейса пассажир еще не зарегистрировался.

Page 22: Многоязычность

Конфигурационный параметр

  • bookings.lang
  • Таблицы для хранения многоязычных названий
    • airports_data
    • aircrafts_data

Комментарии преподавателя

Язык, на котором выводятся названия городов, аэропортов и моделей самолетов, переключается с помощью конфигурационного параметра bookings.lang. В состав демобазы входят названия на русском (ru) и английском (en) языках.

Можно самостоятельно расширить языковую поддержку, добавив в строки таблиц airports_data и aircrafts_data названия на произвольном языке.

Page 23: Многоязычность

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

=> SELECT * FROM airports a WHERE a.airport_code = 'VKO' \gx
-[ RECORD 1 ]+------------------------------
airport_code | VKO
airport_name | Внуково
city         | Москва
coordinates  | (37.2615013123,55.5914993286)
timezone     | Europe/Moscow

Чтобы сменить язык, достаточно установить конфигурационный параметр:

=> SET bookings.lang = 'en';
SET
=> SELECT * FROM airports a WHERE a.airport_code = 'VKO' \gx
-[ RECORD 1 ]+------------------------------
airport_code | VKO
airport_name | Vnukovo International Airport
city         | Moscow
coordinates  | (37.2615013123,55.5914993286)
timezone     | Europe/Moscow

Реализация использует представление над базовой таблицей, которая содержит переводы в формате JSON:

=> SELECT * FROM airports_data ml WHERE ml.airport_code = 'VKO' \gx
-[ RECORD 1 ]+---------------------------------------------------------
airport_code | VKO
airport_name | {"en": "Vnukovo International Airport", "ru": "Внуково"}
city         | {"en": "Moscow", "ru": "Москва"}
coordinates  | (37.2615013123,55.5914993286)
timezone     | Europe/Moscow

Page 24: Итоги

Схема демобазы достаточно проста, но позволяет писать сложные и интересные запросы

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

Page 25: Практика

Напишите несколько запросов к демонстрационной базе данных. 1. Сколько человек бывает включено в одно бронирование? 2. До каких городов нельзя добраться из Москвы без пересадок? 3. Какая модель самолета выполняет больше всего рейсов, а какая — меньше всего? 4. А какая модель перевезла больше всего пассажиров?

  1. В результате будет два столбца:
  • Количество человек в бронировании
  • Число бронирований

Page 26: 1. Сколько человек в одном бронировании?

Посчитаем количество человек в каждом бронировании, а затем число бронирований для каждого количества.

=> SELECT tt.cnt, count(*)
FROM (
  SELECT count(*) cnt
  FROM tickets t
  GROUP BY t.book_ref
) tt
GROUP BY tt.cnt
ORDER BY tt.cnt;
 cnt |  count
-----+---------
   1 | 1388875
   2 |  613356
   3 |  101440
   4 |    7245
   5 |     194
(5 rows)

2. До каких городов нельзя добраться из Москвы без пересадок?

Найдем города, куда можно добраться, и выведем все остальные.

=> SELECT a.city
FROM airports a
EXCEPT
SELECT arr.city
FROM flights f
  JOIN airports dep ON f.departure_airport = dep.airport_code
  JOIN airports arr ON f.arrival_airport = arr.airport_code
WHERE dep.city = 'Москва';
         city
----------------------
 Калуга
 Когалым
 Якутск
 Новокузнецк
 Сургут
 Иркутск
 Удачный
 Кызыл
 Стрежевой
 Ярославль
 Иваново
 Усть-Кут
 Магадан
 Чита
 Череповец
 Комсомольск-на-Амуре
 Усть-Илимск
 Москва
 Благовещенск
 Ухта
 Нижнекамск
(21 rows)

Интересно, что из Москвы в Москву без пересадок добраться не получится.

3. Какие модели выполняют больше всего и меньше всего рейсов?

Page 27: Page 27

=> SELECT a.model, f.cnt
FROM aircrafts a
  LEFT JOIN (
    SELECT f.aircraft_code, count(*) cnt
    FROM flights f
    GROUP BY f.aircraft_code
  ) f
  ON f.aircraft_code = a.aircraft_code
ORDER BY cnt DESC NULLS LAST;
        model        |  cnt
---------------------+-------
 Сессна 208 Караван  | 60196
 Бомбардье CRJ-200   | 58611
 Сухой Суперджет-100 | 55213
 Аэробус A321-200    | 12672
 Боинг 737-300       |  8263
 Аэробус A319-100    |  8032
 Боинг 767-300       |  7920
 Боинг 777-300       |  3960
 Аэробус A320-200    |
(9 rows)

Больше всех трудится маленькая Сессна, а одна модель авиапарка вообще не используется на рейсах.

4. Какая модель перевезла больше всего пассажиров?

Число пассажиров на рейсе можно посчитать по посадочным талонам.

=> SELECT a.model, count(*) cnt
FROM boarding_passes bp
  JOIN flights f ON f.flight_id = bp.flight_id
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY count(*) DESC;
        model        |   cnt
---------------------+---------
 Сухой Суперджет-100 | 2767457
 Бомбардье CRJ-200   | 1155683
 Боинг 777-300       | 1111547
 Боинг 767-300       |  945568
 Аэробус A321-200    |  777370
 Боинг 737-300       |  649730
 Аэробус A319-100    |  407361
 Сессна 208 Караван  |  111096
(8 rows)

02 Занятие 02: Выполнение запросов / Планирование и выполнение

Исходная страница в нативном Markdown: Выполнение запросов / Планирование и выполнение
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_02_query_query.html Archive: QPT-handouts-16.zip Страниц PDF: 32

Page 1: Выполнение запросов / Планирование и выполнение

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Общие подходы к оптимизации
  • Простой протокол и этапы обработки запросов
  • Расширенный протокол
  • Подробнее о планировании

Page 3: Подходы к оптимизации

Настройка параметров

  • подстройка под имеющуюся нагрузку
  • глобальное влияние на всю систему
  • мониторинг
  • Оптимизация запросов
    • уменьшение нагрузки
    • локальное воздействие (запрос или несколько запросов)
    • профилирование

Комментарии преподавателя

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

Можно выделить два основных подхода. Первый состоит в том, чтобы отслеживать состояние системы и добиваться того, чтобы она справлялась с имеющейся нагрузкой. Для этого можно настраивать параметры СУБД (основные из которых рассматриваются в курсе DBA2 и отчасти в этом курсе), а также настраивать операционную систему. Если настройки не помогают, при таком подходе остается только модернизировать аппаратуру (что тоже помогает не всегда).

Другой подход, который в основном мы и будем рассматривать далее, состоит в том, чтобы не приспосабливаться под нагрузку, а уменьшать ее. «Полезная» нагрузка формируется запросами. Если удается найти узкое место, то можно попробовать тем или иным способом повлиять на выполнение запроса и получить тот же результат, потратив меньше ресурсов. Такой способ действует более локально (на отдельный запрос или ряд запросов), но уменьшение нагрузки благоприятно сказывается и на работе всей системы.

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

Page 4: Этапы обработки запроса

Разбор

  • Переписывание (трансформация)
  • Планирование (оптимизация)
  • Выполнение

Комментарии преподавателя

Сначала рассмотрим, как выполняется запрос в простом случае – например, если в psql написать команду SELECT.

Page 5: Разбор

  • системный
  • каталог
  • дерево
  • запроса
  • разбор
  • (parse)
  • текст запроса

Комментарии преподавателя

Обработка обычного запроса выполняется в несколько этапов.

Во-первых, запрос разбирается (parse).

Сначала производится синтаксический разбор: текст запроса представляется в виде дерева — так с ним удобнее работать.

Затем выполняется семантический анализ (parse analysis), в ходе которого определяется, на какие объекты БД ссылается запрос и есть ли у пользователя доступ к ним (для этого анализатор заглядывает в системный каталог).

Page 6: Синтаксический разбор

Source slide page 6: Синтаксический разбор{#source-figure-006 caption=“Source slide page 6: Синтаксический разбор”}

SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;

Комментарии преподавателя

Рассмотрим простой пример: запрос, приведенный на слайде.

На этапе синтаксического разбора в памяти обслуживающего процесса будет построено дерево, упрощенно показанное на рисунке ниже запроса. Цветом показано примерное соответствие частей текста запроса и узлов дерева.

(RTE — неочевидное сокращение от Range Table Entry. Этим именем внутри PostgreSQL называются таблицы, подзапросы, результаты соединений — иными словами, наборы строк, с которыми может оперировать SQL.)

Если любопытно, то реальное дерево можно увидеть, установив параметр debug_print_parse и заглянув в журнал сообщений сервера. Практического смысла в этом нет (если, конечно, вы не разработчик ядра PostgreSQL).

Page 7: Семантический разбор

Source slide page 7: Семантический разбор{#source-figure-007 caption=“Source slide page 7: Семантический разбор”}

SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;

Комментарии преподавателя

На этапе семантического разбора анализатор сверяется с системным каталогом и связывает имя «pg_tables» с представлением, имеющим определенный идентификатор (oid) в системном каталоге. Также будут проверены права доступа к этому представлению.

Page 8: Переписывание

Source slide page 8: Переписывание{#source-figure-008 caption=“Source slide page 8: Переписывание”}

Комментарии преподавателя

Во-вторых, запрос переписывается, или трансформируется (rewrite) с учетом правил.

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

Page 9: Переписывание

Source slide page 9: Переписывание{#source-figure-009 caption=“Source slide page 9: Переписывание”}

SELECT schemaname, tablename
FROM (
  SELECT ...
  FROM pg_class c
    LEFT JOIN pg_namespace n
      ON n.oid = c.relnamespace
    LEFT JOIN pg_tablespace t
      ON t.oid = c.reltablespace
  WHERE c.relkind = 'r'::"char"
)
WHERE tableowner = 'postgres'
ORDER BY tablename;

Комментарии преподавателя

На слайде приведен запрос с подставленным текстом представления (это условность: реально в таком виде запрос не существует — вся работа по переписыванию происходит только с деревом запроса).

Родительский узел поддерева, соответствующего подзапросу, — тот узел, который ссылается на это представление. На рисунке в этом поддереве хорошо видна древовидная структура запроса.

Дерево после переписывания можно увидеть в журнале сообщений сервера, установив параметр debug_print_rewritten.

Page 10: Планирование

Source slide page 10: Планирование{#source-figure-010 caption=“Source slide page 10: Планирование”}

Комментарии преподавателя

В-третьих, запрос планируется (plan).

SQL — декларативный язык, и один запрос можно выполнить разными способами. Планировщик (он же оптимизатор) перебирает различные способы выполнения и оценивает их. Оценка дается на основе некоторой математической модели исходя из информации об обрабатываемых данных (статистики).

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

Page 11: Планирование

Source slide page 11: Планирование{#source-figure-011 caption=“Source slide page 11: Планирование”}

Sort  (cost=19.59..19.59 rows=1 width=128)
  Sort Key: c.relname
  ->  Nested Loop Left Join  (cost=0.00..19.58 rows=1 width=128)
        Join Filter: (n.oid = c.relnamespace)
        ->  Seq Scan on pg_class c  (cost=0.00..18.44 rows=1 width=72)
              Filter: ((relkind = 'r'::"char") AND
                       (pg_get_userbyid(relowner) = 'postgres'::name))
        ->  Seq Scan on pg_namespace n  (cost=0.00..1.06 rows=6 width=68)

Комментарии преподавателя

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

Здесь операции Seq Scan – это чтение соответствующих таблиц, а Nested Loop – способ соединения двух таблиц. В виде текста на слайде приведен план выполнения в том виде, как его показывает команда EXPLAIN. Подробно о методах доступа к данным, о способах соединения и об EXPLAIN мы будем говорить в следующих темах.

Пока имеет смысл обратить внимание на два момента:

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

● каждый узел дерева снабжен информацией о предполагаемом числе строк (rows) и о стоимости (cost).

Для интересующихся — увидеть «настоящее» дерево плана можно, установив параметр debug_print_plan.

Page 12: Выполнение

Source slide page 12: Выполнение{#source-figure-012 caption=“Source slide page 12: Выполнение”}

Комментарии преподавателя

В-четвертых, запрос выполняется (execute) в соответствии с выбранным планом, и результат возвращается клиенту.

Задав параметрам log_parser_stats, log_planner_stats и log_executor_stats значение on, можно вывести в журнал подробную статистику каждого этапа. Но на практике это обычно не требуется.

https://postgrespro.ru/docs/postgresql/16/query-path

Page 13: Простой протокол

Простой протокол запросов применяется, когда на сервер отправляется оператор и, если это SELECT или команда с фразой RETURNING, мы ожидаем получение всех строк результата. Например:

=> SELECT model FROM aircrafts WHERE aircraft_code = '773';
     model
---------------
 Боинг 777-300
(1 row)

Обычный удобный способ получить план запроса — команда EXPLAIN (пока отключаем вывод стоимости):

=> EXPLAIN (buffers, costs off)
SELECT * FROM airports;
                QUERY PLAN
-------------------------------------------
 Seq Scan on airports_data ml
 Planning:
   Buffers: shared hit=57 read=6 dirtied=1
(3 rows)

Если задан параметр buffers, в поле Planning Buffers будет показано число страниц буферного кеша, которое было прочитано при построении плана. Если добавить параметр analyze, сервер выполнит запрос и отобразит его план с детальной информацией о выполнении. Будьте аккуратны с запросами, изменяющими данные!

=> EXPLAIN (analyze, buffers, costs off)
SELECT * FROM airports;
                                QUERY PLAN
--------------------------------------------------------------------------
 Seq Scan on airports_data ml (actual time=0.074..0.435 rows=104 loops=1)
   Buffers: shared read=3
 Planning Time: 0.065 ms
 Execution Time: 0.463 ms
(4 rows)

В поле Buffers отображается тип кеша (shared — буферный кеш в общей памяти) и количество страниц: hit — найденных в кеше; read — не найденных в кеше и запрошенных у операционной системы; written — записанных в файлы; dirtied — ставших грязными (т.е. впервые измененных в кеше). Теперь создадим временную таблицу и вставим в нее строки таблицы airports:

=> CREATE TEMP TABLE temp_airports (LIKE airports);
CREATE TABLE
=> EXPLAIN (analyze, buffers, costs off)
INSERT INTO temp_airports SELECT * FROM airports;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Insert on temp_airports (actual time=0.622..0.622 rows=0 loops=1)
   Buffers: shared hit=3, local hit=104 dirtied=3 written=5
   ->  Seq Scan on airports_data ml (actual time=0.066..0.322 rows=104 loops=1)
         Buffers: shared hit=3
 Planning Time: 0.256 ms
 Execution Time: 0.641 ms
(6 rows)

Временная таблица кешируется в локальной памяти сеанса (Buffers … local). Предупреждение: вывод времени выполнения каждого шага, как в этом примере, может существенно замедлять выполнение запроса на некоторых платформах. Если такая информация не нужна, лучше указывать timing off.

Page 14: Page 14

Page 15: Выполнение

Source slide page 15: Выполнение{#source-figure-015 caption=“Source slide page 15: Выполнение”}

Конвейер

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

Комментарии преподавателя

На этапе выполнения дерево плана (на слайде оно перерисовано так, чтобы показать только самое основное) работает как конвейер.

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

Некоторые узлы (как NESTLOOP на рисунке) соединяют данные, полученные из разных источников. Здесь узел обращается по очереди к двум дочерним узлам (соединение всегда осуществляется попарно) и, получив от них строки, соединяет их и возвращает наверх (то есть узлу сортировки).

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

Некоторые узлы могут вернуть результат, только получив от дочерних узлов все данные. К таким узлам относится сортировка — нельзя отсортировать неполную выборку. Другие узлы могут возвращать данные по мере поступления. Например, доступ к данным с помощью чтения таблицы может отдавать наверх данные по мере чтения (это позволяет быстро получить первую часть результата — например, для страничного отображения на веб-странице).

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

Page 16: JIT-компиляция

Компиляция части запросов в исходный код

  • вычисление выражений в предложении WHERE
  • вычисления целевого списка выражений (SELECT)
  • агрегаты и проекции
  • Преобразование версий строк
    • перенос версий строк с диска в развернутое представление в памяти

Комментарии преподавателя

Динамическая компиляция JIT (just-in-time, «точно в нужное время») используется для компиляции кода или его фрагментов в момент выполнения программы. Эта технология позволяет ускорить выполнение интерпретируемого кода и используется во многих системах.

В PostgreSQL с помощью JIT-компиляции можно скомпилировать часть кода, который выполняется при работе запросов SQL. Для этого PostgreSQL должен быть собран с поддержкой LLVM.

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

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

https://postgrespro.ru/docs/postgresql/16/jit-reason

Page 17: JIT-компиляция

По умолчанию JIT-компиляция включена, но для демобазы она выключена:

=> SELECT setconfig
FROM pg_db_role_setting
WHERE setdatabase = (SELECT oid FROM pg_database WHERE datname='demo');
                         setconfig
-----------------------------------------------------------
 {"search_path=bookings, public",bookings.lang=ru,jit=off}
(1 row)
=> SELECT name, setting, boot_val
FROM pg_settings
WHERE name='jit';
 name | setting | boot_val
------+---------+----------
 jit  | off     | on
(1 row)

Выполним запрос, рассчитывающий значение числа π. В запросе активно используются вычисления, которые JIT может оптимизировать:

=> SET jit = on;
SET
=> WITH pi AS (
  SELECT random() x, random() y
  FROM generate_series(1,10_000_000)
)
SELECT 4*sum(1-floor(x*x+y*y))/count(*) val FROM pi;
   val
---------
 3.14185
(1 row)

Команда EXPLAIN ANALYZE покажет подробную информацию о том, какие оптимизации JIT сработали:

=> EXPLAIN (analyze, timing off)
WITH pi AS (
  SELECT random() x, random() y
  FROM generate_series(1,10_000_000)
)
SELECT 4*sum(1-floor(x*x+y*y))/count(*) val FROM pi;
                                                       QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------
 Aggregate  (cost=525000.00..525000.02 rows=1 width=8) (actual rows=1 loops=1)
   CTE pi
     ->  Function Scan on generate_series  (cost=0.00..150000.00 rows=10000000 width=16)
(actual rows=10000000 loops=1)
   ->  CTE Scan on pi  (cost=0.00..200000.00 rows=10000000 width=16) (actual
rows=10000000 loops=1)
 Planning Time: 0.622 ms
 JIT:
   Functions: 6
   Options: Inlining true, Optimization true, Expressions true, Deforming true
 Execution Time: 11744.292 ms
(9 rows)

Выключим JIT-компиляцию и повторим запрос:

=> SET jit = off;
SET

Page 18: Page 18

=> EXPLAIN (analyze, timing off)
WITH pi AS (
  SELECT random() x, random() y
  FROM generate_series(1,10_000_000)
)
SELECT 4*sum(1-floor(x*x+y*y))/count(*) val FROM pi;
                                                       QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------
 Aggregate  (cost=525000.00..525000.02 rows=1 width=8) (actual rows=1 loops=1)
   CTE pi
     ->  Function Scan on generate_series  (cost=0.00..150000.00 rows=10000000 width=16)
(actual rows=10000000 loops=1)
   ->  CTE Scan on pi  (cost=0.00..200000.00 rows=10000000 width=16) (actual
rows=10000000 loops=1)
 Planning Time: 0.076 ms
 Execution Time: 9784.838 ms
(6 rows)

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

Page 19: Расширенный протокол

Уточнение схемы обработки запроса

  • Подготовленные операторы
  • Курсоры

Комментарии преподавателя

PostgreSQL также предусматривает расширенный протокол запросов. На практике это означает возможность использования подготовленных операторов и курсоров.

Page 20: Простой протокол

Source slide page 20: Простой протокол{#source-figure-020 caption=“Source slide page 20: Простой протокол”}

Комментарии преподавателя

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

Page 21: Расширенный протокол

Source slide page 21: Расширенный протокол{#source-figure-021 caption=“Source slide page 21: Расширенный протокол”}

курсоры подготовленные операторы

Комментарии преподавателя

Расширенный протокол позволяет более детально управлять обработкой запроса.

Во-первых, запрос может быть подготовлен. Для этого клиент передает запрос серверу (возможно, в параметризованном виде), а сервер выполняет разбор и переписывание и сохраняет подготовленное дерево запроса в локальную память обслуживающего процесса.

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

Подготовка позволяет избежать повторного разбора и переписывания одного и того же запроса, если он выполняется в одном сеансе неоднократно.

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

https://postgrespro.ru/docs/postgresql/16/protocol-flow#PROTOCOL-FLOW-EXT-QUERY

Page 22: Расширенный протокол

Source slide page 22: Расширенный протокол{#source-figure-022 caption=“Source slide page 22: Расширенный протокол”}

привязка параметров курсоры подготовленные операторы

Комментарии преподавателя

Если запрос не имеет параметров, серверу нет смысла перепланировать запрос при каждом выполнении. В этом случае он сразу запоминает общий (generic) план выполнения. Это позволяет еще больше экономить ресурсы.

Если же запрос имеет параметры, то сервер может перейти на общий план, если сочтет, что в среднем он получается не хуже частных планов. Подробнее о том, в каких случаях происходит переключение, говорится в теме «Базовая статистика».

Есть и еще один повод использовать подготовленные операторы: гарантировать защиту от внедрения SQL-кода, если входные данные для запроса получены из ненадежного источника (например, из поля ввода на веб-форме).

Page 23: Подготовленные операторы

Создадим подготовленный оператор для запроса с параметром:

=> PREPARE model(varchar) AS
  SELECT model FROM aircrafts WHERE aircraft_code = $1;
PREPARE

Теперь мы можем вызывать оператор по имени:

=> EXECUTE model('773');
     model
---------------
 Боинг 777-300
(1 row)
=> EXECUTE model('763');
     model
---------------
 Боинг 767-300
(1 row)

Все подготовленные операторы можно увидеть в представлении:

=> SELECT * FROM pg_prepared_statements \gx
-[ RECORD 1 ]---+--------------------------------------------------------
name            | model
statement       | PREPARE model(varchar) AS                              +
                |   SELECT model FROM aircrafts WHERE aircraft_code = $1;
prepare_time    | 2026-04-10 14:34:10.820223+03
parameter_types | {"character varying"}
result_types    | {text}
from_sql        | t
generic_plans   | 0
custom_plans    | 2

name — имя подготовленного оператора, statement — создавшая его команда, prepare_time — момент создания, parameter_types — типы параметров (массив), result_types — типы столбцов результата (массив), from_sql — оператор создан командой SQL PREPARE (а не вызовом функции драйвера), generic_plans — сколько раз использовался общий план, custom_plans — сколько раз строились и использовались частные планы. Если подготовленный оператор больше не нужен, его можно удалить командой DEALLOCATE, но в любом случае оператор пропадет при завершении сеанса.

=> \c
You are now connected to database "demo" as user "postgres".
=> SELECT * FROM pg_prepared_statements \gx
(0 rows)

Команды PREPARE, EXECUTE, DEALLOCATE — команды SQL. Клиенты на других языках программирования будут использовать операции, определенные в соответствующем драйвере. Но любой драйвер использует один и тот же протокол для взаимодействия с сервером. Если в psql выполнить запрос с параметрами, он будет использовать расширенный протокол запросов. В этом случае нужно предварительно задать значения параметров:

=> \bind '773'

Подготовленный оператор будет создан неявно.

=> SELECT model FROM aircrafts WHERE aircraft_code = $1;

Page 24: Page 24

     model
---------------
 Боинг 777-300
(1 row)

После выполнения запроса подготовленный оператор удаляется:

=> SELECT * FROM pg_prepared_statements \gx
(0 rows)

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

=> \bind '763'
=> EXPLAIN SELECT model FROM aircrafts WHERE aircraft_code = $1;
                            QUERY PLAN
------------------------------------------------------------------
 Seq Scan on aircrafts_data ml  (cost=0.00..1.36 rows=1 width=32)
   Filter: (aircraft_code = '763'::bpchar)
(2 rows)

Такой подход можно использовать, если нужно отладить выполнение запроса с расширенным протоколом.

Курсоры

Курсоры дают возможность построчной обработки результата. Они часто используются в приложениях и имеют особенности, связанные с оптимизацией. На SQL использование курсоров можно продемонстрировать следующим образом. Объявляем курсор (при этом он сразу же открывается) и выбираем первую строку:

=> BEGIN;
BEGIN
=> DECLARE c CURSOR FOR SELECT * FROM aircrafts;
DECLARE CURSOR
=> FETCH c;
 aircraft_code |     model     | range
---------------+---------------+-------
 773           | Боинг 777-300 | 11100
(1 row)

Читаем вторую строку результата и закрываем открытый курсор (курсор закроется и автоматически по окончании транзакции):

=> FETCH c;
 aircraft_code |     model     | range
---------------+---------------+-------
 763           | Боинг 767-300 |  7900
(1 row)
=> CLOSE c;
CLOSE CURSOR
=> COMMIT;
COMMIT

Page 25: Подробнее о планировании

Процесс планирования

  • Оценка кардинальности
  • Оценка стоимости
  • Выбор наилучшего плана

Комментарии преподавателя

Планирование запроса — очень важный и достаточно сложный этап, поэтому остановимся на нем подробнее.

Page 26: Процесс планирования

Статистика

  • данные о размере таблиц и распределении данных
  • Оценка кардинальности
    • селективность условий — доля выбираемых строк,
    • кардинальность — итоговое число строк
    • для расчета требуется статистика
  • Оценка стоимости
    • в первую очередь зависит от типа узла и числа обрабатываемых строк
  • Перебор планов оптимизатором
    • выбирается план с наименьшей стоимостью

Комментарии преподавателя

Оптимизатор перебирает всевозможные планы выполнения, оценивает их и выбирает план с наименьшей стоимостью.

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

Для оценки объема данных важны два понятия:

● кардинальность — общее число строк;

● селективность — доля строк, отбираемых условиями (предикатами).

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

Таким образом, в итоге все сводится к статистике — информации, собираемой и обновляемой процессом автоанализа или командой ANALYZE.

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

Page 27: Оценка кардинальности

Source slide page 27: Оценка кардинальности{#source-figure-027 caption=“Source slide page 27: Оценка кардинальности”}

Кардинальность метода доступа

  • rowsA where cond = rowsA · selcond Кардинальность соединения
    • rowsA join B on cond = rowsA · rowsB · selcond

Комментарии преподавателя

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

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

Пока отметим, что достаточно уметь оценивать селективность простых условий, а селективность условий, составленных с помощью логических операций, рассчитываются по простым формулам:

selx and y = selx · sely ; selx or y = 1 – (1 – selx) (1 – sely)

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

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

Аналогично можно поступить и с другими узлами, например, с сортировками или агрегациями.

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

Page 28: Оценка стоимости

Source slide page 28: Оценка стоимости{#source-figure-028 caption=“Source slide page 28: Оценка стоимости”}

Вычисляется на основе математических моделей

  • cost = costA + ∑costдочерние узлы A Две компоненты
    • подготовительная работа .. получение всех строк

Комментарии преподавателя

Теперь рассмотрим общий процесс оценки стоимости. Он также рекурсивен по своей природе. Чтобы рассчитать стоимость поддерева, сначала надо вычислить стоимости дочерних узлов и сложить их, а затем добавить стоимость самого узла.

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

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

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

Другие операции, наоборот, требуют выполнения предварительных действий. Например, сортировка в приведенном примере должна сначала получить от дочернего узла все данные, чтобы начать работу. У таких узлов начальная стоимость будет отлична от нуля — эту стоимость придется «заплатить» независимо от того, сколько строк результата потребуется.

Важно понимать, что стоимость отражает оценку планировщика и может не коррелировать с реальным временем выполнения. Можно считать, что стоимость выражена в неких «условных единицах», которые сами по себе ни о чем не говорят. Стоимость нужна лишь для того, чтобы планировщик мог сравнивать разные планы одного и того же запроса.

Page 29: Выбор лучшего плана

Перебор планов

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

Комментарии преподавателя

Оптимизатор старается перебрать все возможные планы выполнения запроса, чтобы выбрать из них лучший.

Для сокращения перебора используется алгоритм динамического программирования, но при большом количестве вариантов (в первую очередь из-за числа соединяемых таблиц) точное решение задачи оптимизации за разумное время становится невозможным. В таком случае планировщик сокращает количество перебираемых планов, либо рассматривая не все варианты попарных соединений, либо переключаясь на генетический алгоритм оптимизации (GEQO — Genetic Query Optimization). Это может привести к тому, что оптимизатор выберет не лучший план не из-за ошибок в оценке, а просто потому, что лучший план не рассматривался.

https://postgrespro.ru/docs/postgresql/16/geqo

Что же считается «лучшим планом»?

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

Однако при использовании курсоров может быть важно как можно быстрее получить первые строки. Поэтому существует параметр cursor_tuple_fraction (значение по умолчанию 0.1), задающий долю строк, которую надо получить как можно быстрее. Чем меньше значение этого параметра, тем больше на выбор плана влияет начальная стоимость, а не полная.

Page 30: Итоги

Обработка запроса состоит из нескольких шагов: разбор и переписывание, планирование, выполнение

  • Имеются два протокола выполнения запросов
    • простой — непосредственное выполнение и получение результата
    • расширенный — подготовленные операторы и курсоры
  • Время выполнения зависит от качества планирования
  • План строится оптимизатором на основе стоимости

Page 31: Практика

  1. Влияние подготовки на выполнение долгого оператора. Вычислите среднюю стоимость одного перелета; посчитайте среднее время выполнения этого запроса. Подготовьте оператор для этого запроса; снова посчитайте среднее время выполнения. Во сколько раз ускорилось выполнение?
    1. Влияние подготовки на выполнение коротких операторов. Многократно запросите данные о бронировании с номером 0824C5; посчитайте среднее время выполнения. Подготовьте оператор для этого запроса; снова посчитайте среднее время выполнения. Во сколько раз ускорилось выполнение в этом случае?

Комментарии преподавателя

Время выполнения одного и того же запроса может отличаться, причем довольно сильно (особенно время первого выполнения). Чтобы сгладить разницу, время надо усреднить, выполнив запрос несколько раз. Для этого удобно использовать язык PL/pgSQL, учитывая, что:

● динамический запрос, выполняемый командой PL/pgSQL EXECUTE (не путать с командой SQL EXECUTE!), каждый раз проходит все этапы;

● запрос SQL, встроенный в PL/pgSQL-код, выполняется с помощью подготовленных операторов.

Пример синтаксиса команды для обычного оператора:

DO $$
BEGIN
  FOR i IN 1..10 LOOP
    EXECUTE 'SELECT ... FROM ...';
  END LOOP;
END;
$$ LANGUAGE plpgsql;

Для подготовленного оператора (здесь SELECT заменяется на PERFORM, поскольку нас не интересует результат как таковой):

DO $$
BEGIN
  FOR i IN 1..10 LOOP
    PERFORM ... FROM ...;
  END LOOP;
END;
$$ LANGUAGE plpgsql;

Page 32: 1. Долгий запрос

=> \timing on
Timing is on.

Обычный оператор:

=> DO $$
BEGIN
  FOR i IN 1..10 LOOP
    EXECUTE 'SELECT avg(amount) FROM ticket_flights';
  END LOOP;
END;
$$ LANGUAGE plpgsql;
DO
Time: 41000,951 ms (00:41,001)

Подготовленный оператор:

=> DO $$
BEGIN
  FOR i IN 1..10 LOOP
    PERFORM avg(amount) FROM ticket_flights;
  END LOOP;
END;
$$ LANGUAGE plpgsql;
DO
Time: 41989,118 ms (00:41,989)

Время изменилось незначительно — большую часть занимает выполнение запроса.

2. Быстрый запрос

Обычный оператор:

=> DO $$
BEGIN
  FOR i IN 1..100_000 LOOP
    EXECUTE 'SELECT * FROM bookings WHERE book_ref = ''0824C5''';
  END LOOP;
END;
$$ LANGUAGE plpgsql;
DO
Time: 7199,333 ms (00:07,199)

Подготовленный оператор:

=> DO $$
BEGIN
  FOR i IN 1..100_000 LOOP
    PERFORM * FROM bookings WHERE book_ref = '0824C5';
  END LOOP;
END;
$$ LANGUAGE plpgsql;
DO
Time: 1253,532 ms (00:01,254)

Время сократилось существенно — разбор и планирование занимают большую часть общего времени.

03 Занятие 03: Доступ к данным / Методы доступа

Исходная страница в нативном Markdown: Доступ к данным / Методы доступа
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_03_am_am.html Archive: QPT-handouts-16.zip Страниц PDF: 38

Page 1: Доступ к данным / Методы доступа

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Последовательное сканирование (Seq Scan)
  • Сканирование индекса (Index Scan)
  • Сканирование по битовой карте (Bitmap Scan)
  • Сканирование только индекса (Index-Only Scan)
  • Сравнение эффективности методов доступа

Page 3: Seq Scan

Source slide page 3: Seq Scan{#source-figure-003 caption=“Source slide page 3: Seq Scan”}

Последовательное чтение всех страниц

  • страницы читаются в кеш
  • проверяется видимость версий строк
  • данные возвращаются в произвольном порядке
  • время сканирования зависит от физического размера файла

Комментарии преподавателя

В распоряжении оптимизатора имеется несколько способов доступа к данным. Самый простой из них — последовательное сканирование таблицы. Файл (или файлы) основного слоя таблицы читается постранично от начала до конца. Напомним, что чтение происходит через буферный кеш (для временных таблиц – через локальный кеш сеанса).

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

Последовательное сканирование эффективно работает, когда надо прочитать всю таблицу или значительную ее часть (если селективность условия низка).

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

Подробнее про версии строк, снимки данных и очистку ненужных версий строк рассказано в курсе DBA2.

https://postgrespro.ru/docs/postgresql/16/mvcc

https://postgrespro.ru/docs/postgresql/16/sql-vacuum

Page 4: Последовательное сканирование

В плане выполнения запроса последовательное сканирование представлено узлом Seq Scan:

=> EXPLAIN (buffers) SELECT * FROM flights;
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63)
 Planning:
   Buffers: shared hit=84 read=14 dirtied=3
(3 rows)

В скобках приведены важные значения: cost — оценка стоимости; rows — оценка числа строк, возвращаемых операцией; width — оценка среднего размера одной строки в байтах. Стоимость указывается в некоторых условных единицах и состоит из двух компонент. Первое число показывает начальную стоимость вычисления узла. Для последовательного сканирования это ноль — чтобы начать возвращать данные, никакой подготовки не требуется. Второе число показывает полную стоимость получения всех данных. Как считается полная стоимость? Оптимизатор PostgreSQL учитывает ввод-вывод и ресурсы процессора. Составляющая ввода-вывода рассчитывается как произведение числа страниц в таблице и условной стоимости чтения одной страницы:

=> SELECT relpages, current_setting('seq_page_cost'),
  relpages * current_setting('seq_page_cost')::real AS total
FROM pg_class WHERE relname = 'flights';
 relpages | current_setting | total
----------+-----------------+-------
     2624 | 1               |  2624
(1 row)

Составляющая ресурсов процессора складывается из стоимости обработки каждой строки:

=> SELECT reltuples, current_setting('cpu_tuple_cost'),
  reltuples * current_setting('cpu_tuple_cost')::real AS total
FROM pg_class WHERE relname = 'flights';
 reltuples | current_setting |  total
-----------+-----------------+---------
    214867 | 0.01            | 2148.67
(1 row)

Последовательное сканирование и мертвые версии строк

Создадим копию таблицы flights, отключив для нее автоочистку, чтобы мертвые версии строк не удалялись автоматически:

=> CREATE TABLE flights_copy
  WITH (autovacuum_enabled = false)
  AS SELECT * FROM flights;
SELECT 214867

И проверим размер полученной таблицы:

=> SELECT pg_size_pretty(pg_total_relation_size('flights_copy'));
 pg_size_pretty
----------------
 21 MB
(1 row)

Удалим все строки из этой таблицы:

Page 5: Page 5

=> DELETE FROM flights_copy;
DELETE 214867

Мертвые версии строк останутся в файле таблицы до ближайшей очистки, поэтому размер таблицы останется прежним:

=> SELECT pg_size_pretty(pg_total_relation_size('flights_copy'));
 pg_size_pretty
----------------
 21 MB
(1 row)

Получим план запроса с параметрами analyze и buffers — обратите внимание на количество страниц в Buffers и стоимость:

=> EXPLAIN (analyze, buffers, timing off)
SELECT count(*) FROM flights_copy;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
--------
 Aggregate  (cost=3968.80..3968.81 rows=1 width=8) (actual rows=1 loops=1)
   Buffers: shared hit=2624
   ->  Seq Scan on flights_copy  (cost=0.00..3699.84 rows=107584 width=0) (actual rows=0
loops=1)
         Buffers: shared hit=2624
 Planning:
   Buffers: shared hit=3 read=3
 Planning Time: 1.317 ms
 Execution Time: 26.420 ms
(8 rows)

Прочитано более двух тысяч страниц. Очистим таблицу вручную и снова проверим размер таблицы:

=> VACUUM flights_copy;
VACUUM
=> SELECT pg_size_pretty(pg_total_relation_size('flights_copy'));
 pg_size_pretty
----------------
 16 kB
(1 row)

Мертвые версии строк удалены, размер таблицы уменьшился. Снова получим план запроса:

=> EXPLAIN (analyze, buffers, timing off)
SELECT count(*) FROM flights_copy;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Aggregate  (cost=0.00..0.01 rows=1 width=8) (actual rows=1 loops=1)
   ->  Seq Scan on flights_copy  (cost=0.00..0.00 rows=1 width=0) (actual rows=0 loops=1)
 Planning:
   Buffers: shared hit=2
 Planning Time: 0.519 ms
 Execution Time: 0.026 ms
(6 rows)

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

=> DROP TABLE flights_copy;
DROP TABLE

Page 6: Массовое вытеснение

Source slide page 6: Массовое вытеснение{#source-figure-006 caption=“Source slide page 6: Массовое вытеснение”}

Комментарии преподавателя

При последовательном сканировании через буферный кеш проходит большое количество страниц с «одноразовыми» данными, которые могут вытеснять полезные страницы из буферов. Чтобы этого не происходило, используются так называемые буферные кольца. Для последовательного чтения большой таблицы (размер которой превышает четверть буферного кеша) из всего кеша используются только 32 страницы, в пределах которых действует вытеснение. При этом остальные данные в буфере не страдают.

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

Если в процессе чтения таблицы другому процессу потребуется та же таблица, он не начинает читать ее с начала, а подключается к имеющемуся буферному кольцу. После окончания сканирования процесс дочитывает «пропущенное» начало таблицы.

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

Page 7: Массовое вытеснение

Сбросим текущие значения статистики ввода-вывода:

=> SELECT pg_stat_reset_shared('io');
 pg_stat_reset_shared
----------------------
(1 row)

Прочитаем все строки из таблицы, размер которой больше четверти shared_buffers:

=> EXPLAIN (buffers, analyze, costs off)
SELECT * FROM tickets;
                               QUERY PLAN
------------------------------------------------------------------------
 Seq Scan on tickets (actual time=0.600..1751.245 rows=2949857 loops=1)
   Buffers: shared read=49415
 Planning:
   Buffers: shared hit=52 read=6 dirtied=1
 Planning Time: 3.326 ms
 Execution Time: 1915.796 ms
(6 rows)

Прочитано 49415 страниц. Накопленную статистику показывает представление pg_stat_io:

=> SELECT reads, hits, reuses
FROM pg_stat_io
WHERE context = 'bulkread' -- сканирование больших таблиц
AND object = 'relation'
AND backend_type = 'client backend';
 reads | hits | reuses
-------+------+--------
 49415 |    0 |  49383
(1 row)

Всего было прочитано (reads) 49415 страниц, из них (reuses) 49383 страниц были загружены в кеш с вытеснением. Таким образом, для сканирования использовалось буферное кольцо размером 49415 − 49383 = 32 страницы. Снова сбросим статистику и повторно обратимся к той же таблице:

=> SELECT pg_stat_reset_shared('io');
 pg_stat_reset_shared
----------------------
(1 row)
=> EXPLAIN (buffers, analyze, costs off)
SELECT * FROM tickets;
                              QUERY PLAN
-----------------------------------------------------------------------
 Seq Scan on tickets (actual time=0.881..980.262 rows=2949857 loops=1)
   Buffers: shared hit=32 read=49383
 Planning Time: 0.048 ms
 Execution Time: 1163.647 ms
(4 rows)
=> SELECT reads, hits, reuses
FROM pg_stat_io
WHERE context = 'bulkread' -- сканирование больших таблиц
AND object = 'relation'
AND backend_type = 'client backend';

Page 8: Page 8

 reads | hits | reuses
-------+------+--------
 49383 |   32 |  49351
(1 row)

Теперь видим, что 32 страницы уже находились в кеше после прошлого чтения (hits), так что серверу пришлось прочитать оставшиеся 49383 страниц (reads), и при этом из кеша вытеснилось 49351 страниц (reuses). Снова размер кольца составляет 49383 − 49351 = 32 буферов, но в них уже находились другие страницы, а не те, что использовались в первый раз.

Page 9: B-дерево

Индекс

  • вспомогательная структура во внешней памяти
  • сопоставляет ключи и идентификаторы строк таблицы
  • Устройство: дерево поиска
    • сбалансированное
    • сильно ветвистое
    • только сортируемые типы данных (операции «больше», «меньше»)
    • результаты поиска автоматически отсортированы
  • Использование
    • ускорение доступа
    • поддержка ограничений целостности

Комментарии преподавателя

В этой теме мы будем рассматривать только один из доступных в PostgreSQL типов индексов: B-дерево (B-tree). Это наиболее часто применяющийся на практике тип индекса. Некоторые другие типы индексов рассмотрены далее в этом курсе в теме «Типы индексов».

Как и все индексы в PostgreSQL, B-дерево является вторичной структурой — индекс не содержит в себе никакой информации, которую нельзя было бы получить из самой таблицы, однако занимает дополнительное место на диске. Индекс можно удалить и пересоздать. Индексы нужны для ускорения операций, затрагивающих небольшую часть таблицы: выборки малого количества строк, а также поддержки ограничений целостности (первичных и уникальных ключей).

Индексы сопоставляют значения проиндексированных полей (ключи поиска) и идентификаторы строк таблицы. Для этого в индексе B-tree строится упорядоченное дерево ключей, в котором можно быстро найти нужный ключ, а вместе с ним — и ссылки на версии строк. Но проиндексировать можно только данные, допускающие сортировку (должны быть определены операции «больше», «меньше»). Например, числа, строки и даты могут быть проиндексированы, а точки на плоскости — нет (для них существуют другие типы индексов). При индексации текстовых строк нужно учитывать особенности правил сортировки (подробнее см. курс DBA2, тема «Локализация»).

Особенностями B-дерева является его сбалансированность (постоянная глубина) и сильная ветвистость. Хотя размер дерева зависит от проиндексированных столбцов, на практике деревья обычно имеют глубину не больше 4–5.

Page 10: B-дерево

Source slide page 10: B-дерево{#source-figure-010 caption=“Source slide page 10: B-дерево”}

Комментарии преподавателя

В верхней части слайда приведен пример B-дерева. Его страницы состоят из индексных записей, каждая из которых содержит:

● ключ – значения столбцов, по которым построен индекс (такие столбцы называются ключевыми);

● ссылку на другую страницу индекса или ссылки на версии строк.

Внутри страницы ключи всегда упорядочены.

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

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

Верхняя страница дерева, на которую нет ссылок, называется корнем.

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

По умолчанию считается, что неопределенные значения «больше» обычных, и они хранятся в дереве справа. Этот порядок можно изменить при создании индекса с помощью предложений NULLS LAST и NULLS FIRST.

Page 11: Index Scan: одно значение

Source slide page 11: Index Scan: одно значение{#source-figure-011 caption=“Source slide page 11: Index Scan: одно значение”}

Комментарии преподавателя

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

Начинаем с корня дерева. Индексные записи в корневой странице определяют диапазоны значений ключей в нижележащих страницах: «от 1 до 9» и «9 и больше». Нам подходит диапазон «от 1 до 9», что соответствует строке с ключом 1. Заметим, что ключи хранятся упорядоченно, следовательно, поиск внутри страницы выполняется очень эффективно.

Ссылка из найденной записи приводит нас к странице второго уровня. В ней мы находим диапазон «от 3 до 6» (ключ 3) и переходим к странице третьего уровня.

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

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

Прежде чем вернуть найденные версии строк, необходимо проверить их видимость.

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

Page 12: Сканирование индекса

Рассмотрим таблицу бронирований:

=> \d bookings
                        Table "bookings.bookings"
    Column    |           Type           | Collation | Nullable | Default
--------------+--------------------------+-----------+----------+---------
 book_ref     | character(6)             |           | not null |
 book_date    | timestamp with time zone |           | not null |
 total_amount | numeric(10,2)            |           | not null |
Indexes:
    "bookings_pkey" PRIMARY KEY, btree (book_ref)
Referenced by:
    TABLE "tickets" CONSTRAINT "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES
bookings(book_ref)

Столбец book_ref является первичным ключом и для него автоматически был создан индекс bookings_pkey. Проверим план запроса с поиском одного значения:

=> EXPLAIN SELECT * FROM bookings WHERE book_ref = 'CDE08B';
                                  QUERY PLAN
-------------------------------------------------------------------------------
 Index Scan using bookings_pkey on bookings  (cost=0.43..8.45 rows=1 width=21)
   Index Cond: (book_ref = 'CDE08B'::bpchar)
(2 rows)

Выбран метод доступа Index Scan, указано имя использованного индекса. Здесь обращение и к индексу, и к таблице представлено одним узлом плана. Строкой ниже указано условие доступа. Начальная стоимость индексного доступа — оценка ресурсов для спуска к листовому узлу. Она зависит от высоты дерева. При оценке считается, что необходимые страницы окажутся в кеше, и оцениваются только ресурсы процессора: цифра получается небольшой. Полная стоимость добавляет оценку чтения необходимых листовых страниц индекса и табличных страниц. В данном случае, поскольку индекс уникальный, модель рассчитывает на то, что будет прочитана одна индексная страница и одна табличная. Стоимость каждого из чтений оценивается параметром random_page_cost:

=> SELECT current_setting('random_page_cost');
 current_setting
-----------------
 4
(1 row)

Его значение обычно больше, чем seq_page_cost, поскольку произвольный доступ стоит дороже (хотя для SSD-дисков этот параметр следует существенно уменьшить). Итого получаем 8, и еще немного добавляет оценка процессорного времени на обработку строк. В строке Index Cond плана указываются только те условия, по которым происходит обращение к индексу или которые могут быть проверены на уровне индекса. Дополнительные условия, которые можно проверить только по таблице, отображаются в отдельной строке Filter:

=> EXPLAIN
SELECT * FROM bookings
WHERE book_ref = 'CDE08B' AND total_amount > 1000;
                                  QUERY PLAN
-------------------------------------------------------------------------------
 Index Scan using bookings_pkey on bookings  (cost=0.43..8.45 rows=1 width=21)
   Index Cond: (book_ref = 'CDE08B'::bpchar)
   Filter: (total_amount > '1000'::numeric)
(3 rows)

Page 13: Index Scan: диапазон

Source slide page 13: Index Scan: диапазон{#source-figure-013 caption=“Source slide page 13: Index Scan: диапазон”}

Комментарии преподавателя

B-дерево позволяет эффективно искать не только отдельные значения, но и диапазоны значений (по условиям «меньше», «больше», «меньше или равно», «больше или равно», а также «between»).

Вот как это происходит. Сначала мы ищем крайний ключ условия. Например, для условия «от 4 до 9» мы можем выбрать значение 4 или 9, а для условия «меньше 9» надо взять 9. Затем спускаемся до листовой страницы индекса так, как мы рассматривали в предыдущем примере, и получаем первое значение из таблицы.

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

На слайде показан пример поиска значений по условию «x BETWEEN 4 AND 9» или, что тоже самое, «x >= 4 AND x <= 9». Спустившись к значению 4, мы затем перебираем ключи 5, 6, и так далее до 9. Встретив ключ 10, прекращаем поиск.

Нам помогают два свойства: упорядоченность ключей на всех страницах и связанность листовых страниц двунаправленным списком. Результат поиска автоматически получается отсортированным.

Обратите внимание, что к одной и той же табличной странице нам пришлось обращаться несколько раз. Мы прочитали первую страницу таблицы (значение 4), затем последнюю (тоже 4), затем опять первую (5), затем опять последнюю (6) и так далее.

Page 14: Поиск по диапазону

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

=> EXPLAIN (costs off)
SELECT * FROM bookings
WHERE book_ref > '000900' AND book_ref < '000939'
ORDER BY book_ref;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Index Scan using bookings_pkey on bookings
   Index Cond: ((book_ref > '000900'::bpchar) AND (book_ref < '000939'::bpchar))
(2 rows)

Тот же самый индекс может использоваться и для получения строк в обратном порядке:

=> EXPLAIN (analyze, buffers, costs off, timing off, summary off)
SELECT * FROM bookings
WHERE book_ref > '000900' AND book_ref < '000939'
ORDER BY book_ref DESC;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Index Scan Backward using bookings_pkey on bookings (actual rows=5 loops=1)
   Index Cond: ((book_ref > '000900'::bpchar) AND (book_ref < '000939'::bpchar))
   Buffers: shared hit=4 read=1
 Planning:
   Buffers: shared hit=12
(5 rows)

В этом случае мы спускаемся от корня дерева к правому листовому узлу и проходим по списку листовых страниц в обратную сторону. Обратите внимание на количество страниц (Buffers), которое потребовалось прочитать. Сравним поиск по диапазону с повторяющимся поиском отдельных значений. Получим тот же результат с помощью конструкции IN, и посмотрим, сколько страниц потребовалось прочитать в этом случае:

=> EXPLAIN (analyze, buffers, costs off)
SELECT * FROM bookings
WHERE book_ref IN ('000906','000909','000917','000930','000938')
ORDER BY book_ref DESC;
                                          QUERY PLAN
------------------------------------------------------------------------------------------
-----
 Index Scan Backward using bookings_pkey on bookings (actual time=0.038..0.064 rows=5
loops=1)
   Index Cond: (book_ref = ANY ('{000906,000909,000917,000930,000938}'::bpchar[]))
   Buffers: shared hit=24
 Planning Time: 0.084 ms
 Execution Time: 0.075 ms
(5 rows)

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

Page 15: Bitmap Index Scan

Source slide page 15: Bitmap Index Scan{#source-figure-015 caption=“Source slide page 15: Bitmap Index Scan”}

Комментарии преподавателя

Многократный просмотр одних и тех же табличных страниц крайне неэффективен. Даже в лучшем случае, если нужная страница находится в буферном кеше, ее нужно найти и заблокировать (см. курс DBA2: тема «Буферный кеш» модуля «Журналирование»), а в худшем приходится иметь дело со случайными чтениями с диска.

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

Сначала сканируется индекс (Bitmap Index Scan) и в локальной памяти процесса строится битовая карта. Битовая карта состоит из фрагментов. Фрагменты соответствуют табличным страницам, а каждый бит фрагмента соответствует версии строки в этой странице. При построении битовой карты в ней отмечаются те версии строк, которые удовлетворяют условию и должны быть прочитаны.

За счет разделения битовой карты на фрагменты карта, в которой отмечено немного версий, будет занимать мало места.

Page 16: Bitmap Heap Scan

Source slide page 16: Bitmap Heap Scan{#source-figure-016 caption=“Source slide page 16: Bitmap Heap Scan”}

Комментарии преподавателя

Когда индекс просканирован и битовая карта готова, начинается сканирование таблицы (Bitmap Heap Scan). При этом:

● используется собственный механизм предвыборки, при котором асинхронно читаются effective_io_concurrency страниц (по умолчанию значение равно единице);

● на одной странице может проверяться несколько версий строк, но каждая страница просматривается ровно один раз.

Page 17: Сканирование по битовой карте

Будем рассматривать таблицу бронирований bookings. Создадим на ней два дополнительных индекса:

=> CREATE INDEX ON bookings(book_date);
CREATE INDEX
=> CREATE INDEX ON bookings(total_amount);
CREATE INDEX

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

=> EXPLAIN
SELECT * FROM bookings WHERE total_amount < 5000;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Bitmap Heap Scan on bookings  (cost=80.62..8941.11 rows=4153 width=21)
   Recheck Cond: (total_amount < '5000'::numeric)
   ->  Bitmap Index Scan on bookings_total_amount_idx  (cost=0.00..79.58 rows=4153
width=0)
         Index Cond: (total_amount < '5000'::numeric)
(4 rows)

Чтобы упорядочить сканирование большого числа страниц, планировщик выбрал сканирование по битовой карте. Этот метод состоит из двух узлов: Bitmap Index Scan читает индекс и строит битовую карту; Bitmap Heap Scan читает табличные страницы, используя построенную карту. Обратите внимание на стоимости: карта должна быть построена полностью, прежде чем ее можно будет использовать.

Объединение битовых карт

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

=> EXPLAIN (costs off)
SELECT * FROM bookings
WHERE total_amount < 5000 OR total_amount > 500000;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Bitmap Heap Scan on bookings
   Recheck Cond: ((total_amount < '5000'::numeric) OR (total_amount > '500000'::numeric))
   ->  BitmapOr
         ->  Bitmap Index Scan on bookings_total_amount_idx
               Index Cond: (total_amount < '5000'::numeric)
         ->  Bitmap Index Scan on bookings_total_amount_idx
               Index Cond: (total_amount > '500000'::numeric)
(7 rows)

Здесь сначала были построены две битовые карты — по одной на каждое условие, а затем объединены побитовой операцией «или». Таким же образом могут быть использованы и разные индексы:

=> EXPLAIN (costs off)
SELECT * FROM bookings
WHERE total_amount < 5000
   OR book_date = bookings.now() - INTERVAL '1 day';

Page 18: Page 18

                                                                  QUERY PLAN
------------------------------------------------------------------------------------------
----------------------------------------------------
 Bitmap Heap Scan on bookings
   Recheck Cond: ((total_amount < '5000'::numeric) OR (book_date = ('2017-08-15
18:00:00+03'::timestamp with time zone - '1 day'::interval)))
   ->  BitmapOr
         ->  Bitmap Index Scan on bookings_total_amount_idx
               Index Cond: (total_amount < '5000'::numeric)
         ->  Bitmap Index Scan on bookings_book_date_idx
               Index Cond: (book_date = ('2017-08-15 18:00:00+03'::timestamp with time
zone - '1 day'::interval))
(7 rows)

Page 19: Неточные фрагменты

Битовая карта без потери точности

  • пока размер карты не превышает work_mem,
  • информация хранится с точностью до версии строки
  • Битовая карта с потерей точности
    • если память закончилась, происходит огрубление
    • части уже построенной карты до отдельных страниц
    • требуется примерно 1 МБ памяти на 64 ГБ данных;
    • ограничение work_mem может быть превышено

Комментарии преподавателя

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

Если карта перестает помещаться в work_mem, часть ее фрагментов «огрубляется» — каждый бит начинает соответствовать целой странице, а не отдельной версии строки (lossy bitmap). Стоимость обработки таких фрагментов увеличивается. Освободившееся место используется для того, чтобы продолжить строить карту.

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

Page 20: Неточные фрагменты

Source slide page 20: Неточные фрагменты{#source-figure-020 caption=“Source slide page 20: Неточные фрагменты”}

Комментарии преподавателя

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

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

Page 21: Неточные фрагменты

Узел Bitmap Heap Scan показывает условие перепроверки (Recheck Cond). Сама же перепроверка выполняется не всегда, а только при потере точности, когда битовая карта не помещается в память. Повторим запрос, изменив условие: будем искать бронирования с суммой до 5 тысяч ₽, сделанные за последний месяц.

=> SELECT bookings.now() - INTERVAL '1 months';
        ?column?
------------------------
 2017-07-15 18:00:00+03
(1 row)
=> \bind '2017-07-15 18:00:00+03'
=> EXPLAIN (analyze, costs off, timing off)
SELECT count(*) FROM bookings
WHERE total_amount < 5000 AND book_date > $1;
                                                          QUERY PLAN
------------------------------------------------------------------------------------------
-------------------------------------
 Aggregate (actual rows=1 loops=1)
   ->  Bitmap Heap Scan on bookings (actual rows=129 loops=1)
         Recheck Cond: ((total_amount < '5000'::numeric) AND (book_date > '2017-07-15
18:00:00+03'::timestamp with time zone))
         Heap Blocks: exact=129
         ->  BitmapAnd (actual rows=0 loops=1)
               ->  Bitmap Index Scan on bookings_total_amount_idx (actual rows=1471
loops=1)
                     Index Cond: (total_amount < '5000'::numeric)
               ->  Bitmap Index Scan on bookings_book_date_idx (actual rows=178142
loops=1)
                     Index Cond: (book_date > '2017-07-15 18:00:00+03'::timestamp with
time zone)
 Planning Time: 0.150 ms
 Execution Time: 15.700 ms
(11 rows)

Строка «Heap Blocks: exact» говорит о том, что все фрагменты битовой карты построены с точностью до строк — перепроверка не выполняется. Уменьшим размер выделяемой памяти.

=> SET work_mem = '64kB';
SET
=> \bind '2017-07-15 18:00:00+03'
=> EXPLAIN (analyze, costs off, timing off)
SELECT count(*) FROM bookings
WHERE total_amount < 5000 AND book_date > $1;

Page 22: Page 22

                                                          QUERY PLAN
------------------------------------------------------------------------------------------
-------------------------------------
 Aggregate (actual rows=1 loops=1)
   ->  Bitmap Heap Scan on bookings (actual rows=129 loops=1)
         Recheck Cond: ((total_amount < '5000'::numeric) AND (book_date > '2017-07-15
18:00:00+03'::timestamp with time zone))
         Rows Removed by Index Recheck: 89895
         Heap Blocks: exact=811 lossy=568
         ->  BitmapAnd (actual rows=0 loops=1)
               ->  Bitmap Index Scan on bookings_total_amount_idx (actual rows=1471
loops=1)
                     Index Cond: (total_amount < '5000'::numeric)
               ->  Bitmap Index Scan on bookings_book_date_idx (actual rows=178142
loops=1)
                     Index Cond: (book_date > '2017-07-15 18:00:00+03'::timestamp with
time zone)
 Planning Time: 0.150 ms
 Execution Time: 46.078 ms
(12 rows)

Здесь появились lossy-фрагменты битовой карты — с точностью до страниц. Также указано, сколько строк не прошло перепроверку условия (Rows Removed by Index Recheck). Восстановим значение параметра.

=> RESET work_mem;
RESET

Page 23: Index Scan / Bitmap Scan

Source slide page 23: Index Scan / Bitmap Scan{#source-figure-023 caption=“Source slide page 23: Index Scan / Bitmap Scan”}

Комментарии преподавателя

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

Разумеется, планировщик это тоже учитывает (как именно — рассматривается в теме «Базовая статистика»).

Page 24: Кластеризация

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

=> SELECT * FROM bookings LIMIT 10;
 book_ref |       book_date        | total_amount
----------+------------------------+--------------
 000004   | 2016-08-13 15:40:00+03 |     55800.00
 00000F   | 2017-07-05 03:12:00+03 |    265700.00
 000010   | 2017-01-08 19:45:00+03 |     50900.00
 000012   | 2017-07-14 09:02:00+03 |     37900.00
 000026   | 2016-08-30 11:08:00+03 |     95600.00
 00002D   | 2017-05-20 18:45:00+03 |    114700.00
 000034   | 2016-08-08 05:46:00+03 |     49100.00
 00003F   | 2016-12-12 15:02:00+03 |    109800.00
 000048   | 2016-09-17 01:57:00+03 |     92400.00
 00004A   | 2016-10-13 21:57:00+03 |     29000.00
(10 rows)

Переупорядочим строки в соответствии с индексом по столбцу total_amount. Пока идет процесс, обратите внимание: Команда CLUSTER устанавливает исключительную блокировку, поскольку полностью перестраивает таблицу (как VACUUM FULL); Строки упорядочиваются, но не поддерживаются в упорядоченном виде — в процессе работы кластеризация будет ухудшаться.

=> CLUSTER bookings USING bookings_total_amount_idx;
CLUSTER
=> VACUUM ANALYZE bookings;
VACUUM

Убедимся, что строки упорядочены по стоимости:

=> SELECT * FROM bookings LIMIT 10;
 book_ref |       book_date        | total_amount
----------+------------------------+--------------
 00F39E   | 2017-02-12 04:11:00+03 |      3400.00
 0103E1   | 2017-04-03 09:32:00+03 |      3400.00
 013695   | 2016-11-01 09:30:00+03 |      3400.00
 0158C0   | 2017-04-24 07:47:00+03 |      3400.00
 01AD57   | 2017-03-15 16:11:00+03 |      3400.00
 020E97   | 2017-01-02 12:25:00+03 |      3400.00
 021FD3   | 2017-05-27 10:20:00+03 |      3400.00
 0278C7   | 2017-02-04 17:42:00+03 |      3400.00
 029452   | 2016-12-10 13:51:00+03 |      3400.00
 0355DD   | 2016-09-19 13:22:00+03 |      3400.00
(10 rows)
=> EXPLAIN (costs off)
SELECT * FROM bookings
WHERE total_amount < 5000;
                       QUERY PLAN
--------------------------------------------------------
 Index Scan using bookings_total_amount_idx on bookings
   Index Cond: (total_amount < '5000'::numeric)
(2 rows)

До кластеризации использовалось сканирование по битовой карте, но теперь проще и выгодней сделать обычное индексное сканирование.

Page 25: Index-Only Scan

Source slide page 25: Index-Only Scan{#source-figure-025 caption=“Source slide page 25: Index-Only Scan”}

Комментарии преподавателя

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

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

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

Это одна из причин, по которым стоит выполнять очистку достаточно часто — именно этот процесс обновляет карту видимости.

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

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

https://postgrespro.ru/docs/postgresql/16/indexes-index-only-scans

Page 26: Сканирование только индекса

Если вся необходимая информация содержится в самом индексе, то нет необходимости обращаться к таблице — за исключением проверки видимости:

=> EXPLAIN (costs off)
SELECT total_amount FROM bookings
WHERE total_amount > 200000;
                         QUERY PLAN
-------------------------------------------------------------
 Index Only Scan using bookings_total_amount_idx on bookings
   Index Cond: (total_amount > '200000'::numeric)
(2 rows)

Посмотрим план этого запроса с помощью EXPLAIN ANALYZE:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT total_amount FROM bookings
WHERE total_amount > 200000;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Index Only Scan using bookings_total_amount_idx on bookings (actual rows=141535 loops=1)
   Index Cond: (total_amount > '200000'::numeric)
   Heap Fetches: 0
(3 rows)

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

=> UPDATE bookings
SET total_amount = total_amount
WHERE book_ref = '00F39E';
UPDATE 1

Сколько версий строк придется проверить теперь?

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT total_amount FROM bookings
WHERE total_amount > 200000;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Index Only Scan using bookings_total_amount_idx on bookings (actual rows=141535 loops=1)
   Index Cond: (total_amount > '200000'::numeric)
   Heap Fetches: 88
(3 rows)

Проверять пришлось все версии, попавшие на измененную страницу.

Page 27: Многоколоночный индекс

Source slide page 27: Многоколоночный индекс{#source-figure-027 caption=“Source slide page 27: Многоколоночный индекс”}

Комментарии преподавателя

Индекс можно создать по нескольким столбцам. В этом случае имеет значение порядок следования столбцов и порядок сортировки.

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

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

Page 28: Многоколоночный индекс

Source slide page 28: Многоколоночный индекс{#source-figure-028 caption=“Source slide page 28: Многоколоночный индекс”}

Комментарии преподавателя

Однако если запрос содержит условие только на второй столбец, индекс оказывается бесполезным. На слайде видно, что листовые записи, в которых второй столбец равен «A», могут оказаться в любом месте индекса — у нас нет способа спуститься к ним от корня дерева.

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

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

Page 29: Многоколоночные индексы

На таблице перелетов ticket_flights создан индекс по столбцам ticket_no, flight_id. Запрос по обоим столбцам использует индекс:

=> EXPLAIN SELECT *
FROM ticket_flights
WHERE ticket_no = '0005432000284' AND flight_id = 187662;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
-
 Index Scan using ticket_flights_pkey on ticket_flights  (cost=0.56..8.58 rows=1 width=32)
   Index Cond: ((ticket_no = '0005432000284'::bpchar) AND (flight_id = 187662))
(2 rows)

Запрос только по номеру билета — тоже:

=> EXPLAIN SELECT *
FROM ticket_flights
WHERE ticket_no = '0005432000284';
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Index Scan using ticket_flights_pkey on ticket_flights  (cost=0.56..16.58 rows=3
width=32)
   Index Cond: (ticket_no = '0005432000284'::bpchar)
(2 rows)

Но для запроса по номеру рейса такой индекс не годится:

=> EXPLAIN SELECT *
FROM ticket_flights
WHERE flight_id = 187662;
                                     QUERY PLAN
------------------------------------------------------------------------------------
 Gather  (cost=1000.00..114651.43 rows=103 width=32)
   Workers Planned: 2
   ->  Parallel Seq Scan on ticket_flights  (cost=0.00..113641.12 rows=43 width=32)
         Filter: (flight_id = 187662)
(4 rows)

Page 30: Include-индексы

Индекс с дополнительными неключевыми столбцами

CREATE INDEX ... INCLUDE (...)
  • Неключевые столбцы
    • не используются при поиске по индексу
    • не учитываются ограничением уникальности
    • значения хранятся в индексной записи
    • и возвращаются без обращения к таблице

Комментарии преподавателя

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

● добавление столбца в уникальный индекс нарушит гарантию уникальности исходных столбцов;

● тип данных добавляемого столбца может не поддерживаться индексом.

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

https://postgrespro.ru/docs/postgresql/16/sql-createindex

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

В настоящее время include-индексы поддерживаются только для индексов на основе B-деревьев, GiST и SP-GiST-индексов.

Include-индексы создаются, чтобы индекс стал покрывающим, но не стоит путать эти два термина. Индекс вполне может быть покрывающим для некоторого запроса, но не использовать предложение INCLUDE. А include-индекс может не быть покрывающим для каких-то запросов.

Page 31: Include-индексы

Индекс tickets_pkey не является покрывающим для приведенного запроса, поскольку требуется вернуть не только столбец ticket_no, который есть в индексе, но и book_ref, которого в индексе нет:

=> EXPLAIN (analyze, buffers, costs off, summary off)
SELECT ticket_no, book_ref FROM tickets
WHERE ticket_no > '0005435990286';
                                       QUERY PLAN
----------------------------------------------------------------------------------------
 Index Scan using tickets_pkey on tickets (actual time=1.714..13.165 rows=7146 loops=1)
   Index Cond: (ticket_no > '0005435990286'::bpchar)
   Buffers: shared hit=74 read=153
 Planning:
   Buffers: shared read=4
(5 rows)

Buffers показывает количество прочитанных страниц (hit+read). Создадим include-индекс, добавив в него неключевой столбец book_ref, так как он требуется запросу:

=> CREATE UNIQUE INDEX ON tickets (ticket_no) INCLUDE (book_ref);
CREATE INDEX

Повторим запрос:

=> EXPLAIN (analyze, buffers, costs off, summary off)
SELECT ticket_no, book_ref FROM tickets
WHERE ticket_no > '0005435990286';
                                                  QUERY PLAN
------------------------------------------------------------------------------------------
--------------------
 Index Only Scan using tickets_ticket_no_book_ref_idx on tickets (actual
time=0.068..6.469 rows=7146 loops=1)
   Index Cond: (ticket_no > '0005435990286'::bpchar)
   Heap Fetches: 0
   Buffers: shared hit=4 read=35
 Planning:
   Buffers: shared hit=20 read=4
(6 rows)

Теперь оптимизатор выбирает метод Index Only Scan и использует только что созданный индекс. Количество прочитанных страниц сократилось. Поскольку карта видимости актуальна, обращаться к таблице не пришлось (Heap Fetches: 0). В include-индекс можно включать столбцы с типами данных, которые не поддерживаются B-деревом (например, геометрические типы и xml).

Page 32: Сравнение эффективности

Source slide page 32: Сравнение эффективности{#source-figure-032 caption=“Source slide page 32: Сравнение эффективности”}

Комментарии преподавателя

Индексное сканирование лучше всего работает при очень высокой селективности, когда по индексу выбирается одно или несколько значений.

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

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

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

Еще одно замечание: при индексном доступе результат возвращается отсортированным, что в ряде случаев увеличивает привлекательность такого доступа даже при низкой селективности.

Page 33: Сравнение эффективности

Source slide page 33: Сравнение эффективности{#source-figure-033 caption=“Source slide page 33: Сравнение эффективности”}

Комментарии преподавателя

Эффективность сканирования только индекса сильно зависит от актуальности карты видимости и от того, сколько страниц действительно содержат только актуальные версии строк.

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

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

Поэтому планировщику приходится учитывать состояние карты видимости: при неблагоприятном прогнозе исключительно индексное сканирование не применяется из-за опасности получить замедление вместо ускорения.

Page 34: Итоги

Оптимизатор располагает несколькими методами доступа

  • последовательное сканирование
  • сканирование индекса
  • сканирование только индекса
  • сканирование по битовой карте
  • Модель стоимости учитывает множество параметров

Page 35: Практика

  1. Убедитесь, что при повторном выполнении запрос, выбирающий все строки из таблицы flights, читает данные из кеша СУБД. 2. В демонстрации был создан include-индекс для таблицы билетов tickets. Замените им индекс, поддерживающий первичный ключ. 3. Создайте индекс по столбцу amount таблицы перелетов ticket_flights. Найдите перелеты стоимостью более 120000 руб. (примерно 1% строк). Какой метод доступа был выбран? 4. Повторите п. 3 для стоимости менее 42000 руб. (около 90% строк).

Комментарии преподавателя

  1. Используйте команду EXPLAIN с параметрами analyze и buffers.

  2. Попробуйте также запретить выбранный метод доступа (параметр enable_seqscan) и сравнить скорости выполнения.

Page 36: 1. Запрос всех строк таблицы flights

=> EXPLAIN (analyze, buffers)
SELECT * FROM flights;
                                                   QUERY PLAN
------------------------------------------------------------------------------------------
----------------------
 Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63) (actual
time=0.016..55.436 rows=214867 loops=1)
   Buffers: shared read=2624
 Planning:
   Buffers: shared hit=84 read=14 dirtied=3
 Planning Time: 2.680 ms
 Execution Time: 70.409 ms
(6 rows)

Все страницы прочитаны с диска в общую память (Buffers: shared read=2624). Возможно, часть страниц была получена из кеша операционной системы, но PostgreSQL об этом ничего не знает. Повторим запрос:

=> EXPLAIN (analyze, buffers)
SELECT * FROM flights;
                                                   QUERY PLAN
------------------------------------------------------------------------------------------
----------------------
 Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63) (actual
time=0.006..20.589 rows=214867 loops=1)
   Buffers: shared hit=2624
 Planning Time: 0.052 ms
 Execution Time: 33.684 ms
(4 rows)

Так как мы только что обращались к таблице, в буферном кеше сохранились ее страницы. Поэтому сервер читает данные из буферного кеша общей памяти (Buffers: shared hit=2624).

2. Include-индекс для первичного ключа

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

=> BEGIN;
BEGIN

В демонстрации был создан такой include-индекс:

=> CREATE UNIQUE INDEX tickets_ticket_no_book_ref_idx
ON tickets (ticket_no) INCLUDE (book_ref);
CREATE INDEX

Теперь индекс tickets_pkey является избыточным и может быть заменен на новый. Для этого удалим старое ограничение целостности (при этом удалится и старый индекс) и добавим новое ограничение, указав имя уже созданного нового индекса. При этом надо учесть наличие внешнего ключа на таблице ticket_flights, которое тоже придется создать заново:

=> ALTER TABLE tickets DROP CONSTRAINT tickets_pkey CASCADE;
NOTICE:  drop cascades to constraint ticket_flights_ticket_no_fkey on table ticket_flights
ALTER TABLE
=> ALTER TABLE tickets ADD CONSTRAINT tickets_pkey PRIMARY KEY USING INDEX tickets_ticket_no_book_ref_idx;
NOTICE:  ALTER TABLE / ADD CONSTRAINT USING INDEX will rename index
"tickets_ticket_no_book_ref_idx" to "tickets_pkey"
ALTER TABLE
=> ALTER TABLE ticket_flights
ADD FOREIGN KEY (ticket_no) REFERENCES tickets(ticket_no);
ALTER TABLE
=> \d tickets

Page 37: Page 37

                        Table "bookings.tickets"
     Column     |         Type          | Collation | Nullable | Default
----------------+-----------------------+-----------+----------+---------
 ticket_no      | character(13)         |           | not null |
 book_ref       | character(6)          |           | not null |
 passenger_id   | character varying(20) |           | not null |
 passenger_name | text                  |           | not null |
 contact_data   | jsonb                 |           |          |
Indexes:
    "tickets_pkey" PRIMARY KEY, btree (ticket_no) INCLUDE (book_ref)
Foreign-key constraints:
    "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
Referenced by:
    TABLE "ticket_flights" CONSTRAINT "ticket_flights_ticket_no_fkey" FOREIGN KEY
(ticket_no) REFERENCES tickets(ticket_no)

Вернемся к исходному состоянию:

=> ROLLBACK;
ROLLBACK

3. Выборка 1% строк

=> CREATE INDEX ON ticket_flights(amount);
CREATE INDEX
=> EXPLAIN (analyze)
SELECT * FROM ticket_flights WHERE amount > 120000;
                                                                  QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------------------------------
 Bitmap Heap Scan on ticket_flights  (cost=1671.24..76447.67 rows=89136 width=32) (actual
time=19.706..750.306 rows=83988 loops=1)
   Recheck Cond: (amount > '120000'::numeric)
   Heap Blocks: exact=3394
   ->  Bitmap Index Scan on ticket_flights_amount_idx  (cost=0.00..1648.95 rows=89136
width=0) (actual time=17.424..17.424 rows=83988 loops=1)
         Index Cond: (amount > '120000'::numeric)
 Planning Time: 1.943 ms
 Execution Time: 755.810 ms
(7 rows)

Выбрано сканирование по битовой карте, она уместилась в оперативную память без потери точности. Для небольшой выборки сканирование по битовой карте эффективнее, чем последовательное.

4. Выборка 90% строк

=> EXPLAIN (analyze)
SELECT * FROM ticket_flights WHERE amount < 42000;
                                                          QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------
 Seq Scan on ticket_flights  (cost=0.00..174831.15 rows=7534013 width=32) (actual
time=18.224..3829.011 rows=7540479 loops=1)
   Filter: (amount < '42000'::numeric)
   Rows Removed by Filter: 851373
 Planning Time: 0.084 ms
 Execution Time: 4248.969 ms
(5 rows)

Запретим последовательное сканирование.

=> SET enable_seqscan = off;
SET
=> EXPLAIN (analyze)
SELECT * FROM ticket_flights WHERE amount < 42000;

Page 38: Page 38

                                                                       QUERY PLAN
------------------------------------------------------------------------------------------
---------------------------------------------------------------
 Bitmap Heap Scan on ticket_flights  (cost=141145.04..310951.79 rows=7534013 width=32)
(actual time=1817.807..18162.448 rows=7540479 loops=1)
   Recheck Cond: (amount < '42000'::numeric)
   Rows Removed by Index Recheck: 298921
   Heap Blocks: exact=36281 lossy=33034
   ->  Bitmap Index Scan on ticket_flights_amount_idx  (cost=0.00..139261.53 rows=7534013
width=0) (actual time=1800.373..1800.373 rows=7540479 loops=1)
         Index Cond: (amount < '42000'::numeric)
 Planning Time: 0.093 ms
 Execution Time: 18524.686 ms
(8 rows)

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

=> RESET ALL;
RESET
=> DROP INDEX ticket_flights_amount_idx;
DROP INDEX

04 Занятие 04: Доступ к данным / Параллельный доступ

Исходная страница в нативном Markdown: Доступ к данным / Параллельный доступ
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_04_am_parallels.html Archive: QPT-handouts-16.zip Страниц PDF: 28

Page 1: Доступ к данным / Параллельный доступ

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Параллельные планы
  • Размер пула процессов
  • Параллельное последовательное сканирование
  • Параллельный индексный доступ

Page 3: Параллельные планы

Source slide page 3: Параллельные планы{#source-figure-003 caption=“Source slide page 3: Параллельные планы”}

Ведущий процесс

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

Комментарии преподавателя

PostgreSQL поддерживает параллельное выполнение запросов. Идея состоит в том, что ведущий процесс, выполняющий запрос, порождает (с помощью postmaster, конечно) несколько рабочих процессов, которые одновременно выполняют одну и ту же «параллельную» часть плана. Результаты этого выполнения передаются ведущему процессу, который собирает их в узле Gather.

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

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

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

https://postgrespro.ru/docs/postgresql/16/parallel-query

Заметим, что в PostgreSQL нет другого теоретически возможного режима распараллеливания, при котором несколько процессов составляют конвейер для обработки данных (грубо говоря, отдельные узлы плана выполняются отдельными процессами). Разработчики PostgreSQL сочли такой режим неэффективным.

Page 4: Размер пула процессов

Source slide page 4: Размер пула процессов{#source-figure-004 caption=“Source slide page 4: Размер пула процессов”}

Комментарии преподавателя

Параллельным выполнением управляет довольно много параметров.

Сначала рассмотрим те, что ограничивают число рабочих процессов.

Вообще механизм рабочих процессов используется не только для параллельного выполнения запросов. Например, рабочие процессы задействованы в логической репликации, ими могут пользоваться расширения. Рабочие процессы можно использовать в прикладном коде (подробнее об этом — в теме «Фоновые процессы» курса DEV2). Общее число одновременно выполняющихся рабочих процессов ограничено параметром max_worker_processes (по умолчанию 8).

Число одновременно выполняющихся рабочих процессов, занимающихся параллельными планами, ограничено параметром max_parallel_workers (по умолчанию тоже 8).

Число одновременно выполняющихся рабочих процессов, обслуживающих один ведущий процесс, ограничено параметром max_parallel_workers_per_gather (по умолчанию 2).

Значения этих параметров следует изменить в зависимости от возможностей аппаратуры, объема данных и загруженности системы. Например, даже если в базе данных есть большие таблицы и запросы могли бы выиграть от распараллеливания, но при этом в системе нет свободных ядер, то параллельное выполнение не будет иметь смысла.

Page 5: Parallel Seq Scan

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

  • агрегация
  • параллельная агрегация
  • Число рабочих процессов
  • Ограничения

Page 6: Parallel Seq Scan

Source slide page 6: Parallel Seq Scan{#source-figure-006 caption=“Source slide page 6: Parallel Seq Scan”}

страницы читаются последовательно, но разными процессами

Комментарии преподавателя

Примером узла, выполняющегося в параллельном режиме является Parallel Seq Scan — «параллельное последовательное сканирование».

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

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

Page 7: Агрегация

Рассмотрим запрос с агрегатной функцией на небольшой таблице. Такой запрос выполняется последовательно:

=> EXPLAIN
SELECT count(*) FROM seats;
                          QUERY PLAN
---------------------------------------------------------------
 Aggregate  (cost=24.74..24.75 rows=1 width=8)
   ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=0)
(2 rows)

План состоит из двух узлов. Верхний узел Aggregate, в котором происходит вычисление count, получает данные от дочернего узла Seq Scan. Обратите внимание на узел Aggregate: его начальная стоимость практически равна полной. Это означает, что узел не может выдать результат (состоящий из одной строки), пока не обработает все данные — что вполне логично. Разница между оценкой для Aggregate и верхней оценкой для Seq Scan — стоимость работы собственно узла Aggregate. Она рассчитывается исходя из того, что надо будет посчитать каждую строку, выданную узлом Seq Scan; одна элементарная операция оценивается значением параметра cpu_operator_cost:

=> SELECT reltuples, current_setting('cpu_operator_cost'),
  reltuples * current_setting('cpu_operator_cost')::real AS total
FROM pg_class WHERE relname = 'seats';
 reltuples | current_setting |   total
-----------+-----------------+-----------
      1339 | 0.0025          | 3.3474998
(1 row)

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

Теперь рассмотрим такой же пример, но с большой таблицей. Здесь в плане запроса увидим параллельное последовательное сканирование:

=> EXPLAIN
SELECT count(*) FROM bookings;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Finalize Aggregate  (cost=25442.58..25442.59 rows=1 width=8)
   ->  Gather  (cost=25442.36..25442.57 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=24442.36..24442.37 rows=1 width=8)
               ->  Parallel Seq Scan on bookings  (cost=0.00..22243.29 rows=879629
width=0)
(5 rows)

Все, что находится ниже узла Gather — параллельная часть плана. Она выполняется в каждом из рабочих процессов (которых запланировано два) и, возможно, в ведущем процессе. Узел Gather и все узлы выше выполняются только в ведущем процессе. Это последовательная часть плана. Начнем разбираться снизу вверх. Узел Parallel Seq Scan представляет сканирование таблицы в параллельном режиме. В поле rows показана оценка числа строк, которые обработает один рабочий процесс. Всего их запланировано 2, и еще часть работы выполнит ведущий, поэтому общее число строк делится на 2.4 (доля ведущего процесса уменьшается с ростом числа рабочих процессов).

=> SELECT round(reltuples / 2.4) "rows"
FROM pg_class WHERE relname = 'bookings';
  rows
--------
 879629
(1 row)

Page 8: Page 8

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

=> SHOW parallel_leader_participation;
 parallel_leader_participation
-------------------------------
 on
(1 row)

Если ведущий процесс становится узким местом, его можно разгрузить:

=> SET parallel_leader_participation = off;
SET
=> EXPLAIN
SELECT count(*) FROM bookings;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
---
 Finalize Aggregate  (cost=27641.65..27641.66 rows=1 width=8)
   ->  Gather  (cost=27641.44..27641.65 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=26641.44..26641.45 rows=1 width=8)
               ->  Parallel Seq Scan on bookings  (cost=0.00..24002.55 rows=1055555
width=0)
(5 rows)

В этом случае общее число строк делится на число запланированных рабочих процессов (2):

=> SELECT round(reltuples / 2) "rows"
FROM pg_class WHERE relname = 'bookings';
  rows
---------
 1055555
(1 row)

Вернем значение по умолчанию для параметра parallel_leader_participation:

=> RESET parallel_leader_participation;
RESET

Для оценки узла Parallel Seq Scan компонента ввода-вывода берется полностью (таблицу все равно придется прочитать страница за страницей), а ресурсы процессора делятся между процессами (на 2.4 в данном случае).

=> SELECT round(
  (
    relpages * current_setting('seq_page_cost')::real +
    reltuples * current_setting('cpu_tuple_cost')::real / 2.4
  )::numeric,
  2
) AS "cost"
FROM pg_class WHERE relname = 'bookings';
   cost
----------
 22243.29
(1 row)

Выведем план еще раз:

=> EXPLAIN
SELECT count(*) FROM bookings;

Page 9: Page 9

                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Finalize Aggregate  (cost=25442.58..25442.59 rows=1 width=8)
   ->  Gather  (cost=25442.36..25442.57 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=24442.36..24442.37 rows=1 width=8)
               ->  Parallel Seq Scan on bookings  (cost=0.00..22243.29 rows=879629
width=0)
(5 rows)

Следующий узел — Partial Aggregate — выполняет агрегацию данных, полученных рабочим процессом, то есть в данном случае подсчитывает количество строк. Оценка выполняется уже известным образом (и добавляется к оценке сканирования таблицы):

=> SELECT round(
  (
    reltuples * current_setting('cpu_operator_cost')::real / 2.4
  )::numeric,
  2
) AS "cost"
FROM pg_class WHERE relname = 'bookings';
  cost
---------
 2199.07
(1 row)

Следующий узел — Gather — выполняется ведущим процессом. Он отвечает за запуск рабочих процессов и получение от них данных. Запуск процессов и пересылка каждой строки данных оцениваются следующими значениями:

=> SELECT current_setting('parallel_setup_cost') parallel_setup_cost,
current_setting('parallel_tuple_cost') parallel_tuple_cost;
 parallel_setup_cost | parallel_tuple_cost
---------------------+---------------------
 1000                | 0.1
(1 row)

В данном случае пересылается всего одна строка и основная стоимость приходится на запуск. И снова посмотрим на план запроса:

=> EXPLAIN
SELECT count(*) FROM bookings;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Finalize Aggregate  (cost=25442.58..25442.59 rows=1 width=8)
   ->  Gather  (cost=25442.36..25442.57 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=24442.36..24442.37 rows=1 width=8)
               ->  Parallel Seq Scan on bookings  (cost=0.00..22243.29 rows=879629
width=0)
(5 rows)

Последний узел — Finalize Aggregate — агрегирует полученные частичные агрегаты. Поскольку для этого надо сложить всего три числа, оценка минимальна.

Page 10: Число рабочих процессов

Равно нулю (параллельный план не строится)

  • если размер таблицы < min_parallel_table_scan_size = 8MB
  • Фиксировано
    • если для таблицы указан параметр хранения parallel_workers
  • Вычисляется по формуле
    • 1 + ⌊ log 3 (размер таблицы / min_parallel_table_scan_size) ⌋
  • Всегда не больше max_parallel_workers_per_gather

Комментарии преподавателя

Сколько рабочих процессов будет использоваться?

Планировщик вообще не будет рассматривать параллельное сканирование, если физический размер таблицы меньше значения параметра min_parallel_table_scan_size.

Обычно число процессов вычисляется по формуле, приведенной на слайде. Она означает, что при увеличении таблицы в три раза будет добавляться очередной процесс. Например, для стандартного значения min_parallel_table_scan_size = 8MB:

таблица процессы таблица процессы

8MB 1 216MB 4

24MB 2 648MB 5

72MB 3 1.9GB 6

Число процессов можно и явно указать в параметре хранения parallel_workers таблицы.

При этом число процессов в любом случае не будет превышать значения параметра max_parallel_workers_per_gather. Если при выполнении запроса доступное число процессов окажется меньше запланированного, будут использоваться только доступные (вплоть до последовательного выполнения, если пул полностью исчерпан).

Page 11: Число рабочих процессов при последовательном сканировании

Планировщик не рассматривает параллельные планы для таблиц размером меньше, чем:

=> SHOW min_parallel_table_scan_size;
 min_parallel_table_scan_size
------------------------------
 8MB
(1 row)

Если запросить информацию из таблицы немного большего размера (flights, 19 Мбайт), будет запланирован один дополнительный процесс:

=> EXPLAIN (analyze, costs off)
SELECT count(*) FROM flights;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 Finalize Aggregate (actual time=122.523..128.452 rows=1 loops=1)
   ->  Gather (actual time=122.096..128.442 rows=2 loops=1)
         Workers Planned: 1
         Workers Launched: 1
         ->  Partial Aggregate (actual time=93.255..93.256 rows=1 loops=2)
               ->  Parallel Seq Scan on flights (actual time=0.037..63.446 rows=107434
loops=2)
 Planning Time: 0.226 ms
 Execution Time: 128.495 ms
(8 rows)

При запросе данных из большой таблицы (bookings, 105 Мбайт) расчетное количество — три процесса.

=> EXPLAIN (analyze, costs off)
SELECT count(*) FROM bookings;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
--------
 Finalize Aggregate (actual time=610.478..621.055 rows=1 loops=1)
   ->  Gather (actual time=608.925..621.042 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual time=592.703..592.705 rows=1 loops=3)
               ->  Parallel Seq Scan on bookings (actual time=0.588..402.333 rows=703703
loops=3)
 Planning Time: 0.096 ms
 Execution Time: 621.087 ms
(8 rows)

Однако запланировано два процесса, так как параметр max_parallel_workers_per_gather ограничивает число процессов параллельного участка плана и сейчас действует значение по умолчанию (2).

=> SHOW max_parallel_workers_per_gather;
 max_parallel_workers_per_gather
---------------------------------
 2
(1 row)

Ослабив ограничение, получим план с тремя процессами:

=> SET max_parallel_workers_per_gather = 5;
SET
=> EXPLAIN (analyze, costs off)
SELECT count(*) FROM bookings;

Page 12: Page 12

                                            QUERY PLAN
------------------------------------------------------------------------------------------
--------
 Finalize Aggregate (actual time=586.674..600.373 rows=1 loops=1)
   ->  Gather (actual time=585.881..600.359 rows=4 loops=1)
         Workers Planned: 3
         Workers Launched: 3
         ->  Partial Aggregate (actual time=558.216..558.217 rows=1 loops=4)
               ->  Parallel Seq Scan on bookings (actual time=0.056..392.736 rows=527778
loops=4)
 Planning Time: 0.083 ms
 Execution Time: 600.410 ms
(8 rows)

Для конкретной таблицы параметром хранения parallel_workers можно задать рекомендованное количество процессов:

=> ALTER TABLE bookings SET (parallel_workers = 4);
ALTER TABLE
=> EXPLAIN (analyze, costs off)
SELECT count(*) FROM bookings;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
--------
 Finalize Aggregate (actual time=626.586..643.912 rows=1 loops=1)
   ->  Gather (actual time=625.726..643.891 rows=5 loops=1)
         Workers Planned: 4
         Workers Launched: 4
         ->  Partial Aggregate (actual time=589.271..589.273 rows=1 loops=5)
               ->  Parallel Seq Scan on bookings (actual time=0.056..382.341 rows=422222
loops=5)
 Planning Time: 0.138 ms
 Execution Time: 643.958 ms
(8 rows)

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

=> ALTER TABLE bookings RESET (parallel_workers);
ALTER TABLE
=> RESET max_parallel_workers_per_gather;
RESET

Page 13: Не распараллеливаются

Запросы на запись

  • а также запросы с блокировкой строк
  • Курсоры
    • в том числе запросы в цикле FOR в PL/pgSQL
  • Запросы с функциями PARALLEL UNSAFE
  • Запросы в функциях, вызванных из распараллеленного запроса

Комментарии преподавателя

Не каждый запрос может выполняться в параллельном режиме.

Не распараллеливаются запросы, изменяющие или блокирующие данные (UPDATE, DELETE, SELECT FOR UPDATE и т. п.).

Не распараллеливаются запросы, выполнение которых может быть приостановлено — это относится к запросам в курсорах, в том числе в циклах FOR PL/pgSQL.

Не распараллеливаются запросы, содержащие функции, помеченные как PARALLEL UNSAFE (пометки параллельности рассматриваются в теме «Функции»).

Не распараллеливаются запросы, содержащиеся в функциях, которые вызываются из распараллеленного запроса (чтобы не допустить рекурсивного разрастания).

Часть из этих ограничений может быть снята в следующих версиях PostgreSQL.

https://postgrespro.ru/docs/postgresql/16/when-can-parallel-query-be-used

Page 14: Только последовательно

Чтение результатов общих табличных выражений (CTE)

  • Чтение результатов нераскрываемых подзапросов
  • Обращения к временным таблицам
  • Вызовы функций PARALLEL RESTRICTED
  • Функции, использующие вложенные транзакции

Комментарии преподавателя

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

К ним относятся:

● чтение результатов общих табличных выражений (подзапросов в предложении WITH);

● чтение результатов других нераскрываемых подзапросов (которые представляются в плане узлами, например, SubPlan);

● обращения ко временным таблицам (так как они доступны только ведущему процессу);

● вызовы функций, помеченных как PARALLEL RESTRICTED.

Если в запросе вызывается функция, использующая вложенные транзакции (например, функция на PL/pgSQL с обработкой исключений), запрос завершится с ошибкой. Такие функции должны быть помечены как PARALLEL RESTRICTED. Подробнее пометки параллельности рассматриваются в теме «Функции».

https://postgrespro.ru/docs/postgresql/16/parallel-safety

Page 15: Только последовательные операции

Некоторые операции всегда выполняются последовательно, например, обращения к временным таблицам. Для примера создадим временную таблицу tmp_bookings — копию таблицы bookings:

=> CREATE TEMP TABLE tmp_bookings AS
  SELECT * FROM bookings;
SELECT 2111110
  • Используя операцию объединения множеств UNION ALL (без удаления дубликатов), объединим результаты двух запросов — к основной и временной таблицам:
=> EXPLAIN (costs off)
SELECT count(*) FROM bookings
UNION ALL
SELECT count(*) FROM tmp_bookings;
                      QUERY PLAN
-------------------------------------------------------
 Append
   ->  Finalize Aggregate
         ->  Gather
               Workers Planned: 2
               ->  Partial Aggregate
                     ->  Parallel Seq Scan on bookings
   ->  Aggregate
         ->  Seq Scan on tmp_bookings
(8 rows)

Данные из таблицы bookings читаются параллельно (по умолчанию используется два процесса), а из временной таблицы tmp_bookings — последовательно. Если операция не выполняется параллельно, это не обязательно означает, что она в принципе не распараллеливается: возможно, планировщик счел, что в данном случае последовательное выполнение эффективнее. Параметр debug_parallel_query позволяет проверить, может ли операция выполняться в параллельном режиме. Без агрегации обращение к таблице bookings выполняется последовательно:

=> EXPLAIN (costs off)
SELECT * FROM bookings;
      QUERY PLAN
----------------------
 Seq Scan on bookings
(1 row)

Но со включенным параметром мы видим, что в принципе параллельный план возможен:

=> SET debug_parallel_query = on;
SET
=> EXPLAIN (costs off)
SELECT * FROM bookings;
         QUERY PLAN
----------------------------
 Gather
   Workers Planned: 1
   Single Copy: true
   ->  Seq Scan on bookings
(4 rows)

Параметр служит только для отладки. С ним параллельное выполнение планируется, даже если оно не выгодно, и всегда использует единственный процесс.

=> RESET debug_parallel_query;
RESET

Page 16: Parallel Index Scan

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

  • Параллельное сканирование только индекса
  • Параллельное сканирование по битовой карте
  • Число рабочих процессов

Page 17: Parallel Index Scan

Source slide page 17: Parallel Index Scan{#source-figure-017 caption=“Source slide page 17: Parallel Index Scan”}

Комментарии преподавателя

Индексный доступ тоже может выполняться в параллельном режиме. Это происходит в два этапа. Сначала ведущий процесс спускается от корня дерева к листовой странице.

Page 18: Parallel Index Scan

Source slide page 18: Parallel Index Scan{#source-figure-018 caption=“Source slide page 18: Parallel Index Scan”}

Комментарии преподавателя

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

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

Page 19: Параллельное сканирование индекса

В качестве примера параллельного сканирования индекса найдем общую стоимость всех бронирований с кодами, меньшими 400000 (они составляют примерно одну четверть от общего числа):

=> EXPLAIN
SELECT sum(total_amount)
FROM bookings WHERE book_ref < '400000';
                                                    QUERY PLAN
------------------------------------------------------------------------------------------
------------------------
 Finalize Aggregate  (cost=17487.96..17487.97 rows=1 width=32)
   ->  Gather  (cost=17487.74..17487.95 rows=2 width=32)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=16487.74..16487.75 rows=1 width=32)
               ->  Parallel Index Scan using bookings_pkey on bookings
(cost=0.43..15916.40 rows=228535 width=6)
                     Index Cond: (book_ref < '400000'::bpchar)
(6 rows)

Аналогичный план мы уже видели при параллельном последовательном сканировании, но в данном случае данные читаются с помощью индекса узлом Parallel Index Scan. Полная стоимость складывается из стоимостей доступа к индексу и к таблице. Оценка стоимости индексного доступа не делится между процессами, так как индекс читается процессами последовательно, страница за страницей:

=> SELECT round(
  (relpages / 4.0) * current_setting('random_page_cost')::real +
  (reltuples / 4.0) * current_setting('cpu_index_tuple_cost')::real +
  (reltuples / 4.0) * current_setting('cpu_operator_cost')::real
) AS index_cost
FROM pg_class WHERE relname = 'bookings_pkey';
 index_cost
------------
       9750
(1 row)

Значением параметра cpu_operator_cost оценивается операция сравнения значений («меньше»). Стоимость доступа к 1/4 табличных страниц (поделенных между процессами) оценивается аналогично последовательному сканированию, поскольку строки физически упорядочены по book_ref:

=> SELECT round(
  (relpages / 4.0) * current_setting('seq_page_cost')::real +
  (reltuples / 4.0) / 2.4 * current_setting('cpu_tuple_cost')::real
) AS table_cost
FROM pg_class WHERE relname = 'bookings';
 table_cost
------------
       5561
(1 row)

Page 20: Parallel Index Only Scan

Source slide page 20: Parallel Index Only Scan{#source-figure-020 caption=“Source slide page 20: Parallel Index Only Scan”}

Комментарии преподавателя

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

Page 21: Параллельное сканирование только индекса

Рассмотрим похожий пример, в котором сканирование только индекса выполняется параллельно:

=> EXPLAIN
SELECT count(book_ref)
FROM bookings WHERE book_ref <= '400000';
                                                      QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------
 Finalize Aggregate  (cost=13990.96..13990.97 rows=1 width=8)
   ->  Gather  (cost=13990.75..13990.96 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=12990.75..12990.76 rows=1 width=8)
               ->  Parallel Index Only Scan using bookings_pkey on bookings
(cost=0.43..12419.41 rows=228535 width=7)
                     Index Cond: (book_ref <= '400000'::bpchar)
(6 rows)

Стоимость доступа к таблице здесь учитывает только обработку строк, без ввода-вывода:

=> SELECT round(
  (reltuples / 4.0) / 2.4 * current_setting('cpu_tuple_cost')::real
) AS table_cost
FROM pg_class WHERE relname = 'bookings';
 table_cost
------------
       2199
(1 row)

Вклад индексного доступа остается прежним.

Page 22: Parallel Bitmap Heap Scan

Source slide page 22: Parallel Bitmap Heap Scan{#source-figure-022 caption=“Source slide page 22: Parallel Bitmap Heap Scan”}

Комментарии преподавателя

Сканирование по битовой карте может выполняться параллельно.

Первый этап — сканирование индекса и построение битовой карты — всегда выполняется последовательно ведущим процессом.

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

Page 23: Параллельное сканирование по битовой карте

Для построения битовой карты нам понадобится новый индекс для таблицы bookings:

=> CREATE INDEX ON bookings(total_amount);
CREATE INDEX

Сколько бронирований сделано за последний месяц на сумму до 20 тысяч ₽?

=> SELECT bookings.now() - INTERVAL '1 months';
        ?column?
------------------------
 2017-07-15 18:00:00+03
(1 row)
=> \bind '2017-07-15 18:00:00+03'
=> EXPLAIN (costs off)
  SELECT count(*) FROM bookings
  WHERE total_amount < 20000 AND book_date > $1;
                                          QUERY PLAN
------------------------------------------------------------------------------------------
----
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Parallel Bitmap Heap Scan on bookings
                     Recheck Cond: (total_amount < '20000'::numeric)
                     Filter: (book_date > '2017-07-15 18:00:00+03'::timestamp with time
zone)
                     ->  Bitmap Index Scan on bookings_total_amount_idx
                           Index Cond: (total_amount < '20000'::numeric)
(9 rows)

Ведущий процесс строит битовую карту в узле Bitmap Index Scan. Сканирование таблицы по построенной битовой карте выполняется параллельно в узле Parallel Bitmap Heap Scan.

Page 24: Число рабочих процессов

Равно нулю (параллельный план не строится)

если размер выборки < min\_parallel\_index\_scan\_size = 512kB

  • Фиксировано

если для таблицы указан параметр хранения parallel\_workers

  • Вычисляется по формуле

1 + ⌊ log 3 (размер выборки / min\_parallel\_index\_scan\_size) ⌋

  • Но не больше, чем max_parallel_workers_per_gather

Комментарии преподавателя

Число рабочих процессов выбирается примерно так же, как и в случае последовательного сканирования. Объем данных, который предполагается прочитать из индекса (определяемый числом индексных страниц), сравнивается со значением параметра min_parallel_index_scan_size (по умолчанию 512kB).

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

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

Если размер выборки достаточно велик, число рабочих процессов определяется по формуле, если только оно не указано явно в параметре хранения parallel_workers таблицы (не индекса!).

Число процессов в любом случае не будет превышать значения параметра max_parallel_workers_per_gather.

Page 25: Итоги

Параллельные операции используют ресурсы процессора в нескольких рабочих процессах

  • PostgreSQL выделяет рабочие процессы из общего пула
  • Все методы доступа могут работать в параллельном режиме

Page 26: Практика

Выполните запрос, вычисляющий общее количество бронирований, с различными настройками планировщика и сравните планы выполнения: 1. С настройками по умолчанию. 2. Запретив последовательное сканирование. 3. Дополнительно запретив сканирование только индекса. 4. Разрешив все методы доступа, но отключив параллельность.

Комментарии преподавателя

  1. Речь идет о запросе EXPLAIN (costs off) SELECT count(book_ref) FROM bookings;

  2. Установите параметр enable_seqscan в off.

  3. Установите параметр enable_indexonlyscan в off.

  4. Восстановите значения параметров по умолчанию и установите параметр max_parallel_workers_per_gather в 0.

Page 27: 1. План запроса с настройками по умолчанию

=> EXPLAIN (costs off)
SELECT count(book_ref) FROM bookings;
                   QUERY PLAN
-------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Parallel Seq Scan on bookings
(5 rows)

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

2. Запрет последовательного сканирования

Отключим Seq Scan и повторим запрос:

=> SET enable_seqscan = off;
SET
=> EXPLAIN (costs off)
SELECT count(book_ref) FROM bookings;
                                 QUERY PLAN
----------------------------------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Parallel Index Only Scan using bookings_pkey on bookings
(5 rows)

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

3. Запрет сканирования только индекса

Отключим и Index Only Scan:

=> SET enable_indexonlyscan = off;
SET
=> EXPLAIN SELECT count(book_ref) FROM bookings;
                                       QUERY PLAN
----------------------------------------------------------------------------------------
 Aggregate  (cost=10000039835.88..10000039835.89 rows=1 width=8)
   ->  Seq Scan on bookings  (cost=10000000000.00..10000034558.10 rows=2111110 width=7)
(2 rows)

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

4. Запрет параллельности

Сбросим все параметры и запретим параллельные планы:

=> RESET ALL;
RESET
=> SET max_parallel_workers_per_gather = 0;
SET
=> EXPLAIN (costs off)
SELECT count(book_ref) FROM bookings;

Page 28: Page 28

         QUERY PLAN
----------------------------
 Aggregate
   ->  Seq Scan on bookings
(2 rows)

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

=> RESET ALL;
RESET

05 Занятие 05: Доступ к данным / Типы индексов

Исходная страница в нативном Markdown: Доступ к данным / Типы индексов
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_05_am_indexes.html Archive: QPT-handouts-16.zip Страниц PDF: 26

Page 1: Доступ к данным / Типы индексов

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Хеш-индекс
  • GiST
  • Класс операторов
  • SP-GiST
  • GIN
  • BRIN

Page 3: Идея хеширования

Source slide page 3: Идея хеширования{#source-figure-003 caption=“Source slide page 3: Идея хеширования”}

CREATE INDEX ON t USING hash(title);
SELECT * FROM t
WHERE title = 'Abbey Road';

Комментарии преподавателя

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

  1. вычисляется хеш-функция от заданного значения;

  2. по нескольким битам полученного хеш-кода определяется номер корзины;

  3. корзина просматривается в поисках хеш-кода.

Если данные распределены неравномерно, в одну корзину могут попасть много значений. В этом случае эффективность поиска будет страдать.

По сути, хеш-индекс представляет собой хеш-таблицу, которая хранится на диске.

https://postgrespro.ru/docs/postgresql/16/hash-intro

Page 4: Хеш-индекс

Хранит только хеш-коды, но не исходные значения

  • размер не зависит от ключа индексирования
  • невозможно сканирование только индекса
  • Индекс увеличивается динамически
    • скачкообразный рост
  • Поиск только по условию равенства

Комментарии преподавателя

Хеш-индекс хранит только значения хеш-функции и ссылки на версии строк; само индексируемое значение не сохраняется. Поэтому размер хеш-индекса не зависит от размера ключа индексирования, но теряется возможность сканирования только индекса — значение можно прочитать только из таблицы.

Размер хеш-индекса увеличивается динамически при добавлении новых значений. При увеличении количество корзин удваивается, поэтому рост размера происходит скачкообразно.

В отличие от B-дерева, хеш-индексы имеют много ограничений, в частности:

● единственная поддерживаемая операция — поиск по условию равенства, поскольку хеш-функция не сохраняет порядок следования значений;

● не поддерживается ограничение уникальности;

● нельзя создать многоколоночный индекс и добавить к индексу дополнительные include-столбцы.

Поэтому хеш-индексы не получили широкого распространения. Однако за счет меньшего размера и фиксированного времени поиска хеш-индекс может в ряде случаев работать быстрее, чем индекс на основе B-дерева.

Page 5: Хеш-индекс

Посмотрим на план запроса, получающего определенное место в салонах всех самолетов:

=> EXPLAIN (costs off)
SELECT * FROM seats WHERE seat_no = '31D';
                QUERY PLAN
-------------------------------------------
 Seq Scan on seats
   Filter: ((seat_no)::text = '31D'::text)
(2 rows)

Поскольку подходящего индекса нет, используется последовательное сканирование. Создадим хеш-индекс по полю seat_no и повторим запрос:

=> CREATE INDEX ON seats USING hash(seat_no);
CREATE INDEX
=> EXPLAIN (costs off)
SELECT * FROM seats WHERE seat_no = '31D';
                     QUERY PLAN
-----------------------------------------------------
 Bitmap Heap Scan on seats
   Recheck Cond: ((seat_no)::text = '31D'::text)
   ->  Bitmap Index Scan on seats_seat_no_idx
         Index Cond: ((seat_no)::text = '31D'::text)
(4 rows)

Теперь планировщик использует хеш-индекс и строит битовую карту. Поменяем условие равенства на «больше»:

=> EXPLAIN (costs off)
SELECT * FROM seats WHERE seat_no > '31D';
                QUERY PLAN
-------------------------------------------
 Seq Scan on seats
   Filter: ((seat_no)::text > '31D'::text)
(2 rows)

С неравенствами хеш-индекс использоваться не может.

Page 6: Пример индекса GiST

Source slide page 6: Пример индекса GiST{#source-figure-006 caption=“Source slide page 6: Пример индекса GiST”}

Комментарии преподавателя

GiST расшифровывается как generalized search tree — обобщенное дерево поиска.

Идею работы GiST-индексов рассмотрим на примере точек на плоскости.

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

Как видно на рисунке, прямоугольники могут пересекаться (хотя это и уменьшает эффективность поиска).

Page 7: Пример индекса GiST

Source slide page 7: Пример индекса GiST{#source-figure-007 caption=“Source slide page 7: Пример индекса GiST”}

Комментарии преподавателя

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

Page 8: Пример индекса GiST

Source slide page 8: Пример индекса GiST{#source-figure-008 caption=“Source slide page 8: Пример индекса GiST”}

Комментарии преподавателя

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

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

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

  2. спускаемся в выбранные поддеревья и повторяем поиск в них.

Такой алгоритм индексирования называется R-деревом.

Page 9: Индекс GiST

Сбалансированное дерево поиска

  • рассчитано на произвольные типы данных
  • не требуется упорядоченность
  • Типичные поддерживаемые операции
    • вхождение в область
    • нахождение слева, справа, сверху, снизу от области
  • Поиск ближайших соседей
    • первые k значений, ближайших к заданному

Комментарии преподавателя

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

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

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

https://postgrespro.ru/docs/postgresql/16/gist-intro

Page 10: Индекс GiST

Для демонстрации работы GiST-индекса обратимся к таблице airports_data (на этой таблице построено представление airports). В таблице есть поле coordinates типа point, по нему и будем строить GiST-индекс. Но сначала выполним следующий запрос — найдем все аэропорты, находящиеся недалеко от Москвы:

=> EXPLAIN (costs off)
SELECT airport_code
FROM airports_data
WHERE coordinates <@ '<(37.622513,55.753220),1.0>'::circle;
                          QUERY PLAN
---------------------------------------------------------------
 Seq Scan on airports_data
   Filter: (coordinates <@ '<(37.622513,55.75322),1>'::circle)
(2 rows)

Без индекса просматривается вся таблица. Создадим GiST-индекс:

=> CREATE INDEX airports_gist_idx ON airports_data
USING gist(coordinates);
CREATE INDEX

Таблица airports_data невелика, поэтому планировщик все равно будет использовать последовательное сканирование. Временно отключим этот метод доступа:

=> SET enable_seqscan = off;
SET

Повторим запрос:

=> EXPLAIN (costs off)
SELECT airport_code
FROM airports_data
WHERE coordinates <@ '<(37.622513,55.753220),1.0>'::circle;
                            QUERY PLAN
-------------------------------------------------------------------
 Index Scan using airports_gist_idx on airports_data
   Index Cond: (coordinates <@ '<(37.622513,55.75322),1>'::circle)
(2 rows)

Теперь планировщик получает нужные строки, обращаясь к индексу airports_gist_idx. Удалим созданный индекс:

=> DROP INDEX airports_gist_idx;
DROP INDEX

Page 11: Класс операторов

Source slide page 11: Класс операторов{#source-figure-011 caption=“Source slide page 11: Класс операторов”}

Посредник между индексным методом и типом данных

  • Может включать значительную часть логики индексирования

Комментарии преподавателя

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

Такие индексы, как B-деревья и хеш-индексы, тоже используют классы операторов, но они довольно простые: содержат такие операторы, как «равно», «больше» и «меньше».

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

Например, с помощью GiST можно индексировать значения диапазонных типов (таких, как int4range или tstzrange). Такой индекс позволяет находить диапазоны, включенные в заданный диапазон, пересекающиеся с заданным диапазоном, примыкающие к заданному диапазону и т. п.

GiST можно считать каркасом, на основе которого строятся произвольные схемы индексации (не только R-дерево), реализуя нужным образом класс операторов. Это гораздо проще, чем создать с нуля новый тип индекса, что весьма трудоемко и требует очень высокой квалификация разработчика.

https://postgrespro.ru/docs/postgresql/16/indexes-opclass

Page 12: Пример индекса SP-GiST

Source slide page 12: Пример индекса SP-GiST{#source-figure-012 caption=“Source slide page 12: Пример индекса SP-GiST”}

Комментарии преподавателя

SP-GiST расшифровывается как space partitioning GiST. Это тоже обобщенное дерево поиска, но оно строится путем разбиения пространства поиска на непересекающиеся области.

Рассмотрим пример индекса SP-GiST для точек на плоскости.

Один из вариантов — дерево квадрантов. Корневой узел разбивает плоскость на четыре части (квадранта) по отношению к выбранной точке-центроиду.

Page 13: Пример индекса SP-GiST

Source slide page 13: Пример индекса SP-GiST{#source-figure-013 caption=“Source slide page 13: Пример индекса SP-GiST”}

Комментарии преподавателя

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

Page 14: Пример индекса SP-GiST

Source slide page 14: Пример индекса SP-GiST{#source-figure-014 caption=“Source slide page 14: Пример индекса SP-GiST”}

Комментарии преподавателя

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

Page 15: Индекс SP-GiST

Несбалансированное дерево поиска

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

Комментарии преподавателя

SP-GiST, как и GiST, является каркасом, на основе которого, реализуя классы операторов, можно строить произвольные схемы индексации. Например, дерево квадрантов для точек реализуется классом операторов point_ops. Другой способ деления плоскости — на две, а не на четыре части. Такой способ называется k-мерным деревом и реализуется другим классом операторов — kd_point_ops.

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

Как правило, индексы SP-GiST предоставляют поддержку тех же типов данных и операторов, что и GiST. Но из-за другой структуры индекса они могут оказаться как более, так и менее эффективными, чем GiST.

https://postgrespro.ru/docs/postgresql/16/spgist

Page 16: Индекс SP-GiST

Создадим индекс SP-GiST по полю coordinates в таблице airports_data. Для точек есть два класса операторов. По умолчанию используется point_ops (дерево квадрантов), а мы для примера укажем kd_point_ops (k-мерное дерево):

=> CREATE INDEX airports_spgist_idx ON airports_data
USING spgist(coordinates kd_point_ops);
CREATE INDEX

Попробуем найти все аэропорты, расположенные выше (севернее) Надыма:

=> EXPLAIN (costs off)
SELECT airport_code
FROM airports_data
WHERE coordinates >^ '(72.69889831542969,65.48090362548828)'::point;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Bitmap Heap Scan on airports_data
   Recheck Cond: (coordinates >^ '(72.69889831542969,65.48090362548828)'::point)
   ->  Bitmap Index Scan on airports_spgist_idx
         Index Cond: (coordinates >^ '(72.69889831542969,65.48090362548828)'::point)
(4 rows)

Теперь таблица сканируется по битовой карте, построенной на основе SP-GiST-индекса airports_spgist_idx.

Page 17: Идея GIN-индекса

Source slide page 17: Идея GIN-индекса{#source-figure-017 caption=“Source slide page 17: Идея GIN-индекса”}

Комментарии преподавателя

GIN — generalized inverted index, обобщенный инвертированный индекс.

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

Индекс GIN в первую очередь используется для индексации документов с целью ускорения полнотекстового поиска. По сути, это обычное B-дерево, но в него помещены не сами документы, а составляющие их слова. GIN-индекс оптимизирован с учетом того, что каждое слово может встречаться во многих документах. Если «список страниц» очень велик, он может храниться не в самой индексной странице, а в отдельном B-дереве.

https://postgrespro.ru/docs/postgresql/16/gin

Page 18: Индекс GIN

Инвертированный список

  • для типов данных, значения которых (документы) состоят из элементов
  • индексируются элементы, а не документы
  • Типичная поддерживаемая операция
    • проверка соответствия документа поисковому запросу
    • проверка вхождения элемента в массив
    • поиск документов JSON по ключам или значениям

Комментарии преподавателя

GIN работает с типами данных, значения которых состоят из элементов, а не являются атомарными. При этом индексируются не сами значения, а их элементы.

Как и GiST и SP-GiST, метод GIN является каркасом, который можно настроить не только на работу с текстом (состоящим из слов), но и с другими типами данных: с массивами (состоящими из элементов), с документами JSON (состоящими из ключей и значений). Для этого создается класс операторов, реализующий разбиение документа на элементы и проверку соответствия документа поисковому запросу.

Page 19: Индекс GIN

В столбце days_of_week представления routes хранится массив номеров дней недели, по которым выполняется рейс:

=> SELECT flight_no, days_of_week FROM routes LIMIT 5;
 flight_no | days_of_week
-----------+--------------
 PG0001    | {6}
 PG0002    | {7}
 PG0003    | {2,6}
 PG0004    | {3,7}
 PG0005    | {2,5,7}
(5 rows)

Для представления нельзя построить индекс, поэтому сохраним его строки в отдельной таблице:

=> CREATE TABLE routes_tbl
AS SELECT * FROM routes;
SELECT 710

Теперь создадим GIN-индекс:

=> CREATE INDEX routestbl_gin_idx ON routes_tbl USING gin(days_of_week);
CREATE INDEX

С помощью GIN-индекса можно, например, отобрать рейсы, отправляющиеся только по средам и субботам:

=> EXPLAIN (costs off)
SELECT flight_no, departure_airport_name AS departure,
  arrival_airport_name AS arrival, days_of_week
FROM routes_tbl
WHERE days_of_week = ARRAY[3,6];
                       QUERY PLAN
---------------------------------------------------------
 Bitmap Heap Scan on routes_tbl
   Recheck Cond: (days_of_week = '{3,6}'::integer[])
   ->  Bitmap Index Scan on routestbl_gin_idx
         Index Cond: (days_of_week = '{3,6}'::integer[])
(4 rows)

Созданный GIN-индекс содержит всего семь элементов: целые числа от 1 до 7, представляющие дни недели. Для каждого из них в индексе хранятся ссылки на рейсы, выполняющиеся в этот день.

Page 20: Пример индекса BRIN

Source slide page 20: Пример индекса BRIN{#source-figure-020 caption=“Source slide page 20: Пример индекса BRIN”}

SELECT * FROM t WHERE temperature BETWEEN 20 AND 30;

Комментарии преподавателя

BRIN — block range index, «индекс зон блоков». Таблица разбивается на зоны определенной (настраиваемой) длины, каждая из которых охватывает набор последовательно расположенных страниц. Например, на слайде показан индекс с зоной размером 128 страниц. По каждой зоне собирается сводная информация, например, минимум и максимум значений индексируемого столбца.

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

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

https://postgrespro.ru/docs/postgresql/16/brin

Page 21: Индекс BRIN

Список зон со сводной информацией

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

Комментарии преподавателя

Используя классы операторов, можно выбирать сводную информацию, хранимую в индексе для каждой зоны. Это может быть как просто минимум и максимум, так и несколько диапазонов значений, а для геометрических типов данных можно хранить охватывающий прямоугольник (как в GiST).

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

Поскольку BRIN не хранит ссылки на версии строк, он возвращает перечень страниц зоны в виде неточной битовой карты. Обычное индексное сканирование (и сканирование только индекса) невозможны.

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

Page 22: Индекс BRIN

Для примера построим индекс BRIN по самой большой таблице:

=> CREATE INDEX tflights_brin_idx ON ticket_flights USING brin(flight_id);
CREATE INDEX

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

=> EXPLAIN (analyze, costs off, timing off)
SELECT *
FROM ticket_flights
WHERE flight_id BETWEEN 3000 AND 4000;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Gather (actual rows=46357 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Bitmap Heap Scan on ticket_flights (actual rows=15452 loops=3)
         Recheck Cond: ((flight_id >= 3000) AND (flight_id <= 4000))
         Rows Removed by Index Recheck: 2377352
         Heap Blocks: lossy=20149
         ->  Bitmap Index Scan on tflights_brin_idx (actual rows=598210 loops=1)
               Index Cond: ((flight_id >= 3000) AND (flight_id <= 4000))
 Planning Time: 77.935 ms
 Execution Time: 4364.705 ms
(11 rows)

Поскольку BRIN не хранит ссылки на версии строк, единственный возможный способ доступа — сканирование по неточной (lossy) битовой карте.

Page 23: Итоги

Помимо B-дерева, существуют и другие, более специфичные типы индексов

  • Хеш-индекс для поиска по равенству
  • GiST и SP-GiST для несортируемых типов данных
  • GIN для документов
  • BRIN для очень больших таблиц

Page 24: Практика

  1. Сравните размеры и время построения хеш-индекса по полям разного размера (book_ref и contact_data) таблицы билетов tickets. Повторите то же для индекса на основе B-дерева. 2. Воспользуйтесь GIN-индексом и расширением pg_trgm для поиска пассажиров, номер телефона которых содержит последовательность цифр 1234. Можно ли ускорить такой запрос с помощью B-дерева?

Комментарии преподавателя

  1. Для оценки размера индекса можно использовать функцию pg_total_relation_size(‘имя-индекса’).

  2. Создайте GIN-индекс по выражению contact_data-»‘phone’, используя класс операторов gin_trgm_ops из расширения pg_trgm.

Страница документации: https://postgrespro.ru/docs/postgresql/16/pgtrgm

Page 25: 1. Сравнение размера и времени создания индексов

Посмотрим структуру таблицы tickets:

=> \d tickets
                        Table "bookings.tickets"
     Column     |         Type          | Collation | Nullable | Default
----------------+-----------------------+-----------+----------+---------
 ticket_no      | character(13)         |           | not null |
 book_ref       | character(6)          |           | not null |
 passenger_id   | character varying(20) |           | not null |
 passenger_name | text                  |           | not null |
 contact_data   | jsonb                 |           |          |
Indexes:
    "tickets_pkey" PRIMARY KEY, btree (ticket_no)
Foreign-key constraints:
    "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)
Referenced by:
    TABLE "ticket_flights" CONSTRAINT "ticket_flights_ticket_no_fkey" FOREIGN KEY
(ticket_no) REFERENCES tickets(ticket_no)

Поле book_ref имеет фиксированный размер, а поле contact_data имеет тип jsonb. Включим подсчет времени выполнения запросов:

=> \timing on
Timing is on.

Создадим hash-индексы…

=> CREATE INDEX tickets_hash_br ON tickets USING hash(book_ref);
CREATE INDEX
Time: 12494,426 ms (00:12,494)
=> CREATE INDEX tickets_hash_cd ON tickets USING hash(contact_data);
CREATE INDEX
Time: 12111,018 ms (00:12,111)

…и индексы B-tree:

=> CREATE INDEX tickets_btree_br ON tickets(book_ref);
CREATE INDEX
Time: 27089,915 ms (00:27,090)
=> CREATE INDEX tickets_btree_cd ON tickets(contact_data);
CREATE INDEX
Time: 72766,842 ms (01:12,767)

Время создания хеш-индексов примерно одинаково, а время создания btree-индексов зависит от размера индексируемого поля.

=> \timing off
Timing is off.

Теперь проверим размеры полученных индексов:

=> SELECT pg_size_pretty(pg_total_relation_size('tickets_hash_br')) "hash book_ref",
  pg_size_pretty(pg_total_relation_size('tickets_hash_cd')) "hash contact_data",
  pg_size_pretty(pg_total_relation_size('tickets_btree_br')) "btree book_ref",
  pg_size_pretty(pg_total_relation_size('tickets_btree_cd')) "btree contact_data" \gx
-[ RECORD 1 ]------+-------
hash book_ref      | 81 MB
hash contact_data  | 80 MB
btree book_ref     | 59 MB
btree contact_data | 226 MB

Размер hash-индексов тоже примерно одинаковый. А размер индексов B-tree (как и время их построения) зависит от размера индексируемого поля, поскольку такие индексы хранят индексируемые значения.

Page 26: 2. Расширение pg_trgm

Выполним запрос:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM tickets
WHERE contact_data->>'phone' LIKE '%1234%';
                              QUERY PLAN
----------------------------------------------------------------------
 Gather (actual rows=1801 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=128 read=49287
   ->  Parallel Seq Scan on tickets (actual rows=600 loops=3)
         Filter: ((contact_data ->> 'phone'::text) ~~ '%1234%'::text)
         Rows Removed by Filter: 982685
         Buffers: shared hit=128 read=49287
 Planning:
   Buffers: shared hit=24 read=9 dirtied=1
 Planning Time: 4.555 ms
 Execution Time: 2995.486 ms
(12 rows)

Обратите внимание на количество прочитанных страниц (Buffers) — их почти пятьдесят тысяч. Добавим расширение pg_trgm:

=> CREATE EXTENSION pg_trgm;
CREATE EXTENSION

Создадим GIN-индекс с классом операторов gin_trgm_ops:

=> CREATE INDEX tickets_gin
  ON tickets USING GIN ((contact_data->>'phone') gin_trgm_ops);
CREATE INDEX

Такой класс операторов ускоряет поиск по шаблону, который начинается на знак процента, и даже по регулярным выражениям. Индекс на основе B-дерева этого не позволяет. Повторим запрос:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM tickets
WHERE contact_data->>'phone' LIKE '%1234%';
                                QUERY PLAN
--------------------------------------------------------------------------
 Bitmap Heap Scan on tickets (actual rows=1801 loops=1)
   Recheck Cond: ((contact_data ->> 'phone'::text) ~~ '%1234%'::text)
   Rows Removed by Index Recheck: 46
   Heap Blocks: exact=1820
   Buffers: shared hit=23 read=1818 written=30
   ->  Bitmap Index Scan on tickets_gin (actual rows=1847 loops=1)
         Index Cond: ((contact_data ->> 'phone'::text) ~~ '%1234%'::text)
         Buffers: shared hit=21
 Planning:
   Buffers: shared hit=26 read=1 dirtied=1
 Planning Time: 0.561 ms
 Execution Time: 308.370 ms
(12 rows)

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

06 Занятие 06: Сортировка и группировка / Сортировка

Исходная страница в нативном Markdown: Сортировка и группировка / Сортировка
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_06_sort_sort.html Archive: QPT-handouts-16.zip Страниц PDF: 22

Page 1: Сортировка и группировка / Сортировка

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Получение отсортированных данных
  • Сортировка в памяти
  • Внешняя сортировка
  • Инкрементальная сортировка
  • Сортировка в параллельных планах
  • Сортировка при построении индекса
  • Оконные функции с сортировкой

Page 3: Получение данных

Индексный доступ (В-дерево)

  • возвращает отсортированный набор строк
  • Последовательный доступ (Seq Scan)
    • возвращает неупорядоченный набор строк
    • необходима дополнительная операция сортировки

Комментарии преподавателя

Как уже было сказано ранее, индексный доступ (при использовании B-дерева) позволяет сразу же получить набор строк, отсортированный по ключам индексирования.

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

Page 4: Получение отсортированных данных

Индексный доступ автоматически возвращает строки, отсортированные по проиндексированному столбцу:

=> EXPLAIN (costs off)
SELECT * FROM flights
ORDER BY flight_id;
                QUERY PLAN
------------------------------------------
 Index Scan using flights_pkey on flights
(1 row)

Но если попросить сервер отсортировать данные по столбцу без индекса, потребуется два отдельных шага: получение данных и сортировка.

=> EXPLAIN (costs off)
SELECT * FROM flights
ORDER BY status;
        QUERY PLAN
---------------------------
 Sort
   Sort Key: status
   ->  Seq Scan on flights
(3 rows)

Дальше мы будем разбираться, как устроена сортировка в узле Sort.

Page 5: Сортировка в памяти

Быстрая сортировка (quick sort)

  • Частичная пирамидальная сортировка (top-N heapsort)
    • когда нужна только часть значений

Комментарии преподавателя

В идеальном случае набор строк, подлежащий сортировке, целиком помещается в память, ограниченную параметром work_mem. В этом случае все строки просто сортируются алгоритмом быстрой сортировки (quick sort) и результат возвращается родительскому узлу.

Если нужно получить лишь несколько первых строк отсортированного набора (при использовании предложения LIMIT), сортировать весь набор не обязательно. В этом случае сервер может применить частичную пирамидальную сортировку (top-N heapsort), которая также выполняется в оперативной памяти.

Page 6: Сортировка в памяти

В распоряжении планировщика имеется несколько методов сортировки. В следующем примере используется быстрая сортировка (Sort Method: quicksort). В той же строке указан объем использованной памяти:

=> EXPLAIN (analyze, timing off, summary off)
SELECT *
FROM seats
ORDER BY seat_no;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
-
 Sort  (cost=90.93..94.28 rows=1339 width=15) (actual rows=1339 loops=1)
   Sort Key: seat_no
   Sort Method: quicksort  Memory: 101kB
   ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=15) (actual rows=1339 loops=1)
(4 rows)

Чтобы начать выдавать данные, узлу Sort нужно полностью отсортировать набор строк. Поэтому начальная стоимость узла включает в себя полную стоимость чтения таблицы. В общем случае сложность быстрой сортировки равна O(M logM), где M — число строк в исходном наборе. Если набор строк ограничен, планировщик может переключиться на частичную сортировку (грубо говоря, вместо полной сортировки здесь 100 раз находится минимальное значение):

=> EXPLAIN (analyze, timing off, summary off)
SELECT *
FROM seats
ORDER BY seat_no
LIMIT 100;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
-------
 Limit  (cost=72.57..72.82 rows=100 width=15) (actual rows=100 loops=1)
   ->  Sort  (cost=72.57..75.91 rows=1339 width=15) (actual rows=100 loops=1)
         Sort Key: seat_no
         Sort Method: top-N heapsort  Memory: 32kB
         ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=15) (actual rows=1339
loops=1)
(5 rows)

Обратите внимание, что стоимость запроса снизилась и для сортировки потребовалось меньше памяти. В общем случае сложность алгоритма top-N heapsort ниже, чем для быстрой сортировки — она равна O(M logN).

Page 7: Внешняя сортировка

Source slide page 7: Внешняя сортировка{#source-figure-007 caption=“Source slide page 7: Внешняя сортировка”}

Комментарии преподавателя

Если набор строк велик, он не поместится в память целиком. В таком случае используется алгоритм внешней сортировки.

Набор строк читается в память, пока есть возможность, затем сортируется и записывается во временный файл.

Page 8: Внешняя сортировка

Source slide page 8: Внешняя сортировка{#source-figure-008 caption=“Source slide page 8: Внешняя сортировка”}

Комментарии преподавателя

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

Напомним, что общий размер временных файлов сеанса (не включая временные таблицы) ограничен значением параметра temp_file_limit.

Page 9: Внешняя сортировка

Source slide page 9: Внешняя сортировка{#source-figure-009 caption=“Source slide page 9: Внешняя сортировка”}

Комментарии преподавателя

Далее несколько файлов (два или более) соединяются с сохранением порядка строк.

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

Если оперативной памяти недостаточно, чтобы соединить сразу все файлы, сначала соединяется часть файлов и результат записывается в новый временный файл, затем происходит соединение этих новых файлов и так далее.

Мы не обсуждаем здесь все детали реализации сортировки, с ними можно ознакомиться в файле src/backend/utils/sort/tuplesort.c.

История развития способов сортировки в PostgreSQL хорошо описана в презентации Грегори Старка «Sorting Through The Ages»: https://wiki.postgresql.org/images/5/59/Sorting\_through\_the\_ages.pdf, синхронный перевод доклада на русский язык доступен на сайте https://pgconf.ru/talk/1587768​.

Page 10: Внешняя сортировка

Пример плана с внешней сортировкой (Sort Method: external merge):

=> EXPLAIN (analyze, buffers, timing off, summary off)
SELECT *
FROM flights
ORDER BY scheduled_departure;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
---------
 Sort  (cost=31883.96..32421.12 rows=214867 width=63) (actual rows=214867 loops=1)
   Sort Key: scheduled_departure
   Sort Method: external merge  Disk: 17112kB
   Buffers: shared hit=3 read=2624, temp read=2139 written=2145
   ->  Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63) (actual rows=214867
loops=1)
         Buffers: shared read=2624
 Planning:
   Buffers: shared hit=10 read=1
(8 rows)

Обратите внимание на то, что узел Sort записывает и читает временные данные (temp read и written). Увеличим значение work_mem:

=> SET work_mem = '48MB';
SET
=> EXPLAIN (analyze, buffers, timing off, summary off)
SELECT *
FROM flights
ORDER BY scheduled_departure;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
---------
 Sort  (cost=23802.46..24339.62 rows=214867 width=63) (actual rows=214867 loops=1)
   Sort Key: scheduled_departure
   Sort Method: quicksort  Memory: 26161kB
   Buffers: shared hit=2624
   ->  Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63) (actual rows=214867
loops=1)
         Buffers: shared hit=2624
(6 rows)

Теперь все строки поместились в память, и планировщик выбрал более дешевую быструю сортировку.

=> RESET work_mem;
RESET

Page 11: Incremental Sort

Source slide page 11: Incremental Sort{#source-figure-011 caption=“Source slide page 11: Incremental Sort”}

Комментарии преподавателя

Если набор данных требуется отсортировать по ключам K1 … Km … Kn и при этом известно, что набор уже отсортирован по первым m ключам, можно не пересортировывать все данные заново. Достаточно разбить набор данных на группы, имеющие одинаковые значения начальных ключей K1 … Km (значения таких групп следуют друг за другом), и затем отсортировать отдельно каждую из групп по оставшимся ключам Km+1 … Kn. Такой способ называется инкрементальной сортировкой.

Алгоритм обрабатывает относительно крупные группы строк отдельно, а небольшие объединяет и сортирует полностью. Поскольку сортируемые наборы данных уменьшаются, уменьшается и требование к доступному объему памяти. Но увеличение количества наборов приводит к росту накладных расходов.

  • Наборы могут обрабатываться как в оперативной памяти, так и на диске
  • если не хватает объема work_mem.

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

Отключить использование инкрементальной сортировки можно с помощью параметра enable_incremental_sort.

Page 12: Инкрементальная сортировка

Инкрементальная сортировка может использовать как сортировку в памяти, так и внешнюю сортировку. Для иллюстрации создадим индекс на таблице bookings:

=> CREATE INDEX ON bookings(total_amount);
CREATE INDEX

Посмотрим на пример инкрементальной сортировки:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT *
FROM bookings
ORDER BY total_amount, book_date;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 Incremental Sort (actual rows=2111110 loops=1)
   Sort Key: total_amount, book_date
   Presorted Key: total_amount
   Full-sort Groups: 2823  Sort Method: quicksort  Average Memory: 28kB  Peak Memory: 28kB
   Pre-sorted Groups: 2624  Sort Method: quicksort  Average Memory: 2193kB  Peak Memory:
2263kB
   ->  Index Scan using bookings_total_amount_idx on bookings (actual rows=2111110
loops=1)
(6 rows)

Здесь данные, полученные из таблицы bookings по только что созданному индексу bookings_total_amount_idx, уже отсортированы по столбцу total_amount (Presorted Key), поэтому остается доупорядочить строки по столбцу book_date. Строка Pre-sorted Groups относится к крупным группам, которые досортировывались по столбцу book_date, а строка Full-sort Groups — к небольшим группам, которые были объединены и отсортированы полностью. В примере все группы поместились в выделенную память и применялась быстрая сортировка. Уменьшим work_mem и повторим запрос:

=> SET work_mem = '128 kB';
SET
=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT *
FROM bookings
ORDER BY total_amount, book_date;
                                                                     QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------------------------------------
 Incremental Sort (actual rows=2111110 loops=1)
   Sort Key: total_amount, book_date
   Presorted Key: total_amount
   Full-sort Groups: 2823  Sort Method: quicksort  Average Memory: 28kB  Peak Memory: 28kB
   Pre-sorted Groups: 2624  Sort Methods: quicksort, external merge  Average Memory: 0kB
Peak Memory: 45kB  Average Disk: 1047kB  Peak Disk: 1088kB
   ->  Index Scan using bookings_total_amount_idx on bookings (actual rows=2111110
loops=1)
(6 rows)

Теперь, при нехватке памяти, для некоторых крупных групп пришлось применить внешнюю сортировку с использованием временных файлов (Pre-sorted Groups … external merge).

=> RESET work_mem;
RESET

Page 13: В параллельных планах

Source slide page 13: В параллельных планах{#source-figure-013 caption=“Source slide page 13: В параллельных планах”}

Узел Gather Merge сохраняет порядок сортировки

  • выполняет слияние данных, поступающих от дочерних узлов

Комментарии преподавателя

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

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

Поэтому в таких планах используется узел Gather Merge, сохраняющий порядок сортировки поступающих строк. Для этого он реализует алгоритм слияния, объединяя несколько отсортированных наборов в один.

Page 14: В параллельных планах

Запрос, сортирующий большую таблицу по неиндексированному полю, может выполняться параллельно:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT *
FROM bookings
ORDER BY book_date;
                               QUERY PLAN
------------------------------------------------------------------------
 Gather Merge (actual rows=2111110 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Sort (actual rows=703703 loops=3)
         Sort Key: book_date
         Sort Method: external merge  Disk: 24624kB
         Worker 0:  Sort Method: external merge  Disk: 21696kB
         Worker 1:  Sort Method: external merge  Disk: 21904kB
         ->  Parallel Seq Scan on bookings (actual rows=703703 loops=3)
(9 rows)

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

Page 15: Построение B-дерева

Используется сортировка

  • сначала все строки сортируются
  • затем строки собираются в листовые индексные страницы
  • ссылки на них собираются в страницы следующего уровня
  • и так далее, пока не дойдем до корня
  • Может выполняться параллельно
    • max_parallel_maintenance_workers
  • Ограничение
    • maintenance_work_mem, так как операция не частая

Комментарии преподавателя

При построении индекса (речь идет про B-дерево) сервер мог бы добавлять записи в пустой индекс по одной, обрабатывая последовательно строки таблицы. Но такой способ крайне неэффективен.

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

Сортировка устроена точно так же, как рассматривалось выше. Однако размер памяти ограничен не work_mem, а maintenance_work_mem, поскольку операция создания индекса не слишком частая и имеет смысл выделить для нее больше памяти.

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

Page 16: Оконные функции

Source slide page 16: Оконные функции{#source-figure-016 caption=“Source slide page 16: Оконные функции”}

Окно определяет агрегируемую выборку для каждой строки

  • В отсортированном наборе границы рамки могут двигаться
    • OVER ()
    • OVER (ORDER BY)
    • окно
   1 2 3 4 5      1 2 3 4 5
   1 2 3 4 5      1 2 3 4 5
   1 2 3 4 5      1 2 3 4 5
   1 2 3 4 5      1 2 3 4 5
   1 2 3 4 5      1 2 3 4 5

Комментарии преподавателя

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

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

Если окно указано как OVER(), то рамка одинакова для каждой строки и состоит из всех строк набора.

Page 17: Оконные функции

Посмотрим план запроса, вычисляющего сумму бронирований нарастающим итогом:

=> EXPLAIN SELECT *, sum(total_amount) OVER (ORDER BY book_date)
FROM bookings;
                                  QUERY PLAN
-------------------------------------------------------------------------------
 WindowAgg  (cost=342915.67..379860.10 rows=2111110 width=53)
   ->  Sort  (cost=342915.67..348193.45 rows=2111110 width=21)
         Sort Key: book_date
         ->  Seq Scan on bookings  (cost=0.00..34558.10 rows=2111110 width=21)
(4 rows)

Оконная функция вычисляется в узле WindowAgg, который получает отсортированные данные от дочернего узла Sort. Добавление к запросу других оконных функций, использующих тот же порядок строк (не обязательно с совпадающим окном), а также предложения ORDER BY не приводит к появлению лишних сортировок:

=> EXPLAIN SELECT *,
  sum(total_amount) OVER (ORDER BY book_date),
  avg(total_amount) OVER (ORDER BY book_date ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING)
FROM bookings
ORDER BY book_date;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 WindowAgg  (cost=342915.67..411526.75 rows=2111110 width=85)
   ->  WindowAgg  (cost=342915.67..379860.10 rows=2111110 width=53)
         ->  Sort  (cost=342915.67..348193.45 rows=2111110 width=21)
               Sort Key: book_date
               ->  Seq Scan on bookings  (cost=0.00..34558.10 rows=2111110 width=21)
(5 rows)

Здесь два узла WindowAgg (каждый для своего окна), но общий узел Sort. Стоимость запроса увеличилась, но лишь немного. Конечно, оконные функции, требующие разного порядка строк, вынуждают сервер пересортировывать данные:

=> EXPLAIN SELECT *,
  sum(total_amount) OVER (ORDER BY book_date),
  count(*) OVER (ORDER BY book_ref)
FROM bookings;
                                                QUERY PLAN
------------------------------------------------------------------------------------------
-----------------
 WindowAgg  (cost=422743.30..459687.73 rows=2111110 width=61)
   ->  Sort  (cost=422743.30..428021.08 rows=2111110 width=29)
         Sort Key: book_date
         ->  WindowAgg  (cost=0.43..99951.73 rows=2111110 width=29)
               ->  Index Scan using bookings_pkey on bookings  (cost=0.43..68285.08
rows=2111110 width=21)
(5 rows)

В этом примере нижний узел WindowAgg (соответствующий функции count) получает упорядоченный набор строк по индексу, а для верхнего узла WindowAgg (соответствующего функции sum) строки переупорядочивает узел Sort. Сортировка в оконных функциях может использовать любые методы, рассмотренные выше. Например, быструю сортировку:

=> EXPLAIN (analyze, buffers, timing off, summary off)
SELECT *, count(*) OVER (ORDER BY seat_no)
FROM seats;

Page 18: Page 18

                                           QUERY PLAN
------------------------------------------------------------------------------------------
-------
 WindowAgg  (cost=90.93..114.36 rows=1339 width=23) (actual rows=1339 loops=1)
   Buffers: shared read=8
   ->  Sort  (cost=90.93..94.28 rows=1339 width=15) (actual rows=1339 loops=1)
         Sort Key: seat_no
         Sort Method: quicksort  Memory: 101kB
         Buffers: shared read=8
         ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=15) (actual rows=1339
loops=1)
               Buffers: shared read=8
(8 rows)

Или внешнюю:

=> EXPLAIN (analyze, buffers, timing off, summary off)
SELECT *, sum(total_amount) OVER (ORDER BY book_date)
FROM bookings;
                                                 QUERY PLAN
------------------------------------------------------------------------------------------
-------------------
 WindowAgg  (cost=342915.67..379860.10 rows=2111110 width=53) (actual rows=2111110
loops=1)
   Buffers: shared hit=13447, temp read=16491 written=16528
   ->  Sort  (cost=342915.67..348193.45 rows=2111110 width=21) (actual rows=2111110
loops=1)
         Sort Key: book_date
         Sort Method: external merge  Disk: 65984kB
         Buffers: shared hit=13447, temp read=16491 written=16528
         ->  Seq Scan on bookings  (cost=0.00..34558.10 rows=2111110 width=21) (actual
rows=2111110 loops=1)
               Buffers: shared hit=13447
(8 rows)

Page 19: Итоги

Сортировка используется при выполнении запросов и для построения B-деревьев

  • Существуют разные способы реализации сортировки
    • в памяти
    • внешняя (создаются временные файлы)
    • инкрементальная
  • Наличие индексов может позволить избежать сортировки

Page 20: Практика

  1. Какой план будет выбран для следующего запроса: SELECT * FROM flights ORDER BY scheduled_departure; Изменится ли план выполнения, если увеличить значение work_mem до 32 Мбайт?
    1. Создайте индекс по столбцам passenger_name и passenger_id таблицы билетов (tickets). Потребовался ли временный файл для выполнения этой операции?

Комментарии преподавателя

  1. Включите журналирование использования временных файлов, установив значение параметра log_temp_files в ноль.

Page 21: 1. Выполнение запроса с сортировкой

Выполним запрос со значениями параметров по умолчанию:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT *
FROM flights
ORDER BY scheduled_departure;
                           QUERY PLAN
----------------------------------------------------------------
 Sort (actual rows=214867 loops=1)
   Sort Key: scheduled_departure
   Sort Method: external merge  Disk: 17112kB
   Buffers: shared hit=3 read=2624, temp read=2139 written=2145
   ->  Seq Scan on flights (actual rows=214867 loops=1)
         Buffers: shared read=2624
 Planning:
   Buffers: shared hit=105 read=22 dirtied=3
 Planning Time: 4.869 ms
 Execution Time: 257.229 ms
(10 rows)

Сервер выбрал внешнюю сортировку (Sort Method: external merge). Обратите внимание на количество страниц и тип ввода-вывода (Buffers): значения temp read и written говорят о том, что сервер использовал временные файлы. Выполните запрос повторно несколько раз — видно, что серверу всегда не хватает оперативной памяти. Увеличим значение параметра work_mem до 32 Мбайт:

=> SET work_mem = '32 MB';
SET

Увеличив work_mem, мы позволяем серверу использовать больше оперативной памяти для сортировки. Повторно выполним запрос и сравним планы выполнения:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT *
FROM flights
ORDER BY scheduled_departure;
                       QUERY PLAN
--------------------------------------------------------
 Sort (actual rows=214867 loops=1)
   Sort Key: scheduled_departure
   Sort Method: quicksort  Memory: 26161kB
   Buffers: shared hit=2624
   ->  Seq Scan on flights (actual rows=214867 loops=1)
         Buffers: shared hit=2624
 Planning Time: 0.064 ms
 Execution Time: 166.844 ms
(8 rows)

Теперь сервер использует сортировку в памяти (Sort Method: quicksort), в строке Buffers исчезли поля temp read и written.

2. Построение индекса

Включим журналирование временных файлов:

=> SET log_temp_files = 0;
SET

Текущее значение maintenance_work_mem:

=> SHOW maintenance_work_mem;
 maintenance_work_mem
----------------------
 64MB
(1 row)

Page 22: Page 22

Создаем индекс:

=> \timing on
Timing is on.
=> CREATE INDEX ON tickets(passenger_name, passenger_id);
CREATE INDEX
Time: 32769,532 ms (00:32,770)

Временный файл понадобился:

student$ tail -n 2 /var/log/postgresql/postgresql-16-main.log
2026-04-10 15:07:31.403 MSK [37559] postgres@demo LOG:  temporary file: path
"base/pgsql_tmp/pgsql_tmp37559.0.fileset/0.0", size 65101824
2026-04-10 15:07:31.403 MSK [37559] postgres@demo STATEMENT:  CREATE INDEX ON
tickets(passenger_name, passenger_id);

07 Занятие 07: Сортировка и группировка / Группировка

Исходная страница в нативном Markdown: Сортировка и группировка / Группировка
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_07_sort_group.html Archive: QPT-handouts-16.zip Страниц PDF: 23

Page 1: Сортировка и группировка / Группировка

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Применение группировки
  • Группировка хешированием
  • Группировка сортировкой
  • Комбинированная группировка
  • Группировка в параллельных планах
  • Оконные функции

Page 3: Применение группировки

Явное указание

  • GROUP BY
  • Исключение дубликатов
    • DISTINCT
    • UNION, INTERSECT, EXCEPT

Комментарии преподавателя

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

Группировка может выполняться и неявно при устранении дубликатов в запросе со словом DISTINCT или с командами обработки двух наборов строк (UNION, INTERSECT, EXCEPT без слова ALL), которые удаляют дубликаты.

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

Page 4: Применение группировки

Выполним запрос, явно группирующий строки таблицы seats по классам обслуживания:

=> EXPLAIN (costs off)
SELECT fare_conditions
FROM seats
GROUP BY fare_conditions;
          QUERY PLAN
------------------------------
 HashAggregate
   Group Key: fare_conditions
   ->  Seq Scan on seats
(3 rows)

Теперь получим все различные классы обслуживания:

=> EXPLAIN (costs off)
SELECT DISTINCT fare_conditions
FROM seats;
          QUERY PLAN
------------------------------
 HashAggregate
   Group Key: fare_conditions
   ->  Seq Scan on seats
(3 rows)

В третьем (исключительно учебном) примере дважды получим все строки таблицы seats и объединим их с помощью команды UNION:

=> EXPLAIN (costs off)
SELECT fare_conditions
FROM seats
UNION
SELECT fare_conditions
FROM seats;
              QUERY PLAN
---------------------------------------
 HashAggregate
   Group Key: seats.fare_conditions
   ->  Append
         ->  Seq Scan on seats
         ->  Seq Scan on seats seats_1
(5 rows)

Результаты всех трех запросов одинаковы, и во всех планах есть узел HashAggregate.

Page 5: Группировка хешированием

Source slide page 5: Группировка хешированием{#source-figure-005 caption=“Source slide page 5: Группировка хешированием”}

Комментарии преподавателя

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

В примере на слайде показано, как вычисляется значение агрегатной функции count при группировке по полю year. Сервер получает и по очереди обрабатывает строки таблицы.

Предварительно рассчитывается количество корзин — степень числа 2, и определяется число разрядов хеш-кода, содержащих номер корзины. В примере на слайде это два последних разряда.

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

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

В нашем примере первая строка порождает новую запись в корзине 00.

Page 6: Группировка хешированием

Source slide page 6: Группировка хешированием{#source-figure-006 caption=“Source slide page 6: Группировка хешированием”}

SELECT year, count(*) cnt
FROM albums
GROUP BY year;

Комментарии преподавателя

Вторая строка попадает в корзину 01 и порождает в ней новую запись.

Page 7: Группировка хешированием

Source slide page 7: Группировка хешированием{#source-figure-007 caption=“Source slide page 7: Группировка хешированием”}

SELECT year, count(*) cnt
FROM albums
GROUP BY year;

Комментарии преподавателя

Третья строка порождает новую запись в корзине 11.

Page 8: Группировка хешированием

Source slide page 8: Группировка хешированием{#source-figure-008 caption=“Source slide page 8: Группировка хешированием”}

SELECT year, count(*) cnt
FROM albums
GROUP BY year;

Комментарии преподавателя

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

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

Page 9: Группировка хешированием

Узел, который отвечает за агрегацию методом хеширования, обозначается в плане выполнения как HashAggregate:

=> EXPLAIN
SELECT aircraft_code, count(*)
FROM seats
GROUP BY aircraft_code;
                          QUERY PLAN
---------------------------------------------------------------
 HashAggregate  (cost=28.09..28.18 rows=9 width=12)
   Group Key: aircraft_code
   ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=4)
(3 rows)

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

=> EXPLAIN (analyze, timing off, costs off)
SELECT aircraft_code, count(*)
FROM seats
GROUP BY aircraft_code;
                     QUERY PLAN
----------------------------------------------------
 HashAggregate (actual rows=9 loops=1)
   Group Key: aircraft_code
   Batches: 1  Memory Usage: 24kB
   ->  Seq Scan on seats (actual rows=1339 loops=1)
 Planning Time: 0.069 ms
 Execution Time: 0.370 ms
(6 rows)

Page 10: Хеш-группировка и пакеты

Source slide page 10: Хеш-группировка и пакеты{#source-figure-010 caption=“Source slide page 10: Хеш-группировка и пакеты”}

Комментарии преподавателя

Если выделенной памяти не хватает, исходный набор разбивается на части (пакеты), так чтобы хеш-таблица для каждого пакета умещалась в оперативной памяти. Расчетное количество пакетов — степень числа 2; в хеш-коде выделяется нужное количество битов, которые определяют принадлежность записи пакету (на слайде два пакета, поэтому достаточно одного бита). Хеш-таблица для пакета 0 располагается в оперативной памяти, строки остальных пакетов помещаются во временные файлы — каждый пакет в свой файл.

Использование временных файлов значительно снижает производительность, и при хешировании этот эффект проявляется сильнее, чем для других алгоритмов. Поэтому при группировке хешированием выделяется work_mem × hash_mem_multiplier оперативной памяти — по умолчанию вдвое больше, чем обычно.

Page 11: Хеш-группировка и пакеты

Source slide page 11: Хеш-группировка и пакеты{#source-figure-011 caption=“Source slide page 11: Хеш-группировка и пакеты”}

Комментарии преподавателя

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

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

Page 12: Хеш-группировка и пакеты

Рассмотрим план другого запроса, с группировкой по большой таблице:

=> EXPLAIN
SELECT book_ref, count(*)
FROM tickets
GROUP BY book_ref;
                              QUERY PLAN
-----------------------------------------------------------------------
 HashAggregate  (cost=244868.67..281908.98 rows=1399153 width=15)
   Group Key: book_ref
   Planned Partitions: 16
   ->  Seq Scan on tickets  (cost=0.00..78917.44 rows=2950244 width=7)
(4 rows)

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

=> EXPLAIN (analyze, timing off, costs off)
SELECT book_ref, count(*)
FROM tickets
GROUP BY book_ref;
                        QUERY PLAN
----------------------------------------------------------
 HashAggregate (actual rows=2111110 loops=1)
   Group Key: book_ref
   Batches: 81  Memory Usage: 8337kB  Disk Usage: 80512kB
   ->  Seq Scan on tickets (actual rows=2949857 loops=1)
 Planning Time: 0.068 ms
 Execution Time: 6206.680 ms
(6 rows)

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

Page 13: Группировка сортировкой

Source slide page 13: Группировка сортировкой{#source-figure-013 caption=“Source slide page 13: Группировка сортировкой”}

SELECT DISTINCT year
FROM albums;
SELECT year, count(year)
FROM albums
GROUP BY year;

Комментарии преподавателя

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

Узел Unique устраняет дубликаты, добавляя к результату очередное значение, если оно не совпадает с предыдущим.

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

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

В отличие от HashAggregate, оба узла: Unique и GroupAggregate – возвращают упорядоченные данные.

Page 14: Группировка сортировкой

Пример плана с узлом GroupAggregate, в котором происходит группировка отсортированного набора:

=> EXPLAIN (costs off)
SELECT ticket_no, count(ticket_no)
FROM ticket_flights
GROUP BY ticket_no;
                            QUERY PLAN
-------------------------------------------------------------------
 GroupAggregate
   Group Key: ticket_no
   ->  Index Only Scan using ticket_flights_pkey on ticket_flights
(3 rows)

В этом запросе сортировку набора данных обеспечивает индексный доступ. Запрос с DISTINCT использует для устранения дубликатов узел Unique:

=> EXPLAIN (costs off)
SELECT DISTINCT ticket_no
FROM ticket_flights
ORDER BY ticket_no;
                               QUERY PLAN
-------------------------------------------------------------------------
 Result
   ->  Unique
         ->  Index Only Scan using ticket_flights_pkey on ticket_flights
(3 rows)

Поскольку узел возвращает результаты, упорядоченные так же, как требует предложение ORDER BY, дополнительная сортировка не требуется.

Комбинированная группировка

При группировке по нескольким наборам полей (которая возникает при использовании конструкций GROUPING SETS, CUBE или ROLLUP в предложении GROUP BY) может оказаться выгодным совместно использовать группировку хешированием и группировку сортировкой. В плане запроса такая комбинированная группировка отображается в виде узла MixedAggregate. В качестве примера рассмотрим запрос к таблице перелетов, в котором подсчитывается и объединяется количество строк для трех различных вариантов группировки:

=> EXPLAIN (costs off)
SELECT fare_conditions, ticket_no, amount, count(*)
FROM ticket_flights
GROUP BY
  GROUPING SETS (fare_conditions, ticket_no, amount);
               QUERY PLAN
----------------------------------------
 MixedAggregate
   Hash Key: amount
   Group Key: fare_conditions
   Sort Key: ticket_no
     Group Key: ticket_no
   ->  Sort
         Sort Key: fare_conditions
         ->  Seq Scan on ticket_flights
(8 rows)

Узел MixedAggregate получает набор данных, отсортированный по столбцу fare_conditions. На первом этапе в процессе чтения набора выполняется группировка по столбцу fare_conditions (Group Key). По мере чтения строки переупорядочиваются по столбцу ticket_no и одновременно с этим записываются в хеш-таблицу с ключом amount. На втором этапе сканируется набор строк, отсортированный на предыдущем этапе по столбцу ticket_no, и значения группируются по этому же столбцу (Sort Key и вложенный Group Key). Наконец, сканируется хеш-таблица, подготовленная на первом этапе, и значения группируются по столбцу amount (Hash

Page 15: Page 15

Key).

Page 16: В параллельных планах

Source slide page 16: В параллельных планах{#source-figure-016 caption=“Source slide page 16: В параллельных планах”}

Комментарии преподавателя

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

Пример такого плана показан на слайде слева. Обычно такой вариант выигрывает, если количество групп достаточно велико.

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

Пример такого плана показан на слайде справа.

Page 17: В параллельных планах

Сгруппируем строки таблицы перелетов по рейсам (их, очевидно, много):

=> EXPLAIN (analyze, timing off, costs off)
SELECT flight_id, count(*)
FROM ticket_flights
GROUP BY flight_id;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Finalize HashAggregate (actual rows=150588 loops=1)
   Group Key: flight_id
   Batches: 5  Memory Usage: 8241kB  Disk Usage: 7824kB
   ->  Gather (actual rows=439353 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial HashAggregate (actual rows=146451 loops=3)
               Group Key: flight_id
               Batches: 5  Memory Usage: 8241kB  Disk Usage: 27664kB
               Worker 0:  Batches: 5  Memory Usage: 8241kB  Disk Usage: 27824kB
               Worker 1:  Batches: 5  Memory Usage: 8241kB  Disk Usage: 27728kB
               ->  Parallel Seq Scan on ticket_flights (actual rows=2797284 loops=3)
 Planning Time: 0.113 ms
 Execution Time: 10904.675 ms
(14 rows)

Здесь каждый процесс в узле Partial HashAggregate вычисляет агрегатную функцию по своим данным с помощью собственной хеш-таблицы. Ведущий процесс после сбора данных в узле Gather получает итоговое значение агрегата, объединяя группы в узле Finalize HashAggregate тоже с помощью хеширования. Теперь сгруппируем строки таблицы перелетов по стоимости. Количество групп будет небольшим:

=> SELECT count(DISTINCT amount) FROM ticket_flights;
 count
-------
   338
(1 row)

Как будет выполняться такой запрос?

=> EXPLAIN (analyze, timing off, costs off)
SELECT amount, count(*)
FROM ticket_flights
GROUP BY amount;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
-
 Finalize GroupAggregate (actual rows=338 loops=1)
   Group Key: amount
   ->  Gather Merge (actual rows=1000 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Sort (actual rows=333 loops=3)
               Sort Key: amount
               Sort Method: quicksort  Memory: 38kB
               Worker 0:  Sort Method: quicksort  Memory: 38kB
               Worker 1:  Sort Method: quicksort  Memory: 37kB
               ->  Partial HashAggregate (actual rows=333 loops=3)
                     Group Key: amount
                     Batches: 1  Memory Usage: 61kB
                     Worker 0:  Batches: 1  Memory Usage: 61kB
                     Worker 1:  Batches: 1  Memory Usage: 61kB
                     ->  Parallel Seq Scan on ticket_flights (actual rows=2797284 loops=3)
 Planning Time: 0.246 ms
 Execution Time: 7080.126 ms
(18 rows)

Поскольку групп немного, оказывается выгодным отсортировать результаты в каждом процессе (Sort), собрать их в ведущем процессе в один отсортированный набор (Gather Merge), а затем, объединяя группы, вычислить агрегатную

Page 18: Page 18

функцию (Finalize GroupAggregate).

Page 19: Оконные функции

Source slide page 19: Оконные функции{#source-figure-019 caption=“Source slide page 19: Оконные функции”}

Группировка всегда использует сортировку

OVER (PARTITION BY)
OVER (PARTITION BY ORDER BY)
 1 2 3 4 5          1 2 3 4 5
 1 2 3 4 5          1 2 3 4 5
 1 2 3 4 5          1 2 3 4 5
 1 2 3 4 5          1 2 3 4 5
 1 2 3 4 5          1 2 3 4 5

Комментарии преподавателя

Группировка используется и в оконных функциях.

Если определение окна включает конструкцию PARTITION BY, оконная функция вычисляется на основе групп строк (аналогично группировке GROUP BY).

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

Как и в примере из темы «Сортировка», указание ORDER BY упорядочивает строки в каждой группе; в этом случае предполагается, что рамка охватывает строки от первой до текущей.

В отличие от обычной группировки GROUP BY, группировка в определении окна всегда реализуется с помощью сортировки: план строится так, чтобы на вход узлу WindowAgg пришел набор строк, отсортированный сначала по ключам группировки PARTITION BY, а затем — по ключам сортировки ORDER BY. Хеширование не применяется даже в отсутствие конструкции ORDER BY.

Page 20: Итоги

Группировка происходит не только при агрегации, но и при устранении дубликатов

  • Выполняется с помощью хеширования или сортировки

Page 21: Практика

  1. Как выполняется запрос, вычисляющий количество мест каждой категории в таблице seats? Попробуйте со значениями параметров по умолчанию, а затем запретите группировку хешированием. 2. Изучите запрос с оконной функцией и предложением PARTITION BY: SELECT status, count(*) OVER (PARTITION BY status) FROM flights WHERE flight_no = ‘PG0007’ AND departure_airport = ‘VKO’ AND flight_id BETWEEN 24104 AND 24115;
  • Добавьте вызов функции row_number с тем же окном
  • и сравните результаты и планы выполнения.

Комментарии преподавателя

  1. Запрос: SELECT fare_conditions, count(*) FROM seats GROUP BY fare_conditions;

Для запрета группировки хешированием установите параметр enable_hashagg в off.

Page 22: 1. Разные способы группировки

Сначала выполним запрос со значениями параметров по умолчанию:

=> EXPLAIN
SELECT fare_conditions, count(*)
FROM seats
GROUP BY fare_conditions;
                          QUERY PLAN
---------------------------------------------------------------
 HashAggregate  (cost=28.09..28.12 rows=3 width=16)
   Group Key: fare_conditions
   ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=8)
(3 rows)

Для группировки используется узел HashAggregate. Запретим группировку хешированием:

=> SET enable_hashagg = off;
SET

Повторно выполним запрос и сравним планы выполнения:

=> EXPLAIN
SELECT fare_conditions, count(*)
FROM seats
GROUP BY fare_conditions;
                             QUERY PLAN
---------------------------------------------------------------------
 GroupAggregate  (cost=90.93..101.00 rows=3 width=16)
   Group Key: fare_conditions
   ->  Sort  (cost=90.93..94.28 rows=1339 width=8)
         Sort Key: fare_conditions
         ->  Seq Scan on seats  (cost=0.00..21.39 rows=1339 width=8)
(5 rows)

Теперь остается только вариант с узлом GroupAggregate, а для него приходится сортировать данные в узле Sort. Вернем значение по умолчанию:

=> RESET enable_hashagg;
RESET

2. Оконные функции и PARTITION BY

Приведенный запрос возвращает количество элементов в каждой группе:

=> SELECT status, count(*) OVER (PARTITION BY status)
FROM flights
WHERE flight_no = 'PG0007'
AND departure_airport = 'VKO'
AND flight_id BETWEEN 24104 AND 24115;
  status   | count
-----------+-------
 Arrived   |     4
 Arrived   |     4
 Arrived   |     4
 Arrived   |     4
 Scheduled |     2
 Scheduled |     2
(6 rows)

В плане выполнения видно, что группировка обеспечивается тем же узлом WindowAgg, который мы видели при использовании конструкции ORDER BY в определении окна:

Page 23: Page 23

=> EXPLAIN (costs off)
SELECT status, count(*) OVER (PARTITION BY status)
FROM flights
WHERE flight_no = 'PG0007'
AND departure_airport = 'VKO'
AND flight_id BETWEEN 24104 AND 24115;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 WindowAgg
   ->  Sort
         Sort Key: status
         ->  Index Scan using flights_pkey on flights
               Index Cond: ((flight_id >= 24104) AND (flight_id <= 24115))
               Filter: ((flight_no = 'PG0007'::bpchar) AND (departure_airport =
'VKO'::bpchar))
(6 rows)

Добавим номер текущей строки в ее группе с помощью функции row_number():

=> SELECT status,
  count(*) OVER (PARTITION BY status),
  row_number() OVER (PARTITION BY status)
FROM flights
WHERE flight_no = 'PG0007'
AND departure_airport = 'VKO'
AND flight_id BETWEEN 24104 AND 24115;
  status   | count | row_number
-----------+-------+------------
 Arrived   |     4 |          1
 Arrived   |     4 |          2
 Arrived   |     4 |          3
 Arrived   |     4 |          4
 Scheduled |     2 |          1
 Scheduled |     2 |          2
(6 rows)

Добавление новой функции с совпадающим окном не порождает новые узлы в плане:

=> EXPLAIN (costs off)
SELECT status,
  count(*) OVER (PARTITION BY status),
  row_number() OVER (PARTITION BY status)
FROM flights
WHERE flight_no = 'PG0007'
AND departure_airport = 'VKO'
AND flight_id BETWEEN 24104 AND 24115;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 WindowAgg
   ->  Sort
         Sort Key: status
         ->  Index Scan using flights_pkey on flights
               Index Cond: ((flight_id >= 24104) AND (flight_id <= 24115))
               Filter: ((flight_no = 'PG0007'::bpchar) AND (departure_airport =
'VKO'::bpchar))
(6 rows)

08 Занятие 08: Способы соединения / Соединение вложенным циклом

Исходная страница в нативном Markdown: Способы соединения / Соединение вложенным циклом
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_08_join_nestloop.html Archive: QPT-handouts-16.zip Страниц PDF: 27

Page 1: Способы соединения / Соединение вложенным циклом

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

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

Page 3: Соединения

Способы соединения — не соединения SQL

  • inner/left/right/full/cross join, in, exists — логические операции
  • способы соединения — механизм реализации
  • Соединяются не таблицы, а наборы строк
    • могут быть получены от любого узла дерева плана
  • Наборы строк соединяются попарно
    • порядок соединений важен с точки зрения производительности
    • обычно важен и порядок внутри пары

Комментарии преподавателя

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

Способы соединения представляют собой алгоритмы для соединения двух наборов строк. С их помощью реализуются и другие конструкции языка SQL, например, EXISTS. Не стоит путать одно с другим: SQL-соединения — это логические операции над двумя множествами; способы соединения PostgreSQL — это возможные реализации таких соединений, учитывающие производительность.

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

Наконец, наборы строк всегда соединяются попарно. Порядок, в котором соединяются таблицы, в большинстве случаев не влияет на результат запроса (например, a join b join c или b join c join a), но очень сильно влияет на производительность. Как мы увидим дальше, важен и порядок, в котором соединяются два набора строк (a join b или b join a).

Page 4: Nested Loop

Source slide page 4: Nested Loop{#source-figure-004 caption=“Source slide page 4: Nested Loop”}

Для каждой строки одного набора перебираем подходящие строки другого набора

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Начнем с соединения вложенными циклами (nested loop), как с самого простого. Его алгоритм таков: для каждой строки одного из наборов перебираем и возвращаем соответствующие ему строки второго набора. По сути, это два вложенных цикла, отсюда и название способа.

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

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

Page 5: Nested Loop

Source slide page 5: Nested Loop{#source-figure-005 caption=“Source slide page 5: Nested Loop”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Рисунки иллюстрируют этот способ соединения. На них: • строки, к которым ранее уже был доступ, показаны серым; • строки, доступ к которым выполняется на текущем шаге, выделены цветом; • оранжевым контуром выделены строки, составляющие пару, подходящую по условию соединения (в данном примере — по равенству числовых идентификаторов).

Сначала мы читаем первую строку первого набора и находим ее пару во втором наборе. Соответствие нашлось, и у нас уже есть первая строка результата, которую можно вернуть вышестоящему узлу плана: («Let It Be», «Across the Universe»).

Page 6: Nested Loop

Source slide page 6: Nested Loop{#source-figure-006 caption=“Source slide page 6: Nested Loop”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Читаем вторую строку первого набора.

Для нее тоже перебираем пары из второго набора. Сначала возвращаем («Yellow Submarine», «All Together Now»)…

Page 7: Nested Loop

Source slide page 7: Nested Loop{#source-figure-007 caption=“Source slide page 7: Nested Loop”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

…затем вторую пару («Yellow Submarine», «All You Need Is Love»).

Page 8: Nested Loop

Source slide page 8: Nested Loop{#source-figure-008 caption=“Source slide page 8: Nested Loop”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Переходим к третьей строке первого набора. Для нее соответствий нет.

Page 9: Nested Loop

Source slide page 9: Nested Loop{#source-figure-009 caption=“Source slide page 9: Nested Loop”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Для четвертой строки тоже нет соответствий. На этом работа соединения заканчивается.

Часть строк второго набора мы вообще не рассматривали — на рисунке они остались белыми.

(С исходным кодом алгоритма можно ознакомиться в файле src/backend/executor/nodeNestloop.c.)

Page 10: Соединение вложенным циклом

Так выглядит план для соединения вложенным циклом, которое оптимизатор обычно предпочитает для небольших выборок (смотрим перелеты, включенные в два билета):

=> EXPLAIN (costs off) SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.ticket_no IN ('0005432312163','0005432312164');
                                    QUERY PLAN
-----------------------------------------------------------------------------------
 Nested Loop
   ->  Index Scan using tickets_pkey on tickets t
         Index Cond: (ticket_no = ANY ('{0005432312163,0005432312164}'::bpchar[]))
   ->  Index Scan using ticket_flights_pkey on ticket_flights tf
         Index Cond: (ticket_no = t.ticket_no)
(5 rows)

Планировщик использует параметризованное соединение. Для каждой строки внешнего набора (узел Index Scan по таблице билетов) выбираются строки из внутреннего набора (узел Index Scan по таблице перелетов), отвечающие условию соединения. На каждой итерации внешнего цикла условие индексного доступа для внутреннего набора имеет вид ticket_no = константа. Процесс повторяется до тех пор, пока внешний набор не исчерпает все строки. Команда EXPLAIN ANALYZE позволяет узнать, сколько раз на самом деле выполнялся вложенный цикл (loops), сколько в среднем было выбрано строк (rows) и сколько потрачено времени (time) за один раз. Видно, что планировщик немного ошибся — в итоге получилось 8 строк вместо 6:

=> EXPLAIN (analyze, summary off)
SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.ticket_no IN ('0005432312163','0005432312164');
                                                                  QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------------------------------
 Nested Loop  (cost=0.99..46.11 rows=6 width=136) (actual time=15.454..23.118 rows=8
loops=1)
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..12.90 rows=2 width=104)
(actual time=12.763..12.797 rows=2 loops=1)
         Index Cond: (ticket_no = ANY ('{0005432312163,0005432312164}'::bpchar[]))
   ->  Index Scan using ticket_flights_pkey on ticket_flights tf  (cost=0.56..16.58
rows=3 width=32) (actual time=1.870..5.147 rows=4 loops=2)
         Index Cond: (ticket_no = t.ticket_no)
(5 rows)

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

=> EXPLAIN (costs off) SELECT *
FROM ( VALUES (1000), (10000) ) d(range)
  JOIN aircrafts a ON a.range >= d.range;
                   QUERY PLAN
-------------------------------------------------
 Nested Loop
   Join Filter: (ml.range >= "*VALUES*".column1)
   ->  Seq Scan on aircrafts_data ml
   ->  Materialize
         ->  Values Scan on "*VALUES*"
(5 rows)

Это непараметризованное соединение: для каждой строки внешнего набора придется читать весь внутренний набор (про узел Materialize мы будем говорить позже; в данном случае это просто значения из VALUES) и для полученных пар строк проверять условие Join Filter. По сути, это декартово произведение двух наборов строк с фильтрацией. Обратите внимание, что соединение вложенным циклом позволяет соединять строки по любому условию, не обязательно по равенству значений.

Page 11: Модификации

Существует несколько модификаций алгоритма. Для левого соединения:

=> EXPLAIN (costs off) SELECT *
FROM aircrafts a
  LEFT JOIN seats s ON (a.aircraft_code = s.aircraft_code)
WHERE a.model LIKE 'Аэробус%';
                          QUERY PLAN
--------------------------------------------------------------
 Nested Loop Left Join
   ->  Seq Scan on aircrafts_data ml
         Filter: ((model ->> lang()) ~~ 'Аэробус%'::text)
   ->  Bitmap Heap Scan on seats s
         Recheck Cond: (ml.aircraft_code = aircraft_code)
         ->  Bitmap Index Scan on seats_pkey
               Index Cond: (aircraft_code = ml.aircraft_code)
(7 rows)

Эта модификация возвращает строки, даже если для левого (a) набора строк не нашлось соответствия в правом (s) наборе. Антисоединение возвращает те строки одного набора, для которых не нашлось соответствия в другом наборе. Такая модификация может использоваться для обработки предиката NOT EXISTS:

=> EXPLAIN (costs off) SELECT *
FROM aircrafts a
WHERE a.model LIKE 'Аэробус%'
AND NOT EXISTS (
  SELECT * FROM seats s WHERE s.aircraft_code = a.aircraft_code
);
                        QUERY PLAN
----------------------------------------------------------
 Nested Loop Anti Join
   ->  Seq Scan on aircrafts_data ml
         Filter: ((model ->> lang()) ~~ 'Аэробус%'::text)
   ->  Index Only Scan using seats_pkey on seats s
         Index Cond: (aircraft_code = ml.aircraft_code)
(5 rows)

Та же операция антисоединения используется и для аналогичного запроса, записанного иначе:

=> EXPLAIN (costs off) SELECT *
FROM aircrafts a
  LEFT JOIN seats s ON (a.aircraft_code = s.aircraft_code)
WHERE a.model LIKE 'Аэробус%'
AND s.aircraft_code IS NULL;
                        QUERY PLAN
----------------------------------------------------------
 Nested Loop Anti Join
   ->  Seq Scan on aircrafts_data ml
         Filter: ((model ->> lang()) ~~ 'Аэробус%'::text)
   ->  Index Scan using seats_pkey on seats s
         Index Cond: (aircraft_code = ml.aircraft_code)
(5 rows)

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

=> EXPLAIN SELECT *
FROM aircrafts a
WHERE a.model LIKE 'Аэробус%'
AND EXISTS (
  SELECT * FROM seats s WHERE s.aircraft_code = a.aircraft_code
);

Page 12: Page 12

                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Nested Loop Semi Join  (cost=0.28..4.02 rows=1 width=40)
   ->  Seq Scan on aircrafts_data ml  (cost=0.00..3.39 rows=1 width=72)
         Filter: ((model ->> lang()) ~~ 'Аэробус%'::text)
   ->  Index Only Scan using seats_pkey on seats s  (cost=0.28..6.88 rows=149 width=4)
         Index Cond: (aircraft_code = ml.aircraft_code)
(5 rows)

Обратите внимание: хотя в плане для таблицы s указано rows=149, на самом деле достаточно получить всего одну строку, чтобы понять значение предиката EXISTS. PostgreSQL так и делает (actual rows=1):

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT *
FROM aircrafts a
WHERE a.model LIKE 'Аэробус%'
AND EXISTS (
  SELECT * FROM seats s WHERE s.aircraft_code = a.aircraft_code
);
                                QUERY PLAN
---------------------------------------------------------------------------
 Nested Loop Semi Join (actual rows=3 loops=1)
   ->  Seq Scan on aircrafts_data ml (actual rows=3 loops=1)
         Filter: ((model ->> lang()) ~~ 'Аэробус%'::text)
         Rows Removed by Filter: 6
   ->  Index Only Scan using seats_pkey on seats s (actual rows=1 loops=3)
         Index Cond: (aircraft_code = ml.aircraft_code)
         Heap Fetches: 0
(7 rows)

Модификаций алгоритма вложенного цикла для правого (RIGHT) и полного (FULL) соединений не существует. Это связано с тем, что полный проход по второму набору строк может не выполняться.

Page 13: Мемоизация

Source slide page 13: Мемоизация{#source-figure-013 caption=“Source slide page 13: Мемоизация”}

Кеширование повторяющихся данных внутреннего набора

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Если внутренний набор сканируется много раз с повторяющимися значениями параметров, иногда имеет смысл закешировать результат, чтобы не читать много раз одни и те же данные. Эта операция называется мемоизацией. Она выполняется в узле Memoize, который встраивается между Nested Loop и узлом, поставляющим данные.

(Если планировщик ошибается в своих расчетах, мемоизацию можно отключить, установив параметр enable_memoize в значение off.)

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

Page 14: Мемоизация

Source slide page 14: Мемоизация{#source-figure-014 caption=“Source slide page 14: Мемоизация”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

album_id

Комментарии преподавателя

Рассмотрим пример. В отличие от предыдущего, здесь песен меньше, чем альбомов; к тому же, почти все они относятся к одному и тому же альбому.

Если необходимой строки нет в хеш-таблице, узел Memoize читает ее из внутреннего набора, сохраняет в кеше и возвращает родительскому узлу Nested Loop.

В общем случае значению параметра могут соответствовать несколько строк из внутреннего набора — будут закешированы все эти строки. Если все они не помещаются в память (которая ограничена размером work_mem × hash_mem_multiplier), значение параметра игнорируется, поскольку кешировать только часть строк нет смысла. В плане запроса количество таких ситуаций будет показано как overflow.

Page 15: Мемоизация

Source slide page 15: Мемоизация{#source-figure-015 caption=“Source slide page 15: Мемоизация”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

album_id

Комментарии преподавателя

Если необходимая строка уже есть в кеше, узел Memoize сразу возвращает ее узлу Nested Loop. Обращения к внутреннему набору не происходит.

Page 16: Мемоизация

Source slide page 16: Мемоизация{#source-figure-016 caption=“Source slide page 16: Мемоизация”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

album_id

Комментарии преподавателя

Пока в кеше хватает места, новые значения продолжают кешироваться. При этом новое значение добавляется в начало кеша.

Page 17: Мемоизация

Source slide page 17: Мемоизация{#source-figure-017 caption=“Source slide page 17: Мемоизация”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

album_id

Комментарии преподавателя

Уже закешированное значение «всплывает наверх», когда к нему обращаются, а остальные, соответственно, опускаются вниз.

Page 18: Мемоизация

Source slide page 18: Мемоизация{#source-figure-018 caption=“Source slide page 18: Мемоизация”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

album_id

Комментарии преподавателя

Если память заканчивается, из кеша удаляются «нижние» строки, к которым дольше всего не было обращений. Таким образом реализуется алгоритм вытеснения LRU.

Page 19: Мемоизация

Посмотрим план запроса, в котором соединяются таблицы перелетов и модели самолетов. Пример подобран таким образом, что из внутреннего набора (таблица aircrafts_data) будет получена только одна строка по ключу f.aircraft_code (Cache Key), она и будет закеширована в узле Memoize:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT * FROM flights f
  JOIN aircrafts_data a ON f.aircraft_code = a.aircraft_code
WHERE f.flight_no = 'PG0003';
                                              QUERY PLAN
------------------------------------------------------------------------------------------
------------
 Nested Loop (actual rows=113 loops=1)
   ->  Bitmap Heap Scan on flights f (actual rows=113 loops=1)
         Recheck Cond: (flight_no = 'PG0003'::bpchar)
         Heap Blocks: exact=2
         ->  Bitmap Index Scan on flights_flight_no_scheduled_departure_key (actual
rows=113 loops=1)
               Index Cond: (flight_no = 'PG0003'::bpchar)
   ->  Memoize (actual rows=1 loops=113)
         Cache Key: f.aircraft_code
         Cache Mode: logical
         Hits: 112  Misses: 1  Evictions: 0  Overflows: 0  Memory Usage: 1kB
         ->  Index Scan using aircrafts_pkey on aircrafts_data a (actual rows=1 loops=1)
               Index Cond: (aircraft_code = f.aircraft_code)
(12 rows)

Обратите внимание: в первый раз за нужной строкой приходится сходить в таблицу (Misses: 1); все повторные обращения обслуживаются кешем (Hits: 112), для этого хватило одного килобайта памяти; вытеснений из кеша не было (Evictions: 0); строки, выбранные из внутреннего набора, всегда умещались в выделенную память (Overflows: 0).

Page 20: Вычислительная сложность

~ N × M, где

  • N — число строк во внешнем наборе данных,
  • M — среднее число строк внутреннего набора,
  • приходящееся на одну итерацию
  • Соединение эффективно только для небольшого числа строк

Комментарии преподавателя

Если принять за N число строк во внешнем наборе данных, а за M — среднее число строк внутреннего набора, приходящееся на одну итерацию, то общая сложность соединения будет пропорциональна произведению N × M.

Для непараметризованного соединения M будет в точности равно числу строк внутреннего набора; для параметризованного — M может быть значительно меньше.

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

Page 21: Стоимость соединения вложенным циклом

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

=> EXPLAIN SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.ticket_no IN ('0005432312163','0005432312164');
                                             QUERY PLAN
------------------------------------------------------------------------------------------
-----------
 Nested Loop  (cost=0.99..46.11 rows=6 width=136)
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..12.90 rows=2 width=104)
         Index Cond: (ticket_no = ANY ('{0005432312163,0005432312164}'::bpchar[]))
   ->  Index Scan using ticket_flights_pkey on ticket_flights tf  (cost=0.56..16.58
rows=3 width=32)
         Index Cond: (ticket_no = t.ticket_no)
(5 rows)

Первый результат выдается сразу, без предварительных действий, поэтому начальная стоимость узла Nested Loop равна сумме начальных стоимостей дочерних узлов Index Scan. Полная стоимость узла Nested Loop складывается из: стоимости получения данных от внешнего набора (полная стоимость узла Index Scan по билетам); стоимости получения данных от внутреннего набора (полная стоимость узла Index Scan по перелетам), умноженной на расчетное число строк внешнего набора (2); стоимости процессорной обработки строк. В общем случае формула более сложная, но основной вывод: стоимость пропорциональна N × M, где N — число строк во внешнем наборе данных, а M — среднее число строк внутреннего набора, читаемое за одну итерацию. В худшем случае стоимость получается квадратичной.

Page 22: В параллельных планах

Source slide page 22: В параллельных планах{#source-figure-022 caption=“Source slide page 22: В параллельных планах”}

Внешний набор строк сканируется параллельно, внутренний — последовательно каждым процессом

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Соединение вложенным циклом может использоваться в параллельных планах.

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

Page 23: В параллельных планах

Source slide page 23: В параллельных планах{#source-figure-023 caption=“Source slide page 23: В параллельных планах”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

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

Page 24: Вложенный цикл в параллельных планах

Найдем всех пассажиров, купивших билеты на определенный рейс:

=> EXPLAIN (costs off)
SELECT t.passenger_name
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE f.flight_id = 12345;
                          QUERY PLAN
--------------------------------------------------------------
 Nested Loop
   ->  Index Only Scan using flights_pkey on flights f
         Index Cond: (flight_id = 12345)
   ->  Gather
         Workers Planned: 2
         ->  Nested Loop
               ->  Parallel Seq Scan on ticket_flights tf
                     Filter: (flight_id = 12345)
               ->  Index Scan using tickets_pkey on tickets t
                     Index Cond: (ticket_no = tf.ticket_no)
(10 rows)

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

Page 25: Итоги

Вложенный цикл не требует подготовительных действий

  • может отдавать результат соединения без задержек
  • Эффективен для небольших выборок
    • внешний набор строк не очень велик
    • к внутреннему есть эффективный доступ (обычно по индексу)
  • Зависит от порядка соединения
    • обычно лучше, если внешний набор меньше внутреннего
  • Поддерживает соединение по любому условию
    • как эквисоединения, так и любые другие

Комментарии преподавателя

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

Обратная сторона состоит в том, что этот способ крайне неэффективен для больших объемов данных. Ситуация та же, что и с индексами: чем больше выборка, тем больше накладных расходов.

Таким образом, соединение вложенными циклами имеет смысл применять, если:

● один из наборов строк небольшой;

● к другому набору есть эффективный доступ по условию соединения;

● общее количество строк результата невелико.

Это обычная ситуация для OLTP-запросов (например, запросов от пользовательского интерфейса, где веб-страница или экранная форма должны открыться быстро и не выводят большой объем информации).

Еще одна особенность, которую стоит отметить: соединение вложенными циклами может работать для любого условия соединения. Подходит как эквисоединение (по условию равенства, как в примере), так и любое другое.

Page 26: Практика

  1. Создайте индекс на таблице рейсов (flights) по аэропортам отправления (departure_airport). Найдите все рейсы из Ульяновска и проверьте план выполнения запроса. 2. Постройте таблицу расстояний между всеми аэропортами (так, чтобы каждая пара встречалась только один раз). Какой способ соединения используется в таком запросе?

Комментарии преподавателя

  1. Используйте оператор <@> из расширения earthdistance.

Page 27: 1. Рейсы из Ульяновска

Индекс на таблице рейсов:

=> CREATE INDEX ON flights(departure_airport);
CREATE INDEX

План запроса:

=> EXPLAIN SELECT *
  FROM flights f JOIN airports a ON a.airport_code = f.departure_airport
  WHERE a.city = 'Ульяновск';
                                              QUERY PLAN
------------------------------------------------------------------------------------------
------------
 Nested Loop  (cost=24.31..3732.03 rows=2066 width=162)
   ->  Seq Scan on airports_data ml  (cost=0.00..30.56 rows=1 width=145)
         Filter: ((city ->> lang()) = 'Ульяновск'::text)
   ->  Bitmap Heap Scan on flights f  (cost=24.31..2637.48 rows=2066 width=63)
         Recheck Cond: (departure_airport = ml.airport_code)
         ->  Bitmap Index Scan on flights_departure_airport_idx  (cost=0.00..23.79
rows=2066 width=0)
               Index Cond: (departure_airport = ml.airport_code)
(7 rows)

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

=> SELECT airport_code, airport_name FROM airports WHERE city = 'Ульяновск';
 airport_code |    airport_name
--------------+---------------------
 ULY          | Ульяновск-Восточный
 ULV          | Баратаевка
(2 rows)

2. Таблица расстояний между аэропортами

=> CREATE EXTENSION earthdistance CASCADE;
NOTICE:  installing required extension "cube"
CREATE EXTENSION

Чтобы отсечь повторяющиеся пары, можно соединить таблицы по условию «больше»:

=> EXPLAIN SELECT a1.airport_code "from",
      a2.airport_code "to",
      a1.coordinates <@> a2.coordinates "distance, miles"
  FROM airports a1 JOIN airports a2 ON a1.airport_code > a2.airport_code;
                                             QUERY PLAN
------------------------------------------------------------------------------------------
-----------
 Nested Loop  (cost=0.14..147.97 rows=3605 width=16)
   ->  Seq Scan on airports_data ml  (cost=0.00..4.04 rows=104 width=20)
   ->  Index Scan using airports_data_pkey on airports_data ml_1  (cost=0.14..0.95
rows=35 width=20)
         Index Cond: (airport_code < ml.airport_code)
(4 rows)

Вложенный цикл — единственный способ соединения для такого условия.

09 Занятие 09: Способы соединения / Соединение хешированием

Исходная страница в нативном Markdown: Способы соединения / Соединение хешированием
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_09_join_hashjoin.html Archive: QPT-handouts-16.zip Страниц PDF: 36

Page 1: Способы соединения / Соединение хешированием

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Последовательное соединение хешированием: одно-и двухпроходное
  • Вычислительная сложность
  • Параллельное соединение хешированием: одно-и двухпроходное

Page 3: Хеш-соединение

Source slide page 3: Хеш-соединение{#source-figure-003 caption=“Source slide page 3: Хеш-соединение”}

По строкам одного набора строится хеш-таблица, строки другого набора сопоставляются с ней

  • Только эквисоединения

Комментарии преподавателя

Основную идею хеширования мы рассмотрели в темах «Типы индексов» и «Группировка».

Для соединения хеширование применяется следующим образом. Сначала по одному из наборов в узле Hash строится хеш-таблица. Затем в узле Hash Join строки другого набора сопоставляются с построенной таблицей.

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

Рассмотрим на примере, как происходит соединение.

Page 4: Однопроходное соединение

Применяется, когда для хеш-таблицы достаточно оперативной памяти

Page 5: Построение хеш-таблицы

Source slide page 5: Построение хеш-таблицы{#source-figure-005 caption=“Source slide page 5: Построение хеш-таблицы”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Первым этапом в памяти строится хеш-таблица.

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

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

Размер хеш-таблицы в памяти ограничен значением work_mem × × hash_mem_multiplier. Наилучшая эффективность достигается, если вся хеш-таблица помещается в этот объем памяти целиком. Поэтому планировщик обычно выбирает в качестве внутреннего меньший из двух наборов строк. Также стоит не использовать в запросе лишние поля, в том числе «звездочку», чтобы не перегружать хеш-таблицу ненужной информацией.

Page 6: Сопоставление

Source slide page 6: Сопоставление{#source-figure-006 caption=“Source slide page 6: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

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

  1. с таким же хеш-кодом,

  2. и со значениями полей, подходящими под условие соединения,

то мы нашли пару.

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

В нашем примере для первой строки соответствия нет.

Page 7: Сопоставление

Source slide page 7: Сопоставление{#source-figure-007 caption=“Source slide page 7: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Вторая строка второго набора дает соответствие, которое уже можно вернуть вышестоящему узлу плана: («Yellow Submarine», «All Together Now»).

Page 8: Сопоставление

Source slide page 8: Сопоставление{#source-figure-008 caption=“Source slide page 8: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Для третьей строки соответствия нет (соответствующая корзина хеш-таблицы пуста).

Page 9: Сопоставление

Source slide page 9: Сопоставление{#source-figure-009 caption=“Source slide page 9: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Для четвертой получаем соответствие («Let It Be», «Across the Universe»).

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

Page 10: Сопоставление

Source slide page 10: Сопоставление{#source-figure-010 caption=“Source slide page 10: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;
SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Для пятой строки получаем соответствие («Yellow Submarine», «All You Need Is Love»).

Page 11: Сопоставление

Source slide page 11: Сопоставление{#source-figure-011 caption=“Source slide page 11: Сопоставление”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Для шестой строки соответствия нет. На этом работа соединения завершена.

Исходный код алгоритма можно найти в файле src/backend/executor/nodeHashjoin.c

Page 12: Однопроходное соединение хешированием

При низкой селективности оптимизатор предпочитает соединение хешированием:

=> EXPLAIN (costs off)
SELECT *
FROM aircrafts a
  JOIN seats s ON a.aircraft_code = s.aircraft_code;
                    QUERY PLAN
---------------------------------------------------
 Hash Join
   Hash Cond: (s.aircraft_code = ml.aircraft_code)
   ->  Seq Scan on seats s
   ->  Hash
         ->  Seq Scan on aircrafts_data ml
(5 rows)

Узел Hash Join начинает работу с того, что обращается к дочернему узлу Hash. Тот получает от своего дочернего узла (здесь — Seq Scan) весь набор строк и строит хеш-таблицу. Затем Hash Join обращается ко второму дочернему узлу и соединяет строки, постепенно возвращая полученные результаты. Проверим, как выполняется этот запрос:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT *
FROM aircrafts a
  JOIN seats s ON a.aircraft_code = s.aircraft_code;
                            QUERY PLAN
-------------------------------------------------------------------
 Hash Join (actual rows=1339 loops=1)
   Hash Cond: (s.aircraft_code = ml.aircraft_code)
   ->  Seq Scan on seats s (actual rows=1339 loops=1)
   ->  Hash (actual rows=9 loops=1)
         Buckets: 1024  Batches: 1  Memory Usage: 9kB
         ->  Seq Scan on aircrafts_data ml (actual rows=9 loops=1)
(6 rows)

Хеш-таблица поместилась в память (Batches: 1). Параметр Buckets показывает число корзин в хеш-таблице, а Memory Usage — объем использованной оперативной памяти. Обратите внимание, что хеш-таблица строилась по меньшему набору строк. Модификации Hash Join включают уже рассмотренные ранее Left, Semi и Anti, а также Right для правого и Full для полного соединений:

=> EXPLAIN (costs off)
SELECT *
FROM aircrafts a
  FULL JOIN seats s ON a.aircraft_code = s.aircraft_code;
                    QUERY PLAN
---------------------------------------------------
 Hash Full Join
   Hash Cond: (s.aircraft_code = ml.aircraft_code)
   ->  Seq Scan on seats s
   ->  Hash
         ->  Seq Scan on aircrafts_data ml
(5 rows)
=> EXPLAIN (costs off)
SELECT *
FROM seats s
  RIGHT JOIN aircrafts a ON a.aircraft_code = s.aircraft_code;

Page 13: Page 13

                    QUERY PLAN
---------------------------------------------------
 Hash Right Join
   Hash Cond: (s.aircraft_code = ml.aircraft_code)
   ->  Seq Scan on seats s
   ->  Hash
         ->  Seq Scan on aircrafts_data ml
(5 rows)

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

Page 14: Двухпроходное соединение

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

Page 15: Построение хеш-таблицы

Source slide page 15: Построение хеш-таблицы{#source-figure-015 caption=“Source slide page 15: Построение хеш-таблицы”}

Комментарии преподавателя

Если хеш-таблица не помещается в объем памяти, ограниченный work_mem × hash_mem_multiplier, первый (внутренний) набор строк разбивается на отдельные пакеты. Для распределения по пакетам используется некоторое количество битов хеш-кода, поэтому число пакетов всегда является степенью числа 2. В идеале в каждый пакет попадает примерно одинаковое количество строк, но, если значения в строках повторяются, возможен перекос.

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

Хеш-таблица для первого пакета остается в памяти, а строки, принадлежащие другим пакетам, сбрасываются на диск во временные файлы — каждый пакет в свой файл.

На рисунке показано четыре пакета.

Page 16: name album_id Сопоставление – пакет 1

Source slide page 16: name album_id Сопоставление – пакет 1{#source-figure-016 caption=“Source slide page 16: name album_id Сопоставление – пакет 1”}

Комментарии преподавателя

Далее читается второй (внешний) набор строк. Если строка принадлежит первому пакету, она сопоставляется с хеш-таблицей, которая как раз содержит первый пакет. С другими пакетами строку сопоставлять не надо — в них не может найтись соответствие, поскольку хеш-коды заведомо будут отличаться.

Если строка принадлежит другому пакету, она сбрасывается на диск — опять же, каждый пакет в свой временный файл.

Таким образом, при N пакетах используются 2(N–1) файлов.

Следует учитывать, что использование временных файлов на диске ограничивается параметром temp_file_limit, который определяет общий предел дисковой памяти для сеанса. (Буферы временных таблиц в это ограничение не входят.)

Page 17: Сопоставление – пакет 2

Source slide page 17: Сопоставление – пакет 2{#source-figure-017 caption=“Source slide page 17: Сопоставление – пакет 2”}

Комментарии преподавателя

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

Процедура повторяется для всех оставшихся N–1 пакетов. На рисунке показано соединение для второго пакета (01).

Page 18: Сопоставление – пакет 3

Source slide page 18: Сопоставление – пакет 3{#source-figure-018 caption=“Source slide page 18: Сопоставление – пакет 3”}

Комментарии преподавателя

На рисунке показано соединение для третьего пакета (10).

Page 19: Сопоставление – пакет 4

Source slide page 19: Сопоставление – пакет 4{#source-figure-019 caption=“Source slide page 19: Сопоставление – пакет 4”}

Комментарии преподавателя

После обработки последнего пакета соединение завершено и временные файлы освобождены.

Таким образом, при нехватке оперативной памяти алгоритм соединения становится двухпроходным: каждый пакет (кроме первого) требуется записать на диск и затем прочитать повторно. Разумеется, это сказывается на эффективности соединения. Поэтому важно, чтобы:

● в хеш-таблицу попадали только действительно нужные поля (обязанность автора запроса),

● хеш-таблица строилась по меньшему набору строк (обязанность планировщика).

Page 20: Двухпроходное соединение хешированием

Возьмем теперь большие таблицы и выведем статистику ввода-вывода:

=> EXPLAIN (analyze, buffers, costs off, timing off, summary off)
SELECT *
FROM bookings b
  JOIN tickets t ON b.book_ref = t.book_ref;
                            QUERY PLAN
------------------------------------------------------------------
 Hash Join (actual rows=2949857 loops=1)
   Hash Cond: (t.book_ref = b.book_ref)
   Buffers: shared read=62862, temp read=54218 written=54218
   ->  Seq Scan on tickets t (actual rows=2949857 loops=1)
         Buffers: shared read=49415
   ->  Hash (actual rows=2111110 loops=1)
         Buckets: 131072  Batches: 32  Memory Usage: 4551kB
         Buffers: shared read=13447, temp written=10701
         ->  Seq Scan on bookings b (actual rows=2111110 loops=1)
               Buffers: shared read=13447
 Planning:
   Buffers: shared hit=88 read=13 dirtied=2
(12 rows)

Теперь хеш-таблица не поместилась в память и используется двухпроходное соединение. Потребовалось 32 пакета (Batches). Видно, что узел Hash записывает пакеты во временные файлы (temp written), а узел Hash Join и записывает, и читает (temp read и written). Сравним с таким же запросом, который выводит только одно поле:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT b.book_ref
FROM bookings b
  JOIN tickets t ON b.book_ref = t.book_ref;
                            QUERY PLAN
------------------------------------------------------------------
 Hash Join (actual rows=2949857 loops=1)
   Hash Cond: (t.book_ref = b.book_ref)
   ->  Seq Scan on tickets t (actual rows=2949857 loops=1)
   ->  Hash (actual rows=2111110 loops=1)
         Buckets: 262144  Batches: 16  Memory Usage: 7057kB
         ->  Seq Scan on bookings b (actual rows=2111110 loops=1)
(6 rows)

Расход памяти уменьшился, так как в хеш-таблице теперь только одно поле (вместо трех), и количество пакетов уменьшилось вдвое.

Page 21: Вычислительная сложность

~ N+M, где

  • N и M — число строк в первом и втором наборах данных
  • Начальные затраты на построение хеш-таблицы
  • Эффективно для большого числа строк

Комментарии преподавателя

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

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

Соединение хешированием (в сочетании с полным сканированием таблиц) характерно для OLAP-запросов, в которых надо обработать большое число строк, причем общая пропускная способность важнее времени отклика.

Page 22: Стоимость хеш-соединения

=> EXPLAIN SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no;
                                    QUERY PLAN
-----------------------------------------------------------------------------------
 Hash Join  (cost=161879.14..498586.67 rows=8391960 width=136)
   Hash Cond: (tf.ticket_no = t.ticket_no)
   ->  Seq Scan on ticket_flights tf  (cost=0.00..153852.60 rows=8391960 width=32)
   ->  Hash  (cost=78913.73..78913.73 rows=2949873 width=104)
         ->  Seq Scan on tickets t  (cost=0.00..78913.73 rows=2949873 width=104)
(5 rows)

Начальная стоимость узла Hash Join складывается из стоимостей: получения всего первого набора данных (здесь — билеты); построения хеш-таблицы — пока таблица не готова, соединение не может начаться. Можно обратить внимание на то, что в узле Hash стоимость построения хеш-таблицы не отражена. Полная стоимость добавляет к начальной следующие стоимости: получения всего второго набора данных (здесь — перелеты); проверки по хеш-таблице; обращения к диску в случае, когда предполагается использование более одного пакета. Главный вывод: стоимость хеш-соединения пропорциональна N + M, где N и M — число строк в соединяемых наборах данных. При больших N и M это значительно выгоднее, чем произведение в случае соединения вложенным циклом.

Page 23: Параллельный алгоритм

Source slide page 23: Параллельный алгоритм{#source-figure-023 caption=“Source slide page 23: Параллельный алгоритм”}

Эффективный алгоритм: параллельное построение хеш-таблицы и параллельное сопоставление

Комментарии преподавателя

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

Возможность параллельного хеш-соединения управляется параметром enable_parallel_hash; по умолчанию параметр включен.

Как и у последовательного алгоритма, у параллельного есть два варианта: однопроходный при достаточном количестве оперативной памяти и двухпроходный.

Начнем с однопроходного варианта.

Page 24: Параллельно, один проход

Процессы используют общую хеш-таблицу

Page 25: Построение хеш-таблицы

Source slide page 25: Построение хеш-таблицы{#source-figure-025 caption=“Source slide page 25: Построение хеш-таблицы”}

Комментарии преподавателя

Однопроходный алгоритм используется, если хеш-таблица помещается в суммарный объем памяти, выделенный всем участвующим в соединении процессам, то есть размер хеш-таблицы ограничен значением work_mem × hash_mem_multiplier × количество процессов.

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

Page 26: name album_id Сопоставление

Source slide page 26: name album_id Сопоставление{#source-figure-026 caption=“Source slide page 26: name album_id Сопоставление”}

Комментарии преподавателя

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

Page 27: Однопроходное параллельное хеш-соединение

Увеличим объем памяти, выделяемый процессу для построения хеш-таблицы:

=> SET work_mem = '32MB';
SET

Выполним запрос с агрегацией:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT count(*)
FROM bookings b
  JOIN tickets t ON b.book_ref = t.book_ref;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Hash Join (actual rows=983286 loops=3)
                     Hash Cond: (t.book_ref = b.book_ref)
                     ->  Parallel Seq Scan on tickets t (actual rows=983286 loops=3)
                     ->  Parallel Hash (actual rows=703703 loops=3)
                           Buckets: 4194304  Batches: 1  Memory Usage: 115424kB
                           ->  Parallel Seq Scan on bookings b (actual rows=703703
loops=3)
(11 rows)

Обратите внимание на использование памяти (Memory Usage): объем превышает ограничение, установленное для одного рабочего процесса (64MB = work_mem × hash_mem_multiplier), но в общую память трех процессов хеш-таблица помещается. Поэтому выполняется однопроходное соединение (Batches: 1).

Page 28: Параллельно, два прохода

Наборы строк разбиваются на пакеты, которые затем параллельно обрабатываются рабочими процессами

Page 29: Разбиение на пакеты

Source slide page 29: Разбиение на пакеты{#source-figure-029 caption=“Source slide page 29: Разбиение на пакеты”}

Комментарии преподавателя

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

Сначала рабочие процессы параллельно читают первый набор данных, разбивают его на пакеты и записывают пакеты во временные файлы. Первый пакет тоже попадает в файл; хеш-таблица в памяти не строится.

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

Затем рабочие процессы параллельно читают второй набор данных и также разбивают его на пакеты и записывают во временные файлы.

Таким образом, при N пакетах на диск записываются 2N файлов.

Page 30: Сопоставление

Source slide page 30: Сопоставление{#source-figure-030 caption=“Source slide page 30: Сопоставление”}

Комментарии преподавателя

Затем каждый рабочий процесс выбирает себе по одному пакету.

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

После заполнения хеш-таблицы процесс читает второй набор выбранного пакета и сопоставляет строки.

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

Page 31: Сопоставление

Source slide page 31: Сопоставление{#source-figure-031 caption=“Source slide page 31: Сопоставление”}

Комментарии преподавателя

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

Несколько хеш-таблиц работают лучше, чем одна большая: в этом случае проще организовать совместную работу и меньше ресурсов тратится на синхронизацию.

Page 32: Двухпроходное параллельное хеш-соединение

Теперь уменьшим объем памяти:

=> SET work_mem = '16MB';
SET

Выполним тот же запрос с агрегацией:

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT count(*)
FROM bookings b
  JOIN tickets t ON b.book_ref = t.book_ref;
                                         QUERY PLAN
------------------------------------------------------------------------------------------
--
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Hash Join (actual rows=983286 loops=3)
                     Hash Cond: (t.book_ref = b.book_ref)
                     ->  Parallel Seq Scan on tickets t (actual rows=983286 loops=3)
                     ->  Parallel Hash (actual rows=703703 loops=3)
                           Buckets: 1048576  Batches: 4  Memory Usage: 28896kB
                           ->  Parallel Seq Scan on bookings b (actual rows=703703
loops=3)
(11 rows)

Соединение стало двухпроходным с четырьмя пакетами.

Page 33: Итоги

Соединение хешированием требует подготовки

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

Комментарии преподавателя

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

Зато соединение хешированием эффективно работает на больших объемах данных. Оба набора строк читаются последовательно и только один раз (два раза в случае нехватки оперативной памяти).

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

Page 34: Практика

  1. Напишите запрос, показывающий занятые места в салоне для всех рейсов. Какой способ соединения выбрал планировщик? Проверьте, хватило ли оперативной памяти для размещения хеш-таблиц. 2. Измените запрос, чтобы он выводил только общее количество занятых мест. Как изменился план запроса? Почему планировщик не использовал аналогичный план для предыдущего запроса? 3. Напишите запрос, показывающий имена пассажиров и номера рейсов, которыми они следуют. Разберитесь по плану запроса, в какой последовательности выполняются операции.

Комментарии преподавателя

  1. Для этого достаточно соединить рейсы (flights) с посадочными талонами (boarding_passes).

  2. Такой запрос должен соединять три таблицы: билеты (tickets), перелеты (ticket_flights) и рейсы (flights).

Page 35: 1. Список занятых мест

=> EXPLAIN SELECT f.flight_no, bp.seat_no
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id;
                                    QUERY PLAN
-----------------------------------------------------------------------------------
 Hash Join  (cost=8508.51..229824.96 rows=7925944 width=10)
   Hash Cond: (bp.flight_id = f.flight_id)
   ->  Seq Scan on boarding_passes bp  (cost=0.00..137538.44 rows=7925944 width=7)
   ->  Hash  (cost=4772.67..4772.67 rows=214867 width=11)
         ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=11)
(5 rows)

Использовано соединение хешированием.

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT f.flight_no, bp.seat_no
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id;
                             QUERY PLAN
--------------------------------------------------------------------
 Hash Join (actual rows=7925812 loops=1)
   Hash Cond: (bp.flight_id = f.flight_id)
   ->  Seq Scan on boarding_passes bp (actual rows=7925812 loops=1)
   ->  Hash (actual rows=214867 loops=1)
         Buckets: 262144  Batches: 2  Memory Usage: 6676kB
         ->  Seq Scan on flights f (actual rows=214867 loops=1)
(6 rows)

Хеш-таблица не поместилась целиком в память, потребовалось два пакета.

2. Количество занятых мест

=> EXPLAIN SELECT count(*)
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id;
                                                 QUERY PLAN
------------------------------------------------------------------------------------------
-------------------
 Finalize Aggregate  (cost=114697.17..114697.18 rows=1 width=8)
   ->  Gather  (cost=114696.95..114697.16 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=113696.95..113696.96 rows=1 width=8)
               ->  Parallel Hash Join  (cost=5467.82..105440.76 rows=3302477 width=0)
                     Hash Cond: (bp.flight_id = f.flight_id)
                     ->  Parallel Seq Scan on boarding_passes bp  (cost=0.00..91303.77
rows=3302477 width=4)
                     ->  Parallel Hash  (cost=3887.92..3887.92 rows=126392 width=4)
                           ->  Parallel Seq Scan on flights f  (cost=0.00..3887.92
rows=126392 width=4)
(9 rows)

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

=> EXPLAIN (analyze, costs off, timing off, summary off)
SELECT count(*)
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id;

Page 36: Page 36

                                          QUERY PLAN
------------------------------------------------------------------------------------------
-----
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Hash Join (actual rows=2641937 loops=3)
                     Hash Cond: (bp.flight_id = f.flight_id)
                     ->  Parallel Seq Scan on boarding_passes bp (actual rows=2641937
loops=3)
                     ->  Parallel Hash (actual rows=71622 loops=3)
                           Buckets: 262144  Batches: 1  Memory Usage: 10496kB
                           ->  Parallel Seq Scan on flights f (actual rows=71622 loops=3)
(11 rows)

Обратите внимание на поле loops в узлах выше и ниже Gather — оно соответствует реальному числу процессов, работавших над запросом.

3. Пассажиры и номера рейсов

=> EXPLAIN (costs off)
SELECT t.passenger_name, f.flight_no
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id;
                   QUERY PLAN
-------------------------------------------------
 Hash Join
   Hash Cond: (tf.flight_id = f.flight_id)
   ->  Hash Join
         Hash Cond: (tf.ticket_no = t.ticket_no)
         ->  Seq Scan on ticket_flights tf
         ->  Hash
               ->  Seq Scan on tickets t
   ->  Hash
         ->  Seq Scan on flights f
(9 rows)

Сначала выполняется соединение билетов (tickets) с перелетами (ticket_flights), причем хеш-таблица строится по таблице билетов. Затем рейсы (flights) соединяются с результатом первого соединения; хеш-таблица строится по таблице рейсов.

10 Занятие 10: Способы соединения / Соединение слиянием

Исходная страница в нативном Markdown: Способы соединения / Соединение слиянием
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_10_join_mergejoin.html Archive: QPT-handouts-16.zip Страниц PDF: 25

Page 1: Способы соединения / Соединение слиянием

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Алгоритм соединения слиянием
  • Вычислительная сложность
  • Соединение слиянием в параллельных планах

Page 3: Соединение слиянием

Source slide page 3: Соединение слиянием{#source-figure-003 caption=“Source slide page 3: Соединение слиянием”}

Слияние двух отсортированных наборов строк

  • Результат соединения автоматически отсортирован

Комментарии преподавателя

Третий, и последний, способ соединения — соединение слиянием.

Идея этого способа состоит в том, что два предварительно отсортированных набора данных нетрудно объединить в один общий — и точно так же отсортированный — набор. Похожим образом работает узел Gather Merge.

Подготовительным этапом для соединения слиянием служит сортировка обоих наборов строк. Сортировка — дорогая операция, она имеет сложность O(N log N).

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

Page 4: Слияние

Source slide page 4: Слияние{#source-figure-004 caption=“Source slide page 4: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Само слияние устроено просто. Сначала берем первые строки обоих наборов и сравниваем их между собой. В данном случае мы сразу нашли соответствие и можем вернуть первую строку результата: («Yellow Submarine», «All Together Now»).

Общий алгоритм таков: читаем следующую строку того набора, для которого значение поля, по которому происходит соединение, меньше (один набор «догоняет» другой). Если же значения одинаковы, как в нашем примере, то читаем следующую строку второго (внутреннего) набора.

Page 5: Слияние

Source slide page 5: Слияние{#source-figure-005 caption=“Source slide page 5: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Вновь соответствие: («Yellow Submarine», «All You Need Is Love»).

Снова читаем следующую строку второго набора.

Page 6: Слияние

Source slide page 6: Слияние{#source-figure-006 caption=“Source slide page 6: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

В данном случае соответствия нет.

Поскольку 1 < 2, читаем следующую строку первого набора.

Page 7: Слияние

Source slide page 7: Слияние{#source-figure-007 caption=“Source slide page 7: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Соответствия нет.

3 > 2, поэтому читаем следующую строку второго набора.

Page 8: Слияние

Source slide page 8: Слияние{#source-figure-008 caption=“Source slide page 8: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Снова нет соответствия, снова 3 > 2, снова читаем строку второго набора.

Page 9: Слияние

Source slide page 9: Слияние{#source-figure-009 caption=“Source slide page 9: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Есть соответствие: («Let It Be», «Across the Universe»).

3 = 3, читаем следующую строку второго набора.

Page 10: Слияние

Source slide page 10: Слияние{#source-figure-010 caption=“Source slide page 10: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Соответствия нет. 3 < 5, читаем строку первого набора.

Page 11: Слияние

Source slide page 11: Слияние{#source-figure-011 caption=“Source slide page 11: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Соответствия нет. 4 < 5, читаем строку первого набора.

Page 12: Слияние

Source slide page 12: Слияние{#source-figure-012 caption=“Source slide page 12: Слияние”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

И последний шаг: снова нет соответствия.

На этом соединение слиянием окончено.

На самом деле алгоритм сложнее — если в первом (внешнем) наборе строк встречается несколько одинаковых значений, нужно иметь возможность перечитать строки второго (внутреннего) набора с тем же ключом соединения.

Псевдокод алгоритма можно посмотреть в файле src/backend/executor/nodeMergejoin.c.

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

Page 13: Соединение слиянием

Если результат необходим в отсортированном виде, оптимизатор может предпочесть соединение слиянием. Особенно, если данные от дочерних узлов можно получить уже отсортированными — как в этом примере:

=> EXPLAIN (costs off) SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
ORDER BY t.ticket_no;
                           QUERY PLAN
-----------------------------------------------------------------
 Merge Join
   Merge Cond: (t.ticket_no = tf.ticket_no)
   ->  Index Scan using tickets_pkey on tickets t
   ->  Index Scan using ticket_flights_pkey on ticket_flights tf
(4 rows)

Вот еще один пример с двумя соединениями слиянием, в котором один узел Merge Join получает отсортированный набор от другого узла Merge Join:

=> EXPLAIN (costs off) SELECT t.ticket_no, bp.flight_id, bp.seat_no
FROM tickets t
  JOIN ticket_flights tf ON t.ticket_no = tf.ticket_no
  JOIN boarding_passes bp ON bp.ticket_no = tf.ticket_no
   AND bp.flight_id = tf.flight_id
ORDER BY t.ticket_no;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Merge Join
   Merge Cond: ((t.ticket_no = tf.ticket_no) AND (bp.flight_id = tf.flight_id))
   ->  Merge Join
         Merge Cond: (bp.ticket_no = t.ticket_no)
         ->  Index Scan using boarding_passes_pkey on boarding_passes bp
         ->  Index Only Scan using tickets_pkey on tickets t
   ->  Index Only Scan using ticket_flights_pkey on ticket_flights tf
(7 rows)

Здесь соединяются перелеты (ticket_flights) и посадочные талоны (boarding_passes), и с этим, уже отсортированным по номерам билетов, набором строк соединяются билеты (tickets). Для Merge Join также существуют модификации Left, Right, Semi, Anti и Full. В настоящее время соединение слиянием поддерживает только эквисоединения, соединения по операциям «больше» или «меньше» не реализованы. Таким образом, модификация Full существует только для соединений хешированием и слиянием, и оба варианта работают только с условием равенства. Если пренебречь производительностью, полное соединение можно получить, объединив результаты левого соединения и антисоединения — таким вариантом придется воспользоваться, если потребуется полное соединение по иному условию.

Page 14: Вычислительная сложность

~ N+M, где

  • N и M — число строк в первом и втором наборах данных,
  • если не требуется сортировка
  • ~ N log N+M log M,
    • если сортировка нужна
  • Возможные начальные затраты на сортировку
  • Эффективно для большого числа строк

Комментарии преподавателя

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

Поэтому соединение слиянием может успешно применяться как в OLTP-, так и в OLAP-запросах.

Однако если сортировка требуется, то стоимость становится пропорциональной произведению количества строк на логарифм этого количества, и на больших объемах такой способ скорее всего проиграет соединению хешированием.

Page 15: Вычислительная сложность

Посмотрим на стоимость соединения слиянием:

=> EXPLAIN SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
ORDER BY t.ticket_no;
                                                  QUERY PLAN
------------------------------------------------------------------------------------------
---------------------
 Merge Join  (cost=0.99..822357.61 rows=8391708 width=136)
   Merge Cond: (t.ticket_no = tf.ticket_no)
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..139114.55 rows=2950138
width=104)
   ->  Index Scan using ticket_flights_pkey on ticket_flights tf  (cost=0.56..570971.36
rows=8391708 width=32)
(4 rows)

Начальная стоимость включает: сумму начальных стоимостей дочерних узлов (включает стоимость сортировки, если она необходима); стоимость получения первой пары строк, соответствующих друг другу. Полная стоимость добавляет к начальной: сумму стоимостей получения обоих наборов данных; стоимость сравнения строк. Общий вывод: стоимость соединения слиянием пропорциональна N + M (где N и M — число соединяемых строк), если не требуется отдельная сортировка. Сортировка набора из K строк добавляет к оценке как минимум K × log(K). В отличие от соединения хешированием, слияние без сортировки хорошо подходит для случая, когда надо быстро получить первые строки.

=> EXPLAIN SELECT *
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
ORDER BY t.ticket_no
LIMIT 1000;
                                                     QUERY PLAN
------------------------------------------------------------------------------------------
---------------------------
 Limit  (cost=0.99..98.99 rows=1000 width=136)
   ->  Merge Join  (cost=0.99..822357.61 rows=8391708 width=136)
         Merge Cond: (t.ticket_no = tf.ticket_no)
         ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..139114.55
rows=2950138 width=104)
         ->  Index Scan using ticket_flights_pkey on ticket_flights tf
(cost=0.56..570971.36 rows=8391708 width=32)
(5 rows)

Обратите внимание и на то, как уменьшилась общая стоимость.

Page 16: В параллельных планах

Source slide page 16: В параллельных планах{#source-figure-016 caption=“Source slide page 16: В параллельных планах”}

Внешний набор строк сканируется параллельно, внутренний — последовательно каждым процессом

Комментарии преподавателя

Соединение слиянием может использоваться в параллельных планах.

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

Page 17: В параллельных планах

Source slide page 17: В параллельных планах{#source-figure-017 caption=“Source slide page 17: В параллельных планах”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

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

На этом слайде показаны строки, перебираемые первым процессом.

Page 18: В параллельных планах

Source slide page 18: В параллельных планах{#source-figure-018 caption=“Source slide page 18: В параллельных планах”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

Второй процесс просканирует весь набор и найдет одно соответствие.

Page 19: В параллельных планах

Source slide page 19: В параллельных планах{#source-figure-019 caption=“Source slide page 19: В параллельных планах”}

SELECT a.title, s.name
FROM albums a JOIN songs s ON a.id = s.album_id;

Комментарии преподавателя

А третий процесс не обнаружит ни одного соответствия.

Конечно, все три процесса сканируют внутренний набор одновременно, а не по очереди.

Page 20: В параллельных планах

Нам потребуется индекс:

=> CREATE INDEX ON tickets(book_ref);
CREATE INDEX

Вот пример параллельного плана, в котором используется соединение слиянием:

=> EXPLAIN (costs off)
SELECT count(*)
FROM bookings b
  JOIN (
    SELECT book_ref FROM tickets GROUP BY book_ref
) t ON b.book_ref = t.book_ref;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Merge Join
                     Merge Cond: (b.book_ref = tickets.book_ref)
                     ->  Parallel Index Only Scan using bookings_pkey on bookings b
                     ->  Group
                           Group Key: tickets.book_ref
                           ->  Index Only Scan using tickets_book_ref_idx on tickets
(10 rows)

Здесь индекс на таблице бронирований bookings_pkey сканируется параллельно, а результат группировки из подзапроса t — каждым процессом полностью.

Page 21: Итоги

Соединение слиянием может потребовать подготовки

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

Комментарии преподавателя

Чтобы начать соединение слиянием, оба набора строк должны быть отсортированы. Хорошо, если удается получить данные уже в нужном порядке; если нет — требуется выполнить сортировку.

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

Итак, в распоряжении планировщика есть три способа соединения: вложенный цикл, хеширование и слияние (не считая различных модификаций). Для каждого способа есть ситуации, в которых он оказывается более эффективным, чем остальные. Это позволяет планировщику выбрать именно тот способ, который — как предполагается — лучше подойдет в каждом конкретном случае. А точность предположений напрямую зависит от имеющейся статистики.

Page 22: Практика

  1. Проверьте план выполнения запроса, показывающего все места в салонах в порядке кодов самолетов: SELECT * FROM aircrafts a JOIN seats s ON a.aircraft_code = s.aircraft_code ORDER BY a.aircraft_code; Но оформите его в виде курсора. Уменьшите значение параметра cursor_tuple_fraction в десять раз. Как при этом изменился план выполнения? 2. Самолет можно заменить другим, если его вместимость отличается не более, чем на 20%. Получите таблицу замен между моделями Боинг и Аэробус, выполнив полное соединение. Как выполняется запрос?
  2. Запрос, который нужно выполнить:
WITH cap AS (
    SELECT a.model, count(*)::numeric capacity
    FROM aircrafts a
      JOIN seats s ON a.aircraft_code = s.aircraft_code
    GROUP BY a.model
), a AS (
    SELECT * FROM cap WHERE model LIKE 'Аэробус%'
), b AS (
    SELECT * FROM cap WHERE model LIKE 'Боинг%'
)
SELECT a.model AS airbus, b.model AS boeing
FROM a FULL JOIN b
  ON b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
ORDER BY 1,2;

Page 23: 1. Параметр cursor_tuple_fraction

План выполнения курсора:

=> EXPLAIN DECLARE c CURSOR FOR SELECT *
FROM aircrafts a
  JOIN seats s ON a.aircraft_code = s.aircraft_code
ORDER BY a.aircraft_code;
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Merge Join  (cost=1.51..420.71 rows=1339 width=55)
   Merge Cond: (s.aircraft_code = ml.aircraft_code)
   ->  Index Scan using seats_pkey on seats s  (cost=0.28..64.60 rows=1339 width=15)
   ->  Sort  (cost=1.23..1.26 rows=9 width=72)
         Sort Key: ml.aircraft_code
         ->  Seq Scan on aircrafts_data ml  (cost=0.00..1.09 rows=9 width=72)
(6 rows)

Текущее значение cursor_tuple_fraction:

=> SHOW cursor_tuple_fraction;
 cursor_tuple_fraction
-----------------------
 0.1
(1 row)

Уменьшим его:

=> SET cursor_tuple_fraction = 0.01;
SET
=> EXPLAIN DECLARE c CURSOR FOR SELECT *
FROM aircrafts a
  JOIN seats s ON a.aircraft_code = s.aircraft_code
ORDER BY a.aircraft_code;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 Merge Join  (cost=0.41..431.73 rows=1339 width=55)
   Merge Cond: (ml.aircraft_code = s.aircraft_code)
   ->  Index Scan using aircrafts_pkey on aircrafts_data ml  (cost=0.14..12.27 rows=9
width=72)
   ->  Index Scan using seats_pkey on seats s  (cost=0.28..64.60 rows=1339 width=15)
(4 rows)

Теперь планировщик выбирает другой план: его начальная стоимость ниже (хотя общая стоимость, наоборот, выше).

2. Полное соединение

Попробуем выполнить запрос.

=> WITH cap AS (
    SELECT a.model, count(*)::numeric capacity
    FROM aircrafts a
      JOIN seats s ON a.aircraft_code = s.aircraft_code
   GROUP BY a.model
), a AS (
    SELECT * FROM cap WHERE model LIKE 'Аэробус%'
), b AS (
    SELECT * FROM cap WHERE model LIKE 'Боинг%'
)
SELECT a.model AS airbus, b.model AS boeing
FROM a FULL JOIN b
  ON b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
ORDER BY 1,2;
ERROR:  FULL JOIN is only supported with merge-joinable or hash-joinable join conditions

Получаем ошибку: полное соединение реализовано только для условия равенства, поскольку только вложенный цикл

Page 24: Page 24

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

=> WITH cap AS (
    SELECT a.model, count(*)::numeric capacity
    FROM aircrafts a
      JOIN seats s ON a.aircraft_code = s.aircraft_code
   GROUP BY a.model
), a AS (
    SELECT * FROM cap WHERE model LIKE 'Аэробус%'
), b AS (
    SELECT * FROM cap WHERE model LIKE 'Боинг%'
)
SELECT a.model AS airbus, b.model AS boeing
FROM a LEFT JOIN b
  ON b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
UNION ALL
SELECT NULL, b.model
FROM b
WHERE NOT EXISTS (
  SELECT 1
  FROM a
  WHERE b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
)
ORDER BY 1,2;
      airbus      |    boeing
------------------+---------------
 Аэробус A319-100 | Боинг 737-300
 Аэробус A320-200 | Боинг 737-300
 Аэробус A321-200 |
                  | Боинг 767-300
                  | Боинг 777-300
(5 rows)

План запроса показывает, что соединения выполняются методом вложенного цикла:

=> EXPLAIN (costs off)
WITH cap AS (
    SELECT a.model, count(*)::numeric capacity
    FROM aircrafts a
      JOIN seats s ON a.aircraft_code = s.aircraft_code
   GROUP BY a.model
), a AS (
    SELECT * FROM cap WHERE model LIKE 'Аэробус%'
), b AS (
    SELECT * FROM cap WHERE model LIKE 'Боинг%'
)
SELECT a.model AS airbus, b.model AS boeing
FROM a LEFT JOIN b
  ON b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
UNION ALL
SELECT NULL, b.model
FROM b
WHERE NOT EXISTS (
  SELECT 1
  FROM a
  WHERE b.capacity::numeric/a.capacity BETWEEN 0.8 AND 1.2
)
ORDER BY 1,2;

Page 25: Page 25

                                                   QUERY PLAN
------------------------------------------------------------------------------------------
----------------------
 Sort
   Sort Key: a.model, b.model
   CTE cap
     ->  HashAggregate
           Group Key: (ml.model ->> lang())
           ->  Hash Join
                 Hash Cond: (s.aircraft_code = ml.aircraft_code)
                 ->  Seq Scan on seats s
                 ->  Hash
                       ->  Seq Scan on aircrafts_data ml
   CTE a
     ->  CTE Scan on cap
           Filter: (model ~~ 'Аэробус%'::text)
   CTE b
     ->  CTE Scan on cap cap_1
           Filter: (model ~~ 'Боинг%'::text)
   ->  Append
         ->  Nested Loop Left Join
               Join Filter: (((b.capacity / a.capacity) >= 0.8) AND ((b.capacity /
a.capacity) <= 1.2))
               ->  CTE Scan on a
               ->  CTE Scan on b
         ->  Nested Loop Anti Join
               Join Filter: (((b_1.capacity / a_1.capacity) >= 0.8) AND ((b_1.capacity /
a_1.capacity) <= 1.2))
               ->  CTE Scan on b b_1
               ->  CTE Scan on a a_1
(25 rows)

11 Занятие 11: Статистика / Базовая статистика

Исходная страница в нативном Markdown: Статистика / Базовая статистика
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_11_stats_basic.html Archive: QPT-handouts-16.zip Страниц PDF: 27

Page 1: Статистика / Базовая статистика

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Базовая статистика
  • Наиболее частые значения и гистограммы
  • Статистика элементов составных значений
  • Использование статистики для оценки кардинальности и селективности
  • Частные и общие планы выполнения
  • Частичный индекс и индекс по выражению

Page 3: Базовая статистика

Размер таблицы

  • строки (pg_class.reltuples) и страницы (pg_class.relpages)
  • Собирается
    • операциями DDL
    • очисткой
    • анализом
  • Настройка
    • default_statistics_target = 100

Комментарии преподавателя

Базовая статистика собирается на уровне всей таблицы и на уровне отдельных столбцов.

К статистике таблицы относится информация о размере объекта (reltuples и relpages в таблице pg_class). Поскольку такая статистика крайне важна, она обновляется некоторыми DDL-операциями (CREATE INDEX, CREATE TABLE AS SELECT) и уточняется при очистке и анализе.

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

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

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

https://postgrespro.ru/docs/postgresql/16/row-estimation-examples

Page 4: Базовая статистика

Source slide page 4: Базовая статистика{#source-figure-004 caption=“Source slide page 4: Базовая статистика”}

pg_statistic (pg_stats)

Комментарии преподавателя

Вся остальная статистика собирается отдельно для каждого столбца при анализе таблицы. Обычно этим занимается автоанализ (его настройка рассматривается в курсе DBA2).

Статистика на уровне столбцов хранится в таблице pg_statistic. Но смотреть проще в представление pg_stats, которое показывает информацию в более удобном виде.

Поле null_frac содержит долю строк с неопределенными значениями в столбце (от 0 до 1).

Поле n_distinct хранит число уникальных значений в столбце. Если значение n_distinct отрицательно, то модуль этого числа показывает долю уникальных значений. Например, −1 означает, что все значения уникальны (типичный случай для первичного ключа).

https://postgrespro.ru/docs/postgresql/16/planner-stats#PLANNER-STATS-S INGLE-COLUMN

Page 5: Число строк

Начнем с оценки кардинальности в простом случае запроса без предикатов.

=> EXPLAIN
SELECT * FROM flights;
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=63)
(1 row)

Точное значение:

=> SELECT count(*) FROM flights;
 count
--------
 214867
(1 row)

Оптимизатор получает значение из pg_class:

=> SELECT reltuples, relpages FROM pg_class WHERE relname = 'flights';
 reltuples | relpages
-----------+----------
    214867 |     2624
(1 row)

Значение параметра, управляющего ориентиром статистики, по умолчанию равно 100:

=> SHOW default_statistics_target;
 default_statistics_target
---------------------------
 100
(1 row)

Поскольку при анализе таблицы учитывается 300 × default_statistics_target строк, то оценки для относительно крупных таблиц могут не быть абсолютно точными.

Доля неопределенных значений

Часть рейсов еще не отправились, поэтому время вылета для них не определено:

=> EXPLAIN
SELECT * FROM flights WHERE actual_departure IS NULL;
                          QUERY PLAN
---------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..4772.67 rows=16667 width=63)
   Filter: (actual_departure IS NULL)
(2 rows)

Точное значение:

=> SELECT count(*) FROM flights WHERE actual_departure IS NULL;
 count
-------
 16348
(1 row)

Оценка оптимизатора получена как общее число строк, умноженное на долю NULL-значений:

=> SELECT 214867 * null_frac FROM pg_stats
WHERE tablename = 'flights' AND attname = 'actual_departure';

Page 6: Число уникальных значений

      ?column?
--------------------
 16666.517337217927
(1 row)

Проверим количество моделей самолетов в таблице рейсов:

=> SELECT n_distinct FROM pg_stats
WHERE tablename = 'flights' AND attname = 'aircraft_code';
 n_distinct
------------
          8
(1 row)

Это соответствует действительности:

=> SELECT count(DISTINCT aircraft_code) FROM flights;
 count
-------
     8
(1 row)

А в таблице самих самолетов?

=> SELECT n_distinct FROM pg_stats
WHERE tablename = 'aircrafts_data' AND attname = 'aircraft_code';
 n_distinct
------------
         -1
(1 row)

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

Кардинальность соединения

Селективность соединения — доля строк от декартова произведения двух таблиц, которая остается после применения условия соединения. Поэтому для расчета кардинальности соединения оптимизатор оценивает кардинальность декартова произведения и умножает его на селективность условия соединения (и на селективность условий фильтров, если они есть). Рассмотрим пример:

=> EXPLAIN SELECT *
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code;
                                  QUERY PLAN
------------------------------------------------------------------------------
 Hash Join  (cost=1.20..59857.41 rows=214867 width=103)
   Hash Cond: (f.aircraft_code = ml.aircraft_code)
   ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=63)
   ->  Hash  (cost=1.09..1.09 rows=9 width=72)
         ->  Seq Scan on aircrafts_data ml  (cost=0.00..1.09 rows=9 width=72)
(5 rows)

Точное значение кардинальности:

=> SELECT count(*)
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code;
 count
--------
 214867
(1 row)

Page 7: Page 7

Базовая формула для расчета селективности соединения (в предположении равномерного распределения) — минимальное из значений 1/nd1 и 1/nd2, где nd1 — число уникальных значений ключа соединения в первом наборе строк; nd2 — число уникальных значений ключа соединения во втором наборе строк. В данном случае получаем ровно то, что требуется:

=> SELECT round(214867 * 9 * least(1.0/8, 1.0/9));
 round
--------
 214867
(1 row)

Page 8: Наиболее частые значения

Source slide page 8: Наиболее частые значения{#source-figure-008 caption=“Source slide page 8: Наиболее частые значения”}

Комментарии преподавателя

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

Но в реальности неравномерные распределения встречаются очень часто. Поэтому собирается еще и следующая информация:

● массив наиболее частых значений — поле most_common_vals;

● массив частот этих значений — поле most_common_freqs.

Частота из этих массивов непосредственно служит оценкой селективности для поиска конкретного значения.

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

Тонкий момент представляют «большие» значения. Чтобы не увеличивать размер pg_statistic и не нагружать планировщик бесполезной работой, значения, превышающие 1 Кбайт, исключаются из статистики и анализа. В самом деле, если в поле хранятся такие большие значения, скорее всего, они уникальны и не имеют шансов попасть в most_common_vals.

Page 9: Наиболее частые значения

Для эксперимента ограничим размер списка наиболее частых значений (который по умолчанию определяется параметром default_statistics_target) на уровне столбца:

=> ALTER TABLE flights
ALTER COLUMN arrival_airport
SET STATISTICS 10;
ALTER TABLE
=> ANALYZE flights;
ANALYZE

Если значение попало в список наиболее частых, селективность можно узнать непосредственно из статистики. Пример (Шереметьево):

=> EXPLAIN
SELECT * FROM flights WHERE arrival_airport = 'SVO';
                          QUERY PLAN
---------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=19381 width=63)
   Filter: (arrival_airport = 'SVO'::bpchar)
(2 rows)

Точное значение:

=> SELECT count(*) FROM flights WHERE arrival_airport = 'SVO';
 count
-------
 19348
(1 row)

Вот как выглядит список наиболее частых значений и частота их встречаемости:

=> SELECT most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'flights' AND attname = 'arrival_airport' \gx
-[ RECORD 1
]-----+-----------------------------------------------------------------------------------
-----------------
most_common_vals  | {DME,SVO,LED,VKO,OVB,KJA,SVX,ROV,PEE,BZK}
most_common_freqs |
{0.09736667,0.0902,0.0585,0.05293333,0.030833334,0.022166668,0.0206,0.018533334,0.01763333
4,0.0175}

Кардинальность вычисляется как число строк, умноженное на частоту значения:

=> SELECT 214867 * s.most_common_freqs[array_position((s.most_common_vals::text::text[]),'SVO')]
FROM pg_stats s
WHERE s.tablename = 'flights' AND s.attname = 'arrival_airport';
      ?column?
--------------------
 19381.003294982016
(1 row)

Список наиболее частых значений может использоваться и для оценки селективности неравенств. Для этого в most_common_vals надо найти все значения, удовлетворяющие неравенству, и просуммировать частоты соответствующих элементов из most_common_freqs. Если же указанного значения нет в списке наиболее частых, то селективность вычисляется исходя из предположения, что все данные (кроме наиболее частых) распределены равномерно. Например, в списке частых значений нет Владивостока.

=> EXPLAIN
SELECT * FROM flights WHERE arrival_airport = 'VVO';

Page 10: Page 10

                          QUERY PLAN
--------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=1311 width=63)
   Filter: (arrival_airport = 'VVO'::bpchar)
(2 rows)

Точное значение:

=> SELECT count(*) FROM flights WHERE arrival_airport = 'VVO';
 count
-------
  1188
(1 row)

Для получения оценки вычислим сумму частот наиболее частых значений:

=> SELECT sum(f) FROM pg_stats s, unnest(s.most_common_freqs) f
  WHERE s.tablename = 'flights' AND s.attname = 'arrival_airport';
    sum
------------
 0.42626664
(1 row)

На менее частые значения приходятся оставшиеся строки. Поскольку мы исходим из предположения о равномерности распределения менее частых значений, селективность будет равна 1/nd, где nd — число уникальных значений:

=> SELECT n_distinct
FROM pg_stats s
WHERE s.tablename = 'flights' AND s.attname = 'arrival_airport';
 n_distinct
------------
        104
(1 row)

Учитывая, что из этих значений 10 входят в список наиболее частых, а неопределенных значений нет, получаем следующую оценку:

=> SELECT 214867 * (1 - 0.42626664) / (104 - 10);
       ?column?
-----------------------
 1311.4507006714893617
(1 row)

Page 11: Гистограмма

Source slide page 11: Гистограмма{#source-figure-011 caption=“Source slide page 11: Гистограмма”}

Комментарии преподавателя

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

Ширина корзин выбирается так, чтобы в каждую попало примерно одинаковое число значений (на рисунке это выражается в одинаковой площади прямоугольников).

При таком построении достаточно хранить только массив крайних значений каждой корзины — поле histogram_bounds. Частота одной корзины равна 1/(число корзин).

Оценить селективность условия поле < значение можно как N/(общее число корзин), где N — число корзин, лежащих слева от значения. Оценку можно улучшить, добавив часть корзины, в которую попадает само значение.

Если же надо оценить селективность условия поле = значение, то гистограмма в этом не может помочь, и приходится довольствоваться предположением о равномерном распределении и брать в качестве оценки 1/n_distinct.

Page 12: Комбинация методов

Source slide page 12: Комбинация методов{#source-figure-012 caption=“Source slide page 12: Комбинация методов”}

Комментарии преподавателя

Но обычно два подхода объединяются: строится список наиболее частых значений, а все остальные значения покрываются гистограммой.

При этом гистограмма строится так, что в ней не учитываются значения, попавшие в список. Это позволяет улучшить оценки.

Page 13: Гистограмма

При условиях «больше» и «меньше» для оценки будет использоваться список наиболее частых значений, или гистограмма, или оба способа вместе. Гистограмма строится так, чтобы не включать наиболее частые значения и NULL:

=> SELECT histogram_bounds
FROM pg_stats s
WHERE s.tablename = 'flights' AND s.attname = 'arrival_airport';
               histogram_bounds
-----------------------------------------------
 {AAQ,CEK,GOJ,KGP,KZN,NBC,OMS,RGK,TJM,URJ,YKS}
(1 row)

Число корзин гистограммы определяется параметром default_statistics_target, а границы выбираются так, чтобы в каждой корзине находилось примерно одинаковое количество значений. Рассмотрим пример:

=> EXPLAIN
SELECT * FROM flights WHERE arrival_airport <= 'GOJ';
                          QUERY PLAN
---------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=49336 width=63)
   Filter: (arrival_airport <= 'GOJ'::bpchar)
(2 rows)

Точное значение:

=> SELECT count(*) FROM flights WHERE arrival_airport <= 'GOJ';
 count
-------
 50520
(1 row)

Как получена оценка? Учтем частоту наиболее частых значений, попадающих в указанный интервал:

=> SELECT sum( s.most_common_freqs[array_position((s.most_common_vals::text::text[]),v)] )
FROM pg_stats s, unnest(s.most_common_vals::text::text[]) v
WHERE s.tablename = 'flights' AND s.attname = 'arrival_airport' AND v <= 'GOJ';
    sum
------------
 0.11486667
(1 row)

Указанный интервал занимает ровно 2 корзины гистограммы из 10, а неопределенных значений в данном столбце нет, получаем следующую оценку:

=> SELECT 214867 * (
0.11486667 + (1 - 0.42626664) * (2.0 / 10.0)
);
              ?column?
------------------------------------
 49336.3299555140000000000000000000
(1 row)

В общем случае учитываются и не полностью занятые корзины (с помощью линейной аппроксимации).

Кардинальность соединения

В случае неравномерного распределения данных в ключах соединения базовая формула расчета селективности соединения (минимальное из значений 1/nd1 и 1/nd2) дает неправильный результат. Например, рейсы совершают разные модели самолетов с разной вместимостью, и для соединения рейсов с местами мы получили бы:

Page 14: Page 14

=> SELECT round(214867 * 1339 * least(1.0/8, 1.0/8));
  round
----------
 35963364
(1 row)

При этом точное значение в два раза меньше:

=> SELECT count(*)
FROM flights f
  JOIN seats s ON f.aircraft_code = s.aircraft_code;
  count
----------
 16518865
(1 row)

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

=> EXPLAIN SELECT *
FROM flights f
  JOIN seats s ON f.aircraft_code = s.aircraft_code;
                               QUERY PLAN
------------------------------------------------------------------------
 Hash Join  (cost=38.13..279462.54 rows=16614391 width=78)
   Hash Cond: (f.aircraft_code = s.aircraft_code)
   ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=63)
   ->  Hash  (cost=21.39..21.39 rows=1339 width=15)
         ->  Seq Scan on seats s  (cost=0.00..21.39 rows=1339 width=15)
(5 rows)

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

=> SELECT count(*)
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
  JOIN seats s ON a.aircraft_code = s.aircraft_code;
  count
----------
 16518865
(1 row)

Однако теперь планировщик ошибается:

=> EXPLAIN
SELECT *
FROM flights f
  JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
  JOIN seats s ON a.aircraft_code = s.aircraft_code;
                                  QUERY PLAN
------------------------------------------------------------------------------
 Hash Join  (cost=39.33..8415391.09 rows=31967435 width=118)
   Hash Cond: (f.aircraft_code = ml.aircraft_code)
   ->  Hash Join  (cost=38.13..279462.54 rows=16614391 width=78)
         Hash Cond: (f.aircraft_code = s.aircraft_code)
         ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=63)
         ->  Hash  (cost=21.39..21.39 rows=1339 width=15)
               ->  Seq Scan on seats s  (cost=0.00..21.39 rows=1339 width=15)
   ->  Hash  (cost=1.09..1.09 rows=9 width=72)
         ->  Seq Scan on aircrafts_data ml  (cost=0.00..1.09 rows=9 width=72)
(9 rows)

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

Page 15: Дополнительные поля

Упорядоченность (использовать ли битовую карту?)

  • pg_stats.correlation
  • (1 — по возрастанию, 0 — хаотично, –1 — по убыванию)
  • Видимость (использовать ли сканирование только индекса?)
    • pg_class.relallvisible
  • Средний размер значения в байтах (оценка памяти)
    • pg_stats.avg_width

Комментарии преподавателя

Сервер хранит еще несколько показателей статистики.

В поле pg_stats.correlation записывается показатель физической упорядоченности значений столбца. Если значения хранятся строго по возрастанию, показатель будет близок к единице; если по убыванию — к минус единице. Чем более хаотично расположены данные на диске, тем ближе значение показателя к нулю. Именно это поле использует оптимизатор, когда выбирает между сканированием битовой карты и обычным индексным сканированием.

Поле pg_class.relallvisible хранит количество страниц таблицы, которые содержат только актуальные версии строк (эта информация обновляется вместе с картой видимости). Если количество недостаточно велико, планировщик может отказаться от сканирования только индекса в пользу сканирования по битовой карте.

В поле pg_stats.avg_width сохраняется средний размер значений в данном столбце в байтах для расчета необходимого для операции объема памяти.

https://postgrespro.ru/docs/postgresql/16/view-pg-stats

Page 16: Элементы составных полей

Наиболее частые элементы

  • pg_stats.most_common_elems
  • pg_stats.most_common_elem_freqs
  • Гистограмма количества элементов
    • pg_stats.elem_count_histogram

Комментарии преподавателя

Для таких составных типов, как массивы или tsvector, в pg_stats хранится распределение не только самих значений, но и их элементов:

● most_common_elems и most_common_elem_freqs содержат наиболее частые элементы и их частоты;

● elem_count_histogram содержит гистограмму количества элементов в значении (например, в случае массива — гистограмму длин массивов).

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

Page 17: Элементы составных полей

Для примера рассмотрим таблицу pg_constraint — в ней хранятся ограничения целостности, определенные для таблиц. И в ней есть поле conkey с массивом номеров столбцов, образующих ограничение:

=> SELECT conname, conkey
FROM pg_constraint
WHERE conname LIKE 'boarding%';
                  conname                  | conkey
-------------------------------------------+--------
 boarding_passes_flight_id_boarding_no_key | {2,3}
 boarding_passes_flight_id_seat_no_key     | {2,4}
 boarding_passes_pkey                      | {1,2}
 boarding_passes_ticket_no_fkey            | {1,2}
(4 rows)

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

=> SET default_statistics_target = 7;
SET
=> ANALYZE pg_constraint;
ANALYZE
=> SELECT most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'pg_constraint' AND attname = 'conkey' \gx
-[ RECORD 1
]-----+---------------------------------------------------------------------------------
most_common_vals  | {"{1}","{2}","{2,3}","{1,2}","{1,2,3}","{2,3,4}","{2,4}"}
most_common_freqs |
{0.4057971,0.10869565,0.10144927,0.06521739,0.036231883,0.028985508,0.028985508}

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

=> SELECT count(*)
FROM pg_constraint
WHERE conkey @> ARRAY[2::smallint];
 count
-------
    65
(1 row)
=> EXPLAIN SELECT *
FROM pg_constraint
WHERE conkey @> ARRAY[2::smallint];
                           QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on pg_constraint  (cost=0.00..5.72 rows=65 width=1073)
   Filter: (conkey @> '{2}'::smallint[])
(2 rows)

Для таких условий используется статистика по отдельным элементам:

=> SELECT most_common_elems, most_common_elem_freqs, elem_count_histogram
FROM pg_stats
WHERE tablename = 'pg_constraint' AND attname = 'conkey' \gx
-[ RECORD 1
]----------+------------------------------------------------------------------------------
--------------------------------------------------------------------------
most_common_elems      | {1,2,3,4,5,6,7,8,9,10,20}
most_common_elem_freqs |
{0.5514706,0.4779412,0.30882353,0.11029412,0.036764707,0.022058824,0.022058824,0.022058824
,0.022058824,0.007352941,0.007352941,0.007352941,0.5514706,0}
elem_count_histogram   | {1,1,1,1,2,2,4,1.5882353}

Page 18: Page 18

Для частот и гистограмм в конце массива содержится дополнительная информация (минимум, максимум, среднее), поэтому количество значений превышает установленный ориентир. Планировщик получает оценку, используя частоту элемента 2:

=> SELECT 0.4779412 * reltuples rows
FROM pg_class
WHERE relname = 'pg_constraint';
    rows
------------
 65.9558856
(1 row)
=> RESET default_statistics_target;
RESET
=> ANALYZE pg_constraint;
ANALYZE

Page 19: Частные и общие планы

Частные планы

  • может быть полезен при неравномерном распределении,
  • но запрос планируется заново при каждом вызове
  • Seq Scan on tasks
  • Index Scan on tasks
  • Index Cond: status = ’todo'
  • Filter: status = ‘done’
  • строится
  • с учетом значений
  • параметров
  • Общий план
    • оптимален для равномерного распределения
    • кешируется в сеансе для подготовленных операторов
    • Index Scan on tasks
    • Index Cond: id = $1
    • строится
    • без учета значений
    • параметров

Комментарии преподавателя

При простом протоколе запросов (вспомните тему «Планирование и выполнение») каждый запрос планируется заново с учетом значений параметров.

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

При выполнении подготовленного оператора есть варианты. Запрос может, как обычно, планироваться каждый раз заново с учетом значений параметров. Такие планы называются частными (custom).

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

Но запрос можно спланировать и без учета параметров. Это позволяет сохранить не только дерево разбора, но и сам план, и не тратить время на повторное планирование. Такой план называется общим (generic).

Общий план отлично работает в случае равномерного распределения, когда селективность условия не зависит от конкретного значения. Но в случае неравномерного распределения общий план может хорошо работать для одних значений и плохо — для других.

Разберемся, как планировщик решает, какой вариант использовать.

Page 20: Частные и общие планы

Подготовим запрос и создадим индекс:

=> PREPARE f(text) AS
SELECT * FROM flights WHERE status = $1;
PREPARE
=> CREATE INDEX ON flights(status);
CREATE INDEX

Поиск отмененных рейсов будет использовать индекс, поскольку статистика говорит о том, что таких рейсов мало:

=> EXPLAIN EXECUTE f('Cancelled');
                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Index Scan using flights_status_idx on flights  (cost=0.29..434.19 rows=415 width=63)
   Index Cond: ((status)::text = 'Cancelled'::text)
(2 rows)

А поиск прибывших рейсов — нет, поскольку их много:

=> EXPLAIN EXECUTE f('Arrived');
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)

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

=> EXPLAIN EXECUTE f('Arrived');
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)
=> EXPLAIN EXECUTE f('Arrived');
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)
=> EXPLAIN EXECUTE f('Arrived');
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)

Количество выполнений общего и частных планов можно посмотреть в представлении pg_prepared_statements:

=> SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;

Page 21: Page 21

 name | generic_plans | custom_plans
------+---------------+--------------
 f    |             0 |            5
(1 row)

В следующий раз планировщик переключится на общий план. Вместо конкретного значения в плане будет указан номер параметра:

=> EXPLAIN EXECUTE f('Arrived');
                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights  (cost=401.83..3473.47 rows=35811 width=63)
   Recheck Cond: ((status)::text = $1)
   ->  Bitmap Index Scan on flights_status_idx  (cost=0.00..392.88 rows=35811 width=0)
         Index Cond: ((status)::text = $1)
(4 rows)
=> SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;
 name | generic_plans | custom_plans
------+---------------+--------------
 f    |             1 |            5
(1 row)

Имея текст запроса с параметрами, представленными номерами (например, из журнала сообщений сервера), можно посмотреть общий план такого запроса следующим образом:

=> EXPLAIN (generic_plan)
SELECT * FROM flights WHERE status = $1;
                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Bitmap Heap Scan on flights  (cost=401.83..3473.47 rows=35811 width=63)
   Recheck Cond: ((status)::text = $1)
   ->  Bitmap Index Scan on flights_status_idx  (cost=0.00..392.88 rows=35811 width=0)
         Index Cond: ((status)::text = $1)
(4 rows)

Переход на общий план может быть нежелателен в случае неравномерного распределения значений. Параметр plan_cache_mode позволяет отключить использование частных планов (или наоборот, с самого начала использовать общий план):

=> SHOW plan_cache_mode;
 plan_cache_mode
-----------------
 auto
(1 row)
=> SET plan_cache_mode = 'force_custom_plan';
SET
=> EXPLAIN EXECUTE f('Arrived');
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)
=> RESET plan_cache_mode;
RESET

Частичный индекс

Поиск уже прибывших рейсов будет выполняться последовательным сканированием таблицы flights, поэтому индексные записи с ключом Arrived никогда не используются. При этом они занимают место и требуют ресурсов для синхронизации с изменениями строк таблицы. Проверим размер полного индекса по полю status:

Page 22: Page 22

=> SELECT indexname, pg_size_pretty(pg_relation_size(indexname::text)) size
FROM pg_indexes
WHERE indexname LIKE 'flights_status%';
     indexname      |  size
--------------------+---------
 flights_status_idx | 1472 kB
(1 row)

Можно поместить в индекс только ссылки на строки, удовлетворяющие условиям с высокой селективностью. Заменим полный индекс на частичный:

=> DROP INDEX flights_status_idx;
DROP INDEX
=> CREATE INDEX on flights(status)
WHERE status IN ('Delayed', 'Departed', 'Cancelled');
CREATE INDEX

Размер частичного индекса существенно меньше, чем полного:

=> SELECT indexname, pg_size_pretty(pg_relation_size(indexname::text)) size
FROM pg_indexes
WHERE indexname LIKE 'flights_status%';
     indexname      | size
--------------------+-------
 flights_status_idx | 16 kB
(1 row)

Когда это выгодно (для редких значений) будет использоваться частичный индекс:

=> EXPLAIN SELECT *
FROM flights WHERE status = 'Cancelled';
                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Index Scan using flights_status_idx on flights  (cost=0.15..438.04 rows=415 width=63)
   Index Cond: ((status)::text = 'Cancelled'::text)
(2 rows)

…а для частых значений — последовательное сканирование:

=> EXPLAIN SELECT *
FROM flights WHERE status = 'Arrived';
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5309.84 rows=198480 width=63)
   Filter: ((status)::text = 'Arrived'::text)
(2 rows)

Таким образом можно сэкономить дисковое пространство и снизить затраты на сопровождение полного индекса.

Индекс по выражению

Если в условиях используются не столбцы, а, например, обращения к функциям, планировщик не учитывает множество значений. Например, рейсов, совершенных в январе, будет примерно 1/12 от общего количества:

=> SELECT count(*) FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;
 count
-------
 16831
(1 row)

Однако планировщик не понимает смысла функции extract и использует фиксированную селективность 0,5%:

=> EXPLAIN
SELECT * FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;

Page 23: Page 23

                                                  QUERY PLAN
------------------------------------------------------------------------------------------
---------------------
 Gather  (cost=1000.00..5943.27 rows=1074 width=63)
   Workers Planned: 1
   ->  Parallel Seq Scan on flights  (cost=0.00..4835.87 rows=632 width=63)
         Filter: (EXTRACT(month FROM (scheduled_departure AT TIME ZONE
'Europe/Moscow'::text)) = '1'::numeric)
(4 rows)
=> SELECT 214867 * 0.005;
 ?column?
----------
 1074.335
(1 row)

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

=> CREATE INDEX ON flights(
  extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow')
);
CREATE INDEX
=> ANALYZE flights;
ANALYZE

Теперь оценка исправилась:

=> EXPLAIN
SELECT * FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;
                                                    QUERY PLAN
------------------------------------------------------------------------------------------
-------------------------
 Bitmap Heap Scan on flights  (cost=317.98..3234.52 rows=16717 width=63)
   Recheck Cond: (EXTRACT(month FROM (scheduled_departure AT TIME ZONE
'Europe/Moscow'::text)) = '1'::numeric)
   ->  Bitmap Index Scan on flights_extract_idx  (cost=0.00..313.80 rows=16717 width=0)
         Index Cond: (EXTRACT(month FROM (scheduled_departure AT TIME ZONE
'Europe/Moscow'::text)) = '1'::numeric)
(4 rows)

Статистика для индексов по выражению хранится вместе с базовой статистикой по столбцам таблиц:

=> SELECT n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'flights_extract_idx' \gx
-[ RECORD 1
]-----+-----------------------------------------------------------------------------------
-------------------------------------------
n_distinct        | 12
most_common_vals  | {8,9,7,10,5,1,12,4,3,6,11,2}
most_common_freqs |
{0.122433335,0.111033335,0.080233335,0.07966667,0.0795,0.0778,0.07703333,0.077,0.076233335
,0.07536667,0.07266667,0.071033336}

Индекс можно построить только по детерминированному выражению, то есть для одинаковых значений параметров (столбцов) оно всегда должно выдавать одинаковый результат. Подробнее об этом рассказано в теме «Функции».

Page 24: Итоги

Характеристики данных собираются в виде статистики

  • Статистика нужна для оценки кардинальности
  • Кардинальность используется для оценки стоимости
  • Стоимость позволяет выбрать оптимальный план
  • Основа успеха — адекватная статистика и корректная кардинальность

Page 25: Практика

  1. Создайте индекс на таблице билетов (tickets) по имени пассажира (passenger_name). 2. Какая статистика имеется для этой таблицы? 3. Объясните оценку кардинальности и выбор плана выполнения следующих запросов: а) выборка всех билетов, б) выборка билетов на имя ALEKSANDR IVANOV, в) выборка билетов на имя ANNA VASILEVA, г) выборка билета с идентификатором 0005432000284.

Page 26: 1. Индекс

=> CREATE INDEX ON tickets(passenger_name);
CREATE INDEX

2. Наличие статистики

Некоторые основные значения:

=> SELECT reltuples, relpages FROM pg_class WHERE relname = 'tickets';
  reltuples   | relpages
--------------+----------
 2.949857e+06 |    49415
(1 row)
=> SELECT
    attname,
    null_frac nul,
    n_distinct,
    left(most_common_vals::text,20) mcv,
    cardinality(most_common_vals) mc,
    left(histogram_bounds::text,20) histogram,
    cardinality(histogram_bounds) hist,
    correlation
  FROM pg_stats WHERE tablename = 'tickets';
    attname     | nul | n_distinct  |         mcv          | mc  |      histogram       |
hist |  correlation
----------------+-----+-------------+----------------------+-----+----------------------+-
-----+----------------
 ticket_no      |   0 |          -1 |                      |     | {0005432000293,00054 |
 101 |     0.99999994
 book_ref       |   0 | -0.50253797 |                      |     | {000204,0269B4,050E4 |
 101 |   0.0029648095
 passenger_id   |   0 |          -1 |                      |     | {"0001 012839","0093 |
 101 |   0.0072664083
 passenger_name |   0 |       10367 | {"ALEKSANDR IVANOV", | 100 | {"ADELINA CHERNOVA", |
 101 |   -0.005419655
 contact_data   |   0 |          -1 |                      |     | {"{\"phone\": \"+700 |
 101 | -0.00046799128
(5 rows)

Ни один столбец не содержит неопределенных значений. Уникальных номеров бронирования примерно в два раза меньше, чем строк в таблице (то есть на каждое бронирование в среднем приходится два билета). Имеется около 10000 разных имен. Все остальные столбцы содержат уникальные значения. Размеры массивов наиболее частых значений и гистограмм соответствуют значению параметра default_statistics_target (100). Для имен пассажиров есть наиболее частые значения. Для других столбцов они не имеют смысла, так как максимальное количество билетов (5) встречается в 194 бронированиях, а остальные столбцы уникальны. Гистограммы есть для всех столбцов, они нужны для оценки предикатов с условиями неравенства. Строки таблицы физически упорядочены по номеру билета. Данные в других столбцах расположены более или менее хаотично.

3. Планы запросов

=> EXPLAIN SELECT * FROM tickets;
                            QUERY PLAN
-------------------------------------------------------------------
 Seq Scan on tickets  (cost=0.00..78913.57 rows=2949857 width=104)
(1 row)

Кардинальность равна числу строк в таблице; выбрано полное сканирование.

=> EXPLAIN SELECT * FROM tickets WHERE passenger_name = 'ALEKSANDR IVANOV';

Page 27: Page 27

                                         QUERY PLAN
------------------------------------------------------------------------------------------
---
 Bitmap Heap Scan on tickets  (cost=73.20..17677.38 rows=6293 width=104)
   Recheck Cond: (passenger_name = 'ALEKSANDR IVANOV'::text)
   ->  Bitmap Index Scan on tickets_passenger_name_idx  (cost=0.00..71.63 rows=6293
width=0)
         Index Cond: (passenger_name = 'ALEKSANDR IVANOV'::text)
(4 rows)

Селективность оценена по списку наиболее частых значений; выбрано сканирование по битовой карте.

=> EXPLAIN SELECT * FROM tickets WHERE passenger_name = 'ANNA VASILEVA';
                                        QUERY PLAN
------------------------------------------------------------------------------------------
-
 Bitmap Heap Scan on tickets  (cost=6.47..1004.20 rows=263 width=104)
   Recheck Cond: (passenger_name = 'ANNA VASILEVA'::text)
   ->  Bitmap Index Scan on tickets_passenger_name_idx  (cost=0.00..6.40 rows=263 width=0)
         Index Cond: (passenger_name = 'ANNA VASILEVA'::text)
(4 rows)

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

=> EXPLAIN SELECT * FROM tickets WHERE ticket_no = '0005432000284';
                                  QUERY PLAN
------------------------------------------------------------------------------
 Index Scan using tickets_pkey on tickets  (cost=0.43..8.45 rows=1 width=104)
   Index Cond: (ticket_no = '0005432000284'::bpchar)
(2 rows)

Кардинальность равна 1, так как значения этого столбца уникальны; выбрано индексное сканирование.

12 Занятие 12: Статистика / Расширенная статистика

Исходная страница в нативном Markdown: Статистика / Расширенная статистика
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_12_stats_extended.html Archive: QPT-handouts-16.zip Страниц PDF: 19

Page 1: Статистика / Расширенная статистика

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Функциональная зависимость
  • Наиболее частые комбинации значений
  • Число уникальных комбинаций значений
  • Статистика по выражениям

Page 3: Расширенная статистика

Включает

  • многовариантную статистику (по нескольким столбцам)
  • статистику по выражениям
  • Объект базы данных, создается вручную
    • хранится в pg_statistic_ext и pg_statistic_ext_data
    • представления pg_stats_ext и pg_stats_ext_exprs
  • После создания статистика собирается автоматически

Комментарии преподавателя

Базовой статистики, которая собирается автоматически, может не хватать для точных оценок кардинальности и селективности.

PostgreSQL позволяет администратору вручную определить, какая дополнительная — расширенная — статистика требуется. Можно собрать статистику, охватывающую не один столбец, а сразу несколько (многовариантная статистика), или статистику по произвольному выражению.

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

Расширенная статистика создается командой CREATE STATISTICS. После того, как объект создан, соответствующая статистика будет собираться автоматически в фоновом режиме или командой ANALYZE.

https://postgrespro.ru/docs/postgresql/16/planner-stats#PLANNER-STATS-E XTENDED

Собранная информация хранится в таблицах pg_statistic_ext и pg_statistic_ext_data; доступная пользователю статистика отображается в представлениях pg_stats_ext и pg_stats_ext_exprs.

Page 4: Зависимые столбцы

Source slide page 4: Зависимые столбцы{#source-figure-004 caption=“Source slide page 4: Зависимые столбцы”}

Функциональная зависимость (dependencies)

  • значение одного столбца определяет значение другого столбца
  • статистика улучшает оценку селективности условий

Комментарии преподавателя

Существует несколько видов многовариантной статистики (то есть статистики по нескольким столбцам таблицы), которые можно указать при создании объекта расширенной статистики.

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

В примере на слайде почтовый индекс однозначно определяет город, поэтому селективность условия city = ‘Самара’ and index = ‘443000’ определяется селективностью предиката index = ‘443000’. Такие предикаты, селективность которых нельзя рассчитывать отдельно друг от друга, называются коррелированными.

https://postgrespro.ru/docs/postgresql/16/planner-stats#PLANNER-STATS-E XTENDED-FUNCTIONAL-DEPS

Page 5: Функциональные зависимости

Рассмотрим запрос с двумя условиями:

=> SELECT count(*)
FROM flights
WHERE flight_no = 'PG0007' AND departure_airport = 'VKO';
 count
-------
   396
(1 row)

Оценка оказывается сильно заниженной:

=> EXPLAIN
SELECT * FROM flights
WHERE flight_no = 'PG0007' AND departure_airport = 'VKO';
                                                QUERY PLAN
------------------------------------------------------------------------------------------
-----------------
 Bitmap Heap Scan on flights  (cost=12.18..1277.67 rows=27 width=63)
   Recheck Cond: (flight_no = 'PG0007'::bpchar)
   Filter: (departure_airport = 'VKO'::bpchar)
   ->  Bitmap Index Scan on flights_flight_no_scheduled_departure_key  (cost=0.00..12.18
rows=501 width=0)
         Index Cond: (flight_no = 'PG0007'::bpchar)
(5 rows)

Причина в том, что планировщик полагается на независимость предикатов и считает итоговую селективность как произведение селективностей предикатов. Это хорошо видно в приведенном плане: оценка в узле Bitmap Index Scan (условие на flight_no) верная, а после фильтрации в узле Bitmap Heap Scan (условие на departure_airport) — заниженная. Однако мы понимаем, что номер рейса однозначно определяет аэропорт отправления: фактически, второе условие избыточно (конечно, считая, что аэропорт указан правильно). Это можно объяснить планировщику с помощью статистики по функциональной зависимости:

=> CREATE STATISTICS (dependencies)
ON flight_no, departure_airport FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE

Собранная статистика хранится в следующем виде:

=> SELECT dependencies
FROM pg_stats_ext
WHERE statistics_name = 'flights_flight_no_departure_airport_stat';
               dependencies
------------------------------------------
 {"2 => 5": 1.000000, "5 => 2": 0.011233}
(1 row)

Сначала идут порядковые номера атрибутов, а после двоеточия — коэффициент зависимости.

=> EXPLAIN
SELECT * FROM flights
WHERE flight_no = 'PG0007' AND departure_airport = 'VKO';

Page 6: Page 6

                                                QUERY PLAN
------------------------------------------------------------------------------------------
-----------------
 Bitmap Heap Scan on flights  (cost=10.55..814.31 rows=275 width=63)
   Recheck Cond: (flight_no = 'PG0007'::bpchar)
   Filter: (departure_airport = 'VKO'::bpchar)
   ->  Bitmap Index Scan on flights_flight_no_scheduled_departure_key  (cost=0.00..10.48
rows=275 width=0)
         Index Cond: (flight_no = 'PG0007'::bpchar)
(5 rows)

Теперь оценка улучшилась. Команда \d показывает объекты расширенной статистики для конкретной таблицы:

=> \d flights
                                              Table "bookings.flights"
       Column        |           Type           | Collation | Nullable |
Default
---------------------+--------------------------+-----------+----------+------------------
--------------------------
 flight_id           | integer                  |           | not null |
nextval('flights_flight_id_seq'::regclass)
 flight_no           | character(6)             |           | not null |
 scheduled_departure | timestamp with time zone |           | not null |
 scheduled_arrival   | timestamp with time zone |           | not null |
 departure_airport   | character(3)             |           | not null |
 arrival_airport     | character(3)             |           | not null |
 status              | character varying(20)    |           | not null |
 aircraft_code       | character(3)             |           | not null |
 actual_departure    | timestamp with time zone |           |          |
 actual_arrival      | timestamp with time zone |           |          |
Indexes:
    "flights_pkey" PRIMARY KEY, btree (flight_id)
    "flights_flight_no_scheduled_departure_key" UNIQUE CONSTRAINT, btree (flight_no,
scheduled_departure)
Check constraints:
    "flights_check" CHECK (scheduled_arrival > scheduled_departure)
    "flights_check1" CHECK (actual_arrival IS NULL OR actual_departure IS NOT NULL AND
actual_arrival IS NOT NULL AND actual_arrival > actual_departure)
    "flights_status_check" CHECK (status::text = ANY (ARRAY['On Time'::character
varying::text, 'Delayed'::character varying::text, 'Departed'::character varying::text,
'Arrived'::character varying::text, 'Scheduled'::character varying::text,
'Cancelled'::character varying::text]))
Foreign-key constraints:
    "flights_aircraft_code_fkey" FOREIGN KEY (aircraft_code) REFERENCES
aircrafts_data(aircraft_code)
    "flights_arrival_airport_fkey" FOREIGN KEY (arrival_airport) REFERENCES
airports_data(airport_code)
    "flights_departure_airport_fkey" FOREIGN KEY (departure_airport) REFERENCES
airports_data(airport_code)
Referenced by:
    TABLE "ticket_flights" CONSTRAINT "ticket_flights_flight_id_fkey" FOREIGN KEY
(flight_id) REFERENCES flights(flight_id)
Statistics objects:
    "bookings.flights_flight_no_departure_airport_stat" (dependencies) ON flight_no,
departure_airport FROM flights

В разделе Statistics objects показаны имена, столбцы и нестандартные целевые значения объектов статистики.

Page 7: Частые комбинации

Source slide page 7: Частые комбинации{#source-figure-007 caption=“Source slide page 7: Частые комбинации”}

Наиболее частые комбинации значений (mcv)

  • как pg_stats.most_common_vals/freqs, но для нескольких столбцов
  • статистика улучшает оценку селективности условий

Комментарии преподавателя

Список наиболее частых комбинаций значений позволяет сохранить несколько комбинаций значений и их частоту.

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

https://postgrespro.ru/docs/postgresql/16/planner-stats#PLANNER-STATS-E XTENDED-MCV-LISTS

Page 8: Частые комбинации

Столбцы могут быть коррелированы, но не всегда между ними есть прямая функциональная зависимость. Выполним такой запрос:

=> EXPLAIN (analyze, timing off, summary off)
SELECT * FROM flights
WHERE departure_airport = 'LED' AND aircraft_code = '321';
                                              QUERY PLAN
------------------------------------------------------------------------------------------
-------------
 Gather  (cost=1000.00..5594.79 rows=749 width=63) (actual rows=5148 loops=1)
   Workers Planned: 1
   Workers Launched: 1
   ->  Parallel Seq Scan on flights  (cost=0.00..4519.89 rows=441 width=63) (actual
rows=2574 loops=2)
         Filter: ((departure_airport = 'LED'::bpchar) AND (aircraft_code = '321'::bpchar))
         Rows Removed by Filter: 104860
(6 rows)

Планировщик ошибается в несколько раз. Учет функциональной зависимости недостаточно исправит ситуацию:

=> CREATE STATISTICS (dependencies)
ON departure_airport, aircraft_code FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE
=> EXPLAIN
SELECT * FROM flights
WHERE departure_airport = 'LED' AND aircraft_code = '321';
                                        QUERY PLAN
------------------------------------------------------------------------------------------
-
 Gather  (cost=1000.00..5696.09 rows=1762 width=63)
   Workers Planned: 1
   ->  Parallel Seq Scan on flights  (cost=0.00..4519.89 rows=1036 width=63)
         Filter: ((departure_airport = 'LED'::bpchar) AND (aircraft_code = '321'::bpchar))
(4 rows)

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

=> DROP STATISTICS flights_departure_airport_aircraft_code_stat;
DROP STATISTICS
=> CREATE STATISTICS (mcv)
ON departure_airport, aircraft_code FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE
=> EXPLAIN
SELECT * FROM flights
WHERE departure_airport = 'LED' AND aircraft_code = '321';
                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5847.00 rows=5164 width=63)
   Filter: ((departure_airport = 'LED'::bpchar) AND (aircraft_code = '321'::bpchar))
(2 rows)

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

Page 9: Page 9

для конкретной статистики:

=> ALTER STATISTICS flights_departure_airport_aircraft_code_stat
SET STATISTICS 300;
ALTER STATISTICS
=> ANALYZE flights;
ANALYZE
=> EXPLAIN (analyze, timing off, summary off)
SELECT * FROM flights
WHERE departure_airport = 'LED' AND aircraft_code = '321';
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Seq Scan on flights  (cost=0.00..5847.00 rows=5169 width=63) (actual rows=5148 loops=1)
   Filter: ((departure_airport = 'LED'::bpchar) AND (aircraft_code = '321'::bpchar))
   Rows Removed by Filter: 209719
(3 rows)

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

=> SELECT values, frequency
FROM pg_statistic_ext
  JOIN pg_statistic_ext_data ON oid = stxoid,
  pg_mcv_list_items(stxdmcv) m
WHERE stxname = 'flights_departure_airport_aircraft_code_stat'
LIMIT 10;
  values   |      frequency
-----------+----------------------
 {SVO,SU9} |  0.03048888888888889
 {DME,SU9} |  0.03017777777777778
 {LED,321} | 0.024055555555555556
 {DME,CR2} |               0.0238
 {VKO,CR2} |                0.022
 {BZK,SU9} |               0.0187
 {SVO,CR2} | 0.018266666666666667
 {KJA,CN1} | 0.015111111111111112
 {VKO,SU9} | 0.013333333333333334
 {DME,321} | 0.012977777777777777
(10 rows)

Page 10: Уникальные комбинации

Source slide page 10: Уникальные комбинации{#source-figure-010 caption=“Source slide page 10: Уникальные комбинации”}

Число уникальных комбинаций значений (ndistinct)

  • как pg_stats.ndistinct, но для нескольких столбцов
  • статистика улучшает оценку кардинальности для группировки

Комментарии преподавателя

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

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

https://postgrespro.ru/docs/postgresql/16/planner-stats#PLANNER-STATS-E XTENDED-N-DISTINCT-COUNTS

Page 11: Уникальные комбинации

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

=> SELECT count(*) FROM (
  SELECT DISTINCT departure_airport, arrival_airport FROM flights
) t;
 count
-------
   618
(1 row)

Но планировщик не знает об этом:

=> EXPLAIN
SELECT DISTINCT departure_airport, arrival_airport FROM flights;
                             QUERY PLAN
---------------------------------------------------------------------
 HashAggregate  (cost=5847.01..5955.16 rows=10816 width=8)
   Group Key: departure_airport, arrival_airport
   ->  Seq Scan on flights  (cost=0.00..4772.67 rows=214867 width=8)
(3 rows)

Расширенная статистика позволяет исправить и эту оценку (если не указать вид статистики, в создаваемый объект будут включены все поддерживаемые виды):

=> CREATE STATISTICS
ON departure_airport, arrival_airport FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE
=> EXPLAIN
SELECT DISTINCT departure_airport, arrival_airport FROM flights;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
------
 Unique  (cost=5616.51..5621.15 rows=618 width=8)
   ->  Sort  (cost=5616.51..5618.06 rows=618 width=8)
         Sort Key: departure_airport, arrival_airport
         ->  Gather  (cost=5519.88..5587.86 rows=618 width=8)
               Workers Planned: 1
               ->  HashAggregate  (cost=4519.88..4526.06 rows=618 width=8)
                     Group Key: departure_airport, arrival_airport
                     ->  Parallel Seq Scan on flights  (cost=0.00..3887.92 rows=126392
width=8)
(8 rows)

Статистику по уникальным комбинациям можно увидеть так:

=> SELECT n_distinct
FROM pg_stats_ext
WHERE statistics_name = 'flights_departure_airport_arrival_airport_stat';
  n_distinct
---------------
 {"5, 6": 618}
(1 row)

Посмотреть список всех объектов расширенной статистики можно командой \dX:

=> \x \dX \x

Page 12: Page 12

Expanded display is on.
List of extended statistics
-[ RECORD 1 ]+------------------------------------------------
Schema       | bookings
Name         | flights_departure_airport_aircraft_code_stat
Definition   | departure_airport, aircraft_code FROM flights
Ndistinct    |
Dependencies |
MCV          | defined
-[ RECORD 2 ]+------------------------------------------------
Schema       | bookings
Name         | flights_departure_airport_arrival_airport_stat
Definition   | departure_airport, arrival_airport FROM flights
Ndistinct    | defined
Dependencies | defined
MCV          | defined
-[ RECORD 3 ]+------------------------------------------------
Schema       | bookings
Name         | flights_flight_no_departure_airport_stat
Definition   | flight_no, departure_airport FROM flights
Ndistinct    |
Dependencies | defined
MCV          |
Expanded display is off.

Отображается наличие статистики по типам (Ndistinct, Dependencies, MCV), а значения нужно смотреть в таблице pg_statistic_ext_data.

Page 13: Статистика по выражению

Source slide page 13: Статистика по выражению{#source-figure-013 caption=“Source slide page 13: Статистика по выражению”}

Расширенная статистика по выражению

  • как если бы в таблице был определен генерируемый столбец
  • статистика улучшает оценку селективности условий с выражениями

Комментарии преподавателя

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

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

Заметим, что выражения также можно использовать вместо имен столбцов в многовариантной статистике любого типа.

Page 14: Статистика по выражению

Как мы видели в теме «Базовая статистика», если в условии используются выражение, планировщик, не имея информации о селективности, использует константу и часто ошибается:

=> SELECT count(*) FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;
 count
-------
 16831
(1 row)
=> EXPLAIN
SELECT * FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;
                                                  QUERY PLAN
------------------------------------------------------------------------------------------
---------------------
 Gather  (cost=1000.00..5943.27 rows=1074 width=63)
   Workers Planned: 1
   ->  Parallel Seq Scan on flights  (cost=0.00..4835.87 rows=632 width=63)
         Filter: (EXTRACT(month FROM (scheduled_departure AT TIME ZONE
'Europe/Moscow'::text)) = '1'::numeric)
(4 rows)

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

=> CREATE STATISTICS
ON extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow')
FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE
=> EXPLAIN
SELECT * FROM flights
WHERE extract(month FROM scheduled_departure AT TIME ZONE 'Europe/Moscow') = 1;
                                               QUERY PLAN
------------------------------------------------------------------------------------------
---------------
 Seq Scan on flights  (cost=0.00..6384.17 rows=16860 width=63)
   Filter: (EXTRACT(month FROM (scheduled_departure AT TIME ZONE 'Europe/Moscow'::text))
= '1'::numeric)
(2 rows)

Оценка стала корректной. Расширенная статистика по выражениям хранится отдельно. Вот несколько столбцов:

=> SELECT statistics_name, expr, n_distinct, most_common_vals
FROM pg_stats_ext_exprs \gx
-[ RECORD 1
]----+-----------------------------------------------------------------------------
statistics_name  | flights_expr_stat
expr             | EXTRACT(month FROM (scheduled_departure AT TIME ZONE
'Europe/Moscow'::text))
n_distinct       | 12
most_common_vals | {8,9,10,5,1,7,3,12,4,6,11,2}

Page 15: Итоги

Расширенная статистика помогает в сложных случаях

  • Создается вручную, поддерживается автоматически
  • Может увеличить затраты на анализ и планирование

Page 16: Практика

  1. Используя команды из демонстрации, создайте статистики типа dependencies, mcv и ndistinct для таблицы flights. Измерьте время выполнения команды ANALYZE. Удалите расширенные статистики, снова измерьте время выполнения команды ANALYZE и сравните с предыдущим результатом. 2. Напишите запрос, выбирающий все перелеты в бизнес-классе стоимостью более 100 тыс. рублей. Поможет ли расширенная статистика улучшить оценку кардинальности результата? Если да, то какой тип статистики лучше использовать?

Комментарии преподавателя

  1. Выполняйте измерение времени несколько раз и усредняйте результаты, чтобы сгладить неравномерности.

Page 17: 1. Затраты на анализ

Создадим расширенные статистики аналогично тому, как это было сделано в демонстрации. Статистика по функциональной зависимости:

=> CREATE STATISTICS flights_dep(dependencies)
ON flight_no, departure_airport FROM flights;
CREATE STATISTICS

Списки наиболее частых комбинаций значений:

=> CREATE STATISTICS flights_mcv(mcv)
ON departure_airport, aircraft_code FROM flights;
CREATE STATISTICS

Статистика по уникальным комбинациям значений:

=> CREATE STATISTICS flights_nd(ndistinct)
ON departure_airport, arrival_airport FROM flights;
CREATE STATISTICS

Измерим время анализа.

=> \timing on
Timing is on.
=> ANALYZE flights;
ANALYZE
Time: 875,119 ms

В первый раз анализ может занимать существенно больше времени, чем обычно.

=> ANALYZE flights;
ANALYZE
Time: 680,730 ms
=> ANALYZE flights;
ANALYZE
Time: 620,083 ms
=> \timing off
Timing is off.

Удалим созданные расширенные статистики:

=> DROP STATISTICS flights_dep;
DROP STATISTICS
=> DROP STATISTICS flights_mcv;
DROP STATISTICS
=> DROP STATISTICS flights_nd;
DROP STATISTICS

Повторно измеряем время:

=> \timing on
Timing is on.
=> ANALYZE flights;
ANALYZE
Time: 385,839 ms
=> ANALYZE flights;
ANALYZE
Time: 415,945 ms
=> \timing off

Page 18: 2. Применение расширенной статистики

Timing is off.

Было создано всего три расширенные статистики, но время анализа до и после удаления заметно различается. С увеличением количества собираемых расширенных статистик будет расти и время на анализ, что может привести к увеличению нагрузки на сервер. Поэтому использовать расширенную статистику нужно осмысленно и только в тех случаях, когда это необходимо. Перелеты в бизнес-классе стоимостью свыше 100 тысяч ₽:

=> EXPLAIN (analyze, timing off, summary off)
SELECT *
FROM ticket_flights
WHERE fare_conditions = 'Business' and amount > 100_000;
                                                    QUERY PLAN
------------------------------------------------------------------------------------------
------------------------
 Gather  (cost=1000.00..124557.57 rows=11764 width=32) (actual rows=111203 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on ticket_flights  (cost=0.00..122381.18 rows=4902 width=32)
(actual rows=37068 loops=3)
         Filter: ((amount > '100000'::numeric) AND ((fare_conditions)::text =
'Business'::text))
         Rows Removed by Filter: 2760216
(6 rows)

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

=> CREATE STATISTICS (mcv) ON fare_conditions, amount FROM ticket_flights;
CREATE STATISTICS
=> ANALYZE ticket_flights;
ANALYZE
=> EXPLAIN (timing off, summary off)
SELECT *
FROM ticket_flights
WHERE fare_conditions = 'Business' and amount > 100_000;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
-------
 Gather  (cost=1000.00..126555.45 rows=31727 width=32)
   Workers Planned: 2
   ->  Parallel Seq Scan on ticket_flights  (cost=0.00..122382.75 rows=13220 width=32)
         Filter: ((amount > '100000'::numeric) AND ((fare_conditions)::text =
'Business'::text))
(4 rows)

Оценка немного улучшилась, но все еще сильно отличается от точного значения. Можно заметить, что доля строк, удовлетворяющих условию, немногим более 1%:

=> SELECT count(*) FILTER (WHERE fare_conditions = 'Business' and amount > 100_000)
  / count(*)::float
FROM ticket_flights;
      ?column?
---------------------
 0.01325130614791586
(1 row)

Поскольку по умолчанию хранится 100 наиболее частых пар, среди них с большой вероятностью встретится не более 1–2 удовлетворяющих условию, и из-за этого оценка оптимизатора не будет адекватной. Попробуем повысить точность, увеличив объем статистики:

=> ALTER STATISTICS ticket_flights_fare_conditions_amount_stat SET STATISTICS 500;
ALTER STATISTICS

Page 19: Page 19

=> ANALYZE ticket_flights;
ANALYZE
=> EXPLAIN (timing off, summary off)
SELECT *
FROM ticket_flights
WHERE fare_conditions = 'Business' and amount > 100_000;
                                           QUERY PLAN
------------------------------------------------------------------------------------------
-------
 Gather  (cost=1000.00..133923.68 rows=105416 width=32)
   Workers Planned: 2
   ->  Parallel Seq Scan on ticket_flights  (cost=0.00..122382.08 rows=43923 width=32)
         Filter: ((amount > '100000'::numeric) AND ((fare_conditions)::text =
'Business'::text))
(4 rows)

Оценка стала практически точной.

13 Занятие 13: Оптимизация запросов / Профилирование

Исходная страница в нативном Markdown: Оптимизация запросов / Профилирование
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_13_optim_profiling.html Archive: QPT-handouts-16.zip Страниц PDF: 20

Page 1: Оптимизация запросов / Профилирование

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Профилирование как инструмент для поиска узких мест
  • Выбор подзадачи для профилирования
  • Средства построения профиля

Page 3: Инструмент

Профилирование

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

Комментарии преподавателя

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

Но как найти тот запрос, который имеет смысл оптимизировать?

В принципе, решение любой задачи оптимизации (не только в контексте СУБД) начинается с профилирования, хоть этот термин и не всегда употребляется явно. Мы должны разбить задачу, вызывающую нарекания, на подзадачи и измерить, какую часть общего времени они занимают. Также полезна информация о числе выполнений каждой из подзадач.

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

Бывает так, что подзадача выполняется быстро, но часто (это, например, характерно для запросов, генерируемых ORM-ами). Может оказаться невозможным ускорить выполнение отдельных запросов, но стоит задаться вопросом: должна ли подзадача выполняться так часто? Такой вопрос может привести к необходимости изменения архитектуры, что всегда непросто, но в итоге дать существенный выигрыш.

Page 4: Что профилировать

Всю активность системы

  • полезно администратору для поиска ресурсоемких задач
  • мониторинг, расширение pg_profile
  • Отдельную задачу, вызывающую нарекания
    • полезно для решения конкретной проблемы
    • чем точнее, тем лучше: широкий охват размывает картину

Комментарии преподавателя

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

Такой профиль должна показывать система мониторинга.

Можно также воспользоваться расширением pg_profile. Оно основано на статистических представлениях PostgreSQL, информация из которых сохраняется в хранилище снимков активности. Изучая и сравнивая между собой снимки, можно выявлять проблемы и причины их возникновения. Автор расширения — Андрей Зубков (https://github.com/zubkov-andrei/pg\_profile).

Детальное обсуждение того, как именно строить систему мониторинга, какие инструменты для этого использовать и какие метрики собирать выходит за рамки курса, но порекомендуем книгу Алексея Лесовского «Мониторинг PostgreSQL»:

https://postgrespro.ru/education/books/monitoring

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

Page 5: Что измерять

Время выполнения

  • имеет смысл для пользователя
  • крайне нестабильный показатель
  • Страничный ввод-вывод
    • стабилен по отношению ко внешним факторам
    • мало что говорит пользователям

Комментарии преподавателя

В каких единицах измерять ресурсы? Самая осмысленная для конечного пользователя характеристика — это время отклика: сколько прошло времени «от нажатия на кнопку» до «получения результата».

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

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

Поэтому очень важно анализировать проблемы на полном наборе данных. Для этого, например, можно использовать Database Lab Engine — средство быстрого создания тонких клонов, разработанное Postgres.ai: https://github.com/postgres-ai/database-lab-engine​.

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

Page 6: Профиль приложения

Подзадачи

  • клиентская часть
  • сервер приложений
  • сервер баз данных
  • ← проблема часто, но не всегда, именно здесь
  • сеть
  • Как профилировать
    • технически трудно, нужны разнообразные средства мониторинга
    • подтвердить предположение обычно несложно

Комментарии преподавателя

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

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

К сожалению, получить такой полный профиль достаточно трудно. Для этого все компоненты информационной системы должны быть снабжены подсистемами мониторинга и трассировки, учитывающими особенности именно этой системы. Но обычно несложно измерить общее время отклика (хотя бы и секундомером) и сравнить с общим временем работы СУБД; убедиться в отсутствии существенных сетевых задержек и т. п. Если же этого не проделать, то вполне может оказаться, что мы будем искать ключи там, где светлее, а не там, где их потеряли. Собственно, весь смысл профилирования и состоит в том, чтобы выяснить, что именно нуждается в оптимизации.

Далее мы будем считать установленным, что проблема именно в СУБД.

Page 7: Профиль PL/pgSQL

Расширение PL Profiler

  • стороннее расширение
  • предназначено только для функций на PL/pgSQL
  • профилирование отдельного скрипта или сеанса
  • отчет о работе в html, включая flame graph
  • Расширение plpgsql_check
    • стороннее расширение
    • проверяет PL/pgSQL и встроенный SQL
    • позволяет находить ошибки компиляции
    • поиск зависимых объектов в функциях
    • возможность автоматического профилирования функций

Комментарии преподавателя

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

Однако серверная часть приложения, выполняющаяся в СУБД, может состоять не только из SQL-запросов, но и содержать процедурный код. Если код написан на языке PL/pgSQL, то для его профилирования можно воспользоваться сторонним расширением PL Profiler (основной автор — Ян Вик: https://github.com/bigsql/plprofiler).

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

Еще можно порекомендовать расширение plpgsql_check (основной автор — Павел Стехуле: https://github.com/okbob/plpgsql\_check). Это расширение проверяет достоверность идентификаторов SQL, используемых в коде PL/pgSQL, пытается выявить проблемы с производительностью, имеет встроенный профилировщик кода.

Поскольку курс посвящен оптимизации запросов SQL, мы не будем рассматривать эти расширения, но упоминаем их для полноты картины.

Page 8: Профиль запросов

Журнал сообщений сервера

  • включается конфигурационными параметрами:
  • log_min_duration_statement = 0
  • время и текст всех запросов
  • log_line_prefix
  • идентифицирующая информация
  • сложно включить для отдельного сеанса
  • большой объем; при увеличении порога времени теряем информацию
  • не отслеживаются вложенные запросы
  • (можно использовать расширение auto_explain)
  • анализ внешними средствами, такими, как pgBadger

Комментарии преподавателя

Для получения профиля по выполняемым SQL-запросам есть два основных средства, встроенных в PostgreSQL: журнал сообщений сервера и статистика.

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

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

Для анализа вложенных запросов (когда в запросе вызывается функция, содержащая другие запросы) потребуется модуль auto_explain (https://postgrespro.ru/docs/postgresql/16/auto-explain).

Следующая проблема — анализ журнала. Для этого приходится применять внешние средства, из которых стандартом де-факто является расширение pgBadger (https://github.com/darold/pgbadger).

Разумеется, можно выводить в журнал и собственные сообщения, если они могут помочь делу.

Page 9: Профиль запросов

Расширение pg\_stat\_statements

  • подробная информация о запросах в представлении
  • (в том числе о вложенных)
  • ограниченный размер хранилища
  • запросы считаются одинаковыми «с точностью до констант»,
  • даже если имеют разные планы выполнения
  • идентификация только по имени пользователя и базе данных
  • унифицированный идентификатор запроса (compute_query_id = auto)

Комментарии преподавателя

Второй способ — статистика, а именно расширение pg_stat_statements ( https://postgrespro.ru/docs/postgresql/16/pgstatstatements).

Расширение собирает достаточно подробную информацию о выполняемых запросах (в том числе в терминах ввода-вывода страниц) и отображает ее в представлении pg_stat_statements.

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

При этом «одинаковыми» считаются запросы, имеющие одинаковое (с точностью до констант) дерево разбора. Надо иметь в виду, что такие запросы могли иметь разные планы выполнения и выполняться разное время.

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

При установке конфигурационного параметра compute_query_id в значение auto или on ядро СУБД будет вычислять единый идентификатор запроса. Его можно выводить в журнал сообщений, настроив параметр log_line_prefix, а также использовать для соединения информации из ядра (колонка pg_stat_activity.query_id), pg_stat_statements и других расширений.

https://postgrespro.ru/docs/postgresql/16/runtime-config-statistics#GUC-CO MPUTE-QUERY-ID

Page 10: Расширение pg_stat_statements

Подключим расширение pg_stat_statements, с помощью которого будем строить профиль:

=> CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION
=> ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements';
ALTER SYSTEM

Подключение библиотеки требует перезагрузки.

student$ sudo pg_ctlcluster 16 main restart
student$ psql demo

Настроим расширение так, чтобы собиралась информация о всех запросах, в том числе вложенных:

=> SET pg_stat_statements.track = 'all';
SET

Создадим представление, чтобы смотреть на собранную статистику выполнения операторов:

=> CREATE VIEW statements_v AS
SELECT
  queryid,
  toplevel,
  substring(regexp_replace(query,' +',' ','g') FOR 55) AS query,
  calls,
  round(total_exec_time)/1000 AS time_sec,
  shared_blks_hit + shared_blks_read + shared_blks_written AS shared_blks
FROM pg_stat_statements
ORDER BY total_exec_time DESC;
CREATE VIEW

Здесь мы для простоты показываем только часть полей и выводим только общее число страниц кеша.

Профиль выполнения

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

=> CREATE FUNCTION qty(aircraft_code char, fare_conditions varchar)
RETURNS bigint AS $$
  SELECT count(*)
  FROM flights f
    JOIN boarding_passes bp ON bp.flight_id = f.flight_id
    JOIN seats s ON s.aircraft_code = f.aircraft_code AND s.seat_no = bp.seat_no
  WHERE f.aircraft_code = qty.aircraft_code AND s.fare_conditions = qty.fare_conditions;
$$ STABLE LANGUAGE sql;
CREATE FUNCTION

Для отчета создадим функцию, возвращающую набор строк:

=> CREATE FUNCTION report()
RETURNS TABLE(model text, economy bigint, comfort bigint, business bigint)
AS $$
DECLARE
  r record;
BEGIN
  FOR r IN SELECT a.aircraft_code, a.model FROM aircrafts a ORDER BY a.model LOOP
    report.model := r.model;
    report.economy := qty(r.aircraft_code, 'Economy');
    report.comfort := qty(r.aircraft_code, 'Comfort');
    report.business := qty(r.aircraft_code, 'Business');
    RETURN NEXT;
  END LOOP;
END;
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

Page 11: Page 11

Теперь исследуем работу предложенной реализации. Сбросим статистику:

=> SELECT pg_stat_statements_reset();
 pg_stat_statements_reset
--------------------------
(1 row)

Дата последнего сброса статистики видна в представлении pg_stat_statements_info:

=> SELECT stats_reset FROM pg_stat_statements_info;
         stats_reset
------------------------------
 2026-04-10 14:48:07.70063+03
(1 row)

Выполним функцию report():

=> SELECT * FROM report();
        model        | economy | comfort | business
---------------------+---------+---------+----------
 Аэробус A319-100    |  337129 |       0 |    70232
 Аэробус A320-200    |       0 |       0 |        0
 Аэробус A321-200    |  649388 |       0 |   127982
 Боинг 737-300       |  589901 |       0 |    59829
 Боинг 767-300       |  817621 |       0 |   127947
 Боинг 777-300       |  895896 |  132571 |    83080
 Бомбардье CRJ-200   | 1155683 |       0 |        0
 Сессна 208 Караван  |  111096 |       0 |        0
 Сухой Суперджет-100 | 2425034 |       0 |   342423
(9 rows)

Посмотрим, какую статистику мы получили:

=> SELECT * FROM statements_v \gx
-[ RECORD 1 ]--------------------------------------------------------
queryid     | 1399367483717482784
toplevel    | t
query       | SELECT * FROM report()
calls       | 1
time_sec    | 46.327
shared_blks | 919430
-[ RECORD 2 ]--------------------------------------------------------
queryid     | -6704952473219487249
toplevel    | f
query       | SELECT count(*)                                        +
            |  FROM flights f                                        +
            |  JOIN boarding_passes
calls       | 27
time_sec    | 46.294
shared_blks | 918912
-[ RECORD 3 ]--------------------------------------------------------
queryid     | 1651686694542252645
toplevel    | f
query       | SELECT a.aircraft_code, a.model FROM aircrafts a ORDER
calls       | 1
time_sec    | 0.001
shared_blks | 19
-[ RECORD 4 ]--------------------------------------------------------
queryid     | -7827778417405733903
toplevel    | t
query       | SELECT pg_stat_statements_reset()
calls       | 1
time_sec    | 0
shared_blks | 0
-[ RECORD 5 ]--------------------------------------------------------
queryid     | -7737098410881999591
toplevel    | t
query       | SELECT stats_reset FROM pg_stat_statements_info
calls       | 1
time_sec    | 0
shared_blks | 0

Page 12: Page 12

Первым идет основной запрос, который детализируется ниже. Toplevel — признак выполнения запроса на верхнем уровне. Вторым идет запрос из функции qty — он вызывался 27 раз. Третий — запрос, по которому работает цикл. Обратите внимание, что идентификатор запроса в pg_stat_statements совпадает с системным благодаря настройке compute_query_id:

=> EXPLAIN (verbose)
SELECT pg_stat_statements_reset();
                             QUERY PLAN
---------------------------------------------------------------------
 Result  (cost=0.00..0.01 rows=1 width=4)
   Output: pg_stat_statements_reset('0'::oid, '0'::oid, '0'::bigint)
 Query Identifier: -7827778417405733903
(3 rows)

Page 13: Профиль одного запроса

EXPLAIN ANALYZE

  • подзадачи — узлы плана
  • продолжительность — actual time
  • или страничный ввод-вывод — buffers
  • количество выполнений — loops
  • Особенности
    • помимо наиболее ресурсоемких узлов, кандидаты на оптимизацию —
    • узлы с большой ошибкой прогноза кардинальности
    • любое вмешательство может привести к полной перестройке плана
    • иногда приходится довольствоваться простым EXPLAIN

Комментарии преподавателя

Так или иначе, среди выполненных запросов мы находим тот, который будем оптимизировать. Как работать с самим запросом? Тут тоже поможет профиль, который выдает команда EXPLAIN ANALYZE.

Подзадачами такого профиля являются узлы плана (план — не плоский список, а дерево). Продолжительность выполнения узла и число его повторений покажут time и loops из блока значений actual. С помощью параметра buffers можно получить объем ввода-вывода (и время, потраченное на операции ввода-вывода, если включен параметр track_io_timing).

План выполнения содержит также крайне важную информацию об ожидании оптимизатора относительно кардинальности каждого шага. Как правило, если нет серьезной ошибки в прогнозе кардинальности, то и план будет построен адекватный (если нет, то надо менять глобальные настройки). Поэтому стоит обращать внимание не только на наиболее ресурсоемкие узлы, но и на узлы с большим (на порядок или выше) расхождением между rows и actual rows. Смотреть надо на наиболее вложенный проблемный узел, поскольку ошибка будет распространяться от него выше по дереву.

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

Работать с большими планами в текстовом виде не всегда удобно. Для лучшей наглядности можно пользоваться сторонними инструментами, из которых отметим https://explain.tensor.ru/about​.

Page 14: Профиль одного запроса

Попробуем теперь вывести тот же отчет одним запросом и посмотрим на его выполнение командой EXPLAIN с параметрами analyze и buffers, чтобы в плане отображалось количество фактически прочитанных и записанных страниц файлов данных и временных файлов:

=> EXPLAIN (analyze, buffers, costs off, timing off)
WITH t AS (
  SELECT f.aircraft_code,
    count(*) FILTER (WHERE s.fare_conditions = 'Economy') economy,
    count(*) FILTER (WHERE s.fare_conditions = 'Comfort') comfort,
    count(*) FILTER (WHERE s.fare_conditions = 'Business') business
  FROM flights f
    JOIN boarding_passes bp ON bp.flight_id = f.flight_id
    JOIN seats s ON s.aircraft_code = f.aircraft_code AND s.seat_no = bp.seat_no
  GROUP BY f.aircraft_code
)
SELECT a.model,
  coalesce(t.economy,0) economy,
  coalesce(t.comfort,0) comfort,
  coalesce(t.business,0) business
FROM aircrafts a
  LEFT JOIN t ON a.aircraft_code = t.aircraft_code
ORDER BY a.model;

Page 15: Page 15

                                                          QUERY PLAN
------------------------------------------------------------------------------------------
-------------------------------------
 Sort (actual rows=9 loops=1)
   Sort Key: ((ml.model ->> lang()))
   Sort Method: quicksort  Memory: 25kB
   Buffers: shared hit=3114 read=57798, temp read=13666 written=13666
   ->  Hash Left Join (actual rows=9 loops=1)
         Hash Cond: (ml.aircraft_code = t.aircraft_code)
         Buffers: shared hit=3114 read=57798, temp read=13666 written=13666
         ->  Seq Scan on aircrafts_data ml (actual rows=9 loops=1)
               Buffers: shared hit=1
         ->  Hash (actual rows=8 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 9kB
               Buffers: shared hit=3113 read=57798, temp read=13666 written=13666
               ->  Subquery Scan on t (actual rows=8 loops=1)
                     Buffers: shared hit=3113 read=57798, temp read=13666 written=13666
                     ->  HashAggregate (actual rows=8 loops=1)
                           Group Key: f.aircraft_code
                           Batches: 1  Memory Usage: 24kB
                           Buffers: shared hit=3113 read=57798, temp read=13666
written=13666
                           ->  Hash Join (actual rows=7925812 loops=1)
                                 Hash Cond: ((f.aircraft_code = s.aircraft_code) AND
((bp.seat_no)::text = (s.seat_no)::text))
                                 Buffers: shared hit=3113 read=57798, temp read=13666
written=13666
                                 ->  Hash Join (actual rows=7925812 loops=1)
                                       Hash Cond: (bp.flight_id = f.flight_id)
                                       Buffers: shared hit=3105 read=57798, temp
read=13666 written=13666
                                       ->  Seq Scan on boarding_passes bp (actual
rows=7925812 loops=1)
                                             Buffers: shared hit=481 read=57798
                                       ->  Hash (actual rows=214867 loops=1)
                                             Buckets: 262144  Batches: 2  Memory Usage:
6256kB
                                             Buffers: shared hit=2624, temp written=366
                                             ->  Seq Scan on flights f (actual
rows=214867 loops=1)
                                                   Buffers: shared hit=2624
                                 ->  Hash (actual rows=1339 loops=1)
                                       Buckets: 2048  Batches: 1  Memory Usage: 79kB
                                       Buffers: shared hit=8
                                       ->  Seq Scan on seats s (actual rows=1339 loops=1)
                                             Buffers: shared hit=8
 Planning:
   Buffers: shared hit=29
 Planning Time: 1.917 ms
 Execution Time: 16600.443 ms
(40 rows)

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

=> SELECT sum(relpages)
FROM pg_class
WHERE relname IN ('flights','boarding_passes','aircrafts','seats');
  sum
-------
 60911
(1 row)

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

Page 16: Итоги

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

  • Средства профилирования зависят от задачи:
    • журнал сообщений сервера и pg_stat_statements
    • EXPLAIN ANALYZE

Page 17: Практика

  1. Выполните первую версию отчета, показанного в демонстрации, включив вывод текста и времени выполнения запросов в журнал сообщений. 2. Посмотрите, какая информация попала в журнал сообщений. 3. Повторите предыдущие пункты, включив расширение auto_explain с выводом вложенных запросов.

Page 18: 1. Отчет

=> CREATE FUNCTION qty(aircraft_code char, fare_conditions varchar)
RETURNS bigint AS $$
  SELECT count(*)
  FROM flights f
    JOIN boarding_passes bp ON bp.flight_id = f.flight_id
    JOIN seats s ON s.aircraft_code = f.aircraft_code AND s.seat_no = bp.seat_no
  WHERE f.aircraft_code = qty.aircraft_code AND s.fare_conditions = qty.fare_conditions;
$$ STABLE LANGUAGE sql;
CREATE FUNCTION
=> CREATE FUNCTION report()
RETURNS TABLE(model text, economy bigint, comfort bigint, business bigint)
AS $$
DECLARE
  r record;
BEGIN
  FOR r IN SELECT a.aircraft_code, a.model FROM aircrafts a ORDER BY a.model LOOP
    report.model := r.model;
    report.economy := qty(r.aircraft_code, 'Economy');
    report.comfort := qty(r.aircraft_code, 'Comfort');
    report.business := qty(r.aircraft_code, 'Business');
    RETURN NEXT;
  END LOOP;
END;
$$ STABLE LANGUAGE plpgsql;
CREATE FUNCTION

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

=> SET log_min_duration_statement = 0;
SET
=> SELECT * FROM report();
        model        | economy | comfort | business
---------------------+---------+---------+----------
 Аэробус A319-100    |  337129 |       0 |    70232
 Аэробус A320-200    |       0 |       0 |        0
 Аэробус A321-200    |  649388 |       0 |   127982
 Боинг 737-300       |  589901 |       0 |    59829
 Боинг 767-300       |  817621 |       0 |   127947
 Боинг 777-300       |  895896 |  132571 |    83080
 Бомбардье CRJ-200   | 1155683 |       0 |        0
 Сессна 208 Караван  |  111096 |       0 |        0
 Сухой Суперджет-100 | 2425034 |       0 |   342423
(9 rows)

2. Сообщения в журнале

student$ tail -n 1 /var/log/postgresql/postgresql-16-main.log
2026-04-10 15:14:17.422 MSK [42389] postgres@demo LOG:  duration: 32596.333 ms
statement: SELECT * FROM report();

Вложенные SQL-операторы не выводятся.

3. Расширение auto\_explain

=> LOAD 'auto_explain';
LOAD
=> RESET log_min_duration_statement;
RESET
=> SET compute_query_id = on; -- для вывода идентификаторов запросов
SET
=> SET auto_explain.log_min_duration = 0;

Page 19: Page 19

SET
=> SET auto_explain.log_nested_statements = on;
SET
=> SET auto_explain.log_verbose = on;
SET
=> SELECT * FROM report();
        model        | economy | comfort | business
---------------------+---------+---------+----------
 Аэробус A319-100    |  337129 |       0 |    70232
 Аэробус A320-200    |       0 |       0 |        0
 Аэробус A321-200    |  649388 |       0 |   127982
 Боинг 737-300       |  589901 |       0 |    59829
 Боинг 767-300       |  817621 |       0 |   127947
 Боинг 777-300       |  895896 |  132571 |    83080
 Бомбардье CRJ-200   | 1155683 |       0 |        0
 Сессна 208 Караван  |  111096 |       0 |        0
 Сухой Суперджет-100 | 2425034 |       0 |   342423
(9 rows)

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

student$ tail -n 50 /var/log/postgresql/postgresql-16-main.log
    JOIN seats s ON s.aircraft_code = f.aircraft_code AND s.seat_no = bp.seat_no
  WHERE f.aircraft_code = qty.aircraft_code AND s.fare_conditions =
qty.fare_conditions;
Query Parameters: $1 = 'SU9', $2 = 'Business'
Finalize Aggregate  (cost=106686.82..106686.83 rows=1 width=8)
  Output: count(*)
  ->  Gather  (cost=106686.61..106686.82 rows=2 width=8)
        Output: (PARTIAL count(*))
        Workers Planned: 2
        ->  Partial Aggregate  (cost=105686.61..105686.62 rows=1 width=8)
              Output: PARTIAL count(*)
              ->  Hash Join  (cost=4417.66..105491.56 rows=78019 width=0)
                    Inner Unique: true
                    Hash Cond: ((bp.seat_no)::text = (s.seat_no)::text)
                    ->  Parallel Hash Join  (cost=4401.39..104374.33 rows=412804
width=7)
                          Output: f.aircraft_code, bp.seat_no
                          Inner Unique: true
                          Hash Cond: (bp.flight_id = f.flight_id)
                          ->  Parallel Seq Scan on bookings.boarding_passes bp
(cost=0.00..91303.77 rows=3302477 width=7)
                                Output: bp.ticket_no, bp.flight_id,
bp.boarding_no, bp.seat_no
                          ->  Parallel Hash  (cost=4203.90..4203.90 rows=15799
width=8)
                                Output: f.flight_id, f.aircraft_code
                                ->  Parallel Seq Scan on bookings.flights f
(cost=0.00..4203.90 rows=15799 width=8)
                                      Output: f.flight_id, f.aircraft_code
                                      Filter: (f.aircraft_code = $1)
                    ->  Hash  (cost=15.64..15.64 rows=50 width=7)
                          Output: s.aircraft_code, s.seat_no
                          ->  Bitmap Heap Scan on bookings.seats s
(cost=5.41..15.64 rows=50 width=7)
                                Output: s.aircraft_code, s.seat_no
                                Recheck Cond: (s.aircraft_code = $1)
                                Filter: ((s.fare_conditions)::text = ($2)::text)
                                ->  Bitmap Index Scan on seats_pkey
(cost=0.00..5.39 rows=149 width=0)
                                      Index Cond: (s.aircraft_code = $1)
Query Identifier: -6704952473219487249
2026-04-10 15:14:49.163 MSK [42389] postgres@demo CONTEXT:  SQL function "qty" statement 1
PL/pgSQL function report() line 9 at assignment
2026-04-10 15:14:49.179 MSK [42389] postgres@demo LOG:  duration: 0.089 ms  plan:
Query Text: SELECT a.aircraft_code, a.model FROM aircrafts a ORDER BY a.model
Sort  (cost=3.51..3.53 rows=9 width=36)
  Output: ml.aircraft_code, ((ml.model ->> lang()))
  Sort Key: ((ml.model ->> lang()))
  ->  Seq Scan on bookings.aircrafts_data ml  (cost=0.00..3.36 rows=9 width=36)
        Output: ml.aircraft_code, (ml.model ->> lang())
2026-04-10 15:14:49.179 MSK [42389] postgres@demo CONTEXT:  PL/pgSQL function report()

Page 20: Page 20

line 5 at FOR over SELECT rows
2026-04-10 15:14:49.179 MSK [42389] postgres@demo LOG:  duration: 31025.589 ms  plan:
Query Text: SELECT * FROM report();
Function Scan on bookings.report  (cost=0.25..10.25 rows=1000 width=56)
  Output: model, economy, comfort, business
  Function Call: report()
Query Identifier: 5264940823427202119

В журнал попадают вложенные запросы и планы выполнения — собственно, это и есть основная функция расширения. Помимо этого, с указанными настройками в журнал попадают параметры запроса (строка Query Parameters) и идентификатор запроса (строка Query Identifier).

14 Занятие 14: Оптимизация запросов / Материализация

Исходная страница в нативном Markdown: Оптимизация запросов / Материализация
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_14_optim_materialization.html Archive: QPT-handouts-16.zip Страниц PDF: 24

Page 1: Оптимизация запросов / Материализация

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Материализация в запросах
  • Временные таблицы
  • Управление порядком соединений
  • Материализованные представления

Page 3: Узел Materialize

Source slide page 3: Узел Materialize{#source-figure-003 caption=“Source slide page 3: Узел Materialize”}

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

Комментарии преподавателя

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

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

Строки сохраняются в оперативной памяти, пока их объем укладывается в ограничение work_mem. При превышении этого ограничения все строки сбрасываются во временный файл и при необходимости читаются оттуда. Объем всех временных файлов одного сеанса ограничен значением параметра temp_file_limit.

Например, на слайде верхний узел Nested Loop выполняет соединение вложенным циклом, но к внутреннему набору данных нет эффективного доступа: он вычисляется с помощью другого соединения. Чтобы не повторять многократно вложенное соединение, его результат можно материализовать.

Page 4: Узел Materialize

Source slide page 4: Узел Materialize{#source-figure-004 caption=“Source slide page 4: Узел Materialize”}

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

Комментарии преподавателя

После того как результат вложенного соединения материализован, узел Nested Loop будет обращаться к уже готовому набору строк.

Page 5: Узел Materialize

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

=> EXPLAIN (costs off)
SELECT a1.city, a2.city
FROM airports a1, airports a2
WHERE a1.timezone = 'Europe/Moscow'
  AND abs(a2.coordinates[1]) > 66.652; -- за полярным кругом
                                QUERY PLAN
--------------------------------------------------------------------------
 Nested Loop
   ->  Seq Scan on airports_data ml
         Filter: (timezone = 'Europe/Moscow'::text)
   ->  Materialize
         ->  Seq Scan on airports_data ml_1
               Filter: (abs(coordinates[1]) > '66.652'::double precision)
(6 rows)

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

=> EXPLAIN (costs off)
SELECT * FROM
  (SELECT * FROM tickets ORDER BY ticket_no) AS t
JOIN
  (SELECT * FROM ticket_flights ORDER BY ticket_no) AS tf
ON tf.ticket_no = t.ticket_no;
                             QUERY PLAN
--------------------------------------------------------------------
 Merge Join
   Merge Cond: (tickets.ticket_no = ticket_flights.ticket_no)
   ->  Index Scan using tickets_pkey on tickets
   ->  Materialize
         ->  Index Scan using ticket_flights_pkey on ticket_flights
(5 rows)

Page 6: Материализация CTE

Source slide page 6: Материализация CTE{#source-figure-006 caption=“Source slide page 6: Материализация CTE”}

Комментарии преподавателя

Общие табличные выражения (common table expression, CTE), они же подзапросы WITH, — прекрасный способ структурировать запрос и сделать его более понятным. В отличие от обычных подзапросов, использование CTE не приводит к большой вложенности.

По возможности планировщик раскрывает (не материализует) подзапросы CTE. Это позволяет ему выбирать оптимальный порядок соединений. По умолчанию подзапрос материализуется в следующих ситуациях:

● Основной запрос обращается к подзапросу несколько раз — чтобы не повторять вычисления.

В этом случае материализацию можно отменить, указав предложение AS NOT MATERIALIZED.

● Подзапрос имеет побочные эффекты (изменяет данные) — чтобы изменение произошло ровно один раз. (К побочным эффектам относится также обращение к изменчивым функциям, см. тему «Функции».)

В этом случае материализацию отменить невозможно.

Материализацию всегда можно принудительно включить, указав предложение AS MATERIALIZED.

https://postgrespro.ru/docs/postgresql/16/queries-with#QUERIES-WITH-CT E-MATERIALIZATION

Page 7: Материализация CTE

Оптимизатор старается не материализовать подзапросы в WITH без надобности:

=> EXPLAIN (costs off)
WITH q AS (
  SELECT f.flight_id, a.aircraft_code
  FROM flights f
    JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
)
SELECT *
FROM q
  JOIN seats s ON s.aircraft_code = q.aircraft_code
WHERE s.seat_no = '1A';
                         QUERY PLAN
------------------------------------------------------------
 Hash Join
   Hash Cond: (f.aircraft_code = ml.aircraft_code)
   ->  Hash Join
         Hash Cond: (f.aircraft_code = s.aircraft_code)
         ->  Seq Scan on flights f
         ->  Hash
               ->  Seq Scan on seats s
                     Filter: ((seat_no)::text = '1A'::text)
   ->  Hash
         ->  Seq Scan on aircrafts_data ml
(10 rows)

Но явное указание заставляет оптимизатор планировать подзапрос отдельно от основного запроса:

=> EXPLAIN (costs off)
WITH q AS MATERIALIZED (
  SELECT f.flight_id, a.aircraft_code
  FROM flights f
    JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
)
SELECT *
FROM q
  JOIN seats s ON s.aircraft_code = q.aircraft_code
WHERE s.seat_no = '1A';
                        QUERY PLAN
-----------------------------------------------------------
 Hash Join
   Hash Cond: (q.aircraft_code = s.aircraft_code)
   CTE q
     ->  Hash Join
           Hash Cond: (f.aircraft_code = ml.aircraft_code)
           ->  Seq Scan on flights f
           ->  Hash
                 ->  Seq Scan on aircrafts_data ml
   ->  CTE Scan on q
   ->  Hash
         ->  Seq Scan on seats s
               Filter: ((seat_no)::text = '1A'::text)
(12 rows)

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

=> EXPLAIN (analyze, costs off, buffers)
WITH b AS (
  SELECT * FROM bookings
)
SELECT *
FROM b AS b1
  JOIN b AS b2 ON b1.book_ref = b2.book_ref
WHERE b2.book_ref = '000112';

Page 8: Page 8

                                   QUERY PLAN
--------------------------------------------------------------------------------
 Nested Loop (actual time=0.041..1699.543 rows=1 loops=1)
   Buffers: shared read=13447, temp read=8483 written=8483
   CTE b
     ->  Seq Scan on bookings (actual time=0.021..360.324 rows=2111110 loops=1)
           Buffers: shared read=13447
   ->  CTE Scan on b b1 (actual time=0.036..459.585 rows=1 loops=1)
         Filter: (book_ref = '000112'::bpchar)
         Rows Removed by Filter: 2111109
         Buffers: shared read=1, temp read=8483 written=1
   ->  CTE Scan on b b2 (actual time=0.004..1239.952 rows=1 loops=1)
         Filter: (book_ref = '000112'::bpchar)
         Rows Removed by Filter: 2111109
         Buffers: shared read=13446, temp written=8482
 Planning:
   Buffers: shared hit=29 read=2
 Planning Time: 0.217 ms
 Execution Time: 1722.597 ms
(17 rows)

Обычно это оправдано, особенно если в подзапросе происходят затратные вычисления. Но в некоторых случаях (как в этом примере) план без материализации может оказаться эффективнее, и тогда ее можно отменить. Сравните значение buffers с предыдущим вариантом:

=> EXPLAIN (analyze, costs off, buffers)
WITH b AS NOT MATERIALIZED (
  SELECT * FROM bookings
)
SELECT *
FROM b AS b1
  JOIN b AS b2 ON b1.book_ref = b2.book_ref
WHERE b2.book_ref = '000112';
                                              QUERY PLAN
------------------------------------------------------------------------------------------
-------------
 Nested Loop (actual time=3.217..3.219 rows=1 loops=1)
   Buffers: shared hit=4 read=4
   ->  Index Scan using bookings_pkey on bookings (actual time=3.184..3.185 rows=1
loops=1)
         Index Cond: (book_ref = '000112'::bpchar)
         Buffers: shared read=4
   ->  Index Scan using bookings_pkey on bookings bookings_1 (actual time=0.027..0.028
rows=1 loops=1)
         Index Cond: (book_ref = '000112'::bpchar)
         Buffers: shared hit=4
 Planning:
   Buffers: shared hit=2
 Planning Time: 0.654 ms
 Execution Time: 3.241 ms
(12 rows)

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

Page 9: Рекурсивные запросы

Source slide page 9: Рекурсивные запросы{#source-figure-009 caption=“Source slide page 9: Рекурсивные запросы”}

Комментарии преподавателя

Рекурсивные запросы строятся на базе общих табличных выражений.

Узел Recursive Union использует рабочую и промежуточную таблицы: рабочая таблица хранит строки, получаемые на текущей итерации, а промежуточная — накапливает результат. Содержимое рабочей таблицы считывается в рекурсивной части запроса узлом Worktable Scan.

Каждая из этих двух таблиц материализуется по тем же правилам: пока содержимое таблицы помещается в work_mem, она хранится в памяти, а затем все строки сбрасываются на диск.

После выполнения рекурсивного запроса накопленное содержимое промежуточной таблицы передается узлу CTE Scan, а рабочая таблица отбрасывается.

https://postgrespro.ru/docs/postgresql/16/queries-with#QUERIES-WITH-RE CURSIVE

Page 10: Рекурсивные запросы

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

=> EXPLAIN (analyze, buffers, costs off, timing off)
WITH RECURSIVE r(n, airport_code) AS (
  SELECT 1, a.airport_code
  FROM airports a
  UNION ALL
  SELECT r.n+1, f.arrival_airport
  FROM r
    JOIN flights f ON f.departure_airport = r.airport_code
  WHERE r.n < 2
)
SELECT * FROM r;
                                 QUERY PLAN
----------------------------------------------------------------------------
 CTE Scan on r (actual rows=214971 loops=1)
   Buffers: shared read=2627, temp read=473 written=945
   CTE r
     ->  Recursive Union (actual rows=214971 loops=1)
           Buffers: shared read=2627, temp read=473 written=473
           ->  Seq Scan on airports_data ml (actual rows=104 loops=1)
                 Buffers: shared read=3
           ->  Hash Join (actual rows=107434 loops=2)
                 Hash Cond: (f.departure_airport = r_1.airport_code)
                 Buffers: shared read=2624, temp read=473 written=1
                 ->  Seq Scan on flights f (actual rows=214867 loops=1)
                       Buffers: shared read=2624
                 ->  Hash (actual rows=52 loops=2)
                       Buckets: 1024  Batches: 1  Memory Usage: 13kB
                       Buffers: temp read=473 written=1
                       ->  WorkTable Scan on r r_1 (actual rows=52 loops=2)
                             Filter: (n < 2)
                             Rows Removed by Filter: 107434
                             Buffers: temp read=473 written=1
 Planning:
   Buffers: shared hit=9 dirtied=1
 Planning Time: 0.180 ms
 Execution Time: 360.230 ms
(23 rows)

Обратите внимание на строки temp read/written в узлах WorkTable Scan и Recursive Union, специфичных для рекурсивных запросов.

Page 11: Оконные функции

Source slide page 11: Оконные функции{#source-figure-011 caption=“Source slide page 11: Оконные функции”}

Комментарии преподавателя

При вычислении оконных функций в узле WindowAgg (см. тему «Сортировка») тоже применяется материализация: строки текущего раздела (PARTITION BY) могут неоднократно попадать в рамку, и поэтому материализуются.

В этом случае материализованными строками пользуется только сам узел WindowAgg: родительскому узлу передается результат вычислений, а не эти промежуточные данные.

https://postgrespro.ru/docs/postgresql/16/tutorial-window

Page 12: Временные таблицы

Таблица, доступная в пределах одного сеанса

  • регистрируется в системном каталоге
  • не журналируется
  • создаются файлы на диске
  • кеширование в локальной памяти сеанса (temp_buffers)
  • Очистка и анализ
    • только вручную

Комментарии преподавателя

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

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

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

https://postgrespro.ru/docs/postgresql/16/sql-createtable

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

Page 13: Временные таблицы

Создадим временную таблицу:

=> CREATE TEMP TABLE airports_msk
ON COMMIT PRESERVE ROWS -- по умолчанию
AS SELECT *
FROM airports
WHERE timezone = 'Europe/Moscow';
SELECT 44

Временная таблица создается во временной схеме pg_temp. По умолчанию таблица существует до конца сеанса; можно указать, чтобы при завершении транзакции удалялись строки (ON COMMIT DELETE ROWS) или сама таблица (ON COMMIT DROP). Зачастую есть смысл проанализировать только что заполненную временную таблицу, прежде чем использовать ее в запросах. Сравните оценки кардинальности при выборе из обычной таблицы и из временной:

=> EXPLAIN
SELECT *
FROM airports
WHERE timezone = 'Europe/Moscow';
                            QUERY PLAN
-------------------------------------------------------------------
 Seq Scan on airports_data ml  (cost=0.00..26.52 rows=44 width=99)
   Filter: (timezone = 'Europe/Moscow'::text)
(2 rows)
=> EXPLAIN
SELECT *
FROM airports_msk;
                           QUERY PLAN
-----------------------------------------------------------------
 Seq Scan on airports_msk  (cost=0.00..15.20 rows=520 width=128)
(1 row)

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

=> SELECT relpages, reltuples,
   10 * current_setting('seq_page_cost')::float +
  520 * current_setting('cpu_tuple_cost')::float AS cost
FROM pg_class
WHERE relname = 'airports_msk'
  AND relpersistence = 't'; -- временная
 relpages | reltuples | cost
----------+-----------+------
        0 |        -1 | 15.2
(1 row)

Если проанализировать таблицу, оценка станет точной:

=> ANALYZE airports_msk;
ANALYZE
=> EXPLAIN
SELECT *
FROM airports_msk;
                          QUERY PLAN
--------------------------------------------------------------
 Seq Scan on airports_msk  (cost=0.00..1.44 rows=44 width=67)
(1 row)

Page 14: Порядок соединений

Количество вариантов соединений растет экспоненциально с ростом количества таблиц в запросе

  • Соединение JOIN
    • полный перебор при числе соединений не более join_collapse_limit = 8
    • дальше — группами по join_collapse_limit таблиц
  • Соединение через запятую
    • полный перебор при числе соединений не более geqo_threshold = 12
    • дальше — генетический алгоритм
  • Материализация — подсказка планировщику

Комментарии преподавателя

С ростом количества таблиц в запросе число вариантов соединений и, как следствие, затраты на выбор плана растут экспоненциально.

Если число соединений (записанных в виде JOIN) превышает значение join_collapse_limit (по умолчанию 8), планировщик рассматривает возможные соединения в группах по join_collapse_limit таблиц, а затем соединяет эти группы между собой.

(Для подзапросов в предложении FROM есть похожий параметр — from_collapse_limit.)

Если таблицы соединены запятой (без ключевого слова JOIN), планировщик рассматривает все варианты соединений, но переключается на генетический алгоритм оптимизации, когда количество соединений превышает geqo_threshold (12 по умолчанию).

Все это может приводить к неоптимальным планам. Подробности можно прочитать в статье Павла Толмачева: https://habr.com/ru/company/postgrespro/blog/662021/

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

https://postgrespro.ru/docs/postgresql/16/explicit-joins

Page 15: Управление порядком соединений

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

=> SHOW join_collapse_limit;
 join_collapse_limit
---------------------
 8
(1 row)

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

=> EXPLAIN (costs on)
SELECT *
FROM tickets t
 JOIN ticket_flights tf ON (tf.ticket_no = t.ticket_no)
 JOIN flights f ON (f.flight_id = tf.flight_id);
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Hash Join  (cost=171644.23..778549.09 rows=8391960 width=199)
   Hash Cond: (tf.ticket_no = t.ticket_no)
   ->  Hash Join  (cost=9767.51..302692.44 rows=8391960 width=95)
         Hash Cond: (tf.flight_id = f.flight_id)
         ->  Seq Scan on ticket_flights tf  (cost=0.00..153852.60 rows=8391960 width=32)
         ->  Hash  (cost=4772.67..4772.67 rows=214867 width=63)
               ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=63)
   ->  Hash  (cost=78913.10..78913.10 rows=2949810 width=104)
         ->  Seq Scan on tickets t  (cost=0.00..78913.10 rows=2949810 width=104)
(9 rows)

В выбранном плане сначала соединяются таблицы рейсов (flights) и перелетов (ticket_flights), а затем результат соединяется с билетами (tickets). Установив параметр join_collapse_limit в единицу, можно зафиксировать порядок соединений:

=> SET join_collapse_limit = 1;
SET
=> EXPLAIN (costs on)
SELECT *
FROM tickets t
 JOIN ticket_flights tf ON (tf.ticket_no = t.ticket_no)
 JOIN flights f ON (f.flight_id = tf.flight_id);
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Hash Join  (cost=171644.23..860501.09 rows=8391960 width=199)
   Hash Cond: (tf.flight_id = f.flight_id)
   ->  Hash Join  (cost=161876.73..498583.25 rows=8391960 width=136)
         Hash Cond: (tf.ticket_no = t.ticket_no)
         ->  Seq Scan on ticket_flights tf  (cost=0.00..153852.60 rows=8391960 width=32)
         ->  Hash  (cost=78913.10..78913.10 rows=2949810 width=104)
               ->  Seq Scan on tickets t  (cost=0.00..78913.10 rows=2949810 width=104)
   ->  Hash  (cost=4772.67..4772.67 rows=214867 width=63)
         ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=63)
(9 rows)

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

=> RESET join_collapse_limit;
RESET

С похожим параметром from_collapse_limit предлагается познакомиться в практике.

Page 16: Page 16

Page 17: Мат. представления

Материализация результата запроса

  • только для чтения
  • возможно создание индексов
  • Обновление данных
    • ручная синхронизация
    • инкрементальное обновление — расширение pg_ivm
  • Очистка и анализ
    • вручную и автоматически

Комментарии преподавателя

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

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

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

https://postgrespro.ru/docs/postgresql/16/sql-creatematerializedview

Полная синхронизация материализованного представления может быть слишком затратной. Штатное инкрементальное обновление (отдельных строк по мере изменения базовых таблиц) не доступно, но такую возможность дает расширение pg_ivm (автор — Юго Нагата, https://github.com/sraoss/pg\_ivm).

Page 18: Материализованные представления

Создадим материализованное представление:

=> CREATE MATERIALIZED VIEW airports_msk AS
SELECT *
FROM airports
WHERE timezone = 'Europe/Moscow';
SELECT 44
=> \dt airports_msk
              List of relations
  Schema   |     Name     | Type  |  Owner
-----------+--------------+-------+----------
 pg_temp_3 | airports_msk | table | postgres
(1 row)

Схема pg_temp при поиске проверяется первой, теперь придется обращаться к материализованному представлению по полному имени. Это неудобно, лучше удалить временную таблицу:

=> DROP TABLE pg_temp.airports_msk;
DROP TABLE

Материализованные представления можно индексировать:

=> CREATE UNIQUE INDEX ON airports_msk (airport_code);
CREATE INDEX
=> EXPLAIN (costs off) SELECT *
FROM airports_msk
ORDER BY airport_code
LIMIT 3;
                              QUERY PLAN
----------------------------------------------------------------------
 Limit
   ->  Index Scan using airports_msk_airport_code_idx on airports_msk
(2 rows)

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

=> INSERT INTO airports_data (airport_code, airport_name, city, coordinates, timezone)
  VALUES ('ZIA', '{"ru": "Жуковский"}', '{}', point(38.1517, 55.5533), 'Europe/Moscow');
INSERT 0 1
=> SELECT count(*)
FROM airports_msk
WHERE airport_code = 'ZIA';
 count
-------
     0
(1 row)

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

=> REFRESH MATERIALIZED VIEW CONCURRENTLY airports_msk;
REFRESH MATERIALIZED VIEW
=> SELECT count(*)
FROM airports_msk
WHERE airport_code = 'ZIA';

Page 19: Page 19

 count
-------
     1
(1 row)

Page 20: Итоги

Оптимизатор может материализовать строки, полученные узлами плана, для повторного использования

  • Можно управлять материализацией с помощью CTE, временных таблиц и материализованных представлений
  • Материализация позволяет управлять порядком соединений

Page 21: Практика

  1. В начале демонстрации приводились примеры двух запросов с узлом Materialize. Выдайте планировщику указание не использовать этот узел и проверьте, в каком случае он сможет обойтись без материализации, а в каком — нет. 2. Проверьте план выполнения запроса при значениях параметра from_collapse_limit 8 (по умолчанию) и 1:
SELECT *
FROM
  (
    SELECT *
    FROM ticket_flights tf, tickets t
    WHERE tf.ticket_no = t.ticket_no
  ) ttf,
  flights f
WHERE f.flight_id = ttf.flight_id;

Комментарии преподавателя

  1. Воспользуйтесь параметром enable_material: https://postgrespro.ru/docs/postgresql/16/runtime-config-query#GUC-ENAB LE-MATERIAL

  2. Прочитайте про параметр в документации: https://postgrespro.ru/docs/postgresql/16/explicit-joins

Page 22: 1. Отключение узла Materialize

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

=> EXPLAIN SELECT a1.city, a2.city
FROM airports a1, airports a2
WHERE a1.timezone = 'Europe/Moscow'
  AND abs(a2.coordinates[1]) > 66.652;
                                   QUERY PLAN
--------------------------------------------------------------------------------
 Nested Loop  (cost=0.00..805.90 rows=1540 width=64)
   ->  Seq Scan on airports_data ml  (cost=0.00..4.30 rows=44 width=49)
         Filter: (timezone = 'Europe/Moscow'::text)
   ->  Materialize  (cost=0.00..4.74 rows=35 width=49)
         ->  Seq Scan on airports_data ml_1  (cost=0.00..4.56 rows=35 width=49)
               Filter: (abs(coordinates[1]) > '66.652'::double precision)
(6 rows)

Попросим планировщик не использовать материализацию:

=> SET enable_material = off;
SET
=> EXPLAIN SELECT a1.city, a2.city
FROM airports a1, airports a2
WHERE a1.timezone = 'Europe/Moscow'
  AND abs(a2.coordinates[1]) > 66.652;
                                QUERY PLAN
--------------------------------------------------------------------------
 Nested Loop  (cost=0.00..948.16 rows=1540 width=64)
   ->  Seq Scan on airports_data ml_1  (cost=0.00..4.56 rows=35 width=49)
         Filter: (abs(coordinates[1]) > '66.652'::double precision)
   ->  Seq Scan on airports_data ml  (cost=0.00..4.30 rows=44 width=49)
         Filter: (timezone = 'Europe/Moscow'::text)
(5 rows)

Планировщик обошелся без материализации, но стоимость плана увеличилась. Проверим второй запрос:

=> RESET enable_material;
RESET
=> EXPLAIN (costs off)
SELECT * FROM
  (SELECT * FROM tickets ORDER BY ticket_no) AS t
JOIN
  (SELECT * FROM ticket_flights ORDER BY ticket_no) AS tf
ON tf.ticket_no = t.ticket_no;
                             QUERY PLAN
--------------------------------------------------------------------
 Merge Join
   Merge Cond: (tickets.ticket_no = ticket_flights.ticket_no)
   ->  Index Scan using tickets_pkey on tickets
   ->  Materialize
         ->  Index Scan using ticket_flights_pkey on ticket_flights
(5 rows)
=> SET enable_material = off;
SET
=> EXPLAIN (costs off)
SELECT * FROM
  (SELECT * FROM tickets ORDER BY ticket_no) AS t
JOIN
  (SELECT * FROM ticket_flights ORDER BY ticket_no) AS tf
ON tf.ticket_no = t.ticket_no;

Page 23: Page 23

                             QUERY PLAN
--------------------------------------------------------------------
 Merge Join
   Merge Cond: (tickets.ticket_no = ticket_flights.ticket_no)
   ->  Index Scan using tickets_pkey on tickets
   ->  Materialize
         ->  Index Scan using ticket_flights_pkey on ticket_flights
(5 rows)

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

=> RESET enable_material;
RESET

2. Параметр from\_collapse\_limit

Сначала посмотрим, как работает запрос со значением from_collapse_limit по умолчанию:

=> SHOW from_collapse_limit;
 from_collapse_limit
---------------------
 8
(1 row)
=> EXPLAIN (costs off, summary off, settings)
SELECT *
FROM
  (
    SELECT *
    FROM ticket_flights tf, tickets t
    WHERE tf.ticket_no = t.ticket_no
  ) ttf,
  flights f
WHERE f.flight_id = ttf.flight_id;
                       QUERY PLAN
---------------------------------------------------------
 Hash Join
   Hash Cond: (tf.ticket_no = t.ticket_no)
   ->  Hash Join
         Hash Cond: (tf.flight_id = f.flight_id)
         ->  Seq Scan on ticket_flights tf
         ->  Hash
               ->  Seq Scan on flights f
   ->  Hash
         ->  Seq Scan on tickets t
 Settings: search_path = 'bookings, public', jit = 'off'
(10 rows)

В подзапросе соединяются две таблицы, но оптимизатор раскрывает подзапрос и выбирает порядок соединения для всех трех таблиц. Первыми здесь соединяются таблицы перелетов (ticket_flights) и рейсов (flights). Теперь уменьшим значение from_collapse_limit до единицы и посмотрим на новый план того же запроса:

=> SET from_collapse_limit = 1;
SET
=> EXPLAIN (costs off, summary off, settings)
SELECT *
FROM
  (
    SELECT *
    FROM ticket_flights tf, tickets t
    WHERE tf.ticket_no = t.ticket_no
  ) ttf,
  flights f
WHERE f.flight_id = ttf.flight_id;

Page 24: Page 24

                                     QUERY PLAN
------------------------------------------------------------------------------------
 Hash Join
   Hash Cond: (tf.flight_id = f.flight_id)
   ->  Hash Join
         Hash Cond: (tf.ticket_no = t.ticket_no)
         ->  Seq Scan on ticket_flights tf
         ->  Hash
               ->  Seq Scan on tickets t
   ->  Hash
         ->  Seq Scan on flights f
 Settings: search_path = 'bookings, public', jit = 'off', from_collapse_limit = '1'
(10 rows)

План запроса поменялся — сначала соединяются две таблицы из подзапроса, который теперь не раскрывается. Если во FROM вместо списка таблиц использовать конструкцию JOIN, оптимизатор сначала преобразует каждую такую конструкцию в список таблиц (при этом обрабатывая таблицы группами не более чем по join_collapse_limit), а потом уже раскрывает подзапросы (на глубину не более чем from_collapse_limit). Поэтому имеет смысл устанавливать обоим параметрам равные значения.

15 Занятие 15: Оптимизация запросов / Функции

Исходная страница в нативном Markdown: Оптимизация запросов / Функции
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_15_optim_functions.html Archive: QPT-handouts-16.zip Страниц PDF: 26

Page 1: Оптимизация запросов / Функции

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Категории изменчивости
  • Подстановка кода функции в текст запроса
  • Вызов табличных функций
  • Настройки COST и ROWS
  • Вспомогательные функции планировщика
  • Конфигурационные параметры

Page 3: Категории изменчивости

Volatile

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

Комментарии преподавателя

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

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

Категория Stable используется для функций, возвращаемое значение которых не меняется в пределах одного SQL-оператора. В частности, такие функции не могут менять состояние БД. Такая функция может быть выполнена один раз во время выполнения запроса, а затем будет использоваться вычисленное значение.

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

Можно — не означает, что всегда происходит именно так, но планировщик вправе выполнить такие оптимизации.

https://postgrespro.ru/docs/postgresql/16/xfunc-volatility

Page 4: Категории изменчивости и оптимизация

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

=> CREATE FUNCTION rnd() RETURNS float
LANGUAGE sql VOLATILE
RETURN random();
CREATE FUNCTION

Проверим план выполнения следующего запроса:

=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

В плане мы видим обращение к табличной функции generate_series в узле Function Scan. Ее результат фильтруется, для каждой строки в фильтре честно вызывается скалярная функция random. В этом можно убедиться и воочию:

=> SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
 generate_series
-----------------
               1
               2
               3
               4
               5
               6
               7
               8
              10
(9 rows)
=> \g
 generate_series
-----------------
               3
               4
               6
               7
               9
(5 rows)
=> \g
 generate_series
-----------------
               3
               7
               9
              10
(4 rows)
=> \g

Page 5: Page 5

 generate_series
-----------------
               2
               4
               7
               8
               9
(5 rows)
=> \g
 generate_series
-----------------
               2
               3
               4
(3 rows)

Здесь случайным образом получаем от 0 до 10 строк. Функция с категорией изменчивости Stable будет вызвана всего один раз — поскольку мы фактически указали, что ее значение не может измениться в пределах оператора:

=> ALTER FUNCTION rnd() STABLE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                      QUERY PLAN
------------------------------------------------------
 Result
   One-Time Filter: (rnd() > '0.5'::double precision)
   ->  Function Scan on generate_series
(3 rows)

Узел Result формирует строку выборки, а выражение One-Time Filter вычисляется один раз, так что результатом запроса будет либо 0, либо 10 строк.

=> SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
 generate_series
-----------------
(0 rows)
=> \g
 generate_series
-----------------
               1
               2
               3
               4
               5
               6
               7
               8
               9
              10
(10 rows)
=> \g
 generate_series
-----------------
(0 rows)

Наконец, категория Immutable позволяет вычислить значение функции еще на этапе планирования, поэтому во время выполнения вычисление условия фильтра уже не требуется:

=> ALTER FUNCTION rnd() IMMUTABLE;
ALTER FUNCTION

Page 6: Page 6

=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
        QUERY PLAN
--------------------------
 Result
   One-Time Filter: false
(2 rows)
=> \g
            QUERY PLAN
----------------------------------
 Function Scan on generate_series
(1 row)
=> \g
            QUERY PLAN
----------------------------------
 Function Scan on generate_series
(1 row)

Для Immutable получаем случайный план! Ответственность «за дачу заведомо ложных показаний» лежит на разработчике.

Page 7: Подстановка кода функций

Скалярные функции на SQL

  • один оператор SELECT без предложения FROM,
  • возвращает одно значение
  • вызываемые функции не должны быть изменчивее вызывающей
  • и др.
  • Табличные функции на SQL
    • один оператор SELECT
    • категория Immutable или Stable
    • функция не STRICT
    • и др.

Комментарии преподавателя

Оптимизатор PostgreSQL умеет подставлять (inline) тело функции в SQL-запрос. Это работает как со скалярными, так и с табличными функциями.

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

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

https://wiki.postgresql.org/wiki/Inlining\_of\_SQL\_functions

Page 8: Подстановка кода функций в SQL-запрос

Тело очень простых скалярных функций на языке SQL может быть подставлено прямо в основной SQL-оператор на этапе разбора запроса. В этом случае время на вызов функции не тратится. Пример мы уже видели: наша функция rnd(). Проверим, какой будет план запроса в случае, когда категория изменчивости функции rnd (Stable) не соответствует категории изменчивости функции random (Volatile):

=> ALTER FUNCTION rnd() STABLE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                      QUERY PLAN
------------------------------------------------------
 Result
   One-Time Filter: (rnd() > '0.5'::double precision)
   ->  Function Scan on generate_series
(3 rows)

В фильтре упоминается функция rnd(). Поменяем категорию изменчивости функции на Volatile:

=> ALTER FUNCTION rnd() VOLATILE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT * FROM generate_series(1,10) WHERE rnd() > 0.5;
                   QUERY PLAN
------------------------------------------------
 Function Scan on generate_series
   Filter: (random() > '0.5'::double precision)
(2 rows)

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

=> CREATE FUNCTION flights_from(airport_name text)
RETURNS SETOF flights
AS $$
  SELECT f.*
  FROM flights f
    JOIN airports a ON f.departure_airport = a.airport_code
  WHERE a.airport_name = flights_from.airport_name;
$$
LANGUAGE sql STABLE;
CREATE FUNCTION

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

=> EXPLAIN (costs off)
SELECT *
FROM flights_from('Оренбург-Центральный')
WHERE status = 'Arrived';

Page 9: Page 9

                                    QUERY PLAN
----------------------------------------------------------------------------------
 Hash Join
   Hash Cond: (f.departure_airport = ml.airport_code)
   ->  Seq Scan on flights f
         Filter: ((status)::text = 'Arrived'::text)
   ->  Hash
         ->  Seq Scan on airports_data ml
               Filter: ((airport_name ->> lang()) =
'Оренбург-Центральный'::text)
(7 rows)

Page 10: Вызов табличных функций

Source slide page 10: Вызов табличных функций{#source-figure-010 caption=“Source slide page 10: Вызов табличных функций”}

SELECT * FROM fun()
SELECT fun()

Комментарии преподавателя

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

Если же функция вызывается в предложении SELECT, в плане она выполняется в узле ProjectSet. В этом случае функция может воспользоваться интерфейсом возвращения строк «по требованию» (value-per-call), без материализации.

Так работают функции на языке SQL, большинство встроенных функций (написанных на С) и функции на PL/Python, возвращающие итератор. Функции на других языках программирования тоже могут использовать этот интерфейс, если такая возможность в них реализована.

Page 11: Табличные функции

Вызовем функцию generate_series в предложении FROM с ограничением на количество строк (LIMIT):

=> EXPLAIN (analyze, costs off)
SELECT * FROM generate_series(1,10_000_000)
LIMIT 10;
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Limit (actual time=1978.942..1978.948 rows=10 loops=1)
   ->  Function Scan on generate_series (actual time=1978.940..1978.943 rows=10 loops=1)
 Planning Time: 0.048 ms
 Execution Time: 2015.720 ms
(4 rows)

В плане запроса видим узел Function Scan — сначала были получены все строки из функции, и только потом наложено ограничение LIMIT. А теперь повторим запрос, только вызовем функцию из предложения SELECT:

=> EXPLAIN (analyze, costs off)
SELECT generate_series(1,10_000_000)
LIMIT 10;
                          QUERY PLAN
--------------------------------------------------------------
 Limit (actual time=0.004..0.006 rows=10 loops=1)
   ->  ProjectSet (actual time=0.002..0.004 rows=10 loops=1)
         ->  Result (actual time=0.001..0.001 rows=1 loops=1)
 Planning Time: 0.037 ms
 Execution Time: 0.017 ms
(5 rows)

Теперь в плане появился узел ProjectSet, который формирует десять строк выборки — в данном случае оптимизатору удалось получать строки по требованию. Время выполнения запроса сократилось на порядки. Узел Result здесь представляет опущенное в запросе предложение FROM — он передает родительскому узлу ровно одну строку. Однако не все функции могут возвращать строки по одной. Например, обращение к любой функции на языке PL/pgSQL возвращает все строки результата:

=> CREATE FUNCTION plpgsql_rows() RETURNS SETOF integer
AS $$
BEGIN
  RETURN QUERY
    SELECT * FROM generate_series(1,10_000_000);
END
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Вызовем функцию из предложения SELECT:

=> EXPLAIN (analyze, costs off)
SELECT plpgsql_rows() LIMIT 10;
                            QUERY PLAN
-------------------------------------------------------------------
 Limit (actual time=4214.189..4214.195 rows=10 loops=1)
   ->  ProjectSet (actual time=4214.188..4214.192 rows=10 loops=1)
         ->  Result (actual time=0.001..0.001 rows=1 loops=1)
 Planning Time: 0.027 ms
 Execution Time: 4253.766 ms
(5 rows)

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

=> SELECT generate_series(1, generate_series(1,3)), unnest(ARRAY['A','B','C']);

Page 12: Page 12

 generate_series | unnest
-----------------+--------
               1 | A
               1 | B
               2 | B
               1 | C
               2 | C
               3 | C
(6 rows)
=> EXPLAIN (verbose, costs off)
SELECT generate_series(1, generate_series(1,3)), unnest(ARRAY['A','B','C']);
                                     QUERY PLAN
------------------------------------------------------------------------------------
 ProjectSet
   Output: generate_series(1, (generate_series(1, 3))), (unnest('{A,B,C}'::text[]))
   ->  ProjectSet
         Output: generate_series(1, 3), unnest('{A,B,C}'::text[])
         ->  Result
(5 rows)

Нижний узел ProjectSet формирует выборку из результатов выполнения двух табличных функций: generate_series(1,3) и unnest. В этой выборке оказываются три строки. Верхний узел ProjectSet формирует итоговую выборку, вычисляя внешний вызов generate_series. Без учета материализации такой запрос эквивалентен следующему запросу, в котором функции вызываются в предложении FROM:

=> SELECT g2.i, u.c
FROM generate_series(1,3) WITH ORDINALITY AS g1(i)
  FULL JOIN LATERAL unnest(ARRAY['A','B','C']) WITH ORDINALITY AS u(c)
    ON g1.ordinality = u.ordinality
  CROSS JOIN LATERAL generate_series(1, g1.i) AS g2(i);
 i | c
---+---
 1 | A
 1 | B
 2 | B
 1 | C
 2 | C
 3 | C
(6 rows)

Page 13: Настройки COST и ROWS

CREATE FUNCTION fun( )

SELECT * FROM fun()
  • Function Scan ( rows=1000 cost=100 )
  • оценки
  • по умолчанию
  • CREATE FUNCTION fun( ) ROWS 12 COST 123
SELECT * FROM fun()
  • Function Scan ( rows=12 cost=123 )
  • ROWS
  • COST

Комментарии преподавателя

Обычно (если не удалось подставить тело функции в запрос) оптимизатор не имеет возможности анализировать код функции и воспринимает ее как «черный ящик».

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

Параметр COST задает стоимость пользовательской функции в единицах cpu_operator_cost. По умолчанию функции на C получают оценку 1, а на других языках — 100.

Параметр ROWS указывает примерное число возвращаемых строк.

COST и ROWS можно указывать как при создании функции, так и для уже существующих функций.

https://postgrespro.ru/docs/enterprise/16/sql-createfunction

Page 14: Настройки COST и ROWS

Напишем табличную функцию на языке PL/pgSQL, выводящую дни недели:

=> CREATE FUNCTION days_of_week() RETURNS SETOF text
AS $$
BEGIN
    FOR i IN 7 .. 13 LOOP
        RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> SELECT * FROM days_of_week();
 days_of_week
--------------
 Пн
 Вт
 Ср
 Чт
 Пт
 Сб
 Вс
(7 rows)

План запроса:

=> EXPLAIN
SELECT * FROM days_of_week();
                              QUERY PLAN
----------------------------------------------------------------------
 Function Scan on days_of_week  (cost=0.25..10.25 rows=1000 width=32)
(1 row)

Стоимость выполнения функции считается постоянной, для пользовательских функций по умолчанию она равна стоимости 100 операторов:

=> SELECT 100 * current_setting('cpu_operator_cost')::float;
 ?column?
----------
     0.25
(1 row)

Но это значение можно изменить:

=> ALTER FUNCTION days_of_week COST 1000;
ALTER FUNCTION
=> EXPLAIN
SELECT * FROM days_of_week();
                              QUERY PLAN
----------------------------------------------------------------------
 Function Scan on days_of_week  (cost=2.50..12.50 rows=1000 width=32)
(1 row)

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

=> ALTER FUNCTION days_of_week ROWS 10;
ALTER FUNCTION

Повторим запрос:

Page 15: Page 15

=> EXPLAIN
SELECT * FROM days_of_week();
                            QUERY PLAN
-------------------------------------------------------------------
 Function Scan on days_of_week  (cost=2.50..2.60 rows=10 width=32)
(1 row)

Теперь сервер считает, что функция вернет 10 строк, поэтому уменьшилась полная стоимость узла:

=> SELECT 1000 * current_setting('cpu_operator_cost')::float
+ 10 * current_setting('cpu_tuple_cost')::float;
 ?column?
----------
      2.6
(1 row)

Измененные значения можно увидеть в системном каталоге:

=> SELECT procost, prorows FROM pg_proc WHERE proname='days_of_week';
 procost | prorows
---------+---------
    1000 |      10
(1 row)

Или с помощью метакоманды psql:

=> \sf days_of_week
CREATE OR REPLACE FUNCTION bookings.days_of_week()
 RETURNS SETOF text
 LANGUAGE plpgsql
 COST 1000 ROWS 10
AS $function$
BEGIN
    FOR i IN 7 .. 13 LOOP
        RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
    END LOOP;
END;
$function$

Page 16: Вспомогательные функции

Source slide page 16: Вспомогательные функции{#source-figure-016 caption=“Source slide page 16: Вспомогательные функции”}

CREATE FUNCTION fun(x) ROWS 18

SELECT * FROM fun(1)           SELECT * FROM fun(2)
  • CREATE FUNCTION fun(x) SUPPORT fun_support
SELECT * FROM fun(1)           SELECT * FROM fun(2)

Комментарии преподавателя

Параметры COST и ROWS позволяют задать стоимость и количество строк, возвращаемых функцией, как постоянные величины. Но константы не всегда дают желаемый результат.

В PostgreSQL есть возможность для функции написать вспомогательную функцию, которая предоставляет планировщику информацию, зависящую от значений аргументов основной (целевой) функции.

Вспомогательная функция может по значениям аргументов целевой функции выдавать:

● оценку ее стоимости;

● оценку числа возвращаемых строк;

● выражение, эквивалентное вызову функции.

Дополнительно для функций, возвращающих boolean:

● оценку селективности;

● эквивалентный предикат с индексируемым оператором.

Вспомогательная функция должна быть написана на языке С.

https://postgrespro.ru/docs/postgresql/16/xfunc-optimization

Page 17: Вспомогательные функции планировщика

Посмотрим план запроса с вызовом функции generate_series:

=> EXPLAIN
SELECT n FROM generate_series(1,5) n;
                              QUERY PLAN
----------------------------------------------------------------------
 Function Scan on generate_series n  (cost=0.00..0.05 rows=5 width=4)
(1 row)

В отличии от функции days_of_week, оптимизатор сразу правильно оценивает количество возвращаемых строк. Более того, оценка зависит от параметров функции:

=> EXPLAIN
SELECT n FROM generate_series(1,15) n;
                              QUERY PLAN
-----------------------------------------------------------------------
 Function Scan on generate_series n  (cost=0.00..0.15 rows=15 width=4)
(1 row)

Благодаря вспомогательной функции (она может быть написана только на языке С) планировщик получает дополнительную информацию, которую использует для вычисления селективности условий, кардинальности функции или ее стоимости. Посмотреть, имеется ли вспомогательная функция, можно в таблице pg_proc:

=> SELECT left(pg_get_function_arguments(p.oid), 57) proargtypes, prosupport
FROM pg_proc p
WHERE p.proname = 'generate_series';
                        proargtypes                        |          prosupport
-----------------------------------------------------------+------------------------------
 integer, integer, integer                                 | generate_series_int4_support
 integer, integer                                          | generate_series_int4_support
 bigint, bigint, bigint                                    | generate_series_int8_support
 bigint, bigint                                            | generate_series_int8_support
 numeric, numeric, numeric                                 | -
 numeric, numeric                                          | -
 timestamp without time zone, timestamp without time zone, | -
 timestamp with time zone, timestamp with time zone, inter | -
 timestamp with time zone, timestamp with time zone, inter | -
(9 rows)

Как видно, вспомогательные функции существуют не для всех перегруженных вариантов функции generate_series. Посмотрим на план запроса, генерирующего ряд дат:

=> EXPLAIN SELECT *
FROM generate_series(now(), now() + interval '5 day','1 day');
                               QUERY PLAN
------------------------------------------------------------------------
 Function Scan on generate_series  (cost=0.01..10.01 rows=1000 width=8)
(1 row)

Без вспомогательной функции и указания ROWS оптимизатор не имеет информации о числе строк в результате и поэтому использует значение по умолчанию (1000). С каждой версией в PostgreSQL появляются новые вспомогательные функции.

Page 18: Пометки параллельности

CREATE FUNCTION fun( ) PARALLEL …

  • небезопасные для распараллеливания
  • (по умолчанию)

UNSAFE

  • ограниченно распараллеливаемые
  • RESTRICTED
  • безопасные для распараллеливания
  • SAFE

Комментарии преподавателя

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

При создании функции (или позже) можно указать одну из трех пометок:

● UNSAFE — запрещаются параллельные планы, если в запросе есть вызов функции;

● RESTRICTED — разрешаются параллельные планы, но запрещено вызывать функцию в параллельной части плана;

● SAFE — безопасна для параллельной обработки.

По умолчанию используется пометка UNSAFE.

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

https://postgrespro.ru/docs/postgresql/16/parallel-safety#PARALLEL-LABELI NG

Page 19: Пометки параллельности

Пометки параллельности можно увидеть в столбце proparallel таблицы pg_proc (r=restricted, s=safe, u=unsafe):

=> SELECT proparallel, count(*)
FROM pg_proc
GROUP BY proparallel;
 proparallel | count
-------------+-------
 r           |   185
 s           |  3027
 u           |    91
(3 rows)

Все основные стандартные функции безопасны. Пометки также показывает метакоманда \df+ утилиты psql (поле Parallel):

=> \x
Expanded display is on.
=> \df+ random
List of functions
-[ RECORD 1 ]-------+-----------------
Schema              | pg_catalog
Name                | random
Result data type    | double precision
Argument data types |
Type                | func
Volatility          | volatile
Parallel            | restricted
Owner               | postgres
Security            | invoker
Access privileges   |
Language            | internal
Internal name       | drandom
Description         | random value
=> \x
Expanded display is off.

Проверим, как пометка параллельности влияет на план выполнения запроса. Напишем функцию, вычисляющую стоимость билета. Она помечена как безопасная для параллельного выполнения:

=> CREATE FUNCTION ticket_amount(ticket_no char(13)) RETURNS numeric
LANGUAGE plpgsql STABLE PARALLEL SAFE
AS $$
BEGIN
    RETURN (SELECT sum(amount)
            FROM ticket_flights tf
            WHERE tf.ticket_no = ticket_amount.ticket_no
    );
END;
$$;
CREATE FUNCTION

Запрос проверяет, что общая стоимость бронирований совпадает с общей стоимостью билетов:

=> EXPLAIN (costs off)
SELECT (SELECT sum(ticket_amount(ticket_no)) FROM tickets) =
       (SELECT sum(total_amount) FROM bookings);

Page 20: Page 20

                       QUERY PLAN
---------------------------------------------------------
 Result
   InitPlan 1 (returns $1)
     ->  Finalize Aggregate
           ->  Gather
                 Workers Planned: 2
                 ->  Partial Aggregate
                       ->  Parallel Seq Scan on tickets
   InitPlan 2 (returns $3)
     ->  Finalize Aggregate
           ->  Gather
                 Workers Planned: 2
                 ->  Partial Aggregate
                       ->  Parallel Seq Scan on bookings
(13 rows)

План запроса состоит из двух частей: в узле InitPlan 1 выполняется подзапрос с агрегацией по tickets, а подзапрос в узле InitPlan 2 выполняет агрегацию по bookings. Сейчас оба подзапроса выполняются параллельно. Поменяем пометку параллельности на UNSAFE:

=> ALTER FUNCTION ticket_amount PARALLEL UNSAFE;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT (SELECT sum(ticket_amount(ticket_no)) FROM tickets) =
       (SELECT sum(total_amount) FROM bookings);
             QUERY PLAN
------------------------------------
 Result
   InitPlan 1 (returns $0)
     ->  Aggregate
           ->  Seq Scan on tickets
   InitPlan 2 (returns $1)
     ->  Aggregate
           ->  Seq Scan on bookings
(7 rows)

Теперь оба подзапроса выполняются последовательно — пометка запрещает параллельные планы. А теперь пометим функцию как ограниченно распараллеливаемую (RESTRICTED):

=> ALTER FUNCTION ticket_amount PARALLEL RESTRICTED;
ALTER FUNCTION
=> EXPLAIN (costs off)
SELECT (SELECT sum(ticket_amount(ticket_no)) FROM tickets) =
       (SELECT sum(total_amount) FROM bookings);
                       QUERY PLAN
---------------------------------------------------------
 Result
   InitPlan 1 (returns $0)
     ->  Aggregate
           ->  Seq Scan on tickets
   InitPlan 2 (returns $2)
     ->  Finalize Aggregate
           ->  Gather
                 Workers Planned: 2
                 ->  Partial Aggregate
                       ->  Parallel Seq Scan on bookings
(10 rows)

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

Конфигурационные параметры

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

Page 21: Page 21

установки параметров для конкретных подпрограмм. Рассмотрим в качестве примера запрос:

=> EXPLAIN (analyze, costs off, timing off)
SELECT count(*) FROM bookings;
                                  QUERY PLAN
------------------------------------------------------------------------------
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Seq Scan on bookings (actual rows=703703 loops=3)
 Planning Time: 0.108 ms
 Execution Time: 747.866 ms
(8 rows)

Допустим, мы хотим использовать параллельные планы, но именно этот запрос собираемся выполнять последовательно. Тогда мы можем установить параметр на уровне функции:

=> CREATE FUNCTION count_bookings() RETURNS bigint
AS $$
SELECT count(*) FROM bookings;
$$ LANGUAGE sql STABLE;
CREATE FUNCTION
=> ALTER FUNCTION count_bookings SET max_parallel_workers_per_gather = 0;
ALTER FUNCTION

О том, как проверить план запроса, выполняющегося внутри функции, мы говорили в теме «Профилирование». Воспользуемся расширением auto_explain:

=> LOAD 'auto_explain';
LOAD
=> SET auto_explain.log_min_duration = 0;
SET
=> SET auto_explain.log_nested_statements = on;
SET

Выполним запрос:

=> SELECT count_bookings();
 count_bookings
----------------
        2111110
(1 row)

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

student$ tail -n 10 /var/log/postgresql/postgresql-16-main.log
2026-04-10 14:51:13.534 MSK [27878] postgres@demo LOG:  duration: 260.123 ms  plan:
Query Text:
SELECT count(*) FROM bookings;
Aggregate  (cost=39835.88..39835.89 rows=1 width=8)
  ->  Seq Scan on bookings  (cost=0.00..34558.10 rows=2111110 width=0)
2026-04-10 14:51:13.534 MSK [27878] postgres@demo CONTEXT:  SQL function "count_bookings"
statement 1
2026-04-10 14:51:13.534 MSK [27878] postgres@demo LOG:  duration: 260.362 ms  plan:
Query Text: SELECT count_bookings();
Result  (cost=0.00..0.26 rows=1 width=8)

Page 22: Итоги

Функция — черный ящик для планировщика, если ее тело не подставляется в запрос

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

Page 23: Практика

  1. Отключите материализацию общего табличного выражения, в котором вызывается функция random. Объясните результат. 2. Напишите функцию-обертку на SQL для запроса SELECT * FROM generate_series (1, 10_000_000). Рассмотрите три варианта: сам исходный запрос и вызовы функции в предложениях FROM и SELECT. Сравните планы выполнения запросов и использование временных файлов. Что меняется при установке категории изменчивости Stable? 3. Какую категорию изменчивости имеет функция days_of_week из демонстрации? Какова ее изменчивость на самом деле?
  2. Определение функции:
CREATE FUNCTION days_of_week() RETURNS SETOF text
AS $$
BEGIN
    FOR i IN 7 .. 13 LOOP
        RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
    END LOOP;
END;
$$ LANGUAGE plpgsql;

Page 24: 1. Материализация изменчивых функций в CTE

Общее табличное выражение с изменчивой функцией всегда материализуется:

=> EXPLAIN (costs off)
WITH c AS (
  SELECT random()
)
SELECT * FROM c;
   QUERY PLAN
----------------
 CTE Scan on c
   CTE c
     ->  Result
(3 rows)

В этом случае указание NOT MATERIALIZED не действует:

=> EXPLAIN (costs off)
WITH c AS NOT MATERIALIZED (
  SELECT random()
)
SELECT * FROM c;
   QUERY PLAN
----------------
 CTE Scan on c
   CTE c
     ->  Result
(3 rows)

2. Функция-обертка

=> CREATE FUNCTION sql_rows_lab() RETURNS SETOF integer
AS $$
  SELECT * FROM generate_series(1,10_000_000);
$$ LANGUAGE sql;
CREATE FUNCTION

По умолчанию функция имеет категорию изменчивости Volatile. Базовый запрос:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM generate_series(1,10_000_000);
                           QUERY PLAN
-----------------------------------------------------------------
 Function Scan on generate_series (actual rows=10000000 loops=1)
   Buffers: temp read=17090 written=17090
 Planning Time: 0.301 ms
 Execution Time: 4017.077 ms
(4 rows)

Поскольку функция вызывается в предложении FROM, происходит материализация. Памяти work_mem не хватает, все строки сбрасываются на диск (temp written) и затем считываются (temp read). Вызов функции в предложении SELECT:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT sql_rows_lab();
                QUERY PLAN
-------------------------------------------
 ProjectSet (actual rows=10000000 loops=1)
   Buffers: temp read=17090 written=17090
   ->  Result (actual rows=1 loops=1)
 Planning Time: 0.034 ms
 Execution Time: 10728.799 ms
(5 rows)

Page 25: Page 25

В узле ProjectSet нет материализации, поэтому цифры temp written/read остались без изменений. Однако время выполнения сильно увеличилось: оно тратится на передачу десяти миллионов строк от узла к узлу по одной строке. Вызов функции в предложении FROM:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM sql_rows_lab();
                          QUERY PLAN
--------------------------------------------------------------
 Function Scan on sql_rows_lab (actual rows=10000000 loops=1)
   Buffers: temp read=34180 written=34180
 Planning Time: 0.028 ms
 Execution Time: 7592.387 ms
(4 rows)

Здесь к узлу Function Scan внутри функции добавляется еще один в основном запросе, поэтому количество использованных временных страниц удваивается. Сменим категорию изменчивости функции:

=> ALTER FUNCTION sql_rows_lab STABLE;
ALTER FUNCTION

При вызове функции из предложения SELECT ничего не меняется:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT sql_rows_lab();
                       QUERY PLAN
--------------------------------------------------------
 ProjectSet (actual rows=10000000 loops=1)
   Buffers: shared hit=8, temp read=17090 written=17090
   ->  Result (actual rows=1 loops=1)
 Planning Time: 0.028 ms
 Execution Time: 8739.434 ms
(5 rows)

Вызовем функцию из предложения FROM:

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM sql_rows_lab();
                           QUERY PLAN
-----------------------------------------------------------------
 Function Scan on generate_series (actual rows=10000000 loops=1)
   Buffers: temp read=17090 written=17090
 Planning Time: 0.070 ms
 Execution Time: 3954.514 ms
(4 rows)

Теперь тело функции подставляется в основной запрос.

3. Дни недели

Функция была создана следующей командой:

=> CREATE FUNCTION days_of_week() RETURNS SETOF text
AS $$
BEGIN
    FOR i IN 7 .. 13 LOOP
        RETURN NEXT to_char(to_date(i::text,'J'),'TMDy');
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Категория изменчивости не указана, поэтому подразумевается Volatile. Может показаться, что это постоянная функция (Immutable), поскольку у нее нет параметров и список дней недели не меняется.

=> SELECT * FROM days_of_week();

Page 26: Page 26

 days_of_week
--------------
 Пн
 Вт
 Ср
 Чт
 Пт
 Сб
 Вс
(7 rows)

Однако названия дней недели зависят от настройки локализации. Текущее значение:

=> \dconfig lc_time
List of configuration parameters
 Parameter |    Value
-----------+-------------
 lc_time   | ru_RU.UTF-8
(1 row)

Изменим настройку:

=> SET lc_time = 'en_US.UTF8';
SET
=> SELECT * FROM days_of_week();
 days_of_week
--------------
 Mon
 Tue
 Wed
 Thu
 Fri
 Sat
 Sun
(7 rows)

Теперь функция возвращает названия дней недели на английском языке, поэтому правильным будет задать категорию Stable:

=> ALTER FUNCTION days_of_week() STABLE;
ALTER FUNCTION

16 Занятие 16: Оптимизация запросов / Подходы к настройке

Исходная страница в нативном Markdown: Оптимизация запросов / Подходы к настройке
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: qpt_16_optim_tuning.html Archive: QPT-handouts-16.zip Страниц PDF: 35

Page 1: Оптимизация запросов / Подходы к настройке

Авторские права

© Postgres Professional, 2019–2024 Авторы: Егор Рогов, Павел Лузанов, Павел Толмачев, Илья Баштанов Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

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

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Что настраивать?
  • Настройка сервера
  • Настройка приложения
  • Запросы

Page 3: Что настраивать?

Source slide page 3: Что настраивать?{#source-figure-003 caption=“Source slide page 3: Что настраивать?”}

  • запросы
  • приложение

Комментарии преподавателя

Производительность работы информационной системы обеспечивается на разных уровнях.

Мы совсем не рассматриваем два важных уровня: оборудование (которое может быть виртуализировано, и тогда к картине добавляется еще один слой) и операционную систему. В частности, работа СУБД с данными происходит через файловую систему, а эффективность и надежность операций ввода-вывода зависит от дисковой подсистемы. Поэтому прежде всего нужно обеспечить необходимые ресурсы и настроить ОС для их максимального использования.

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

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

Но запросы, составляющие рабочую нагрузку, инициируются приложением. Поэтому и настройка приложения не менее важна, чем настройка СУБД.

Page 4: Настройка сервера

Использование ресурсов

  • Физическое расположение данных
  • Статистика и настройки оптимизатора

Page 5: Ресурсы: CPU и ввод-вывод

Фоновые рабочие процессы

  • max_parallel_workers_per_gather = 2
  • max_parallel_workers = 8
  • max_worker_processes = 8
  • Ввод-вывод
    • effective_io_concurrency = 1
    • maintenance_io_concurrency = 10

Комментарии преподавателя

Начнем с настроек, которые определяют, какими ресурсами может распоряжаться СУБД.

PostgreSQL может использовать несколько ядер процессоров при параллельной обработке. Для этого запускаются дополнительные фоновые рабочие процессы. Подробно параллельное выполнение запросов было рассмотрено в теме «Параллельный доступ», а использование фоновых процессов для прикладной разработки — в теме «Фоновые процессы» курса DEV2.

Из настроек, связанных с использованием ввода-вывода, отметим параметр effective_io_concurrency. Он сообщает системе количество независимых дисков в дисковом массиве. Фактически этот параметр влияет только на количество страниц, которые будут предварительно считываться в кеш при сканировании по битовой карте.

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

Page 6: Ресурсы: память

Память

  • ↑
  • work_mem
  • = 4MB
  • hash_mem_multiplier
  • = 2
  • maintenance_work_mem = 64MB
  • ↑
  • shared_buffers
  • = 128MB
  • effective_cache_size
  • = 4GB

Комментарии преподавателя

Ряд настроек влияет на выделение и использование памяти.

Параметр work_mem определяет размер доступной памяти для отдельных операций (рассматривался в соответствующих темах).

Также он влияет и на выбор плана. Например, при небольших значениях предпочтение отдается сортировке, которая лучше работает при недостатке памяти, чем хеширование. Поэтому для узлов, использующих хеширование, размер рабочей памяти можно увеличить с помощью параметра-мультипликатора hash_mem_multiplier.

Параметр maintenance_work_mem влияет на скорость построения индексов и на работу служебных процессов.

Параметр shared_buffers определяет размер буферного кеша экземпляра. Настройка этого параметра рассматривается в курсе DBA2, но точно можно сказать, что значение по умолчанию крайне мало.

Параметр effective_cache_size подсказывает PostgreSQL общий объем кешируемых данных (как в буферном кеше, так и в кеше ОС). Чем больше его значение, тем более предпочтительным будет индексный доступ. Этот параметр не влияет на реальное выделение памяти.

Page 7: Физическое расположение

Табличные пространства

  • разнесение данных по разным физическим устройствам
  • ALTER TABLESPACE SET …
  • Секционирование
    • разделение таблицы на отдельно управляемые части
    • для упрощения администрирования и ускорения доступа
  • Шардирование
    • размещение секций на разных серверах для масштабирования
    • нагрузки на чтение и запись
    • секционирование и расширение postgres-fdw
    • или сторонние решения

Комментарии преподавателя

Физическая организация данных может сильно влиять на производительность.

С помощью табличных пространств можно управлять размещением объектов по физическим устройствам ввода-вывода. Например, активно используемые данные хранить на SSD-дисках, а архивные — на более медленных HDD.

Часть параметров сервера, которые зависят от характеристик носителя, (например, random_page_cost) можно устанавливать на уровне табличных пространств.

Секционирование позволяет организовать работу с данными очень большого объема. Основная выгода для производительности состоит в замене полного сканирования всей таблицы сканированием отдельной секции. Заметим, что секции тоже можно размещать по различным табличным пространствам.

Еще один вариант — размещение секций на разных серверах (шардирование) с возможностью выполнения распределенных запросов. Стандартный PostgreSQL содержит только базовые механизмы, необходимые для организации шардирования: секционирование и расширение postgres-fdw. Более эффективно и полноценно шардинг реализуется с помощью внешних решений. Обзорно этот вопрос рассматривается в последней теме курса DBA3.

https://postgrespro.ru/docs/postgresql/16/ddl-partitioning

https://postgrespro.ru/docs/postgresql/16/postgres-fdw

Page 8: Оптимизатор: статистика

Актуальность

  • настройка автоочистки и автоанализа
  • autovacuum_max_workers, autovacuum_analyze_scale_factor, …
  • Точность
    • default_statistics_target = 100
    • индекс по выражению, расширенная статистика

Комментарии преподавателя

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

Кроме того, статистика должна быть достаточно точной. Абсолютной точности достичь не получится (и не нужно), но погрешности не должны приводить к построению некорректных планов. Признаком неактуальной, неточной статистики будет серьезное несоответствие ожидаемого и реального числа строк в листовых узлах плана.

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

Page 9: Оптимизатор: стоимость

Ввод-вывод

  • seq_page_cost
  • = 1.0
  • random_page_cost = 4.0
  • ↓ для SSD
  • Время процессора
    • cpu_tuple_cost, cpu_index_tuple_cost, cpu_operator_cost
    • стоимость пользовательских функций

Комментарии преподавателя

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

Параметры seq_page_cost и random_page_cost связаны со вводом-выводом и определяют относительную стоимость чтения одной страницы при последовательном и при произвольном доступе.

Значение seq_page_cost равно единице и его не стоит изменять. Высокое значение параметра random_page_cost отражает реалии HDD-дисков. Для SSD-дисков (а также в случаях, когда все данные с большой вероятностью будут закешированы), значение этого параметра необходимо значительно уменьшать, например, до 1.1.

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

Но, как мы видели в теме «Функции», иногда полезно задать стоимость пользовательской функции в единицах cpu_operator_cost.

https://postgrespro.ru/docs/postgresql/16/runtime-config-query#RUNTIME-C ONFIG-QUERY-CONSTANTS

Page 10: Настройки стоимости

Пример запроса с корректной оценкой кардинальности:

=> EXPLAIN (analyze, timing off) SELECT *
FROM bookings
WHERE book_ref < '9000';
                                           QUERY PLAN
------------------------------------------------------------------------------------------
-------
 Seq Scan on bookings  (cost=0.00..39835.88 rows=1203203 width=21) (actual rows=1187454
loops=1)
   Filter: (book_ref < '9000'::bpchar)
   Rows Removed by Filter: 923656
 Planning Time: 5.477 ms
 Execution Time: 715.530 ms
(5 rows)

Планировщик выбрал последовательное сканирование, и сделал это на основе полной информации. Так ли удачно это решение? Проверим, скомандовав планировщику не использовать последовательное сканирование, если есть другие способы:

=> SET enable_seqscan = off; -- действует до конца сеанса
SET

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

=> EXPLAIN (analyze, timing off) SELECT *
FROM bookings
WHERE book_ref < '9000';
                                                      QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------
 Index Scan using bookings_pkey on bookings  (cost=0.43..41931.48 rows=1203203 width=21)
(actual rows=1187454 loops=1)
   Index Cond: (book_ref < '9000'::bpchar)
 Planning Time: 0.299 ms
 Execution Time: 466.058 ms
(4 rows)

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

=> RESET enable_seqscan; -- отменяем
RESET

Page 11: Настройка приложения

Клиенты и соединения

  • Схема данных

Page 12: Клиенты и соединения

Много сеансов

  • пул соединений
  • Курсоры
    • если нужна часть выборки
    • cursor_tuple_fraction
  • Много коротких запросов
    • подготовленные операторы
    • перенос логики в SQL

Комментарии преподавателя

Если приложение открывает слишком много соединений или устанавливает их слишком часто и на короткое время, может потребоваться установить между приложением и СУБД пул соединений. Однако пул накладывает на приложение определенные ограничения. Подробно этот вопрос рассмотрен в курсе DEV2.

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

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

В таких случаях со стороны СУБД практически нет средств для оптимизации (кроме подготовленных операторов, если запросы повторяются). Действенный метод — избавление от процедурного кода в приложении и перенос его на сервер БД в виде небольшого числа крупных SQL-команд. Это дает планировщику возможность применить более эффективные способы доступа и соединений и избавляет от многочисленных пересылок данных от клиента к серверу и обратно. К сожалению, если приложение уже написано, переделать его бывает очень затратно.

Page 13: Схема данных

Нормализация — устранение избыточности в данных

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

Комментарии преподавателя

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

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

Самый частый способ денормализации — индексы (хотя о них обычно не думают в таком контексте). Индексы обновляются автоматически.

Можно дублировать некоторые данные (или результаты расчета на основе этих данных) в столбцах таблиц. Можно использовать генерируемые столбцы или синхронизировать данные с помощью триггеров. Так или иначе, за денормализацию отвечает база данных.

Другой пример — материализованные представления. Их также надо обновлять, например, по расписанию или другим способом. Подробно это рассматривалось в теме «Материализация».

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

Page 14: Схема данных

Типы данных

  • выбор подходящих типов
  • составные типы (массивы, JSON) вместо отдельных таблиц
  • Ограничения целостности
    • помимо обеспечения целостности данных, могут учитываться
    • планировщиком для устранения ненужных соединений, улучшения
    • оценок селективности и других оптимизаций
    • первичный ключ и уникальность — уникальный индекс
    • внешний ключ
    • отсутствие неопределенных значений
    • проверка CHECK (constraint_exclusion)

Комментарии преподавателя

Важен правильный выбор типов данных из всего многообразия, предлагаемого PostgreSQL. Например, представление интервалов дат не двумя отдельными столбцами, а с помощью диапазонных типов (daterange, tstzrange) позволяет использовать индексы GiST и SP-GiST для таких операций, как пересечение интервалов.

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

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

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

● Наличие внешнего ключа и ограничений NOT NULL позволяет в ряде случаев устранять лишние соединения (что особенно важно при использовании представлений), а также улучшает оценку селективности в случае, если соединение происходит по нескольким столбцам.

● Ограничение CHECK с использованием параметра constraint_exclusion позволяет не сканировать таблицы (или секции), если в них гарантированно нет требуемых данных.

Page 15: Схема данных

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

=> EXPLAIN SELECT *
FROM ticket_flights tf
  JOIN boarding_passes bp ON tf.flight_id = bp.flight_id
                         AND tf.ticket_no = bp.ticket_no;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Hash Join  (cost=310601.20..677419.75 rows=7925688 width=57)
   Hash Cond: ((tf.flight_id = bp.flight_id) AND (tf.ticket_no = bp.ticket_no))
   ->  Seq Scan on ticket_flights tf  (cost=0.00..153850.08 rows=8391708 width=32)
   ->  Hash  (cost=137535.88..137535.88 rows=7925688 width=25)
         ->  Seq Scan on boarding_passes bp  (cost=0.00..137535.88 rows=7925688 width=25)
(5 rows)

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

=> BEGIN;
BEGIN
=> ALTER TABLE boarding_passes
DROP CONSTRAINT boarding_passes_ticket_no_fkey;
ALTER TABLE
=> EXPLAIN SELECT *
FROM ticket_flights tf
  JOIN boarding_passes bp ON tf.flight_id = bp.flight_id
                         AND tf.ticket_no = bp.ticket_no;
                                               QUERY PLAN
------------------------------------------------------------------------------------------
--------------
 Gather  (cost=164413.25..358079.46 rows=299 width=57)
   Workers Planned: 2
   ->  Parallel Hash Join  (cost=163413.25..357049.56 rows=125 width=57)
         Hash Cond: ((tf.flight_id = bp.flight_id) AND (tf.ticket_no = bp.ticket_no))
         ->  Parallel Seq Scan on ticket_flights tf  (cost=0.00..104898.45 rows=3496545
width=32)
         ->  Parallel Hash  (cost=91302.70..91302.70 rows=3302370 width=25)
               ->  Parallel Seq Scan on boarding_passes bp  (cost=0.00..91302.70
rows=3302370 width=25)
(7 rows)
=> ROLLBACK;
ROLLBACK

Page 16: Запросы

Пути оптимизации

  • Короткие запросы
  • Длинные запросы

Page 17: Пути оптимизации

Цель оптимизации — получить адекватный план

  • Исправление неэффективностей
    • каким-то образом найти и исправить узкое место
    • бывает сложно определить, в чем проблема
    • часто приводит к борьбе с планировщиком
  • Правильный расчет кардинальности
    • добиться правильного расчета кардинальности в каждом узле
    • и положиться на планировщик
    • если план все еще неадекватный, настраивать глобальные параметры

Комментарии преподавателя

Цель оптимизации запроса — получить адекватный план выполнения. Есть разные пути, которыми можно идти к этой цели.

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

Если идти таким путем, то хочется иметь возможность целиком или частично отключить планировщик и самому создать план выполнения. Такая возможность называется подсказками (хинтами) и в явном виде отсутствует в PostgreSQL.

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

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

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

Обычно имеет смысл применять оба способа, смотря по ситуации и сообразуясь со здравым смыслом.

Page 18: Короткие запросы

Читают небольшую часть данных, выдают мало строк

  • Характерны для OLTP
Примечание

Note Важно получать ответ быстро

  • Характерные особенности плана
    • условия с высокой селективностью
    • индексы
    • соединения методом вложенного цикла

Комментарии преподавателя

Запросы можно (довольно условно) разделить на две группы, имеющие свои особенности и требующие разных подходов к оптимизации: «короткие» и «длинные».

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

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

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

Page 19: Короткие запросы

Сразу отключим параллельное выполнение. Для коротких запросов оно бесполезно, а параллельные планы сложнее читать.

=> SET max_parallel_workers_per_gather = 0;
SET

Рассмотрим запрос, который выводит посадочные талоны, выданные на рейсы, отправляющиеся в течение часового интервала:

=> EXPLAIN (analyze, timing off) SELECT bp.*
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE date_trunc('hour',f.scheduled_departure) = '2017-06-01 12:00:00';
                                                         QUERY PLAN
------------------------------------------------------------------------------------------
----------------------------------
 Hash Join  (cost=5860.43..164201.65 rows=39616 width=25) (actual rows=1799 loops=1)
   Hash Cond: (bp.flight_id = f.flight_id)
   ->  Seq Scan on boarding_passes bp  (cost=0.00..137535.88 rows=7925688 width=25)
(actual rows=7925812 loops=1)
   ->  Hash  (cost=5847.00..5847.00 rows=1074 width=4) (actual rows=51 loops=1)
         Buckets: 2048  Batches: 1  Memory Usage: 18kB
         ->  Seq Scan on flights f  (cost=0.00..5847.00 rows=1074 width=4) (actual
rows=51 loops=1)
               Filter: (date_trunc('hour'::text, scheduled_departure) = '2017-06-01
12:00:00+03'::timestamp with time zone)
               Rows Removed by Filter: 214816
 Planning Time: 0.585 ms
 Execution Time: 2977.441 ms
(10 rows)

Этот запрос можно отнести к разряду коротких: из двух больших таблиц требуется гораздо меньше процента данных. Однако в плане мы видим, что таблицы читаются полностью. Кроме того, наблюдается большая разница между прогнозируемой и фактической кардинальностью. Начнем оптимизацию с самого вложенного узла, Seq Scan по таблице рейсов flights. Из-за чего планировщик ошибается в оценке кардинальности? Дело в функции date_trunc: для нее нет вспомогательной функции планировщика, поэтому берется фиксированная оценка селективности 0,5%. В темах «Базовая статистика» и «Расширенная статистика» мы исправляли подобные ситуации с помощью индекса по выражению и расширенной статистики. Но в данном случае достаточно переписать условие так, чтобы слева от оператора находилось поле таблицы:

=> EXPLAIN (analyze, timing off) SELECT *
FROM flights
WHERE scheduled_departure >= '2017-06-01 12:00:00'
  AND scheduled_departure <  '2017-06-01 13:00:00';
                                                                                 QUERY
PLAN
------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------
 Index Scan using flights_flight_no_scheduled_departure_key on flights
(cost=0.42..5564.85 rows=23 width=63) (actual rows=51 loops=1)
   Index Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp with time
zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time zone))
 Planning Time: 1.523 ms
 Execution Time: 65.985 ms
(4 rows)

Теперь оценка больше похожа на правду, а запрос заодно стал использовать индекс. Хорошо ли это? В целом хорошо, но индекс создан по столбцам flight_no и scheduled_departure, а условие есть только на второй столбец. В теме «Методы доступа» мы выяснили, что в этом случае индекс будет сканироваться полностью, что нельзя считать эффективным способом доступа к данным. Создадим новый индекс:

=> CREATE INDEX ON flights(scheduled_departure);

Page 20: Page 20

CREATE INDEX

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

=> EXPLAIN (analyze, timing off) SELECT bp.*
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.scheduled_departure >= '2017-06-01 12:00:00'
  AND f.scheduled_departure <  '2017-06-01 13:00:00';
QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
----
 Nested Loop  (cost=10.30..13749.40 rows=848 width=25) (actual rows=1799 loops=1)
   ->  Bitmap Heap Scan on flights f  (cost=4.66..90.54 rows=23 width=4) (actual rows=51
loops=1)
         Recheck Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp with
time zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time zone))
         Heap Blocks: exact=50
         ->  Bitmap Index Scan on flights_scheduled_departure_idx  (cost=0.00..4.65
rows=23 width=0) (actual rows=51 loops=1)
               Index Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp
with time zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time
zone))
   ->  Bitmap Heap Scan on boarding_passes bp  (cost=5.64..592.30 rows=156 width=25)
(actual rows=35 loops=51)
         Recheck Cond: (f.flight_id = flight_id)
         Heap Blocks: exact=49
         ->  Bitmap Index Scan on boarding_passes_flight_id_seat_no_key  (cost=0.00..5.60
rows=156 width=0) (actual rows=35 loops=51)
               Index Cond: (flight_id = f.flight_id)
 Planning Time: 0.505 ms
 Execution Time: 39.208 ms
(13 rows)

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

=> EXPLAIN (analyze, buffers, costs off, timing off) SELECT bp.*
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.scheduled_departure >= '2017-06-01 12:00:00'
  AND f.scheduled_departure <  '2017-06-01 13:00:00';
QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
----
 Nested Loop (actual rows=1799 loops=1)
   Buffers: shared hit=259
   ->  Bitmap Heap Scan on flights f (actual rows=51 loops=1)
         Recheck Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp with
time zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time zone))
         Heap Blocks: exact=50
         Buffers: shared hit=53
         ->  Bitmap Index Scan on flights_scheduled_departure_idx (actual rows=51 loops=1)
               Index Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp
with time zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time
zone))
               Buffers: shared hit=3
   ->  Bitmap Heap Scan on boarding_passes bp (actual rows=35 loops=51)
         Recheck Cond: (f.flight_id = flight_id)
         Heap Blocks: exact=49
         Buffers: shared hit=206
         ->  Bitmap Index Scan on boarding_passes_flight_id_seat_no_key (actual rows=35
loops=51)
               Index Cond: (flight_id = f.flight_id)
               Buffers: shared hit=157
 Planning:
   Buffers: shared hit=16
 Planning Time: 0.288 ms
 Execution Time: 1.364 ms
(20 rows)

Page 21: Page 21

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

=> DROP INDEX flights_scheduled_departure_idx;
DROP INDEX
=> CREATE INDEX ON flights(scheduled_departure, flight_id);
CREATE INDEX
=> EXPLAIN (analyze, buffers, costs off, timing off) SELECT bp.*
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.scheduled_departure >= '2017-06-01 12:00:00'
  AND f.scheduled_departure <  '2017-06-01 13:00:00';
                                                                                    QUERY
PLAN
------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------
 Nested Loop (actual rows=1799 loops=1)
   Buffers: shared hit=207 read=3
   ->  Index Only Scan using flights_scheduled_departure_flight_id_idx on flights f
(actual rows=51 loops=1)
         Index Cond: ((scheduled_departure >= '2017-06-01 12:00:00+03'::timestamp with
time zone) AND (scheduled_departure < '2017-06-01 13:00:00+03'::timestamp with time zone))
         Heap Fetches: 0
         Buffers: shared hit=1 read=3
   ->  Bitmap Heap Scan on boarding_passes bp (actual rows=35 loops=51)
         Recheck Cond: (f.flight_id = flight_id)
         Heap Blocks: exact=49
         Buffers: shared hit=206
         ->  Bitmap Index Scan on boarding_passes_flight_id_seat_no_key (actual rows=35
loops=51)
               Index Cond: (flight_id = f.flight_id)
               Buffers: shared hit=157
 Planning:
   Buffers: shared hit=40 read=1
 Planning Time: 0.353 ms
 Execution Time: 2.747 ms
(17 rows)

Мы выиграли еще около 20% чтений. Это может быть важно для запросов, которые выполняются часто, но вряд ли это наш случай. Скорее всего, включение в индекс всех полей таблицы boarding_passes будет избыточным — накладные расходы перевесят выигрыш в производительности. Остановимся на достигнутом.

Page 22: Длинные запросы

Читают большие таблицы целиком

  • Характерны для OLAP
Примечание

Note Важно не читать одни и те же данные повторно

  • Характерные особенности плана
    • условия с низкой селективностью
    • полное сканирование
    • соединение хешированием
    • агрегация
    • параллельное выполнение

Комментарии преподавателя

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

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

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

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

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

Page 23: Длинные запросы

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

=> \timing on
Timing is on.
=> SELECT count(*)
FROM flights f
  JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  JOIN boarding_passes bp ON bp.flight_id = tf.flight_id AND bp.ticket_no = tf.ticket_no
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';
  count
---------
 6940272
(1 row)
Time: 169677,982 ms (02:49,678)
=> \timing off
Timing is off.

Короткий это запрос или длинный? Узнаем селективность условий:

=> SELECT count(*) FROM flights f;
 count
--------
 214867
(1 row)
=> SELECT count(*) FROM flights f
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';
 count
--------
 173846
(1 row)

Условия отбирают примерно 80% строк, все таблицы в запросе большие. Очевидно, это длинный запрос. Проверим его план:

=> EXPLAIN
SELECT count(*)
FROM flights f
  JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  JOIN boarding_passes bp ON bp.flight_id = tf.flight_id AND bp.ticket_no = tf.ticket_no
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';

Page 24: Page 24

                                                        QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------
 Aggregate  (cost=12209.21..12209.22 rows=1 width=8)
   ->  Nested Loop  (cost=6.20..12208.75 rows=184 width=0)
         Join Filter: (f.flight_id = tf.flight_id)
         ->  Nested Loop  (cost=5.64..12095.86 rows=184 width=22)
               ->  Seq Scan on flights f  (cost=0.00..9070.01 rows=5 width=4)
                     Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
               ->  Bitmap Heap Scan on boarding_passes bp  (cost=5.64..603.61 rows=156
width=18)
                     Recheck Cond: (f.flight_id = flight_id)
                     ->  Bitmap Index Scan on boarding_passes_flight_id_seat_no_key
(cost=0.00..5.60 rows=156 width=0)
                           Index Cond: (flight_id = f.flight_id)
         ->  Index Only Scan using ticket_flights_pkey on ticket_flights tf
(cost=0.56..0.60 rows=1 width=18)
               Index Cond: ((ticket_no = bp.ticket_no) AND (flight_id = bp.flight_id))
(12 rows)

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

=> SET enable_nestloop = off;
SET
=> EXPLAIN (analyze, buffers, timing off, costs off)
SELECT count(*)
FROM flights f
  JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  JOIN boarding_passes bp ON bp.flight_id = tf.flight_id AND bp.ticket_no = tf.ticket_no
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';

Page 25: Page 25

                                                              QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------------------
 Aggregate (actual rows=1 loops=1)
   Buffers: shared hit=7068 read=123768, temp read=102066 written=102066
   ->  Hash Join (actual rows=6940272 loops=1)
         Hash Cond: ((tf.flight_id = f.flight_id) AND (tf.ticket_no = bp.ticket_no))
         Buffers: shared hit=7068 read=123768, temp read=102066 written=102066
         ->  Seq Scan on ticket_flights tf (actual rows=8391852 loops=1)
               Buffers: shared read=69933
         ->  Hash (actual rows=6940272 loops=1)
               Buckets: 262144 (originally 1024)  Batches: 64 (originally 1)  Memory
Usage: 7780kB
               Buffers: shared hit=7068 read=53835, temp written=34991
               ->  Hash Join (actual rows=6940272 loops=1)
                     Hash Cond: (bp.flight_id = f.flight_id)
                     Buffers: shared hit=7068 read=53835
                     ->  Seq Scan on boarding_passes bp (actual rows=7925812 loops=1)
                           Buffers: shared hit=4444 read=53835
                     ->  Hash (actual rows=173846 loops=1)
                           Buckets: 262144 (originally 1024)  Batches: 1 (originally 1)
Memory Usage: 8160kB
                           Buffers: shared hit=2624
                           ->  Seq Scan on flights f (actual rows=173846 loops=1)
                                 Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
                                 Rows Removed by Filter: 41021
                                 Buffers: shared hit=2624
 Planning:
   Buffers: shared hit=52
 Planning Time: 0.508 ms
 Execution Time: 27471.062 ms
(26 rows)

Обращения к таблицам автоматически сменились на последовательное чтение. Запрос ускорился, ему потребовалось прочитать примерно 130 тысяч блоков. Мы не смотрели план выполнения первой версии с параметром buffers, но она — проверьте! — читает в 200 с лишним раз больше данных.

=> RESET enable_nestloop;
RESET

Нам осталось более аккуратно объяснить планировщику, какой план стоит использовать. Самый мягкий способ — предоставить более точную статистику. В данном случае подойдет статистика по выражению, которую мы рассматривали в теме «Расширенная статистика»:

=> CREATE STATISTICS ON (actual_departure - scheduled_departure) FROM flights;
CREATE STATISTICS
=> CREATE STATISTICS ON (actual_arrival - scheduled_arrival) FROM flights;
CREATE STATISTICS
=> ANALYZE flights;
ANALYZE

Проверим план:

=> EXPLAIN
SELECT count(*)
FROM flights f
  JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  JOIN boarding_passes bp ON bp.flight_id = tf.flight_id AND bp.ticket_no = tf.ticket_no
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';

Page 26: Page 26

                                                        QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------
 Aggregate  (cost=759127.42..759127.43 rows=1 width=8)
   ->  Hash Join  (cost=11627.55..744760.69 rows=5746693 width=0)
         Hash Cond: (tf.flight_id = f.flight_id)
         ->  Merge Join  (cost=1.12..649799.91 rows=7925688 width=8)
               Merge Cond: ((tf.ticket_no = bp.ticket_no) AND (tf.flight_id =
bp.flight_id))
               ->  Index Only Scan using ticket_flights_pkey on ticket_flights tf
(cost=0.56..292220.18 rows=8391708 width=18)
               ->  Index Only Scan using boarding_passes_pkey on boarding_passes bp
(cost=0.56..275989.88 rows=7925688 width=18)
         ->  Hash  (cost=9070.01..9070.01 rows=155794 width=4)
               ->  Seq Scan on flights f  (cost=0.00..9070.01 rows=155794 width=4)
                     Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
(10 rows)

Оценки исправились, теперь во всех узлах они довольно точно соответствуют реальным цифрам. Однако планировщик выбрал не тот план, что мы видели: он перестал использовать соединение вложенным циклом, но оставил индексный доступ и применил соединение слиянием. Если бы мы выполнили запрос, то увидели бы, что время еще сократилось, хотя и было прочитано более двух миллионов страниц. Считать ли такой результат хорошим? Если мы уверены, что размера кеша хватит, чтобы в нем находились данные наших крупных таблиц — неплохо, ведь запрос ускоряется. Но мы уже говорили о том, что время — ненадежный показатель, поскольку в реальной системе за кеш будут конкурировать данные многих таблиц, и реальное обращение к страницам может оказаться медленнее ожидаемого. Это может послужить поводом вернуться к настройкам сервера БД с помощью параметров random_page_cost и effective_cache_size. Однако зададимся вопросом: не читаем ли мы лишние данные? Знание предметной области позволяет нам прийти к выводу, что таблица ticket_flights вовсе не нужна в запросе. Таблицу flights можно соединить непосредственно с boarding_passes — внешние ключи проверяют корректность данных, но не требуются для соединения. Добавление ticket_flights никак не ограничивает данные (не может быть ситуации, при которой посадочный талон есть, а перелет отсутствует). Вообще, переформулирование запроса — самый разнообразный и вариативный способ влияния на производительность. Некоторые эквивалентные преобразования планировщик умеет выполнять сам, но мы можем: переписать условия так, чтобы они могли (или наоборот, не могли) использовать индекс; заменить коррелированные подзапросы (которые, по сути, являются вложенным циклом) соединениями; устранить лишние сканирования (как в этом примере, или, скажем, за счет применения оконных функций); использовать недоступные планировщику трансформации, такие, как преобразование условий OR в UNION; и т. п. Итак, упрощаем запрос:

=> EXPLAIN (analyze, buffers, timing off, costs off)
SELECT count(*)
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';

Page 27: Page 27

                                                        QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------
 Aggregate (actual rows=1 loops=1)
   Buffers: shared hit=7100 read=53803, temp read=11797 written=11797
   ->  Hash Join (actual rows=6940272 loops=1)
         Hash Cond: (bp.flight_id = f.flight_id)
         Buffers: shared hit=7100 read=53803, temp read=11797 written=11797
         ->  Seq Scan on boarding_passes bp (actual rows=7925812 loops=1)
               Buffers: shared hit=4476 read=53803
         ->  Hash (actual rows=173846 loops=1)
               Buckets: 262144  Batches: 2  Memory Usage: 5118kB
               Buffers: shared hit=2624, temp written=253
               ->  Seq Scan on flights f (actual rows=173846 loops=1)
                     Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
                     Rows Removed by Filter: 41021
                     Buffers: shared hit=2624
 Planning:
   Buffers: shared hit=16
 Planning Time: 0.356 ms
 Execution Time: 6056.663 ms
(18 rows)

Потребовалось прочитать всего 61 тысячу страниц. Проверим, сколько всего страниц в таблицах:

=> SELECT sum(relpages) FROM pg_class
WHERE relname IN ('flights','boarding_passes');
  sum
-------
 60903
(1 row)

Теперь мы уверены, что не читаем лишних данных. Можно ли еще что-то улучшить? План запроса говорит о том, что выполнялось двухпроходное соединение хешированием. Можно увеличить значение параметра work_mem, чтобы хеш-соединению было достаточно одного прохода без временных файлов.

=> SET work_mem = '8MB'; -- имеет смысл увеличить для всего сервера
SET
=> EXPLAIN (analyze, timing off, costs off)
SELECT count(*)
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';

Page 28: Page 28

                                                        QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------
 Aggregate (actual rows=1 loops=1)
   ->  Hash Join (actual rows=6940272 loops=1)
         Hash Cond: (bp.flight_id = f.flight_id)
         ->  Seq Scan on boarding_passes bp (actual rows=7925812 loops=1)
         ->  Hash (actual rows=173846 loops=1)
               Buckets: 262144  Batches: 1  Memory Usage: 8160kB
               ->  Seq Scan on flights f (actual rows=173846 loops=1)
                     Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
                     Rows Removed by Filter: 41021
 Planning Time: 0.337 ms
 Execution Time: 4239.371 ms
(11 rows)

В начале темы мы отключали параллельные планы. Однако для длинных запросов параллельные планы могут иметь смысл. Конечно, при условии наличия свободных ядер, как рассматривалось в теме «Параллельный доступ». Посмотрим:

=> RESET max_parallel_workers_per_gather;
RESET
=> EXPLAIN (analyze, timing off, costs off)
SELECT count(*)
FROM flights f
  JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.actual_departure - f.scheduled_departure BETWEEN interval '1 min' AND interval '4 hours'
  AND f.actual_arrival - f.scheduled_arrival BETWEEN interval '1 min' AND interval '4 hours';
                                                              QUERY PLAN
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
---------------------------------------------
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Gather (actual rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate (actual rows=1 loops=3)
               ->  Parallel Hash Join (actual rows=2313424 loops=3)
                     Hash Cond: (bp.flight_id = f.flight_id)
                     ->  Parallel Seq Scan on boarding_passes bp (actual rows=2641937
loops=3)
                     ->  Parallel Hash (actual rows=57949 loops=3)
                           Buckets: 262144  Batches: 1  Memory Usage: 8928kB
                           ->  Parallel Seq Scan on flights f (actual rows=57949 loops=3)
                                 Filter: (((actual_departure - scheduled_departure) >=
'00:01:00'::interval) AND ((actual_departure - scheduled_departure) <=
'04:00:00'::interval) AND ((actual_arrival - scheduled_arrival) >= '00:01:00'::interval)
AND ((actual_arrival - scheduled_arrival) <= '04:00:00'::interval))
                                 Rows Removed by Filter: 13674
 Planning Time: 1.249 ms
 Execution Time: 5104.969 ms
(15 rows)

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

Page 29: Подсказки оптимизатору

Отсутствуют в явном виде

  • хотя средства влияния имеются: конфигурационные параметры,
  • материализация CTE и другие
  • Сторонние расширения
    • pg_hint_plan

Комментарии преподавателя

Еще один (традиционный для других СУБД) способ влияния — подсказки оптимизатору — отсутствует в PostgreSQL. Это принципиальное решение сообщества: https://wiki.postgresql.org/wiki/OptimizerHintsDiscussion​.

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

Кроме того, есть специальные расширения, например https://postgrespro.ru/docs/enterprise/16/pg-hint-plan (автор — Киотаро Хоригучи). Но нельзя забывать, что использование подсказок, сильно ограничивающих свободу планировщика, может навредить в будущем, когда распределение данных изменится.

Page 30: Итоги

Настройка выполняется на разных уровнях системы

  • Доступен широкий спектр методов влияния на план выполнения запросов
  • Разные типы запросов требуют разных подходов
  • Ничто не заменит голову и здравый смысл

Page 31: Практика

  1. Оптимизируйте запрос, выводящий контактную информацию пассажиров, купивших билеты бизнес-класса, рейсы которых были задержаны более чем на 5 часов: SELECT t.* FROM tickets t JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no JOIN flights f ON f.flight_id = tf.flight_id WHERE tf.fare_conditions = ‘Business’ AND f.actual_departure > f.scheduled_departure + interval ‘5 hour’; 2. Оптимизируйте запрос, вычисляющий среднюю стоимость билетов на перелеты, выполняемые различными типами самолетов. Начните с варианта, предложенного в комментарии.
SELECT a.aircraft_code, (
  SELECT round(avg(tf.amount))
  FROM flights f
    JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  WHERE f.aircraft_code = a.aircraft_code
)
FROM aircrafts a

Page 32: 1. Оптимизация короткого запроса

Отключим параллельное выполнение.

=> SET max_parallel_workers_per_gather = 0;
SET
=> EXPLAIN (analyze, timing off)
SELECT t.*
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE tf.fare_conditions = 'Business'
  AND f.actual_departure > f.scheduled_departure + interval '5 hour';
                                                           QUERY PLAN
------------------------------------------------------------------------------------------
---------------------------------------
 Merge Join  (cost=213958.71..365358.47 rows=280477 width=104) (actual rows=2 loops=1)
   Merge Cond: (t.ticket_no = tf.ticket_no)
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..139117.71 rows=2949649
width=104) (actual rows=1336684 loops=1)
   ->  Materialize  (cost=213958.28..215360.67 rows=280477 width=14) (actual rows=2
loops=1)
         ->  Sort  (cost=213958.28..214659.48 rows=280477 width=14) (actual rows=2
loops=1)
               Sort Key: tf.ticket_no
               Sort Method: quicksort  Memory: 25kB
               ->  Hash Join  (cost=6742.28..183783.59 rows=280477 width=14) (actual
rows=2 loops=1)
                     Hash Cond: (tf.flight_id = f.flight_id)
                     ->  Seq Scan on ticket_flights tf  (cost=0.00..174832.50 rows=841434
width=18) (actual rows=859656 loops=1)
                           Filter: ((fare_conditions)::text = 'Business'::text)
                           Rows Removed by Filter: 7532196
                     ->  Hash  (cost=5847.00..5847.00 rows=71622 width=4) (actual rows=1
loops=1)
                           Buckets: 131072  Batches: 1  Memory Usage: 1025kB
                           ->  Seq Scan on flights f  (cost=0.00..5847.00 rows=71622
width=4) (actual rows=1 loops=1)
                                 Filter: (actual_departure > (scheduled_departure +
'05:00:00'::interval))
                                 Rows Removed by Filter: 214866
 Planning Time: 34.196 ms
 Execution Time: 6402.087 ms
(19 rows)

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

=> CREATE INDEX ON flights ((actual_departure - scheduled_departure));
CREATE INDEX
=> ANALYZE flights;
ANALYZE
=> EXPLAIN (analyze, timing off)
SELECT t.*
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE tf.fare_conditions = 'Business'
  AND f.actual_departure - f.scheduled_departure > interval '5 hour';

Page 33: Page 33

                                                         QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------------
 Nested Loop  (cost=12.81..177059.58 rows=8 width=104) (actual rows=2 loops=1)
   ->  Hash Join  (cost=12.38..177053.69 rows=8 width=14) (actual rows=2 loops=1)
         Hash Cond: (tf.flight_id = f.flight_id)
         ->  Seq Scan on ticket_flights tf  (cost=0.00..174832.50 rows=841434 width=18)
(actual rows=859656 loops=1)
               Filter: ((fare_conditions)::text = 'Business'::text)
               Rows Removed by Filter: 7532196
         ->  Hash  (cost=12.35..12.35 rows=2 width=4) (actual rows=1 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 9kB
               ->  Index Scan using flights_expr_idx on flights f  (cost=0.42..12.35
rows=2 width=4) (actual rows=1 loops=1)
                     Index Cond: ((actual_departure - scheduled_departure) >
'05:00:00'::interval)
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..0.74 rows=1 width=104)
(actual rows=1 loops=2)
         Index Cond: (ticket_no = tf.ticket_no)
 Planning Time: 41.565 ms
 Execution Time: 3048.739 ms
(14 rows)

Теперь займемся таблицей ticket_flights, которая тоже сканируется полностью, хотя из нее читается незначительная часть строк. Помог бы индекс по классам обслуживания fare_conditions, но лучше создать индекс по столбцу flight_id, что позволит эффективно выполнять соединение вложенным циклом с flights:

=> CREATE INDEX ON ticket_flights(flight_id);
CREATE INDEX
=> EXPLAIN (analyze, timing off)
SELECT t.*
FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
WHERE tf.fare_conditions = 'Business'
  AND f.actual_departure - f.scheduled_departure > interval '5 hour';
                                                                  QUERY PLAN
------------------------------------------------------------------------------------------
----------------------------------------------------
 Nested Loop  (cost=1.28..833.55 rows=8 width=104) (actual rows=2 loops=1)
   ->  Nested Loop  (cost=0.85..827.66 rows=8 width=14) (actual rows=2 loops=1)
         ->  Index Scan using flights_expr_idx on flights f  (cost=0.42..12.35 rows=2
width=4) (actual rows=1 loops=1)
               Index Cond: ((actual_departure - scheduled_departure) >
'05:00:00'::interval)
         ->  Index Scan using ticket_flights_flight_id_idx on ticket_flights tf
(cost=0.43..407.54 rows=11 width=18) (actual rows=2 loops=1)
               Index Cond: (flight_id = f.flight_id)
               Filter: ((fare_conditions)::text = 'Business'::text)
               Rows Removed by Filter: 10
   ->  Index Scan using tickets_pkey on tickets t  (cost=0.43..0.74 rows=1 width=104)
(actual rows=1 loops=2)
         Index Cond: (ticket_no = tf.ticket_no)
 Planning Time: 2.229 ms
 Execution Time: 24.514 ms
(12 rows)

Время выполнения уменьшилось до миллисекунд.

2. Оптимизация длинного запроса

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

Page 34: Page 34

=> EXPLAIN (analyze, buffers, timing off)
SELECT a.aircraft_code, (
  SELECT round(avg(tf.amount))
  FROM flights f
    JOIN ticket_flights tf ON tf.flight_id = f.flight_id
  WHERE f.aircraft_code = a.aircraft_code
)
FROM aircrafts a;
                                                          QUERY PLAN
------------------------------------------------------------------------------------------
-------------------------------------
 Seq Scan on aircrafts_data ml  (cost=0.00..1657338.13 rows=9 width=36) (actual rows=9
loops=1)
   Buffers: shared hit=24480 read=558601
   SubPlan 1
     ->  Aggregate  (cost=184148.55..184148.56 rows=1 width=32) (actual rows=1 loops=9)
           Buffers: shared hit=24480 read=558600
           ->  Hash Join  (cost=5645.56..181526.13 rows=1048967 width=6) (actual
rows=932428 loops=9)
                 Hash Cond: (tf.flight_id = f.flight_id)
                 Buffers: shared hit=24480 read=558600
                 ->  Seq Scan on ticket_flights tf  (cost=0.00..153851.52 rows=8391852
width=10) (actual rows=8391852 loops=8)
                       Buffers: shared hit=864 read=558600
                 ->  Hash  (cost=5309.84..5309.84 rows=26858 width=4) (actual rows=23874
loops=9)
                       Buckets: 65536 (originally 32768)  Batches: 1 (originally 1)
Memory Usage: 2629kB
                       Buffers: shared hit=23616
                       ->  Seq Scan on flights f  (cost=0.00..5309.84 rows=26858 width=4)
(actual rows=23874 loops=9)
                             Filter: (aircraft_code = ml.aircraft_code)
                             Rows Removed by Filter: 190993
                             Buffers: shared hit=23616
 Planning:
   Buffers: shared hit=58 read=9
 Planning Time: 5.426 ms
 Execution Time: 41879.712 ms
(21 rows)

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

=> SELECT sum(relpages) FROM pg_class
WHERE relname IN ('flights','ticket_flights','aircrafts_ml');
  sum
-------
 72557
(1 row)

Причина в коррелированном подзапросе, который выполняется для каждой из девяти моделей самолетов, образуя неявный вложенный цикл. Раскроем подзапрос, добавив группировку. Может показаться заманчивым избавиться от таблицы самолетов aircrafts, ведь код самолета можно получить непосредственно из таблицы рейсов flights. Однако в этом случае из результатов пропадет модель, не совершившая ни одного рейса. Поэтому необходимо левое соединение. Кроме того, сразу вспомним, что в демонстрации мы увеличивали work_mem, чтобы избежать двухпроходного соединения.

=> SET work_mem = '8MB';
SET
=> EXPLAIN (analyze, buffers, timing off)
SELECT a.aircraft_code, round(avg(tf.amount))
FROM aircrafts a
  LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
  LEFT JOIN ticket_flights tf ON tf.flight_id = f.flight_id
GROUP BY a.aircraft_code;

Page 35: Page 35

                                                         QUERY PLAN
------------------------------------------------------------------------------------------
-----------------------------------
 HashAggregate  (cost=257701.41..257701.54 rows=9 width=36) (actual rows=9 loops=1)
   Group Key: ml.aircraft_code
   Batches: 1  Memory Usage: 24kB
   Buffers: shared hit=2765 read=69793
   ->  Hash Right Join  (cost=7459.71..215742.15 rows=8391852 width=10) (actual
rows=8456132 loops=1)
         Hash Cond: (f.aircraft_code = ml.aircraft_code)
         Buffers: shared hit=2765 read=69793
         ->  Hash Right Join  (cost=7458.51..183339.07 rows=8391852 width=10) (actual
rows=8456131 loops=1)
               Hash Cond: (tf.flight_id = f.flight_id)
               Buffers: shared hit=2764 read=69793
               ->  Seq Scan on ticket_flights tf  (cost=0.00..153851.52 rows=8391852
width=10) (actual rows=8391852 loops=1)
                     Buffers: shared hit=140 read=69793
               ->  Hash  (cost=4772.67..4772.67 rows=214867 width=8) (actual rows=214867
loops=1)
                     Buckets: 262144  Batches: 1  Memory Usage: 10442kB
                     Buffers: shared hit=2624
                     ->  Seq Scan on flights f  (cost=0.00..4772.67 rows=214867 width=8)
(actual rows=214867 loops=1)
                           Buffers: shared hit=2624
         ->  Hash  (cost=1.09..1.09 rows=9 width=4) (actual rows=9 loops=1)
               Buckets: 1024  Batches: 1  Memory Usage: 9kB
               Buffers: shared hit=1
               ->  Seq Scan on aircrafts_data ml  (cost=0.00..1.09 rows=9 width=4)
(actual rows=9 loops=1)
                     Buffers: shared hit=1
 Planning:
   Buffers: shared hit=26 read=2
 Planning Time: 17.266 ms
 Execution Time: 14892.030 ms
(26 rows)

Лишние чтения пропали, запрос ускорился.