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

Разработка серверной части приложений PostgreSQL. Расширенный курс

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

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

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

Базовый язык: Базовый язык: Русский. В проверенных открытых каталогах не найден текущий английский архив, поэтому базовой является русская версия.

О курсе

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

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

ЯзыкТекущий архивВерсияТемыСтраница курса
EnglishОткрытый архив не найден--HTTP 404
РусскийDEV2-handouts-16.zip1621HTTP 200

Содержание

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

  • dev2_new_features.pdf
  • dev2_student_guide.pdf

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

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

00 Занятие 00: Разработка серверной части приложений / Расширенный курс

Исходная страница в нативном Markdown: Разработка серверной части приложений / Расширенный курс
Примечание

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

Page 1: Разработка серверной части приложений / Расширенный курс

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

© Postgres Professional, 2017–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: Разработчикам”}

DEV2, 4 дня

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

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

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

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

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

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

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

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

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

https://postgrespro.ru/education/courses

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

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

  • DBA2, 4 дня

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

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

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

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

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

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

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

Page 4: О курсе DEV2

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

  • Предварительные знания
    • общие сведения об архитектуре PostgreSQL
    • уверенное владение SQL и PL/pgSQL
    • минимальные сведения о работе в Unix
  • Какие навыки будут получены
    • понимание внутренней организации сервера
    • полное использование возможностей, предоставляемых PostgreSQL
    • для реализации логики приложения
    • расширение возможностей СУБД для решения специальных задач

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

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

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

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

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

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

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

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

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

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

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

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

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

Page 7: День 1

Архитектура

    1. Изоляция
    1. Многоверсионность
    1. Очистка
    1. Журналирование
    1. Блокировки

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

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

Page 8: День 2

Source slide page 8: День 2{#source-figure-008 caption=“Source slide page 8: День 2”}

Учебное приложение

    1. Книжный магазин 2.0
  • Расширяемость
      1. Пул соединений
      1. Типы для больших значений
      1. Пользовательские типы данных
      1. Классы операторов

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

Начиная со второго дня занятий, каждая тема содержит два набора практических заданий: связанных с приложением «Книжный магазин 2.0» (они отмечены значком книги) и дополнительных. За время, которое отводится на задания (около 30 минут), выполнить всю практику невозможно. Используйте дополнительные задания для самостоятельной работы.

Page 9: День 3

Расширяемость (продолжение)

    1. Слабоструктурированные данные
    1. Фоновые процессы
    1. Асинхронная обработка
    1. Создание расширений
    1. Языки программирования

Page 10: День 4

Расширяемость (продолжение)

    1. Агрегатные и оконные функции 17. Обзор полнотекстового поиска Репликация
    1. Обзор физической репликации 19. Обзор логической репликации Внешние данные
      1. Обзор

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

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

01 Занятие 01: Архитектура / Изоляция

Исходная страница в нативном Markdown: Архитектура / Изоляция
Примечание

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

Page 1: Архитектура / Изоляция

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

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

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

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

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

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

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

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

Page 2: Темы

  • Транзакции и их свойства
  • Уровни изоляции в стандарте SQL
  • Особенности реализации PostgreSQL

Page 3: Транзакции и их свойства

Транзакция — последовательность операций,

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

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

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

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

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

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

Четвертое свойство ACID — долговечность — мы рассмотрим позже, в теме «Журналирование».

Page 4: Read Uncommitted

Должен предотвращать аномалии

  • потерянные изменения:
  • транзакция может перезаписать изменения других транзакций,
  • зафиксированных после ее начала
  • Не реализован в PostgreSQL
    • работает как Read Committed

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

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

Самый слабый уровень — Read Uncommitted, который (как следует из названия) позволяет увидеть даже незафиксированные данные.

Согласно стандарту на этом уровне должна предотвращаться единственная аномалия:

● Потерянные изменения (lost updates). Если после начала транзакции T1 другая транзакция T2 изменила и зафиксировала строки, транзакция T1 может перезаписать эти изменения.

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

Page 5: Read Committed

Должен предотвращать аномалии

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

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

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

● «Грязное» чтение (dirty read). Транзакция T1 может читать строки, измененные, но еще не зафиксированные, транзакцией T2. Отмена изменений (ROLLBACK) в T2 приведет к тому, что T1 прочитает данные, которых никогда не существовало.

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

В PostgreSQL (как и во многих других СУБД) именно этот уровень изоляции используется по умолчанию — как компромисс между строгостью изоляции и эффективностью.

Заметим, что в PostgreSQL на уровне Read Committed можно потерять изменения — такой пример будет показан в демонстрации.

Page 6: Управление уровнем изоляции

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

=> CREATE DATABASE arch_isolation;
CREATE DATABASE
=> \c arch_isolation
You are now connected to database "arch_isolation" as user "student".

Уровни изоляции посмотрим на примере с таблицей, представляющей состояние светофора:

=> CREATE TABLE lights(
  id integer GENERATED ALWAYS AS IDENTITY,
  lamp text,
  state text
);
CREATE TABLE

Это будет пешеходный светофор с двумя лампочками:

=> INSERT INTO lights(lamp,state) VALUES
    ('red', 'on'), ('green', 'off');
INSERT 0 2
=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | off
(2 rows)

Один способ установить уровень изоляции — команда SET TRANSACTION, выполненная в начале транзакции:

=> BEGIN;
BEGIN
=> SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET

Проверить текущий уровень можно, посмотрев значение параметра:

=> SHOW transaction_isolation;
 transaction_isolation
-----------------------
 read committed
(1 row)
=> COMMIT;
COMMIT

А можно указать уровень прямо в команде BEGIN:

=> BEGIN ISOLATION LEVEL READ COMMITTED;
BEGIN
=> COMMIT;
COMMIT

По умолчанию используется уровень Read Committed:

=> SHOW default_transaction_isolation;
 default_transaction_isolation
-------------------------------
 read committed
(1 row)

Page 7: Page 7

Так что если этот параметр не менялся, можно не указывать уровень явно.

=> BEGIN;
BEGIN
=> SHOW transaction_isolation;
 transaction_isolation
-----------------------
 read committed
(1 row)
=> COMMIT;
COMMIT

Read Committed и грязное чтение

Попробуем прочитать «грязные» данные. В первой транзакции гасим красный свет:

=> BEGIN;
BEGIN
=> UPDATE lights SET state = 'off' WHERE lamp = 'red';
UPDATE 1

Начнем второй сеанс.

student$ psql arch_isolation

В нем откроем еще одну транзакцию с тем же уровнем Read Committed.

=> BEGIN;
BEGIN
=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | off
(2 rows)

Вторая транзакция не видит незафиксированных изменений. Отменим изменение.

=> ROLLBACK;
ROLLBACK
=> ROLLBACK;
ROLLBACK

Read Committed и чтение зафиксированных изменений

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

=> BEGIN;
BEGIN
=> UPDATE lights SET state = 'off' WHERE lamp = 'red';
UPDATE 1
=> BEGIN;
BEGIN
=> SELECT * FROM lights ORDER BY id;

Page 8: Page 8

 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | off
(2 rows)

Пока не видно.

=> COMMIT;
COMMIT

А теперь?

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | off
  2 | green | off
(2 rows)
=> COMMIT;
COMMIT

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

=> SELECT *, pg_sleep(2) FROM lights ORDER BY id;
=> UPDATE lights SET state = 'on';
UPDATE 2
 id | lamp  | state | pg_sleep
----+-------+-------+----------
  1 | red   | off   |
  2 | green | off   |
(2 rows)

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

=> CREATE FUNCTION get_state(lamp text) RETURNS text
LANGUAGE sql VOLATILE
RETURN (SELECT l.state FROM lights l WHERE l.lamp = get_state.lamp);
CREATE FUNCTION

Повторим эксперимент, но теперь запрос будет использовать функцию:

=> SELECT *, get_state(lamp), pg_sleep(2) FROM lights ORDER BY id;
=> UPDATE lights SET state = 'off';
UPDATE 2
 id | lamp  | state | get_state | pg_sleep
----+-------+-------+-----------+----------
  1 | red   | on    | on        |
  2 | green | on    | off       |
(2 rows)

Правильный вариант здесь — объявить функцию с категорией изменчивости stable.

=> ALTER FUNCTION get_state STABLE;
ALTER FUNCTION

Page 9: Page 9

=> SELECT *, get_state(lamp), pg_sleep(2) FROM lights ORDER BY id;
=> UPDATE lights SET state = 'on';
UPDATE 2
 id | lamp  | state | get_state | pg_sleep
----+-------+-------+-----------+----------
  1 | red   | off   | off       |
  2 | green | off   | off       |
(2 rows)

Вывод: внимательно следите за категорией изменчивости функций на уровне изоляции Read Committed. Со значениями «по умолчанию» легко получить несогласованные данные.

Read Committed и потерянные изменения

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

=> BEGIN;
BEGIN
=> UPDATE lights SET state = 'off' WHERE lamp = 'red';
UPDATE 1

Вторая транзакция видит, что обе лампочки горят…

=> BEGIN;
BEGIN
=> SELECT * FROM lights;
 id | lamp  | state
----+-------+-------
  2 | green | on
  1 | red   | on
(2 rows)

… меняет состояние каждой лампочки…

=> UPDATE lights
SET state = CASE WHEN state = 'on' THEN 'off' ELSE 'on' END;

… но зависает, ожидая завершения первой.

=> COMMIT;
COMMIT
UPDATE 2

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

=> COMMIT;
COMMIT
=> SELECT * FROM lights;
 id | lamp  | state
----+-------+-------
  2 | green | off
  1 | red   | on
(2 rows)

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

Page 10: Page 10

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

  • В результате вторая транзакция смогла отработать успешно за счет того, что она видела несогласованные данные: часть — на один момент времени, часть — на другой. Но если изменение выполняется не в одной команде SQL, то обновление будет потеряно.
=> BEGIN;
BEGIN

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

=> UPDATE lights SET state = 'blink' WHERE lamp = 'red';
UPDATE 1

Во второй транзакции клиент видит, что красная лампочка горит…

=> BEGIN;
BEGIN
=> SELECT state AS old_state FROM lights WHERE lamp = 'red';
 old_state
-----------
 on
(1 row)

… и выключает ее:

=> -- привязка значения параметра
\bind 'off'
=> UPDATE lights SET state = $1 WHERE lamp = 'red';

Вторая транзакция ждет завершения первой.

=> COMMIT;
COMMIT
UPDATE 1
=> COMMIT;
COMMIT

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

=> SELECT * FROM lights WHERE lamp = 'red';
 id | lamp | state
----+------+-------
  1 | red  | off
(1 row)

В этом случае вторая транзакция записала свои изменения «поверх» изменений первой транзакции. На уровне Read Committed сервер не может это предотвратить, поскольку команда UPDATE фактически содержит предопределенную константу.

Page 11: Repeatable Read

Должен предотвращать аномалии

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

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

Согласно стандарту, в дополнение к потерянным изменениям и грязным чтениям уровень изоляции Repeatable Read должен предотвращать аномалию, которую мы видели, когда рассматривали уровень Read Committed:

● Неповторяющееся чтение (non-repeatable read). После того как транзакция T1 прочитала строку, транзакция T2 изменила или удалила эту строку и зафиксировала изменения (COMMIT). При повторном чтении этой же строки транзакция T1 видит, что строка изменена или удалена.

Реализация PostgreSQL более строгая, чем того требует стандарт, и предотвращает аномалию

● Фантомное чтение (phantom read). Транзакция T1 прочитала набор строк по некоторому условию. Затем транзакция T2 добавила строки, также удовлетворяющие этому условию. Если транзакция T1 повторит запрос, она получит другую выборку строк.

На самом деле существуют и другие аномалии (не отраженные в стандарте), которые допускаются в PostgreSQL на уровне Repeatable Read.

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

Page 12: Repeatable Read и неповторяющееся чтение

Убедимся в отсутствии аномалии неповторяющегося чтения.

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT * FROM lights WHERE lamp = 'red';
 id | lamp | state
----+------+-------
  1 | red  | off
(1 row)
=> UPDATE lights SET state = 'on' WHERE lamp = 'red' RETURNING *;
 id | lamp | state
----+------+-------
  1 | red  | on
(1 row)
UPDATE 1

Какое значение получит теперь первая транзакция?

=> SELECT * FROM lights WHERE lamp = 'red';
 id | lamp | state
----+------+-------
  1 | red  | off
(1 row)

Повторное чтение измененной строки возвращает первоначальное значение.

=> COMMIT;
COMMIT

Repeatable Read и фантомное чтение

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

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT * FROM lights WHERE state = 'off';
 id | lamp  | state
----+-------+-------
  2 | green | off
(1 row)
=> INSERT INTO lights(lamp,state) VALUES ('yellow', 'off')
    RETURNING *;
 id |  lamp  | state
----+--------+-------
  3 | yellow | off
(1 row)
INSERT 0 1
=> SELECT * FROM lights WHERE state = 'off';
 id | lamp  | state
----+-------+-------
  2 | green | off
(1 row)

Действительно, транзакция не видит добавленной записи, удовлетворяющей первоначальному условию. Уровень изоляции Repeatable Read в PostgreSQL более строгий, чем того требует стандарт SQL.

Page 13: Repeatable Read и потерянные изменения

=> COMMIT;
COMMIT

Уберем желтую лампочку.

=> DELETE FROM lights WHERE lamp = 'yellow';
DELETE 1

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

=> SELECT * FROM lights WHERE lamp = 'red';
 id | lamp | state
----+------+-------
  1 | red  | on
(1 row)
=> BEGIN;
BEGIN
=> UPDATE lights SET state = 'blink' WHERE lamp = 'red';
UPDATE 1
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN

Клиент запрашивает состояние красной лампочки…

=> SELECT state AS current_state FROM lights WHERE lamp = 'red';
 current_state
---------------
 on
(1 row)

…а затем меняет его на сервере:

=> -- привязка значения параметра
\bind 'off'
=> UPDATE lights SET state = $1 WHERE lamp = 'red';

Вторая транзакция ждет завершения первой.

=> COMMIT;
COMMIT
ERROR:  could not serialize access due to concurrent update

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

=> ROLLBACK;
ROLLBACK

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

Repeatable Read и другие аномалии

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

UPDATE lights SET state = 'on' WHERE state != 'on';

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

UPDATE lights SET state = 'off' WHERE state != 'off';

Page 14: Page 14

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

=> UPDATE lights SET state = 'on' WHERE lamp = 'red';
UPDATE 1

Начальное состояние:

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | off
(2 rows)
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> UPDATE lights SET state = 'on' WHERE state != 'on';
UPDATE 1

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

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | on
(2 rows)
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN

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

=> UPDATE lights SET state = 'off' WHERE state != 'off';
UPDATE 1
=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | off
  2 | green | off
(2 rows)

Теперь обе транзакции фиксируют свои изменения…

=> COMMIT;
COMMIT
=> COMMIT;
COMMIT

И оказывается, что…

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | off
  2 | green | on
(2 rows)

…одна лампочка включена, а другая — выключена. Это пример аномалии конкурентного доступа — несогласованная запись (write skew), — которая возможна, даже если нет грязного, неповторяющегося и фантомного чтений.

Page 15: Page 15

Page 16: Serializable

Должен предотвращать любые аномалии

  • результат конкурентного выполнения совпадает с результатом
  • выполнения транзакций в какой-либо последовательности
  • Реализация в PostgreSQL
    • для предотвращения аномалий транзакции могут обрываться
    • для правильной работы уровень Serializable должен использоваться
    • всеми транзакциями
    • уровень не работает на физических репликах

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

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

В PostgreSQL этот уровень реализован и работает, если все транзакции в системе используют уровень Serializable.

Следует учитывать, что реализация имеет определенные ограничения:

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

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

Page 17: Serializable

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

=> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
=> UPDATE lights SET state = 'on' WHERE state != 'on';
UPDATE 1

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

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | on
  2 | green | on
(2 rows)

Вторая транзакция не видит незафиксированных изменений: для нее красный свет еще выключен, и она выключает зеленый.

=> BEGIN ISOLATION LEVEL SERIALIZABLE;
BEGIN
=> UPDATE lights SET state = 'off' WHERE state != 'off';
UPDATE 1

Для второй транзакции обе лампочки выключены:

=> SELECT * FROM lights ORDER BY id;
 id | lamp  | state
----+-------+-------
  1 | red   | off
  2 | green | off
(2 rows)

Теперь обе транзакции фиксируют свои изменения…

=> COMMIT;
COMMIT
=> COMMIT;
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

И вторая транзакция получает ошибку. Действительно, при последовательном выполнении транзакций допустимо два результата: если сначала выполняется первая транзакция, а потом вторая, то все лампочки будут погашены; если сначала выполняется вторая транзакция, а потом первая, то все лампочки будут включены. «Промежуточный» вариант получить невозможно, поэтому выполнение одной из транзакций завершается ошибкой. Важный момент: чтобы уровень Serializable работал корректно, на этом уровне должны работать все транзакции. Если смешивать транзакции разных уровней, фактически Serializable будет вести себя, как Repeatable Read.

Page 18: Итоги: стандарт SQL

  • неповторя-
  • потерянные
  • обновления
  • грязное
  • фантомное
  • другие
  • аномалии
  • ющееся
  • чтение
  • чтение
  • чтение
  • Read Uncommitted ― да да да да
  • Read Committed ― ― да да да
  • Repeatable Read ― ― ― да да
  • Serializable ― ― ― ― ―

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

Подытожим информацию.

В стандарте SQL определены четыре уровня изоляции транзакций: Read Uncommitted, Read Committed, Repeatable Read, Serializable (по умолчанию).

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

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

Page 19: Итоги: PostgreSQL

  • неповторя-
  • потерянные
  • обновления
  • грязное
  • фантомное
  • другие
  • аномалии
  • ющееся
  • чтение
  • чтение
  • чтение
  • Read Uncommitted
  • Read Committed * ― да да да
  • Repeatable Read ― ― ― ― да
  • Serializable ― ― ― ― ―
  • * предотвращается не всегда

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

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

В PostgreSQL используется изоляция на основе снимков данных (Snapshot Isolation) в сочетании с многоверсионностью. Это определяет следующие свойства:

● Грязное чтение не допускается вообще, причем без ущерба для производительности.

● Уровень Read Committed используется по умолчанию и применяется в большинстве систем. Зачастую он требует ручной установки блокировок, чтобы обеспечить корректность.

● Уровень Repeatable Read не допускает не только неповторяемое чтение, но и фантомное чтение (хотя и не обеспечивает полную изоляцию). Этот уровень удобен для отчетов, поскольку позволяет нескольким запросам видеть согласованные данные, а только читающие транзакции никогда не обрываются.

● Уровень Serializable также полностью реализован, но имеет определенные ограничения.

https://postgrespro.ru/docs/postgresql/16/transaction-iso

Page 20: Практика

  1. Создайте пустую таблицу. Проверьте, что на уровне изоляции Read Committed не предотвращается аномалия фантомного чтения.
    1. Откройте транзакцию с уровнем изоляции Repeatable Read (и пока не выполняйте в ней никаких команд). В другом сеансе удалите строку из таблицы. Видна ли удаленная строка в открытой транзакции? Будет ли видна удаленная строка, если после открытия транзакции выполнить запрос к базе данных, не обращаясь к таблицам?
    1. Убедитесь, что команда DROP TABLE транзакционна.

Page 21: 1. Фантомное чтение на уровне Read Committed

Source slide page 21: 1. Фантомное чтение на уровне Read Committed{#source-figure-021 caption=“Source slide page 21: 1. Фантомное чтение на уровне Read Committed”}

=> CREATE DATABASE arch_isolation;
CREATE DATABASE
=> \c arch_isolation
You are now connected to database "arch_isolation" as user "student".

Пустая таблица:

=> CREATE TABLE t(id integer);
CREATE TABLE

Во втором сеансе начнем транзакцию Read Committed и выполним запрос.

=> \c arch_isolation
You are now connected to database "arch_isolation" as user "student".
=> BEGIN;
BEGIN
=> SELECT * FROM t;
 id
----
(0 rows)

Вставим новую строку в таблицу (изменения фиксируются).

=> INSERT INTO t VALUES (1);
INSERT 0 1
=> SELECT * FROM t;
 id
----
  1
(1 row)

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

=> COMMIT;
COMMIT

2. На какой момент времени видны данные?

Открываем транзакцию Repeatable Read:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN

Добавляем строку в таблицу (изменения фиксируются):

=> INSERT INTO t VALUES (2);
INSERT 0 1

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

=> SELECT * FROM t;
 id
----
  1
  2
(2 rows)

Page 22: Page 22

Почему видны обе строки? Дело в том, что транзакции Repeatable Read (и Serializable) видят данные на момент начала первой команды, а не на момент выполнения BEGIN. Если сейчас добавить в таблицу еще строк, результат повторной выборки уже не изменится.

=> INSERT INTO t VALUES (3);
INSERT 0 1
=> SELECT * FROM t;
 id
----
  1
  2
(2 rows)
=> COMMIT;
COMMIT

3. Транзакционность DDL

Откроем транзакцию и удалим таблицу t.

=> BEGIN;
BEGIN
=> DROP TABLE t;
DROP TABLE
=> SELECT * FROM t;
ERROR:  relation "t" does not exist
LINE 1: SELECT * FROM t;
                      ^

Таблица удалена. Теперь откатим транзакцию:

=> ROLLBACK;
ROLLBACK
=> SELECT * FROM t;
 id
----
  1
  2
  3
(3 rows)

Удаление отменено, таблица опять существует.

Page 23: Практика+

  1. Команда COMMIT в коде на PL/pgSQL начинает новую транзакцию. Каким будет ее уровень изоляции и есть ли способ его изменить? Исследуйте анонимные блоки и процедуры.

Page 24: 1. Уровень изоляции в PL/pgSQL-коде

=> DO $$
BEGIN
    COMMIT;
    RAISE NOTICE '%', current_setting('transaction_isolation');
END;
$$;
NOTICE:  read committed
DO

Уровень изоляции новой транзакции назначается в соответствии с параметром default_transaction_isolation:

=> SHOW default_transaction_isolation;
 default_transaction_isolation
-------------------------------
 read committed
(1 row)

Сменить уровень можно командой SET TRANSACTION:

=> DO $$
BEGIN
    COMMIT;
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    RAISE NOTICE '%', current_setting('transaction_isolation');
END;
$$;
NOTICE:  repeatable read
DO

В процедуре такой код тоже сработает, но задать уровень первоначальной транзакции не получится:

=> CREATE OR REPLACE PROCEDURE test() LANGUAGE plpgsql AS $$
BEGIN
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    RAISE NOTICE '%', current_setting('transaction_isolation');
END;
$$;
CREATE PROCEDURE
=> CALL test();
ERROR:  SET TRANSACTION ISOLATION LEVEL must be called before any query
CONTEXT:  SQL statement "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
PL/pgSQL function test() line 3 at SQL statement

Можно в начале блока добавить COMMIT и тем самым начать новую транзакцию:

...
BEGIN
    COMMIT;
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
...

Но более правильный вариант — задать параметр default_transaction_isolation:

=> CREATE OR REPLACE PROCEDURE test() LANGUAGE plpgsql AS $$
BEGIN
    RAISE NOTICE '%', current_setting('transaction_isolation');
END;
$$;
CREATE PROCEDURE
=> SET default_transaction_isolation = 'serializable';
SET
=> CALL test();
NOTICE:  serializable
CALL

Page 25: Page 25

02 Занятие 02: Архитектура / Многоверсионность

Исходная страница в нативном Markdown: Архитектура / Многоверсионность
Примечание

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

Page 1: Архитектура / Многоверсионность

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

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

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

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

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

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

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

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

Page 2: Темы

  • Страницы
  • Версии строк
  • Снимки данных

Page 3: Страницы

Структура страниц

  • Формат данных

Page 4: Страницы

Source slide page 4: Страницы{#source-figure-004 caption=“Source slide page 4: Страницы”}

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

Размер страницы составляет 8 Кбайт. Это значение можно увеличить (вплоть до 32 Кбайт), но только при сборке. И таблицы, и индексы, и большинство других объектов, которые в PostgreSQL обозначаются термином relation, используют одинаковую структуру страниц, чтобы пользоваться общим буферным кешем. В начале страницы идет заголовок (24 байта), содержащий общие сведения о странице и размер ее областей: указателей, свободного пространства, версий строк и специальной области.

Версии строк содержат те самые данные, которые мы храним в таблицах и других объектах БД, плюс заголовок. «Версия строки» по-английски называется tuple; иногда мы будем говорить просто «строка».

Указатели имеют фиксированный размер (4 байта) и составляют массив, позиция в котором определяет идентификатор строки (tuple id, tid). Указатели ссылаются на версии строк (tuple), расположенные в конце блока. Такая косвенная адресация удобна тем, что во-первых, позволяет найти нужную строку, не перебирая все содержимое блока (строки имеют разную длину), а во-вторых, позволяет перемещать строку внутри блока, не ломая ссылки из индексов.

Между указателями и версиями строк находится свободное место.

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

https://postgrespro.ru/docs/postgresql/16/storage-page-layout

Page 5: Формат данных

Source slide page 5: Формат данных{#source-figure-005 caption=“Source slide page 5: Формат данных”}

Страницы читаются в оперативную память «как есть»

  • данные не переносимы между разными платформами
  • между полями данных возможны пропуски из-за выравнивания

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

Формат данных на диске полностью совпадает с представлением данных в оперативной памяти. Страница читается в буферный кеш «как есть», без преобразований.

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

Кроме того, многие архитектуры предусматривают выравнивание данных по границам машинных слов. Например, на 32-битной системе x86 целые числа (тип integer, занимает 4 байта) будут выровнены по границе 4-байтных слов, как и числа с плавающей точкой двойной точности (тип double precision, 8 байт). А в 64-битной системе значения double precision будут выровнены по границе 8-байтных слов.

Из-за этого размер табличной строки зависит от порядка расположения полей. Обычно этот эффект не сильно заметен, но в некоторых случаях он может привести к существенному увеличению размера. Например, если располагать поля типов char(1) и integer вперемешку, между ними, как правило, будет пропадать 3 байта.

https://pgconf.ru/rmedia/2016/05/13/tuple-internals-ru.pdf

Page 6: Подготовка

=> CREATE DATABASE arch_mvcc;
CREATE DATABASE
=> \c arch_mvcc
You are now connected to database "arch_mvcc" as user "student".

Создадим таблицу и индекс по одному из полей.

=> CREATE TABLE t(
  id integer,
  s text
);
CREATE TABLE
=> CREATE INDEX t_s on t(s);
CREATE INDEX
=> INSERT INTO t(id, s) VALUES (42, 'FOO');
INSERT 0 1

Структура страницы

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

=> CREATE EXTENSION pageinspect;
CREATE EXTENSION

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

=> SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('t',0));
 lower | upper | special | pagesize
-------+-------+---------+----------
    28 |  8160 |    8192 |     8192
(1 row)

Области занимают следующие диапазоны адресов: 0 — начало заголовка страницы и указатели на версии строк, lower — начало свободного места, upper — начало данных (версий строк), special — начало спец. данных (только для индексов), pagesize — конец страницы.

Page 7: Версии строк

Структура версий строк

  • Как работают операции над данными
  • HOT-обновления

Page 8: Вставка

Source slide page 8: Вставка{#source-figure-008 caption=“Source slide page 8: Вставка”}

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

Рассмотрим, как устроены версии строк (tuples) и как выполняются операции со строками на низком уровне. Начнем со вставки.

В нашем примере — таблица с двумя столбцами (число и текст). При вставке строки в табличной странице (heap page) появится указатель с номером 1, ссылающийся на первую и единственную версию строки. Каждый такой указатель помимо ссылки содержит длину версии строки и несколько бит, определяющих ее статус.

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

● xmin и xmax — поля, определяющие видимость данной версии строки в терминах начального и конечного номеров транзакций;

● карту неопределенных значений (в ней отмечены поля со значением NULL);

● ряд признаков, показывающих, например, статус транзакций xmin и xmax, если он уже известен (зафиксированы или оборваны).

В нашей версии строки поле xmin заполнено номером текущей транзакции (100). Поскольку изменения еще не фиксировались и транзакция активна, то в журнале статуса транзакций (CLOG) соответствующая запись заполнена нулями. CLOG можно представить себе как массив, в котором для каждой транзакции (начиная с некоторой) отводится ровно два бита.

Поле xmax заполнено фиктивным номером 0. Транзакции не будут обращать внимание на этот номер, поскольку установлен признак, что эта транзакция оборвана.

Page 9: Вставка

Source slide page 9: Вставка{#source-figure-009 caption=“Source slide page 9: Вставка”}

индекс не содержит информации о версионности

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

Пусть также имеется индекс B-дерево, созданный по текстовому полю.

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

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

Индексные записи тоже могут иметь очень разную структуру в зависимости от типа индекса. Например, для B-дерева записи в листовых страницах содержат значение ключа индексирования и ссылку (ctid) на соответствующую строку таблицы (структура B-дерева разбирается в теме «Классы операторов» и в учебном курсе QPT «Оптимизация запросов»).

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

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

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

Page 10: Фиксация изменений

Source slide page 10: Фиксация изменений{#source-figure-010 caption=“Source slide page 10: Фиксация изменений”}

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

Когда транзакция фиксируется, в CLOG для этой транзакции выставляется признак committed. Это, по сути, единственная операция (не считая журнала предзаписи), которая необходима.

Когда какая-либо другая транзакция обратится к версии строки, ей придется ответить на вопросы:

  1. завершилась ли транзакция 100 (надо проверить список активных процессов и их транзакций; такая структура в общей памяти имеет название ProcArray),

  2. а если завершилась, то фиксацией или отменой (свериться с CLOG).

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

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

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

Page 11: Удаление

Source slide page 11: Удаление{#source-figure-011 caption=“Source slide page 11: Удаление”}

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

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

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

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

Page 12: Отмена изменений

Source slide page 12: Отмена изменений{#source-figure-012 caption=“Source slide page 12: Отмена изменений”}

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

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

Номер прерванной транзакции остается в поле xmax. Когда другая транзакция обратится к версии строки, она проверит статус транзакции 101 и установит в версию строки признак-подсказку, что транзакция оборвана. Это будет означать, что на значение в поле xmax смотреть не нужно.

Page 13: Обновление

Source slide page 13: Обновление{#source-figure-013 caption=“Source slide page 13: Обновление”}

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

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

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

Старая версия помечается номером текущей транзакции в поле xmax. Обратите внимание, что новое значение 102 записалось поверх старого 101, так как транзакция 101 была отменена. Кроме того, биты-подсказки сброшены, так как статус текущей транзакции еще не известен.

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

Так же, как и при удалении, значение xmax в первой версии строки служит признаком того, что строка заблокирована.

Чем плохо такое обновление?

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

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

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

Page 14: Версии строк

Начнем с чистого листа.

=> TRUNCATE TABLE t;
TRUNCATE TABLE

Вставим одну строку, предварительно начав транзакцию.

=> BEGIN;
BEGIN
=> INSERT INTO t(id, s) VALUES (42, 'FOO');
INSERT 0 1

Номер нашей текущей транзакции:

=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                821
(1 row)

Посмотрим на таблицу, добавив системные столбцы:

=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  821 |    0 | 42 | FOO
(1 row)

Xmin совпадает с номером нашей транзакции, а xmax равен фиктивному значению 0. Чтобы заглянуть в индекс, воспользуемся расширением pageinspect. Нулевая страница индекса содержит метаинформацию, поэтому смотрим в первую.

=> SELECT itemoffset, ctid, data FROM bt_page_items('t_s',1);
 itemoffset | ctid  |          data
------------+-------+-------------------------
          1 | (0,1) | 09 46 4f 4f 00 00 00 00
(1 row)

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

=> COMMIT;
COMMIT

Теперь проверим, как работает обновление. Начнем второй сеанс.

student$ psql arch_mvcc

В нем прочитаем таблицу на уровне Repeatable Read:

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  821 |    0 | 42 | FOO
(1 row)

Page 15: Page 15

Теперь обновляем строку.

=> BEGIN;
BEGIN
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                822
(1 row)
=> UPDATE t SET s = 'BAR';
UPDATE 1

Запрос в первом сеансе выдает одну строку (новую версию):

=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,2) |  822 |    0 | 42 | BAR
(1 row)

Но в странице присутствуют обе версии. Предыдущую продолжает видеть второй сеанс:

=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  821 |  822 | 42 | FOO
(1 row)

Номер транзакции, удалившей первую версию строки, записался в xmax. При этом в индексной странице обнаруживаем указатели на обе версии:

=> SELECT itemoffset, ctid, data FROM bt_page_items('t_s',1);
 itemoffset | ctid  |          data
------------+-------+-------------------------
          1 | (0,2) | 09 42 41 52 00 00 00 00
          2 | (0,1) | 09 46 4f 4f 00 00 00 00
(2 rows)

Индексные записи внутри страницы упорядочены. Поэтому первой идет запись с ключом BAR (ссылается на версию (0,2)), а второй — запись с ключом FOO (ссылается на версию (0,1)). Завершим транзакции.

=> COMMIT;
COMMIT
=> COMMIT;
COMMIT

Page 16: HOT-обновление

Source slide page 16: HOT-обновление{#source-figure-016 caption=“Source slide page 16: HOT-обновление”}

обновляемые столбцы не должны входить ни в один индекс цепочка обновлений — только в пределах одной страницы

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

Однако если индекс создан по полю, значение которого не изменилось в результате обновления строки, то нет смысла создавать дополнительную запись в B-дереве, содержащую то же самое значение ключа. Именно так работает оптимизация, называемая HOT-обновлением — Heap-Only Tuple Update.

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

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

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

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

Page 17: HOT-обновление

Снова опустошим таблицу.

=> TRUNCATE TABLE t;
TRUNCATE TABLE

Но пусть теперь индекс будет построен по столбцу id:

=> DROP INDEX t_s;
DROP INDEX
=> CREATE INDEX t_id on t(id);
CREATE INDEX
=> INSERT INTO t(id, s) VALUES (42, 'FOO');
INSERT 0 1

Начнем транзакцию с уровнем изоляции Repeatable Read.

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  826 |    0 | 42 | FOO
(1 row)

До этого мы обновляли проиндексированный столбец, а теперь столбец s не входит ни в один индекс.

=> BEGIN;
BEGIN
=> UPDATE t SET s = 'BAR';
UPDATE 1

Появляется вторая версия строки.

=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,2) |  827 |    0 | 42 | BAR
(1 row)

Но другой сеанс продолжает видеть самую первую версию:

=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  826 |  827 | 42 | FOO
(1 row)

Что же обнаружится в индексе?

=> SELECT itemoffset, ctid, data FROM bt_page_items('t_id',1);
 itemoffset | ctid  |          data
------------+-------+-------------------------
          1 | (0,1) | 2a 00 00 00 00 00 00 00
(1 row)

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

Page 18: Page 18

=> SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_xact_all_tables
WHERE relid = 't'::regclass;
 n_tup_upd | n_tup_hot_upd
-----------+---------------
         1 |             1
(1 row)

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

=> ROLLBACK;
ROLLBACK
=> ROLLBACK;
ROLLBACK

Page 19: Снимки данных

Снимок данных

  • Видимость версий строк
  • Горизонт снимка
  • Горизонт транзакции
  • Горизонт базы данных

Page 20: Снимок данных

Дает согласованную картину данных на момент времени

  • видны только зафиксированные на этот момент данные
  • Создается:

снимок

  • Read Committed —
  • в начале каждого оператора
  • Repeatable Read, Serializable —
  • в начале первого оператора транзакции
  • xid

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

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

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

Согласованность здесь понимается в обычном ACID-смысле: данные должны быть корректны, что, по сути, означает, что видеть нужно только те данные, которые были зафиксированы на момент создания снимка. Это полностью исключает аномалию грязного чтения на любом уровне изоляции.

На уровне изоляции Read Committed снимок создается в начале каждого оператора транзакции. Снимок активен, пока выполняется оператор. Таким образом каждый оператор видит согласованные данные, но если между двумя операторами другие транзакции зафиксируют изменения, возможны неповторяемое и фантомное чтения.

На уровне Repeatable Read (и Serializable) снимок создается один раз в начале первого оператора транзакции. Такой снимок остается активным до самого конца транзакции. В этом случае неповторяемое и фантомное чтения исключаются.

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

Page 21: Видимость версий строк

Видимость версии строки ограничена xmin и xmax

  • Версия попадает в снимок, когда
  • изменения транзакции xmin видны для снимка, изменения транзакции xmax не видны для снимка Изменения транзакции видны, когда
    • либо это та же самая транзакция, что создала снимок,
    • либо она завершилась фиксацией до момента создания снимка
  • Отдельные правила для видимости собственных изменений
    • учитывается порядковый номер операции в транзакции (cmin/cmax)

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

Будет или нет данная версия строки видна в снимке, определяется двумя полями ее заголовка — xmin и xmax, — то есть номерами создавшей и удалившей транзакций. Такие интервалы не пересекаются, поэтому одна строка представлена в любом снимке максимум одной своей версией.

Точные правила видимости довольно сложны и учитывают различные «крайние» случаи. Чуть упрощая, можно сказать, что версия строки видна, когда в снимке видны изменения, сделанные транзакцией xmin, и не видны изменения, сделанные транзакцией xmax. Иными словами, создание версии строки должно быть видно, а удаление (если оно было) — нет.

В свою очередь, изменения транзакции видны в снимке, если либо это та же самая транзакция, что создала снимок (транзакция видит свои же изменения), либо транзакция была зафиксирована до создания снимка.

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

Page 22: Снимок данных

Source slide page 22: Снимок данных{#source-figure-022 caption=“Source slide page 22: Снимок данных”}

  • xmin — номер самой ранней активной транзакции xmax — номер, следующий за последней зафиксированной транзакцией xip_list — список активных транзакций

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

В момент создания снимка данных запоминаются несколько значений, которые и определяют снимок:

● xmin — нижняя граница снимка, в качестве которой выступает номер самой ранней активной транзакции.

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

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

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

● xip_list (xid-in-progress list) — список активных транзакций.

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

Информацию о снимке можно получить с помощью функции pg_current_snapshot() и нескольких других: https://postgrespro.ru/docs/postgresql/16/functions-info

Page 23: Горизонт снимка

Source slide page 23: Горизонт снимка{#source-figure-023 caption=“Source slide page 23: Горизонт снимка”}

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

Номер самой ранней из активных транзакций (xmin снимка) имеет важный смысл — он определяет горизонт снимка.

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

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

Page 24: Горизонт транзакции

Source slide page 24: Горизонт транзакции{#source-figure-024 caption=“Source slide page 24: Горизонт транзакции”}

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

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

Если транзакция будет долго находиться в таком состоянии ожидания (оно называется idle in transaction), не выполняя никаких команд, горизонт будет оставаться на месте.

Page 25: Горизонт базы данных

Source slide page 25: Горизонт базы данных{#source-figure-025 caption=“Source slide page 25: Горизонт базы данных”}

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

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

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

На слайде показан пример трех транзакций с уровнем изоляции Read Committed. Для оператора UPDATE транзакции T1 был построен снимок. Когда оператор отрабатывает, снимок удаляется и перестает удерживать горизонт. Однако транзакция T1 по-прежнему активна, и поэтому горизонты снимков транзакций T2 и T3 не сдвинутся правее номера транзакции T1. Тем самым активная транзакция удерживает горизонт самим фактом своего существования.

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

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

Page 26: Снимки данных

И еще раз начнем транзакцию в отдельном сеансе.

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT ctid, xmin, xmax, * FROM t;
 ctid  | xmin | xmax | id |  s
-------+------+------+----+-----
 (0,1) |  826 |  827 | 42 | FOO
(1 row)

Одновременно с этим выполним несколько других транзакций.

=> UPDATE t SET s = 'BAR';
UPDATE 1
=> INSERT INTO t(id, s)  VALUES (43, 'BAZ');
INSERT 0 1

Горизонт транзакций можно увидеть следующим образом:

=> SELECT query,backend_xmin
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
AND datname='arch_mvcc';
               query                | backend_xmin
------------------------------------+--------------
 SELECT ctid, xmin, xmax, * FROM t; |          828
 SELECT query,backend_xmin         +|          830
 FROM pg_stat_activity             +|
 WHERE backend_xmin IS NOT NULL    +|
 AND datname='arch_mvcc';           |
(2 rows)

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

=> COMMIT;
COMMIT

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

=> SELECT query,backend_xmin
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
AND datname='arch_mvcc';
             query              | backend_xmin
--------------------------------+--------------
 SELECT query,backend_xmin     +|          830
 FROM pg_stat_activity         +|
 WHERE backend_xmin IS NOT NULL+|
 AND datname='arch_mvcc';       |
(1 row)

Page 27: Итоги

PostgreSQL использует многоверсионность и изоляцию на основе снимков данных

  • Индексы не содержат информации о версионности
    • чтобы вернуть данные, нужно проверить видимость
    • (по таблице или карте видимости)
  • Лишние индексы не только влекут накладные расходы, но и препятствуют HOT-обновлениям
  • Транзакции должны быть настолько длинными, насколько необходимо, но не более

Page 28: Практика

  1. Функционал точек сохранения должен позволять откатывать часть транзакции. Чтобы разобраться, как это работает, выполните операции внутри одной транзакции:
  • вставьте строку в таблицу;
  • установите точку сохранения (SAVEPOINT);
  • вставьте в таблицу еще одну строку;
  • откатите изменения до точки сохранения;
  • вставьте еще одну строку;
  • зафиксируйте изменения.
  • При этом после каждой операции выводите номер текущей
  • транзакции и проверяйте значения столбцов xmin и xmax.

Page 29: 1. Точки сохранения

=> CREATE DATABASE arch_mvcc;
CREATE DATABASE
=> \c arch_mvcc
You are now connected to database "arch_mvcc" as user "student".

Создадим таблицу:

=> CREATE TABLE t(s text);
CREATE TABLE

Начинаем транзакцию.

=> BEGIN;
BEGIN

Вставляем первую строку:

=> INSERT INTO t VALUES ('first');
INSERT 0 1

Вот номер нашей транзакции и состояние таблицы:

=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                817
(1 row)
=> SELECT *, xmin, xmax FROM t;
   s   | xmin | xmax
-------+------+------
 first |  817 |    0
(1 row)

Пока все как обычно. Устанавливаем точку сохранения и вставляем вторую строку:

=> SAVEPOINT sp;
SAVEPOINT
=> INSERT INTO t VALUES ('second');
INSERT 0 1
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                817
(1 row)

Номер транзакции прежний.

=> SELECT *, xmin, xmax FROM t;
   s    | xmin | xmax
--------+------+------
 first  |  817 |    0
 second |  818 |    0
(2 rows)

А вот в xmin второй строки указан другой номер. Это вложенная транзакция, она начинается сразу после установки точки сохранения. Откатим изменения до точки сохранения и вставим третью строку:

Page 30: Page 30

=> ROLLBACK TO sp;
ROLLBACK
=> INSERT INTO t VALUES ('third');
INSERT 0 1
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                817
(1 row)
=> SELECT *, xmin, xmax FROM t;
   s   | xmin | xmax
-------+------+------
 first |  817 |    0
 third |  819 |    0
(2 rows)

У третьей строки опять другой номер. Это еще одна вложенная транзакция, которая началась сразу после отката к точке сохранения. Зафиксируем изменения:

=> COMMIT;
COMMIT

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

03 Занятие 03: Архитектура / Очистка

Исходная страница в нативном Markdown: Архитектура / Очистка
Примечание

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

Page 1: Архитектура / Очистка

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

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

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

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

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

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

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

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

Page 2: Темы

  • Очистка версий строк и анализ таблиц
  • Заморозка версий строк

Page 3: Очистка и анализ

Как устроен процесс очистки

  • Анализ таблиц
  • Полная очистка

Page 4: Очистка

Выполняется командой VACUUM

  • очищает ненужные версии строк в табличных страницах
  • (пропуская страницы, уже отмеченные в карте видимости)
  • очищает индексные записи, ссылающиеся на очищенные версии строк
  • обновляет карты видимости и свободного пространства
  • Обычно работает в автоматическом режиме
    • частота обработки зависит от интенсивности изменений в таблице
    • процессы autovacuum launcher и autovacuum worker

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

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

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

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

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

Важно, чтобы процесс автоочистки был правильно настроен. Как это делается, рассматривается в курсе DBA2 «Администрирование PostgreSQL. Настройка и мониторинг». Если автоочистка не будет срабатывать вовремя, таблицы будут разрастаться в размерах. Также важно, чтобы в системе с OLTP-нагрузкой не было длинных транзакций, подолгу удерживающих горизонт базы данных и мешающих очистке.

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

https://postgrespro.ru/docs/postgresql/16/routine-vacuuming

Page 5: Пример

Source slide page 5: Пример{#source-figure-005 caption=“Source slide page 5: Пример”}

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

Рассмотрим пример.

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

В индексе есть три ссылки: на каждую из версий строки.

Page 6: Фаза очистки индексов

Source slide page 6: Фаза очистки индексов{#source-figure-006 caption=“Source slide page 6: Фаза очистки индексов”}

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

Очистка обрабатывает данные постранично. Таблица при этом может использоваться обычным образом и для чтения, и для изменения (однако одновременное выполнение для таблицы таких команд, как CREATE INDEX, ALTER TABLE и др. будет невозможно — подробнее см. тему «Блокировки»).

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

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

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

Page 7: Фаза очистки таблицы

Source slide page 7: Фаза очистки таблицы{#source-figure-007 caption=“Source slide page 7: Фаза очистки таблицы”}

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

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

После этой фазы устаревших версий строк нет ни в индексах, ни в таблице.

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

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

Page 8: Очистка

=> CREATE DATABASE arch_vacuum;
CREATE DATABASE
=> \c arch_vacuum
You are now connected to database "arch_vacuum" as user "student".

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

=> CREATE TABLE t(
    id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY
) WITH (autovacuum_enabled = off);
CREATE TABLE

Вставим в таблицу некоторое количество строк и узнаем размер ее основного слоя:

=> INSERT INTO t
SELECT n
FROM generate_series(1,1000) as s(n);
INSERT 0 1000
=> SELECT pg_size_pretty(pg_relation_size('t', 'main'));
 pg_size_pretty
----------------
 40 kB
(1 row)

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

=> \c arch_vacuum
You are now connected to database "arch_vacuum" as user "student".
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN
=> SELECT 1; -- любой запрос строит снимок
 ?column?
----------
        1
(1 row)

Горизонт базы данных определяется этим снимком:

=> SELECT backend_xmin
FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
--------------
          818
(1 row)

Обновим все строки таблицы.

=> UPDATE t SET id = id + 1000;
UPDATE 1000

Как изменится размер таблицы?

=> SELECT pg_size_pretty(pg_relation_size('t', 'main'));
 pg_size_pretty
----------------
 72 kB
(1 row)

Page 9: Page 9

Source slide page 9: Page 9{#source-figure-009 caption=“Source slide page 9: Page 9”}

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

=> VACUUM VERBOSE t;
INFO:  vacuuming "arch_vacuum.public.t"
INFO:  finished vacuuming "arch_vacuum.public.t": index scans: 0
pages: 0 removed, 9 remain, 9 scanned (100.00% of total)
tuples: 0 removed, 2000 remain, 1000 are dead but not yet removable
removable cutoff: 818, which was 1 XIDs old when operation ended
new relfrozenxid: 817, which is 1 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers
removed
avg read rate: 0.000 MB/s, avg write rate: 33.967 MB/s
buffer usage: 26 hits, 0 misses, 3 dirtied
WAL usage: 1 records, 0 full page images, 237 bytes
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

Обратите внимание: tuples: 0 removed, 2000 remain — ни одна версия строк не может быть очищена, 1000 are dead but not yet removable — хотя две трети из них — неактуальные, removable cutoff: 818 — текущий горизонт, индекс не очищался. Завершим параллельную транзакцию…

=> COMMIT;
COMMIT

…и снова вызовем очистку:

=> VACUUM VERBOSE t;
INFO:  vacuuming "arch_vacuum.public.t"
INFO:  finished vacuuming "arch_vacuum.public.t": index scans: 1
pages: 0 removed, 9 remain, 9 scanned (100.00% of total)
tuples: 1000 removed, 1000 remain, 0 are dead but not yet removable
removable cutoff: 819, which was 0 XIDs old when operation ended
new relfrozenxid: 818, which is 1 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan needed: 5 pages from table (55.56% of total) had 1000 dead item identifiers
removed
index "t_pkey": pages: 8 in total, 2 newly deleted, 2 currently deleted, 0 reusable
avg read rate: 0.000 MB/s, avg write rate: 13.587 MB/s
buffer usage: 55 hits, 0 misses, 1 dirtied
WAL usage: 29 records, 1 full page images, 16218 bytes
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

Теперь: removable cutoff: 819 — горизонт подвинулся, tuples: 1000 removed — очищены все неактуальные версии строк, index scan needed … 1000 dead item identifiers removed — очищены указатели на них из индекса. Посмотрим на размер:

=> SELECT pg_size_pretty(pg_relation_size('t', 'main'));
 pg_size_pretty
----------------
 72 kB
(1 row)

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

Page 10: Анализ

Выполняется командой ANALYZE

  • собирает статистику для планировщика
  • можно вместе с очисткой командой VACUUM ANALYZE
  • Обычно работает в автоматическом режиме
    • вместе с автоматической очисткой

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

Еще одна задача, которую обычно совмещают с очисткой, — анализ, то есть сбор статистической информации для планировщика запросов. Анализируется число строк в таблице, распределение данных по столбцам и т. п. Более подробно сбор статистики рассматривается в курсе QPT «Оптимизация запросов».

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

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

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

Page 11: Полная очистка

Перестраивает файлы и возвращает место файловой системе

  • Команда VACUUM FULL
    • полностью перестраивает таблицу и все ее индексы
    • монопольно блокирует таблицу и ее индексы на все время работы
  • Команда REINDEX
    • полностью перестраивает только индексы
    • монопольно блокирует индекс и запрещает запись в таблицу
    • вариант REINDEX CONCURRENTLY не мешает чтению и записи,
    • но выполняется дольше

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

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

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

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

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

Команда REINDEX перестраивает индексы, не трогая при этом таблицу. Фактически, VACUUM FULL использует эту команду для того, чтобы перестроить индексы. Вариант REINDEX CONCURRENTLY работает дольше, но не блокирует индекс и не мешает чтению и обновлению данных.

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

Page 12: Пример

Source slide page 12: Пример{#source-figure-012 caption=“Source slide page 12: Пример”}

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

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

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

В индексе есть три ссылки на каждую из версий строки.

Page 13: После полной очистки

Source slide page 13: После полной очистки{#source-figure-013 caption=“Source slide page 13: После полной очистки”}

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

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

Page 14: Полная очистка

Вызываем полную очистку таблицы:

=> VACUUM FULL VERBOSE t;
INFO:  vacuuming "public.t"
INFO:  "public.t": found 0 removable, 1000 nonremovable row versions in 9 pages
DETAIL:  0 dead row versions cannot be removed yet.
CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s.
VACUUM

Таблица и индекс теперь полностью перестроены. Как изменился размер?

=> SELECT pg_size_pretty(pg_relation_size('t', 'main'));
 pg_size_pretty
----------------
 40 kB
(1 row)

Размер вернулся к первоначальному.

Page 15: Заморозка

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

  • Заморозка версий строк

Page 16: Переполнение счетчика

Source slide page 16: Переполнение счетчика{#source-figure-016 caption=“Source slide page 16: Переполнение счетчика”}

пространство номеров транзакций (32 бита) закольцовано половина номеров — прошлое, половина — будущее

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

Под номер транзакции в PostgreSQL выделено 32 бита. Это довольно большое число (около 4 млрд номеров), но при активной работе сервера оно вполне может быть исчерпано. Например при нагрузке 1000 транзакций в секунду это произойдет всего через полтора месяца непрерывной работы.

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

Почему под номер транзакции не выделено 64 бита — ведь это полностью исключило бы проблему? Дело в том, что (как рассматривалось в теме «Многоверсионность») в заголовке каждой версии строки хранятся два номера транзакций — xmin и xmax. Заголовок и так достаточно большой, а увеличение разрядности привело бы к его увеличению еще на 8 байт.

Page 17: Переполнение счетчика

Source slide page 17: Переполнение счетчика{#source-figure-017 caption=“Source slide page 17: Переполнение счетчика”}

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

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

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

Page 18: Заморозка

Source slide page 18: Заморозка{#source-figure-018 caption=“Source slide page 18: Заморозка”}

процесс очистки выполняет заморозку старых версий строк после заморозки номер транзакции xmin может быть использован заново

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

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

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

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

Page 19: Заморозка

Source slide page 19: Заморозка{#source-figure-019 caption=“Source slide page 19: Заморозка”}

замороженные версии строк считаются «бесконечно старыми»

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

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

https://postgrespro.ru/docs/postgresql/16/routine-vacuuming#VACUUM-FOR -WRAPAROUND

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: После заморозки”}

замораживается только номер xmin версии строк с xmax являются неактуальными и будут очищены

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

Для того чтобы пометить номер транзакции xmin как замороженный, выставляется специальная комбинация информационных битов в заголовке версии строки.

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

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

Page 22: Заморозка вручную

VACUUM FREEZE

  • принудительная заморозка версий строк с xmin любого возраста
  • тот же эффект и при VACUUM FULL, CLUSTER
  • COPY … WITH FREEZE
    • принудительная заморозка во время загрузки
    • таблица должна быть создана или опустошена в той же транзакции
    • могут нарушаться правила изоляции транзакции

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

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

Заморозку можно вызвать вручную командой VACUUM FREEZE — при этом будут заморожены все версии строк, без оглядки на возраст транзакций. При перестройке таблицы командами VACUUM FULL или CLUSTER все строки также замораживаются.

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

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

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

Page 23: Итоги

Многоверсионность требует очистки старых версий строк

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

Page 24: Практика

  1. В демонстрации было показано, что при обновлении всех строк таблицы одной командой UPDATE объем данных на диске увеличивается вдвое. Проделайте сходный эксперимент, но обновляйте таблицу небольшими пакетами строк, чередуя обновление с запуском очистки. Как сильно увеличилась таблица на этот раз? 2. Сравните время, за которое удаляются все строки таблицы при использовании команд DELETE и TRUNCATE.

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

  1. С размером пакета можно экспериментировать; возьмите, например, 1 % от числа строк в таблице.

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

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

2. Воспользуйтесь командой psql \timing.

Page 25: 1. Пакетное обновление

=> CREATE DATABASE arch_vacuum;
CREATE DATABASE
=> \c arch_vacuum
You are now connected to database "arch_vacuum" as user "student".

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

=> CREATE TABLE t(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    n float,
    processed boolean DEFAULT false
);
CREATE TABLE
=> INSERT INTO t(n) SELECT random() FROM generate_series(1,100_000);
INSERT 0 100000

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

=> SELECT pg_size_pretty(pg_relation_size('t'));
 pg_size_pretty
----------------
 5096 kB
(1 row)

Еще раз проверим показанное в демонстрации. Обновляем все строки:

=> UPDATE t SET n = n + 1;
UPDATE 100000

Размер таблицы после обновления:

=> SELECT pg_size_pretty(pg_relation_size('t'));
 pg_size_pretty
----------------
 10192 kB
(1 row)

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

=> \timing on
Timing is on.
=> TRUNCATE t;
TRUNCATE TABLE
Time: 13,354 ms
=> \timing off
Timing is off.

Заново вставляем те же строки:

=> INSERT INTO t(n) SELECT random() FROM generate_series(1,100_000);
INSERT 0 100000
=> SELECT pg_size_pretty(pg_relation_size('t'));

Page 26: Page 26

 pg_size_pretty
----------------
 5096 kB
(1 row)

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

=> CREATE FUNCTION do_update(batch_size integer) RETURNS void
LANGUAGE sql VOLATILE
BEGIN ATOMIC
    WITH batch AS (
        -- отбираем необработанные строки для пакета
        -- и блокируем их, пропуская уже заблокированные
        SELECT * FROM t
        WHERE NOT processed
        LIMIT batch_size
        FOR UPDATE SKIP LOCKED
    )
    UPDATE t
    SET n = n + 1,
        processed = true
    WHERE id IN (SELECT id FROM batch);
END;
CREATE FUNCTION

Предложение FOR UPDATE SKIP LOCKED будет подробнее рассмотрено в теме «Блокировки». Такой способ даст необходимое количество необработанных строк (из числа имеющихся в таблице), причем выполнение не будет задержано другими обновлениями, которые могут выполняться в это же время. Удобно также, что этот способ не требует вычисления каких-либо диапазонов. Обновление просто надо продолжать до тех пор, пока в таблице не останется необработанных строк. Поскольку выполнение команды VACUUM не допускается в блоке транзакции, воспользуется средствами bash, чтобы выполнить нужные команды:

student$ for i in {1..100}
do
  psql -d arch_vacuum -c 'SELECT do_update(1000);'
  psql -d arch_vacuum -c 'VACUUM;'
done >/dev/null

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

=> SELECT count(*) FROM t WHERE NOT processed;
 count
-------
     0
(1 row)

Размер таблицы после пакетного обновления:

=> SELECT pg_size_pretty(pg_relation_size('t'));
 pg_size_pretty
----------------
 5216 kB
(1 row)

Таблица увеличилась чуть больше, чем на 2%.

2. Удаление строк

=> \timing on
Timing is on.
=> DELETE FROM t;
DELETE 100000
Time: 112,229 ms
=> \timing off
Timing is off.

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

Page 27: Page 27

По принципу работы команда TRUNCATE напоминает VACUUM FULL: она заменяет файл данных новым (пустым) файлом, и для этого полностью блокирует работу с таблицей для других транзакций.

04 Занятие 04: Архитектура / Журналирование

Исходная страница в нативном Markdown: Архитектура / Журналирование
Примечание

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

Page 1: Архитектура / Журналирование

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

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

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

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

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

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

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

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

Page 2: Темы

  • Буферный кеш
  • Журнал предзаписи

Page 3: Буферный кеш

Устройство и использование

  • Фоновая запись
  • Локальный кеш для временных таблиц

Page 4: Буферный кеш

Source slide page 4: Буферный кеш{#source-figure-004 caption=“Source slide page 4: Буферный кеш”}

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

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

Буферный кеш располагается в общей памяти сервера и представляет собой массив буферов (его размер задается конфигурационным параметром shared_buffers). Каждый буфер состоит из места под одну страницу данных и заголовка. Заголовок содержит расположение страницы на диске (файл и номер страницы) и другую информацию.

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

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

Page 5: Буферный кеш

=> CREATE DATABASE arch_wal;
CREATE DATABASE
=> \c arch_wal
You are now connected to database "arch_wal" as user "student".

Создадим таблицу.

=> CREATE TABLE t(
    id integer,
    s char(100) DEFAULT ''
) WITH (autovacuum_enabled = off);
CREATE TABLE
=> INSERT INTO t(id) SELECT g.id FROM generate_series(1,1000) g(id);
INSERT 0 1000

В состав PostgreSQL входит расширение, которое позволяет посмотреть, что происходит в буферном кеше.

=> CREATE EXTENSION pg_buffercache;
CREATE EXTENSION

Размер кеша определяется конфигурационным параметром:

=> SHOW shared_buffers;
 shared_buffers
----------------
 128MB
(1 row)

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

=> SELECT count(*), pg_size_pretty(count(*) * 8192) FROM pg_buffercache;
 count | pg_size_pretty
-------+----------------
 16384 | 128 MB
(1 row)

Page 6: Страница в кеше

Source slide page 6: Страница в кеше{#source-figure-006 caption=“Source slide page 6: Страница в кеше”}

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

Рассмотрим случай, когда необходимая страница находится в кеше.

Буферный кеш и хеш-таблица находятся в общей памяти, и доступ к ним есть у всех процессов сервера. Поэтому одновременный, конкурентный доступ к этим структурам необходимо упорядочивать.

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

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

Дальше процесс «закрепляет» буфер, увеличивая счетчик pin count в заголовке страницы. Пока буфер закреплен (значение pin count больше нуля), считается, что буфер используется и его содержимое не должно «радикально» измениться. Например, в странице может появиться новая версия строки — обычно это не помешает другим процессам благодаря многоверсионности и правилам видимости. Но в закрепленный буфер не может быть прочитана другая страница.

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

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: Чтение с вытеснением”}

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

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

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

● имеет значение счетчика обращений, равное 0;

● не закреплен.

По-английски этот алгоритм называется clock-sweep.

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

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

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

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

Page 9: Чтение с вытеснением

Source slide page 9: Чтение с вытеснением{#source-figure-009 caption=“Source slide page 9: Чтение с вытеснением”}

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

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

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

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

Записываются буферы, которые одновременно:

● содержат измененные (грязные) страницы;

● не закреплены (pin count = 0);

● имеют нулевое число обращений (usage count = 0).

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

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

Page 10: Чтение с вытеснением

Source slide page 10: Чтение с вытеснением{#source-figure-010 caption=“Source slide page 10: Чтение с вытеснением”}

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

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

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

Page 11: Использование буферного кеша

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

=> \q
student$ sudo pg_ctlcluster 16 main restart
student$ psql arch_wal

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

=> SELECT isdirty, usagecount, count(*)
FROM pg_buffercache b
WHERE b.relfilenode = pg_relation_filenode('t'::regclass)
GROUP BY isdirty, usagecount;
 isdirty | usagecount | count
---------+------------+-------
(0 rows)

Теперь выполним запрос, который прочитает все страницы таблицы. Команда EXPLAIN ANALYZE с параметром BUFFERS покажет, какое количество обращений к буферам потребовалось для выполнения.

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM t;
                QUERY PLAN
------------------------------------------
 Seq Scan on t (actual rows=1000 loops=1)
   Buffers: shared read=18 dirtied=18
 Planning:
   Buffers: shared hit=17 read=7
 Planning Time: 5.043 ms
 Execution Time: 0.274 ms
(6 rows)

В данном случае запрос прочитал 18 страниц, ни одной из которых не оказалось в кеше (read). А почему эти страницы были изменены (dirtied)? Как рассматривалось в теме «Многоверсионность», при первом чтении новых версий строк в заголовках этих версий проставляется статус транзакции (зафиксирована или оборвана). Это вызывает изменение страницы. Что теперь мы обнаружим в кеше?

=> SELECT isdirty, usagecount, count(*)
FROM pg_buffercache b
WHERE b.relfilenode = pg_relation_filenode('t'::regclass)
GROUP BY isdirty, usagecount;
 isdirty | usagecount | count
---------+------------+-------
 t       |          1 |    18
(1 row)

Как раз 18 буферов заполнены данными таблицы. А как изменится выполнение того же самого запроса?

=> EXPLAIN (analyze, buffers, costs off, timing off)
SELECT * FROM t;
                QUERY PLAN
------------------------------------------
 Seq Scan on t (actual rows=1000 loops=1)
   Buffers: shared hit=18
 Planning Time: 0.052 ms
 Execution Time: 0.111 ms
(4 rows)

Теперь все данные были взяты из кеша (hit) и время выполнения запроса уменьшилось. Именно с наличием кеша и связан тот факт, что при повторном выполнении запросы обычно работают быстрее.

Page 12: Page 12

Page 13: Временные таблицы

Данные временных таблиц

  • видны только одному сеансу — нет смысла использовать общий кеш
  • существуют в пределах сеанса — не жалко потерять при сбое
  • Используется локальный буферный кеш
    • не требуются блокировки
    • память выделяется по необходимости в пределах temp_buffers
    • обычный алгоритм вытеснения

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

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

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

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

Page 14: Журнал предзаписи

Устройство журнала

  • Контрольная точка
  • Восстановление после сбоя
  • Синхронный и асинхронный режимы

Page 15: Журнал предзаписи

Необходимость

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

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

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

Одновременно с изменением данных создается запись в журнале, содержащая достаточную информацию для повторения этой операции. Журнальная запись в обязательном порядке попадает на диск (или другое энергонезависимое устройство) до того, как туда попадет измененная страница — отсюда и название: «журнал предзаписи», «write-ahead log».

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

В журнал не записываются операции с нежурналируемыми (и временными) таблицами.

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

https://postgrespro.ru/docs/postgresql/16/wal-intro

Page 16: Устройство журнала

Source slide page 16: Устройство журнала{#source-figure-016 caption=“Source slide page 16: Устройство журнала”}

последовательность записей «номер» записи — 64-битный LSN (log sequence number) специальный тип pg_lsn

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

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

● Номер транзакции, к которой относится запись.

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

● Контрольная сумма (CRC).

Для того чтобы сослаться на определенную запись, используется тип данных pg_lsn (LSN = log sequence number) — 64-битное число, представляющее собой байтовое смещение записи относительно начала журнала.

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

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

Page 17: Устройство журнала

Начнем транзакцию.

=> BEGIN;
BEGIN

Текущая позиция в журнале:

=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/5A1AD08
(1 row)

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

=> UPDATE t SET s = 'FOO' WHERE id = 1;
UPDATE 1

Изменится ли позиция в журнале?

=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/5A1D5C8
(1 row)

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

=> COMMIT;
COMMIT

Позиция в журнале снова изменилась:

=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 0/5A1D5F0
(1 row)

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

=> SELECT '0/5A1D5F0'::pg_lsn - '0/5A1AD08'::pg_lsn;
 ?column?
----------
    10472
(1 row)

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

Page 18: Контрольная точка

Source slide page 18: Контрольная точка{#source-figure-018 caption=“Source slide page 18: Контрольная точка”}

Процесс периодического сброса грязных страниц на диск

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

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

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

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

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

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

Page 19: Контрольная точка

Source slide page 19: Контрольная точка{#source-figure-019 caption=“Source slide page 19: Контрольная точка”}

помеченные страницы постепенно записываются, пометка убирается из заголовка буфера

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

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

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

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

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

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

Page 20: Контрольная точка

Сейчас в буферном кеше находится несколько грязных буферов:

=> SELECT count(*)
FROM pg_buffercache b
WHERE isdirty;
 count
-------
    18
(1 row)

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

=> CHECKPOINT;
CHECKPOINT

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

=> SELECT count(*)
FROM pg_buffercache b
WHERE isdirty;
 count
-------
     0
(1 row)

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

Page 21: Восстановление

Source slide page 21: Восстановление{#source-figure-021 caption=“Source slide page 21: Восстановление”}

При старте сервера после сбоя

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

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

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

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

Чтобы понять, нужно ли применять журнальную запись к странице, надо сравнить LSN последнего изменения страницы (который при любых изменениях записывается в ее заголовок) с LSN журнальной записи. Если LSN страницы оказывается меньше, то операцию необходимо повторить.

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

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

На этом процесс startup завершает работу, а процесс checkpointer выполняет контрольную точку, чтобы зафиксировать восстановленное состояние.

Page 22: Режимы журналирования

Синхронный

  • сброс журнальных записей на диск при каждой фиксации
  • гарантируется долговечность
  • увеличивается время отклика
  • Асинхронный
    • сброс журнальных записей происходит периодически
    • гарантируется согласованность, но не долговечность
    • недавно зафиксированные изменения могут пропасть
  • Настройка
    • synchronous_commit = on / off
    • можно
    • изменять на уровне
    • транзакции

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

Запись журнала происходит в одном из двух режимов.

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

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

В асинхронном режиме журнал записывается частями в фоновом режиме.

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

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

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

https://postgrespro.ru/docs/postgresql/16/wal-async-commit

Page 23: Итоги

Буферный кеш ускоряет доступ к часто используемым данным (но не решает всех проблем производительности)

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

Page 24: Практика

  1. Воспользуйтесь расширением pg_prewarm, чтобы автоматически восстанавливать содержимое буферного кеша после перезагрузки сервера. Проверьте и затем отключите расширение. 2. Создайте таблицу и вставьте в нее столько строк, чтобы ее объем составлял примерно половину от объема буферного кеша. Перезагрузите сервер, чтобы очистить кеш. Выполните запрос на чтение всех строк таблицы и после этого проверьте, сколько буферов в кеше содержат страницы этой таблицы. Объясните результат. 3. Сравните производительность системы при синхронном и асинхронном режимах фиксации, используя утилиту эталонного тестирования pgbench.

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

  1. Для этого потребуется добавить расширение pg_prewarm в загружаемые библиотеки:
ALTER SYSTEM SET shared_preload_libraries = 'pg_prewarm';

и затем перезагрузить сервер.

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

  1. Для анализа используйте расширение pg_buffercache, показанное в демонстрации.

  2. Инициализируйте тестовые таблицы, вызвав pgbench с ключом -i.

Затем выполняйте эталонный тест в одном и другом режимах на протяжении некоторого времени.

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

Page 25: 1. Прогрев кеша с помощью pg_prewarm

Расширение необходимо добавить в загружаемые библиотеки и перезапустить сервер:

=> ALTER SYSTEM SET shared_preload_libraries = 'pg_prewarm';
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart

Создадим таблицу с данными:

student$ psql
=> CREATE DATABASE arch_wal;
CREATE DATABASE
=> \c arch_wal
You are now connected to database "arch_wal" as user "student".
=> CREATE TABLE t(n integer);
CREATE TABLE
=> INSERT INTO t(n) SELECT id FROM generate_series(1,10_000) id;
INSERT 0 10000

Проверим наличие страниц в буферном кеше:

=> CREATE EXTENSION pg_buffercache;
CREATE EXTENSION
=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('t'::regclass);
 count
-------
    48
(1 row)

Перезапустим сервер.

student$ sudo pg_ctlcluster 16 main restart

Проверим буферный кеш:

student$ psql arch_wal
=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('t'::regclass);
 count
-------
    48
(1 row)

Все страницы были автоматически загружены в буферный кеш при старте сервера. Отключим расширение.

=> ALTER SYSTEM RESET shared_preload_libraries;
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart

2. Массовое вытеснение

student$ psql arch_wal

Размер буферного кеша:

=> SHOW shared_buffers;

Page 26: Page 26

 shared_buffers
----------------
 128MB
(1 row)

В созданной таблице 10 тысяч строк занимают:

=> SELECT pg_size_pretty(pg_table_size('t'));
 pg_size_pretty
----------------
 384 kB
(1 row)

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

=> INSERT INTO t(n) SELECT id FROM generate_series(1,2_000_000) id;
INSERT 0 2000000
=> SELECT pg_size_pretty(pg_table_size('t')),
          pg_table_size('t')/8192 AS pages;
 pg_size_pretty | pages
----------------+-------
 70 MB          |  8899
(1 row)

Перезагрузим сервер.

student$ sudo pg_ctlcluster 16 main restart
student$ psql arch_wal

Выполним запрос, читающий все табличные страницы:

=> SELECT count(*) FROM t;
  count
---------
 2010000
(1 row)

В буферном кеше данные таблицы занимают всего:

=> SELECT count(*)
FROM pg_buffercache
WHERE relfilenode = pg_relation_filenode('t'::regclass);
 count
-------
    96
(1 row)

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

3. Зависимость производительности от режима фиксации

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

student$ pgbench -i arch_wal
dropping old tables...
NOTICE:  table "pgbench_accounts" does not exist, skipping
NOTICE:  table "pgbench_branches" does not exist, skipping
NOTICE:  table "pgbench_history" does not exist, skipping
NOTICE:  table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
100000 of 100000 tuples (100%) done (elapsed 0.14 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done in 0.39 s (drop tables 0.00 s, create tables 0.02 s, client-side generate 0.18 s,
vacuum 0.08 s, primary keys 0.11 s).

Синхронная фиксация включена:

Page 27: Page 27

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

Запускаем эталонный тест TPC-B на 30 секунд:

student$ pgbench -T 30 arch_wal
pgbench (16.11 (Ubuntu 16.11-1.pgdg24.04+1))
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
duration: 30 s
number of transactions actually processed: 9015
number of failed transactions: 0 (0.000%)
latency average = 3.327 ms
initial connection time = 8.652 ms
tps = 300.578702 (without initial connection time)

Показатель — количество транзакций в секунду (tps). Выключаем синхронную фиксацию:

=> ALTER SYSTEM SET synchronous_commit = off;
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Повторяем тест:

student$ pgbench -T 30 arch_wal
pgbench (16.11 (Ubuntu 16.11-1.pgdg24.04+1))
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
duration: 30 s
number of transactions actually processed: 29080
number of failed transactions: 0 (0.000%)
latency average = 1.031 ms
initial connection time = 6.193 ms
tps = 969.514536 (without initial connection time)

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

=> ALTER SYSTEM RESET synchronous_commit;
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

05 Занятие 05: Архитектура / Блокировки

Исходная страница в нативном Markdown: Архитектура / Блокировки
Примечание

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

Page 1: Архитектура / Блокировки

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

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

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

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

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

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

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

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

Page 2: Темы

  • Общая информация о блокировках
  • Блокировки отношений и других объектов
  • Блокировки на уровне строк

Page 3: Блокировки

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

  • Механизм
    • перед обращением к данным процесс захватывает блокировку
    • после обращения — освобождает (обычно в конце транзакции)
    • несовместимые блокировки приводят к очередям

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

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

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

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

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

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

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

Page 4: Блокировки объектов

Блокировки номера транзакции

  • Блокировки отношений
  • Очередь ожидания

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

Из всего многообразия блокировок на уровне объектов мы рассмотрим только блокировки номера транзакции и блокировки отношений.

Еще один вид блокировок этого же типа — рекомендательные блокировки — упоминался в теме «Обработка ошибок» курса DEV1.

Page 5: Номер транзакции

Типы ресурсов в pg\_locks

  • virtualxid — виртуальный номер транзакции
  • transactionid — настоящий номер транзакции
  • Режимы
    • исключительный
    • разделяемый
  • Транзакция удерживает исключительную блокировку собственного номера
    • способ дождаться завершения транзакции

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

Рассмотрение блокировок на уровне объектов («обычные», «тяжелые» блокировки) мы начнем с блокировок номера транзакции.

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

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

Уникальный настоящий номер выдается счетчиком XID в тот момент, когда транзакция изменяет какие-либо данные. Он нужен, чтобы отслеживать статус пишущей транзакции (активна, зафиксирована или оборвана); такой номер можно записывать в поля xmin и xmax заголовка версий строк.

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

Page 6: Блокировка номера транзакции

Source slide page 6: Блокировка номера транзакции{#source-figure-006 caption=“Source slide page 6: Блокировка номера транзакции”}

=> CREATE DATABASE arch_locks;
CREATE DATABASE
=> \c arch_locks
You are now connected to database "arch_locks" as user "student".

Начнем в другом сеансе новую транзакцию.

=> \c arch_locks
You are now connected to database "arch_locks" as user "student".
=> BEGIN;
BEGIN

Нам понадобится номер обслуживающего процесса:

=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          10981
(1 row)

Все «обычные» блокировки видны в представлении pg_locks. Какие блокировки удерживает только что начатая транзакция?

=> SELECT locktype, virtualxid AS virtxid, transactionid AS xid,
    mode, granted
FROM pg_locks
WHERE pid = 10981;
  locktype  | virtxid | xid |     mode      | granted
------------+---------+-----+---------------+---------
 virtualxid | 3/12    |     | ExclusiveLock | t
(1 row)

locktype — тип ресурса, mode — режим блокировки, granted — удалось ли получить блокировку. Каждой транзакции сразу выдается виртуальный номер, и транзакция удерживает его исключительную блокировку. Как только транзакция начинает менять какие-либо данные, ей выдается настоящий номер, который учитывается в правилах видимости. Номер можно получить и явно:

=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                816
(1 row)
=> SELECT locktype, virtualxid AS virtxid, transactionid AS xid,
    mode, granted
FROM pg_locks
WHERE pid = 10981;
   locktype    | virtxid | xid |     mode      | granted
---------------+---------+-----+---------------+---------
 virtualxid    | 3/12    |     | ExclusiveLock | t
 transactionid |         | 816 | ExclusiveLock | t
(2 rows)

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

Page 7: Блокировки отношений

Тип ресурса в pg\_locks

  • relation — таблицы, индексы и т. п.
  • Режимы
    • Access Share
    • SELECT
    • Row Share
    • SELECT FOR UPDATE/SHARE
    • Row Exclusive
    • UPDATE, DELETE, INSERT, MERGE
    • Share Update Exclusive
    • VACUUM, ALTER TABLE,
    • CREATE INDEX CONCURRENTLY
    • Share
    • CREATE INDEX
    • Share Row Exclusive
    • CREATE TRIGGER, ALTER TABLE
    • Exclusive
    • REFRESH MAT. VIEW CONCURRENTLY
    • Access Exclusive
    • DROP, TRUNCATE, REINDEX, VACUUM FULL,
    • LOCK TABLE, ALTER TABLE, REFRESH MAT. VIEW
    • допускают
    • параллельное
    • изменение
    • данных
    • в таблице

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

Второй важный случай блокировок объектов — блокировки отношений (таблиц, индексов, последовательностей и т. п.). Такие блокировки имеют тип relation в pg_locks.

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

https://postgrespro.ru/docs/postgresql/16/explicit-locking#LOCKING-TABLES

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

Самый слабый режим — Access Share, он захватывается командой SELECT и совместим с любым режимом, кроме самого сильного — Access Exclusive. Это означает, что запрос не мешает ни другим запросам, ни изменению данных в таблице, ни чему-либо другому, но не дает, например, удалить таблицу в то время, как из нее читаются данные.

Другой пример: режим Share (как и другие более сильные режимы) не совместим с изменением данных в таблице. Например, команда CREATE INDEX заблокирует команды INSERT, UPDATE, DELETE и MERGE (и наоборот). Поэтому существует команда CREATE INDEX CONCURRENTLY, использующая режим Share Update Exclusive, который совместим с такими изменениями (за счет этого команда выполняется дольше).

Page 8: Очередь ожидания

Source slide page 8: Очередь ожидания{#source-figure-008 caption=“Source slide page 8: Очередь ожидания”}

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

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

Пусть, как на рисунке, транзакция T100 выполняет команду UPDATE, при этом на отношение будет установлена блокировка с режимом Row Exclusive.

Транзакция T110, выполняющая CREATE INDEX, встает в очередь за T100, поскольку запрашивает режим Share, не совместимый с Row Exclusive. Если следом придет транзакция T120 с командой VACUUM FULL (ей нужен режим Access Exclusive, не совместимый ни с каким другим), она встанет в очередь за CREATE INDEX.

А еще одна транзакция с обычным запросом SELECT (требует Access Share, совместимый с выполняющейся сейчас командой UPDATE) тоже честно займет очередь за VACUUM FULL.

Page 9: Очередь ожидания

Source slide page 9: Очередь ожидания{#source-figure-009 caption=“Source slide page 9: Очередь ожидания”}

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

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

Page 10: Блокировка отношений

Создадим таблицу банковских счетов с тремя строками:

=> CREATE TABLE accounts(acc_no integer, amount numeric);
CREATE TABLE
=> INSERT INTO accounts VALUES (1,100.00), (2,200.00), (3,300.00);
INSERT 0 3

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

=> SELECT * FROM accounts;
 acc_no | amount
--------+--------
      1 | 100.00
      2 | 200.00
      3 | 300.00
(3 rows)

Как изменятся блокировки?

=> SELECT locktype, relation::regclass, virtualxid AS virtxid,
    transactionid AS xid, mode, granted
FROM pg_locks
WHERE pid = 10981;
   locktype    | relation | virtxid | xid |      mode       | granted
---------------+----------+---------+-----+-----------------+---------
 relation      | accounts |         |     | AccessShareLock | t
 virtualxid    |          | 3/12    |     | ExclusiveLock   | t
 transactionid |          |         | 816 | ExclusiveLock   | t
(3 rows)

Добавилась блокировка таблицы в режиме Access Share. Она совместима с блокировкой любого режима, кроме Access Exclusive, поэтому не мешает практически никаким операциям, но не дает, например, удалить таблицу. Попробуем.

=> \c arch_locks
You are now connected to database "arch_locks" as user "student".
=> BEGIN;
BEGIN
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          11421
(1 row)
=> DROP TABLE accounts;

Команда не выполняется — ждет освобождения блокировки. Какой?

=> SELECT locktype, relation::regclass, virtualxid AS virtxid,
    transactionid AS xid, mode, granted
FROM pg_locks
WHERE pid = 11421;
   locktype    | relation | virtxid | xid |        mode         | granted
---------------+----------+---------+-----+---------------------+---------
 virtualxid    |          | 4/5     |     | ExclusiveLock       | t
 relation      | accounts |         |     | AccessExclusiveLock | f
 transactionid |          |         | 819 | ExclusiveLock       | t
(3 rows)

Транзакция пыталась получить блокировку таблицы в режиме Access Exclusive, но не смогла (granted = f). Информацию о том, что транзакция ожидает чего-то для продолжения работы, можно получить и так:

Page 11: Page 11

=> SELECT state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE pid = 11421;
 state  | wait_event_type | wait_event
--------+-----------------+------------
 active | Lock            | relation
(1 row)

state — состояние: активная транзакция, выполняющая команду; wait_event_type — тип ожидания: блокировка (бывают и другие, например, ожидание ввода-вывода); wait_event — конкретное ожидание: ожидание блокировки отношения. Мы можем найти номер блокирующего процесса (в общем случае — несколько номеров)…

=> SELECT pg_blocking_pids(11421);
 pg_blocking_pids
------------------
 {10981}
(1 row)

…и посмотреть информацию о сеансах, к которым они относятся:

=> SELECT * FROM pg_stat_activity
WHERE pid = ANY(pg_blocking_pids(11421)) \gx
-[ RECORD 1 ]----+------------------------------
datid            | 16566
datname          | arch_locks
pid              | 10981
leader_pid       |
usesysid         | 16384
usename          | student
application_name | psql
client_addr      |
client_hostname  |
client_port      | -1
backend_start    | 2026-04-10 16:02:24.978483+03
xact_start       | 2026-04-10 16:02:25.07702+03
query_start      | 2026-04-10 16:02:25.958195+03
state_change     | 2026-04-10 16:02:25.963636+03
wait_event_type  | Client
wait_event       | ClientRead
state            | idle in transaction
backend_xid      | 816
backend_xmin     |
query_id         |
query            | SELECT * FROM accounts;
backend_type     | client backend

После завершения транзакции все блокировки снимаются и таблица удаляется:

=> COMMIT;
COMMIT
DROP TABLE
=> COMMIT;
COMMIT

Page 12: Блокировки строк

Разделяемые и исключительные блокировки

  • «Нечестная» очередь ожидания

Page 13: Блокировки строк

Используются совместно с многоверсионностью, для чтения данных не требуются

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

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

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

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

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

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

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

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

Page 14: Блокировки строк

Source slide page 14: Блокировки строк{#source-figure-014 caption=“Source slide page 14: Блокировки строк”}

Режимы

  • Update — удаление строки или изменение всех полей
  • No Key Update — изменение любых полей, кроме ключевых
  • Share — запрет изменения любых полей строки
  • Key Share — запрет изменения ключевых полей строки
  • Key Share
  • Share
  • No Key Update
  • Update

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

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

Два режима представляют исключительные (exclusive) блокировки, которые одновременно может удерживать только одна транзакция. Режим UPDATE предполагает полное изменение (или удаление) строки, а режим NO KEY UPDATE — изменение только тех полей, которые не входят в уникальные индексы (иными словами, при таком изменении все внешние ключи остаются без изменений). Команда UPDATE сама выбирает минимальный подходящий режим блокировки; обычно строки блокируются в режиме NO KEY UPDATE.

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

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

Напомним, что обычное чтение (SELECT) вообще не блокирует строки.

Матрица совместимости режимов приведена внизу слайда. Из нее видно, что разделяемый режим KEY SHARE совместим с исключительным режимом NO KEY UPDATE (то есть можно обновлять неключевые поля и быть уверенным в том, что ключ не изменится).

https://postgrespro.ru/docs/postgresql/16/explicit-locking#LOCKING-ROWS

Page 15: Блокировка строк

Source slide page 15: Блокировка строк{#source-figure-015 caption=“Source slide page 15: Блокировка строк”}

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

=> CREATE TABLE accounts(acc_no integer PRIMARY KEY, amount numeric);
CREATE TABLE
=> INSERT INTO accounts VALUES (1,100.00), (2,200.00), (3,300.00);
INSERT 0 3

В новой транзакции обновим сумму первого счета (при этом ключ не меняется):

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
UPDATE 1
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                822
(1 row)

Как правило, признаком блокировки строки служит номер блокирующей транзакции, записанный в поле xmax (и еще ряд информационных битов, определяющих режим блокировки):

=> SELECT xmax, * FROM accounts;
 xmax | acc_no | amount
------+--------+--------
  822 |      1 | 100.00
    0 |      2 | 200.00
    0 |      3 | 300.00
(3 rows)

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

=> CREATE EXTENSION pgrowlocks;
CREATE EXTENSION
=> SELECT locked_row, xids, modes, pids FROM pgrowlocks('accounts');
 locked_row | xids  |       modes       |  pids
------------+-------+-------------------+---------
 (0,1)      | {822} | {"No Key Update"} | {10981}
(1 row)

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

=> UPDATE accounts SET acc_no = 20 WHERE acc_no = 2;
UPDATE 1
=> SELECT locked_row, xids, modes, pids FROM pgrowlocks('accounts');
 locked_row | xids  |       modes       |  pids
------------+-------+-------------------+---------
 (0,1)      | {822} | {"No Key Update"} | {10981}
 (0,2)      | {822} | {Update}          | {10981}
(2 rows)

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

Page 16: Page 16

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

=> BEGIN;
BEGIN
=> SELECT * FROM accounts WHERE acc_no = 1 FOR KEY SHARE;
 acc_no | amount
--------+--------
      1 | 100.00
(1 row)
=> SELECT * FROM accounts WHERE acc_no = 3 FOR SHARE;
 acc_no | amount
--------+--------
      3 | 300.00
(1 row)
=> SELECT pg_current_xact_id();
 pg_current_xact_id
--------------------
                824
(1 row)
=> SELECT locked_row, xids, modes, pids FROM pgrowlocks('accounts');
 locked_row |   xids    |             modes             |     pids
------------+-----------+-------------------------------+---------------
 (0,1)      | {822,824} | {"No Key Update","Key Share"} | {10981,11421}
 (0,2)      | {822}     | {Update}                      | {10981}
 (0,3)      | {824}     | {"For Share"}                 | {11421}
(3 rows)
=> ROLLBACK;
ROLLBACK
=> ROLLBACK;
ROLLBACK

Как не ждать блокировку?

Иногда удобно не ждать освобождения блокировки, а сразу получить ошибку, если необходимый ресурс занят. Приложение может перехватить и обработать такую ошибку. Для этого ряд команд SQL (такие, как SELECT и некоторые варианты ALTER) позволяют указать ключевое слово NOWAIT. Заблокируем таблицу, обновив первую строку:

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount + 1 WHERE acc_no = 1;
UPDATE 1
=> BEGIN;
BEGIN
=> LOCK TABLE accounts NOWAIT; -- IN ACCESS EXCLUSIVE MODE
ERROR:  could not obtain lock on relation "accounts"

Транзакция сразу же получает ошибку.

=> ROLLBACK;
ROLLBACK

Команды UPDATE и DELETE не позволяют указать NOWAIT. Но можно сначала выполнить команду

SELECT ... FOR UPDATE NOWAIT; -- или FOR NO KEY UPDATE NOWAIT

а затем, если строки успешно заблокированы, изменить или удалить их. Например:

=> BEGIN;

Page 17: Page 17

Source slide page 17: Page 17{#source-figure-017 caption=“Source slide page 17: Page 17”}

BEGIN
=> SELECT * FROM accounts WHERE acc_no = 1 FOR UPDATE NOWAIT;
ERROR:  could not obtain lock on row in relation "accounts"

Снова тут же получаем ошибку - строка уже заблокирована. Но при успехе SELECT … FOR UPDATE в этой транзакции можно было бы далее изменять заблокированную строку.

=> ROLLBACK;
ROLLBACK

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

=> BEGIN;
BEGIN
=> SELECT * FROM accounts ORDER BY acc_no
FOR UPDATE SKIP LOCKED LIMIT 1;
 acc_no | amount
--------+--------
      2 | 200.00
(1 row)

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

=> ROLLBACK;
ROLLBACK

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

=> SET lock_timeout = '1s';
SET
=> ALTER TABLE accounts DROP COLUMN amount;
ERROR:  canceling statement due to lock timeout

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

=> RESET lock_timeout;
RESET
=> ROLLBACK;
ROLLBACK

Page 18: «Очередь» ожидания

Source slide page 18: «Очередь» ожидания{#source-figure-018 caption=“Source slide page 18: «Очередь» ожидания”}

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

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

Допустим, транзакции с номером 101 удалось заблокировать строку в разделяемом режиме Share. Информация о блокировке записывается в заголовок версии строки — в поле xmax появляется номер этой транзакции.

Page 19: «Очередь» ожидания

Source slide page 19: «Очередь» ожидания{#source-figure-019 caption=“Source slide page 19: «Очередь» ожидания”}

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

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

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

Кроме того, ожидающая транзакция удерживает блокировку условного ресурса типа tuple (версия строки), показывая, что она первая в «очереди».

Page 20: «Очередь» ожидания

Source slide page 20: «Очередь» ожидания{#source-figure-020 caption=“Source slide page 20: «Очередь» ожидания”}

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

Если появляются другие транзакции, конфликтующие с текущей блокировкой версии строки (в нашем примере — 112, 122, 132), первым делом они пытаются захватить блокировку типа tuple для этой версии.

Поскольку блокировка tuple уже удерживается транзакцией 102, другие транзакции ждут освобождения этой блокировки. Получается своеобразная «очередь», в которой есть первый и все остальные.

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

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

Page 21: «Очередь» ожидания

Source slide page 21: «Очередь» ожидания{#source-figure-021 caption=“Source slide page 21: «Очередь» ожидания”}

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

В нашем примере транзакция 101 заблокировала строку в разделяемом (Share) режиме. Пока транзакция 102 ожидает завершения транзакции 101, может появиться еще транзакция 133, желающая получить блокировку строки в разделяемом режиме, совместимом с текущей блокировкой транзакции 101.

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

Page 22: «Очередь» ожидания

Source slide page 22: «Очередь» ожидания{#source-figure-022 caption=“Source slide page 22: «Очередь» ожидания”}

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

Когда транзакция 101 завершится, транзакция 102 будет разбужена, но не сможет захватить блокировку строки, поскольку строка все еще заблокирована транзакцией 133. Транзакция 102 будет вынуждена снова «заснуть».

PostgreSQL использует разделяемые блокировки для внутренних нужд, в частности, при проверке внешних ключей. В прикладном коде можно явно запросить разделяемую блокировку с помощью команды SELECT … FOR SHARE или SELECT … FOR KEY SHARE.

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

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

https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/acc ess/heap/README.tuplock;hb=HEAD

Page 23: «Очередь» ожидания

Source slide page 23: «Очередь» ожидания{#source-figure-023 caption=“Source slide page 23: «Очередь» ожидания”}

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

Когда транзакция 133 завершится, транзакция 102 получит возможность первой записать свой номер в поле xmax, после чего она освободит блокировку tuple. Тогда одна случайная транзакция из всех остальных успеет захватить блокировку tuple и станет первой в «очереди».

Page 24: «Очередь» ожидания

Начинаем транзакцию и обновляем строку.

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
UPDATE 1

Будем смотреть только на блокировки, связанные с номерами транзакций и версиями строк:

=> SELECT pid, locktype, page, tuple, transactionid AS xid,
    mode, granted
FROM pg_locks WHERE locktype IN ('transactionid','tuple')
ORDER BY pid, granted DESC, locktype;
  pid  |   locktype    | page | tuple | xid |     mode      | granted
-------+---------------+------+-------+-----+---------------+---------
 10935 | transactionid |      |       | 829 | ExclusiveLock | t
(1 row)

Другая транзакция пытается обновить ту же строку:

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;

Какие при этом возникают блокировки?

=> SELECT pid, locktype, page, tuple, transactionid AS xid,
    mode, granted
FROM pg_locks WHERE locktype IN ('transactionid','tuple')
ORDER BY pid, granted DESC, locktype;
  pid  |   locktype    | page | tuple | xid |     mode      | granted
-------+---------------+------+-------+-----+---------------+---------
 10935 | transactionid |      |       | 829 | ExclusiveLock | t
 10981 | transactionid |      |       | 830 | ExclusiveLock | t
 10981 | tuple         |    0 |     1 |     | ExclusiveLock | t
 10981 | transactionid |      |       | 829 | ShareLock     | f
(4 rows)

Транзакция захватила блокировку, связанную с версией строки 1 на странице 0, и ждет завершения первой транзакции:

=> SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity
WHERE pid IN (10935,10981) ORDER BY pid;
  pid  | pg_blocking_pids
-------+------------------
 10935 | {}
 10981 | {10935}
(2 rows)

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

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;

Что увидим в pg_locks на этот раз?

=> SELECT pid, locktype, page, tuple, transactionid AS xid,
    mode, granted
FROM pg_locks WHERE locktype IN ('transactionid','tuple')
ORDER BY pid, granted DESC, locktype;

Page 25: Page 25

  pid  |   locktype    | page | tuple | xid |     mode      | granted
-------+---------------+------+-------+-----+---------------+---------
 10935 | transactionid |      |       | 829 | ExclusiveLock | t
 10981 | transactionid |      |       | 830 | ExclusiveLock | t
 10981 | tuple         |    0 |     1 |     | ExclusiveLock | t
 10981 | transactionid |      |       | 829 | ShareLock     | f
 11421 | transactionid |      |       | 831 | ExclusiveLock | t
 11421 | tuple         |    0 |     1 |     | ExclusiveLock | f
(6 rows)

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

=> SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity
WHERE pid IN (10935,10981,11421) ORDER BY pid;
  pid  | pg_blocking_pids
-------+------------------
 10935 | {}
 10981 | {10935}
 11421 | {10981}
(3 rows)

Если теперь первая транзакция успешно завершается…

=> COMMIT;
COMMIT
UPDATE 1

…выполняется UPDATE и первая версия становится неактуальной.

=> SELECT pid, locktype, page, tuple, transactionid AS xid,
    mode, granted
FROM pg_locks WHERE locktype IN ('transactionid','tuple')
ORDER BY pid, granted DESC, locktype;
  pid  |   locktype    | page | tuple | xid |     mode      | granted
-------+---------------+------+-------+-----+---------------+---------
 10981 | transactionid |      |       | 830 | ExclusiveLock | t
 11421 | transactionid |      |       | 831 | ExclusiveLock | t
 11421 | transactionid |      |       | 830 | ShareLock     | f
(3 rows)

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

=> SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity
WHERE pid IN (10981,11421) ORDER BY pid;
  pid  | pg_blocking_pids
-------+------------------
 10981 | {}
 11421 | {10981}
(2 rows)
=> COMMIT;
COMMIT
UPDATE 1
=> COMMIT;
COMMIT

Page 26: Итоги

Блокировки отношений и других объектов БД используются для организации конкурентного доступа к общим ресурсам

  • хранятся в разделяемой памяти сервера
  • имеется механизм очередей
  • Блокировки строк реализованы иначе
    • хранятся в страницах данных из-за потенциально большого количества
    • используют блокировки уровня объектов для организации «очереди»

Page 27: Практика

  1. Какие блокировки на уровне изоляции Read Committed удерживает транзакция, прочитавшая одну строку таблицы по первичному ключу? Проверьте на практике. 2. Посмотрите, как в представлении pg_locks отображаются рекомендательные блокировки. 3. Убедитесь на практике, что проверка внешнего ключа и обновление строки могут выполняться одновременно. Изучите возникающие при этом блокировки уровня строки. 4. Воспроизведите ситуацию взаимоблокировки двух транзакций и проверьте, как она обрабатывается сервером.

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

  1. Рекомендательные блокировки упоминались в теме «Обработка ошибок» курса DEV1.

https://postgrespro.ru/docs/postgresql/16/functions-admin#FUNCTIONS-AD VISORY-LOCKS

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

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

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

  1. Взаимоблокировка двух транзакций возникает, когда

● первая транзакция удерживает блокировку объектов, необходимых второй транзакции для продолжения работы,

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

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

Page 28: 1. Блокировки при чтении строки по первичному ключу

=> CREATE DATABASE arch_locks;
CREATE DATABASE
=> \c arch_locks
You are now connected to database "arch_locks" as user "student".
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          56563
(1 row)

Создадим таблицу как в демонстрации:

=> CREATE TABLE accounts(acc_no integer PRIMARY KEY, amount numeric);
CREATE TABLE
=> INSERT INTO accounts VALUES (1,100.00), (2,200.00), (3,300.00);
INSERT 0 3

Прочитаем строку, начав транзакцию:

=> \c arch_locks
You are now connected to database "arch_locks" as user "student".
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          56707
(1 row)
=> BEGIN;
BEGIN
=> SELECT * FROM accounts WHERE acc_no = 1;
 acc_no | amount
--------+--------
      1 | 100.00
(1 row)

Блокировки включают блокировку индекса, поддерживающего ограничение первичного ключа, в режиме Access Share:

=> SELECT locktype, relation::REGCLASS, virtualxid AS virtxid,
    transactionid AS xid, mode, granted
FROM pg_locks
WHERE pid = 56707;
  locktype  |   relation    | virtxid | xid |      mode       | granted
------------+---------------+---------+-----+-----------------+---------
 relation   | accounts_pkey |         |     | AccessShareLock | t
 relation   | accounts      |         |     | AccessShareLock | t
 virtualxid |               | 3/15    |     | ExclusiveLock   | t
(3 rows)
=> COMMIT;
COMMIT

2. Рекомендательные блокировки

Захватим рекомендательную блокировку уровня сеанса:

=> BEGIN;
BEGIN

Page 29: Page 29

=> SELECT pg_advisory_lock(42);
 pg_advisory_lock
------------------
(1 row)

Блокировка:

=> SELECT locktype, virtualxid AS virtxid, objid, mode, granted
FROM pg_locks
WHERE pid = 56707;
  locktype  | virtxid | objid |     mode      | granted
------------+---------+-------+---------------+---------
 virtualxid | 3/16    |       | ExclusiveLock | t
 advisory   |         |    42 | ExclusiveLock | t
(2 rows)

Идентификатор ресурса для блокировки типа advisory отображается в столбце objid.

=> COMMIT;
COMMIT

3. Проверка внешнего ключа

Нам понадобится расширение для анализа блокировок на уровне строк:

=> CREATE EXTENSION pgrowlocks;
CREATE EXTENSION

Создадим таблицу клиентов:

=> CREATE TABLE clients(
    client_id integer PRIMARY KEY,
    name text
);
CREATE TABLE
=> INSERT INTO clients VALUES (10,'alice'), (20,'bob');
INSERT 0 2

В таблицу счетов добавим столбец для идентификатора клиента и внешний ключ:

=> ALTER TABLE accounts
    ADD client_id integer REFERENCES clients(client_id);
ALTER TABLE

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

=> BEGIN;
BEGIN
=> INSERT INTO accounts(acc_no, amount, client_id)
    VALUES (4,400.00,20);
INSERT 0 1

Проверка внешнего ключа приводит к появлению блокировки строки в таблице клиентов в режиме KeyShare:

=> SELECT * FROM pgrowlocks('clients') \gx
-[ RECORD 1 ]-----------------
locked_row | (0,2)
locker     | 822
multi      | f
xids       | {822}
modes      | {"For Key Share"}
pids       | {56707}

Это не мешает изменять неключевые столбцы этой строки:

=> UPDATE clients SET name = 'brian' WHERE client_id = 20;
UPDATE 1

Page 30: 4. Взаимоблокировка двух транзакций

=> COMMIT;
COMMIT

Обычная причина возникновения взаимоблокировок — разный порядок блокирования строк таблиц в приложении. Первая транзакция намерена перенести 100 рублей с первого счета на второй. Для этого она сначала уменьшает первый счет:

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount - 100.00 WHERE acc_no = 1;
UPDATE 1

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

=> BEGIN;
BEGIN
=> UPDATE accounts SET amount = amount - 10.00 WHERE acc_no = 2;
UPDATE 1

Теперь первая транзакция пытается увеличить второй счет…

=> UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 2;

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

=> UPDATE accounts SET amount = amount + 10.00 WHERE acc_no = 1;

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

ERROR:  deadlock detected
DETAIL:  Process 56707 waits for ShareLock on transaction 824; blocked by process 56563.
Process 56563 waits for ShareLock on transaction 825; blocked by process 56707.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,1) in relation "accounts"
UPDATE 1
=> COMMIT;
COMMIT
=> COMMIT;
ROLLBACK

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

06 Занятие 06: Учебное приложение / Книжный магазин 2.0

Исходная страница в нативном Markdown: Учебное приложение / Книжный магазин 2.0
Примечание

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

Page 1: Учебное приложение / Книжный магазин 2.0

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

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

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

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

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

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

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

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

Page 2: Темы

  • Обзор приложения «Книжный магазин 2.0»
  • Схема данных
  • Интерфейс с клиентской частью
  • Разграничение доступа

Page 3: Книжный магазин

Интернет-магазин для покупателей

  • поиск книг
  • детальная информация о выбранной книге
  • корзина для зарегистрированных пользователей
  • «Админка» для сотрудников
    • заказ книг у поставщика
    • установка розничной цены
    • фоновые задания: запуск, просмотр результатов

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

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

Как и раньше, клиентская часть состоит из интернет-магазина и «админки». Однако со времен DEV1 магазин вырос и число покупателей увеличилось.

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

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

Зато добавилась возможность запускать фоновые задания (например, отчеты) и просматривать их результаты после завершения.

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

Page 4: Приложение

Source slide page 4: Приложение{#source-figure-004 caption=“Source slide page 4: Приложение”}

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

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

● «Книжный магазин» — это интерфейс веб-пользователя, в котором он может просматривать и покупать книги.

● «Админка» — интерфейс сотрудников магазина, в котором они могут заказывать книги и устанавливать их цену, а также выполнять фоновые задания.

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

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

Исходный код приложения не является темой курса, но может быть получен в git-репозитории https://pubgit.postgrespro.ru/pub/dev2app.git

Page 5: Демонстрация приложения

В этой демонстрации мы показываем приложение «Книжный магазин 2.0» в том виде, в котором оно будет после завершения всех практических заданий. Приложение доступно в браузере виртуальной машины курса по адресу http://localhost/ Открываем http://localhost…

student$ xdg-open http://localhost

Page 6: Основные сущности

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

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

Основные сущности нашей базы данных практически не изменились со времен курса DEV1. Это:

● Книга. К книгам добавились атрибуты.

● Автор. Книги и авторы связаны отношением многие-ко-многим (авторство).

● Операции с книгами: покупки в магазине и поступления на склад. К атрибутам добавилась цена книги.

Новые сущности:

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

● Пользователь веб-магазина. Определяется именем (логином) и имеет почтовый адрес.

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

● Сеанс работы пользователя с магазином. Сеанс связан с вопросами аутентификации и разграничения доступа.

Page 7: Основные таблицы

Source slide page 7: Основные таблицы{#source-figure-007 caption=“Source slide page 7: Основные таблицы”}

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

Основные таблицы базы данных представлены на слайде.

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

Связи «многие ко многим» представлены дополнительными таблицами:

● authorships — авторство;

● cart_items — книги в корзине.

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

С расчетом рейтинга книг (rating) связаны еще два столбца: голоса пользователей «за» (votes_up) и «против» (votes_down).

Page 8: Интерфейс с клиентом

Source slide page 8: Интерфейс с клиентом{#source-figure-008 caption=“Source slide page 8: Интерфейс с клиентом”}

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

Клиентский API серверной части полностью построен на функциях.

Интерфейсные функции веб-магазина доступны только пользователю web и размещены в схеме webapi:

● аутентификация и разграничение доступа;

● работа с каталогом книг;

● операции с корзиной.

Интерфейсные функции админки доступны только пользователю emp и размещены в схеме empapi:

● работа с каталогом книг;

● фоновые задания.

Аутентификация в админке не предусмотрена.

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

Интерфейсные функции объявлены как SECURITY DEFINER, чтобы иметь доступ к объектам базы данных. С помощью механизма привилегий по умолчанию (ALTER DEFAULT PRIVILEGES) доступ к функциям в схеме webapi автоматически выдается только пользователю web, в схеме empapi — пользователю emp, а доступ к функциям в схеме public отбирается у роли public.

(Вопросы разграничения доступа рассматриваются в курсе DEV1.)

Сейчас мы рассмотрим интерфейс подробнее, но обратите внимание, что информационная панель веб-клиента показывает, какие вызовы он отправляет на сервер.

Page 9: Аутентификация

Source slide page 9: Аутентификация{#source-figure-009 caption=“Source slide page 9: Аутентификация”}

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

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

Новый пользователь регистрируется вызовом register_user. Для простоты мы не используем пароли (но если бы использовали, то хеш пароля хранился бы в таблице users).

Чтобы иметь возможность совершать покупки, пользователь должен войти в систему. Это выполняет функция login. Она создает сеанс пользователя, который определяется токеном (UUID).

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

Наконец, вызов logout завершает сеанс.

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

Page 10: Аутентификация

=> \c bookstore2
You are now connected to database "bookstore2" as user "student".

У нас уже есть два зарегистрированных пользователя:

=> SELECT * FROM users;
 user_id | username |      email
---------+----------+-----------------
       1 | alice    | alice@localhost
       2 | bob      | bob@localhost
(2 rows)

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

=> SELECT webapi.register_user('charlie','charlie@localhost');
 register_user
---------------
(1 row)

Пользователь входит в систему и получает токен:

=> SELECT webapi.login('charlie');
                login
--------------------------------------
 983e080e-9d3b-4631-90e9-24220550eea9
(1 row)

При этом в базе появляется сеанс:

=> SELECT * FROM sessions;
              auth_token              | user_id
--------------------------------------+---------
 947289f6-cf12-4930-adf3-5b46b85dac9a |       1
 983e080e-9d3b-4631-90e9-24220550eea9 |       3
(2 rows)

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

=> SELECT username
FROM users
WHERE user_id = check_auth('00000000-0000-0000-0000-000000000000');
ERROR:  query returned no rows
CONTEXT:  PL/pgSQL function check_auth(uuid) line 5 at SQL statement
=> SELECT username
FROM users
WHERE user_id = check_auth('983e080e-9d3b-4631-90e9-24220550eea9');
 username
----------
 charlie
(1 row)

Page 11: Каталог книг

Source slide page 11: Каталог книг{#source-figure-011 caption=“Source slide page 11: Каталог книг”}

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

Список книг (конечно, вместе с авторами, хотя эти таблицы не показаны на слайде для краткости) нужен как магазину, так и админке — но с разным набором полей. Всю возможную информацию выбирает закрытая функция public.get_catalog, получая на вход параметры поиска. Функции webapi.get_catalog и empapi.get_catalog реализованы как обертки вокруг public.get_catalog.

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

Голосование «за» или «против» книги выполняется функцией webapi.cast_vote. Функция допускает голосование несколько раз, но работает только для зарегистрированных пользователей.

Заказ книги у поставщика в админке выполняет функция empapi.receipt. Она создает соответствующую операцию с книгой.

За установку розничной цены на книгу отвечает функция empapi.set_retail_price. Текущую цену возвращает функция public.get_retail_price, которую клиент никогда не вызывает напрямую, но она используется во многих интерфейсных функциях.

Page 12: Каталог книг

Информацию о книгах клиент получает функцией get_catalog. Например, для интернет-магазина:

=> SELECT book_id, title, authors_list, format, rating, price
FROM webapi.get_catalog('рефакторинг','rating','asc') \gx
-[ RECORD 1 ]+--------------------------------------------------
book_id      | 6
title        | Рефакторинг SQL-приложений
authors_list | {"(7,Фаро,Стефан,\"\")","(8,Лерми,Паскаль,\"\")"}
format       | 70x100/16
rating       | 0
price        | 980
-[ RECORD 2 ]+--------------------------------------------------
book_id      | 14
title        | Чистый код: создание, анализ и
рефакторинг
authors_list | {"(18,Мартин,Роберт,\"\")"}
format       | 70x100/16
rating       | 0
price        | 1350

Установим розничную цену для одной книги:

=> SELECT empapi.set_retail_price(6, 1000.00, now());
 set_retail_price
------------------
(1 row)
=> SELECT book_id, price
FROM webapi.get_catalog('рефакторинг','rating','asc');
 book_id |  price
---------+---------
       6 | 1000.00
      14 |    1350
(2 rows)

Поступление 50 книг по 100 ₽ на склад:

=> SELECT empapi.receipt(6, 50, 100.00);
 receipt
---------
(1 row)

Этот вызов создает соответствующую операцию:

=> SELECT * FROM operations
WHERE book_id = 6
ORDER BY operation_id DESC
LIMIT 1 \gx
-[ RECORD 1 ]+------------------------------
operation_id | 34675
book_id      | 6
qty          | 50
price        | 100.00
at           | 2026-04-10 16:03:51.658559+03

Пользователь может голосовать за книгу:

=> SELECT webapi.cast_vote('983e080e-9d3b-4631-90e9-24220550eea9',6,+1);
 cast_vote
-----------
(1 row)

Page 13: Page 13

=> SELECT book_id, votes_up, votes_down
FROM webapi.get_catalog('рефакторинг','rating','asc');
 book_id | votes_up | votes_down
---------+----------+------------
       6 |        1 |          0
      14 |        0 |          0
(2 rows)

Page 14: Корзина

Source slide page 14: Корзина{#source-figure-014 caption=“Source slide page 14: Корзина”}

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

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

Функция webapi.add_to_cart добавляет в корзину одну книгу или убирает из корзины одну книгу. Функция webapi.remove_from_cart полностью удаляет всю позицию из корзины.

Функция webapi.get_cart возвращает содержимое корзины.

Функция webapi.checkout совершает покупку, убирая позиции из корзины и создавая соответствующие операции с книгами.

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

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

Page 15: Корзина

Положим книги в корзину:

=> SELECT webapi.add_to_cart(
    auth_token => '983e080e-9d3b-4631-90e9-24220550eea9',
    book_id => 6,
    qty => +1 -- по умолчанию
);
 add_to_cart
-------------
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '983e080e-9d3b-4631-90e9-24220550eea9',
    book_id => 6
);
 add_to_cart
-------------
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '983e080e-9d3b-4631-90e9-24220550eea9',
    book_id => 3
);
 add_to_cart
-------------
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '983e080e-9d3b-4631-90e9-24220550eea9',
    book_id => 1
);
 add_to_cart
-------------
(1 row)

Вот что у нас в корзине:

=> SELECT *
FROM webapi.get_cart('983e080e-9d3b-4631-90e9-24220550eea9') \gx
-[ RECORD 1 ]+----------------------------------------------------------------------
book_id      | 1
title        | Основы технологий баз данных
authors_list |
{"(1,Новиков,Борис,Асенович)","(2,Горшкова,Екатерина,
Александровна)"}
qty          | 1
onhand_qty   | 34
price        | 640
-[ RECORD 2 ]+----------------------------------------------------------------------
book_id      | 3
title        | PostgreSQL: Основы языка SQL
authors_list | {"(4,Моргунов,Евгений,Павлович)"}
qty          | 1
onhand_qty   | 80
price        | 660
-[ RECORD 3 ]+----------------------------------------------------------------------
book_id      | 6
title        | Рефакторинг SQL-приложений
authors_list | {"(7,Фаро,Стефан,\"\")","(8,Лерми,Паскаль,\"\")"}
qty          | 2
onhand_qty   | 90
price        | 1000.00

Page 16: Page 16

Уберем одну книгу:

=> SELECT webapi.remove_from_cart(
    auth_token => '983e080e-9d3b-4631-90e9-24220550eea9',
    book_id => 1
);
 remove_from_cart
------------------
(1 row)

И совершим покупку:

=> SELECT * FROM webapi.checkout('983e080e-9d3b-4631-90e9-24220550eea9');
 checkout
----------
(1 row)

Что осталось в корзине?

=> SELECT book_id, title, qty, onhand_qty, price
FROM webapi.get_cart('983e080e-9d3b-4631-90e9-24220550eea9') \gx
(0 rows)

Конечно, ничего. Зато появились операции покупки:

=> SELECT * FROM operations
ORDER BY operation_id DESC
LIMIT 2 \gx
-[ RECORD 1 ]+------------------------------
operation_id | 34677
book_id      | 3
qty          | -1
price        | 660
at           | 2026-04-10 16:03:53.843617+03
-[ RECORD 2 ]+------------------------------
operation_id | 34676
book_id      | 6
qty          | -2
price        | 1000.00
at           | 2026-04-10 16:03:53.843617+03

Page 17: Фоновые задания

Source slide page 17: Фоновые задания{#source-figure-017 caption=“Source slide page 17: Фоновые задания”}

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

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

● programs — программы, которые можно запускать. У программы есть название и имя «исполняемой» функции.

● tasks — собственно задания, то есть экземпляры программ. Задание имеет статус (запланировано к запуску, работает и т. п.), значения параметров, дату запуска и окончания работы, результат выполнения и другие.

Программа регистрируется функцией public.register_program. Клиент не вызывает эту функцию, поэтому она находится в схеме public.

Задание ставится на выполнение функцией empapi.run_program.

Несколько функций предназначены для получения информации:

● о списке программ — empapi.get_programs;

● о списке заданий — empapi.get_tasks;

● о результате выполнения — empapi.task_results.

Page 18: Фоновые задания

Список зарегистрированных программ, которые можно выполнять как фоновые задания:

=> SELECT empapi.get_programs();
                 get_programs
----------------------------------------------
 (2,"Отправка письма",sendmail_task)
 (3,"Отчет по складским остаткам",stock_task)
 (1,Приветствие,greeting_task)
(3 rows)

Список фоновых заданий:

=> SELECT * FROM empapi.get_tasks() \gx
-[ RECORD 1 ]-----------------------------
task_id    | 2
program_id | 2
name       | Отправка письма
host       |
port       |
started    | 2026-04-10 16:03:53.062561+03
finished   | 2026-04-10 16:03:54.06841+03
status     | finished
-[ RECORD 2 ]-----------------------------
task_id    | 1
program_id | 1
name       | Приветствие
host       |
port       |
started    | 2026-04-10 16:03:10.686018+03
finished   | 2026-04-10 16:03:11.70945+03
status     | finished

Поставим в очередь на выполнение еще одно задание «Приветствие»:

=> SELECT empapi.run_program(1);
 run_program
-------------
           3
(1 row)

В ответ получаем номер задания. Немного подождем… Проверим статус задания:

=> SELECT status FROM empapi.get_tasks() WHERE task_id = 3;
  status
----------
 finished
(1 row)

Задание завершено. Получим результат:

=> SELECT * FROM empapi.task_results(3);
   task_results
-------------------
 num    greeting  +
 --- -------------+
 1   Hello, world!+
 2   Hello, world!+
 3   Hello, world!+
(1 row)

Мы вернемся к фоновым заданиям позже в теме «Асинхронная обработка».

Page 19: Page 19

Page 20: Итоги

Часть рассмотренного функционала еще только предстоит реализовать

  • Прежде чем вносить изменения в приложение, необходимо разобраться в том, как оно устроено

Page 21: Практика

Здесь и далее все практические задания, связанные с приложением, выполняются в базе данных bookstore2. 1. Сеанс удаляется, если пользователь выходит из системы, но если просто закрыть вкладку браузера, сеансы будут накапливаться. Реализуйте автоматическое удаление прошлых сеансов пользователя при повторном входе. 2. Реализуйте отсутствующую интерфейсную функцию webapi.add_to_cart. Функция должна работать только для пользователей, вошедших в систему. Если книга присутствует в корзине, количество ее экземпляров не может быть меньше одного. Проверьте результат в приложении.

  • Какое обновление таблицы cart_items выполняется при
  • изменении количества книг — обычное или HOT?

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

База данных bookstore2 уже создана. При необходимости ее можно пересоздать с помощью скрипта bookstore2.sql в подкаталоге dev2 домашнего каталога.

  1. Внесите изменение в функцию webapi.login: перед тем как создавать новый сеанс, закройте существующие сеансы этого пользователя, используя функцию webapi.logout.

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

  1. Функция webapi.add_to_cart присутствует в базе, но пуста. Сохраните ее сигнатуру без изменений. Функция принимает параметры:

● auth_token — токен;

● book_id — идентификатор книги;

● qty — количество (может быть +1 или −1, другие значения считаются некорректными).

Если qty = +1, надо либо добавить экземпляр книги в корзину (если такой книги еще нет в корзине), либо увеличить количество экземпляров на единицу.

Если qty = −1, надо уменьшить количество экземпляров книги в корзине на единицу. При этом количество не должно быть меньше 1 (чтобы полностью удалить книгу из корзины, используется другая функция — webapi.remove_from_cart).

Тип обновления можно узнать в таблице pg_stat_all_tables (вспомните тему «Архитектура. Многоверсионность» ).

Page 22: 1. Удаление сеансов

Добавим в функцию входа удаление существующих сеансов:

=> CREATE OR REPLACE FUNCTION webapi.login(username text) RETURNS uuid
AS $$
DECLARE
    auth_token uuid;
    sessions record;
BEGIN
    -- сначала завершим все открытые сеансы
    FOR sessions IN
        SELECT s.auth_token
        FROM sessions s
            JOIN users u ON u.user_id = s.user_id
        WHERE u.username = login.username
    LOOP
        PERFORM webapi.logout(sessions.auth_token);
    END LOOP;
    -- новый сеанс
    INSERT INTO sessions AS s(auth_token, user_id)
        SELECT gen_random_uuid(), u.user_id
        FROM users u
        WHERE u.username = login.username
    RETURNING s.auth_token
        INTO STRICT auth_token; -- ошибка, если пользователя нет
    RETURN auth_token;
END;
$$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
CREATE FUNCTION

2. Функция добавления в корзину

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

=> ALTER TABLE public.cart_items ADD CHECK (qty > 0);
ALTER TABLE

Затем определим функцию add_to_cart. Чтобы не проверять, существует ли книга в корзине, воспользуемся командой INSERT ON CONFLICT.

Page 23: Page 23

=> CREATE OR REPLACE FUNCTION webapi.add_to_cart(
    auth_token uuid,
    book_id bigint,
    qty integer DEFAULT 1
) RETURNS void
AS $$
<<local>>
DECLARE
    user_id bigint;
BEGIN
    user_id := check_auth(auth_token);
    IF qty = 1 THEN
        INSERT INTO cart_items(
            user_id,
            book_id,
            qty
        )
        VALUES (
            user_id,
            book_id,
            1
        )
        ON CONFLICT ON CONSTRAINT cart_items_pkey
            DO UPDATE SET qty = cart_items.qty + 1
        ;
    ELSIF qty = -1 THEN
        UPDATE cart_items ci
        SET qty = ci.qty - 1
        WHERE ci.user_id = local.user_id
            AND ci.book_id = add_to_cart.book_id
        ;
    ELSE
        RAISE EXCEPTION 'qty = %, должно быть 1 или -1', qty;
    END IF;
END;
$$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
CREATE FUNCTION

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

=> SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables
WHERE relid = 'cart_items'::regclass;
 n_tup_upd | n_tup_hot_upd
-----------+---------------
         0 |             0
(1 row)
=> SELECT webapi.login('alice');
                login
--------------------------------------
 248ee101-3229-4b61-8d11-d76ac1a5e400
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '248ee101-3229-4b61-8d11-d76ac1a5e400',
    book_id => 1
);
 add_to_cart
-------------
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '248ee101-3229-4b61-8d11-d76ac1a5e400',
    book_id => 1
);

Page 24: Page 24

 add_to_cart
-------------
(1 row)
=> SELECT webapi.add_to_cart(
    auth_token => '248ee101-3229-4b61-8d11-d76ac1a5e400',
    book_id => 1,
    qty => -1
);
 add_to_cart
-------------
(1 row)
=> SELECT n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables
WHERE relid = 'cart_items'::regclass;
 n_tup_upd | n_tup_hot_upd
-----------+---------------
         2 |             2
(1 row)

Page 25: Практика+

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

Page 26: 1. Аутентификация пользователей приложения в базе данных

Это определенно плохая идея. В приложении могут быть зарегистрированы тысячи и миллионы пользователей. С точки зрения СУБД все они абсолютно равноправны, так что создание для каждого отдельной роли не дает никаких преимуществ при разграничении доступа. А вот неприятностей не оберешься: динамическое и бесконтрольное выполнение команд CREATE ROLE и GRANT приложением ни к чему хорошему не приведет. Такие команды должен выполнять администратор базы данных.

2. Где размещать бизнес-логику?

Этот вопрос из разряда «религиозных», на него нет однозначного ответа. Все зависит от того, на какую часть системы делается акцент. Подход, при котором бизнес-логика реализуется в приложении, а от базы данных отгораживаются ORM-ом (инструментом, помогающим связать объектную модель данных приложения с реляционной моделью данных базы), удобен для разработчиков, привыкших мыслить категориями процедурных и объектно-ориентированных языков программирования. Здесь во главу угла ставится приложение, а СУБД лишь обеспечивает надежное хранение данных. В качестве плюсов этого подхода часто называют простоту масштабирования и развертывания, независимость от конкретной СУБД. Подход, при котором бизнес-логика реализуется в базе данных, а приложению предоставляется высокоуровневый интерфейс, удобен для разработчиков, глубоко знакомых с устройством конкретной СУБД и умеющих пользоваться ее возможностями. Здесь во главу угла ставится база и согласованность данных в ней, а приложений может быть и несколько. В качестве плюсов — все возможности языка SQL и реализованных в СУБД алгоритмов, тонкая оптимизация и настройка производительности, исключение пересылок по сети лишних данных. В этом курсе, во всяком случае, мы всецело на стороне базы данных.

3. Собственная реализация очередей в базе данных

На этот вопрос также нет однозначного ответа. Сравнение разных решений приведено в теме «Асинхронная обработка».

07 Занятие 07: Расширяемость / Пул соединений

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

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

Page 1: Расширяемость / Пул соединений

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

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

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

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

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

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

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

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

Page 2: Темы

  • Для чего используется пул соединений
  • Пул соединений в общей архитектуре системы
  • Доступные варианты, PgBouncer
  • Режимы работы
  • Вопросы аутентификации
  • Управление пулом
  • Особенности разработки при наличии пула
  • Подготовка соединений

Page 3: Один клиент, один процесс

Source slide page 3: Один клиент, один процесс{#source-figure-003 caption=“Source slide page 3: Один клиент, один процесс”}

Создание соединения обходится дорого

  • аутентификация, наполнение кешей
  • Большое количество соединений замедляет работу сервера
  • Каждое соединение расходует память

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

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

При большом количестве клиентов это вызывает проблемы:

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

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

● Каждый процесс занимает определенную часть памяти сервера.

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

Page 4: Пул соединений

Source slide page 4: Пул соединений{#source-figure-004 caption=“Source slide page 4: Пул соединений”}

Обычно большую часть времени соединение простаивает

  • Менеджер пула соединений
    • открывает и удерживает несколько соединений с сервером
    • для клиентов выглядит как сервер PostgreSQL,
    • для сервера PostgreSQL выглядит как клиент

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

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

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

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

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

Page 5: Место в архитектуре

Source slide page 5: Место в архитектуре{#source-figure-005 caption=“Source slide page 5: Место в архитектуре”}

На сервере приложения

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

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

Можно по-разному встроить пул соединений в архитектуру информационной системы.

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

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

Page 6: Место в архитектуре

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

На сервере баз данных

  • если используется несколько серверов приложений
  • Встроенного пула соединений нет

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

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

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

Page 7: Место в архитектуре

Source slide page 7: Место в архитектуре{#source-figure-007 caption=“Source slide page 7: Место в архитектуре”}

И на серверах приложений, и на сервере баз данных

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

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

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

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

Page 8: Доступные продукты

PgBouncer

  • стандарт де-факто
  • используем далее как пример
  • Pgpool-II
  • не только пул, но и балансировка нагрузки Odyssey
    • многопоточный, высокопроизводительный
  • и др.

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

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

Как уже говорилось, встроенного менеджера пула в PostgreSQL пока нет, но есть сторонние продукты. Все представленные здесь менеджеры — проекты с открытым исходным кодом.

PgBouncer (https://www.pgbouncer.org/) используется очень широко и является стандартом де-факто. Это легкий пул соединений, без дополнительных функций. Именно эту программу мы будем рассматривать дальше в этой теме.

Pgpool-II (https://www.pgpool.net) разрабатывает компания SRA OSS (Япония). Это не только пул соединений, но и балансировщик нагрузки. Он реализует интересный функционал (например, анализ трафика для того чтобы отличать пишущие транзакции от только читающих).

Odyssey (https://github.com/yandex/odyssey) — пул соединений от компании Яндекс. Основное преимущество состоит в многопоточной архитектуре, позволяющей добиться высокой производительности.

Это, конечно, не полный список.

Page 9: Режимы работы

«Пул сеансов»

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

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

Менеджер пула может работать в нескольких режимах.

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

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

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

Любое соединение с сервером всегда выполняется к определенной базе данных и под определенной ролью. Поэтому на каждую пару «база данных — роль» менеджер пулов создает новый пул соединений.

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

Page 10: Аутентификация

Source slide page 10: Аутентификация{#source-figure-010 caption=“Source slide page 10: Аутентификация”}

Менеджер пула аутентифицирует соединения клиентов, сервер баз данных аутентифицирует соединения пула

  • требуется специальная настройка
  • можно использовать файл формата pg_hba.conf

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

Известно, что аутентификацию выполняет сервер БД. Но при наличии пула соединений нельзя полагаться только на нее.

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

Таким образом, схема получается следующая:

  1. Менеджер пула выполняет аутентификацию входящего соединения.

Для этого ему требуются настройки, более или менее аналогичные имеющимся в PostgreSQL. PgBouncer позволяет обойтись более простыми настройками, но допускает и использование обычного файла формата pg_hba.conf. Необходимо учитывать, что менеджер пула может поддерживать не все методы аутентификации. Например, PgBouncer напрямую не поддерживает GSSAPI, LDAP и т. п. (Однако поддержка PAM позволяет использовать различные методы.)

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

Page 11: Минимальная настройка

Файл настроек PgBouncer:

student$ sudo ls -l /etc/pgbouncer/pgbouncer.ini
-rw-r----- 1 postgres postgres 11217 апр 10 16:04 /etc/pgbouncer/pgbouncer.ini
student$ sudo grep '^[^;]' /etc/pgbouncer/pgbouncer.ini
[databases]
* = host=localhost port=5432
[users]
[pgbouncer]
logfile = /var/log/postgresql/pgbouncer.log
pidfile = /var/run/postgresql/pgbouncer.pid
listen_addr = localhost
listen_port = 6432
unix_socket_dir = /var/run/postgresql
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = student
pool_mode = transaction

Секция [databases] позволяет переадресовывать обращения к разным БД на разные серверы PostgreSQL (в нашем случае это не используется); PgBouncer слушает порт 6432; Используется аутентификация scram-sha-256; Роль student может выполнять администрирование PgBouncer; Используется режим пула транзакций. Файл пользователей:

student$ sudo cat /etc/pgbouncer/userlist.txt
"student"
"SCRAM-SHA-256$4096:Ivx0LbEUD35XlioHdX44IQ==$jk8pkIWipEmOtxAAzcQ7JKBGt50ery7XZ7Gchslu6yk=:
6ddAByaDBYaR6UhPMJtPb9NMiWH/1frcb4FvKM4Rkmw="
"web"
"SCRAM-SHA-256$4096:Q+xJq/zWx/Zb90njg3Y5kA==$Ky6A5tRLlCSiffTHrlCEdGOa3fSZBeZif6m8K38I9U0=:
KqBzFxEQL+RQU/B9saKznrkbPftAnxooNTZROiCzUEM="
"emp"
"SCRAM-SHA-256$4096:LUAZGsmu+pjdLYCW/O0unA==$de7ECec9KKVLDXgI0c8VGkRLRCo3lXnqMCdBnvScD5I=:
ME0c0wscENrLDqBkBCqxD8s0yUiAOQVmTCn7aQsIivU="

Чтобы не синхронизировать пароли, можно получать их непосредственно с сервера БД:

student$ sudo grep 'auth_query' /etc/pgbouncer/pgbouncer.ini
;; use auth_user with auth_query if user not present in auth_file
;; run auth_query on a specific database.
;auth_query = SELECT rolname, CASE WHEN rolvaliduntil < pg_catalog.now() THEN NULL ELSE
rolpassword END FROM pg_authid WHERE rolname=$1 AND rolcanlogin
;; Authentication database that can be set globally to run "auth_query".

Попробуем подключиться. Поскольку PgBouncer настроен на парольную scram-sha-256-аутентификацию, зададим пароль явно в строке подключения (можно было бы использовать файл ~/.pgpass):

student$ psql postgresql://student@localhost:6432/student?password=student
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          19659
(1 row)

Подключение работает. Теперь закроем соединение и откроем его заново:

=> \q
student$ psql postgresql://student@localhost:6432/student?password=student
=> SELECT pg_backend_pid();

Page 12: Page 12

 pg_backend_pid
----------------
          19659
(1 row)

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

Page 13: Управление

Консоль управления PgBouncer

  • подключение psql к псевдобазе pgbouncer
  • SHOW — ряд команд для вывода состояния и другой информации
  • PAUSE — приостановка клиентов (для перезагрузки базы данных)
  • RESUME — возобновление работы
  • RELOAD — перечитывание файла конфигурации
  • и другие возможности

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

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

Консоль позволяет запрашивать состояние системы и выполнять ряд команд. Например, можно приостановить поток запросов от клиентов на время перезагрузки сервера БД и т. п.

Page 14: Консоль управления

Чтобы работать с консолью управления PgBouncer, необходимо подключиться к одноименной базе данных (это могут сделать пользователи, определенные в параметрах admin_users и stat_users).

student$ psql postgresql://student@localhost:6432/pgbouncer?password=student

Теперь мы работаем не с PostgreSQL — команды обрабатывает сам PgBouncer. Например, можно получить информацию об используемых пулах:

=> SHOW POOLS \gx
-[ RECORD 1 ]---------+------------
database              | pgbouncer
user                  | pgbouncer
cl_active             | 1
cl_waiting            | 0
cl_active_cancel_req  | 0
cl_waiting_cancel_req | 0
sv_active             | 0
sv_active_cancel      | 0
sv_being_canceled     | 0
sv_idle               | 0
sv_used               | 0
sv_tested             | 0
sv_login              | 0
maxwait               | 0
maxwait_us            | 0
pool_mode             | statement
load_balance_hosts    |
-[ RECORD 2 ]---------+------------
database              | student
user                  | student
cl_active             | 0
cl_waiting            | 0
cl_active_cancel_req  | 0
cl_waiting_cancel_req | 0
sv_active             | 0
sv_active_cancel      | 0
sv_being_canceled     | 0
sv_idle               | 1
sv_used               | 0
sv_tested             | 0
sv_login              | 0
maxwait               | 0
maxwait_us            | 0
pool_mode             | transaction
load_balance_hosts    |

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

=> SET max_prepared_statements = 10;
SET max_prepared_statements=10

Вывод команды SHOW CONFIG, показывающий информацию о параметрах, достаточно объемный, поэтому отфильтруем его, чтобы увидеть только один параметр:

=> SHOW CONFIG \gx
------------------------------------
key        | max_prepared_statements
value      | 10
default    | 200
changeable | yes

Пусть к пулу подключится новый клиент:

student$ psql postgresql://student@localhost:6432/student?password=student

А мы приостановим работу всех клиентов:

=> PAUSE;
PAUSE

Page 15: Page 15

Новый клиент пытается выполнить запрос:

=> SELECT now();

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

student$ sudo pg_ctlcluster 16 main restart

Возобновляем работу клиентов, и запрос успешно выполняется:

=> RESUME;
RESUME
             now
------------------------------
 2026-04-10 16:04:29.50771+03
(1 row)

Таким образом можно выполнять работы на сервере БД (например, обновление), не обрывая клиентские соединения.

Page 16: Особенности разработки

Действия, локализованные в сеансе, а не в транзакции

  • подготовленные операторы (PREPARE/EXECUTE)
  • временные таблицы (кроме ON COMMIT DROP)
  • функция currval
  • установка конфигурационных параметров (SET/RESET)
  • рекомендательные блокировки на уровне сеанса
  • межпроцессные уведомления (LISTEN/NOTIFY)
  • курсоры с фразой WITH HOLD
  • загрузка разделяемых библиотек (LOAD)
  • расширения, сохраняющие состояние на уровне сеанса

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

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

К ним относятся подготовленные операторы (если подготовкой управляет клиент), использование временных таблиц, функции currval, установка параметров и рекомендательных блокировок на уровне сеанса, межпроцессные уведомления, курсоры WITH HOLD, загрузка разделяемых библиотек в сеансе, установка таймаута бездействия сеанса (idle_session_timeout) и подобные. А также расширения, реализующие «глобальные переменные» уровня сеанса, такие как pg_variables (https://postgrespro.ru/docs/enterprise/16/pg-variables).

В версии pgBouncer 1.21 (16.10.2023) реализована поддержка подготовленных операторов на стороне клиента. Надо учитывать, что команды уровня SQL (PREPARE, EXECUTE, DEALLOCATE) по-прежнему направляются напрямую на сервер, поэтому подготовка операторов будет работать только при использовании драйверов, непосредственно формирующих сообщения протокола (например, для python или perl).

https://www.pgbouncer.org/changelog.html#pgbouncer-121x

Page 17: Особенности пула транзакций

Source slide page 17: Особенности пула транзакций{#source-figure-017 caption=“Source slide page 17: Особенности пула транзакций”}

Рассмотрим на примере подготовленных операторов — в случае, когда подготовкой управляет клиент.

student$ psql postgresql://student@localhost:6432/student?password=student
=> PREPARE hello AS SELECT 'Hello, world!';
PREPARE

Теперь откроем еще один сеанс.

student$ psql postgresql://student@localhost:6432/student?password=student
=> BEGIN;
BEGIN
=> SELECT name, statement, prepare_time, parameter_types, result_types FROM pg_prepared_statements \gx
-[ RECORD 1 ]---+-----------------------------------------
name            | hello
statement       | PREPARE hello AS SELECT 'Hello, world!';
prepare_time    | 2026-04-10 16:04:29.938899+03
parameter_types | {}
result_types    | {text}
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          22795
(1 row)

Транзакция второго сеанса выполняется в том же соединении, поэтому ей доступен подготовленный оператор:

=> EXECUTE hello;
   ?column?
---------------
 Hello, world!
(1 row)

А какое соединение будет использоваться в первом сеансе?

=> BEGIN;
BEGIN
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          23136
(1 row)

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

=> EXECUTE hello;
ERROR:  prepared statement "hello" does not exist

Подготовленного оператора в памяти этого обслуживающего процесса нет.

=> END;
ROLLBACK
=> END;
COMMIT

Чтобы пользоваться и преимуществами пула соединений, и подготовленными операторами, надо переносить управление подготовкой на сторону сервера. Самый простой и удобный способ — писать функции на языке PL/pgSQL. В этом случае интерпретатор автоматически подготавливает все запросы.

Page 18: Page 18

Page 19: Подготовка соединений

Source slide page 19: Подготовка соединений{#source-figure-019 caption=“Source slide page 19: Подготовка соединений”}

Начальные действия и изоляция

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

PgBouncer предоставляет параметры, позволяющие с помощью SQL-команды

● настраивать только что открытое соединение,

● очищать состояние соединения после использования его клиентом.

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

Запрос, заданный в параметре server_reset_query, по умолчанию срабатывает только в режиме пула сеансов и полностью сбрасывает состояние сеанса, включая содержимое всех кешей. Если установить server_reset_query_always = on, запрос будет срабатывать и в режиме пула транзакций. Тогда, если приложение не обеспечивает должную изоляцию действий в транзакции, в такой запрос можно поместить действия по принудительной изоляции, такие как сброс параметров сеанса, удаление подготовленных операторов, очистка временных таблиц и др.

Page 20: Подготовка соединений и изоляция

Настройки базы данных в PgBouncer позволяют при открытии соединения выполнять любую команду SQL. Для этого добавим атрибут connect_query в строку соединения:

student$ sudo sed -i $'s/^* =.*/* = host=localhost port=5432 connect_query=\'PREPARE hello AS SELECT
\'\'Привет, мир!\'\' greeting;\'/' /etc/pgbouncer/pgbouncer.ini

В конфигурационном файле получили:

student$ sudo grep '^* =' /etc/pgbouncer/pgbouncer.ini
* = host=localhost port=5432 connect_query='PREPARE hello AS SELECT ''Привет,
мир!'' greeting;'

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

=> \c pgbouncer
You are now connected to database "pgbouncer" as user "student".
=> RELOAD;
RELOAD

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

=> \c
You are now connected to database "student" as user "student".
=> SELECT * FROM pg_prepared_statements\gx
-[ RECORD 1 ]---+-------------------------------------------------
name            | hello
statement       | PREPARE hello AS SELECT 'Привет, мир!' greeting;
prepare_time    | 2026-04-10 16:04:31.80215+03
parameter_types | {}
result_types    | {text}
from_sql        | t
generic_plans   | 0
custom_plans    | 0
=> EXECUTE hello;
   greeting
--------------
 Привет, мир!
(1 row)

Page 21: Итоги

На каждое клиентское соединение создается обслуживающий процесс на сервере

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

Page 22: Практика

  1. Убедитесь, что при работе приложения через пул соединений (порт 6432) разные пользователи обычно видят одну и ту же корзину. Выясните причину ошибки и исправьте код хранимых функций так, чтобы приложение работало корректно как без пула соединений, так и с ним. 2. Использование пула мешает выводу в журнал сообщений сервера информации, относящейся только к одному клиенту. Наше приложение вызывает в начале каждой транзакции функцию webapi.trace (или empapi.trace), если в служебной панели установлен признак трассировки. Реализуйте эти функции так, чтобы они устанавливали параметр сервера log_min_duration_statements = 0 и облегчали идентификацию клиента. Проверьте работу по журналу сообщений.

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

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

  1. Проблема воспроизводится следующим образом. Откройте две вкладки браузера с книжным магазином. В первой войдите как alice и положите в корзину одну или несколько книг. Во второй зайдите как bob и проверьте корзину.

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

  1. Необходимые функции уже имеются в базе данных, но ничего не делают. Функция webapi.trace принимает на вход токен аутентификации (или NULL, если пользователь не вошел в систему).

Для идентификации можно использовать параметр log_line_prefix с маской %a, выводящей имя приложения. Имя приложения устанавливается параметром application_name, в которое можно записать имя пользователя, если оно известно.

Page 23: 1. Корректная работа с пулом соединений

Проблема состоит в том, что функция, проверяющая токен, запоминает пользователя в параметре сервера на уровне сеанса:

=> \sf check_auth
CREATE OR REPLACE FUNCTION public.check_auth(auth_token uuid)
 RETURNS bigint
 LANGUAGE plpgsql
 STABLE
AS $function$
DECLARE
    user_id bigint;
BEGIN
    user_id := current_setting('check_auth.user_id', /* missing_ok */true);
    IF user_id IS NULL THEN
        SELECT s.user_id
        INTO STRICT user_id
        FROM sessions s
        WHERE s.auth_token = check_auth.auth_token;
        PERFORM set_config('check_auth.user_id', user_id::text, /* is_local */false);
    END IF;
    RETURN user_id;
END;
$function$

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

=> CREATE OR REPLACE FUNCTION check_auth(auth_token uuid) RETURNS bigint
AS $$
DECLARE
    user_id bigint;
BEGIN
    SELECT s.user_id
    INTO STRICT user_id
    FROM sessions s
    WHERE s.auth_token = check_auth.auth_token;
    RETURN user_id;
END;
$$ LANGUAGE plpgsql STABLE;
CREATE FUNCTION

2. Трассировка

Функция трассировки для админки:

=> CREATE OR REPLACE FUNCTION empapi.trace() RETURNS void
LANGUAGE sql SECURITY DEFINER
RETURN set_config(
               'log_min_duration_statement',
               '0',
               /* is_local */ true
       );
CREATE FUNCTION

Обратите внимание, что параметр устанавливается на время транзакции (третий параметр). Функция трассировки для магазина:

Page 24: Page 24

=> CREATE OR REPLACE FUNCTION webapi.trace(auth_token uuid) RETURNS void
LANGUAGE sql SECURITY DEFINER
RETURN ROW(set_config(
    'log_min_duration_statement', '0', /* is_local */ true
    ),
   set_config(
    'application_name',
    (SELECT 'client=' || u.username
    FROM users u JOIN sessions s ON u.user_id = s.user_id
    WHERE s.auth_token = trace.auth_token), /* is_local */ true
    )
);
CREATE FUNCTION

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

=> ALTER SYSTEM SET log_line_prefix = '%m [%p] %q%u@%d (%a) ';
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

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

=> SELECT webapi.login('alice');
                login
--------------------------------------
 b1232f90-7a18-41cb-8124-da347fb04359
(1 row)
=> BEGIN;
BEGIN
=> SELECT webapi.trace('b1232f90-7a18-41cb-8124-da347fb04359');
 trace
-------
(1 row)
=> SELECT 2+2;
 ?column?
----------
        4
(1 row)
=> COMMIT;
COMMIT
student$ tail -n 1 /var/log/postgresql/postgresql-16-main.log
2026-04-10 16:23:12.803 MSK [58682] student@bookstore2 (client=alice) LOG:  duration:
0.128 ms  statement: SELECT 2+2;

Теперь этим функционалом можно пользоваться, устанавливая в приложении признак трассировки в служебной панели.

Page 25: Практика+

  1. В двух сеансах подключитесь к базе student ролью student через пул соединений. В третьем сеансе подключитесь к консоли pgbouncer и изучите вывод команд SHOW CLIENTS и SHOW SERVERS. 2. Перепишите следующий фрагмент SQL-кода так, чтобы он корректно выполнялся при использовании пула соединений: INSERT INTO master(id, s) SELECT nextval(’m_seq’), ‘m1’; INSERT INTO detail(id, m_id, s) SELECT nextval(’d_seq’), currval(’m_seq’), ‘d1’; 3. Воспроизведите проблему с рекомендательными блокировками уровня сеанса при использовании пула соединений в режиме транзакций.

Page 26: 1. Консоль pgbouncer

Дважды подключаемся к базе student ролью student:

student$ psql postgresql://student@localhost:6432/student?password=student
student$ psql postgresql://student@localhost:6432/student?password=student

Подключаемся к консоли pgbouncer:

student$ psql postgresql://student@localhost:6432/pgbouncer?password=student
=> \x
Expanded display is on.
=> SHOW CLIENTS;

Page 27: Page 27

-[ RECORD 1 ]-------+------------------------
type                | C
user                | student
database            | pgbouncer
replication         | none
state               | idle
addr                | 127.0.0.1
port                | 34504
local_addr          | 127.0.0.1
local_port          | 6432
connect_time        | 2026-04-10 16:23:20 MSK
request_time        | 2026-04-10 16:23:20 MSK
wait                | 0
wait_us             | 103638
close_needed        | 0
ptr                 | 0x58b2cd033c90
link                |
remote_pid          | 0
tls                 |
application_name    | psql
prepared_statements | 0
id                  | 4
-[ RECORD 2 ]-------+------------------------
type                | C
user                | student
database            | student
replication         | none
state               | idle
addr                | 127.0.0.1
port                | 34482
local_addr          | 127.0.0.1
local_port          | 6432
connect_time        | 2026-04-10 16:23:19 MSK
request_time        | 2026-04-10 16:23:19 MSK
wait                | 0
wait_us             | 0
close_needed        | 0
ptr                 | 0x58b2cd032ec0
link                |
remote_pid          | 0
tls                 |
application_name    | psql
prepared_statements | 0
id                  | 1
-[ RECORD 3 ]-------+------------------------
type                | C
user                | student
database            | student
replication         | none
state               | idle
addr                | 127.0.0.1
port                | 34498
local_addr          | 127.0.0.1
local_port          | 6432
connect_time        | 2026-04-10 16:23:19 MSK
request_time        | 2026-04-10 16:23:20 MSK
wait                | 0
wait_us             | 0
close_needed        | 0
ptr                 | 0x58b2cd0335a8
link                |
remote_pid          | 0
tls                 |
application_name    | psql
prepared_statements | 0
id                  | 3

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

=> SHOW SERVERS;

Page 28: Page 28

-[ RECORD 1 ]-------+-------------------------------
type                | S
user                | student
database            | student
replication         | none
state               | idle
addr                | 127.0.0.1
port                | 5432
local_addr          | 127.0.0.1
local_port          | 41018
connect_time        | 2026-04-10 16:23:19 MSK
request_time        | 2026-04-10 16:23:20 MSK
wait                | 0
wait_us             | 0
close_needed        | 0
ptr                 | 0x58b2cd048830
link                |
remote_pid          | 59380
tls                 | TLSv1.3/TLS_AES_256_GCM_SHA384
application_name    | psql
prepared_statements | 0
id                  | 2

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

=> \q
=> \q
=> \q

2. Функции nextval и currval

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

student$ psql
=> CREATE DATABASE ext_connpool;
CREATE DATABASE
=> \c ext_connpool
You are now connected to database "ext_connpool" as user "student".
=> CREATE TABLE master(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    s text
);
CREATE TABLE
=> CREATE TABLE detail(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    m_id integer REFERENCES master(id),
    s text
);
CREATE TABLE

А вставку можно организовать с помощью PL/pgSQL следующим образом:

=> DO $$
DECLARE
   m_id integer;
BEGIN
   INSERT INTO master(s) VALUES ('m1') RETURNING id INTO m_id;
   INSERT INTO detail(m_id, s) VALUES (m_id, 'd1');
END;
$$;
DO
=> \q

3. Рекомендательные блокировки на уровне сеанса

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

Page 29: Page 29

student$ psql postgresql://student@localhost:6432/ext_connpool?password=student
=> BEGIN;
BEGIN
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          60287
(1 row)
=> SELECT pg_advisory_lock(42);
 pg_advisory_lock
------------------
(1 row)
=> END;
COMMIT

Блокировка удерживается после окончания транзакции:

=> SELECT objid FROM pg_locks WHERE locktype = 'advisory';
 objid
-------
    42
(1 row)

Теперь появляется транзакция второго клиента:

student$ psql postgresql://student@localhost:6432/ext_connpool?password=student
=> BEGIN;
BEGIN

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

=> SELECT pg_advisory_unlock(42);
WARNING:  you don't own a lock of type ExclusiveLock
 pg_advisory_unlock
--------------------
 f
(1 row)
=> SELECT pg_backend_pid();
 pg_backend_pid
----------------
          60557
(1 row)

Зато может второй клиент, хоть он и не устанавливал эту блокировку:

=> SELECT pg_advisory_unlock(42);
 pg_advisory_unlock
--------------------
 t
(1 row)
=> COMMIT;
COMMIT

08 Занятие 08: Расширяемость / Типы для больших значений

Исходная страница в нативном Markdown: Расширяемость / Типы для больших значений
Примечание

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

Page 1: Расширяемость / Типы для больших значений

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

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

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

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

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

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

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

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

Page 2: Темы

  • Хранение в файловой системе
  • Технология TOAST
  • Тип bytea
  • Большие объекты

Page 3: Варианты хранения

Файловая система

  • в базе данных только ссылки на файлы
  • Встроенные типы
    • PostgreSQL
    • стандарт SQL
    • двоичные данные
    • bytea
    • blob
    • символьные данные
    • text
    • clob
  • Подсистема «больших объектов»

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

Для хранения и обработки больших объемов данных в стандарте SQL предусмотрены типы:

● clob (character large object) — для символьных данных;

● blob (binary large object) — для двоичных данных.

В PostgreSQL типы clob и blob отсутствуют. Вместо них для символьных данных можно использовать тип text, а для двоичных — bytea (byte array).

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

В качестве альтернативы типу bytea, можно использовать «отчасти устаревшую» (цитата из документации) подсистему работы с большими объектами.

Но прежде чем размещать данные файлов внутри базы, следует рассмотреть возможность хранения их «в обычном виде» в файловой системе.

Page 4: Файловая система

Source slide page 4: Файловая система{#source-figure-004 caption=“Source slide page 4: Файловая система”}

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

Если говорить о двоичных данных большого объема, то один из вариантов — отказаться от размещения их в СУБД. В этом случае файлы с двоичными данными располагаются непосредственно в файловой системе, а внутри базы данных поддерживаются только ссылки на них.

Page 5: Файловая система

Преимущества

  • скорость чтения и записи файлов
  • не увеличивается размер базы данных
  • Недостатки
    • не используются возможности СУБД: транзакции, управление доступом
    • отсутствуют гарантии согласованности, долговечности данных
    • усложняется архитектура системы,
    • например, в части резервного копирования и восстановления

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

Такой подход может быть оправдан в первую очередь производительностью. Скорость чтения и записи файлов увеличивается за счет избежания затрат, характерных для СУБД:

● двойная запись на диск (в WAL и файлы данных);

● двойное кеширование в оперативной памяти (файловый кеш и буферный кеш СУБД);

● накладные расходы на хранение в базе данных и обработку (нарезка на фрагменты для хранения и склейка их при чтении);

● возможное распухание таблиц и индексов приводит к дополнительному чтению устаревших фрагментов значения.

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

С другой стороны, становится невозможным использовать преимущества СУБД:

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

● управление доступом пользователей на чтение и запись данных.

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

Page 6: TOAST

Хранение «длинных» атрибутов в отдельной таблице

  • применяется для типов переменной длины: text, bytea, xml, json и др.
  • «длинные» атрибуты нарезаются на фрагменты меньше страницы
  • возможно сжатие (методы pglz и lz4)
  • размер значения до 1 Гбайта
  • TOAST-таблица
    • читается только при обращении к «длинному» атрибуту
    • возможна частичная декомпрессия значения при чтении
    • собственная версионность
    • работает прозрачно для приложения

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

Любая версия строки в PostgreSQL должна целиком помещаться на одну страницу. Для «длинных» версий строк применяется технология TOAST — The Oversized Attributes Storage Technique. Точнее, TOAST применяется к отдельным атрибутам, имеющим тип переменной длины, например, text и bytea, а также xml и json, которые будут рассмотрены позже в этом курсе. В любом случае размер одного значения (возможно сжатого) не должен превышать 1 Гбайта.

Для каждой основной таблицы при необходимости создается отдельная TOAST-таблица (и к ней специальный индекс). Версии строк в TOAST-таблице тоже должны помещаться на одну страницу, поэтому «длинные» значения хранятся порезанными на части, обычно около 2 Кбайт. Из этих частей PostgreSQL прозрачно для приложения «склеивает» необходимое значение.

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

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

Page 7: Стратегии хранения

  • основная
  • сжатие
  • данных
  • TOAST-
  • таблица
  • таблица
  • Extended да да да
  • External да ― да
    • в самую
    • последнюю
  • Main да в последнюю
    • очередь
    • очередь
  • Plain да ― ―

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

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

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

● EXTERNAL. Допускается только перенос в таблицу TOAST, но не сжатие. Если загружаемые данные уже сжаты, имеет смысл выбрать стратегию EXTERNAL для экономии затрат на малоэффективное повторное сжатие.

● MAIN. Поля с этой стратегией сжимаются только в том случае, если обработки полей EXTENDED и EXTERNAL оказалось недостаточно, чтобы уместить версию строки на странице. Перенос в таблицу TOAST тоже возможен, но только как крайняя мера.

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

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

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

https://postgrespro.ru/docs/postgresql/16/storage-toast

Page 8: Тип bytea

Source slide page 8: Тип bytea{#source-figure-008 caption=“Source slide page 8: Тип bytea”}

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

Хранить двоичные объекты в базе данных можно двумя способами.

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

Тип bytea использует технологию хранения TOAST. Поэтому значения большого размера размещаются не в самой таблице, а нарезаются на фрагменты и хранятся в TOAST-таблице, связанной с основной.

Page 9: Тип bytea

Преимущества

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

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

Главное преимущество хранения больших значений в базе данных — это использование возможностей СУБД. Транзакционный механизм гарантирует согласованность данных и защиту от сбоев. А разграничивать доступ пользователей к значениям можно при помощи средств СУБД.

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

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

https://postgrespro.ru/docs/postgresql/16/datatype-binary

https://postgrespro.ru/docs/postgresql/16/functions-binarystring

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

К недостаткам использования типа bytea можно отнести и ограничение TOAST на размер одного значения в 1 Гбайт.

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

Page 10: Тип bytea и TOAST

=> CREATE DATABASE ext_lob;
CREATE DATABASE
=> \c ext_lob
You are now connected to database "ext_lob" as user "student".

По умолчанию двоичные данные выводятся в шестнадцатеричном формате.

=> SHOW bytea_output;
 bytea_output
--------------
 hex
(1 row)

Значения начинаются с ‘\x’, далее каждый байт представлен двумя шестнадцатеричными цифрами:

=> SELECT 'Hello'::bytea;
    bytea
--------------
 \x48656c6c6f
(1 row)

Добавим нулевой символ к строке:

=> SELECT 'Hello'::bytea || '\x00'::bytea;
    ?column?
----------------
 \x48656c6c6f00
(1 row)

Шестнадцатеричный формат появился в версии 9.0. До этого был доступен только формат «спецпоследовательностей».

=> SET bytea_output = 'escape';
SET

В этом формате ASCII-символы отображаются как есть, а остальные представлены спецпоследовательностями:

=> SELECT 'Hello'::bytea || '\x00'::bytea;
 ?column?
-----------
 Hello\000
(1 row)

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

=> RESET bytea_output;
RESET

Теперь создадим таблицу со столбцом типа bytea.

=> CREATE TABLE demo_bytea(
  filename text,
  data bytea
);
CREATE TABLE

Таблица TOAST создается автоматически для хранения больших значений data и filename (значения типа text также могут быть большими). Найдем имя служебной таблицы:

=> SELECT reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE oid = 'demo_bytea'::regclass;

Page 11: Page 11

       toast_table
-------------------------
 pg_toast.pg_toast_16567
(1 row)

Служебные таблицы TOAST всегда располагаются в специальной схеме pg_toast — чтобы не пересекаться с обычными объектами базы данных. Получить список TOAST-таблиц можно командой psql:

=> \dtS pg_toast.pg_toast_16567*
                 List of relations
  Schema  |      Name      |    Type     |  Owner
----------+----------------+-------------+---------
 pg_toast | pg_toast_16567 | TOAST table | student
(1 row)

Посмотрим на структуру TOAST-таблицы:

=> \d pg_toast.pg_toast_16567
TOAST table "pg_toast.pg_toast_16567"
   Column   |  Type
------------+---------
 chunk_id   | oid
 chunk_seq  | integer
 chunk_data | bytea
Owning table: "public.demo_bytea"
Indexes:
    "pg_toast_16567_index" PRIMARY KEY, btree (chunk_id, chunk_seq)

chunk_id — идентификатор значения, chunk_seq — порядковый номер фрагмента значения, chunk_data — данные фрагмента. Можно обратить внимание, что вывод команды \d для TOAST-таблиц содержит название основной таблицы. Доступ к значениям в TOAST всегда осуществляется по индексу. Это самый быстрый способ получить все фрагменты одного значения для склейки, но доступ ко всем значениям будет заведомо неэффективен. Кроме того, чтение большого объема данных из TOAST-таблиц может приводить к вытеснению полезных данных из буферного кеша. Механизм буферного кольца, предотвращающий массовое вытеснение, для TOAST-таблиц не используется, так как задействуется только при полном последовательном сканировании таблицы, но не при индексном доступе. Добавим в demo_bytea строку. В качестве данных возьмем логическую резервную копию базы данных приложения.

student$ pg_dump -d bookstore2 > /tmp/bookstore2.sql
student$ ls -l /tmp/bookstore2.sql
-rw-rw-r-- 1 student student 1791242 апр 10 16:04 /tmp/bookstore2.sql

Заметим время вставки.

=> \timing on
Timing is on.

Для считывания файла воспользуемся встроенной функцией pg_read_binary_file.

=> INSERT INTO demo_bytea(filename, data) VALUES (
    'bookstore2.sql',
    pg_read_binary_file('/tmp/bookstore2.sql')
);
INSERT 0 1
Time: 135,614 ms
=> \timing off
Timing is off.

Использование TOAST прозрачно для приложения. Нам не нужно обращаться к служебной таблице в запросах. Вот первые 16 байт загруженного файла в двоичном виде:

=> SELECT substring(data,1,16) FROM demo_bytea;

Page 12: Page 12

             substring
------------------------------------
 \x2d2d0a2d2d20506f737467726553514c
(1 row)

Общий размер загруженного значения соответствует размеру файла:

=> SELECT length(data) FROM demo_bytea;
 length
---------
 1791242
(1 row)

Однако размер таблицы demo_bytea составляет всего одну страницу, данных загруженного файла в ней нет:

=> SELECT pg_relation_size('demo_bytea');
 pg_relation_size
------------------
             8192
(1 row)

Значение столбца data попало в TOAST-таблицу. Можно убедиться, что в служебной таблице появилось одно значение:

=> SELECT count(distinct(chunk_id)) FROM pg_toast.pg_toast_16567;
 count
-------
     1
(1 row)

Сколько места требуется для хранения TOAST-таблицы, если сравнивать с размером файла: меньше, больше или ровно столько же?

=> SELECT pg_relation_size('pg_toast.pg_toast_16567');
 pg_relation_size
------------------
           794624
(1 row)

Почему потребовалось меньше места?

=> \d+ demo_bytea
                                        Table "public.demo_bytea"
  Column  | Type  | Collation | Nullable | Default | Storage  | Compression | Stats
target | Description
----------+-------+-----------+----------+---------+----------+-------------+-------------
-+-------------
 filename | text  |           |          |         | extended |             |
 |
 data     | bytea |           |          |         | extended |             |
 |
Access method: heap

Стратегия хранения extended предполагает сжатие данных при помещении в TOAST. А текстовые данные хорошо сжимаются. Начиная с PostgreSQL 14 появилась возможность выбора метода сжатия: pglz (используется по умолчанию) и lz4. Изменим метод сжатия для столбца data и повторим наш опыт, предварительно опустошив таблицу.

=> ALTER TABLE demo_bytea ALTER COLUMN data SET COMPRESSION lz4;
ALTER TABLE
=> TRUNCATE demo_bytea;
TRUNCATE TABLE
=> \timing on
Timing is on.
=> INSERT INTO demo_bytea(filename, data) VALUES (
    'bookstore2.sql',
    pg_read_binary_file('/tmp/bookstore2.sql')
);

Page 13: Page 13

INSERT 0 1
Time: 33,870 ms
=> \timing off
Timing is off.

Вставка данных выполнилась заметно быстрее, но места для сжатых данных потребуется больше — lz4 сжимает данные не так эффективно:

=> SELECT pg_relation_size('pg_toast.pg_toast_16567');
 pg_relation_size
------------------
           892928
(1 row)

Сжатие вообще можно запретить, выбрав стратегию external:

=> ALTER TABLE demo_bytea ALTER COLUMN data SET STORAGE external;
ALTER TABLE

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

=> TRUNCATE demo_bytea;
TRUNCATE TABLE
=> INSERT INTO demo_bytea(filename, data) VALUES (
    'bookstore2.sql',
    pg_read_binary_file('/tmp/bookstore2.sql')
);
INSERT 0 1

Сколько теперь потребуется места для хранения TOAST-таблицы, если сравнивать с размером исходного файла: меньше, больше или ровно столько же?

=> SELECT pg_relation_size('pg_toast.pg_toast_16567');
 pg_relation_size
------------------
          1843200
(1 row)

Теперь для хранения используется немного больше места. Почему?

=> SELECT chunk_id, chunk_seq, substring(chunk_data,1,16),
    length(chunk_data)
FROM pg_toast.pg_toast_16567
ORDER BY 1,2 LIMIT 3;
 chunk_id | chunk_seq |             substring              | length
----------+-----------+------------------------------------+--------
    16580 |         0 | \x2d2d0a2d2d20506f737467726553514c |   1996
    16580 |         1 | \x726d617420746578742c20726174696e |   1996
    16580 |         2 | \x73656172636820746578742c206f7264 |   1996
(3 rows)

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

Page 14: Большие объекты

Source slide page 14: Большие объекты{#source-figure-014 caption=“Source slide page 14: Большие объекты”}

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

Другой вариант хранения двоичных данных большого размера внутри базы данных — использование подсистемы «больших объектов», появившейся задолго до TOAST.

Все большие объекты сохраняются в отдельной таблице системного каталога pg_largeobject. Для ссылок на большие объекты в столбцах других таблиц предоставляются идентификаторы типа oid. Доступ к этим объектам осуществляется при помощи специального интерфейса, похожего на интерфейс работы с файлами.

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

Page 15: Большие объекты

Преимущества

  • транзакционность, управление доступом
  • согласованность и долговечность больших данных
  • размер значения до 4 Тбайт
  • потоковое чтение и запись фрагментов значения
  • Недостатки
    • увеличение размера базы данных
    • меньшая скорость, чем при работе с файлами
    • специальный интерфейс
    • риск появления осиротевших объектов
    • не поддерживается логической репликацией
    • ограничение на количество больших объектов (2

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

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

По сравнению с типом bytea у больших объектов есть преимущества. Во-первых, размер одного объекта может достигать 4 Тбайт. А во-вторых, интерфейс для работы с большими объектами позволяет работать с произвольным фрагментом значения, а не со всем значением целиком, как обычно происходит с TOAST.

С другой стороны, использование специального интерфейса делает работу с большими объектами не такой удобной, как с чистым SQL. К тому же драйвер PostgreSQL должен поддерживать этот интерфейс.

Идентификатор большого объекта хранится в 4-байтовом типе oid, что ограничивает количество объектов в системе числом 4294967296. Таблицы TOAST тоже используют oid, но ограничение на количество значений действует в пределах таблицы, а не на всю систему.

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

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

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

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

Page 16: Использование large objects

Большие объекты хранятся в таблице системного каталога pg_largeobject, структура которой похожа на структуру TOAST-таблицы.

=> \d pg_largeobject
         Table "pg_catalog.pg_largeobject"
 Column |  Type   | Collation | Nullable | Default
--------+---------+-----------+----------+---------
 loid   | oid     |           | not null |
 pageno | integer |           | not null |
 data   | bytea   |           | not null |
Indexes:
    "pg_largeobject_loid_pn_index" PRIMARY KEY, btree (loid, pageno)

Создадим таблицу для хранения ссылок на большие объекты.

=> CREATE TABLE demo_largeobject(
  filename text,
  link oid
);
CREATE TABLE

Для работы с большими объектами будем использовать интерфейсные функции SQL.

=> INSERT INTO demo_largeobject VALUES (
    'bookstore2.sql',
    lo_import('/tmp/bookstore2.sql')
);
INSERT 0 1

Функция lo_import загружает файл с сервера в pg_largeobject и возвращает указатель на него (OID). Функция lo_get считывает указанную часть значения:

=> SELECT filename, link, lo_get(link,1,16) FROM demo_largeobject;
    filename    | link  |               lo_get
----------------+-------+------------------------------------
 bookstore2.sql | 16586 | \x2d0a2d2d20506f737467726553514c20
(1 row)

Что будет, если удалить строку из таблицы demo_largeobject?

=> DELETE FROM demo_largeobject;
DELETE 1

Строка удалится, а большой объект станет «потерянным»:

=> \lo_list
         Large objects
  ID   |  Owner  | Description
-------+---------+-------------
 16586 | student |
(1 row)

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

student$ vacuumlo --verbose ext_lob
Connected to database "ext_lob"
Checking link in public.demo_largeobject
​Removing lo  16586   ​Successfully removed 1 large objects from database "ext_lob".
=> \lo_list
      Large objects
 ID | Owner | Description
----+-------+-------------
(0 rows)

Page 17: Page 17

Для предотвращения потери ссылок также можно воспользоваться расширением lo.

=> CREATE EXTENSION lo;
CREATE EXTENSION

Расширение создает тип данных lo (обертка над oid) и функцию lo_manage для использования в триггерных функциях.

=> CREATE TABLE demo_lo (filename text, link lo);
CREATE TABLE
=> CREATE TRIGGER t_link
BEFORE UPDATE OR DELETE ON demo_lo
FOR EACH ROW
EXECUTE FUNCTION lo_manage(link);
CREATE TRIGGER

Загрузим большой объект и поместим ссылку на него в таблицу.

=> INSERT INTO demo_lo VALUES (
    'bookstore2.sql',
    lo_import('/tmp/bookstore2.sql')
);
INSERT 0 1

Убедимся, что все на месте, и удалим.

=> SELECT filename, lo_get(link,1,16) FROM demo_lo;
    filename    |               lo_get
----------------+------------------------------------
 bookstore2.sql | \x2d0a2d2d20506f737467726553514c20
(1 row)
=> DELETE FROM demo_lo;
DELETE 1
=> \lo_list
      Large objects
 ID | Owner | Description
----+-------+-------------
(0 rows)

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

Page 18: Итоги

Для работы с двоичными данными предоставлены

  • тип bytea, использующий технологию TOAST
  • подсистема «больших объектов»
  • Использование файловой системы позволяет добиться высокой скорости за счет отказа от возможностей СУБД

Page 19: Практика

  1. Добавьте в таблицу books поле cover с типом bytea. Загрузите в поле cover обложки книг из файлов формата jpeg. Файлы находятся в каталоге /home/student/covers. 2. Создайте функцию webapi.get_image, возвращающую обложку книги по переданному идентификатору. 3. Сравните время, за которое выполняется запрос ко всем столбцам таблицы books (SELECT *) и к столбцам без cover.

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

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

  2. Функция get_image уже создана в базе данных, но ничего не возвращает. Заголовок функции:

CREATE FUNCTION webapi.get_image(book_id integer) RETURNS bytea

Page 20: 1. Перекладывание сканов обложек в базу данных

Сканы обложек находятся в каталоге covers:

student$ ls ~/covers/ | head -n 10
abelson_prog.jpg
adelson_chess.jpg
aho_compilers.jpg
aho_structalgo.jpg
ben-ari_pl.jpg
bogdesko_calligraphy.jpg
boswell_readable.jpg
bringhurst_style.jpg
brooks_myth.jpg
camp_no.jpg

Названия файлов хранятся в таблице books в столбце cover_filename. Расширим таблицу столбцом для хранения картинок:

=> ALTER TABLE books ADD cover bytea;
ALTER TABLE

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

=> ALTER TABLE books ALTER COLUMN cover SET STORAGE external;
ALTER TABLE

Переложим файлы в этот столбец:

=> UPDATE books b
SET cover = pg_read_binary_file(
    '/home/student/covers/' || b.cover_filename
);
UPDATE 100

2. Функция, возвращающая скан обложки

=> CREATE OR REPLACE FUNCTION webapi.get_image(book_id bigint)
RETURNS bytea
LANGUAGE sql STABLE SECURITY DEFINER
RETURN (SELECT b.cover
        FROM books b
WHERE b.book_id = get_image.book_id);
CREATE FUNCTION

3. Время выполнения SELECT \*

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

Page 21: Практика+

  1. Создайте таблицу с полем типа bytea. Загрузите в таблицу любой файл, этот же файл загрузите как большой объект. Выполните контрольную точку, чтобы в кеше не осталось грязных буферов. 2. Добавьте один произвольный байт к значению в столбце типа bytea таблицы. Сколько грязных буферов, относящихся к TOAST-таблице, появилось в буферном кеше? 3. Добавьте один произвольный байт к значению большого объекта. Сколько грязных буферов, относящихся к таблице pg_largeobject, появилось в буферном кеше?

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

2 и 3. Чтобы получить количество грязных буферов для таблицы, установите расширение pg_buffercache и воспользуйтесь запросом:

SELECT count(*)
FROM pg_buffercache b
WHERE b.relfilenode = pg_relation_filenode('table_name'::regclass)
AND b.relforknumber = 0 -- основной слой
AND isdirty;
  1. Можно воспользоваться функцией lo_put, записывающей данные по заданному смещению.

Page 22: 1. Подготовка

=> CREATE DATABASE ext_lob;
CREATE DATABASE
=> \c ext_lob
You are now connected to database "ext_lob" as user "student".

Расширение для просмотра буферного кеша:

=> CREATE EXTENSION pg_buffercache;
CREATE EXTENSION

Создадим таблицу со столбцом типа bytea:

=> CREATE TABLE demo_bytea (filename text, data bytea);
CREATE TABLE

Загрузим файл с одной из обложек книг в таблицу:

=> INSERT INTO demo_bytea VALUES (
    'novikov_dbtech.jpg',
    pg_read_binary_file('/home/student/covers/novikov_dbtech.jpg')
);
INSERT 0 1

Этот же файл загрузим как большой объект и запомним его oid:

=> SELECT lo_import('/home/student/covers/novikov_dbtech.jpg') AS "oid";
  oid
-------
 16581
(1 row)

Для чистоты эксперимента очистим таблицы и сбросим все грязные буферы на диск:

=> VACUUM (analyze) demo_bytea, pg_largeobject;
VACUUM
=> CHECKPOINT;
CHECKPOINT

2. Изменение bytea

Добавим к значению нулевой байт:

=> UPDATE demo_bytea SET data = data || '\x00'::bytea;
UPDATE 1

Сколько всего страниц в основном слое TOAST-таблицы?

=> SELECT reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE oid = 'demo_bytea'::regclass;
       toast_table
-------------------------
 pg_toast.pg_toast_16575
(1 row)
=> SELECT pg_relation_size('pg_toast.pg_toast_16575', 'main') / 8192 buffers;
 buffers
---------
     294
(1 row)

А сколько грязных буферов хранит страницы основного слоя?

Page 23: Page 23

=> SELECT count(*)
FROM pg_buffercache b
WHERE b.relfilenode = pg_relation_filenode('pg_toast.pg_toast_16575')
AND b.relforknumber = 0 -- основной слой
AND b.isdirty;
 count
-------
   294
(1 row)

То есть после изменения одного байта все страницы TOAST-таблицы стали грязными.

3. Изменение большого объекта

Добавим в конец объекта нулевой байт:

=> SELECT size FROM pg_stat_file('/home/student/covers/novikov_dbtech.jpg');
  size
---------
 1170438
(1 row)
=> SELECT lo_put(16581, 1170438, '\x00'::bytea);
 lo_put
--------
(1 row)

Посчитаем грязные буферы таблицы pg_largeobject:

=> SELECT count(*)
FROM pg_buffercache b
WHERE b.relfilenode = pg_relation_filenode('pg_largeobject')
AND b.relforknumber = 0 -- основной слой
AND b.isdirty;
 count
-------
     1
(1 row)

Грязным стал всего один буфер.

09 Занятие 09: Расширяемость / Пользовательские типы данных

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

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

Page 1: Расширяемость / Пользовательские типы данных

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

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

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

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

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

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

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

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

Page 2: Темы

  • Составные типы (краткое повторение)
  • Типы перечислений
  • Диапазонные типы и мультидиапазоны
  • Базовые типы
  • Домены
  • Приведение типов, операторы

Page 3: Расширяемость типов

Богатый набор существующих типов

  • числа, строки, даты, слабоструктурированные, геометрия, …
  • массивы
  • Средства для создания новых типов
    • простые: на основе уже имеющихся типов
    • сложные: новый тип «с нуля»

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

PostgreSQL имеет множество встроенных типов данных (и поддержку массивов элементов любых типов данных).

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

Вместе с тем имеются и средства для создания своих собственных типов данных.

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

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

Page 4: Составные типы

Набор именованных атрибутов (табличная строка)

  • Создание нового типа
    • вручную: CREATE TYPE AS
    • автоматически при создании отношения

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

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

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

Новый составной тип можно создать и вручную.

Составные типы определены в стандарте SQL. Они подробно рассматриваются в курсе DEV1, поэтому здесь мы не будем на них останавливаться.

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

https://postgrespro.ru/docs/postgresql/16/functions-comparisons#COMPOSI TE-TYPE-COMPARISON

Page 5: Типы перечислений

Упорядоченный набор значений

  • значения выглядят как текстовые строки
  • хранятся как 4-байтовые числа (oid)
  • Создание нового типа
    • только вручную: CREATE TYPE AS ENUM

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

Тип перечисления создается простым перечислением всех допустимых значений.

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

Стандартных типов перечисления в PostgreSQL нет.

https://postgrespro.ru/docs/postgresql/16/datatype-enum

https://postgrespro.ru/docs/postgresql/16/functions-enum

Page 6: Типы перечислений

=> CREATE DATABASE ext_datatypes;
CREATE DATABASE
=> \c ext_datatypes
You are now connected to database "ext_datatypes" as user "student".

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

=> CREATE TYPE ranks AS ENUM (
    '6', '7', '8', '9', '10', 'Queen', 'King', 'Ace'
);
CREATE TYPE

И для мастей:

=> CREATE TYPE suits AS ENUM (
    'clubs', 'diamonds', 'hearts', 'spades'
);
CREATE TYPE

Напомним, что тип данных — отдельный объект базы данных, который хранится в системном каталоге:

=> \dT public.*
      List of data types
 Schema | Name  | Description
--------+-------+-------------
 public | ranks |
 public | suits |
(2 rows)

Чтобы вспомнить и составные типы, определим еще тип для карты:

=> CREATE TYPE cards AS (
    rank ranks,
    suit suits
);
CREATE TYPE
=> DO $$
DECLARE
    card cards;
BEGIN
    card := ('Ace','spades');
    RAISE NOTICE '%', card;
    RAISE NOTICE 'Старшинство: % ... %',
        enum_first(card.rank), enum_last(card.rank);
    RAISE NOTICE 'Масти: % ... %',
            enum_first(card.suit), enum_last(card.suit);
END;
$$;
NOTICE:  (Ace,spades)
NOTICE:  Старшинство: 6 ... Ace
NOTICE:  Масти: clubs ... spades
DO

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

=> SELECT '6'::ranks < 'Ace'::ranks;
 ?column?
----------
 t
(1 row)

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

Page 7: Page 7

=> CREATE TYPE suits_bis AS ENUM (
    'clubs', 'diamonds', 'hearts', 'spades'
);
CREATE TYPE
=> SELECT 'hearts'::suits = 'hearts'::suits_bis;
ERROR:  operator does not exist: suits = suits_bis
LINE 1: SELECT 'hearts'::suits = 'hearts'::suits_bis;
                               ^
HINT:  No operator matches the given name and argument types. You might need to add
explicit type casts.

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

=> SELECT 'hearts'::suits::text = 'hearts'::suits_bis::text;
 ?column?
----------
 t
(1 row)

Можно получить список значений в виде массива (функция enum_range смотрит только на тип параметра; само значение может быть любым):

=> DO $$
DECLARE
    suit suits;
    rank ranks;
    deck cards[];
BEGIN
    RAISE NOTICE '%', enum_range(NULL::ranks);
    RAISE NOTICE '%', enum_range(NULL::suits);
    FOREACH rank IN ARRAY enum_range(NULL::ranks) LOOP
        FOREACH suit IN ARRAY enum_range(NULL::suits) LOOP
            deck := deck || (rank,suit)::cards;
        END LOOP;
    END LOOP;
END;
$$;
NOTICE:  {6,7,8,9,10,Queen,King,Ace}
NOTICE:  {clubs,diamonds,hearts,spades}
DO

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

=> ALTER TYPE ranks ADD VALUE 'Jack' BEFORE 'Queen';
ALTER TYPE

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

=> SELECT enumsortorder, enumlabel FROM pg_enum WHERE enumtypid = 'ranks'::regtype ORDER BY enumsortorder;
 enumsortorder | enumlabel
---------------+-----------
             1 | 6
             2 | 7
             3 | 8
             4 | 9
             5 | 10
           5.5 | Jack
             6 | Queen
             7 | King
             8 | Ace
(9 rows)

Page 8: Диапазонные типы

Непрерывный отрезок значений от начальной до конечной точки

  • открытый (>, <), закрытый (≤, ≥), неограниченный
  • Создание нового типа
    • CREATE TYPE AS RANGE
    • уже есть для integer, bigint, numeric, timestamp, date
  • Операции
    • сравнение
    • проверка на включение диапазона или элемента, на пересечение
    • объединение, пересечение, вычитание
    • и др.

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

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

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

● проверки на включение одного диапазона в другой, на принадлежность значения диапазона, на пересечение двух диапазонов;

● обычные операции над множествами: объединение, пересечение, вычитание.

Диапазоны предопределены для различных числовых типов и дат (например, int4range для integer и др.). Можно создать и свой собственный диапазонный тип.

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

https://postgrespro.ru/docs/postgresql/16/functions-range

Page 9: Мультидиапазоны

Множество диапазонов

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

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

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

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

Page 10: Диапазонные типы

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

=> SELECT '[1,9]'::int4range;
 int4range
-----------
 [1,10)
(1 row)

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

=> SELECT -100 <@ '[1,)'::int4range, 100 <@ '[1,)'::int4range;
 ?column? | ?column?
----------+----------
 f        | t
(1 row)

Точно так же можно проверить включение одного диапазона в другой:

=> SELECT '[2,3]'::int4range <@ '[1,4]'::int4range;
 ?column?
----------
 t
(1 row)

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

=> SELECT '[1,3]'::int4range * '[2,4]'::int4range;
 ?column?
----------
 [2,4)
(1 row)

При создании нового диапазонного типа указываются: базовый тип, значения которого должны быть сортируемыми; функция разности двух значений, которая должна возвращать результат типа double precision. Например, в PostgreSQL нет типа для диапазонов времени (без даты). Чтобы определить его, нам потребуется функция, возвращающая разность между двумя временами. Обычная разность возвращает значение типа interval:

=> SELECT '10:00'::time - '9:59'::time;
 ?column?
----------
 00:01:00
(1 row)

Но мы можем воспользоваться функцией extract epoch, которая вернет длину диапазона в секундах:

=> CREATE FUNCTION time_diff(a time, b time) RETURNS double precision
LANGUAGE sql STRICT IMMUTABLE
RETURN extract(epoch FROM (a - b));
CREATE FUNCTION
=> SELECT time_diff('10:00','9:59');
 time_diff
-----------
        60
(1 row)

Page 11: Page 11

Теперь можно определить тип диапазона, а вместе с ним — и мультидиапазона:

=> CREATE TYPE timerange AS RANGE (
    subtype = time,
    subtype_diff = time_diff,
    multirange_type_name = timemultirange
);
CREATE TYPE

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

=> SELECT timerange('9:00','11:00','[]') + '[10:00,12:00]'::timerange;
      ?column?
---------------------
 [09:00:00,12:00:00]
(1 row)

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

=> SELECT timemultirange(
      timerange('09:00', '13:00', '[)'),
      timerange('08:00', '10:00', '[)'),
      timerange('14:00', '18:00', '[)')
) AS working_hours;
               working_hours
-------------------------------------------
 {[08:00:00,13:00:00),[14:00:00,18:00:00)}
(1 row)

Обратите внимание, что пересекающиеся диапазоны были объединены.

Page 12: Интервалы дат и времени

Интервал значений определенной длины

  • Операции
    • арифметика дат и времени
    • функции корректировки

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

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

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

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

https://postgrespro.ru/docs/postgresql/16/datatype-datetime

https://postgrespro.ru/docs/postgresql/16/functions-datetime

Page 13: Интервалы

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

=> SELECT timestamp '01.02.2020' - timestamp '01.01.2020';
 ?column?
----------
 31 days
(1 row)

(Хотя та же операция для дат (тип date) возвращает целое число.) Интервалы используются в арифметике дат и времени. Например:

=> SELECT now(), now() + 2 * interval '1 month';
              now              |           ?column?
-------------------------------+-------------------------------
 2026-04-10 16:05:06.856995+03 | 2026-06-10 16:05:06.856995+03
(1 row)

Обратите внимание, что интервал ‘1 month’ может иметь разную длину — от 28 до 31 дней в зависимости от месяца. При необходимости можно преобразовать интервал так, чтобы, например, каждый 24-часовой период считался одним днем:

=> SELECT interval '29 hours', justify_hours(interval '29 hours');
 interval | justify_hours
----------+----------------
 29:00:00 | 1 day 05:00:00
(1 row)

Page 14: Домены

Ограничение допустимых значений существующего типа

  • ограничение NOT NULL
  • значение по умолчанию DEFAULT
  • проверка CHECK
  • Создание нового типа
    • только вручную: CREATE DOMAIN

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

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

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

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

Домены определены в стандарте SQL.

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

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

Page 15: Домены

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

=> CREATE DOMAIN work_timerange AS timerange
NOT NULL
CHECK (VALUE <@ '[10:00,19:00)'::timerange);
CREATE DOMAIN

Его можно использовать при создании таблицы:

=> CREATE TABLE work_events(
    event_name text,
    event_range work_timerange
);
CREATE TABLE
=> INSERT INTO work_events VALUES (
    'обед', '[13:00,14:00)'
);
INSERT 0 1

А так получится?

=> INSERT INTO work_events VALUES (
    'труд', '[14:00,22:00)'
);
ERROR:  value for domain work_timerange violates check constraint "work_timerange_check"

Нет — нарушено условие проверки. А так?

=> INSERT INTO work_events VALUES (
    'лень', NULL
);
ERROR:  domain work_timerange does not allow null values

Нет — неопределенные значения не допускаются. А если так?

=> INSERT INTO work_events VALUES (
    'лень', (SELECT event_range FROM work_events WHERE false)
);
INSERT 0 1

А так получится. Запрет неопределенных значений на уровне домена работает не так, как на уровне столбца. Ограничение NOT NULL надежнее указывать при создании таблицы. Еще одна проблема связана с тем, что при любых операциях значения приводятся к базовому типу. Это не позволяет использовать домены для контроля типов в операциях. Вот пример:

=> CREATE DOMAIN distance AS float;
CREATE DOMAIN
=> CREATE DOMAIN weight AS float;
CREATE DOMAIN

Можно ли складывать метры с граммами?

=> SELECT 2::distance + 3::weight;
 ?column?
----------
        5
(1 row)

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

Page 16: Базовые типы

Тип, не сводящийся к комбинации существующих

  • Создание нового типа
    • CREATE TYPE, требует программирования на языке C
    • множество типов предоставляются расширениями

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

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

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

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

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

Page 17: Действия над типами

Функции, принимающие или возвращающие значения типа

  • Приведение типов
    • преобразование значения одного типа к другому типу
  • Операторы
    • унарные (префиксные)
    • бинарные (инфиксные)

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

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

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

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

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

Операторы позволяют вместо вызова функции f(x,y) использовать инфиксную нотацию, например x+y, что позволяет сделать код проще и нагляднее. Кроме того, как мы увидим в следующей теме «Классы операторов», операторы играют важную роль в индексировании.

Приведение типов в документации:

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

Операторы:

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

https://postgrespro.ru/docs/postgresql/16/xoper-optimization

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

Page 18: Приведение типов

В качестве примера рассмотрим приведение типа timerange к типу interval. Сработает ли такое приведение само по себе?

=> SELECT '[10:00,12:00]'::timerange::interval;
ERROR:  cannot cast type timerange to interval
LINE 1: SELECT '[10:00,12:00]'::timerange::interval;
                                         ^

Увы, нет. Чтобы создать такое приведение, сначала напишем функцию:

=> CREATE FUNCTION timerange_to_interval(a timerange) RETURNS interval
LANGUAGE sql STRICT IMMUTABLE
RETURN make_interval(
    secs => extract( epoch FROM (upper(a)-lower(a)) )
);
CREATE FUNCTION
=> SELECT timerange_to_interval('[10:00,12:00]');
 timerange_to_interval
-----------------------
 02:00:00
(1 row)

Теперь можно создать приведение типов. Здесь мы указываем AS IMPLICIT, чтобы приведение срабатывало и неявно.

=> CREATE CAST (timerange AS interval)
WITH FUNCTION timerange_to_interval
AS IMPLICIT;
CREATE CAST
=> SELECT '[10:00,12:00]'::timerange::interval;
 interval
----------
 02:00:00
(1 row)

Операторы

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

=> CREATE FUNCTION interval_div(a interval, b interval) RETURNS double precision
LANGUAGE sql STRICT IMMUTABLE
RETURN extract(epoch FROM a) / extract(epoch FROM b);
CREATE FUNCTION

Теперь мы можем узнать, сколько раз можно послушать композицию Your Latest Trick, длящуюся 6 минут 33 секунды, если у нас есть полтора часа времени:

=> SELECT interval_div('1:30:00'::interval,'0:06:33'::interval);
    interval_div
--------------------
 13.740458015267176
(1 row)

Определим бинарный оператор, используя эту функцию:

=> CREATE OPERATOR / (
    FUNCTION = interval_div,
    LEFTARG = interval,
    RIGHTARG = interval
);

Page 19: Page 19

CREATE OPERATOR

Имя оператора можно составлять только из специальных символов, список которых приведен в документации к команде CREATE OPERATOR. Если не указать LEFTARG, будет определен префиксный оператор. Постфиксные операторы не поддерживаются начиная с PostgreSQL 14. Теперь можно пользоваться удобной инфиксной формой записи (здесь также показан другой способ записи интервалов):

=> SELECT interval '1 hour 30 min' / interval '6 min 33 sec';
      ?column?
--------------------
 13.740458015267176
(1 row)

Page 20: Итоги

PostgreSQL позволяет создавать новые типы данных

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

Page 21: Практика

  1. Реализуйте в приложении возможность установки розничной цены книг с указанной пользователем даты (сейчас дата просто игнорируется). Предыдущая история изменений цен должна сохраняться. Для этого добавьте в таблицу retail_prices информацию о периоде действия цены и внесите необходимые изменения в функции get_retail_price и set_retail_price. 2. Создайте составной тип для формата издания, состоящего из размеров типографского листа и доли листа. Также создайте приведение для этого нового типа к текстовому. Замените тип данных столбца format таблицы books и убедитесь, что интерфейс с приложением не изменился.

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

  1. Интервал действия розничной цены можно представить по-разному:

● двумя столбцами типа timestamptz («дата с» и «дата по»);

● одним столбцом типа timestamptz («дата с»), при этом цена считается действующей до следующей даты в другой строке таблицы;

● одним столбцом диапазонного типа tstzrange.

Реализуйте последний вариант. Обратите внимание:

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

● Функция get_retail_price должна получать цену на текущий момент. Используйте функцию current_timestamp (возвращающую время начала транзакции), а не clock_timestamp. Это будет важно при последующих изменениях.

  1. Формат издания записывается в виде WxH/N, где W и H — ширина и высота типографского листа, с которым работает печатная машина, и который разрезается потом на N одинаковых частей (страниц книги). Поэтому N как правило представляет собой степень двойки.

Несмотря на то, что отношение длины страницы к ширине может быть разным, форматы книг можно упорядочить, сравнивая площади страниц, которые равны W*H/N.

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

Page 22: 1. Розничная цена на книги

Добавим в таблицу retail_prices столбец с диапазоном дат. Значение по умолчанию — неограниченный диапазон.

=> ALTER TABLE public.retail_prices
    ADD effective tstzrange NOT NULL DEFAULT '(,)';
ALTER TABLE

Функция получения текущей цены должна выбрать тот диапазон, в который входит текущая дата:

=> CREATE OR REPLACE FUNCTION public.get_retail_price(book_id bigint) RETURNS numeric
LANGUAGE sql STABLE SECURITY DEFINER
RETURN (SELECT rp.price
FROM retail_prices rp
        WHERE rp.book_id = get_retail_price.book_id
    AND rp.effective @> current_timestamp);
CREATE FUNCTION

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

=> CREATE OR REPLACE FUNCTION empapi.set_retail_price (
    book_id bigint,
    price numeric,
    at timestamptz
)
RETURNS void
AS $$
DECLARE
    lower_bound timestamptz;
    upper_bound timestamptz;
BEGIN
    PERFORM FROM books b
    WHERE b.book_id = set_retail_price.book_id
    FOR UPDATE;
    SELECT lower(rp.effective), upper(rp.effective)
    INTO lower_bound, upper_bound
    FROM retail_prices rp
    WHERE rp.book_id = set_retail_price.book_id
      AND at <@ rp.effective;
    IF at = lower_bound THEN
        -- только обновляем цену в существующем диапазоне
        UPDATE retail_prices rp
        SET price = set_retail_price.price
        WHERE rp.book_id = set_retail_price.book_id
          AND at <@ rp.effective
        ;
    ELSE
        -- закрываем существующий диапазон...
        UPDATE retail_prices rp
        SET effective = tstzrange(lower_bound, at, '[)')
        WHERE rp.book_id = set_retail_price.book_id
          AND at <@ rp.effective;
        -- ...и добавляем новый
        INSERT INTO retail_prices (
            book_id,
            price,
            effective
        ) VALUES (
            book_id,
            price,
            tstzrange(at, upper_bound, '[)')
        );
    END IF;
END;
$$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
CREATE FUNCTION

Проверим.

=> BEGIN;

Page 23: Page 23

BEGIN
=> SELECT * FROM retail_prices WHERE book_id = 1 ORDER BY effective;
 book_id | price | effective
---------+-------+-----------
       1 |   640 | (,)
(1 row)

Вставка новой цены в конец:

=> SELECT empapi.set_retail_price(1, 200.00, '2026-04-11 16:24:00+03');
 set_retail_price
------------------
(1 row)
=> SELECT * FROM retail_prices WHERE book_id = 1 ORDER BY effective;
 book_id | price  |          effective
---------+--------+-----------------------------
       1 |    640 | (,"2026-04-11 16:24:00+03")
       1 | 200.00 | ["2026-04-11 16:24:00+03",)
(2 rows)

Вставка не в конец:

=> SELECT empapi.set_retail_price(1, 300.00, '2026-04-10 16:24:00+03');
 set_retail_price
------------------
(1 row)
=> SELECT * FROM retail_prices WHERE book_id = 1 ORDER BY effective;
 book_id | price  |                      effective
---------+--------+-----------------------------------------------------
       1 |    640 | (,"2026-04-10 16:24:00+03")
       1 | 300.00 | ["2026-04-10 16:24:00+03","2026-04-11 16:24:00+03")
       1 | 200.00 | ["2026-04-11 16:24:00+03",)
(3 rows)

Цена с уже существующей датой (новый интервал не появляется):

=> SELECT empapi.set_retail_price(1, 400.00, '2026-04-10 16:24:00+03');
 set_retail_price
------------------
(1 row)
=> SELECT * FROM retail_prices WHERE book_id = 1 ORDER BY effective;
 book_id | price  |                      effective
---------+--------+-----------------------------------------------------
       1 |    640 | (,"2026-04-10 16:24:00+03")
       1 | 400.00 | ["2026-04-10 16:24:00+03","2026-04-11 16:24:00+03")
       1 | 200.00 | ["2026-04-11 16:24:00+03",)
(3 rows)
=> ROLLBACK;
ROLLBACK

2. Тип данных для формата издания

Тип данных:

=> CREATE TYPE book_format AS (
   width integer,
   height integer,
   parts integer
);
CREATE TYPE

Page 24: Page 24

Преобразование в текст:

=> CREATE FUNCTION book_format_to_text(f book_format) RETURNS text
LANGUAGE sql STRICT IMMUTABLE
RETURN f.width || 'x' || f.height || '/' || f.parts;
CREATE FUNCTION
=> CREATE CAST (book_format AS text)
WITH FUNCTION book_format_to_text AS IMPLICIT;
CREATE CAST

Проверим:

=> SELECT (90,60,16)::book_format::text;
   row
----------
 90x60/16
(1 row)

И обратно:

=> CREATE FUNCTION text_to_book_format(f text) RETURNS book_format
LANGUAGE sql STRICT IMMUTABLE
RETURN (SELECT (m[1],m[2],m[3])::book_format
FROM regexp_match(f, '(\d+)x(\d+)/(\d+)') m);
CREATE FUNCTION
=> CREATE CAST (text AS book_format)
WITH FUNCTION text_to_book_format AS IMPLICIT;
CREATE CAST

Проверим:

=> SELECT '90x60/16'::text::book_format;
 book_format
-------------
 (90,60,16)
(1 row)

Обратите внимание, что если написать просто:

=> SELECT '90x60/16'::book_format;
ERROR:  malformed record literal: "90x60/16"
LINE 1: SELECT '90x60/16'::book_format;
               ^
DETAIL:  Missing left parenthesis.

то произойдет ошибка: здесь срабатывает не приведение типа text в book_format, а создание типа book_format из литерала, которое использует фиксированный формат для всех составных типов:

=> SELECT '(90,60,16)'::book_format;
 book_format
-------------
 (90,60,16)
(1 row)

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

=> SELECT pg_relation_filepath('books');
 pg_relation_filepath
----------------------
 base/16388/16415
(1 row)

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

=> BEGIN;
BEGIN

Page 25: Page 25

=> ALTER TABLE books ALTER COLUMN format SET DATA TYPE book_format;
ALTER TABLE
=> SELECT relation::regclass, mode
FROM pg_locks
WHERE relation = 'books'::regclass;
 relation |        mode
----------+---------------------
 books    | ShareLock
 books    | AccessExclusiveLock
(2 rows)

Операция выполняется в несколько шагов, но при перезаписи таблицы используется исключительная блокировка. В этой же транзакции надо внести изменения и в интерфейсные функции приложения. Формат возвращают две функции: webapi.get_catalog и empapi.get_catalog, но обе они вызывают одну и ту же функцию public.get_catalog. К счастью, в этой функции столбец format явно приводится к типу text, поэтому никаких изменений не требуется.

=> COMMIT;
COMMIT

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

=> SELECT pg_relation_filepath('books');
 pg_relation_filepath
----------------------
 base/16388/16676
(1 row)

Page 26: Практика+

  1. Производственные смены каждого сотрудника фабрики хранятся в таблице. Напишите запрос, который покажет, сколько человеко-часов пропадет даром, если в некоторый момент времени на 15 минут будет отключено электричество. 2. В таблице, содержащей много данных, используется столбец статуса, имеющий тип перечисления. Проверьте, какие блокировки будут установлены на таблицу при необходимости добавления нового статуса, и какое время занимает эта операция. Сравните с альтернативным подходом, при котором перечисление реализуется как обычный текстовый тип с ограничением CHECK на уровне столбца или домена.

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

  1. Производственные смены могут храниться в таблице вида: CREATE TABLE shifts ( employee_name text, work_hours tstzrange );

  2. Блокировки можно посмотреть в таблице pg_locks, как говорилось в теме «Обзор блокировок».

Кроме обычной проверки status text CHECK (status IN (…))

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

Page 27: 1. Человеко-часы

=> CREATE DATABASE ext_datatypes;
CREATE DATABASE
=> \c ext_datatypes
You are now connected to database "ext_datatypes" as user "student".

Таблица с производственными сменами:

=> CREATE TABLE shifts (
    employee_name text,
    work_hours tstzrange
);
CREATE TABLE

Добавим несколько записей (рабочий день с перерывом на обед):

=> INSERT INTO shifts VALUES
    ('alice',   '[2020-04-01 09:00,2020-04-01 13:00)'),
    ('alice',   '[2020-04-01 14:00,2020-04-01 18:00)'),
    ('bob',     '[2020-04-01 10:00,2020-04-01 14:00)'),
    ('bob',     '[2020-04-01 15:00,2020-04-01 17:00)'),
    ('charlie', '[2020-04-01 08:30,2020-04-01 12:30)'),
    ('charlie', '[2020-04-01 13:30,2020-04-01 17:30)');
INSERT 0 6

Результат:

=> WITH intersection(r) AS (
    SELECT work_hours * '[2020-04-01 09:50,2020-04-01 10:05)'::tstzrange
    FROM shifts
)
SELECT sum(upper(r) - lower(r))
FROM intersection;
   sum
----------
 00:35:00
(1 row)

2. Блокировки при добавлении значения

Тип перечисления и таблица:

=> CREATE TYPE statuses AS ENUM ('todo', 'done');
CREATE TYPE
=> CREATE TABLE process (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    status statuses
);
CREATE TABLE
=> INSERT INTO process(status)
    SELECT 'todo' FROM generate_series(1,1_000_000);
INSERT 0 1000000

Начинаем транзакцию:

=> BEGIN;
BEGIN
=> \timing on
Timing is on.
=> ALTER TYPE statuses ADD VALUE 'in progress';
ALTER TYPE
Time: 0,292 ms

Page 28: Page 28

=> \timing off
Timing is off.

Выведем только блокировки таблицы:

=> SELECT relation::regclass, mode
FROM pg_locks
WHERE relation = 'process'::regclass;
 relation | mode
----------+------
(0 rows)
=> COMMIT;
COMMIT

Итак: добавление выполняется быстро; таблица не блокируется. Вариант с проверкой CHECK.

=> DROP TABLE process;
DROP TABLE
=> DROP TYPE statuses;
DROP TYPE
=> CREATE TABLE process (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    status text CHECK (status IN ('todo','done'))
);
CREATE TABLE
=> INSERT INTO process(status)
    SELECT 'todo' FROM generate_series(1,1_000_000);
INSERT 0 1000000
=> BEGIN;
BEGIN
=> \timing on
Timing is on.
=> ALTER TABLE process
    DROP CONSTRAINT process_status_check,
    ADD CHECK (status IN ('todo','done','in progress'));
ALTER TABLE
Time: 248,148 ms
=> \timing off
Timing is off.
=> SELECT relation::regclass, mode
FROM pg_locks
WHERE relation = 'process'::regclass;
 relation |        mode
----------+---------------------
 process  | AccessExclusiveLock
(1 row)
=> COMMIT;
COMMIT

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

Page 29: Page 29

Вариант с проверкой на домене.

=> DROP TABLE process;
DROP TABLE
=> CREATE DOMAIN statuses AS text CHECK (VALUE IN ('todo','done'));
CREATE DOMAIN
=> CREATE TABLE process (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    status statuses
);
CREATE TABLE
=> INSERT INTO process(status)
    SELECT 'todo' FROM generate_series(1,1_000_000);
INSERT 0 1000000
=> BEGIN;
BEGIN
=> \timing on
Timing is on.
=> ALTER DOMAIN statuses
    DROP CONSTRAINT statuses_check;
ALTER DOMAIN
Time: 0,580 ms
=> ALTER DOMAIN statuses
    ADD CHECK (VALUE IN ('todo','done','in progress'));
ALTER DOMAIN
Time: 214,247 ms
=> \timing off
Timing is off.
=> SELECT relation::regclass, mode
FROM pg_locks
WHERE relation = 'process'::regclass;
 relation |   mode
----------+-----------
 process  | ShareLock
(1 row)
=> COMMIT;
COMMIT

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

10 Занятие 10: Расширяемость / Классы операторов

Исходная страница в нативном Markdown: Расширяемость / Классы операторов
Примечание

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

Page 1: Расширяемость / Классы операторов

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

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

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

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

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

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

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

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

Page 2: Темы

  • Методы доступа (типы индексов)
  • Классы и семейства операторов
  • Метод доступа btree и создание класса операторов для него
  • Идея метода доступа gist и примеры его использования

Page 3: Методы доступа

Определяют способ получения данных

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

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

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

Табличный метод определяет организацию данных в таблице и работает, когда надо прочитать все данные (Seq Scan). В PostgreSQL существует единственный табличный метод heap, и других готовых методов (таких, например, как колоночное хранилище) еще нет, но они должны появиться в будущем.

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

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

Индексный метод можно рассматривать как каркас, который реализует основные алгоритмы для работы с индексной структурой данных, а также берет на себя заботу о низкоуровневых деталях, таких как:

● эффективная конкурентная работа с индексной структурой (в том числе стратегия блокирования);

● страничная организация данных индекса;

● журналирование операций над индексом в WAL.

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

Page 4: Методы доступа

=> CREATE DATABASE ext_opclasses;
CREATE DATABASE
=> \c ext_opclasses
You are now connected to database "ext_opclasses" as user "student".

В версии 16 имеется единственный встроенный табличный метод доступа:

=> SELECT amname FROM pg_am WHERE amtype = 't';
 amname
--------
 heap
(1 row)

Зато много различных индексных методов доступа:

=> SELECT amname FROM pg_am WHERE amtype = 'i';
 amname
--------
 btree
 hash
 gist
 gin
 spgist
 brin
(6 rows)

С btree знакомы все — это «обычный» метод доступа на основе B-дерева, который используется по умолчанию и покрывает большинство потребностей. Остальные методы доступа также очень полезны, но в специальных ситуациях. Некоторые из них мы рассмотрим позже. Для получения информации мы делали запросы к таблицам системного каталога, однако в арсенале psql есть удобная команда, позволяющая получить информацию о методах доступа, классах и семействах операторов, ей дальше мы и будем пользоваться:

=> \dA
List of access methods
  Name  | Type
--------+-------
 brin   | Index
 btree  | Index
 gin    | Index
 gist   | Index
 hash   | Index
 heap   | Table
 spgist | Index
(7 rows)

Page 5: Классы операторов

Source slide page 5: Классы операторов{#source-figure-005 caption=“Source slide page 5: Классы операторов”}

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

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

Дальше мы будем говорить не о табличном, а об индексном доступе.

Итак, с одной стороны есть индексный метод доступа (например, btree), а с другой — конкретные типы данных (такие, как integer, text и т. п.).

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

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

Для B-деревьев это не так очевидно, поскольку на долю класса операторов приходится совсем немного. Но в других методах доступа разные классы операторов могут радикально менять логику.

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

https://postgrespro.ru/docs/postgresql/16/indexes-opclass

Page 6: Классы операторов

Посмотрим, какие классы операторов определены для B-дерева и для разных типов данных. Для целых чисел:

=> \dAc btree (smallint|integer|bigint)
                   List of operator classes
  AM   | Input type | Storage type | Operator class | Default?
-------+------------+--------------+----------------+----------
 btree | bigint     |              | int8_ops       | yes
 btree | integer    |              | int4_ops       | yes
 btree | smallint   |              | int2_ops       | yes
(3 rows)

Для удобства такие «похожие по смыслу» классы операторов объединяются в семейства операторов:

=> \dAf btree (smallint|integer|bigint)
              List of operator families
  AM   | Operator family |     Applicable types
-------+-----------------+---------------------------
 btree | integer_ops     | smallint, integer, bigint
(1 row)

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

=> \dAc btree text
                      List of operator classes
  AM   | Input type | Storage type |   Operator class    | Default?
-------+------------+--------------+---------------------+----------
 btree | text       |              | text_ops            | yes
 btree | text       |              | text_pattern_ops    | no
 btree | text       |              | varchar_ops         | no
 btree | text       |              | varchar_pattern_ops | no
(4 rows)

Классы операторов pattern_ops отличаются от обычных тем, что сравнивают строки посимвольно, игнорируя правила сортировки (collation). А так можно посмотреть, какие операторы включены в конкретный класс операторов:

=> \dAo btree bool_ops
               List of operators of operator families
  AM   | Operator family |      Operator       | Strategy | Purpose
-------+-----------------+---------------------+----------+---------
 btree | bool_ops        | <(boolean,boolean)  |        1 | search
 btree | bool_ops        | <=(boolean,boolean) |        2 | search
 btree | bool_ops        | =(boolean,boolean)  |        3 | search
 btree | bool_ops        | >=(boolean,boolean) |        4 | search
 btree | bool_ops        | >(boolean,boolean)  |        5 | search
(5 rows)

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

=> CREATE TABLE t(
    id integer GENERATED ALWAYS AS IDENTITY,
    s text
);
CREATE TABLE
=> INSERT INTO t(s) VALUES ('foo'), ('bar'), ('xy'), ('z');

Page 7: Page 7

INSERT 0 4

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

CREATE INDEX ON t(id, s);

Но это просто сокращение для:

=> CREATE INDEX ON t
USING btree -- метод доступа
(
    id int4_ops, -- класс операторов для integer
    s  text_ops  -- класс операторов по умолчанию для text
);
CREATE INDEX

Page 8: Метод доступа btree

Source slide page 8: Метод доступа btree{#source-figure-008 caption=“Source slide page 8: Метод доступа btree”}

Сбалансированное дерево, значения упорядочены

  • метод применим только для сортируемых типов данных
  • API метода доступа определяет порядок сортировки

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

Рассмотрим теперь несколько конкретных методов доступа, и начнем с B-деревьев.

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

Это позволяет находить искомое значение, спускаясь от вершины дерева к листьям, каждый раз однозначно выбирая подходящий диапазон. Например, для поиска буквы «H» (на рисунке) мы начинаем с корня. Поскольку «A» <= «H» < «I», мы спускаемся в первый дочерний узел, и так далее.

Итак, этот метод доступа применим только для сортируемых данных (к которым относятся большинство «обычных» типов данных). От класса операторов требуется только определить, как именно упорядочиваются значения конкретного типа.

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

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

Page 9: Класс операторов для B-дерева

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

=> CREATE TYPE capacity_units AS ENUM (
    'B', 'kB', 'MB', 'GB', 'TB', 'PB'
);
CREATE TYPE

И объявим составной тип данных для представления объема информации:

=> CREATE TYPE capacity AS (
    amount integer,
    unit capacity_units
);
CREATE TYPE

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

=> CREATE TABLE test (
    cap capacity
);
CREATE TABLE
=> INSERT INTO test
    SELECT ( (random()*1023)::integer, u.unit )::capacity
    FROM generate_series(1,100),
         unnest(enum_range(NULL::capacity_units)) AS u(unit);
INSERT 0 600

По умолчанию значения составного типа сортируются в лексикографическом порядке, но этот порядок не совпадает с естественным порядком:

=> SELECT * FROM test ORDER BY cap LIMIT 10;
   cap
---------
 (1,kB)
 (1,MB)
 (1,PB)
 (2,GB)
 (2,PB)
 (6,PB)
 (7,GB)
 (11,PB)
 (14,MB)
 (15,MB)
(10 rows)

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

=> CREATE FUNCTION capacity_to_bytes(a capacity) RETURNS numeric
LANGUAGE sql STRICT IMMUTABLE
RETURN a.amount::numeric *
    1024::numeric ^ ( array_position(enum_range(a.unit), a.unit)-1 );
CREATE FUNCTION

Помимо операторов сравнения нам понадобится еще одна вспомогательная функция — тоже для сравнения. Она должна возвращать: −1, если первый аргумент меньше второго; 0, если аргументы равны; 1, если первый аргумент больше второго.

Page 10: Page 10

=> CREATE FUNCTION capacity_cmp(a capacity, b capacity) RETURNS integer
LANGUAGE sql STRICT IMMUTABLE
RETURN CASE
    WHEN capacity_to_bytes(a) < capacity_to_bytes(b) THEN -1
    WHEN capacity_to_bytes(a) > capacity_to_bytes(b) THEN 1
    ELSE 0
END;
CREATE FUNCTION

С помощью этой функции мы определим пять операторов сравнения (и функции для них). Начнем с «меньше»:

=> CREATE FUNCTION capacity_lt(a capacity, b capacity) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN capacity_cmp(a,b) < 0;
CREATE FUNCTION
=> CREATE OPERATOR <(
    LEFTARG = capacity,
    RIGHTARG = capacity,
    FUNCTION = capacity_lt
);
CREATE OPERATOR

И аналогично остальные четыре.

=> CREATE FUNCTION capacity_le(a capacity, b capacity) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN capacity_cmp(a,b) <= 0;
CREATE FUNCTION
=> CREATE OPERATOR <=(
    LEFTARG = capacity,
    RIGHTARG = capacity,
    FUNCTION = capacity_le
);
CREATE OPERATOR
=> CREATE FUNCTION capacity_eq(a capacity, b capacity) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN capacity_cmp(a,b) = 0;
CREATE FUNCTION
=> CREATE OPERATOR =(
    LEFTARG = capacity,
    RIGHTARG = capacity,
    FUNCTION = capacity_eq
);
CREATE OPERATOR
=> CREATE FUNCTION capacity_ge(a capacity, b capacity) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN capacity_cmp(a,b) >= 0;
CREATE FUNCTION
=> CREATE OPERATOR >=(
    LEFTARG = capacity,
    RIGHTARG = capacity,
    FUNCTION = capacity_ge
);
CREATE OPERATOR
=> CREATE FUNCTION capacity_gt(a capacity, b capacity) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN capacity_cmp(a,b) > 0;
CREATE FUNCTION
=> CREATE OPERATOR >(
    LEFTARG = capacity,
    RIGHTARG = capacity,
    FUNCTION = capacity_gt
);
CREATE OPERATOR

Готово. Мы уже можем правильно сравнивать объемы:

Page 11: Page 11

=> SELECT (1,'MB')::capacity > (512, 'kB')::capacity;
 ?column?
----------
 t
(1 row)

Чтобы значения были правильно упорядочены при выборке, нам осталось создать класс операторов. За каждым оператором закреплен собственный номер (в случае btree: 1 — «меньше» и т. д.), поэтому имена операторов могут быть любыми.

=> CREATE OPERATOR CLASS capacity_ops
DEFAULT FOR TYPE capacity
USING btree AS
    OPERATOR 1 <,
    OPERATOR 2 <=,
    OPERATOR 3 =,
    OPERATOR 4 >=,
    OPERATOR 5 >,
    FUNCTION 1 capacity_cmp(capacity,capacity);
CREATE OPERATOR CLASS
=> SELECT * FROM test ORDER BY cap LIMIT 10;
  cap
--------
 (19,B)
 (25,B)
 (29,B)
 (39,B)
 (61,B)
 (69,B)
 (69,B)
 (89,B)
 (89,B)
 (92,B)
(10 rows)

Теперь значения отсортированы правильно. Наш класс операторов будет использоваться по умолчанию при создании индекса:

=> CREATE INDEX ON test(cap);
CREATE INDEX

Любой индекс в PostgreSQL может использоваться только для выражений вида:

<индексированное-поле>  <оператор>  <выражение>

Причем оператор должен входить в соответствующий класс операторов. Будет ли использоваться созданный индекс в таком запросе?

=> SET enable_seqscan = off;  -- временно отключим последовательное сканирование
SET
=> EXPLAIN (costs off)
SELECT * FROM test WHERE cap < (100,'B')::capacity;
                       QUERY PLAN
--------------------------------------------------------
 Bitmap Heap Scan on test
   Filter: (capacity_cmp(cap, '(100,B)'::capacity) < 0)
   ->  Bitmap Index Scan on test_cap_idx
(3 rows)

Да, поскольку: поле test.cap проиндексировано с помощью метода доступа btree и класса операторов capacity_ops; оператор < входит в класс операторов capacity_ops. Поэтому и при доступе с помощью индекса значения будут возвращаться в правильном порядке:

=> SELECT * FROM test WHERE cap < (100,'B')::capacity ORDER BY cap;

Page 12: Page 12

  cap
--------
 (19,B)
 (25,B)
 (29,B)
 (39,B)
 (61,B)
 (69,B)
 (69,B)
 (89,B)
 (89,B)
 (92,B)
(10 rows)

Page 13: Метод доступа GiST

Source slide page 13: Метод доступа GiST{#source-figure-013 caption=“Source slide page 13: Метод доступа GiST”}

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

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

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

Класс операторов для GiST (generalized search tree — обобщенное дерево поиска) определяет, как выглядит предикат, и еще многие детали, необходимые для эффективной работы.

Идею работы GiST-индексов рассмотрим на примере точек на плоскости.

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

Как видно на рисунке, прямоугольники могут пересекаться (хотя это и уменьшает эффективность поиска).

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

Page 14: Метод доступа GiST

Source slide page 14: Метод доступа GiST{#source-figure-014 caption=“Source slide page 14: Метод доступа GiST”}

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

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

Page 15: Метод доступа GiST

Source slide page 15: Метод доступа GiST{#source-figure-015 caption=“Source slide page 15: Метод доступа GiST”}

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

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

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

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

  2. спускаемся в выбранные поддеревья и повторяем поиск в них.

Такой алгоритм индексирования называется R-деревом.

Page 16: Метод доступа GiST

Какие именно операторы поддерживает GiST-индекс, существенно зависит от класса операторов. Информацию можно получить как из документации, так и из системного каталога. Возьмем, например, тип данных point (точки). Доступный класс операторов:

=> \dAc gist point
                   List of operator classes
  AM  | Input type | Storage type | Operator class | Default?
------+------------+--------------+----------------+----------
 gist | point      | box          | point_ops      | yes
(1 row)

Операторы в этом классе:

=> \dAo gist point_ops
              List of operators of operator families
  AM  | Operator family |     Operator      | Strategy | Purpose
------+-----------------+-------------------+----------+----------
 gist | point_ops       | <<(point,point)   |        1 | search
 gist | point_ops       | >>(point,point)   |        5 | search
 gist | point_ops       | ~=(point,point)   |        6 | search
 gist | point_ops       | <<|(point,point)  |       10 | search
 gist | point_ops       | |>>(point,point)  |       11 | search
 gist | point_ops       | <->(point,point)  |       15 | ordering
 gist | point_ops       | <^(point,point)   |       29 | search
 gist | point_ops       | >^(point,point)   |       30 | search
 gist | point_ops       | <@(point,box)     |       28 | search
 gist | point_ops       | <@(point,circle)  |       68 | search
 gist | point_ops       | <@(point,polygon) |       48 | search
(11 rows)

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

=> CREATE TABLE points (
    p point
);
CREATE TABLE
=> INSERT INTO points(p)
    SELECT point(1 - random()*2, 1 - random()*2)
    FROM generate_series(1,10_000);
INSERT 0 10000

Сколько точек расположено в круге радиуса 0.1?

=> SELECT count(*) FROM points WHERE p <@ circle '((0,0),0.1)';
 count
-------
    74
(1 row)

Как выполняется такой запрос?

=> EXPLAIN (costs off)
SELECT * FROM points WHERE p <@ circle '((0,0),0.1)';
               QUERY PLAN
----------------------------------------
 Seq Scan on points
   Filter: (p <@ '<(0,0),0.1>'::circle)
(2 rows)

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

Page 17: Page 17

=> CREATE INDEX ON points USING gist(p);
CREATE INDEX
=> EXPLAIN (costs off)
SELECT * FROM points WHERE p <@ circle '((0,0),0.1)';
                    QUERY PLAN
--------------------------------------------------
 Bitmap Heap Scan on points
   Recheck Cond: (p <@ '<(0,0),0.1>'::circle)
   ->  Bitmap Index Scan on points_p_idx
         Index Cond: (p <@ '<(0,0),0.1>'::circle)
(4 rows)

Еще один интересный оператор <-> вычисляет расстояние от одной точки до другой. Его можно использовать, чтобы найти точки, ближайшие к данной (так называемый поиск ближайших соседей, k-NN search):

=> SELECT * FROM points ORDER BY p <-> point '(0,0)' LIMIT 5;
                       p
-----------------------------------------------
 (-0.008379708221054827,-0.004909517844374989)
 (0.0105701404343983,-0.0032920801688396395)
 (0.010698253417356085,0.009047952697135209)
 (0.006528645163156543,-0.01529100178511733)
 (-0.010100171908385303,-0.017978884746949753)
(5 rows)

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

=> EXPLAIN (costs off)
SELECT * FROM points ORDER BY p <-> point '(0,0)' LIMIT 5;
                     QUERY PLAN
----------------------------------------------------
 Limit
   ->  Index Only Scan using points_p_idx on points
         Order By: (p <-> '(0,0)'::point)
(3 rows)

GiST-индекс можно построить и для столбца диапазонного типа:

=> \dAo gist range_ops
                  List of operators of operator families
  AM  | Operator family |          Operator           | Strategy | Purpose
------+-----------------+-----------------------------+----------+---------
 gist | range_ops       | <<(anyrange,anyrange)       |        1 | search
 gist | range_ops       | &<(anyrange,anyrange)       |        2 | search
 gist | range_ops       | &&(anyrange,anyrange)       |        3 | search
 gist | range_ops       | &>(anyrange,anyrange)       |        4 | search
 gist | range_ops       | >>(anyrange,anyrange)       |        5 | search
 gist | range_ops       | -|-(anyrange,anyrange)      |        6 | search
 gist | range_ops       | @>(anyrange,anyrange)       |        7 | search
 gist | range_ops       | <@(anyrange,anyrange)       |        8 | search
 gist | range_ops       | =(anyrange,anyrange)        |       18 | search
 gist | range_ops       | @>(anyrange,anyelement)     |       16 | search
 gist | range_ops       | <<(anyrange,anymultirange)  |        1 | search
 gist | range_ops       | &<(anyrange,anymultirange)  |        2 | search
 gist | range_ops       | &&(anyrange,anymultirange)  |        3 | search
 gist | range_ops       | &>(anyrange,anymultirange)  |        4 | search
 gist | range_ops       | >>(anyrange,anymultirange)  |        5 | search
 gist | range_ops       | -|-(anyrange,anymultirange) |        6 | search
 gist | range_ops       | @>(anyrange,anymultirange)  |        7 | search
 gist | range_ops       | <@(anyrange,anymultirange)  |        8 | search
(18 rows)

Здесь мы видим другой набор операторов. Одно из применений GiST-индекса — поддержка ограничений целостности типа EXCLUDE (ограничения исключения). Возьмем классический пример бронирования аудиторий:

=> CREATE TABLE booking (
    during tstzrange NOT NULL
);

Page 18: Page 18

CREATE TABLE

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

=> ALTER TABLE booking ADD CONSTRAINT no_intersect
    EXCLUDE USING gist(during WITH &&);
ALTER TABLE

Проверим:

=> INSERT INTO booking(during)
    VALUES ('[today 12:00,today 14:00)'::tstzrange);
INSERT 0 1
=> INSERT INTO booking(during)
    VALUES ('[today 13:00,today 16:00)'::tstzrange);
ERROR:  conflicting key value violates exclusion constraint "no_intersect"
DETAIL:  Key (during)=(["2026-04-10 13:00:00+03","2026-04-10 16:00:00+03")) conflicts
with existing key (during)=(["2026-04-10 12:00:00+03","2026-04-10 14:00:00+03")).

Частая ситуация — наличие дополнительного условия в таком ограничении целостности. Добавим номер аудитории:

=> ALTER TABLE booking ADD room integer NOT NULL DEFAULT 1;
ALTER TABLE

Но мы не сможем добавить этот столбец в ограничение целостности, поскольку класс операторов для метода gist и типа integer не определен.

=> ALTER TABLE booking DROP CONSTRAINT no_intersect;
ALTER TABLE
=> ALTER TABLE booking ADD CONSTRAINT no_intersect
    EXCLUDE USING gist(during WITH &&, room WITH =);
ERROR:  data type integer has no default operator class for access method "gist"
HINT:  You must specify an operator class for the index or define a default operator
class for the data type.

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

=> CREATE EXTENSION btree_gist;
CREATE EXTENSION
=> ALTER TABLE booking ADD CONSTRAINT no_intersect
    EXCLUDE USING gist(during WITH &&, room WITH =);
ALTER TABLE

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

=> INSERT INTO booking(room, during)
    VALUES (2, '[today 13:00,today 16:00)'::tstzrange);
INSERT 0 1

Но одну и ту же — нельзя:

=> INSERT INTO booking(room, during)
    VALUES (1, '[today 13:00,today 16:00)'::tstzrange);
ERROR:  conflicting key value violates exclusion constraint "no_intersect"
DETAIL:  Key (during, room)=(["2026-04-10 13:00:00+03","2026-04-10 16:00:00+03"), 1)
conflicts with existing key (during, room)=(["2026-04-10 12:00:00+03","2026-04-10
14:00:00+03"), 1).

Page 19: Итоги

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

  • Методы доступа — каркас индексирования, реализующий основной алгоритм и низкоуровневые детали
    • btree, hash, gist, sp-gist, gin, brin…
  • Классы операторов связывают метод с типами данных

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

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

Заинтересованным в подробностях рекомендуем внимательно ознакомиться с разделами документации, ссылки на которые приведены в комментариях к слайдам, а также с главами 4 и 5 книги https://postgrespro.ru/education/books/internals

Page 20: Практика

  1. Добавьте в таблицу retail_prices ограничение целостности, не позволяющее создать пересекающиеся диапазоны действия цен для одной книги. Проверьте план выполнения запроса для поиска актуальной цены (get_retail_price). Использует ли он индекс, созданный для поддержки ограничения целостности? 2. Сортировка книг в приложении по формату издания работает неверно, поскольку значения типа для формата издания упорядочиваются лексикографически. Сделайте так, чтобы значения упорядочивались по площади книжной страницы и проверьте, что сортировка в приложении исправилась.

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

  1. Пример похожего ограничения целостности приводился в демонстрации.

Если индекс не будет использоваться запросом из функции get_retail_price, дело может быть в том, что в таблице retail_prices слишком мало строк и полное сканирование оказывается выгоднее. Чтобы проверить применимость индекса, вы можете либо добавить больше строк в таблицу, либо временно запретить использование полного сканирования: SET enable_seqscan = off;

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

Page 21: 1. Ограничение целостности для retail_prices

Добавим ограничение целостности в таблицу:

=> CREATE EXTENSION btree_gist;
CREATE EXTENSION
=> ALTER TABLE retail_prices ADD
    EXCLUDE USING gist(book_id WITH =, effective WITH &&);
ALTER TABLE

Такое ограничение гарантирует, что данные не будет повреждены, даже если в коде функции set_retail_price будет допущена ошибка. В функции get_retail_price используется запрос следующего вида:

=> EXPLAIN (costs off)
SELECT rp.price
FROM retail_prices rp
WHERE rp.book_id = 1
  AND rp.effective @> current_timestamp;
                           QUERY PLAN
----------------------------------------------------------------
 Seq Scan on retail_prices rp
   Filter: ((book_id = 1) AND (effective @> CURRENT_TIMESTAMP))
(2 rows)

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

=> BEGIN;
BEGIN
=> WITH s AS (
    SELECT empapi.set_retail_price(
               b.book_id, 100.00, current_timestamp
           ),
           empapi.set_retail_price(
               b.book_id, 200.00, current_timestamp + interval '1 day'
           ),
           empapi.set_retail_price(
               b.book_id, 300.00, current_timestamp + interval '2 days'
           )
    FROM books b
)
SELECT count(*) FROM s;
 count
-------
   100
(1 row)
=> EXPLAIN (costs off)
SELECT rp.price
FROM retail_prices rp
WHERE rp.book_id = 1
  AND rp.effective @> current_timestamp;
                           QUERY PLAN
-----------------------------------------------------------------
 Bitmap Heap Scan on retail_prices rp
   Recheck Cond: (effective @> CURRENT_TIMESTAMP)
   Filter: (book_id = 1)
   ->  Bitmap Index Scan on retail_prices_book_id_effective_excl
         Index Cond: (effective @> CURRENT_TIMESTAMP)
(5 rows)
=> ROLLBACK;
ROLLBACK

Page 22: 2. Упорядочивание для форматов издания

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

=> CREATE FUNCTION book_format_area(f book_format) RETURNS numeric
LANGUAGE sql STRICT IMMUTABLE
RETURN f.width::numeric * f.height::numeric / f.parts::numeric;
CREATE FUNCTION
=> CREATE FUNCTION book_format_cmp(a book_format, b book_format) RETURNS integer
LANGUAGE sql STRICT IMMUTABLE
RETURN
CASE
    WHEN book_format_area(a) < book_format_area(b) THEN -1
    WHEN book_format_area(a) > book_format_area(b) THEN 1
    ELSE 0
END;
CREATE FUNCTION
=> CREATE FUNCTION book_format_lt(a book_format, b book_format) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN book_format_cmp(a,b) < 0;
CREATE FUNCTION
=> CREATE OPERATOR <(
    LEFTARG = book_format,
    RIGHTARG = book_format,
    FUNCTION = book_format_lt
);
CREATE OPERATOR
=> CREATE FUNCTION book_format_le(a book_format, b book_format) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN book_format_cmp(a,b) <= 0;
CREATE FUNCTION
=> CREATE OPERATOR <=(
    LEFTARG = book_format,
    RIGHTARG = book_format,
    FUNCTION = book_format_le
);
CREATE OPERATOR
=> CREATE FUNCTION book_format_eq(a book_format, b book_format) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN book_format_cmp(a,b) = 0;
CREATE FUNCTION
=> CREATE OPERATOR =(
    LEFTARG = book_format,
    RIGHTARG = book_format,
    FUNCTION = book_format_eq
);
CREATE OPERATOR
=> CREATE FUNCTION book_format_ge(a book_format, b book_format) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN book_format_cmp(a,b) >= 0;
CREATE FUNCTION
=> CREATE OPERATOR >=(
    LEFTARG = book_format,
    RIGHTARG = book_format,
    FUNCTION = book_format_ge
);
CREATE OPERATOR
=> CREATE FUNCTION book_format_gt(a book_format, b book_format) RETURNS boolean
LANGUAGE sql IMMUTABLE STRICT
RETURN book_format_cmp(a,b) > 0;
CREATE FUNCTION

Page 23: Page 23

=> CREATE OPERATOR >(
    LEFTARG = book_format,
    RIGHTARG = book_format,
    FUNCTION = book_format_gt
);
CREATE OPERATOR
=> CREATE OPERATOR CLASS book_format_ops
DEFAULT FOR TYPE book_format
USING btree AS
    OPERATOR 1 <,
    OPERATOR 2 <=,
    OPERATOR 3 =,
    OPERATOR 4 >=,
    OPERATOR 5 >,
    FUNCTION 1 book_format_cmp(book_format,book_format);
CREATE OPERATOR CLASS

Все готово. Проверим:

=> SELECT format FROM books GROUP BY format ORDER BY format;
   format
-------------
 (76,100,32)
 (84,108,32)
 (60,84,16)
 (60,88,16)
 (60,90,16)
 (66,90,16)
 (60,100,16)
 (70,90,16)
 (70,100,16)
 (84,108,16)
 (60,90,8)
(11 rows)

Page 24: Практика+

  1. В таблице хранятся даты событий. Какой индекс позволит ускорить запросы вида «найти определенное количество событий, наиболее близких по времени к указанной дате»? Проверьте. 2. Расширение pg_trgm добавляет функции и операторы для работы с триграммами, а также индексную поддержку. В частности, GiST-индекс ускоряет поиск по условиям вида столбец LIKE ‘%что-то%’
  • Проверьте работу индекса. Найдите в системном каталоге
  • все доступные операторы для класса gist_trgm_ops.
  • Что в случае триграмм может служить общим предикатом,
  • которому удовлетворяют все данные поддерева?

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

  1. Триграммы — это последовательности из трех символов, на которые разбивается строка. Например, из «что-то» получаются следующие триграммы: « ч», « чт», «что», «то-», «о-т», «-то», «то », «о ».

Триграммы позволяют определять «похожесть» строк: если и в одной, и в другой много одинаковых триграмм, то и сами строки похожи.

Поиск по LIKE, где в начале шаблона находятся обычные символы (‘что-то%’) ускоряется и простым индексом на основе B-дерева. Но такой индекс бесполезен, если шаблон начинается с %.

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

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

student$ zcat ~/mail_messages.sql.gz | psql -d ext_opclasses

В качестве запроса можно использовать следующий:

SELECT count(*)
FROM mail_messages
WHERE subject ILIKE '%magic%';

Page 25: 1. Индекс для событий

=> CREATE DATABASE ext_opclasses;
CREATE DATABASE
=> \c ext_opclasses
You are now connected to database "ext_opclasses" as user "student".

Создадим таблицу и заполним ее случайными данными:

=> CREATE TABLE events(
    date_happen timestamptz
);
CREATE TABLE
=> INSERT INTO events(date_happen)
    -- события за 10 лет
    SELECT now() - 10*365*24*60*60 * random() * interval '1 sec'
    FROM generate_series(1,10_000);
INSERT 0 10000

Поиск наиболее близких дат — задача поиска ближайших соседей. Метод доступа btree не поддерживает (пока) поиск ближайших соседей, поэтому придется воспользоваться методом gist (и расширением btree_gist, добавляющим, в том числе, класс операторов для дат).

=> CREATE EXTENSION btree_gist;
CREATE EXTENSION

Ускорить необходимые нам запросы поможет GiST-индекс:

=> CREATE INDEX ON events USING gist(date_happen);
CREATE INDEX

Вот как может выглядеть запрос:

=> SELECT date_happen, date_happen <-> now() - interval '3 years' delta
FROM events
ORDER BY date_happen <-> now() - interval '3 years'
LIMIT 10;
          date_happen          |      delta
-------------------------------+-----------------
 2023-04-10 15:40:22.508297+03 | 00:44:36.92209
 2023-04-10 17:30:21.761684+03 | 01:05:22.331297
 2023-04-10 12:10:17.098219+03 | 04:14:42.332168
 2023-04-10 21:40:54.277702+03 | 05:15:54.847315
 2023-04-10 09:40:55.587455+03 | 06:44:03.842932
 2023-04-10 07:55:28.009215+03 | 08:29:31.421172
 2023-04-10 04:06:11.379265+03 | 12:18:48.051122
 2023-04-11 06:44:04.68727+03  | 14:19:05.256883
 2023-04-11 09:32:27.407142+03 | 17:07:27.976755
 2023-04-09 16:27:45.480714+03 | 23:57:13.949673
(10 rows)

Его план выполнения использует созданный индекс:

=> EXPLAIN (costs off)
SELECT *
FROM events
ORDER BY date_happen <-> now() - interval '3 years'
LIMIT 10;
                            QUERY PLAN
-------------------------------------------------------------------
 Limit
   ->  Index Only Scan using events_date_happen_idx on events
         Order By: (date_happen <-> (now() - '3 years'::interval))
(3 rows)

2. Расширение pg\_trgm

Page 26: Page 26

Установим расширение:

=> CREATE EXTENSION pg_trgm;
CREATE EXTENSION

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

student$ zcat ~/mail_messages.sql.gz | psql -d ext_opclasses
SET
SET
SET
SET
SET
 set_config
------------
(1 row)
SET
SET
SET
SET
ALTER TABLE
DROP TABLE
DROP SEQUENCE
CREATE SEQUENCE
SET
SET
CREATE TABLE
COPY 356125
 setval
---------
 2317563
(1 row)
ALTER TABLE
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages WHERE subject ILIKE '%magic%';
 count
-------
   103
(1 row)
Time: 922,627 ms
=> \timing off
Timing is off.

Создадим индекс:

=> CREATE INDEX ON mail_messages USING gist(subject gist_trgm_ops);
CREATE INDEX
=> EXPLAIN (costs off)
SELECT count(*) FROM mail_messages WHERE subject ILIKE '%magic%';
                         QUERY PLAN
------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on mail_messages
         Recheck Cond: (subject ~~* '%magic%'::text)
         ->  Bitmap Index Scan on mail_messages_subject_idx
               Index Cond: (subject ~~* '%magic%'::text)
(5 rows)
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages WHERE subject ILIKE '%magic%';

Page 27: Page 27

 count
-------
   103
(1 row)
Time: 58,333 ms
=> \timing off
Timing is off.

Операторы для класса операторов gist_trgm_ops:

=> \dAo gist gist_trgm_ops
             List of operators of operator families
  AM  | Operator family |     Operator     | Strategy | Purpose
------+-----------------+------------------+----------+----------
 gist | gist_trgm_ops   | %(text,text)     |        1 | search
 gist | gist_trgm_ops   | <->(text,text)   |        2 | ordering
 gist | gist_trgm_ops   | ~~(text,text)    |        3 | search
 gist | gist_trgm_ops   | ~~*(text,text)   |        4 | search
 gist | gist_trgm_ops   | ~(text,text)     |        5 | search
 gist | gist_trgm_ops   | ~*(text,text)    |        6 | search
 gist | gist_trgm_ops   | %>(text,text)    |        7 | search
 gist | gist_trgm_ops   | <->>(text,text)  |        8 | ordering
 gist | gist_trgm_ops   | %>>(text,text)   |        9 | search
 gist | gist_trgm_ops   | <->>>(text,text) |       10 | ordering
 gist | gist_trgm_ops   | =(text,text)     |       11 | search
(11 rows)

Операторы ~~ и ~~* эквивалентны LIKE и ILIKE; Операторы ~ и ~* проверяют соответствие регулярному выражению (с учетом и без учета регистра). Значение остальных операторов описано на странице документации расширения pg_trgm. Класс операторов для триграмм использует так называемое сигнатурное дерево. Триграммы заменяются на сигнатуры, то есть двоичные числа, в которых все биты нулевые, и лишь один определенный бит установлен в единицу. И вся строка заменяется на сигнатуру, которая вычисляется как побитовое ИЛИ сигнатур составляющих ее триграмм. Таким образом, вместо строки в индексе сохраняется одно число. Общим предикатом в сигнатурном дереве является побитовое ИЛИ всех сигнатур, которые находятся ниже в поддереве. Замена индексируемых элементов битовой сигнатурой позволяет индексировать с помощью GiST вообще все, что угодно (например, изображения). Но, к сожалению, это не слишком эффективный метод. Во-первых, число бит в сигнатуре ограничено, и поэтому некоторые разные элементы будут иметь одинаковую сигнатуру. Во-вторых, чем выше узел стоит в дереве, тем больше в его сигнатуре будет единиц. Это означает, что при поиске придется спускаться в лишние поддеревья и необходимо все время перепроверять полученные результаты. В теме «Слабоструктурированные данные» будет показан другой, более эффективный (но менее универсальный) метод доступа — GIN.

11 Занятие 11: Расширяемость / Слабоструктурированные данные

Исходная страница в нативном Markdown: Расширяемость / Слабоструктурированные данные
Примечание

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

Page 1: Расширяемость / Слабоструктурированные данные

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

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

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

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

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

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

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

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

Page 2: Темы

  • Что такое слабоструктурированные данные
  • Применение в реляционных базах данных
  • Необходимые операции
  • Тип данных для XML: xml
  • Типы данных для JSON: json и jsonb
  • Индексирование документов JSON

Page 3: Что это такое

Наиболее известные примеры: XML и JSON

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

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

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

Широко известными примерами форматов (языков разметки) являются XML и JSON, в последнее время также популярен YAML.

Структура, или схема, документа определяется его собственной разметкой. (Это не исключает возможности определить структуру отдельно — например, для XML есть языки DTD, XMLSchema.)

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

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

Page 4: Применение

Интерфейсный формат

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

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

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

Если слабоструктурированные данные хранятся внутри самой базы данных, то все зависит от того, как эти данные используются. Если внутри SQL запросов не делается попыток работать с отдельными частями документов, то такие значения можно считать атомарными с точки зрения СУБД. Тут достаточно обычных средств SQL.

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

Page 5: Операции

Преобразование документа к реляционному виду

  • сохранение транспортного документа в базу данных
  • использование средств SQL для обработки
  • Преобразование реляционных данных к виду документа
    • выгрузка данных в транспортный формат
  • Выделение части документа
    • специализированный язык запросов
  • Индексирование документов
    • поддержка операций специализированного языка запросов

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

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

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

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

Page 6: Формат XML

eXtensible Markup Language

  • универсальный язык разметки, первый стандарт 1998 года
  • форматы на основе XML: XHTML, FB2, RSS и Atom, SVG, SOAP…
  • Структура
    • вложенные элементы
    • отделяются тегами, могут иметь атрибуты, содержат текст
  • Инструментарий
    • описание схемы (DTD, XML Schema), языки запросов XPath и XQuery,
    • язык преобразования XSLT
    • PostgreSQL: XPath 1.0

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

XML (eXtensible Markup Language) появился в контексте веба и разрабатывается консорциумом W3C. XML является текстовым документом, содержащим специальную разметку. XML определяет синтаксическую структуру разметки, которая с помощью тегов выделяет элементы (возможно, вложенные друг в друга), которые могут также иметь атрибуты. (Язык XML довольно сложен, в нем имеются также инструкции обработки, комментарии и другие сущности, но мы не будем на этом останавливаться.)

XML позволяет определить расширения XML, определяя конкретный набор элементов и их атрибутов, и наделяя их определенной семантикой. К известным расширениям относятся XHTML (более строгий HTML, совместимый с XML), FB2 (формат электронных книг), RSS и Atom (форматы для новостных лент), SVG (векторная графика), SOAP (формат сообщений веб-сервисов) и другие.

За время существования формата XML сформировался богатый инструментарий для работы с ним, включающий языки описания схемы документов (такие, как DTD, XMLSchema), языки запросов (базовый XPath и более богатый XQuery на его основе), языки преобразования документов XML (XSLT и XSL-FO на его основе).

В реляционных базах работу с XML регламентирует стандарт SQL/XML. PostgreSQL поддерживает только XPath 1.0. Преобразования XSLT доступны в расширениях (xml2 или в сторонних). Индексирование не реализовано (хотя все возможности для этого есть).

https://postgrespro.ru/docs/postgresql/16/datatype-xml

https://postgrespro.ru/docs/postgresql/16/functions-xml

Page 7: Тип данных для XML

xml

  • хранение в виде обычного текста + синтаксическая проверка
  • сохраняется исходное форматирование
  • может хранить XML-документ или фрагмент документа

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

Тип xml хранит документ в виде текстовой строки. Но по сравнению с типом text он проверяет введенные значения на допустимость по правилам XML.

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

Для типа xml не определены никакие операторы сравнения, поэтому столбец этого типа нельзя проиндексировать с помощью B-tree. Однако можно, например, использовать индекс по выражению, в котором вызывается XPath и результат приводится к строке символов.

Существуют и другие ограничения реализации этого типа в PostgreSQL.

https://postgrespro.ru/docs/postgresql/16/xml-limits-conformance

Page 8: XML: тип xml

=> CREATE DATABASE ext_semistruct;
CREATE DATABASE
=> \c ext_semistruct
You are now connected to database "ext_semistruct" as user "student".

Будем работать с документом, описывающим компоненты компьютера. Теги выделяются угловыми скобками. Для каждого открывающего тега () есть соответствующий ему закрывающий, имя которого начинается на косую черту (). Такая пара тегов определяет элемент (computer). Атрибуты могут быть перечислены вместе со значениями в открывающем теге. Текст внутри тегов составляет текстовый элемент.

=> SELECT $xml$
<computer>                                <!-- открывающий тег -->
  <motherboard>
    <!-- текстовый элемент -->
    <cpu>Intel® Core™ i7-7567U</cpu>
    <ram>
      <!-- тег с атрибутом -->
      <dimm size_gb="32">Crucial DDR4-2400 SODIMM</dimm>
    </ram>
  </motherboard>
  <disks>
    <ssd size_gb="512">Intel 760p Series</ssd>
    <hdd size_gb="3000">Toshiba Canvio</hdd>
  </disks>
</computer>                               <!-- закрывающий тег -->
$xml$ AS xml \gset

В PostgreSQL имеется тип xml, значение которого хранится в виде символьной строки, а при приведении к типу происходит проверка структуры. В зависимости от значения параметра xmloption проверка ожидает: document — документ (с одним корнем), content — фрагмент документа (любой набор элементов).

=> SHOW xmloption;
 xmloption
-----------
 content
(1 row)

При приведении к типу xml форматирование сохраняется:

=> SELECT :'xml'::xml;
                                xml
--------------------------------------------------------------------
 <computer>                                <!-- открывающий тег -->+
   <motherboard>                                                   +
     <!-- текстовый элемент -->                                    +
     <cpu>Intel® Core™ i7-7567U</cpu>                              +
     <ram>                                                         +
       <!-- тег с атрибутом -->                                    +
       <dimm size_gb="32">Crucial DDR4-2400 SODIMM</dimm>          +
     </ram>                                                        +
   </motherboard>                                                  +
   <disks>                                                         +
     <ssd size_gb="512">Intel 760p Series</ssd>                    +
     <hdd size_gb="3000">Toshiba Canvio</hdd>                      +
   </disks>                                                        +
 </computer>                               <!-- закрывающий тег -->+
(1 row)

А попытка привести некорректный фрагмент даст ошибку:

=> SELECT '<tag>wrong xml<tag>'::xml;

Page 9: XML: выражения XPath

ERROR:  invalid XML content
LINE 1: SELECT '<tag>wrong xml<tag>'::xml;
               ^
DETAIL:  line 1: Premature end of data in tag tag line 1
<tag>wrong xml<tag>
                   ^

Посмотрим, какие средства есть для получения части XML-документа. Для этого используются выражения языка запросов XPath 1.0. Мы не будем детально рассматривать все возможности XPath, но посмотрим некоторые примеры. XML состоит из иерархии элементов, поэтому язык описывает перемещения по дереву. Часть документа, соответствующая пути от корня:

=> SELECT xpath('/computer/motherboard/ram', :'xml');
                           xpath
------------------------------------------------------------
 {"<ram>                                                   +
       <!-- тег с атрибутом -->                            +
       <dimm size_gb=\"32\">Crucial DDR4-2400 SODIMM</dimm>+
     </ram>"}
(1 row)

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

=> SELECT xpath('/computer//ram', :'xml');
                           xpath
------------------------------------------------------------
 {"<ram>                                                   +
       <!-- тег с атрибутом -->                            +
       <dimm size_gb=\"32\">Crucial DDR4-2400 SODIMM</dimm>+
     </ram>"}
(1 row)

По дереву элементов можно «двигаться» не только вниз к листьям, но и вверх к корню:

=> SELECT xpath('//ram/dimm/..', :'xml');
                           xpath
------------------------------------------------------------
 {"<ram>                                                   +
       <!-- тег с атрибутом -->                            +
       <dimm size_gb=\"32\">Crucial DDR4-2400 SODIMM</dimm>+
     </ram>"}
(1 row)

Вместо конкретного имени элемента можно указать, что подходит любой:

=> SELECT xpath('//disks/*', :'xml') \gx
-[ RECORD 1 ]-----------------------------------------------------------------------------
xpath | {"<ssd size_gb=\"512\">Intel 760p Series</ssd>","<hdd size_gb=\"3000\">Toshiba
Canvio</hdd>"}

Здесь мы получили в массиве две части XML-документа. Можно посчитать количество:

=> SELECT xpath('count(//disks/*)', :'xml');
 xpath
-------
 {2}
(1 row)

Можно извлечь не весь элемент, а только его текст:

=> SELECT xpath('//cpu/text()', :'xml');
           xpath
---------------------------
 {"Intel® Core™ i7-7567U"}
(1 row)

Атрибуты записываются с помощью «собаки». Найдем значения атрибутов size_gb:

Page 10: Page 10

=> SELECT xpath('//@size_gb', :'xml');
     xpath
---------------
 {32,512,3000}
(1 row)

Условия фильтрации записываются в квадратных скобках. Найдем все элементы, объем памяти которых начинается от 1000 гигабайт:

=> SELECT xpath('//*[@size_gb >= 1000]', :'xml');
                     xpath
------------------------------------------------
 {"<hdd size_gb=\"3000\">Toshiba Canvio</hdd>"}
(1 row)

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

=> SELECT xmlexists(
    '//disks/*[starts-with(text(),''Toshiba'')]'
    PASSING :'xml'
);
 xmlexists
-----------
 t
(1 row)
=> SELECT xmlexists(
    '//disks/*[starts-with(text(),''Seagate'')]'
    PASSING :'xml'
);
 xmlexists
-----------
 f
(1 row)

XML: преобразование в реляционный вид и обратно

Для того чтобы преобразовать XML-документ к реляционному (табличному) виду, используется функция xmltable. Пусть у нас имеется таблица для дисковых накопителей:

=> CREATE TABLE disks (
    drive_type text,
    name text,
    capacity integer
);
CREATE TABLE

Сначала выделим нужную часть документа:

=> SELECT xpath('//disks/*', :'xml');
                                             xpath
------------------------------------------------------------------------------------------
-----
 {"<ssd size_gb=\"512\">Intel 760p Series</ssd>","<hdd size_gb=\"3000\">Toshiba
Canvio</hdd>"}
(1 row)

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

Page 11: Page 11

=> SELECT * FROM xmltable(
    '//disks/*'
    PASSING :'xml'
    COLUMNS
        drive_type  text PATH 'name()',
        name        text PATH 'text()',
        capacity integer PATH '@size_gb * 1024'
);
 drive_type |       name        | capacity
------------+-------------------+----------
 ssd        | Intel 760p Series |   524288
 hdd        | Toshiba Canvio    |  3072000
(2 rows)

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

=> INSERT INTO disks(drive_type, name, capacity)
SELECT * FROM xmltable(
    '//disks/*'
    PASSING :'xml'
    COLUMNS
        drive_type  text PATH 'name()',
        name        text PATH 'text()',
        capacity integer PATH '@size_gb * 1024'
);
INSERT 0 2

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

=> SELECT table_to_xml(
    tbl => 'disks',
    nulls => true,        -- выводить столбцы с NULL
    tableforest => false, -- в корне один элемент <disks>
    targetns => ''        -- пространство имен XML не нужно
);
                         table_to_xml
---------------------------------------------------------------
 <disks xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">+
                                                              +
 <row>                                                        +
   <drive_type>ssd</drive_type>                               +
   <name>Intel 760p Series</name>                             +
   <capacity>524288</capacity>                                +
 </row>                                                       +
                                                              +
 <row>                                                        +
   <drive_type>hdd</drive_type>                               +
   <name>Toshiba Canvio</name>                                +
   <capacity>3072000</capacity>                               +
 </row>                                                       +
                                                              +
 </disks>                                                     +
(1 row)

Page 12: Формат JSON

JavaScript Object Notation

  • простой язык разметки, стандарт 1999 года
  • появился в JavaScript, но распространен повсеместно
  • Структура
    • объекты (пары «ключ: значение»), массивы значений
    • значения текстовые, числовые, даты, логические
  • Инструментарий
    • PostgreSQL: язык запросов JSONPath,
    • неполная пока поддержка стандарта SQL/JSON,
    • индексирование

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

Формат JSON — подмножество языка JavaScript, описывающее:

● простые значения (текстовые, числовые, даты и логические);

● объекты, состоящие из пар «ключ — значение»;

● массивы значений.

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

Несмотря на то, что JSON — «родной» формат для JavaScript, он получил огромное распространение благодаря своей простоте и удобству, и повсеместно используется в современном интернете.

Изначально PostgreSQL предоставлял собственные операции для работы с JSON; при необходимости более серьезного инструментария можно было воспользоваться расширением jsQuery. В настоящее время в PostgreSQL реализована часть стандарта SQL/JSON:2016, включая язык запросов JSONPath.

Имеется и индексирование документов JSON. Это позволяет комбинировать реляционные возможности PostgreSQL с возможностями, предоставляемыми NoSQL-базами.

https://postgrespro.ru/docs/postgresql/16/datatype-json

https://postgrespro.ru/docs/postgresql/16/functions-json

https://github.com/postgrespro/jsquery

Page 13: Типы данных для JSON

Json

  • хранение в виде обычного текста + синтаксическая проверка
  • сохраняется исходное форматирование
  • необходимость повторного разбора при каждом запросе
  • Jsonb
    • внутренний формат, исключающий повторный разбор
    • поддержка SQL/JSON (язык запросов JSONPath)
    • поддержка индексирования

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

Имеются два типа данных для представления документов JSON: json и jsonb.

Первый появился раньше и, по сути, просто хранит документ в виде текстовой строки (при этом, конечно, проверяется, что строка является корректным документом JSON). Но при любом обращении к части документа JSON его приходится заново разбирать. Это вызывает большие накладные расходы. Кроме того, для формата json не реализован язык JSONPath и не работает индексирование. Поэтому json применяется редко.

Обычно используется тип jsonb (b — binary). Этот формат сохраняет однажды разобранную иерархию элементов, что позволяет эффективно работать с документом. Следует учитывать, что исходный вид документа при этом не сохраняется: нарушается порядок следования элементов, пропадают отступы и дублирующиеся ключи.

https://postgrespro.ru/docs/postgresql/16/datatype-json#DATATYPE-JSONP ATH

Page 14: JSON: типы данных json и jsonb

Документ, описывающий компоненты компьютера, может выглядеть в JSON так:

=> SELECT $js$
{ "motherboard": {
    "cpu": "Intel® Core™ i7-7567U",
    "ram": [
      { "type": "dimm",
        "size_gb": 32,
        "model": "Crucial DDR4-2400 SODIMM"
      }
    ]
  },
  "disks": [
    { "type": "ssd",
      "size_gb": 512,
      "model": "Intel 760p Series"
    },
    { "type": "hdd",
      "size_gb": 3000,
      "model": "Toshiba Canvio"
    }
  ]
}
$js$ AS json \gset

Тип json хранит документ как обычный текст:

=> SELECT :'json'::json;
                    json
---------------------------------------------
                                            +
 { "motherboard": {                         +
     "cpu": "Intel® Core™ i7-7567U",        +
     "ram": [                               +
       { "type": "dimm",                    +
         "size_gb": 32,                     +
         "model": "Crucial DDR4-2400 SODIMM"+
       }                                    +
     ]                                      +
   },                                       +
   "disks": [                               +
     { "type": "ssd",                       +
       "size_gb": 512,                      +
       "model": "Intel 760p Series"         +
     },                                     +
     { "type": "hdd",                       +
       "size_gb": 3000,                     +
       "model": "Toshiba Canvio"            +
     }                                      +
   ]                                        +
 }                                          +
(1 row)

В jsonb документ разбирается и записывается во внутреннем формате, сохраняющем структуру разбора. Из-за этого при выводе документ составляется заново в эквивалентном, но ином виде:

=> SELECT :'json'::jsonb \gx
-[ RECORD 1 ]-----------------------------------------------------------------------------
jsonb | {"disks": [{"type": "ssd", "model": "Intel 760p Series", "size_gb": 512},
{"type": "hdd", "model": "Toshiba Canvio", "size_gb": 3000}], "motherboard": {"cpu":
"Intel® Core™ i7-7567U", "ram": [{"type": "dimm", "model": "Crucial DDR4-2400 SODIMM",
"size_gb": 32}]}}

Чтобы вывести документ в человекочитаемом виде, можно использовать специальную функцию:

=> SELECT jsonb_pretty(:'json'::jsonb);

Page 15: Page 15

                     jsonb_pretty
------------------------------------------------------
 {                                                   +
     "disks": [                                      +
         {                                           +
             "type": "ssd",                          +
             "model": "Intel 760p Series",           +
             "size_gb": 512                          +
         },                                          +
         {                                           +
             "type": "hdd",                          +
             "model": "Toshiba Canvio",              +
             "size_gb": 3000                         +
         }                                           +
     ],                                              +
     "motherboard": {                                +
         "cpu": "Intel® Core™ i7-7567U",             +
         "ram": [                                    +
             {                                       +
                 "type": "dimm",                     +
                 "model": "Crucial DDR4-2400 SODIMM",+
                 "size_gb": 32                       +
             }                                       +
         ]                                           +
     }                                               +
 }
(1 row)

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

JSON: выражения JSONPath и другие средства

Для получения части JSON-документа стандарт SQL:2016 определил язык запросов JSONPath. Вот некоторые примеры. Так же, как и XPath, JSONPath позволяет спускаться по дереву элементов. Часть документа, соответствующая пути от корня:

=> SELECT jsonb_pretty(jsonb_path_query(:'json', '$.motherboard.ram'));
                 jsonb_pretty
----------------------------------------------
 [                                           +
     {                                       +
         "type": "dimm",                     +
         "model": "Crucial DDR4-2400 SODIMM",+
         "size_gb": 32                       +
     }                                       +
 ]
(1 row)

Элементы массива указываются в квадратных скобках:

=> SELECT jsonb_pretty(jsonb_path_query(:'json', '$.disks[0]'));
           jsonb_pretty
-----------------------------------
 {                                +
     "type": "ssd",               +
     "model": "Intel 760p Series",+
     "size_gb": 512               +
 }
(1 row)

Можно получить и все элементы сразу:

=> SELECT jsonb_pretty(jsonb_path_query(:'json', '$.disks[*]'));

Page 16: Page 16

           jsonb_pretty
-----------------------------------
 {                                +
     "type": "ssd",               +
     "model": "Intel 760p Series",+
     "size_gb": 512               +
 }
 {                                +
     "type": "hdd",               +
     "model": "Toshiba Canvio",   +
     "size_gb": 3000              +
 }
(2 rows)

Условия фильтрации записываются в скобках после вопросительного знака. Символ @ обозначает текущий путь. Найдем диски, объем памяти которых начинается от 1000 гигабайт:

=> SELECT jsonb_pretty(
    jsonb_path_query(:'json', '$.disks ? (@.size_gb > 1000)')
);
          jsonb_pretty
--------------------------------
 {                             +
     "type": "hdd",            +
     "model": "Toshiba Canvio",+
     "size_gb": 3000           +
 }
(1 row)

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

=> SELECT jsonb_pretty(
    jsonb_path_query(:'json', '$.disks ? (@.size_gb > 1000).model')
);
   jsonb_pretty
------------------
 "Toshiba Canvio"
(1 row)

В пути может быть и несколько условий:

=> SELECT jsonb_pretty(
    jsonb_path_query(
        :'json',
        '$.disks ? (@.size_gb > 128).model ? (@ starts with "Intel")'
    )
);
    jsonb_pretty
---------------------
 "Intel 760p Series"
(1 row)

Кроме средств JSONPath, можно применять и «традиционную» стрелочную нотацию. Переходим к ключу motherboard, затем к ключу ram, затем берем первый (нулевой) элемент массива:

=> SELECT jsonb_pretty( (:'json'::jsonb)->'motherboard'->'ram'->0 );
               jsonb_pretty
------------------------------------------
 {                                       +
     "type": "dimm",                     +
     "model": "Crucial DDR4-2400 SODIMM",+
     "size_gb": 32                       +
 }
(1 row)

Двойная стрелка возвращает не jsonb, а текстовое представление (необходимые фильтрации придется выполнять уже на уровне SQL):

=> SELECT (:'json'::jsonb)->'motherboard'->'ram'->0->>'model';

Page 17: Page 17

         ?column?
--------------------------
 Crucial DDR4-2400 SODIMM
(1 row)

Начиная с версии PostgreSQL 14 для работы c jsonb можно использовать индексную нотацию:

=> SELECT (:'json'::jsonb)['disks'][1]['model'];
      jsonb
------------------
 "Toshiba Canvio"
(1 row)

JSON: преобразование в реляционный вид и обратно

Стандарт определяет функцию jsontable, но ее реализация ожидается только в PostgreSQL 17. Разумеется, можно выйти из положения и теми средствами, которые существуют. Сначала выделим все диски:

=> TRUNCATE TABLE disks;
TRUNCATE TABLE
=> WITH dsk(d) AS (
    SELECT jsonb_path_query(:'json', '$.disks[*]')
)
SELECT d FROM dsk;
                               d
---------------------------------------------------------------
 {"type": "ssd", "model": "Intel 760p Series", "size_gb": 512}
 {"type": "hdd", "model": "Toshiba Canvio", "size_gb": 3000}
(2 rows)

На основе этого запроса несложно сделать вставку в таблицу:

=> INSERT INTO disks(drive_type, name, capacity)
WITH dsk(d) AS (
    SELECT jsonb_path_query(:'json', '$.disks[*]')
)
SELECT d->>'type', d->>'model', (d->>'size_gb')::integer FROM dsk;
INSERT 0 2

Для обратного преобразования удобно воспользоваться функцией row_to_json:

=> SELECT row_to_json(disks) FROM disks;
                          row_to_json
----------------------------------------------------------------
 {"drive_type":"ssd","name":"Intel 760p Series","capacity":512}
 {"drive_type":"hdd","name":"Toshiba Canvio","capacity":3000}
(2 rows)

Соединить строки в общий JSON-массив можно, например, так:

=> SELECT json_agg(disks) FROM disks;
                             json_agg
-------------------------------------------------------------------
 [{"drive_type":"ssd","name":"Intel 760p Series","capacity":512}, +
  {"drive_type":"hdd","name":"Toshiba Canvio","capacity":3000}]
(1 row)

Здесь отдельные строки преобразуются в объекты JSON автоматически.

Page 18: Метод доступа GIN

Source slide page 18: Метод доступа GIN{#source-figure-018 caption=“Source slide page 18: Метод доступа GIN”}

Инвертированный индекс

  • метод применим для сложносоставных значений (массив, текст)
  • API метода доступа определяет, как выделять элементы значения

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

Идея метода доступа GIN (Generalized Inverted Index) основана на том, что для сложносоставных значений имеет смысл использовать в качестве ключей элементы значений, а не значения целиком.

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

Для хранения элементов в GIN используется обычное B-дерево, поэтому элементы должны принадлежать к сортируемому типу данных. Основные отличия от B-дерева состоят в следующем:

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

● Каждый элемент индекса ссылается на множество табличных строк.

● Хотя элементы и организованы в B-дерево, классы операторов GIN не поддерживают операции сравнения «больше», «меньше».

Таким образом, GIN оптимизирован для других условий использования, нежели B-дерево. (Но имеется расширение btree_gin, реализующее классы операторов GIN для обычных типов данных.)

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

https://postgrespro.ru/docs/postgresql/16/datatype-json#JSON-INDEXING

Page 19: Метод доступа GIN для индексирования JSON

Пусть теперь таблица с дисками будет содержать JSON-документы.

=> DROP TABLE disks;
DROP TABLE
=> CREATE TABLE disks(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    disk jsonb
);
CREATE TABLE

Заполним ее разными моделями, от 10 до 1000 Гбайт:

=> INSERT INTO disks(disk)
WITH rnd(r) AS (
    SELECT (10+random()*990)::integer FROM generate_series(1,100_000)
),
dsk(type, model, capacity, plates) AS (
    SELECT 'hdd', 'NoName '||r||' GB', r, (1 + random()*9)::integer
    FROM rnd
)
SELECT row_to_json(dsk) FROM dsk;
INSERT 0 100000
=> ANALYZE disks;
ANALYZE

Вот что получилось:

=> SELECT * FROM disks LIMIT 3;
 id |                                  disk
----+-------------------------------------------------------------------------
  1 | {"type": "hdd", "model": "NoName 869 GB", "plates": 3, "capacity": 869}
  2 | {"type": "hdd", "model": "NoName 128 GB", "plates": 4, "capacity": 128}
  3 | {"type": "hdd", "model": "NoName 734 GB", "plates": 1, "capacity": 734}
(3 rows)

Сколько всего моделей имеют емкость 10 Гбайт и сколько времени займет поиск?

=> \timing on
Timing is on.

Оператор @? проверяет, есть ли в документе JSON заданный путь.

=> SELECT count(*) FROM disks WHERE disk @? '$ ? (@.capacity == 10)';
 count
-------
    50
(1 row)
Time: 78,916 ms
=> \timing off
Timing is off.

Как выполняется этот запрос?

=> EXPLAIN (costs off)
SELECT count(*) FROM disks WHERE disk @? '$ ? (@.capacity == 10)';
                          QUERY PLAN
--------------------------------------------------------------
 Aggregate
   ->  Seq Scan on disks
         Filter: (disk @? '$?(@."capacity" == 10)'::jsonpath)
(3 rows)

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

Page 20: Page 20

Документы JSONB можно индексировать с помощью метода GIN. Для этого есть два доступных класса операторов:

=> \dAc gin jsonb
                  List of operator classes
 AM  | Input type | Storage type | Operator class | Default?
-----+------------+--------------+----------------+----------
 gin | jsonb      | text         | jsonb_ops      | yes
 gin | jsonb      | integer      | jsonb_path_ops | no
(2 rows)

Класс по умолчанию, jsonb_ops, более универсален, но менее эффективен. Этот класс операторов помещает в индекс все ключи и значения. Из-за этого поиск получается неточным: значение 10 может быть найдено не только в контексте емкости (ключ capacity), но и как число пластин (ключ plates). Зато такой индекс поддерживает и другие операции с JSONB. Попробуем.

=> CREATE INDEX disks_json_idx ON disks USING gin(disk);
CREATE INDEX
=> \timing on
Timing is on.
=> SELECT count(*) FROM disks WHERE disk @? '$ ? (@.capacity == 10)';
 count
-------
    50
(1 row)
Time: 15,747 ms
=> \timing off
Timing is off.

Доступ, тем не менее, ускоряется.

=> EXPLAIN (costs off)
SELECT count(*) FROM disks WHERE disk @? '$ ? (@.capacity == 10)';
                               QUERY PLAN
------------------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on disks
         Recheck Cond: (disk @? '$?(@."capacity" == 10)'::jsonpath)
         ->  Bitmap Index Scan on disks_json_idx
               Index Cond: (disk @? '$?(@."capacity" == 10)'::jsonpath)
(5 rows)

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

=> CREATE INDEX disks_json_path_idx
ON disks USING gin(disk jsonb_path_ops);
CREATE INDEX
=> \timing on
Timing is on.
=> SELECT count(*) FROM disks WHERE disk @? '$ ? (@.capacity == 10)';
 count
-------
    50
(1 row)
Time: 1,178 ms
=> \timing off
Timing is off.

Так гораздо лучше. Еще один вариант — построить индекс на основе B-дерева по выражению. Вот так:

Page 21: Page 21

=> CREATE INDEX disks_btree_idx ON disks( (disk->>'capacity') );
CREATE INDEX
=> \timing on
Timing is on.
=> SELECT count(*) FROM disks WHERE disk->>'capacity' = '10';
 count
-------
    50
(1 row)
Time: 1,293 ms
=> \timing off
Timing is off.

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

=> SELECT indexname,
    pg_size_pretty(pg_total_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = 'disks'
UNION ALL
SELECT 'disks', pg_size_pretty(pg_table_size('disks'::regclass));
      indexname      | pg_size_pretty
---------------------+----------------
 disks_pkey          | 2208 kB
 disks_json_idx      | 1904 kB
 disks_json_path_idx | 1408 kB
 disks_btree_idx     | 712 kB
 disks               | 13 MB
(5 rows)

Page 22: Итоги

PostgreSQL поддерживает слабоструктурированные типы

  • Частичная поддержка XML
    • формат теряет популярность
  • Полноценная поддержка JSON
    • направление активно развивается
  • Индексирование на основе метода доступа GIN

Page 23: Практика

  1. Часть сведений о книгах (ISBN, аннотация и другие) выводятся приложением только на отдельной странице. Сейчас эти данные хранятся в таблице books, но не передаются приложению. Измените функцию, реализующую API приложения, так, чтобы сведения передавались в виде одного объекта JSON. Проверьте реализацию в приложении. 2. Многочисленные столбцы, хранящие дополнительные сведения о книгах, никак не используются в серверной части приложения. Замените их на один столбец, в котором сведения будут храниться в формате JSON. Сделайте это прозрачно для приложения.

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

  1. Сведения должны передаваться в объекте JSON следующего формата: { “ISBN”: isbn, “Аннотация”: abstract, “Издательство”: publisher, “Год выпуска”: year, “Гарнитура”: typeface, “Издание”: edition, “Серия”: series }

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

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

Page 24: 1. Дополнительные сведения о книгах

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

=> \sf get_additional
CREATE OR REPLACE FUNCTION public.get_additional(book books)
 RETURNS jsonb
 LANGUAGE sql
 IMMUTABLE
RETURN NULL::jsonb

Изменим функцию следующим образом:

=> CREATE OR REPLACE FUNCTION public.get_additional(book books) RETURNS jsonb
LANGUAGE sql STABLE
RETURN jsonb_build_object(
        'ISBN',         book.isbn,
        'Аннотация',    book.abstract,
        'Издательство', book.publisher,
        'Год выпуска',  book.year,
        'Гарнитура',    book.typeface,
        'Издание',      book.edition,
        'Серия',        book.series
    );
CREATE FUNCTION

2. Замена нескольких столбцов на один формата JSON

Добавим к таблице books необходимый столбец:

=> ALTER TABLE books ADD additional jsonb;
ALTER TABLE

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

=> CREATE FUNCTION fill_additional() RETURNS trigger
AS $$
BEGIN
    NEW.additional := jsonb_build_object(
        'ISBN',         NEW.isbn,
        'Аннотация',    NEW.abstract,
        'Издательство', NEW.publisher,
        'Год выпуска',  NEW.year,
        'Гарнитура',    NEW.typeface,
        'Издание',      NEW.edition,
        'Серия',        NEW.series
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER books_additional
BEFORE INSERT OR UPDATE OF
    isbn, abstract, publisher, year, typeface, edition, series
ON books
FOR EACH ROW
EXECUTE FUNCTION fill_additional();
CREATE TRIGGER

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

=> UPDATE books
SET additional = get_additional(books)
WHERE additional IS NULL;
UPDATE 100

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

Page 25: Page 25

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

=> CREATE OR REPLACE FUNCTION public.get_additional(book books) RETURNS jsonb
LANGUAGE sql STABLE
RETURN book.additional;
CREATE FUNCTION

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

=> DROP TRIGGER books_additional ON books;
DROP TRIGGER
=> DROP FUNCTION fill_additional;
DROP FUNCTION

И можно удалить ненужные теперь столбцы таблицы:

=> ALTER TABLE books
    DROP isbn,
    DROP abstract,
    DROP publisher,
    DROP year,
    DROP typeface,
    DROP edition,
    DROP series;
ALTER TABLE

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

Page 26: Практика+

  1. В базе данных имеются таблицы пользователей и их заказов, связанные отношением «один ко многим». Напишите функцию, которая по идентификатору пользователя возвращает документ JSON, содержащий все сведения о пользователе вместе с массивом его заказов. 2. В пользовательском интерфейсе необходимо выводить названия городов, хранящиеся в базе данных, на выбранном языке. Один из способов — хранить все переводы названий в объекте JSON в виде пар «код языка — название». Реализуйте такой способ. Для удобства создайте представление, показывающее название городов на языке, заданном в конфигурационном параметре сервера.

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

  1. Таблицы могут быть следующими: CREATE TABLE users( id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text);
CREATE TABLE orders(
  id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  user_id integer REFERENCES users(id),
  amount numeric);

Результат должен иметь вид: { “user_id”: …, “name”: …, “orders”: [ { “order_id”: …, “amount”: … }, … ] }

Для того чтобы сформировать объект JSON из нескольких полей, воспользуйтесь функцией jsonb_build_object.

  1. Название пользовательского конфигурационного параметра должно содержать точку в своем имени, например, «translation.lang».

Получить текущее значение параметра можно функцией current_setting.

Page 27: 1. Формирование JSON

=> CREATE DATABASE ext_semistruct;
CREATE DATABASE
=> \c ext_semistruct
You are now connected to database "ext_semistruct" as user "student".

Создадим таблицы и тестовые данные:

=> CREATE TABLE users(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    name text
);
CREATE TABLE
=> CREATE TABLE orders(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    user_id integer REFERENCES users(id),
    amount numeric
);
CREATE TABLE
=> INSERT INTO users(name) VALUES ('alice'), ('bob'), ('charlie');
INSERT 0 3
=> INSERT INTO orders(amount, user_id)
    SELECT round( (random()*1000)::numeric, 2), u.id
    FROM users u, generate_series(1,3);
INSERT 0 9

Функция использует jsonb_build_object для создания объекта и jsonb_agg для создания массива:

=> CREATE FUNCTION get_users_w_orders(user_id integer) RETURNS jsonb
LANGUAGE sql STABLE
RETURN (SELECT jsonb_build_object(
    'user_id', u.id,
    'name',    u.name,
    'orders',  jsonb_agg(jsonb_build_object(
        'order_id', o.id,
        'amount',   o.amount
    ))
)
FROM users u
    JOIN orders o ON o.user_id = u.id
WHERE u.id = get_users_w_orders.user_id
GROUP BY u.id);
CREATE FUNCTION

Проверим:

=> SELECT jsonb_pretty(get_users_w_orders(1));

Page 28: Page 28

         jsonb_pretty
-------------------------------
 {                            +
     "name": "alice",         +
     "orders": [              +
         {                    +
             "amount": 851.55,+
             "order_id": 1    +
         },                   +
         {                    +
             "amount": 884.08,+
             "order_id": 4    +
         },                   +
         {                    +
             "amount": 177.39,+
             "order_id": 7    +
         }                    +
     ],                       +
     "user_id": 1             +
 }
(1 row)

2. Хранение переводов

Таблица с городами:

=> CREATE TABLE cities_ml(
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    data jsonb
);
CREATE TABLE
=> INSERT INTO cities_ml(data) VALUES
    ('{ "ru": "Москва",
        "en": "Moscow",
        "it": "Mosca" }'),
    ('{ "ru": "Санкт-Петербург",
        "en": "Saint Petersburg",
        "it": "San Pietroburgo" }');
INSERT 0 2

Представление может быть устроено следующим образом:

=> CREATE VIEW cities AS
SELECT c.id,
       c.data ->> current_setting(
                      'translation.lang', /* missing_ok */true
                  ) AS name
FROM cities_ml c;
CREATE VIEW

Проверим результат с разными значениями параметра:

=> SET translation.lang = 'ru';
SET
=> SELECT * FROM cities;
 id |      name
----+-----------------
  1 | Москва
  2 | Санкт-Петербург
(2 rows)
=> SET translation.lang = 'en';
SET
=> SELECT * FROM cities;
 id |       name
----+------------------
  1 | Moscow
  2 | Saint Petersburg
(2 rows)

Page 29: Page 29

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

12 Занятие 12: Расширяемость / Фоновые процессы

Исходная страница в нативном Markdown: Расширяемость / Фоновые процессы
Примечание

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

Page 1: Расширяемость / Фоновые процессы

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

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

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

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

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

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

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

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

Page 2: Темы

  • Механизм фоновых процессов
  • Применение в ядре PostgreSQL
  • Возможности для прикладных задач
  • Расширение dblink
  • Расширение pg_background

Page 3: Фоновые процессы

Фиксированный набор служебных процессов

  • postmaster
  • walwriter
  • checkpointer
  • autovacuum
  • и другие
  • Динамически порождаемые фоновые процессы
    • контролируются процессом postmaster
    • имеют доступ к разделяемой памяти
    • могут устанавливать внутреннее соединение с базами данных
    • реализуются на языке C

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

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

Но вместе с тем часто возникает необходимость временно запустить процесс, который выполнит необходимую работу и завершится. Такую возможность предоставляет механизм фоновых процессов (background workers): https://postgrespro.ru/docs/postgresql/16/bgworker

Как и все остальные, фоновые процессы порождаются и контролируются процессом postmaster. Фоновые процессы имеют все возможности, такие как доступ к разделяемой памяти сервера и соединение с базами данных по внутреннему протоколу SPI (рассматривается в теме «Языки программирования»).

Фоновые процессы должны быть написаны на языке C, пример можно найти в исходном коде (src/test/modules/worker_spi).

Заметим, что процесс автоочистки (autovacuum launcher) тоже динамически порождает рабочие процессы (autovacuum worker), но исторически делает это по-своему, не используя общий механизм фоновых процессов.

Page 4: Использование

Source slide page 4: Использование{#source-figure-004 caption=“Source slide page 4: Использование”}

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

  • max_parallel_workers = 8
  • Параллельное выполнение служебных команд
    • max_parallel_maintenance_workers = 2
  • Логическая репликация
    • max_logical_replication_workers = 4

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

Фоновые процессы используются в PostgreSQL для различных целей.

  1. Параллельное выполнение запросов.

  2. Параллельное выполнение служебных команд (CREATE INDEX и REINDEX (в т. ч. CONCURRENTLY) для B-деревьев, VACUUM).

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

  1. Логическая репликация (для процессов на стороне сервера-подписчика).

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

Page 5: Использование фоновых процессов

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

=> EXPLAIN (analyze, costs off, timing off)
SELECT count(*) FROM mail_messages;
                                    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 mail_messages (actual rows=118708 loops=3)
 Planning Time: 10.258 ms
 Execution Time: 1505.969 ms
(8 rows)

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

Page 6: Прикладные задачи

Некоторые идеи

  • планировщик заданий внутри СУБД
  • асинхронная обработка событий
  • распараллеливание сложной обработки данных
  • автономные транзакции
  • и другое
  • Но пользовательские задачи неудобно писать на языке C

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

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

Например, можно организовать планировщик заданий внутри СУБД. В отличие от, например, cron, такой планировщик не будет зависеть от используемой операционной системы. (Планировщик внутри СУБД реализован в Postgres Pro Enterprise и рассматривается в курсе PGPRO).

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

Можно распараллеливать сложную длинную обработку больших объемов данных.

Можно реализовать автономные транзакции, хотя и весьма дорогим способом. (В Postgres Pro Enterprise имеются автономные транзакции, они рассматриваются в курсе PGPRO).

И так далее.

Однако писать прикладные задачи на языке С крайне неудобно и требует неоправданно высокой квалификации.

Page 7: Расширение dblink

Назначение

  • выполнение произвольных SQL-команд на удаленном сервере
  • в качестве удаленного сервера может выступать и локальный
  • Плюсы
    • стандартное расширение
  • Минусы
    • фоновые процессы не используются,
    • устанавливается новое соединение

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

Одно из возможных решений — использовать расширение dblink, которое предназначено для выполнения SQL-запросов на удаленном сервере PostgreSQL (в качестве удаленного может выступать и локальный сервер).

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

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

Page 8: Расширение dblink

Это стандартное расширение. Установим его:

=> CREATE DATABASE ext_bgworkers;
CREATE DATABASE
=> \c ext_bgworkers
You are now connected to database "ext_bgworkers" as user "student".
=> CREATE EXTENSION dblink;
CREATE EXTENSION

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

=> SELECT * FROM dblink(
    'host=localhost port=5432 dbname=postgres user=postgres password=postgres',
    $$ SELECT * FROM generate_series(1,3); $$
) AS (result integer);
 result
--------
      1
      2
      3
(3 rows)

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

=> SELECT * FROM dblink_exec(
    'host=localhost port=5432 dbname=postgres user=postgres password=postgres',
    $$ VACUUM; $$
);
 dblink_exec
-------------
 VACUUM
(1 row)

Обе функции открывают соединение, выполняют команду и тут же закрывают соединение. Но есть возможность явно управлять соединением. Откроем его, указав имя:

=> SELECT * FROM dblink_connect(
    'remote',
    'host=localhost port=5432 dbname=postgres user=postgres password=postgres'
);
 dblink_connect
----------------
 OK
(1 row)

Можно открыть и несколько соединений. Текущие открытые соединения показывает функция:

=> SELECT * FROM dblink_get_connections();
 dblink_get_connections
------------------------
 {remote}
(1 row)

Теперь можно выполнять команды, используя открытое соединение. В том числе можно вручную управлять транзакциями:

Page 9: Page 9

=> SELECT * FROM dblink_exec(
    'remote',
    $$ BEGIN; $$
);
 dblink_exec
-------------
 BEGIN
(1 row)
=> SELECT * FROM dblink(
    'remote',
    $$ SELECT pg_backend_pid(); $$
) AS (pid integer);
  pid
-------
 34255
(1 row)
=> SELECT * FROM dblink_exec(
    'remote',
    $$ COMMIT; $$
);
 dblink_exec
-------------
 COMMIT
(1 row)

Важная возможность — асинхронные вызовы. Следующая функция отправит запрос на сервер и тут же вернет управление:

=> SELECT * FROM dblink_send_query(
    'remote',
    $$ SELECT 'done' FROM pg_sleep(10); $$
);
 dblink_send_query
-------------------
                 1
(1 row)

1 — успешно; 0 — ошибка. Далее мы можем проверить, выполняется ли еще запрос:

=> SELECT CASE dblink_is_busy('remote')
    WHEN 1 THEN 'еще выполняется'
    ELSE 'уже выполнился'
END;
      case
-----------------
 еще выполняется
(1 row)

Результат получаем так (если запрос еще не выполнился, функция сама дождется результатов):

=> SELECT * FROM dblink_get_result(
    'remote'
) AS (result text);
 result
--------
 done
(1 row)

Не забываем закрыть соединение:

=> SELECT * FROM dblink_disconnect('remote');

Page 10: Page 10

 dblink_disconnect
-------------------
 OK
(1 row)

Page 11: Расширение pg_background

Назначение

  • возможность выполнить команду SQL в фоновом процессе
  • команда может вызвать подпрограмму на любом серверном языке
  • Плюсы
  • используются фоновые процессы Минусы
    • стороннее расширение

Комментарии преподавателя

Есть и другое решение, которое позволяет прикладным разработчикам воспользоваться фоновыми процессами — стороннее расширение pg_background:

https://github.com/vibhorkum/pg\_background

Фактически это небольшая обертка над низкоуровневым API фоновых процессов, позволяющая запускать в фоновом режиме произвольные команды SQL. Разумеется, таким образом можно вызывать хранимые функции и процедуры, написанные на любых серверных языках программирования (например, PL/pgSQL).

Page 12: Расширение pg_background

Расширение уже собрано и доступно для установки в виртуальной машине курса:

=> CREATE EXTENSION pg_background;
CREATE EXTENSION

Расширение предоставляет всего три функции. Функция pg_background_launch запускает фоновый процесс, выполняющий одну SQL-команду. Например, выполним в фоне простой запрос. Для удобства он будет работать 10 секунд:

=> SELECT pg_background_launch(
    $$ SELECT 2+2 FROM (SELECT pg_sleep(10)) $$
);
 pg_background_launch
----------------------
                34713
(1 row)

Пока запрос выполняется, мы можем увидеть процесс в pg_stat_activity:

=> SELECT query, backend_type, wait_event_type, wait_event
FROM pg_stat_activity WHERE pid = 34713 \gx
-[ RECORD 1 ]---+----------------------------------------
query           |  SELECT 2+2 FROM (SELECT pg_sleep(10))
backend_type    | pg_background
wait_event_type | Timeout
wait_event      | PgSleep

Обратите внимание на ожидание. Функция pg_background_result выводит результат выполнения фоновой команды (при необходимости дожидаясь ее окончания). Функция возвращает значения типа record, поэтому для вывода необходимо конкретизировать названия и типы полей составного типа.

=> SELECT * FROM pg_background_result(34713) AS (result integer);
 result
--------
      4
(1 row)

Функция pg_background_detach отключает текущий процесс от ожидания результатов фонового процесса. Из-за особенности текущей реализации между вызовами pg_background_launch и pg_background_detach должна быть небольшая задержка. Передача результатов выполняется через очередь сообщений в разделяемой памяти сервера. Поэтому при переполнении очереди фоновый процесс будет ждать, пока мы не прочитаем накопившиеся сообщения, даже если они нас не интересуют. Запустим процесс, возвращающий много информации:

=> SELECT pg_background_launch(
    $$ SELECT * FROM generate_series(1,1_000_000) $$
);
 pg_background_launch
----------------------
                34889
(1 row)
=> SELECT query, backend_type, wait_event_type, wait_event
FROM pg_stat_activity WHERE pid = 34889 \gx

Page 13: Page 13

-[ RECORD 1 ]---+---------------------------------------------
query           |  SELECT * FROM generate_series(1,1_000_000)
backend_type    | pg_background
wait_event_type | IPC
wait_event      | MessageQueuePutMessage

Обратите внимание на ожидание. Отключимся от процесса:

=> SELECT * FROM pg_background_detach(34889);
 pg_background_detach
----------------------
(1 row)
=> SELECT query, backend_type, wait_event_type, wait_event
FROM pg_stat_activity WHERE pid = 34889 \gx
-[ RECORD 1 ]---+---------------------------------------------
query           |  SELECT * FROM generate_series(1,1_000_000)
backend_type    | pg_background
wait_event_type |
wait_event      |

Больше фоновый процесс ничего не ждет (и, возможно, уже отработал). Заметим, что в случае dblink сложностей с переполнением буфера не возникает, потому что используется не межпроцессное взаимодействие, а устанавливается обычное соединение по клиент-серверному протоколу. Интересно, что изначально в состав расширения pg_background планировалось включить четвертую функция pg_background_run(pid, query), которая должна была передавать новое задание уже запущенному процессу, избегая затрат на создание очередного фонового процесса. Однако пока что это не реализовано.

Page 14: Итоги

Сервер предоставляет механизм фоновых процессов

  • Фоновые процессы используются для внутренних целей, но могут быть использованы и для прикладных задач
  • Расширение pg_background позволяет реализовывать фоновые процессы на процедурных языках

Page 15: Практика

  1. Реализуйте расчет рейтинга книг на основе данных о голосах пользователей «за» и «против». Чтобы не допускать разрастание таблицы, выполняйте обновление пакетами по N строк. Между частичными обновлениями запускайте очистку. Оформите обновление в виде хранимой процедуры, принимающей N как параметр. 2. Выполните процедуру обновления в фоновом режиме.

Комментарии преподавателя

  1. Рейтинг книги оценивается числом от 0 до 1.

«Наивная» формула для определения рейтинга может быть такой:

votes_up / (votes_up + votes_down), если votes_up + votes_down > 0;

0, иначе.

Недостаток этой формулы показывает пример: книга, набравшая 100 голосов «за» и 1 голос «против», получит меньший рейтинг, чем книга, набравшая всего один голос «за».

Можно подумать над более справедливой формулой, а можно воспользоваться уже готовой функцией public.rating.

Пакетное обновление рассматривалось в практике к теме «Очистка». Здесь применим тот же подход. Однако подумайте над тем, как пересчитывать рейтинг только тех книг, для которых это необходимо.

Команда VACUUM не может быть вызвана внутри транзакции, но ее можно выполнить как отдельную SQL-команду в фоновом процессе.

Page 16: 1. Процедура расчета рейтинга

Для вычисления рейтинга мы будем пользоваться готовой функцией public.rating. Эта функция вычисляет рейтинг с учетом баланса между долей положительных оценок и неопределенностью малого числа наблюдений (нижняя граница доверительного интервала Вильсона для параметра Бернулли). Один из возможных способов не пересчитывать рейтинг без надобности состоит в том, чтобы добавить к таблице книг столбец логического типа, указывающий на необходимость пересчета. Процедура расчета рейтинга будет сбрасывать этот флаг, а любое изменение количества голосов — устанавливать. Мы реализуем другой способ. Добавим к таблице столбец с датой последнего пересчета рейтинга. Процедура пересчета будет выполняться только для тех книг, «возраст» рейтинга которых больше определенного порога. Это позволит при необходимости (например, если изменится формула) пересчитать рейтинг всех книг, не обновляя предварительно все строки таблицы.

=> ALTER TABLE books ADD COLUMN rating_at timestamp DEFAULT NULL;
ALTER TABLE

Обратите внимание: добавление столбца затрагивает только системный каталог и не приводит к перезаписи всех строк таблицы. Для обработки строк пакетами нужно определиться: с размером пакета; с тем, как из всех строк выбирать для обработки только нужное количество. Важный момент: обработка пакета может — в принципе — завершиться ошибкой. Важно, чтобы это не повлияло на возможность продолжить обработку и довести ее до конца. Для выбора пакета строк мы используем подход, показанный ранее в практике к теме «Очистка». Будем выбирать и блокировать первые подходящие N строк, игнорируя уже заблокированные:

=> \set N 5
=> \set AGE '1 day'
=> WITH batch AS (
    SELECT * FROM books
    WHERE rating IS NULL OR age(rating_at) > interval :'AGE'
    LIMIT :N
    FOR UPDATE SKIP LOCKED
)
SELECT book_id FROM batch;
 book_id
---------
      56
      70
      28
      22
      68
(5 rows)

Здесь мы выбираем строки, для которых рейтинг не рассчитан или рассчитан «давно». Когда прекращать обновление? Если анализировать количество обновленных строк и сравнивать с размером пакета, то можно пропустить строки, которые были заблокированы каким-нибудь другим процессом. Поэтому лучше явно проверять наличие строк, требующих обработки. И еще один момент: поскольку команду VACUUM нельзя использовать в функциях и процедурах, мы будем запускать ее в фоновом процессе. Теперь мы готовы к тому, чтобы написать процедуру обновления. Для удобства передадим в качестве параметра не только размер пакета, но и порог возраста.

Page 17: Page 17

=> CREATE PROCEDURE update_rating(
    batch_size integer,
    age_threshold interval
        DEFAULT interval '0 sec' -- по умолчанию обработать все
) AS $$
DECLARE
    rows bigint;
    -- будет несколько транзакций, поэтому время запуска надо запомнить
    start_at timestamp := current_timestamp;
BEGIN
    LOOP
        -- обновляем один пакет строк
        WITH batch AS (
            SELECT * FROM books
            WHERE rating IS NULL
               OR start_at - rating_at > age_threshold
            LIMIT batch_size
            FOR UPDATE SKIP LOCKED
        )
        UPDATE books
        SET rating = rating(votes_up, votes_down),
            rating_at = current_timestamp
        WHERE book_id IN (SELECT book_id FROM batch);
        GET DIAGNOSTICS rows := ROW_COUNT;
        RAISE NOTICE 'updated % rows', rows;
        -- фиксируем, чтобы не держать горизонт транзакций
        -- и позволить очистке выполнить свою работу
        COMMIT;
        -- вызываем очистку и дожидаемся окончания
        PERFORM * FROM pg_background_result(pg_background_launch(
            'VACUUM books'
        )) AS (result text);
        RAISE NOTICE 'vacuumed, backend_xmin = %',
            (SELECT backend_xmin FROM pg_stat_activity
             WHERE pid = pg_backend_pid());
        -- остались необработанные строки?
        EXIT WHEN NOT EXISTS (
            SELECT NULL FROM books
            WHERE rating IS NULL
               OR start_at - rating_at > age_threshold
        );
    END LOOP;
    RAISE NOTICE 'all done';
END;
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Проверим:

=> CALL update_rating(10, interval '1 day');
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 869
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 870
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 871
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 872
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 873
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 874
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 875
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 876
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 877
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 878
NOTICE:  all done
CALL

2. Расчета рейтинга в фоновом режиме

Page 18: Page 18

Просто вызовем процедуру в фоновом процессе. Таким образом, из одного фонового процесса можно вызывать другой (конечно, необходимо, чтобы не было превышено предельное количество фоновых процессов, установленное в системе).

=> SELECT * FROM pg_background_result(pg_background_launch(
    'CALL update_rating(10)'
)) AS (result text);
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 879
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 880
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 881
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 882
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 883
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 884
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 885
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 886
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 887
NOTICE:  updated 10 rows
NOTICE:  vacuumed, backend_xmin = 888
NOTICE:  all done
 result
--------
 CALL
(1 row)

Page 19: Практика+

  1. Фоновый процесс не будет запущен, если запуск приведет к превышению числа допустимых процессов. Напишите функцию-обертку для pg_background_launch, которая пытается запустить процесс N раз с интервалом в одну секунду. 2. Сравните накладные расходы на порождение процесса расширений dblink и pg_background, многократно выполняя простой запрос.

Комментарии преподавателя

  1. Предварительно исследуйте, как функция pg_background_launch сигнализирует об ошибке. Для этого удобно уменьшить значение параметра max_worker_processes, но учтите, что один фоновый процесс в системе уже запущен (logical replication launcher) — не уменьшайте значение до 1.

Обработка ошибок рассматривается в курсе DEV1.

  1. Разумеется, накладные расходы dblink будут существенно зависеть от способа аутентификации (в нашем случае — проверка пароля scram-sha-256).

Page 20: 1. Превышение числа фоновых процессов

=> CREATE DATABASE ext_bgworkers;
CREATE DATABASE
=> \c ext_bgworkers
You are now connected to database "ext_bgworkers" as user "student".
=> CREATE EXTENSION pg_background;
CREATE EXTENSION

Уменьшим значение параметра-ограничителя до 2, учитывая, что один фоновый процесс (logical replication launcher) запускается системой автоматически. Изменение требует рестарта сервера.

=> ALTER SYSTEM SET max_worker_processes = 2;
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart
student$ psql ext_bgworkers
=> SHOW max_worker_processes;
 max_worker_processes
----------------------
 2
(1 row)

Запустим один длительный фоновый процесс:

=> SELECT * FROM pg_background_launch(
    'SELECT pg_sleep(10)'
);
 pg_background_launch
----------------------
                73201
(1 row)

Теперь попробуем запустить еще один, перехватывая исключение:

=> DO $$
DECLARE
    hint text;
BEGIN
    PERFORM pg_background_launch('SELECT 42');
EXCEPTION
    WHEN others THEN
        GET STACKED DIAGNOSTICS hint := PG_EXCEPTION_HINT;
        RAISE NOTICE E'sqlstate = %\nsqlerrm = %\nhint = %',
            SQLSTATE, SQLERRM, hint;
END;
$$;
NOTICE:  sqlstate = 53000
sqlerrm = could not register background process
hint = You may need to increase max_worker_processes.
DO

Ошибка с кодом 53000 относится к группе insufficient_resources. Функция может выглядеть следующим образом:

Page 21: Page 21

=> CREATE FUNCTION try_background_launch(sql text, retries integer)
RETURNS integer
AS $$
DECLARE
BEGIN
    LOOP
        BEGIN
            RETURN pg_background_launch(sql);
        EXCEPTION
            WHEN insufficient_resources THEN
                IF retries > 0 THEN
                    retries := retries - 1;
                    RAISE NOTICE 'Sleeping for 1 sec, % retries left',
                        retries;
                    PERFORM pg_sleep(1);
                ELSE
                    RAISE;
                END IF;
        END;
    END LOOP;
END
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

Проверим:

=> SELECT * FROM pg_background_launch(
    'SELECT pg_sleep(10)'
);
 pg_background_launch
----------------------
                73741
(1 row)
=> SELECT * FROM pg_background_result(try_background_launch(
    'SELECT 42', 15
)) AS (result integer);
NOTICE:  Sleeping for 1 sec, 14 retries left
NOTICE:  Sleeping for 1 sec, 13 retries left
NOTICE:  Sleeping for 1 sec, 12 retries left
NOTICE:  Sleeping for 1 sec, 11 retries left
NOTICE:  Sleeping for 1 sec, 10 retries left
NOTICE:  Sleeping for 1 sec, 9 retries left
NOTICE:  Sleeping for 1 sec, 8 retries left
NOTICE:  Sleeping for 1 sec, 7 retries left
NOTICE:  Sleeping for 1 sec, 6 retries left
NOTICE:  Sleeping for 1 sec, 5 retries left
 result
--------
     42
(1 row)

Восстановим значение параметра по умолчанию:

=> ALTER SYSTEM RESET ALL;
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart
student$ psql ext_bgworkers
=> CREATE EXTENSION dblink;
CREATE EXTENSION
=> \timing on
Timing is on.

Просто запрос (выполняем с помощью динамического SQL, чтобы исключить кеширование плана запроса):

Page 22: Page 22

=> DO $$
DECLARE
    result integer;
BEGIN
    FOR i IN 1 .. 1000 LOOP
        EXECUTE 'SELECT 1' INTO result;
    END LOOP;
END;
$$;
DO
Time: 11,551 ms

Расширение pg_background:

=> DO $$
DECLARE
    result integer;
BEGIN
    FOR i IN 1 .. 1000 LOOP
        SELECT * INTO result FROM pg_background_result(
            pg_background_launch('SELECT 1')
        ) AS (result integer);
    END LOOP;
END;
$$;
DO
Time: 10576,951 ms (00:10,577)

Расширение dblink:

=> DO $$
DECLARE
    result integer;
BEGIN
    FOR i IN 1 .. 1000 LOOP
        SELECT * INTO result FROM dblink(
            'host=localhost port=5432 dbname=postgres user=postgres password=postgres',
            'SELECT 1'
        ) AS (result integer);
    END LOOP;
END;
$$;
DO
Time: 35632,168 ms (00:35,632)

13 Занятие 13: Расширяемость / Асинхронная обработка

Исходная страница в нативном Markdown: Расширяемость / Асинхронная обработка
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_13_ext_async.html Archive: DEV2-handouts-16.zip Страниц PDF: 29

Page 1: Расширяемость / Асинхронная обработка

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Зачем нужна асинхронная обработка данных
  • Доступные решения
  • Реализация очереди средствами PostgreSQL

Page 3: Асинхронная обработка

Разнесение во времени возникновения события и его обработки

  • Соображения производительности
    • клиенту не требуется ждать ответа
    • возможность управлять ресурсами для обработки
  • Реализация
    • очередь сообщений
    • более сложный вариант: модель публикация — подписка

Комментарии преподавателя

Идея асинхронной обработки событий состоит в том, что возникновение события и его обработка разносятся во времени.

Например, пользователь хочет получить детализацию расходов на мобильную связь. Детализация формируется несколько минут. Можно показать пользователю «песочные часы» и заставить его ждать (синхронная обработка), а можно выслать детализацию по электронной почте, когда она будет готова (асинхронная обработка).

Другой пример: интеграция двух систем. Первая система обращается ко второй, передавая пакет сообщений. Обработка одного сообщения занимает несколько секунд, но в пакете может оказаться тысяча сообщений. Можно заставить первую систему ожидать получения результата (синхронная обработка), а можно ответить «работаем», обработать сообщения асинхронно и уже затем сообщить результат.

Асинхронная обработка сложнее синхронной, но часто оказывается очень удобной. Она позволяет работать эффективнее (клиенту не надо простаивать, дожидаясь ответа) и управлять ресурсами (обрабатывать события с удобной скоростью и в удобное время, а не немедленно).

(Асинхронная обработка широко применяется и в ядре PostgreSQL. Вспомните режим асинхронной фиксации; процесс контрольной точки; процесс автоочистки.)

Обычная реализация состоит в наличии очереди событий (сообщений): одни процессы создают события, другие — обрабатывают. Возможны более сложные модели, в которых есть возможность публиковать события и подписываться на события нужного типа.

Page 4: Внешние системы

RabbitMQ, ActiveMQ, ZeroMQ и т. п.

  • Плюсы
    • эти системы работают
    • следование стандартам (AMQP, JMP, STOMP, MQTT…)
    • гибкость, масштабирование, производительность
  • Возможные минусы
    • отдельная система (включающая отдельную СУБД)
    • со своими особенностями настройки, администрирования, мониторинга
    • все сложности построения распределенных систем
    • (отсутствие глобальных транзакций)

Комментарии преподавателя

Одним из вариантов реализации очередей событий являются внешние системы. Названия многих из них традиционно заканчиваются на MQ — Message Queuing.

Как правило, это большие серьезные системы, обеспечивающие гибкость, масштабируемость, высокую производительность и прочие полезные свойства. К тому же они реализуют один или несколько стандартных протоколов работы с сообщениями, что позволяет интегрировать их с другими системами, понимающими те же протоколы.

Но надо понимать, что любая большая система потребует серьезных затрат на ее изучение и внедрение. Потребуется разобраться с особенностями настройки, администрирования, мониторинга. Заметим, что в состав систем работы с очередями входит и отдельная СУБД для надежного хранения очередей.

Кроме того, использование внешней системы приводит ко всем сложностям построения распределенных систем. При отсутствии глобальных транзакций, объединяющих разные системы, возможны случаи потери сообщений в результате сбоев.

Page 5: Очередь внутри базы: PgQ

Плюсы

  • система давно на рынке и широко используется
  • Возможные минусы
    • мало гибкости, например, исключительно пакетная обработка
    • плохо документирована
    • внешняя программа-демон

Комментарии преподавателя

Более простым решением может служить реализация очереди в самой СУБД. Особенно это имеет смысл, если события возникают и обрабатываются на сервере баз данных.

Наиболее известна система PgQ, разработанная в свое время компанией Skype (https://github.com/pgq). Эта система достаточно широко используется и про нее известно, что она работает. Если требуется готовое решение, то ей можно и воспользоваться. Поддерживаются версии PostgreSQL 10+.

Из минусов этого решения отметим:

● Недостаточную гибкость. Например, возможна только пакетная обработка событий. Пока обработчик не пометит пакет, как полностью обработанный, все события пакета могут быть доставлены повторно в случае сбоя.

● Отсутствие качественной документации (есть описание API: https://pgq.github.io/extension/pgq/).

● Необходимость во внешней (относительно СУБД) программе, обеспечивающей работу очереди.

Page 6: Очередь внутри базы: pgmq

Плюсы

  • система активно развивается
  • легковесная реализация
  • все компоненты внутри базы
  • Возможные минусы
    • недостаточная гибкость в управлении на низком уровне
    • слабо документирована
    • только исходные коды, необходимость сборки

Комментарии преподавателя

Решение заявлено компанией Tembo как легковесная реализация очередей. Расширение pgmq написано на Rust и использует инфраструктуру сборки расширений pgrx.

Очереди и метаинформация представлены таблицами в базе данных, а элементы очереди («сообщения») — записями в таблицах. Сообщения можно помещать в очередь (в том числе по несколько сразу), читать из очереди, удалять и архивировать. Для выполнения этих основных действий (и еще ряда дополнительных) в расширении предусмотрен набор функций. Детали реализации очереди скрыты от пользователя, что упрощает работу.

Из минусов pgmq отметим следующие:

● Недостаточная гибкость в управлении на низком уровне. Связано с тем, что расширение pgmq базируется на фреймворке pgrx, в который встроено, в том числе, управление соединениями с сервером СУБД. Повлиять на него средствами уже собранного pgmq нельзя.

● Документация недостаточно подробна.

● В репозитории отсутствуют готовые пакеты, а сборка расширения может оказаться нетривиальной и ресурсоемкой.

https://tembo.io/pgmq/

https://github.com/tembo-io/pgmq

Page 7: Очередь средствами расширения pgmq

Создадим базу данных и подключимся к ней:

=> CREATE DATABASE ext_async;
CREATE DATABASE
=> \c ext_async
You are now connected to database "ext_async" as user "student".

Расширение pgmq уже собрано и доступно для установки. Выполним команду создания расширения в нашей базе. Все его объекты будут размещены в схеме pgmq:

=> CREATE EXTENSION pgmq;
CREATE EXTENSION

Создадим очередь под названием pgmq_queue:

=> SELECT pgmq.create('pgmq_queue');
 create
--------
(1 row)

Информация об очередях хранится в таблице meta; посмотреть очереди можно с помощью табличной функции:

=> SELECT * FROM pgmq.list_queues();
 queue_name |          created_at           | is_partitioned | is_unlogged
------------+-------------------------------+----------------+-------------
 pgmq_queue | 2026-04-10 16:07:46.688943+03 | f              | f
(1 row)

Также были созданы таблицы для сообщений очереди: основная q_pgmq_queue и архивная a_pgmq_queue:

=> \dt pgmq.*
            List of relations
 Schema |     Name     | Type  |  Owner
--------+--------------+-------+---------
 pgmq   | a_pgmq_queue | table | student
 pgmq   | meta         | table | student
 pgmq   | q_pgmq_queue | table | student
(3 rows)

Поместим в очередь несколько сообщений (полезная информация представляется значением типа jsonb)…

=> SELECT pgmq.send('pgmq_queue', to_jsonb(i))
FROM (
    VALUES ('alpha'), ('beta'), ('gamma')
) AS v(i);
 send
------
    1
    2
    3
(3 rows)

…и заглянем в основную таблицу очереди:

=> SELECT msg_id, enqueued_at, message
FROM pgmq.q_pgmq_queue
ORDER BY msg_id;
 msg_id |          enqueued_at          | message
--------+-------------------------------+---------
      1 | 2026-04-10 16:07:47.207955+03 | "alpha"
      2 | 2026-04-10 16:07:47.207955+03 | "beta"
      3 | 2026-04-10 16:07:47.207955+03 | "gamma"
(3 rows)

Page 8: Page 8

Source slide page 8: Page 8{#source-figure-008 caption=“Source slide page 8: Page 8”}

Простой способ забрать сообщение из очереди — вызвать функцию pop:

=> SELECT msg_id, enqueued_at, message
FROM pgmq.pop('pgmq_queue');
 msg_id |          enqueued_at          | message
--------+-------------------------------+---------
      1 | 2026-04-10 16:07:47.207955+03 | "alpha"
(1 row)

Другие обработчики тоже могут брать сообщения:

student$ psql -d ext_async
=> SELECT msg_id, enqueued_at, message
FROM pgmq.pop('pgmq_queue');
 msg_id |          enqueued_at          | message
--------+-------------------------------+---------
      2 | 2026-04-10 16:07:47.207955+03 | "beta"
(1 row)
student$ psql -d ext_async
=> SELECT msg_id, enqueued_at, message
FROM pgmq.pop('pgmq_queue');
 msg_id |          enqueued_at          | message
--------+-------------------------------+---------
      3 | 2026-04-10 16:07:47.207955+03 | "gamma"
(1 row)

А первый при очередном обращении обнаружит, что очередь пуста:

=> SELECT msg_id, enqueued_at, message
FROM pgmq.pop('pgmq_queue');
 msg_id | enqueued_at | message
--------+-------------+---------
(0 rows)

И напоследок удалим саму очередь. При этом ее основная и архивная таблицы исчезнут, как и информация в таблице meta:

=> SELECT pgmq.drop_queue('pgmq_queue');
 drop_queue
------------
 t
(1 row)
=> \dt pgmq.*
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+---------
 pgmq   | meta | table | student
(1 row)

Page 9: Очередь своими руками

Возможные плюсы

  • не требуются внешние зависимости
  • простые требования — простая реализация
  • Минусы
    • требуется отладка и тестирование
    • при усложнении требований готовая система может обойтись дешевле

Комментарии преподавателя

Для решения простой задачи, требующей асинхронной обработки, использование сторонних систем может оказаться невыгодным. Возможно, проще написать собственную реализацию, чем приспосабливаться к особенностям сторонней системы.

Конечно, нужно понимать, что:

● реализация должна быть сделана аккуратно, иначе она может привести к проблемам эксплуатации;

● если к системе очередей предъявляются серьезные требования (или есть шанс, что такие требования появятся в будущем), то развитие, тестирование и поддержка собственного решения, наоборот, может оказаться невыгодной.

Далее мы посмотрим, как реализовать очередь сообщений в PostgreSQL своими руками, и какие подводные камни есть на этом пути.

Page 10: Реализация очереди сообщений

Наша задача: реализовать простую очередь сообщений с возможностью конкурентного получения сообщений из нескольких процессов. Полезную информацию снова представим типом JSON — так очередь будет достаточно универсальна. В каждый конкретный момент времени в таблице сообщений не будет много строк, но за все время работы их может оказаться существенное количество. Поэтому идентификатор надо сразу сделать 64-разрядным:

=> CREATE TABLE msg_queue(
    id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    payload jsonb NOT NULL,
    pid integer DEFAULT NULL -- процесс-обработчик
);
CREATE TABLE

Вставка сообщений в очередь проста:

=> INSERT INTO msg_queue(payload)
VALUES
    (to_jsonb(1)),
    (to_jsonb(2)),
    (to_jsonb(3));
INSERT 0 3

Теперь займемся функцией получения и блокирования очередного сообщения. Нам требуется блокировать полученную строку, чтобы одно сообщение не могло быть выбрано два раза (двумя одновременно работающими обработчиками). Это можно сделать с помощью фразы FOR UPDATE:

=> BEGIN;
BEGIN
=> SELECT * FROM msg_queue
WHERE pid IS NULL -- никем не обрабатывается
ORDER BY id LIMIT 1 -- одно в порядке поступления
FOR UPDATE;
 id | payload | pid
----+---------+-----
  1 | 1       |
(1 row)

Но в таком случае аналогичный запрос в другом процессе будет заблокирован до завершения первой транзакции.

=> \c ext_async
You are now connected to database "ext_async" as user "student".
=> BEGIN;
BEGIN
=> SELECT * FROM msg_queue
WHERE pid IS NULL
ORDER BY id LIMIT 1
FOR UPDATE;

Вторая транзакция заблокирована.

=> DELETE FROM msg_queue
WHERE id = 1;
DELETE 1
=> COMMIT;
COMMIT
 id | payload | pid
----+---------+-----
  2 | 2       |
(1 row)

Page 11: Page 11

=> COMMIT;
COMMIT

Для того чтобы не останавливаться на заблокированных строках, служит фраза SKIP LOCKED команды SELECT.

=> BEGIN;
BEGIN
=> SELECT * FROM msg_queue
WHERE pid IS NULL
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;
 id | payload | pid
----+---------+-----
  2 | 2       |
(1 row)
=> BEGIN;
BEGIN
=> SELECT * FROM msg_queue
WHERE pid IS NULL
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;
 id | payload | pid
----+---------+-----
  3 | 3       |
(1 row)
=> COMMIT;
COMMIT
=> COMMIT;
COMMIT

Итак, функция для получения и блокирования очередного сообщения может выглядеть следующим образом:

=> CREATE FUNCTION take_message(OUT msg msg_queue)
LANGUAGE sql VOLATILE
BEGIN ATOMIC
    UPDATE msg_queue
    SET pid = pg_backend_pid()
    WHERE id = (SELECT id FROM msg_queue
WHERE pid IS NULL
        ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED) RETURNING *;
END;
CREATE FUNCTION

В практических заданиях к темам «Очистка» и «Фоновые задания» мы рассматривали типичное решение для получения пакета строк таблицы, например, с целью обновления или удаления. Запрос выглядел так:

WITH batch AS (
    SELECT * FROM t
    WHERE /* необходимые условия */
    LIMIT /* размер пакета */
    FOR UPDATE SKIP LOCKED
)
...

Как видите, в обоих случаях используется тот же самый подход: выбирается и блокируется часть строк (одна или несколько), при этом уже заблокированные строки пропускаются. Теперь напишем функцию завершения работы с сообщением. Мы будем просто удалять его из очереди.

=> CREATE FUNCTION complete_message(msg msg_queue) RETURNS void
LANGUAGE sql VOLATILE
BEGIN ATOMIC
  DELETE FROM msg_queue WHERE id = msg.id;
END;
CREATE FUNCTION

Page 12: Page 12

Теперь мы готовы написать цикл обработки сообщений. Оформим его в виде процедуры.

=> CREATE PROCEDURE process_queue() AS $$
DECLARE
    msg msg_queue;
BEGIN
    LOOP
        SELECT * INTO msg FROM take_message();
        EXIT WHEN msg.id IS NULL;
        -- обработка
        PERFORM pg_sleep(1);
        RAISE NOTICE '[%] processed %; n_tup_del=%, backend_xmin=%',
            pg_backend_pid(),
            msg.payload,
            (SELECT n_tup_del FROM pg_stat_xact_all_tables  -- статистика, накопленная внутри транзакции
             WHERE relname = 'msg_queue'),
            (SELECT backend_xmin FROM pg_stat_activity
             WHERE pid = pg_backend_pid());
        PERFORM complete_message(msg);
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

В этом варианте цикл заканчивается, когда в очереди не остается необработанных сообщений. Вместо этого можно не прекращать цикл, но продолжать ожидать новые события, засыпая, например, на одну секунду. Пробуем.

=> CALL process_queue();
NOTICE:  [35350] processed 2; n_tup_del=0, backend_xmin=833
NOTICE:  [35350] processed 3; n_tup_del=1, backend_xmin=833
CALL

Теперь в два потока.

=> INSERT INTO msg_queue(payload)
SELECT to_jsonb(id) FROM generate_series(1,10) id;
INSERT 0 10
=> \timing on
Timing is on.
=> CALL process_queue();
=> CALL process_queue();
NOTICE:  [36175] processed 2; n_tup_del=0, backend_xmin=835
NOTICE:  [36175] processed 4; n_tup_del=1, backend_xmin=835
NOTICE:  [36175] processed 6; n_tup_del=2, backend_xmin=835
NOTICE:  [36175] processed 8; n_tup_del=3, backend_xmin=835
NOTICE:  [36175] processed 10; n_tup_del=4, backend_xmin=835
CALL
NOTICE:  [35350] processed 1; n_tup_del=0, backend_xmin=835
NOTICE:  [35350] processed 3; n_tup_del=1, backend_xmin=835
NOTICE:  [35350] processed 5; n_tup_del=2, backend_xmin=835
NOTICE:  [35350] processed 7; n_tup_del=3, backend_xmin=835
NOTICE:  [35350] processed 9; n_tup_del=4, backend_xmin=835
CALL
Time: 5019,651 ms (00:05,020)
=> \timing off
Timing is off.

Обработка 10 сообщений двумя потоками заняла около 5 секунд, но горизонт транзакций держался на одном уровне все время обработки очереди! Это будет мешать выполнению очистки и создавать проблемы для всей базы данных.

Page 13: Вспоминаем про горизонт

Что получилось: одна большая транзакция

  • take_message();
    • обработка
  • complete_message();
  • take_message();
    • обработка
  • complete_message();
  • take_message();
    • обработка
  • complete_message();
  • COMMIT;

Комментарии преподавателя

Показанное решение имеет существенный недостаток: вся обработка выполняется в одной длинной транзакции. Вспоминая темы «Многоверсионность» и «Очистка» модуля «Архитектура», можно с уверенностью сказать, что обработка очереди будет мешать нормальной работе очистки.

Page 14: Вспоминаем про горизонт

Что надо: каждое событие в отдельной транзакции

  • take_message();
    • обработка
  • complete_message();
  • take_message();
    • обработка
  • complete_message();
  • take_message();
    • обработка
  • complete_message();
  • COMMIT;
  • COMMIT;
  • COMMIT;

Комментарии преподавателя

Чтобы таких проблем не возникало, надо раздробить длинную транзакцию на несколько более коротких. В нашем случае — обрабатывать каждое событие в собственной транзакции.

Page 15: Вспоминаем про горизонт

Source slide page 15: Вспоминаем про горизонт{#source-figure-015 caption=“Source slide page 15: Вспоминаем про горизонт”}

Еще лучше: позволить обработке события состоять из нескольких транзакций

Комментарии преподавателя

Более того, обработка одного события тоже может (в принципе) разбиваться на несколько транзакций.

В таком случае мы сначала фиксируем изменение статуса события в очереди («в работе»), затем выполняем обработку, а в конце фиксируем факт завершения работы с событием (например, удаляем его из таблицы).

Page 16: Учитываем горизонт транзакций

Это легко сделать, поскольку процедура позволяет управлять транзакциями.

=> CREATE OR REPLACE PROCEDURE process_queue() AS $$
DECLARE
    msg msg_queue;
BEGIN
    LOOP
        SELECT * INTO msg FROM take_message();
        COMMIT; --<<
        EXIT WHEN msg.id IS NULL;
        -- обработка
        PERFORM pg_sleep(1);
        RAISE NOTICE '[%] processed %; n_dead_tup=%, n_tup_del=%, backend_xmin=%',
            pg_backend_pid(),
            msg.payload,
            (SELECT n_dead_tup FROM pg_stat_all_tables  -- статистика, учитываемая автоочисткой
             WHERE relname = 'msg_queue'),
            (SELECT n_tup_del FROM pg_stat_xact_all_tables
             WHERE relname = 'msg_queue'),
            (SELECT backend_xmin FROM pg_stat_activity
             WHERE pid = pg_backend_pid());
        PERFORM complete_message(msg);
        COMMIT; --<<
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Проверим:

=> INSERT INTO msg_queue(payload)
SELECT to_jsonb(id) FROM generate_series(1,5) id;
INSERT 0 5
=> CALL process_queue();
NOTICE:  [35350] processed 1; n_dead_tup=0, n_tup_del=0, backend_xmin=840
NOTICE:  [35350] processed 2; n_dead_tup=0, n_tup_del=1, backend_xmin=842
NOTICE:  [35350] processed 3; n_dead_tup=0, n_tup_del=2, backend_xmin=844
NOTICE:  [35350] processed 4; n_dead_tup=0, n_tup_del=3, backend_xmin=846
NOTICE:  [35350] processed 5; n_dead_tup=0, n_tup_del=4, backend_xmin=848
CALL

Теперь горизонт транзакций продвигается вперед и не мешает очистке. Однако момент срабатывания автоматической очистки определяется на основе данных статистики, которая обновляется лишь по окончании работы всего оператора CALL.

=> SELECT n_dead_tup, n_live_tup, n_mod_since_analyze, n_ins_since_vacuum
FROM pg_stat_all_tables
WHERE relname = 'msg_queue';
 n_dead_tup | n_live_tup | n_mod_since_analyze | n_ins_since_vacuum
------------+------------+---------------------+--------------------
         10 |          0 |                  15 |                  5
(1 row)

Таким образом, чтобы таблица с очередью вовремя очищалась, можно: модифицировать процедуру process_queue таким образом, чтобы обеспечить ее гарантированное периодическое завершение и положиться на автоочистку; периодически запускать обычную (неавтоматическую) очистку. Один из способов реализации — использование фоновых процессов, которые рассмотрены в соответствующей теме этого курса.

Page 17: Чего не хватает

Source slide page 17: Чего не хватает{#source-figure-017 caption=“Source slide page 17: Чего не хватает”}

Зависшие сообщения

  • остаются в статусе «в работе» при аварийном завершении обработчика
  • Решение
    • проверять существование процесса-обработчика, указанного в таблице
    • при отсутствии возвращать сообщение в статус «новый» (возможно)

Комментарии преподавателя

Чего не хватает в нашей реализации?

Во-первых, обработчик может завершиться аварийно. Если мы фиксируем изменение статуса обработки, то событие «повиснет» в статусе «в работе» и не будет больше обрабатываться.

В нашей реализации мы уже сделали шаг в нужную сторону: в таблице сохраняется номер обслуживающего процесса (pid), который взял событие в работу. Можно написать простую проверку: если pid имеется в таблице, но процесса с таким номером нет в системе — значит, произошел сбой.

Что делать в таком случае? Если обработка события выполнялась в одной транзакции, то она была прервана и, следовательно, можно безопасно вернуть событие в статус «новое» — оно будет обработано повторно.

Если же обработка делится на несколько транзакций, надо быть уверенным в том, что обработку можно запускать повторно.

Page 18: Чего не хватает

Корректная обработка исключительных ситуаций

  • Сохранение результатов обработки
  • Решение
    • не удалять обработанные сообщения, а помечать отдельным статусом
    • потребуется правильный индекс
    • потребуется периодическое удаление исторических данных
    • обращаем внимание на очистку

Комментарии преподавателя

Во-вторых, наша реализация никак не обрабатывает исключительные ситуации. Это, конечно, несложно добавить. При возникновении исключения хотелось бы иметь информацию о том, что случилось.

Да и если событие обработано без ошибок, может быть полезным сохранять какую-то информацию об обработке. Это, конечно, зависит от конкретной задачи.

Наша реализация удаляет обработанные события из очереди, но вместо этого можно оставлять их, помечая специальным статусом («завершено», «ошибка» и т. п.). Тогда всю информацию об обработке можно иметь непосредственно в таблице с событиями. В таком случае потребуется эффективный доступ к еще не обработанным сообщениям: частичный индекс с условием pid IS NULL. (Другим решением может быть перенос обработанных событий в отдельную таблицу.)

За удобство потребуется платить реализацией периодического удаления «хвоста» очереди — исторических данных. Если период достаточно большой, то, возможно, удаление надо выполнять пакетами — чтобы не допускать лишнего разрастания таблицы и не мешать очистке.

И, поскольку таблица очередей изменяется довольно активно, надо обеспечить ее своевременную очистку, о чем говорилось в демонстрации.

Page 19: Итоги

Асинхронная обработка полезна во многих случаях

  • Внешние системы имеет смысл использовать, если
    • вписываются в общую архитектуру информационной системы
    • к реализации предъявляются серьезные требования
  • Очередь сообщений в базе данных — простое решение для простых задач
    • важна правильная реализация:
    • эффективное получение очередного события (SKIP LOCKED),
    • избегание долгих транзакций,
    • своевременная очистка таблицы очереди
    • чем больше требований, тем сложнее будет реализация

Page 20: Практика

  1. В приложении предусмотрен механизм фоновых заданий, но серверная часть обработки очереди отсутствует.
  • Напишите недостающие функции: - take_task — получает очередное задание из очереди; - complete_task — завершает обработку задания; - process_tasks — основной цикл обработки заданий. 2. Запустите процедуру обработки очереди заданий в фоновом режиме. Проверьте, что фоновые задания, поставленные в очередь в приложении, выполняются, а результаты их работы доступны для просмотра.

Комментарии преподавателя

  1. Фоновые задания позволяют запустить специально зарегистрированную функцию из пользовательского интерфейса и затем просматривать состояние и результат выполнения.

В качестве результата функция может возвращать множество строк, то есть в простейшем виде тело функции может состоять из одного SQL-запроса. На вход функция должна принимать один параметр типа jsonb. Пример задания: public.greeting_program.

Напишите подпрограммы take_task, complete_task и process_tasks по аналогии с показанными в демонстрации примерами. Учтите:

● take_task должна возвращать задачу в статусе «scheduled» и заполнить подходящие поля таблицы tasks:

started = текущее время, status = «running», pid = номер процесса.

● complete_task должна не удалять задание, а заполнить поля tasks:

finished = текущее время, при нормальном завершении: status = «finished», result = результат, в случае ошибки: status = «error», result = сообщение об ошибке.

● process_tasks не должна завершаться; организуйте бесконечный цикл с задержкой в 1 сек между задачами. Убедитесь, что в режиме ожидания не возникает долгой транзакции. Для удобства установите параметр application_name в значение «process_tasks».

Для фактического выполнения задания процедура должна вызвать функцию empapi.run(task tasks). В случае успешного выполнения функция вернет результат, оформленный в виде текстовой строки. В случае ошибки будет сгенерировано исключение.

Page 21: 1. Реализация обработки очереди заданий

Функция получения задания из очереди аналогична показанной в демонстрации, но должна учитывать поля таблицы:

=> SELECT * FROM tasks \gx
-[ RECORD 1 ]---------
task_id    | 1
program_id | 1
status     | scheduled
params     |
pid        |
started    |
finished   |
result     |
host       |
port       |

(Игнорируйте столбцы host и port — они пригодятся в теме «Обзор физической репликации».)

=> CREATE FUNCTION take_task(OUT task tasks) AS $$
BEGIN
    SELECT *
    INTO task
    FROM tasks
    WHERE status = 'scheduled'
    ORDER BY task_id LIMIT 1
    FOR UPDATE SKIP LOCKED;
    UPDATE tasks
    SET status = 'running',
        started = current_timestamp,
        pid = pg_backend_pid()
    WHERE task_id = task.task_id;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

Поскольку мы не будем удалять задания из очереди, создадим частичный индекс для эффективного доступа к следующему необработанному заданию:

=> CREATE INDEX ON tasks(task_id) WHERE status = 'scheduled';
CREATE INDEX

Функция завершения работы с заданием дополнительно принимает статус завершения и текстовый результат:

=> CREATE FUNCTION complete_task(task tasks, status text, result text) RETURNS void
LANGUAGE sql VOLATILE
BEGIN ATOMIC
    UPDATE tasks
    SET finished = current_timestamp,
        status = complete_task.status,
        result = complete_task.result
    WHERE task_id = task.task_id;
END;
CREATE FUNCTION

Процедура обработки очереди:

Page 22: Page 22

=> CREATE PROCEDURE process_tasks() AS $$
DECLARE
    task tasks;
    result text;
    ctx text;
BEGIN
    SET application_name = 'process_tasks';
    <<forever>>
    LOOP
        PERFORM pg_sleep(1);
        SELECT * INTO task FROM take_task();
        COMMIT;
        CONTINUE forever WHEN task.task_id IS NULL;
        BEGIN
            result := empapi.run(task);
            PERFORM complete_task(task, 'finished', result);
        EXCEPTION
            WHEN others THEN
                GET STACKED DIAGNOSTICS
                    result := MESSAGE_TEXT, ctx := PG_EXCEPTION_CONTEXT;
                PERFORM complete_task(
                    task, 'error', result || E'\n' ||  ctx
                );
        END;
        COMMIT;
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Обратите внимание, что первая команда COMMIT предшествует команде CONTINUE. В противном случае при отсутствии заданий возникала бы долгая транзакция. Несколько слов о том, зачем нужна функция run. В принципе, выполнить задание и получить результат можно было бы таким образом:

func := (
    SELECT p.func FROM programs p WHERE p.program_id = task.program_id
);
EXECUTE format(
    $$SELECT string_agg(f::text, E'\n') FROM %I($1) AS f$$,
    func
)
INTO result
USING task.params;

К сожалению, PL/pgSQL не позволяет гибко работать со значениями составного типа: у значения неизвестного наперед типа (record) нельзя перебрать все имеющиеся в нем поля. Поэтому для вывода приходится полагаться на стандартное преобразование строки в текст. Это будет некрасиво выглядеть в случае нескольких полей:

=> SELECT string_agg(f::text, E'\n') FROM greeting_task() AS f;
     string_agg
---------------------
 (1,"Hello, world!")+
 (2,"Hello, world!")+
 (3,"Hello, world!")
(1 row)

Для аккуратного оформления результата можно воспользоваться другим процедурным языком. Мы используем функцию, написанную на PL/Python. Функция run не вызывается напрямую приложением, но в теме «Обзор физической репликации» мы будем вызывать ее на другом сервере, поэтому она находится в схеме empapi, а не public. Подробнее о том, в каких случаях могут пригодиться другие языки, будет рассказано в теме «Языки программирования».

2. Запуск обработки очереди в фоновом режиме

В очереди стоит тестовое задание:

=> SELECT * FROM tasks \gx

Page 23: Page 23

-[ RECORD 1 ]---------
task_id    | 1
program_id | 1
status     | scheduled
params     |
pid        |
started    |
finished   |
result     |
host       |
port       |

Запускаем обработку (в один поток) и, если все сделано правильно, оно будет выполнено.

=> SELECT pg_background_launch('CALL process_tasks()');
 pg_background_launch
----------------------
                78423
(1 row)
=> SELECT pg_background_detach(78423);
 pg_background_detach
----------------------
(1 row)

Между вызовами нужна небольшая задержка, чтобы функция pg_background_detach не освободила общую память до того, как фоновый процесс обратится к ней — такова особенность текущей реализации. Поэтому мы не вызываем обе функции в одном операторе. Подождем немного…

=> SELECT * FROM tasks \gx
-[ RECORD 1 ]-----------------------------
task_id    | 1
program_id | 1
status     | finished
params     |
pid        | 78423
started    | 2026-04-10 16:28:16.530634+03
finished   | 2026-04-10 16:28:17.540188+03
result     | num    greeting              +
           | --- -------------            +
           | 1   Hello, world!            +
           | 2   Hello, world!            +
           | 3   Hello, world!            +
           |
host       |
port       |

Задание успешно выполнено. Обратите внимание, что результат выполнения содержит и названия столбцов из оригинального запроса. Фоновые процессы, обрабатывающие очередь, легко найти благодаря тому, что процедура устанавливает параметр application_name:

=> SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE application_name = 'process_tasks' \gx
-[ RECORD 1 ]---+---------------------
pid             | 78423
wait_event_type | Timeout
wait_event      | PgSleep
query           | CALL process_tasks()

Page 24: Практика+

  1. Напишите тест, проверяющий, что обработка очереди, показанная в демонстрации, работает корректно при выполнении в несколько потоков. Убедитесь, что тест не проходит, если убрать предложение FOR UPDATE SKIP LOCKED. 2. Добавьте в реализацию проверку «зависших» сообщений. Если такая ситуация будет обнаружена, зависшее сообщение должно быть снова принято в работу.

Комментарии преподавателя

  1. Вставьте в таблицу сообщений большое количество строк и проверьте, что:

а) было обработано каждое сообщение;

б) каждое сообщение было обработано ровно один раз.

Уберите из реализации секундную задержку (имитацию работы), чтобы тест выполнялся быстрее и с достаточным уровнем конкурентности между процессами.

Page 25: 1. Тестирование реализации очереди

=> CREATE DATABASE ext_async;
CREATE DATABASE
=> \c ext_async
You are now connected to database "ext_async" as user "student".

Повторим реализацию очереди, показанную в демонстрации. Таблица:

=> CREATE TABLE msg_queue(
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  payload jsonb NOT NULL,
  pid integer DEFAULT NULL
);
CREATE TABLE

Функция получения и блокирования очередного сообщения:

=> CREATE FUNCTION take_message(OUT msg msg_queue) AS $$
BEGIN
    SELECT *
    INTO msg
    FROM msg_queue
    WHERE pid IS NULL
    ORDER BY id LIMIT 1
    FOR UPDATE SKIP LOCKED;
    UPDATE msg_queue
    SET pid = pg_backend_pid()
    WHERE id = msg.id;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

Функция завершения работы с сообщением:

=> CREATE FUNCTION complete_message(msg msg_queue) RETURNS void
LANGUAGE sql VOLATILE
BEGIN ATOMIC
    DELETE FROM msg_queue WHERE id = msg.id;
END;
CREATE FUNCTION

В процедуру обработки очереди внесем изменение: вместо секундной задержки будем записывать информацию об обрабатываемом сообщении в отдельную таблицу:

=> CREATE TABLE msg_log(
    id bigint,
    pid integer
);
CREATE TABLE
=> CREATE PROCEDURE process_queue() AS $$
DECLARE
    msg msg_queue;
BEGIN
    LOOP
        SELECT * INTO msg FROM take_message();
        EXIT WHEN msg.id IS NULL;
        COMMIT;
        -- обработка
        INSERT INTO msg_log(id, pid) VALUES (msg.id, pg_backend_pid());
        PERFORM complete_message(msg);
        COMMIT;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

Page 26: Page 26

CREATE PROCEDURE

Создаем большое количество сообщений:

=> INSERT INTO msg_queue(payload)
SELECT to_jsonb(id) FROM generate_series(1,1000) id;
INSERT 0 1000

Запускаем обработку в два потока, засекая время:

student$ psql ext_async
=> \timing on
Timing is on.
=> CALL process_queue();
=> CALL process_queue();
CALL
CALL
Time: 3545,203 ms (00:03,545)
=> \timing off
Timing is off.

Проанализируем результаты. При корректной работе мы должны обнаружить в журнальной таблице ровно 1000 уникальных идентификаторов, что будет означать, что обработаны все события, и ни одно не обработано дважды.

=> SELECT count(*), count(DISTINCT id) FROM msg_log;
 count | count
-------+-------
  1000 |  1000
(1 row)

Все корректно. Проверим теперь реализацию без предложения FOR UPDATE SKIP LOCKED.

=> CREATE OR REPLACE FUNCTION take_message(OUT msg msg_queue) AS $$
BEGIN
    SELECT *
    INTO msg
    FROM msg_queue
    WHERE pid IS NULL
    ORDER BY id LIMIT 1
    /*FOR UPDATE SKIP LOCKED*/;
    UPDATE msg_queue
    SET pid = pg_backend_pid()
    WHERE id = msg.id;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION
=> TRUNCATE msg_queue;
TRUNCATE TABLE
=> TRUNCATE msg_log;
TRUNCATE TABLE
=> INSERT INTO msg_queue(payload)
SELECT to_jsonb(id) FROM generate_series(1,1000) id;
INSERT 0 1000

Запускаем обработку:

=> \timing on
Timing is on.
=> CALL process_queue();
=> CALL process_queue();

Page 27: Page 27

CALL
Time: 6964,778 ms (00:06,965)
CALL
=> \timing off
Timing is off.
=> SELECT count(*), count(DISTINCT id) FROM msg_log;
 count | count
-------+-------
  1713 |  1000
(1 row)

Как видим, часть сообщений была обработана дважды. Например:

=> SELECT id, array_agg(pid) FROM msg_log
GROUP BY id HAVING count(*) > 1
LIMIT 10;
  id  |   array_agg
------+---------------
 1009 | {79195,78975}
 1010 | {78975,79195}
 1012 | {78975,79195}
 1013 | {79195,78975}
 1015 | {78975,79195}
 1017 | {79195,78975}
 1019 | {79195,78975}
 1021 | {79195,78975}
 1022 | {79195,78975}
 1024 | {78975,79195}
(10 rows)

Это произошло из-за того, что сообщение, обрабатываемое одним процессом, никак не блокируется и доступно для другого процесса. Восстановим корректную функцию:

=> CREATE OR REPLACE FUNCTION take_message(OUT msg msg_queue) AS $$
BEGIN
    SELECT *
    INTO msg
    FROM msg_queue
    WHERE pid IS NULL
    ORDER BY id LIMIT 1
    FOR UPDATE SKIP LOCKED;
    UPDATE msg_queue
    SET pid = pg_backend_pid()
    WHERE id = msg.id;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

2. Обработка зависших сообщений

Мы можем перехватить ошибку, возникающую при обработке события, но тем не менее всегда есть шанс того, что сама процедура-обработчик завершится аварийно. Сымитируем такую ситуацию:

=> TRUNCATE msg_queue;
TRUNCATE TABLE
=> TRUNCATE msg_log;
TRUNCATE TABLE
=> INSERT INTO msg_queue(payload)
SELECT to_jsonb(id) FROM generate_series(1,1000) id;
INSERT 0 1000

Запускаем обработку…

=> CALL process_queue();

Page 28: Page 28

…а в это время в другом сеансе:

=> BEGIN;
BEGIN
=> LOCK TABLE msg_log;
LOCK TABLE
=> SELECT pid, pg_terminate_backend(pid) FROM msg_log LIMIT 1;
  pid  | pg_terminate_backend
-------+----------------------
 79195 | t
(1 row)
=> COMMIT;
COMMIT
FATAL:  terminating connection due to administrator command
CONTEXT:  SQL statement "INSERT INTO msg_log(id, pid) VALUES (msg.id, pg_backend_pid())"
PL/pgSQL function process_queue() line 11 at SQL statement
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost

Обработчик «упал». Причем, благодаря команде LOCK TABLE, — сразу после того, как зафиксировал номер процесса в таблице очереди. В очереди остались необработанные сообщения и среди них — одно зависшее:

=> SELECT count(*), count(DISTINCT id) FROM msg_log;
 count | count
-------+-------
    32 |    32
(1 row)
=> SELECT * FROM msg_queue WHERE pid IS NOT NULL;
  id  | payload |  pid
------+---------+-------
 2033 | 33      | 79195
(1 row)

Самый простой способ исправить ситуацию — изменить функцию выбора сообщения:

=> CREATE OR REPLACE FUNCTION take_message(OUT msg msg_queue) AS $$
BEGIN
    SELECT *
    INTO msg
    FROM msg_queue
    WHERE pid IS NULL OR pid NOT IN (SELECT pid FROM pg_stat_activity)
    ORDER BY id LIMIT 1
    FOR UPDATE SKIP LOCKED;
    UPDATE msg_queue
    SET pid = pg_backend_pid()
    WHERE id = msg.id;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

Если события обрабатываются быстро и важна высокая пропускная способность, то проверку лучше выполнять отдельно и только время от времени, чтобы избежать постоянного обращения к pg_stat_activity. Снова запустим обработчик, и все сообщения, включая зависшее, будут обработаны.

=> CALL process_queue();
CALL
=> SELECT count(*), count(DISTINCT id) FROM msg_log;

Page 29: Page 29

 count | count
-------+-------
  1000 |  1000
(1 row)

14 Занятие 14: Расширяемость / Создание расширений

Исходная страница в нативном Markdown: Расширяемость / Создание расширений
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_14_ext_extensions.html Archive: DEV2-handouts-16.zip Страниц PDF: 22

Page 1: Расширяемость / Создание расширений

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Расширения в PostgreSQL
  • Создание расширений
  • Версии расширений и обновление
  • Особенности работы утилиты pg_dump

Page 3: Расширяемость PostgreSQL

Возможности

  • функции и языки программирования
  • типы данных, операторы, методы доступа
  • обертки сторонних данных (FDW)
  • Механизмы
    • изменяемый системный каталог
    • API для подключения внешних обработчиков
    • загрузка и выполнение пользовательского кода

Комментарии преподавателя

Расширяемость — важнейшая черта PostgreSQL — это возможность подключать «на лету» новый функционал без изменения кода сервера.

Таким образом можно добавлять языки программирования и разрабатывать на них функции, определять новые типы данных и операторы для работы с ними, создавать новые методы доступа для типов данных, разрабатывать обертки сторонних данных для подключения к внешним источникам.

Для того чтобы это было возможным, системный каталог PostgreSQL хранит большое количество информации об объектах БД. Эта информация не зашита жестко в код сервера. Пользователи могут изменять содержимое таблиц системного каталога, тем самым добавляя новые объекты и связанный с ними функционал.

Кроме того, в исходном коде PostgreSQL встроено большое количество хуков и различных API для подключения пользовательских функций. Это дает возможность разрабатывать такие расширения как pg_stat_statements, auto_explain, pldebugger и многие, многие другие.

Завершает картину возможность загружать в серверные процессы пользовательский код. Например, можно написать разделяемую библиотеку и подключать ее по ходу работы.

https://postgrespro.ru/docs/postgresql/16/extend-how

В качестве предостережения следует отметить, что выполнение процессами сервера неправильно написанного пользовательского кода может привести к катастрофическим последствиям. Следует доверять только проверенному коду из надежных источников.

Page 4: Расширения

Группа взаимосвязанных объектов БД

  • установка всех объектов одной командой
  • невозможность удалить отдельный объект
  • сохранение связи при выгрузке с помощью pg_dump
  • инструменты для перехода на новую версию
  • Источники
    • в составе дистрибутива (contrib)
    • внешние расширения
    • возможность создания собственных расширений

Комментарии преподавателя

Бывают ситуации, когда несколько объектов базы данных логически связаны между собой. Например, несколько типов данных, функции и операторы для работы с ними, классы операторов. Такую связь можно сделать явной с помощью механизма расширений.

Это облегчает управление объектами:

● все объекты устанавливаются одной командой;

● невозможно удалить отдельный объект — расширение можно удалить только полностью;

● связь между объектами сохраняется и при создании резервной копии с помощью утилиты pg_dump;

● есть инструменты для управления версиями расширений.

В состав PostgreSQL входит значительное количество полезных расширений, частью из которых мы уже пользовались.

https://postgrespro.ru/docs/postgresql/16/contrib

https://postgrespro.ru/docs/postgresql/16/contrib-prog

Другой источник — PostgreSQL Extension Network (PGXN) — сеть расширений по аналогии с CPAN для Perl: https://pgxn.org/

Расширения могут распространяться и другими способами, в том числе через пакетные репозитории дистрибутивов ОС.

В этой теме мы рассмотрим, как создавать собственные расширения.

Page 5: Создание расширения

Source slide page 5: Создание расширения{#source-figure-005 caption=“Source slide page 5: Создание расширения”}

Комментарии преподавателя

Расширение устанавливается в базу данных командой CREATE EXTENSION. При этом должны существовать два файла:

● управляющий файл «имя.control» с параметрами расширения;

● скрипт создания объектов расширения «имя–версия.sql».

Версия традиционно имеет вид «1.0», «1.1» и т. д., но это не обязательно: ее имя может состоять из любых символов (но не должно содержать «–» и начинаться или заканчиваться на «-»).

Обычно версию не указывают в команде CREATE EXTENSION, поскольку текущая актуальная версия записана в управляющем файле (параметр default_version) и используется по умолчанию.

Другие параметры расширения указывают зависимости от других расширений (requires), возможность перемещения объектов расширения между схемами (relocatable), возможность установки только суперпользователем (superuser) и др.

https://postgrespro.ru/docs/postgresql/16/extend-extensions

https://postgrespro.ru/docs/postgresql/16/sql-createextension

Расширение удаляется командой DROP EXTENSION. Скрипт для удаления объектов писать не нужно.

https://postgrespro.ru/docs/postgresql/16/sql-dropextension

Page 6: Создание расширения

Source slide page 6: Создание расширения{#source-figure-006 caption=“Source slide page 6: Создание расширения”}

Создадим простое расширение — конвертер единиц измерения, и назовем его uom (units of measure). Начнем с каталога, в котором будем создавать необходимые файлы:

student$ mkdir /home/student/tmp/uom

Сначала создадим управляющий файл с настройками (мы можем сделать это в любом текстовом редакторе). default_version определяет версию по умолчанию, без этого параметра версию придется указывать явно; relocatable говорит о том, что расширение можно перемещать из схемы в схему (мы поговорим об этом чуть позже); encoding требуется, если используются символы, отличные от ASCII; comment определяет комментарий к расширению.

/home/student/tmp/uom/uom.control
default_version = '1.0'
relocatable = true
encoding = UTF8
comment = 'Единицы измерения'

Это не все возможные параметры; полный список можно узнать из документации. Теперь займемся файлом с командами, создающими объекты расширения. Первая строка файла предотвращает случайный запуск скрипта вручную. Все команды будут выполнены в одной транзакции — неявном блоке BEGIN … END. Поэтому команды управления транзакциями (и служебные команды, такие, как VACUUM) здесь не допускаются. Путь поиска (параметр search_path) будет установлен на единственную схему — ту, в которой создаются объекты расширения.

/home/student/tmp/uom/uom--1.0.sql
\echo Use "CREATE EXTENSION uom" to load this file. \quit
-- Справочник единиц измерения
CREATE TABLE uoms (
    uom text PRIMARY KEY,
    k numeric NOT NULL
);
GRANT SELECT ON uoms TO public;
INSERT INTO uoms(uom,k) VALUES ('м',1), ('км',1000), ('см',0.01);
-- Функция для перевода значения из одной единицы
в другую
CREATE FUNCTION convert(value numeric, uom_from text, uom_to text) RETURNS numeric
LANGUAGE sql STABLE STRICT
RETURN convert.value *
    (SELECT k FROM uoms WHERE uom = convert.uom_from) /
    (SELECT k FROM uoms WHERE uom = convert.uom_to);

Чтобы PostgreSQL нашел созданные нами файлы, они должны оказаться в каталоге SHAREDIR/extension. Значение SHAREDIR можно узнать так:

student$ pg_config --sharedir
/usr/share/postgresql/16

Например, посмотрим на файлы расширения pg_background:

student$ ls `pg_config --sharedir`/extension/pg_background*
/usr/share/postgresql/16/extension/pg_background--1.0--1.4.sql
/usr/share/postgresql/16/extension/pg_background--1.1--1.4.sql
/usr/share/postgresql/16/extension/pg_background--1.2--1.4.sql
/usr/share/postgresql/16/extension/pg_background--1.3--1.4.sql
/usr/share/postgresql/16/extension/pg_background--1.4.sql
/usr/share/postgresql/16/extension/pg_background.control

Конечно, файлы расширения можно скопировать вручную, но стандартный способ — воспользоваться утилитой make. Ей понадобится Makefile, который должен выглядеть, как показано ниже.

Page 7: Page 7

Переменная EXTENSION задает имя расширения; Переменная DATA определяет список файлов, которые надо скопировать в SHAREDIR (кроме управляющего); Последние строки не меняются. Они подключают специальный Makefile для расширений, который содержит всю необходимую логику сборки и установки. Важно, чтобы утилита pg_config была доступна — иначе неизвестны пути, по которым установлен PostgreSQL.

/home/student/tmp/uom/Makefile
EXTENSION = uom
DATA = uom--1.0.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

Теперь выполним make install в каталоге расширения:

student$ sudo make install -C /home/student/tmp/uom
make: Entering directory '/home/student/tmp/uom'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//uom.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//uom--1.0.sql  '/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/tmp/uom'

Создадим базу данных и подключимся к ней:

=> CREATE DATABASE ext_extensions;
CREATE DATABASE
=> \c ext_extensions
You are now connected to database "ext_extensions" as user "student".

Проверим, доступно ли наше расширение?

=> SELECT * FROM pg_available_extensions WHERE name = 'uom';
 name | default_version | installed_version |      comment
------+-----------------+-------------------+-------------------
 uom  | 1.0             |                   | Единицы измерения
(1 row)

Попробуем создать в новой базе расширение uom:

=> CREATE EXTENSION uom;
CREATE EXTENSION

Мы не указали версию, поэтому было взято значение из управляющего файла (1.0).

=> SELECT * FROM uoms;
 uom |  k
-----+------
 м   |    1
 км  | 1000
 см  | 0.01
(3 rows)
=> SELECT convert(2, 'км', 'м');
        convert
-----------------------
 2000.0000000000000000
(1 row)

Все работает. Само расширение не относится к какой-либо схеме, но объекты расширения — относятся. В какой схеме они созданы?

=> \dt uoms
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+---------
 public | uoms | table | student
(1 row)

Page 8: Page 8

Объекты установлены в схему, в которой они были бы созданы по умолчанию; в данном случае — public. При создании расширения мы можем указать эту схему явно:

CREATE EXTENSION uom SCHEMA public;

Поскольку мы указали в управляющем файле, что расширение переносимо (relocatable), его можно переместить в другую схему:

=> CREATE SCHEMA uom;
CREATE SCHEMA
=> ALTER EXTENSION uom SET SCHEMA uom;
ALTER EXTENSION

Теперь все объекты находятся в схеме uom:

=> \dt uom.*
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+---------
 uom    | uoms | table | student
(1 row)
=> \df uom.*
                                   List of functions
 Schema |  Name   | Result data type |            Argument data types            | Type
--------+---------+------------------+-------------------------------------------+------
 uom    | convert | numeric          | value numeric, uom_from text, uom_to text | func
(1 row)

Можно ли прочитать данные из таблицы без указания схемы, где она расположена?

=> SELECT * FROM uoms;
ERROR:  relation "uoms" does not exist
LINE 1: SELECT * FROM uoms;
                      ^

Нет, потому что теперь таблица не находится в пути поиска. А будет ли работать функция, если при ее вызове мы явно укажем схему? Напомним, что в определении тела функции обращение к таблице не включало название схемы.

=> SELECT uom.convert(2, 'км', 'м');
        convert
-----------------------
 2000.0000000000000000
(1 row)

Да, потому что код функции был оформлен в современном стиле стандарта SQL, а значит еще на этапе создания был выполнен его разбор и теперь обращение к таблице производится по ее идентификатору, а не по символическому имени. Позаботимся о путях поиска; данные из таблицы читаются:

=> SET search_path = uom, public;
SET
=> SELECT * FROM uoms;
 uom |  k
-----+------
 м   |    1
 км  | 1000
 см  | 0.01
(3 rows)

Отметим, что некоторые расширения не допускают перемещения, но это бывает нечасто.

Page 9: Обновление расширения

Source slide page 9: Обновление расширения{#source-figure-009 caption=“Source slide page 9: Обновление расширения”}

Комментарии преподавателя

Обновление версии расширения выполняется командой ALTER EXTENSION UPDATE. При этом должен существовать скрипт обновления «имя–старая-версия–новая-версия.sql», содержащий необходимые для обновления команды.

Также необходимо изменить управляющий файл «имя.control», обновив актуальную версию и, возможно, другие параметры.

При необходимости может существовать и отдельный управляющий файл, привязанный к версии. Например, если в версии 1.2 появилась зависимость от другого расширения, то эту зависимость неправильно указывать в основном управляющем файле. Параметры, указанные в дополнительном управляющем файле, более приоритетны, чем параметры основного управляющего файла.

В примере, приведенном на слайде, имеются скрипты обновления 1.0→1.1 и 1.1→1.2. Можно создать и скрипт 1.0→1.2, но, как правило, это не требуется: механизм расширений сам берет на себя выбор пути с учетом доступных переходов между версиями. Например, если установлена версия 1.0, то ее можно обновить сразу до 1.2: сначала автоматически применится скрипт 1.0→1.1, а затем 1.1→1.2.

Как и при создании, при обновлении номер версии обычно не указывают — в этом случае обновление происходит до последней актуальной версии, записанной в основном управляющем файле.

https://postgrespro.ru/docs/postgresql/16/sql-alterextension

Page 10: Версии расширения и обновление

При некотором размышлении мы можем сообразить, что не любые единицы допускают преобразование. Например, метры нельзя пересчитать в килограммы. Создадим версию 1.1 нашего расширения, которая это учитывает. В управляющем файле исправим версию на 1.1:

/home/student/tmp/uom/uom.control
default_version = '1.1'
relocatable = true
encoding = UTF8
comment = 'Единицы изменения'

И создадим файл с командами для обновления:

/home/student/tmp/uom/uom--1.0--1.1.sql
\echo Use "CREATE EXTENSION uom" to load this file. \quit
-- Все, что было, отнесем к мерам длины
ALTER TABLE uoms ADD uom_class text NOT NULL DEFAULT 'длина';
-- Добавим единицы измерения массы
INSERT INTO uoms(uom,k,uom_class) VALUES
    ('г', 1,'масса'), ('кг', 1_000,'масса'),
    ('ц', 100_000,'масса'), ('т', 1_000_000,'масса');
-- Функция для перевода значения из одной единицы
в другую
CREATE OR REPLACE FUNCTION convert(
    value numeric,
    uom_from text,
    uom_to text
)
RETURNS numeric AS $$
DECLARE
    uoms_from uoms;
    uoms_to uoms;
BEGIN
    SELECT * INTO uoms_from FROM uoms WHERE uom = convert.uom_from;
    SELECT * INTO uoms_to FROM uoms WHERE uom = convert.uom_to;
    IF uoms_from.uom_class != uoms_to.uom_class THEN
        RAISE EXCEPTION 'Невозможно преобразовать : % -> %',
            uoms_from.uom_class, uoms_to.uom_class;
    END IF;
    RETURN convert.value * uoms_from.k / uoms_to.k;
END;
$$ LANGUAGE plpgsql STABLE STRICT;

Добавим в Makefile новый файл в список DATA:

/home/student/tmp/uom/Makefile
EXTENSION = uom
DATA = uom--1.0.sql uom--1.0--1.1.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

Выполним make install, чтобы разместить файлы расширения:

student$ sudo make install -C /home/student/tmp/uom
make: Entering directory '/home/student/tmp/uom'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//uom.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//uom--1.0.sql .//uom--1.0--1.1.sql
'/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/tmp/uom'

Какие версии расширения нам доступны?

Page 11: Page 11

=> SELECT name, version, installed
FROM pg_available_extension_versions
WHERE name = 'uom';
 name | version | installed
------+---------+-----------
 uom  | 1.0     | t
 uom  | 1.1     | f
(2 rows)

Какие пути обновления доступны?

=> SELECT * FROM pg_extension_update_paths('uom');
 source | target |   path
--------+--------+----------
 1.0    | 1.1    | 1.0--1.1
 1.1    | 1.0    |
(2 rows)

Очевидно, путь один. Заметьте, что если бы мы создали файл «uom–1.1–1.0.sql», можно было бы «понизить версию». Для механизма расширений имена версий ничего не значат. Выполним обновление:

=> ALTER EXTENSION uom UPDATE;
ALTER EXTENSION

Теперь нам доступен новый функционал:

=> SELECT convert(2, 'ц', 'кг');
       convert
----------------------
 200.0000000000000000
(1 row)
=> SELECT convert(1, 'м', 'кг');
ERROR:  Невозможно преобразовать : длина -> масса
CONTEXT:  PL/pgSQL function convert(numeric,text,text) line 9 at RAISE

Утилита pg\_dump

Что попадает в резервную копию базы данных, созданную с помощью утилиты pg_dump?

student$ pg_dump ext_extensions | grep -v '^--'
\restrict IwuMWGFngVEyQ3Vg2T3XUBJYTULgdT2IiGhrZrJ0Z3yzQiJIUYHFEZyMmjr2KCJ
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE SCHEMA uom;
ALTER SCHEMA uom OWNER TO student;
CREATE EXTENSION IF NOT EXISTS uom WITH SCHEMA uom;
COMMENT ON EXTENSION uom IS 'Единицы измерения';

Page 12: Page 12

\unrestrict IwuMWGFngVEyQ3Vg2T3XUBJYTULgdT2IiGhrZrJ0Z3yzQiJIUYHFEZyMmjr2KCJ

Вначале идут установки различных параметров сервера; Объекты расширения не попадают в резервную копию, вместо этого выполняется команда CREATE EXTENSION — это позволяет сохранить зависимости между объектами. В процессе работы с расширением пользователь может захотеть расширить справочник единиц измерения:

=> INSERT INTO uoms(uom,k,uom_class) VALUES
    ('верста',1066.8,'длина'), ('сажень',2.1336,'длина');
INSERT 0 2

Что теперь попадает в резервную копию?

student$ pg_dump ext_extensions | grep -v '^--'
\restrict LhLP51mM1O6eMjckrlK0yyJeuK30bq8IcAChcqyRhf64KnB7LamK2fa9cs9ow8e
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE SCHEMA uom;
ALTER SCHEMA uom OWNER TO student;
CREATE EXTENSION IF NOT EXISTS uom WITH SCHEMA uom;
COMMENT ON EXTENSION uom IS 'Единицы измерения';
\unrestrict LhLP51mM1O6eMjckrlK0yyJeuK30bq8IcAChcqyRhf64KnB7LamK2fa9cs9ow8e

Сделанные пользователем изменения будут потеряны. Но этого можно избежать, если мы сможем разделить предустановленные значения и пользовательские. Подготовим версию 1.2 расширения.

=> DELETE FROM uoms WHERE uom IN ('верста', 'сажень');
DELETE 2

В управляющем файле исправим версию на 1.2:

/home/student/tmp/uom/uom.control
default_version = '1.2'
relocatable = true
encoding = UTF8
comment = 'Единицы измерения'

Создадим файл с командами для обновления. Вызов функции pg_extension_config_dump определяет, какие строки таблицы требуют выгрузки.

/home/student/tmp/uom/uom--1.1--1.2.sql
\echo Use "CREATE EXTENSION uom" to load this file. \quit
-- Добавляем признак предустановленных данных
ALTER TABLE uoms ADD seeded boolean NOT NULL DEFAULT false;
UPDATE uoms SET seeded = true;
SELECT pg_extension_config_dump('uoms', 'WHERE NOT seeded');

Page 13: Page 13

Добавим в Makefile новый файл в список DATA:

/home/student/tmp/uom/Makefile
EXTENSION = uom
DATA = uom--1.0.sql uom--1.0--1.1.sql uom--1.1--1.2.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

Выполним make install, чтобы разместить файлы расширения:

student$ sudo make install -C /home/student/tmp/uom
make: Entering directory '/home/student/tmp/uom'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//uom.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//uom--1.0.sql .//uom--1.0--1.1.sql .//uom--1.1--1.2.sql
'/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/tmp/uom'

И выполним обновление:

=> ALTER EXTENSION uom UPDATE;
ALTER EXTENSION

Повторим эксперимент:

=> INSERT INTO uoms(uom, k, uom_class) VALUES
    ('верста', 1066.8, 'длина'), ('сажень', 2.1336, 'длина');
INSERT 0 2

Что теперь попадает в резервную копию?

student$ pg_dump ext_extensions | grep -v '^--'
\restrict 7TiHb0FQ9NhgR2gEbIar4aAE5uyKDrcI1j9O9XMYGRnSTHvkrusqjHOZuuZSAv0
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
CREATE SCHEMA uom;
ALTER SCHEMA uom OWNER TO student;
CREATE EXTENSION IF NOT EXISTS uom WITH SCHEMA uom;
COMMENT ON EXTENSION uom IS 'Единицы измерения';
COPY uom.uoms (uom, k, uom_class, seeded) FROM stdin;
верста 1066.8 длина
f
сажень 2.1336 длина
f
\.
\unrestrict 7TiHb0FQ9NhgR2gEbIar4aAE5uyKDrcI1j9O9XMYGRnSTHvkrusqjHOZuuZSAv0

На этот раз все правильно: после создания расширения в таблицу добавляются строки, созданные пользователем.

Page 14: Page 14

Page 15: Итоги

Расширения — упаковка взаимосвязанных объектов БД, предназначенных для решения какой-либо задачи

  • Расширения упрощают использование функционала
  • Имеются средства для разработки собственных расширений

Page 16: Практика

  1. Создайте расширение bookfmt, содержащее все необходимое для работы с типом данных для формата изданий. Установите расширение, чтобы объединить имеющиеся в базе данных разрозненные объекты. 2. Воспользуйтесь стандартным расширением isn для того чтобы проверить корректность кодов ISBN у имеющихся в магазине книг.

Комментарии преподавателя

  1. Объекты базы данных, относящиеся к книжному формату — тип данных book_format, приведение типов, функции, операторы и класс операторов — мы создавали в темах «Пользовательские типы данных» и «Классы операторов».

Если создать расширение так, как это показывалось в демонстрации, то для его установки придется удалить из базы существующие объекты. Что, конечно, нежелательно для работающей системы.

Поэтому сначала создайте и установите пустое расширение, а затем напишите скрипт для его обновления, добавляющий к расширению уже имеющиеся в базе данных объекты с помощью команды ALTER EXTENSION ADD ….

  1. В достаточно старых книгах используется 10-значный код ISBN. С 2007 года для совместимости со штрихкодами используют 13-значный код ISBN (первые три цифры всегда равны 978).

Код имеет формат: 978 страна изд-во издание контрольная_цифра.

Между группами цифр обычно ставятся дефисы, но это незначащий символ. Контрольная цифра вычисляется по специальным правилам.

В расширении isn имеются функции isbn(text) для 10-значного кода и isbn13(text) для 13-значного. Если контрольная цифра неверна, функции вызывают ошибку.

Page 17: 1. Расширения для книжного формата

Сначала создадим «пустое» расширение без объектов.

student$ mkdir bookfmt
bookfmt/bookfmt.control
default_version = '0'
relocatable = true
encoding = UTF8
comment = 'Формат издания'
bookfmt/bookfmt--0.sql
\echo Use "CREATE EXTENSION bookfmt" to load this file. \quit
bookfmt/Makefile
EXTENSION = bookfmt
DATA = bookfmt--0.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
student$ sudo make install -C bookfmt
make: Entering directory '/home/student/bookfmt'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//bookfmt.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//bookfmt--0.sql  '/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/bookfmt'

Установим расширение.

=> CREATE EXTENSION bookfmt;
CREATE EXTENSION

Теперь напишем скрипт для обновления, в котором добавим в расширение уже существующие в базе объекты.

bookfmt/bookfmt.control
default_version = '1.0'
relocatable = true
encoding = UTF8
comment = 'Формат издания'
bookfmt/bookfmt--0--1.0.sql
\echo Use "CREATE EXTENSION bookfmt" to load this file. \quit
ALTER EXTENSION bookfmt ADD TYPE book_format;
ALTER EXTENSION bookfmt ADD FUNCTION book_format_to_text;
ALTER EXTENSION bookfmt ADD CAST (book_format AS text);
ALTER EXTENSION bookfmt ADD FUNCTION book_format_area;
ALTER EXTENSION bookfmt ADD FUNCTION book_format_cmp;
ALTER EXTENSION bookfmt ADD FUNCTION book_format_lt;
ALTER EXTENSION bookfmt ADD OPERATOR < (book_format, book_format);
ALTER EXTENSION bookfmt ADD FUNCTION book_format_le;
ALTER EXTENSION bookfmt ADD OPERATOR <= (book_format, book_format);
ALTER EXTENSION bookfmt ADD FUNCTION book_format_eq;
ALTER EXTENSION bookfmt ADD OPERATOR = (book_format, book_format);
ALTER EXTENSION bookfmt ADD FUNCTION book_format_gt;
ALTER EXTENSION bookfmt ADD OPERATOR > (book_format, book_format);
ALTER EXTENSION bookfmt ADD FUNCTION book_format_ge;
ALTER EXTENSION bookfmt ADD OPERATOR >= (book_format, book_format);
ALTER EXTENSION bookfmt ADD OPERATOR CLASS book_format_ops USING btree;
bookfmt/Makefile
EXTENSION = bookfmt
DATA = bookfmt--0.sql bookfmt--0--1.0.sql

Page 18: Page 18

PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
student$ sudo make install -C bookfmt
make: Entering directory '/home/student/bookfmt'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//bookfmt.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//bookfmt--0.sql .//bookfmt--0--1.0.sql
'/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/bookfmt'

Выполним обновление:

=> ALTER EXTENSION bookfmt UPDATE;
ALTER EXTENSION

Теперь все объекты объединены в расширение, которые мы при необходимости сможем развивать. А для того чтобы расширением могли воспользоваться другие, надо подготовить файл «bookfmt–1.0.sql» с командами, создающими все необходимые объекты.

2. Проверка кодов ISBN

Установим расширение:

=> CREATE EXTENSION isn;
CREATE EXTENSION
=> DO $$
DECLARE
    b_id bigint;
    i text;
    i10 isbn;
    i13 isbn13;
BEGIN
    FOR b_id, i IN SELECT book_id, additional->>'ISBN' FROM books LOOP
        BEGIN
            IF length(translate(i,'-','')) = 10 THEN
                i10 := isbn(i);
            ELSE
                i13 := isbn13(i);
            END IF;
        EXCEPTION
            WHEN others THEN
                RAISE NOTICE 'book_id=%: %', b_id, sqlerrm;
        END;
    END LOOP;
END;
$$;
NOTICE:  book_id=20: invalid check digit for ISBN number: "5-7490-0068-9", should be 0
NOTICE:  book_id=61: invalid input syntax for ISBN number: "5-9000242-17-x"
NOTICE:  book_id=19: invalid check digit for ISBN number: "5-7490-0065-1", should be 6
DO

Проблемные данные часто встречаются в реальных системах. В данном случае они могут быть вызваны не только ошибками при вводе, но и неправильно указанным кодом в самой книге. Расширение isn имеет возможность работы в нестрогом режиме, допуская ошибки (но предупреждая о них).

Page 19: Практика+

  1. Создайте расширение с функцией для подготовки текста к публикации. Функция должна применить к текстовому параметру последовательность правил, выполняющих замену по регулярному выражению, и вернуть результат. Правила должны храниться в таблице и применяться в порядке их вставки. К предопределенным правилам пользователь может добавлять собственные. Работа функции не должна зависеть от настройки пути поиска, но должна позволять выбрать схему при установке. 2. Установите расширение в схему typo, добавьте в таблицу пользовательское правило. Корректно ли выгружает копию базы данных утилита pg_dump? Проверьте возможность добавить правило после восстановления из резервной копии.

Комментарии преподавателя

  1. В качестве предопределенных правил используйте, например:

(^|\s)\"(\S) → \1«\2

(\S)\"(\s|$) → \1»\2

(^|\s)-(\s|$) → \1—\2

Эти правила заменяют (не всегда корректно) обычные символы на кавычки-елочки и тире:

  • Буквы “р” нет, - сказал я. → — Буквы «р» нет, — сказал я.

Чтобы обеспечить независимость от пути поиска, расширение придется сделать непереносимым. Укажите соответствующий параметр в управляющем файла, а в теле функции используйте макрос @extschema@ для указания схемы, в которой находится таблица правил. Этот макрос будет заменен на выбранную схему при установке.

  1. Чтобы утилита pg_dump правильно выгружала пользовательские правила, используйте функцию pg_extension_config_dump и для самой таблицы, и для последовательности, созданной для первичного ключа.

При вызове pg_dump можно указать ключи –clean и –create, чтобы копия включала в себя команды для удаления и создания базы данных.

Page 20: 1. Расширение для подготовки текста

student$ mkdir typo

В управляющем файле указываем relocatable = false:

typo/typo.control
default_version = '1.0'
relocatable = false
encoding = UTF8
comment = 'Подготовка текста по настраиваемым
правилам'

Создаем файл с командами.

typo/typo--1.0.sql
\echo Use "CREATE EXTENSION typo" to load this file. \quit
-- В таблице предусматриваем столбец seeded, чтобы
отличать предустановленные правила от
пользовательских.
CREATE TABLE typo_rules (
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    pattern text NOT NULL,
    replace_by text NOT NULL,
    seeded boolean DEFAULT false
);
GRANT SELECT ON typo_rules TO public;
INSERT INTO typo_rules(pattern, replace_by, seeded) VALUES
    ('(^|\s)\"(\S)',   '\1«\2', true),
    ('(\S)\"(\s|$)',   '\1»\2', true),
    ('(^|\s)-(\s|$)', '\1—\2', true);
-- Добавляем функцию, в ней квалифицируем таблицу
именем схемы, чтобы не зависеть от настройки
пути поиска. Имя схемы задается макросом.
CREATE FUNCTION typo(INOUT s text) AS $$
DECLARE
    r record;
BEGIN
    FOR r IN (
        SELECT pattern, replace_by
        FROM @extschema@.typo_rules
        ORDER BY id
    )
    LOOP
        s := regexp_replace(s, r.pattern, r.replace_by, 'g');
    END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;
-- Для того чтобы утилита pg_dump корректно
выгружала пользовательские правила, вызываем
специальную функцию не только для таблицы, но и
для последовательности, которая используется
для первичного ключа.
SELECT pg_extension_config_dump('typo_rules', 'WHERE NOT seeded');
SELECT pg_extension_config_dump('typo_rules_id_seq', '');

Makefile и установка расширения в систему:

typo/Makefile
EXTENSION = typo
DATA = typo--1.0.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

Page 21: 2. Проверка

student$ sudo make install -C typo
make: Entering directory '/home/student/typo'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//typo.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//typo--1.0.sql  '/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/typo'

Создаем базу данных и схему, и устанавливаем расширение:

=> CREATE DATABASE ext_extensions;
CREATE DATABASE
=> \c ext_extensions
You are now connected to database "ext_extensions" as user "student".
=> CREATE SCHEMA typo;
CREATE SCHEMA
=> CREATE EXTENSION typo SCHEMA typo;
CREATE EXTENSION

При установке макрос был автоматически заменен на имя выбранной схемы:

=> \sf typo.typo
CREATE OR REPLACE FUNCTION typo.typo(INOUT s text)
 RETURNS text
 LANGUAGE plpgsql
 STABLE
AS $function$
DECLARE
    r record;
BEGIN
    FOR r IN (
        SELECT pattern, replace_by
        FROM typo.typo_rules
        ORDER BY id
    )
    LOOP
        s := regexp_replace(s, r.pattern, r.replace_by, 'g');
    END LOOP;
END;
$function$

Проверим:

=> SELECT typo.typo(
    'Вдруг попугай заорал: "Овер-рсан! Овер-рсан!" - и все замерли.'
);
                              typo
----------------------------------------------------------------
 Вдруг попугай заорал: «Овер-рсан! Овер-рсан!» — и
все замерли.
(1 row)

Добавим правило:

=> INSERT INTO typo.typo_rules(pattern, replace_by)
    VALUES (' +', ' ');
INSERT 0 1
=> SELECT typo.typo(
    '-  Будет, -  сказал Дрозд. - Я уже   букву "к" нарисовал.'
);
                         typo
-------------------------------------------------------
 — Будет, — сказал Дрозд. — Я уже букву «к»
нарисовал.
(1 row)

Page 22: Page 22

=> \q

Выгружаем копию базы данных и восстанавливаемся из нее (при восстановлении база данных будет удалена и создана заново):

student$ pg_dump --clean --create ext_extensions > ext_extensions.dump
student$ psql -f ext_extensions.dump
SET
SET
SET
SET
SET
 set_config
------------
(1 row)
SET
SET
SET
SET
DROP DATABASE
CREATE DATABASE
ALTER DATABASE
You are now connected to database "ext_extensions" as user "student".
SET
SET
SET
SET
SET
 set_config
------------
(1 row)
SET
SET
SET
SET
CREATE SCHEMA
ALTER SCHEMA
CREATE EXTENSION
COMMENT
COPY 1
 setval
--------
      4
(1 row)

Обратите внимание, что последней командой было установлено корректное значение последовательности. Если бы функция pg_extension_config_dump была вызвана только для таблицы, этого бы не произошло.

student$ psql ext_extensions

Проверяем:

=> INSERT INTO typo.typo_rules(pattern, replace_by)
    VALUES ('\.\.\.', '…');
INSERT 0 1
=> SELECT typo.typo(
    'Как это там... Соус пикан. Полстакана уксусу, две луковицы... и перчик.'
);
                                typo
---------------------------------------------------------------------
 Как это там… Соус пикан. Полстакана уксусу, две
луковицы… и перчик.
(1 row)

15 Занятие 15: Расширяемость / Языки программирования

Исходная страница в нативном Markdown: Расширяемость / Языки программирования
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_15_ext_languages.html Archive: DEV2-handouts-16.zip Страниц PDF: 23

Page 1: Расширяемость / Языки программирования

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Языки серверного программирования
  • Доверенные и недоверенные языки
  • Подключение нового языка
  • Трансформации типов
  • Интерфейс SPI для работы с базой
  • Зачем нужны языки и из чего можно выбирать

Page 3: Языки программирования

«Встроенные»

  • C
  • SQL
  • Стандартные (поддержка сообщества)
    • PL/pgSQL
    • PL/Perl, PL/Python, PL/Tcl
  • Сторонние
    • PL/Java, PL/V8, PL/R, PL/Lua, …

Комментарии преподавателя

Разумеется, к PostgreSQL можно обращаться «извне» из любых языков программирования (ЯП), для которых реализована поддержка клиент-серверного протокола. Но разные ЯП можно использовать и для программирования на стороне сервера: для написания хранимых функций и процедур.

В PostgreSQL «встроены» два ЯП: C, на котором написана вся система, и SQL. Эти языки доступны всегда.

В систему также входят четыре ЯП, которые имеются в стандартной поставке и поддерживаются сообществом. С одним из них — PL/pgSQL — мы уже хорошо знакомы. Он доступен по умолчанию и наиболее часто используется на стороне сервера. Другие языки — PL/Perl, PL/Python (только Python3, поддержка Python2 прекращена) и PL/Tcl — надо устанавливать как расширения. Такой (несколько странный) выбор языков обусловлен историческими причинами.

В документации языки, отличные от C и SQL, называются процедурными, отсюда и приставка «PL» в названиях. Название несколько неудачное: никто не мешает подключить к PostgreSQL функциональный или еще какой-нибудь язык. Поэтому лучше понимать PL как programming language, а не как procedural language.

https://postgrespro.ru/docs/postgresql/16/xplang

PostgreSQL — расширяемая система, поэтому имеется возможность добавления и других ЯП.

Page 4: Доверенные языки

Доверенные

  • гарантируют работу пользователя в рамках выданного доступа
  • ограничено взаимодействие с окружением и внутренностями СУБД,
  • язык должен позволять работать в «песочнице»
  • по умолчанию доступ для всех пользователей
  • Недоверенные
    • доступна полная функциональность языка
    • доступ только у суперпользователей
    • обычно к названию языка добавляют «u»: plperl → plperlu

Комментарии преподавателя

ЯП разделяются на доверенные (trusted) и недоверенные (untrusted).

Доверенные языки должны соблюдать ограничения доступа, установленные в системе. Если пользователь не имеет доступа к данным, подпрограмма на доверенном языке не должна предоставить ему такой доступ.

По сути это означает, что язык должен ограничивать взаимодействие пользователя с окружением (например, с операционной системой) и с внутренностями СУБД (чтобы нельзя было обойти обычные проверки доступа). Не все реализации языков умеют работать в таком режиме «песочницы».

Использование доверенного языка является безопасным, поэтому доступ к нему получают все пользователи (для роли public выдается привилегия usage на язык).

Недоверенные языки не имеют никаких ограничений. В частности, функция на таком языке может выполнять любые операции в ОС с правами пользователя, запустившего сервер базы данных. Это может быть небезопасным, поэтому доступ к такому языку имеют только суперпользователи PostgreSQL.

Напомним, что, если необходимо, суперпользователь может создать функцию на недоверенном языке, в том числе с указанием SECURITY DEFINER, и выдать право на ее исполнение обычным пользователям.

Page 5: Языки программирования

=> CREATE DATABASE ext_languages;
CREATE DATABASE
=> \c ext_languages
You are now connected to database "ext_languages" as user "student".

Проверим список установленных языков:

=> \dL
                      List of languages
  Name   |  Owner   | Trusted |         Description
---------+----------+---------+------------------------------
 plpgsql | postgres | t       | PL/pgSQL procedural language
(1 row)

По умолчанию установлен только PL/pgSQL (C и SQL не в счет). Новые языки принято оформлять как расширения. Вот какие доступны для установки:

=> SELECT name, comment, installed_version
FROM pg_available_extensions
WHERE name LIKE 'pl%'
ORDER BY name;
    name    |                  comment                  | installed_version
------------+-------------------------------------------+-------------------
 plperl     | PL/Perl procedural language               |
 plperlu    | PL/PerlU untrusted procedural language    |
 plpgsql    | PL/pgSQL procedural language              | 1.0
 plpython3u | PL/Python3U untrusted procedural language |
 plsh       | PL/sh procedural language                 |
 plxslt     | PL/XSLT procedural language               |
(6 rows)

Первые четыре — из числа стандартных, а с двумя последними мы познакомимся позже. Установим в текущую базу данных два варианта языка PL/Perl: plperl (доверенный) и plperlu (недоверенный):

=> CREATE EXTENSION plperl;
CREATE EXTENSION
=> CREATE EXTENSION plperlu;
CREATE EXTENSION
=> \dL
                           List of languages
  Name   |  Owner   | Trusted |              Description
---------+----------+---------+----------------------------------------
 plperl  | student  | t       | PL/Perl procedural language
 plperlu | student  | f       | PL/PerlU untrusted procedural language
 plpgsql | postgres | t       | PL/pgSQL procedural language
(3 rows)

Чтобы языки автоматически появлялись во всех новых базах данных, расширения нужно установить в БД template1. Недоверенный язык не имеет ограничений. Например, можно создать функцию, читающую любой файл (аналогично штатной функции pg_read_file):

=> CREATE FUNCTION read_file_untrusted(fname text) RETURNS SETOF text
AS $perl$
    my ($fname) = @_;
    open FILE, $fname or die "Cannot open file";
    chomp(my @f = <FILE>);
    close FILE;
    return \@f;
$perl$ LANGUAGE plperlu VOLATILE;
CREATE FUNCTION

Page 6: Page 6

=> SELECT * FROM read_file_untrusted('/etc/passwd') LIMIT 3;
               read_file_untrusted
-------------------------------------------------
 root:x:0:0:root:/root:/bin/bash
 daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
 bin:x:2:2:bin:/bin:/usr/sbin/nologin
(3 rows)

Что будет, если попробовать сделать то же самое на доверенном языке?

=> CREATE FUNCTION read_file_trusted(fname text) RETURNS SETOF text
AS $perl$
    my ($fname) = @_;
    open FILE, $fname or die "Cannot open file";
    chomp(my @f = <FILE>);
    close FILE;
    return \@f;
$perl$ LANGUAGE plperl VOLATILE;
ERROR:  'open' trapped by operation mask at line 3.
CONTEXT:  compilation of PL/Perl function "read_file_trusted"

Вызов open (в числе прочего) запрещен в доверенном языке.

Page 7: Подключение нового языка

Source slide page 7: Подключение нового языка{#source-figure-007 caption=“Source slide page 7: Подключение нового языка”}

SELECT f(...)
DO $$ ... $$
LANGUAGE ...

Комментарии преподавателя

Интерфейс подключения нового ЯП для использования на стороне сервера включает всего три функции.

При создании хранимой подпрограммы, ее код проверяется на наличие ошибок (validator). Если проверка проходит успешно, подпрограмма сохраняется в системном каталоге. При этом если подпрограмма оформлена в стиле стандарта SQL, то ее тело хранится в виде дерева разбора и будет обрабатываться обычным образом. В остальных случаях сохраняется только исходный код в виде текстовой строки. Компиляция поддерживается только для подпрограмм на языке C.

Проверку можно отключить параметром check_function_bodies; утилита pg_dump пользуется этим, чтобы снять зависимость от порядка создания объектов.

При вызове подпрограммы ее текст передается на выполнение интерпретатору языка, а полученный ответ возвращается в систему (handler). Если язык объявлен доверенным, интерпретатор должен запускаться в «песочнице». Здесь также решается важная задача преобразования типов. Не все типы SQL могут иметь аналоги в ЯП. Обычный подход в этом случае — передача текстового представления типа, но реализация может также учитывать определенные пользователем трансформации типов.

https://postgrespro.ru/docs/postgresql/16/sql-createtransform

При выполнении SQL-команды DO интерпретатору передается код для однократного выполнения (inline).

Из этих трех функций обязательна только функция-handler.

Page 8: Подключение нового языка

Если заглянуть, что выполняет команда CREATE EXTENSION, то для недоверенного языка в скрипте мы увидим примерно следующее:

CREATE LANGUAGE plperlu
  HANDLER plperlu_call_handler
  INLINE plperlu_inline_handler
  VALIDATOR plperlu_validator;

А для доверенного (обратите внимание на слово TRUSTED):

CREATE TRUSTED LANGUAGE plperl
  HANDLER plperl_call_handler
  INLINE plperl_inline_handler
  VALIDATOR plperl_validator;

В этой команде указываются имена функций, реализующих точки входа основного обработчика, обработчика для DO и проверки. Установим еще один язык — PL/Python. Он доступен только как недоверенный:

=> CREATE EXTENSION plpython3u;
CREATE EXTENSION

На его примере посмотрим, как происходит преобразование между системой типов SQL и системой типов языка. Для многих типов предусмотрены преобразования:

=> CREATE FUNCTION test_py_types(n numeric, b boolean, s text, a int[])
RETURNS void AS $python$
    plpy.info(n, type(n))
    plpy.info(b, type(b))
    plpy.info(s, type(s))
    plpy.info(a, type(a))
$python$ LANGUAGE plpython3u IMMUTABLE;
CREATE FUNCTION
=> SELECT test_py_types(42,true,'foo',ARRAY[1,2,3]);
INFO:  (Decimal('42'), <class 'decimal.Decimal'>)
INFO:  (True, <class 'bool'>)
INFO:  ('foo', <class 'str'>)
INFO:  ([1, 2, 3], <class 'list'>)
 test_py_types
---------------
(1 row)

А что мы увидим в таком случае?

=> CREATE FUNCTION test_py_jsonb(j jsonb)
RETURNS jsonb AS $python$
    plpy.info(j, type(j))
    return j
$python$ LANGUAGE plpython3u IMMUTABLE;
CREATE FUNCTION
=> SELECT test_py_jsonb('{ "foo": "bar" }'::jsonb);
INFO:  ('{"foo": "bar"}', <class 'str'>)
 test_py_jsonb
----------------
 {"foo": "bar"}
(1 row)

Здесь SQL-тип json был передан в функцию как строка, а возвращаемое значение было вновь преобразовано в jsonb из текстового представления.

Трансформации типов

Чтобы помочь обработчику языка, можно создать дополнительные трансформации типов. Для нашего случая есть подходящее расширение:

Page 9: Page 9

=> CREATE EXTENSION jsonb_plpython3u;
CREATE EXTENSION

Фактически оно создает трансформацию таким образом (что позволяет передавать тип jsonb и в Python, и обратно в SQL):

CREATE TRANSFORM FOR jsonb LANGUAGE plpython3u (
    FROM SQL WITH FUNCTION jsonb_to_plpython3(internal),
    TO SQL WITH FUNCTION plpython3_to_jsonb(internal)
);

Трансформацию необходимо явно указать в определении функции:

=> CREATE OR REPLACE FUNCTION test_py_jsonb(j jsonb)
RETURNS jsonb
TRANSFORM FOR TYPE jsonb -- использовать трансформацию
AS $python$
    plpy.info(j, type(j))
    return j
$python$ LANGUAGE plpython3u IMMUTABLE;
CREATE FUNCTION
=> SELECT test_py_jsonb('{ "foo": "bar" }'::jsonb);
INFO:  ({'foo': 'bar'}, <class 'dict'>)
 test_py_jsonb
----------------
 {"foo": "bar"}
(1 row)

Теперь SQL-тип jsonb передается в Python как тип dict — словарь (ассоциативный массив).

Page 10: Интерфейс SPI

Source slide page 10: Интерфейс SPI{#source-figure-010 caption=“Source slide page 10: Интерфейс SPI”}

Комментарии преподавателя

PostgreSQL располагает внутренним низкоуровневым API для работы с базой данных. Сюда входят функции для разбора и переписывания запроса, построения плана, выполнения запроса (включая обращения к таблицам, индексам и т. п.). Это самый эффективный способ работы с данными, но он требует внимания ко многим деталям, таким, как управление памятью, установка блокировок и т. д.

Движок SQL, являющийся частью каждого обслуживающего процесса, пользуется именно этим, низкоуровневым, API.

Для удобства имеется более высокоуровневый (но в ряде случаев менее эффективный) интерфейс: SPI, Server Programming Interface.

https://postgrespro.ru/docs/postgresql/16/spi

Интерфейс SPI рассчитан на язык C: подпрограмма на C может пользоваться как этим интерфейсом, так и — при необходимости — низкоуровневым.

Но, как правило, авторы языков программирования предоставляют обертки, позволяющие подпрограммам на этих языках тоже пользоваться SPI. Это значительно выгоднее, чем инициировать из подпрограммы новое соединение и использовать для работы с базой клиент-серверный протокол.

Page 11: Интерфейс SPI

Для доступа к возможностям SPI подпрограммы на языке Python автоматически импортируют модуль plpy (мы уже использовали функцию info из этого модуля — аналог команды RAISE INFO языка PL/pgSQL).

=> CREATE TABLE test(
    n integer PRIMARY KEY,
    descr text
);
CREATE TABLE
=> INSERT INTO test VALUES (1,'foo'), (2,'bar'), (3,'baz');
INSERT 0 3

Напишем для примера функцию, возвращающую текстовое описание по ключу. Отсутствие ключа должно приводить к ошибке. Какие конструкции здесь соответствуют обращению к SPI?

=> CREATE FUNCTION get_descr_py(n integer) RETURNS text
AS $python$
    if "plan_get_descr_py" in SD:
        plan = SD["plan_get_descr_py"]
    else:
        plan = plpy.prepare(
            "SELECT descr FROM test WHERE n = $1", ["integer"]
        )
        SD["plan_get_descr_py"] = plan
    rows = plan.execute([n])
    if rows.nrows() == 0:
        raise plpy.spiexceptions.NoDataFound()
    else:
        return rows[0]["descr"]
$python$ LANGUAGE plpython3u STABLE;
CREATE FUNCTION

Вызов plpy.prepare соответствует функции SPI_prepare (и SPI_keepplan), а plpy.execute — функции SPI_execute_plan. Также неявно вызывается SPI_connect и SPI_finish. То есть обертка языка может дополнительно упрощать интерфейс. Обратите внимание: Чтобы сохранить план подготовленного запроса, приходится использовать словарь SD, сохраняемый между вызовами функции; Требуется явная проверка того, что строка была найдена.

=> SELECT get_descr_py(1);
 get_descr_py
--------------
 foo
(1 row)
=> SELECT get_descr_py(42);
ERROR:  spiexceptions.NoDataFound:
CONTEXT:  Traceback (most recent call last):
  PL/Python function "get_descr_py", line 11, in <module>
    raise plpy.spiexceptions.NoDataFound()
PL/Python function "get_descr_py"

Показательно сравнить с аналогичной функцией на языке PL/pgSQL:

=> CREATE FUNCTION get_descr(n integer) RETURNS text
AS $$
DECLARE
    descr text;
BEGIN
    SELECT t.descr INTO STRICT descr
    FROM test t WHERE t.n = get_descr.n;
    RETURN descr;
END;
$$ LANGUAGE plpgsql STABLE;
CREATE FUNCTION

Page 12: Page 12

План подготавливается и переиспользуется автоматически; Проверка существования строки указывается словом STRICT.

=> SELECT get_descr(1);
 get_descr
-----------
 foo
(1 row)
=> SELECT get_descr(42);
ERROR:  query returned no rows
CONTEXT:  PL/pgSQL function get_descr(integer) line 5 at SQL statement

Другие языки могут предоставлять другой способ доступа к функциям SPI, а могут и не предоставлять.

Page 13: Задачи

Обработка информации, хранимой в базе данных

  • подпрограмма всегда выполняется в контексте подключения к БД
  • PL/pgSQL интегрирован с SQL
  • для остальных — интерфейс серверного программирования (SPI)
  • Вычисления, не связанные с базой данных
    • возможности PL/pgSQL сильно ограничены
    • эффективность
    • удобство использования
    • наличие готовых библиотек
    • специализированные задачи

Комментарии преподавателя

Задачи, для решения которых используются хранимые подпрограммы, можно условно поделить на две группы.

В первую входит работа с информацией, которая содержится внутри базы данных. Здесь любые хранимые подпрограммы выигрывают у внешних (клиентских) программ, поскольку они находятся ближе к данным: во-первых, не требуется установка соединения с сервером, и во-вторых, не требуется пересылка лишнего по сети. PL/pgSQL очень удобен для таких задач, поскольку тесно интегрирован с SQL.

Ко второй группе можно отнести обработку, не связанную с обращением к базе данных. Здесь возможности PL/pgSQL сильно ограничены. Начать с того, что он вычислительно не эффективен: любое выражение вычисляется с помощью запроса (для которого в ряде простых случаев исключен этап планирования).

Если бы были важны только эффективность и универсальность, можно было бы использовать язык C. Но писать на нем прикладной код крайне дорого и долго.

Кроме того, язык PL/pgSQL достаточно старомоден и не располагает возможностями и библиотеками, которые есть в современных ЯП, и он в принципе не пригоден для решения целого ряда задач.

Поэтому использование других языков (отличных от SQL, PL/pgSQL и C) для серверного программирования во многих случаях вполне оправдано.

Page 14: Из чего выбирать

Интенсивная работа с данными

  • SQL, PL/pgSQL
  • Проверенные, часто используемые
    • PL/Perl, PL/Python, PL/V8, PL/Java
  • Другие языки общего назначения
    • PL/Go, PL/Lua, …
  • Специальные задачи
    • PL/R (статистика), PL/Proxy (удаленные вызовы), …
  • Максимальная эффективность
    • C

Комментарии преподавателя

Если подпрограммы используются для интенсивной работы с данными, лучшим выбором вероятно будут обычные SQL и PL/pgSQL.

Для других задач из штатных языков часто используют PL/Perl и PL/Python, а из сторонних — PL/V8 и PL/Java. (С PL/V8 могут быть сложности из-за того, что Debian и Red Hat перестали поставлять это расширение в виде пакета из-за сложностей со сборкой.)

Можно попробовать и другие языки, например, PL/Go или PL/Lua, если в этом есть смысл. Обязательно обращайте внимание на состояние проекта, активность сообщества, возможность получить поддержку.

Есть ряд специализированных языков. Например, для статистической обработки данных пригодится PL/R, для удаленного вызова процедур и шардинга можно использовать PL/Proxy.

В документации упомянуто некоторое количество языков:

https://postgrespro.ru/docs/postgresql/16/external-pl

Больше информации о доступных языках есть на вики:

https://wiki.postgresql.org/wiki/PL\_Matrix

Но и это не полный список. Есть множество других проектов, хотя многие из них не продвигаются дальше первой версии. Не следует сбрасывать со счетов и язык C, если нужна максимальная эффективность. Это сложнее, но в документации и в исходном коде PostgreSQL есть множество соответствующих примеров.

Page 15: Пример специализированного языка: XSLT

В качестве иллюстрации очень специализированного языка (который никак нельзя назвать процедурным!) посмотрим на PL/XSLT. Допустим, мы получаем данные для отчета с помощью запроса, и представляем их в формате XML:

=> SELECT *
FROM query_to_xml('SELECT n, descr FROM test',true,false,'');
                         query_to_xml
---------------------------------------------------------------
 <table xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">+
                                                              +
 <row>                                                        +
   <n>1</n>                                                   +
   <descr>foo</descr>                                         +
 </row>                                                       +
                                                              +
 <row>                                                        +
   <n>2</n>                                                   +
   <descr>bar</descr>                                         +
 </row>                                                       +
                                                              +
 <row>                                                        +
   <n>3</n>                                                   +
   <descr>baz</descr>                                         +
 </row>                                                       +
                                                              +
 </table>                                                     +
(1 row)

Чтобы вывести отчет пользователю, его можно преобразовать в формат HTML с помощью XSLT-преобразования. Подключим язык PL/XSLT:

=> CREATE EXTENSION plxslt;
CREATE EXTENSION

Вот так может выглядеть простое преобразование:

=> CREATE FUNCTION html_report(xml) RETURNS xml
AS $xml$
<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html><body><table>
    <xsl:for-each select="table/row">
        <tr>
          <td><xsl:value-of select="n"/></td>
          <td><xsl:value-of select="descr"/></td>
        </tr>
    </xsl:for-each>
</table></body></html>
</xsl:template>
</xsl:stylesheet>
$xml$ LANGUAGE xslt IMMUTABLE;
CREATE FUNCTION

И вот результат:

=> SELECT * FROM html_report(
    query_to_xml('SELECT * FROM test',true,false,'')
);

Page 16: Page 16

      html_report
------------------------
 <html><body><table>   +
 <tr>                  +
 <td>1</td>            +
 <td>foo</td>          +
 </tr>                 +
 <tr>                  +
 <td>2</td>            +
 <td>bar</td>          +
 </tr>                 +
 <tr>                  +
 <td>3</td>            +
 <td>baz</td>          +
 </tr>                 +
 </table></body></html>+
(1 row)

Таким образом можно разделить логику отчета (обычный SQL-запрос) и его представление.

Page 17: Итоги

PostgreSQL позволяет подключать любые языки

  • Богатый выбор языков программирования позволяет решать любые задачи на стороне сервера

Page 18: Практика

  1. Отправляйте пользователю, совершившему покупку в магазине, письмо-подтверждение с указанием суммы. В виртуальной машине настроен локальный почтовый сервер, пересылающий любую исходящую почту в локальный ящик пользователя student. В PostgreSQL нет встроенной функции для отправки писем, но ее можно реализовать на каком-либо недоверенном языке. Убедитесь, что письмо не может быть отправлено до того, как транзакция покупки будет завершена.

Комментарии преподавателя

  1. Для отправки пользуйтесь уже готовой функцией public.sendmail (посмотрите ее определение) или напишите свою.

Посылать письмо внутри транзакции покупки неправильно: транзакция может быть оборвана по какой-либо причине, а письмо уже уйдет. Воспользуйтесь механизмом фоновых заданий: в транзакции добавляйте задание на отправку письма. В таком случае оно будет отправлено, только если транзакция завершится успешно.

Page 19: 1. Почтовые сообщения

Простая функция для отправки почтовых сообщений через локальный почтовый сервер может выглядеть так:

=> \sf sendmail
CREATE OR REPLACE FUNCTION public.sendmail(from_addr text, to_addr text, subj text, msg
text)
 RETURNS void
 LANGUAGE plpython3u
AS $function$
    import smtplib
    server = smtplib.SMTP('localhost')
    server.sendmail(
        from_addr,
        to_addr,
        "\r\n".join([
            "From: %s" % from_addr,
            "To: %s" % to_addr,
            "Content-Type: text/plain; charset=\"UTF-8\"",
            "Subject: %s" % subj,
            "\r\n%s" % msg
        ]).encode('utf-8')
    )
    server.quit()
$function$

Модуль email дает больше возможностей, но они нам не нужны. Создадим фоновое задание для отправки писем:

=> CREATE FUNCTION public.sendmail_task(params jsonb) RETURNS text
LANGUAGE sql VOLATILE
BEGIN ATOMIC
    SELECT sendmail(
        from_addr => params->>'from_addr',
        to_addr   => params->>'to_addr',
        subj      => params->>'subj',
        msg       => params->>'msg'
    );
    SELECT 'OK';
END;
CREATE FUNCTION
=> SELECT register_program('Отправка письма', 'sendmail_task');
 register_program
------------------
                2
(1 row)

Функция checkout книжного приложения содержит вызов дополнительной функции, куда мы и поместим логику отправки письма:

Page 20: Page 20

=> CREATE OR REPLACE FUNCTION public.before_checkout(user_id bigint)
RETURNS void
AS $$
<<local>>
DECLARE
    params jsonb;
BEGIN
    SELECT jsonb_build_object(
        'from_addr', 'bookstore@localhost',
        'to_addr',    u.email,
        'subj',       'Поздравляем с покупкой',
        'msg',        format(
                          E'Уважаемый %s!\nВы совершили покупку на общую сумму %s ₽.',
                          u.username,
                          sum(ci.qty * get_retail_price(ci.book_id))
                      )
    )
    INTO params
    FROM users u
        JOIN cart_items ci ON ci.user_id = u.user_id
    WHERE u.user_id = before_checkout.user_id
    GROUP BY u.user_id;
    PERFORM empapi.run_program(
        program_id => 2,
        params => params
    );
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION

Page 21: Практика+

  1. Языки Python и Perl имеют удобный тип данных — ассоциативный массив, — который отсутствует в PL/pgSQL. В Python это dict (словарь), в Perl — hash (хеш-таблица).
  • Напишите на одном из этих языков функцию, получающую
  • на вход текстовую строку и возвращающую таблицу из слов,
  • которые встречаются в этой строке, с указанием количества
  • вхождений.
  • Решается ли эта задача на языке SQL? 2. Напишите функцию, которая по имени файла определяет и выводит его MIME-тип, аналогично команде shell
file --brief --mime-type

Комментарии преподавателя

  1. Например: SELECT * FROM words_count(’the best of the best’); word | cnt ——+—– the | 2 of | 1 best | 2

  2. Воспользуйтесь расширением plsh, установленным в виртуальной машине курса. Оно предоставляет язык PL/sh для написания функций на языке командой оболочки Unix.

Пример использования, приведенный автором (Питер Эйзентраут): CREATE FUNCTION concat(text, text) RETURNS text AS $$ #!/bin/sh echo “$1$2” $$ LANGUAGE plsh;

Обратите внимание, что обращение к параметрам возможно только по номеру.

Page 22: 1. Количество вхождений слов в строку

=> CREATE DATABASE ext_languages;
CREATE DATABASE
=> \c ext_languages
You are now connected to database "ext_languages" as user "student".
=> CREATE EXTENSION plpython3u;
CREATE EXTENSION
=> CREATE FUNCTION words_count(s text)
RETURNS TABLE(word text, cnt integer)
AS $python$
    words = {}
    for w in s.split():
        words[w] = words.get(w,0) + 1
    return words.items()
$python$ LANGUAGE plpython3u IMMUTABLE;
CREATE FUNCTION
=> SELECT * FROM words_count('раз два три два три три');
 word | cnt
------+-----
 раз  |   1
 два  |   2
 три  |   3
(3 rows)

На SQL задача тоже решается элементарно. Здесь вместо явного использования ассоциативного массива мы полагаемся на реализацию группировки:

=> SELECT word, count(*)
FROM regexp_split_to_table('раз два три два три три','\s+') word
GROUP BY word;
 word | count
------+-------
 три  |     3
 раз  |     1
 два  |     2
(3 rows)

Фактически в обоих случаях используется хеш-таблица:

=> EXPLAIN (costs off) SELECT word, count(*)
FROM regexp_split_to_table('раз два три два три три','\s+') word
GROUP BY word;
                    QUERY PLAN
---------------------------------------------------
 HashAggregate
   Group Key: word
   ->  Function Scan on regexp_split_to_table word
(3 rows)

2. Тип файла

=> CREATE EXTENSION plsh;
CREATE EXTENSION
=> CREATE FUNCTION file_type(file text) RETURNS text AS $sh$
#!/bin/bash
file --brief --mime-type $1
$sh$ LANGUAGE plsh VOLATILE;
CREATE FUNCTION
=> SELECT file_type('/home/student/covers/novikov_dbtech2.jpg');

Page 23: Page 23

 file_type
------------
 image/jpeg
(1 row)

16 Занятие 16: Расширяемость / Агрегатные и оконные функции

Исходная страница в нативном Markdown: Расширяемость / Агрегатные и оконные функции
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_16_ext_aggregates.html Archive: DEV2-handouts-16.zip Страниц PDF: 27

Page 1: Расширяемость / Агрегатные и оконные функции

Авторские права

© Postgres Professional, 2017–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 имеется достаточно много встроенных агрегатных функций. Часть из них определена стандартом (такие, как min, max, sum, count и т. п.), часть является расширением стандарта.

https://postgrespro.ru/docs/postgresql/16/functions-aggregate

Но иногда может оказаться полезным создать собственную агрегатную функцию. Такая функция работает очень просто.

Имеется некоторое состояние, представленное значением какого-либо типа данных, которое инициализируется определенным значением (например, тип numeric и начальное значение 0).

Для каждой строки агрегируемой выборки вызывается функция перехода, которая получает значение из текущей строки и должна обновить состояние (например, прибавляет текущее значение к состоянию).

В конце вызывается функция финализации, которая преобразует полученное в результате работы состояние в результат агрегатной функции (в нашем примере достаточно просто вернуть число — в итоге получается аналог функции sum).

https://postgrespro.ru/docs/postgresql/16/xaggr

Page 4: Агрегатные функции

=> CREATE DATABASE ext_aggregates;
CREATE DATABASE
=> \c ext_aggregates
You are now connected to database "ext_aggregates" as user "student".

Мы будем писать функцию для получения среднего, аналог встроенной функции avg. Начнем с того, что создадим таблицу:

=> CREATE TABLE test (
    n float,
    grp text
);
CREATE TABLE
=> INSERT INTO test(n,grp)
VALUES (1,'A'), (2,'A'), (3,'B'), (4,'B'), (5,'B');
INSERT 0 5

Состояние должно включать сумму значений и их количество. Для его хранения создадим составной тип:

=> CREATE TYPE average_state AS (
    accum float,
    qty float
);
CREATE TYPE

Теперь определим функцию перехода. Она возвращает новое состояние на основе текущего, прибавляя текущее значение к сумме и единицу к количеству. Мы также включим в функцию отладочный вывод, чтобы иметь возможность наблюдать за ее вызовом.

=> CREATE FUNCTION average_transition(
    state average_state,
    val float
)
RETURNS average_state AS $$
BEGIN
    RAISE NOTICE '%(%) + %', state.accum, state.qty, val;
    RETURN ROW(state.accum+val, state.qty+1)::average_state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Функция финализации делит полученную сумму на количество, чтобы получить среднее:

=> CREATE FUNCTION average_final(
    state average_state
)
RETURNS float AS $$
BEGIN
    RAISE NOTICE '= %(%)', state.accum, state.qty;
    RETURN CASE
        WHEN state.qty > 0 THEN state.accum/state.qty
    END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

И наконец нужно создать агрегат, указав тип состояния и его начальное значение, а также функции перехода и финализации:

=> CREATE AGGREGATE average(float) (
    stype     = average_state,
    initcond  = '(0,0)',
    sfunc     = average_transition,
    finalfunc = average_final
);
CREATE AGGREGATE

Page 5: Page 5

Можно пробовать нашу агрегатную функцию в работе:

=> SELECT average(n) FROM test;
NOTICE:  0(0) + 1
NOTICE:  1(1) + 2
NOTICE:  3(2) + 3
NOTICE:  6(3) + 4
NOTICE:  10(4) + 5
NOTICE:  = 15(5)
 average
---------
       3
(1 row)

Благодаря отладочному выводу хорошо видно, как изменяется начальное состояние (0,0). Функция работает и при указании группировки GROUP BY:

=> SELECT grp, average(n) FROM test GROUP BY grp;
NOTICE:  0(0) + 1
NOTICE:  1(1) + 2
NOTICE:  0(0) + 3
NOTICE:  3(1) + 4
NOTICE:  7(2) + 5
NOTICE:  = 12(3)
NOTICE:  = 3(2)
 grp | average
-----+---------
 B   |       4
 A   |     1.5
(2 rows)

Здесь видно, что используются два разных состояния — свое для каждой группы.

Page 6: Оконные функции

Source slide page 6: Оконные функции{#source-figure-006 caption=“Source slide page 6: Оконные функции”}

Окно определяет рамку (агрегируемую выборку) для каждой строки

OVER ()
OVER (PARTITION 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(), то оконная функция вычисляется на основе всех строк — одинаково для каждой строки выборки.

Если определение окна включает фразу PARTITION BY, то оконная функция вычисляется на основе групп строк (аналогично группировке GROUP BY). В этом случае для всех строк одной группы будет получено одинаковое значение функции.

Page 7: OVER()

Созданная нами агрегатная функция работает как оконная без всяких изменений:

=> SELECT n, average(n) OVER() FROM test;
NOTICE:  0(0) + 1
NOTICE:  1(1) + 2
NOTICE:  3(2) + 3
NOTICE:  6(3) + 4
NOTICE:  10(4) + 5
NOTICE:  = 15(5)
 n | average
---+---------
 1 |       3
 2 |       3
 3 |       3
 4 |       3
 5 |       3
(5 rows)

Обратите внимание, что, поскольку рамка для всех строк одинакова, значение вычисляется только один раз, а не для для каждой строки. И для предложения PARTITION BY:

=> SELECT n, grp, average(n) OVER(PARTITION BY grp) FROM test;
NOTICE:  0(0) + 1
NOTICE:  1(1) + 2
NOTICE:  = 3(2)
NOTICE:  0(0) + 3
NOTICE:  3(1) + 4
NOTICE:  7(2) + 5
NOTICE:  = 12(3)
 n | grp | average
---+-----+---------
 1 | A   |     1.5
 2 | A   |     1.5
 3 | B   |       4
 4 | B   |       4
 5 | B   |       4
(5 rows)

Здесь все работает точно так же, как для обычной группировки GROUP BY.

Page 8: Нарастающий итог

Source slide page 8: Нарастающий итог{#source-figure-008 caption=“Source slide page 8: Нарастающий итог”}

«Голова» рамки может двигаться

OVER (ORDER 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

Комментарии преподавателя

Как только в определении окна мы указываем предложение ORDER BY, упорядочивающее строки выборки, предполагается, что рамка окна охватывает строки от самой первой до текущей. Это можно записать и явным образом: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.

Например, если в качестве оконной функции используется sum, мы получаем сумму «нарастающим итогом».

Конечно, для каждой группы строк, определяемых предложением PARTITION BY, рамка будет располагаться в пределах этой группы.

Page 9: OVER(ORDER BY)

Если добавить к определению окна предложение ORDER BY, получим рамку, «хвост» которой стоит на месте, а голова движется вместе с текущей строкой:

=> SELECT n, average(n) OVER(ORDER BY n) FROM test;
NOTICE:  0(0) + 1
NOTICE:  = 1(1)
NOTICE:  1(1) + 2
NOTICE:  = 3(2)
NOTICE:  3(2) + 3
NOTICE:  = 6(3)
NOTICE:  6(3) + 4
NOTICE:  = 10(4)
NOTICE:  10(4) + 5
NOTICE:  = 15(5)
 n | average
---+---------
 1 |       1
 2 |     1.5
 3 |       2
 4 |     2.5
 5 |       3
(5 rows)

Снова не понадобилось никаких изменений — все работает. Здесь видно, как каждая следующая строка последовательно добавляется к состоянию и вызывается функция финализации. Полная форма того же запроса выглядит так:

SELECT n, average(n) OVER(
    ORDER BY n                       -- сортировка
    ROWS BETWEEN UNBOUNDED PRECEDING -- от самого начала
             AND CURRENT ROW         -- до текущей строки
)
FROM test;

То же самое работает и в сочетании с PARTITION BY:

=> SELECT n, grp, average(n) OVER(PARTITION BY grp ORDER BY n)
FROM test;
NOTICE:  0(0) + 1
NOTICE:  = 1(1)
NOTICE:  1(1) + 2
NOTICE:  = 3(2)
NOTICE:  0(0) + 3
NOTICE:  = 3(1)
NOTICE:  3(1) + 4
NOTICE:  = 7(2)
NOTICE:  7(2) + 5
NOTICE:  = 12(3)
 n | grp | average
---+-----+---------
 1 | A   |       1
 2 | A   |     1.5
 3 | B   |       3
 4 | B   |     3.5
 5 | B   |       4
(5 rows)

Page 10: Скользящая рамка

Source slide page 10: Скользящая рамка{#source-figure-010 caption=“Source slide page 10: Скользящая рамка”}

Могут двигаться «голова» и «хвост» рамки одновременно

OVER (ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
            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

Комментарии преподавателя

Рамку можно указать явно в предложении ROWS BETWEEN. В том числе двигаться может не только «голова» рамки, но и ее «хвост».

https://postgrespro.ru/docs/postgresql/16/sql-expressions#SYNTAX-WINDO W-FUNCTIONS

Если в предыдущих примерах рамка только расширялась (в нее добавлялись все новые значения), то теперь ранее добавленные значения значения могут «уходить» из рамки.

Чтобы пользовательская агрегатная функция работала эффективно в таком режиме, нужно реализовать функцию инверсии, которая устраняет значение из состояния.

Page 11: OVER(ROWS BETWEEN)

С помощью фразы ROWS BETWEEN можно задать любую необходимую конфигурацию рамки, указывая (в частности): UNBOUNDED PRECEDING — с самого начала; n PRECEDING — n предыдущих; CURRENT ROW — текущая строка; n FOLLOWING — n следующих; UNBOUNDED FOLLOWING — до самого конца. Рассмотрим вычисление «скользящего среднего» для трех значений. В отличие от предыдущих примеров из состояния должно «вычитаться» значение, уходящее из рамки, но у нас есть только функция «добавления». Единственный способ выполнить запрос — пересчитывать всю рамку заново:

=> SELECT n, average(n) OVER(ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
FROM test;
NOTICE:  0(0) + 1
NOTICE:  = 1(1)
NOTICE:  1(1) + 2
NOTICE:  = 3(2)
NOTICE:  3(2) + 3
NOTICE:  = 6(3)
NOTICE:  0(0) + 2
NOTICE:  2(1) + 3
NOTICE:  5(2) + 4
NOTICE:  = 9(3)
NOTICE:  0(0) + 3
NOTICE:  3(1) + 4
NOTICE:  7(2) + 5
NOTICE:  = 12(3)
 n | average
---+---------
 1 |       1
 2 |     1.5
 3 |       2
 4 |       3
 5 |       4
(5 rows)

Это, конечно, неэффективно, но мы можем написать недостающую функцию «инверсии»:

=> CREATE FUNCTION average_inverse(
    state average_state,
    val float
) RETURNS average_state AS $$
BEGIN
    RAISE NOTICE '%(%) - %', state.accum, state.qty, val;
    RETURN ROW(state.accum-val, state.qty-1)::average_state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Нужно указать эту функцию в определении агрегата:

=> DROP AGGREGATE average(float);
DROP AGGREGATE
=> CREATE AGGREGATE average(float) (
    -- обычный агрегат
    stype      = average_state,
    initcond   = '(0,0)',
    sfunc      = average_transition,
    finalfunc  = average_final,
    -- вариант с обратной функцией
    mstype     = average_state,
    minitcond  = '(0,0)',
    msfunc     = average_transition,
    minvfunc   = average_inverse,
    mfinalfunc = average_final
);
CREATE AGGREGATE

Page 12: Page 12

Пробуем:

=> SELECT n, average(n) OVER(ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
FROM test;
NOTICE:  0(0) + 1
NOTICE:  = 1(1)
NOTICE:  1(1) + 2
NOTICE:  = 3(2)
NOTICE:  3(2) + 3
NOTICE:  = 6(3)
NOTICE:  6(3) - 1
NOTICE:  5(2) + 4
NOTICE:  = 9(3)
NOTICE:  9(3) - 2
NOTICE:  7(2) + 5
NOTICE:  = 12(3)
 n | average
---+---------
 1 |       1
 2 |     1.5
 3 |       2
 4 |       3
 5 |       4
(5 rows)

Теперь лишние операции не выполняются.

Page 13: Параллелизм

Source slide page 13: Параллелизм{#source-figure-013 caption=“Source slide page 13: Параллелизм”}

Объединение состояний параллельных процессов

Комментарии преподавателя

Агрегатные функции могут выполняться в параллельном режиме. Основной процесс, выполняющий запрос, может создать несколько фоновых рабочих процессов, каждый из которых будет выполнять параллельную обработку части данных в узле плана. Затем полученные результаты передаются в основной процесс, который собирает их и формирует общий результат.

Чтобы пользовательские агрегатные функции могли работать параллельно, нужно реализовать функцию объединения двух состояний в одно общее.

Page 14: Параллелизм

Таблица с пятью строчками, конечно, слишком мала для параллельного выполнения. Возьмем больше данных:

=> CREATE TABLE big (
    n float
);
CREATE TABLE
=> INSERT INTO big
    SELECT random()*10::integer FROM generate_series(1,1_000_000);
INSERT 0 1000000
=> ANALYZE big;
ANALYZE

Встроенные агрегатные функции могут выполняться в параллельном режиме:

=> EXPLAIN SELECT sum(n) FROM big;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=10633.55..10633.56 rows=1 width=8)
   ->  Gather  (cost=10633.33..10633.54 rows=2 width=8)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=9633.33..9633.34 rows=1 width=8)
               ->  Parallel Seq Scan on big  (cost=0.00..8591.67 rows=416667 width=8)
(5 rows)

А наша функция — нет:

=> EXPLAIN SELECT average(n) FROM big;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Aggregate  (cost=264425.25..264425.26 rows=1 width=8)
   ->  Seq Scan on big  (cost=0.00..14425.00 rows=1000000 width=8)
 JIT:
   Functions: 3
   Options: Inlining false, Optimization false, Expressions true, Deforming true
(5 rows)

Чтобы поддержать параллельное выполнение, требуется еще одна функция для объединения двух состояний:

=> CREATE FUNCTION average_combine(
    state1 average_state,
    state2 average_state
) RETURNS average_state AS $$
BEGIN
    RAISE NOTICE '%(%) & %(%)',
        state1.accum, state1.qty, state2.accum, state2.qty;
    RETURN ROW(
        state1.accum+state2.accum,
        state1.qty+state2.qty
    )::average_state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Кроме того, уберем отладочный вывод из функции перехода:

=> CREATE OR REPLACE FUNCTION average_transition(
    state average_state,
    val float
)
RETURNS average_state
LANGUAGE sql IMMUTABLE
RETURN ROW(state.accum+val, state.qty+1)::average_state;
CREATE FUNCTION

Пересоздадим агрегат, указав новую функцию и подтвердив безопасность параллельного выполнения:

Page 15: Page 15

=> DROP AGGREGATE average(float);
DROP AGGREGATE
=> CREATE AGGREGATE average(float) (
    -- обычный агрегат
    stype       = average_state,
    initcond    = '(0,0)',
    sfunc       = average_transition,
    finalfunc   = average_final,
    combinefunc = average_combine,
    parallel    = safe,
    -- вариант с обратной функцией
    mstype      = average_state,
    minitcond   = '(0,0)',
    msfunc      = average_transition,
    minvfunc    = average_inverse,
    mfinalfunc  = average_final
);
CREATE AGGREGATE

Теперь наша функция тоже работает параллельно:

=> EXPLAIN SELECT average(n) FROM big;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=113759.38..113759.39 rows=1 width=8)
   ->  Gather  (cost=113758.42..113758.63 rows=2 width=32)
         Workers Planned: 2
         ->  Partial Aggregate  (cost=112758.42..112758.43 rows=1 width=32)
               ->  Parallel Seq Scan on big  (cost=0.00..8591.67 rows=416667 width=8)
 JIT:
   Functions: 5
   Options: Inlining false, Optimization false, Expressions true, Deforming true
(8 rows)
=> SELECT average(n) FROM big;
NOTICE:  0(0) & 1728634.1211473767(345554)
NOTICE:  1728634.1211473767(345554) & 1654754.6922734831(330864)
NOTICE:  3383388.81342086(676418) & 1619565.6972605654(323582)
NOTICE:  = 5002954.510681425(1000000)
      average
-------------------
 5.002954510681425
(1 row)

Здесь видно, что три процесса поделили работу примерно поровну, и затем три состояния были попарно объединены. В оконном режиме параллельное выполнение не поддерживается, в том числе и для встроенных функций.

Page 16: Итоги

PostgreSQL позволяет создавать агрегатные и оконные функции

  • Агрегатные и оконные функции дают возможность использовать процедурную обработку в стиле SQL

Page 17: Практика

  1. Напишите отчет, выводящий складские остатки по каждой книге в денежном выражении (используя закупочную, а не розничную цену). Оформите отчет как фоновое задание. Учтите, что поступления происходят разными партиями по разной цене, а продажи никак не привязаны к партиям. Поэтому считайте, что в первую очередь продаются книги из более старых партий. 2. Дополните расширение bookfmt функциями min и max для формата издания. Обновите версию расширения и убедитесь, что функции появились в базе данных.

Комментарии преподавателя

  1. Начните с запроса к таблице operations, выводящего только поступления книг, но добавьте столбец, показывающий общее количество проданных книг данного поступления.

В качестве примера книги, для которой на складе остались экземпляры из нескольких партий, можно взять книгу с book_id = 15.

Напишите оконную функцию, вычисляющую при использовании в режиме «нарастающего итога» остаток книг данного поступления на складе (или решите задачу, используя имеющиеся оконные функции).

  1. Расширение bookfmt было написано в практике к теме «Создание расширений».

Page 18: 1. Отчет по складским остаткам

Начнем с простого запроса, который выводит поступления книг и добавляет к каждой строке количество проданных экземпляров данной книги:

=> WITH sold(book_id, qty) AS (
    -- продажи книг
    SELECT book_id,
           -sum(qty)
    FROM   operations
    WHERE  qty < 0
    GROUP BY book_id
)
SELECT o.book_id,
       o.qty,
       s.qty as sold_qty,
       o.price
FROM   operations o
     LEFT JOIN sold s ON s.book_id = o.book_id
WHERE  o.qty > 0
AND    o.book_id = 15 -- для примера
ORDER BY o.book_id, o.at;
 book_id | qty | sold_qty | price
---------+-----+----------+-------
      15 |  36 |      515 |  1159
      15 |  40 |      515 |  1290
      15 |  35 |      515 |  1360
      15 |  32 |      515 |  1210
      15 |  37 |      515 |  1659
      15 |  29 |      515 |  1286
      15 |  31 |      515 |  1585
      15 |  48 |      515 |  1686
      15 |  35 |      515 |  1669
      15 |  28 |      515 |  1345
      15 |  33 |      515 |  1517
      15 |  30 |      515 |  1582
      15 |  28 |      515 |  1458
      15 |  32 |      515 |  1522
      15 |  32 |      515 |  1276
      15 |  21 |      515 |  1422
      15 |  28 |      515 |  1604
(17 rows)

Нам хотелось бы иметь функцию, которая на основании столбцов qty и sold_qty вычислит остаток книг от каждого поступления. Состояние такой агрегатной функции, которую мы будем использовать в режиме «нарастающего итога», включает два целых числа: количество книг, уже распределенных между поступлениями; количество нераспроданных книг в текущем поступлении.

=> CREATE TYPE distribute_state AS (
    distributed integer,
    qty integer
);
CREATE TYPE

Функция перехода увеличивает количество распределенных книг на число книг в поступлении, но только до тех пор, пока оно не превышает общего числа проданных книг:

=> CREATE FUNCTION distribute_transition(
    state distribute_state,
    qty integer,
    sold_qty integer
)
RETURNS distribute_state
LANGUAGE sql IMMUTABLE
RETURN ROW(
        least(state.distributed + qty, sold_qty),
        qty - (least(state.distributed + qty, sold_qty) - state.distributed)
    )::distribute_state;
CREATE FUNCTION

Page 19: Page 19

Функция финализации просто возвращает количество нераспроданных книг текущего поступления:

=> CREATE FUNCTION distribute_final(
    state distribute_state
)
RETURNS integer
LANGUAGE sql IMMUTABLE
RETURN state.qty;
CREATE FUNCTION

Создаем агрегат, указав тип состояния и его начальное значение, а также функции перехода и финализации:

=> CREATE AGGREGATE distribute(qty integer, sold_qty integer) (
    stype     = distribute_state,
    initcond  = '(0,0)',
    sfunc     = distribute_transition,
    finalfunc = distribute_final
);
CREATE AGGREGATE

Добавим новую агрегатную функцию к запросу, указав группировку по книгам (book_id) и сортировку в порядке времени совершения операций:

=> WITH sold(book_id, qty) AS (
    -- продажи книг
    SELECT book_id,
           -sum(qty)::integer
    FROM   operations
    WHERE  qty < 0
    GROUP BY book_id
)
SELECT o.book_id,
       o.qty,
       s.qty as sold_qty,
       distribute(o.qty, s.qty) OVER (
           PARTITION BY o.book_id ORDER BY o.at
       ) left_in_stock,
       o.price
FROM   operations o
     LEFT JOIN sold s ON s.book_id = o.book_id
WHERE  o.qty > 0
AND    o.book_id = 15 -- для примера
ORDER BY o.book_id, o.at;
 book_id | qty | sold_qty | left_in_stock | price
---------+-----+----------+---------------+-------
      15 |  36 |      515 |             0 |  1159
      15 |  40 |      515 |             0 |  1290
      15 |  35 |      515 |             0 |  1360
      15 |  32 |      515 |             0 |  1210
      15 |  37 |      515 |             0 |  1659
      15 |  29 |      515 |             0 |  1286
      15 |  31 |      515 |             0 |  1585
      15 |  48 |      515 |             0 |  1686
      15 |  35 |      515 |             0 |  1669
      15 |  28 |      515 |             0 |  1345
      15 |  33 |      515 |             0 |  1517
      15 |  30 |      515 |             0 |  1582
      15 |  28 |      515 |             0 |  1458
      15 |  32 |      515 |             0 |  1522
      15 |  32 |      515 |             0 |  1276
      15 |  21 |      515 |            12 |  1422
      15 |  28 |      515 |            28 |  1604
(17 rows)

Осталось убрать условие для конкретной книги, умножить остаток на цену, сгруппировать результат по книгам и оформить запрос в виде функции, чтобы зарегистрировать ее как фоновое задание:

Page 20: Page 20

=> CREATE FUNCTION stock_task(params jsonb DEFAULT NULL)
RETURNS TABLE(book_id bigint, cost numeric)
LANGUAGE sql STABLE
BEGIN ATOMIC
    WITH sold(book_id, qty) AS (
    -- продажи книг
        SELECT book_id, -sum(qty)::integer FROM operations
        WHERE  qty < 0
GROUP BY book_id
    ), left_in_stock(book_id, cost) AS (
    SELECT o.book_id,
           distribute(o.qty, s.qty) OVER (
       PARTITION BY o.book_id ORDER BY o.at
           ) * price
    FROM operations o
        LEFT JOIN sold s ON s.book_id = o.book_id
    WHERE  o.qty > 0
)
SELECT book_id, sum(cost) FROM left_in_stock GROUP BY book_id ORDER BY book_id;
END;
CREATE FUNCTION
=> SELECT register_program('Отчет по складским остаткам', 'stock_task');
 register_program
------------------
                3
(1 row)
=> SELECT * FROM stock_task() LIMIT 10;
 book_id | cost
---------+-------
       1 |  5661
       2 |  9224
       3 | 13200
       4 | 10400
       5 | 28105
       6 |  9259
       7 |  7455
       8 | 18615
       9 | 15744
      10 | 49676
(10 rows)

Заметим, что задачу можно решить и с помощью стандартных оконных функций:

Page 21: Page 21

=> WITH sold(book_id, qty) AS (
    -- продажи книг
    SELECT book_id,
           -sum(qty)
    FROM   operations
    WHERE  qty < 0
    GROUP BY book_id
), received(book_id, qty, cum_qty, price) AS (
    -- поступления книг
    SELECT book_id,
           qty,
           sum(qty) OVER (PARTITION BY book_id ORDER BY at),
           price
    FROM   operations
    WHERE  qty > 0
), left_in_stock(book_id, qty, price) AS (
    -- оставшиеся на складе книги
    SELECT r.book_id,
           CASE
               WHEN r.cum_qty - s.qty < 0 THEN 0
               WHEN r.cum_qty - s.qty < r.qty THEN r.cum_qty - s.qty
               ELSE r.qty
           END,
           r.price
    FROM   received r
        LEFT JOIN sold s ON s.book_id = r.book_id
)
SELECT book_id,
       sum(qty*price)
FROM   left_in_stock
GROUP BY book_id
ORDER BY book_id
LIMIT 10; -- ограничим вывод
 book_id |  sum
---------+-------
       1 |  5661
       2 |  9224
       3 | 13200
       4 | 10400
       5 | 28105
       6 |  9259
       7 |  7455
       8 | 18615
       9 | 15744
      10 | 49676
(10 rows)

Разумеется, отчет нетрудно сделать более наглядным, выводя название книги вместо идентификатора.

2. Min и max для типа book\_format

Сейчас функции min и max работают не в соответствии с определенным ранее классом операторов:

=> SELECT min(format), max(format) FROM books;
    min    |    max
-----------+-----------
 60x100/16 | 84x108/32
(1 row)

А вот правильный порядок:

=> SELECT format FROM books GROUP BY format ORDER BY format;

Page 22: Page 22

   format
-------------
 (76,100,32)
 (84,108,32)
 (60,84,16)
 (60,88,16)
 (60,90,16)
 (66,90,16)
 (60,100,16)
 (70,90,16)
 (70,100,16)
 (84,108,16)
 (60,90,8)
(11 rows)

Создадим версию 1.1 расширения с учетом следующего: Состоянием для агрегатной функции является текущее минимальное (максимальное) значение. Функция перехода записывает в состояние минимальное (максимальное) из двух значений — запомненного в состоянии и текущего. Функция финализации не нужна — возвращается просто текущее состояние.

bookfmt/bookfmt.control
default_version = '1.1'
relocatable = true
encoding = UTF8
comment = 'Формат издания'
bookfmt/bookfmt--1.0--1.1.sql
\echo Use "CREATE EXTENSION bookfmt" to load this file. \quit
CREATE FUNCTION format_min_transition(
    min_so_far book_format,
    val book_format
)
RETURNS book_format
LANGUAGE sql IMMUTABLE
RETURN least(min_so_far, val);
CREATE AGGREGATE min(book_format) (
    stype     = book_format,
    sfunc     = format_min_transition,
    sortop    = <
);
CREATE FUNCTION format_max_transition(
    max_so_far book_format,
    val book_format
)
RETURNS book_format
LANGUAGE sql IMMUTABLE
RETURN greatest(max_so_far, val);
CREATE AGGREGATE max(book_format) (
    stype     = book_format,
    sfunc     = format_max_transition,
    sortop    = >
);

При создании агрегата мы дополнительно указали оператор сортировки, реализующий стратегию «меньше» («больше») класса операторов, чтобы планировщик мог оптимизировать вызов наших агрегатных функций, просто получая первое значение из индекса.

bookfmt/Makefile
EXTENSION = bookfmt
DATA = bookfmt--0.sql bookfmt--0--1.0.sql bookfmt--1.0--1.1.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
student$ sudo make install -C bookfmt

Page 23: Page 23

make: Entering directory '/home/student/bookfmt'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/bin/mkdir -p '/usr/share/postgresql/16/extension'
/usr/bin/install -c -m 644 .//bookfmt.control '/usr/share/postgresql/16/extension/'
/usr/bin/install -c -m 644 .//bookfmt--0.sql .//bookfmt--0--1.0.sql
.//bookfmt--1.0--1.1.sql  '/usr/share/postgresql/16/extension/'
make: Leaving directory '/home/student/bookfmt'

Выполним обновление:

=> ALTER EXTENSION bookfmt UPDATE;
ALTER EXTENSION

Проверим:

=> SELECT min(format)::text, max(format)::text FROM books;
    min    |   max
-----------+---------
 76x100/32 | 60x90/8
(1 row)
=> SELECT min(format)::text, max(format)::text FROM books WHERE false;
 min | max
-----+-----
     |
(1 row)

Page 24: Практика+

  1. Напишите агрегатную функцию, вычисляющую средневзвешенное значение для совокупности элементов нескольких типов. Веса для типов элементов передаются функции в виде массива. 2. Бизнес-центр сдает офисы компаниям. В конце месяца администрации БЦ приходит общий счет за электроэнергию, который надо распределить между арендаторами пропорционально площади занимаемых помещений. Выставляемые арендаторам счета необходимо округлить до копеек, но так, чтобы их сумма совпала со значением, указанным в общем счете.

Комментарии преподавателя

  1. Средневзвешенное значение совокупности элементов — среднее значение, учитывающее вес, или важность, каждого элемента. Значение вычисляется по формуле (x1w(t1) + x2w(t2) + … + xnw(tn)) / (w(t1) + w(t2) + … + w(tn)), где

xi — значение элемента, ti — тип элемента, w(ti) — вес элемента данного типа.

Требуется написать агрегатную функцию следующего вида: w_agv(x float, t integer, w float[]).

  1. Помещения бизнес-центра можно представить таблицей:
rent (
    renter text PRIMARY KEY, -- арендатор
    area integer             -- площадь, м^2
);

Пример, показывающий проблему с обычным округлением:

INSERT INTO rent VALUES ('A',100), ('B',100), ('C',100);
SELECT round(1000.00 * area / sum(area) OVER ()), 2)
FROM rent;

Page 25: 1. Средневзвешенное значение

=> CREATE DATABASE ext_aggregates;
CREATE DATABASE
=> \c ext_aggregates
You are now connected to database "ext_aggregates" as user "student".

Рассмотрим задачу на примере данных об оценках за экзаменационные работы. Пусть в экзаменационный билет входят теоретические вопросы (тип 1) и практические задания (тип 2), причем задания имеют больший вес в итоговой оценке.

=> CREATE TABLE results (
    student text,
    task_type integer CHECK (task_type IN (1,2)),
    task_score float CHECK (task_score BETWEEN 1 AND 10)
);
CREATE TABLE
=> INSERT INTO results(student, task_type, task_score) VALUES
    ('Иванов', 1, 4),
    ('Иванов', 1, 6),
    ('Иванов', 2, 10),
    ('Петров', 1, 8),
    ('Петров', 1, 10),
    ('Петров', 2, 5);
INSERT 0 6

Состояние опишем как составной тип, состоящий из суммы произведений значений на соответствующий вес и суммы весов:

=> CREATE TYPE w_avg_state AS (
  w_accum float,
  w_sum float
);
CREATE TYPE

Функция перехода будет получать (кроме состояния) три параметра: текущее значение, его тип и массив весов:

=> CREATE FUNCTION w_avg_transition(
    state w_avg_state,
    val float,
    val_type bigint,
    weight float[]
)
RETURNS w_avg_state AS $$
DECLARE
    w float := coalesce(weight[val_type], 0);
BEGIN
    RAISE NOTICE '%(%) + % * % [%]', state.w_accum, state.w_sum, val, w, val_type;
    RETURN ROW (state.w_accum + coalesce(val, 0) * w, state.w_sum + w)::w_avg_state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Функция финализации вычисляет средневзвешенное как отношение суммы произведений значений на соответствующие веса к сумме весов:

=> CREATE FUNCTION w_avg_final(
    state w_avg_state
)
RETURNS float AS $$
BEGIN
    RAISE NOTICE '= %(%)', state.w_accum, state.w_sum;
    RETURN CASE
        WHEN state.w_sum > 0 THEN state.w_accum / state.w_sum
    END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Теперь объявляем агрегат:

Page 26: Page 26

=> CREATE AGGREGATE w_avg(float, bigint, float[]) (
    stype     = w_avg_state,
    initcond  = '(0, 0)',
    sfunc     = w_avg_transition,
    finalfunc = w_avg_final
);
CREATE AGGREGATE

А теперь подсчитаем средневзвешенные оценки за экзамен, сданный студентами, полагая, что решение практической задачи вдвое ценнее ответа на теоретический вопрос:

=> SELECT student, w_avg(task_score, task_type, ARRAY[1, 2])
FROM results
GROUP BY student;
NOTICE:  0(0) + 4 * 1 [1]
NOTICE:  4(1) + 6 * 1 [1]
NOTICE:  10(2) + 10 * 2 [2]
NOTICE:  0(0) + 8 * 1 [1]
NOTICE:  8(1) + 10 * 1 [1]
NOTICE:  18(2) + 5 * 2 [2]
NOTICE:  = 28(4)
NOTICE:  = 30(4)
 student | w_avg
---------+-------
 Петров  |     7
 Иванов  |   7.5
(2 rows)

2. Округление копеек

=> CREATE TABLE rent (
    renter text PRIMARY KEY,
    area integer
);
CREATE TABLE
=> INSERT INTO rent VALUES ('A',100), ('B',100), ('C',100);
INSERT 0 3

Состояние агрегатной функции будет включать округленную сумму и ошибку округления:

=> CREATE TYPE round2_state AS (
    rounded_amount numeric,
    rounding_error numeric
);
CREATE TYPE

Функция перехода добавляет к состоянию округленную сумму и ошибку округления. А если ошибка округления переваливает за полкопейки, то добавляет копейку к сумме.

=> CREATE FUNCTION round2_transition(
    state round2_state,
    val numeric
)
RETURNS round2_state AS $$
BEGIN
    state.rounding_error :=
        state.rounding_error + val - round(val,2);
    state.rounded_amount :=
        round(val,2) + round(state.rounding_error, 2);
    state.rounding_error :=
        state.rounding_error - round(state.rounding_error, 2);
    RETURN state;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE FUNCTION

Функция финализации возвращает округленную сумму:

=> CREATE FUNCTION round2_final(
    state round2_state
)
RETURNS numeric
LANGUAGE sql IMMUTABLE
RETURN state.rounded_amount;

Page 27: Page 27

CREATE FUNCTION

Объявляем агрегат:

=> CREATE AGGREGATE round2(numeric) (
    stype     = round2_state,
    initcond  = '(0,0)',
    sfunc     = round2_transition,
    finalfunc = round2_final
);
CREATE AGGREGATE

Пробуем. Нам нужен какой-то определенный, но не важно какой именно, порядок просмотра строк. В данном случае подходит арендатор, поскольку он уникален.

=> WITH t AS (
    SELECT *, sum(area) OVER () total_area
    FROM rent
)
SELECT *, round2( 1000.00 * area / total_area ) OVER (ORDER BY renter)
FROM t;
 renter | area | total_area | round2
--------+------+------------+--------
 A      |  100 |        300 | 333.33
 B      |  100 |        300 | 333.34
 C      |  100 |        300 | 333.33
(3 rows)

17 Занятие 17: Расширяемость / Обзор полнотекстового поиска

Исходная страница в нативном Markdown: Расширяемость / Обзор полнотекстового поиска
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_17_ext_fts_overview.html Archive: DEV2-handouts-16.zip Страниц PDF: 32

Page 1: Расширяемость / Обзор полнотекстового поиска

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Зачем нужен полнотекстовый поиск?
  • Документы и запросы
  • Анализаторы
  • Словари и шаблоны
  • Конфигурации
  • Индексная поддержка

Page 3: Зачем текстовый поиск?

Средства SQL — LIKE, регулярные выражения

  • нет морфологического поиска
  • нет возможности ранжирования результатов
  • нет индексной поддержки
  • Внешние поисковые системы
    • сложно синхронизировать с базой данных
    • отсутствие транзакционности
    • нет доступа к метаданным
    • сложности с разграничением доступа

Комментарии преподавателя

В инструментарии SQL уже есть средства поиска по тексту. Это и совсем простые команды LIKE и ILIKE, и упрощенные регулярные выражения SIMILAR TO, и полноценные регулярные выражения (оператор ~). Зачем нужен отдельный механизм?

Имеющиеся средства не позволяют искать с учетом разных словоформ, не позволяют отранжировать результаты по релевантности, имеют ограниченную индексную поддержку (для простого случая префиксного поиска, либо с помощью триграмм, как было показано в практике темы «Классы операторов»).

Для поиска можно использовать внешние поисковые системы. Но и они имеют ряд ограничений, которые невозможно преодолеть из-за того, что такие системы оторваны от базы данных. Их сложно синхронизировать с актуальным содержимым БД; они не транзакционны; они видят только те документы, которые им показывают, и не видят остальной информации в базе; сложно реализовать разграничение прав доступа к информации.

Полнотекстовый поиск позволяет преодолеть ограничения обычных средств SQL и сохранить все их преимущества.

https://postgrespro.ru/docs/postgresql/16/textsearch

Page 4: Документы и запросы

Документ

  • произвольный текст, который можно разобрать на слова (лексемы)
  • docx, pdf и т. п. надо предварительно преобразовать в текст
  • внутреннее компактное представление tsvector
  • Запрос
    • одна или несколько лексем, соединенных логическими связками:
    • &
    • и
    • |
    • или
    • !
    • не
    • <->
    • предшествует (фразовый поиск)
    • ( )
    • для изменения приоритета
    • внутреннее представление tsquery

Комментарии преподавателя

Документ, по которому нужен поиск, должен быть предварительно переведен в специальное представление — тип данных tsvector.

Исходный документ может быть произвольным текстом, который можно разобрать на отдельные слова (точнее, лексемы — разницу подробнее рассмотрим дальше). По двоичным документам тоже можно искать, если предварительно перевести их в текстовый вид (с помощью сторонних библиотек).

Поисковый запрос также должен быть представлен значением специального типа — tsquery. Запрос может состоять либо из одной лексемы, либо из нескольких лексем, связанных логическими операторами «и», «или», «не». Также поддерживается оператор предшествования, который обеспечивает фразовый поиск: можно найти документ, содержащий заданные слова не в любом месте, а стоящие рядом (или на определенном расстоянии друг от друга).

https://postgrespro.ru/docs/postgresql/16/datatype-textsearch

Page 5: Соответствие

Соответствие документа запросу

  • оператор @@
  • Релевантность
    • веса лексем (A, B, C, D в порядке убывания важности)
    • ts_rank — по частоте найденных лексем
    • ts_rank_cd — также учитывает близость лексем
    • оператор расстояния <=> (расширение rum)

Комментарии преподавателя

Чтобы проверить, соответствует ли документ (точнее, его представление в виде tsvector) запросу (точнее, его представлению tsquery), надо использовать оператор @@.

Результаты запроса можно ранжировать по «релевантности», чтобы понять, какие из найденных документов больше соответствуют запросу, а какие — меньше.

Релевантность учитывает вес лексем, который может быть задан буквенной меткой от A до D. По умолчанию (если метки нету) лексеме присваивается наименьшая важность (D). Выделение весами позволяет считать более важными, например, слова из заголовка или краткой аннотации по сравнению со словами в основном тексте документа.

В PostgreSQL есть две встроенные функции: ts_rank (учитывает, насколько часто лексемы из запроса встречаются в документе) и ts_rank_cd (дополнительно учитывает близость найденных лексем). Обе функции позволяют отмасштабировать свой результат с учетом размера документа.

https://postgrespro.ru/docs/postgresql/16/functions-textsearch

Расширение rum (https://github.com/postgrespro/rum) вводит еще одну возможность: оператор расстояния <=>, представляющий собой комбинацию функций ts_rank и ts_rank_cd.

Заметим, что в PostgreSQL не реализована возможность поиска документов, «похожих» на другой документ (основанная, например, на методе TF-IDF).

Page 6: Язык запросов

Будем знакомиться с полнотекстовым поиском на примере базы сообщений из рассылки pgsql-hackers за 1997-2017 года. Эта база уже загружена из резервной копии командой

student$ zcat ~/mail_messages.sql.gz | psql -d ext_fts_overview
=> \c ext_fts_overview
You are now connected to database "ext_fts_overview" as user "student".

База представлена одной таблицей:

=> SELECT id, parent_id, sent, subject, author, left(body_plain,400) body
FROM mail_messages LIMIT 1 \gx
-[ RECORD 1 ]-------------------------------------------------------------------------
id        | 1842152
parent_id | 1842072
sent      | 2014-04-05 07:39:03
subject   | Re: [PATCH] Negative Transition Aggregate Functions (WIP)
author    | Dean Rasheed <dean.a.rasheed@gmail.com>
body      | On 4 April 2014 11:56, Florian Pflug <fgp@phlo.org> wrote:                +
          | >                                                                         +
          | >> On 04.04.2014, at 09:40, Dean Rasheed <dean.a.rasheed@gmail.com> wrote:+
          | >>                                                                        +
          | >> I'm not sure how much additional work is required to sort this out,    +
          | >> but to me it looks more realistic to target 9.5 than 9.4, so at this   +
          | >> point I tend to think that the patch ought to be marked as returned    +
          | >> with feedback.                                                         +
          | >                                                                         +
          |                                                                           +
          | Just doing the first optim

Мы преимущественно будем смотреть на темы писем, потому что они достаточно короткие. Общее количество записей:

=> SELECT count(*) FROM mail_messages;
 count
--------
 356125
(1 row)

Чтобы воспользоваться полнотекстовым поиском, документ надо привести к типу tsvector, а запрос — к типу tsquery. Простой пример, в котором поисковый запрос состоит из одного слова:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('magical')
ORDER BY id LIMIT 5;
   id    |                                    subject
---------+-------------------------------------------------------------------------------
 1589495 | Pid file magically disappears
 1609344 | about the magic(?) of palloc() and variable-length user-defined data type
 1609348 | Re: about the magic(?) of palloc() and variable-length user-defined data type
 1609350 | Re: about the magic(?) of palloc() and variable-length user-defined data type
 1678485 | Re: [PATCHES] Magic block for modules
(5 rows)

Стоит обратить внимание, что найдены документы, содержащие разные формы слова magical, независимо от регистра букв. Позже мы узнаем, как это происходит. Язык запросов позволяет использовать логические связки. Документы, содержащие magic и value:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('magic & value')
ORDER BY id LIMIT 5;

Page 7: Page 7

   id    |             subject
---------+----------------------------------
 1778396 | Macros for time magic values
 1778397 | Re: Macros for time magic values
 1778398 | Re: Macros for time magic values
 1778400 | Re: Macros for time magic values
 1778439 | Re: Macros for time magic values
(5 rows)

Документы, содержащие magic и value, но не time:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('magic & value & !time')
ORDER BY id LIMIT 5;
   id    |                            subject
---------+----------------------------------------------------------------
 1868223 | patch: change magic constants to DEFINE value for readability.
(1 row)

Документы, содержащие magic и либо value, либо constant:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('magic & (value | constant)')
ORDER BY id LIMIT 5;
   id    |             subject
---------+----------------------------------
 1743238 | magic constant -1
 1743241 | Re: magic constant -1
 1743248 | Re: magic constant -1
 1778396 | Macros for time magic values
 1778397 | Re: Macros for time magic values
(5 rows)

Также доступен фразовый поиск, учитывающий порядок и близость позиций лексем. Например, для фразы «time value»:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('time <-> value')
ORDER BY id LIMIT 5;
   id    |                         subject
---------+----------------------------------------------------------
 1847875 | proposal: rounding up time value less than its unit.
 1847989 | Re: proposal: rounding up time value less than its unit.
 1847996 | Re: proposal: rounding up time value less than its unit.
 1849919 | Re: proposal: rounding up time value less than its unit.
 1849928 | Re: proposal: rounding up time value less than its unit.
(5 rows)

Или та же фраза «time value», но между словами должно быть еще одно любое слово:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('time <2> value')
ORDER BY id LIMIT 5;
   id    |             subject
---------+----------------------------------
 1778396 | Macros for time magic values
 1778397 | Re: Macros for time magic values
 1778398 | Re: Macros for time magic values
 1778400 | Re: Macros for time magic values
 1778439 | Re: Macros for time magic values
(5 rows)

Имеется также функция, позволяющая получить поисковый запрос, не указывая логические связки, примерно как в веб-поиске. Например, такой запрос:

to_tsquery('(time <-> value) & !magic')

эквивалентен следующему:

=> SELECT id, subject FROM mail_messages
WHERE to_tsvector(subject) @@ websearch_to_tsquery('"time value" -magic')
ORDER BY id LIMIT 5;

Page 8: Page 8

   id    |                         subject
---------+----------------------------------------------------------
 1847875 | proposal: rounding up time value less than its unit.
 1847989 | Re: proposal: rounding up time value less than its unit.
 1847996 | Re: proposal: rounding up time value less than its unit.
 1849919 | Re: proposal: rounding up time value less than its unit.
 1849928 | Re: proposal: rounding up time value less than its unit.
(5 rows)

Как вывести результат, если поиск идет по большому документу? Запрос выдаст нам весь текст письма:

=> SELECT body_plain FROM mail_messages
WHERE to_tsvector(body_plain) @@ to_tsquery('magic')
ORDER BY id LIMIT 1;
                                body_plain
--------------------------------------------------------------------------
 I know that there has been some discussion on this, but thought a       +
 more-or-less explicit roadmap to v7.0 might be helpful. Here are some   +
 thoughts:                                                               +
                                                                         +
 1) dump/reload is allowed or required on every formal release (e.g. for +
 v6.1->v6.2). This will allow incremental improvements to the backend    +
 code without having to put in everything at once.                       +
                                                                         +
 2) time travel code can be removed during the v6.x releases. It has been+
 unsupported and de-emphasized (not the word I'm thinking of, but can't  +
 recall what de-x word I want) since v6.0.                               +
                                                                         +
 3) the most visible improvements and changes from a user's point of view+
 are in capabilities and user interfaces.                                +
                                                                         +
 How about targeting an accumulation of capabilities for a v7.0 release? +
 The accumulated changes might consist of Purifying the code, Vadim's    +
 backend optimizations (fsync improvements, index improvements and       +
 time-travel-removal?), SQL enhancements including subselects, unions,   +
 and rules, and ?? Assuming that we will continue to get incremental     +
 improvements in data types, parsing, etc, I think that subselects must  +
 be high on the list of desirable features which may not magically appear+
 without a concerted effort.                                             +
                                                                         +
 I would guess that the SQL enhancements will take the most time and     +
 would show up later in the v6.x releases than some of the other items...+
                                                                         +
 Some statement like this on the Web page or attached to the ToDo list   +
 might be helpful.                                                       +
                                                                         +
 Comments?                                                               +
                                                                         +
                         - Tom                                           +
                                                                         +
 ------------------------------
(1 row)

Но можно воспользоваться функцией для форматирования результата, чтобы показать то место в документе, где обнаружено соответствие:

=> SELECT id, translate(
  ts_headline(body_plain,
              to_tsquery('magic'),
              'StartSel=*,StopSel=*,MinWords=8,MaxWords=10'),
  E'\n',
  ' '
) FROM mail_messages
WHERE to_tsvector(body_plain) @@ to_tsquery('magic')
ORDER BY id LIMIT 5;
   id    |                           translate
---------+---------------------------------------------------------------
 1562872 | *magically* appear without a concerted effort.  I would
 1562878 | *magically* appear > without a concerted effort. >  > I would
 1563678 | *magic* which I should not worry about? There
 1563681 | *magic* which I should not worry about? There
 1563878 | *magic* which I should not worry about? There
(5 rows)

Page 9: Page 9

Page 10: Анализатор

документ → анализатор → фрагмент+тип, фрагмент+тип, …

  • Выделяет в тексте документа фрагменты и назначает фрагментам типы
  • Штатный анализатор default
    • 23 типа фрагментов (слова, числа, адреса, теги XML и т. п.)
  • Другие анализаторы
    • расширения
    • набор функций на языке C

Комментарии преподавателя

Рассмотрим, как происходит превращение исходного документа в tsvector, а запроса в tsquery.

Вначале документ пропускается через анализатор (parser), который выделяет в нем фрагменты (tokens). Более того, каждому фрагменту анализатор сопоставляет тип, который используется для дальнейшей обработки. Например, стандартный анализатор (единственный встроенный в PostgreSQL) выделяет 23 типа фрагментов: слова, числа, адреса url и email, XML-теги и другие. За счет этого он довольно универсален.

Чтобы использовать другой анализатор, его нужно написать на языке C, скомпилировать и подключить. Существуют расширения, которые устанавливают уже готовые анализаторы. Например, https://github.com/postgrespro/pg\_tsparser — анализатор, построенный на базе стандартного, но он не считает подчеркивание и минус символами-разделителями слов (что полезно для индексации программного кода).

https://postgrespro.ru/docs/postgresql/16/textsearch-parsers

Page 11: Анализатор

Единственный установленный анализатор называется default:

=> \dFp
        List of text search parsers
   Schema   |  Name   |     Description
------------+---------+---------------------
 pg_catalog | default | default word parser
(1 row)

Посмотрим, как он разбивает текст на фрагменты — с помощью служебной функции:

=> SELECT *
FROM ts_parse('default', 'The bells from the chapel went jingle-jangle');
 tokid |     token
-------+---------------
     1 | The
    12 |
     1 | bells
    12 |
     1 | from
    12 |
     1 | the
    12 |
     1 | chapel
    12 |
     1 | went
    12 |
    16 | jingle-jangle
    11 | jingle
    12 | -
    11 | jangle
(16 rows)

На этом этапе слова остаются без изменений. Обратите внимание, что из одного слова может получиться несколько «пересекающихся» фрагментов. Типы фрагментов, выделяемые анализатором, можно посмотреть так:

=> \dFp+

Page 12: Page 12

    Text search parser "pg_catalog.default"
     Method      |    Function    | Description
-----------------+----------------+-------------
 Start parse     | prsd_start     | (internal)
 Get next token  | prsd_nexttoken | (internal)
 End parse       | prsd_end       | (internal)
 Get headline    | prsd_headline  | (internal)
 Get token types | prsd_lextype   | (internal)
        Token types for parser "pg_catalog.default"
   Token name    |               Description
-----------------+------------------------------------------
 asciihword      | Hyphenated word, all ASCII
 asciiword       | Word, all ASCII
 blank           | Space symbols
 email           | Email address
 entity          | XML entity
 file            | File or path name
 float           | Decimal notation
 host            | Host
 hword           | Hyphenated word, all letters
 hword_asciipart | Hyphenated word part, all ASCII
 hword_numpart   | Hyphenated word part, letters and digits
 hword_part      | Hyphenated word part, all letters
 int             | Signed integer
 numhword        | Hyphenated word, letters and digits
 numword         | Word, letters and digits
 protocol        | Protocol head
 sfloat          | Scientific notation
 tag             | XML tag
 uint            | Unsigned integer
 url             | URL
 url_path        | URL path
 version         | Version number
 word            | Word, all letters
(23 rows)

Page 13: Словари

фрагмент → словарь → лексема, лексема, …

  • Словарь превращает фрагмент в лексему
    • убрать стоп-слова
    • привести буквы к одному регистру
    • привести словоформы к общему виду
    • привести синонимы к одному варианту
    • и т. п.
  • Штатные словари
    • simple — нижний регистр и стоп-слова
    • стемминг для 28 языков

Комментарии преподавателя

Полученный от анализатора фрагмент пропускается через словарь и превращается в лексему (lexeme) или в несколько лексем.

Смысл такого преобразования в том, чтобы «нормализовать» слова, привести их к такому виду, чтобы их было легко найти. Например:

● из текста можно убрать стоп-слова (которые встречаются почти в каждом документе, так что искать их бессмысленно);

● можно привести слова к одному регистру;

● можно отрезать от слов изменяющиеся части (окончания), чтобы находить любые словоформы (стемминг);

● можно свести все синонимы к одному.

В PostgreSQL изначально установлен словарь simple, а также словари стемминга для 28 языков, которые отрезают от слов изменяемые части. Такие словари работают быстро, но не всегда точно, потому что опираются не на словарь, а на алгоритмы.

https://postgrespro.ru/docs/postgresql/16/textsearch-dictionaries

Page 14: Шаблоны словарей

Словарь — параметризованный шаблон

  • набор функций на языке C
  • Штатные шаблоны
    • простой словарь
    • стеммер snowball
    • словарь ispell
    • синонимы: приведение синонимов к одному виду
    • тезаурус: приведение фраз к одному виду

Комментарии преподавателя

Чтобы добавить новый словарь, нужно воспользоваться шаблоном (template), параметризовав его. Шаблон — это, по сути, набор функций на C, реализующих словарь.

В PostgreSQL входят несколько шаблонов:

● простой словарь simple (выполняет преобразование в нижний регистр и проверяет результат по списку стоп-слов);

● словарь snowball (использует специфический стеммер для заданного языка, есть возможность задать свой список стоп-слов);

● словарь ispell (поддерживает морфологические словари, в том числе MySpell и Hunspell, используемые, например, в OpenOffice);

● словарь синонимов, позволяющий определять синонимы для слов;

● «тезаурус», позволяющий заменять синонимами целые фразы.

Page 15: Словари

Посмотрим, как работает стемминг. Функция вернет нам заменяющую слово «The» лексему, обратившись к указанному в первом параметре словарю:

=> SELECT ts_lexize('english_stem','The');
 ts_lexize
-----------
 {}
(1 row)

Это стоп-слово, оно исчезает. Другой пример:

=> SELECT ts_lexize('english_stem','Bells');
 ts_lexize
-----------
 {bell}
(1 row)

Слово приведено к нижнему регистру и отброшено окончание «s» — таким образом, мы найдем документ, если будем искать «bell» или «bells». А превратится ли слово «went» в «go»?

=> SELECT ts_lexize('english_stem','went');
 ts_lexize
-----------
 {went}
(1 row)

Нет — стемминг не справится с формами слова, для этого нужен полноценный морфологический словарь. Представим, что в нашем документе встречаются слова с диакритическими знаками. Обычный стемминг сохранит букву с умляутом, но с точки зрения поиска это может быть нежелательным:

=> SELECT ts_lexize('english_stem','Röntgen');
 ts_lexize
-----------
 {röntgen}
(1 row)

Воспользуемся расширением unaccent, чтобы избавиться от умляута.

=> CREATE EXTENSION unaccent;
CREATE EXTENSION
=> SELECT ts_lexize('unaccent','Röntgen');
 ts_lexize
-----------
 {Rontgen}
(1 row)

Page 16: Конфигурация

Source slide page 16: Конфигурация{#source-figure-016 caption=“Source slide page 16: Конфигурация”}

фрагмент+тип → словари → лексемы

  • документ → анализатор → фрагмент+тип → словари → лексемы
    • фрагмент+тип → словари → лексемы
  • Своя цепочка словарей для каждого типа фрагмента
    • первый словарь, распознавший фрагмент, возвращает лексему
    • фильтрующий словарь передает лексему дальше по цепочке

Комментарии преподавателя

Итак, документ пропускается через анализатор, который выделяет в нем фрагменты. Какие именно словари будут использоваться для преобразования фрагмента в лексемы, определяется его типом. Таким образом можно по-разному обрабатывать слова, числа и т. п.

Анализатор и цепочки словарей для каждого типа фрагмента настраиваются и называются конфигурацией текстового поиска.

Каждый словарь в цепочке решает, что именно делать с фрагментом:

● Фрагмент можно отфильтровать как стоп-слово. На этом обработка фрагмента, очевидно, прекращается.

● Фрагмент можно заменить на лексему и прекратить обработку. Например, словарь simple переводит любое слово в нижний регистр и умеет фильтровать стоп-слова.

● Фрагмент можно заменить на лексему, но передать ее дальше, чтобы другие словари также смогли обработать ее (такой словарь называется фильтрующим).

● Наконец, словарь может не опознать фрагмент и передать его дальше без изменений. Так, например, поступает словарь синонимов: если синоним не найден, фрагмент передается дальше по цепочке.

https://postgrespro.ru/docs/postgresql/16/textsearch-configuration

Page 17: Настройка конфигурации

Для каждого типа фрагментов настраивается цепочка словарей — такая настройка называется конфигурацией. Доступные конфигурации:

=> \dF
               List of text search configurations
   Schema   |    Name    |              Description
------------+------------+---------------------------------------
 pg_catalog | arabic     | configuration for arabic language
 pg_catalog | armenian   | configuration for armenian language
 pg_catalog | basque     | configuration for basque language
 pg_catalog | catalan    | configuration for catalan language
 pg_catalog | danish     | configuration for danish language
 pg_catalog | dutch      | configuration for dutch language
 pg_catalog | english    | configuration for english language
 pg_catalog | finnish    | configuration for finnish language
 pg_catalog | french     | configuration for french language
 pg_catalog | german     | configuration for german language
 pg_catalog | greek      | configuration for greek language
 pg_catalog | hindi      | configuration for hindi language
 pg_catalog | hungarian  | configuration for hungarian language
 pg_catalog | indonesian | configuration for indonesian language
 pg_catalog | irish      | configuration for irish language
 pg_catalog | italian    | configuration for italian language
 pg_catalog | lithuanian | configuration for lithuanian language
 pg_catalog | nepali     | configuration for nepali language
 pg_catalog | norwegian  | configuration for norwegian language
 pg_catalog | portuguese | configuration for portuguese language
 pg_catalog | romanian   | configuration for romanian language
 pg_catalog | russian    | configuration for russian language
 pg_catalog | serbian    | configuration for serbian language
 pg_catalog | simple     | simple configuration
 pg_catalog | spanish    | configuration for spanish language
 pg_catalog | swedish    | configuration for swedish language
 pg_catalog | tamil      | configuration for tamil language
 pg_catalog | turkish    | configuration for turkish language
 pg_catalog | yiddish    | configuration for yiddish language
(29 rows)

Одна из них выбирается в качестве конфигурации по умолчанию и используется, если явно не указать другую:

=> SHOW default_text_search_config;
 default_text_search_config
----------------------------
 pg_catalog.english
(1 row)

Посмотрим на эту конфигурацию подробнее:

=> \dF+ english

Page 18: Page 18

Text search configuration "pg_catalog.english"
Parser: "pg_catalog.default"
      Token      | Dictionaries
-----------------+--------------
 asciihword      | english_stem
 asciiword       | english_stem
 email           | simple
 file            | simple
 float           | simple
 host            | simple
 hword           | english_stem
 hword_asciipart | english_stem
 hword_numpart   | simple
 hword_part      | english_stem
 int             | simple
 numhword        | simple
 numword         | simple
 sfloat          | simple
 uint            | simple
 url             | simple
 url_path        | simple
 version         | simple
 word            | english_stem

Например, для слов (word) используется стемминг английского языка (english_stem). Встроим в конфигурацию словарь, который при обработке английских слов устраняет диакритические знаки. Для этого зададим цепочку словарей для лексем типа «слово» (word):

=> ALTER TEXT SEARCH CONFIGURATION english
ALTER MAPPING FOR word WITH unaccent, english_stem;
ALTER TEXT SEARCH CONFIGURATION
=> \dF+ english
Text search configuration "pg_catalog.english"
Parser: "pg_catalog.default"
      Token      |     Dictionaries
-----------------+-----------------------
 asciihword      | english_stem
 asciiword       | english_stem
 email           | simple
 file            | simple
 float           | simple
 host            | simple
 hword           | english_stem
 hword_asciipart | english_stem
 hword_numpart   | simple
 hword_part      | english_stem
 int             | simple
 numhword        | simple
 numword         | simple
 sfloat          | simple
 uint            | simple
 url             | simple
 url_path        | simple
 version         | simple
 word            | unaccent,english_stem

Unaccent — фильтрующий словарь, поэтому к полученной лексеме далее будет применен стемминг. Проверим:

=> SELECT to_tsvector('Wilhelm Röntgen');
       to_tsvector
-------------------------
 'rontgen':2 'wilhelm':1
(1 row)

Итак, во что же превращается исходный текст при преобразовании в tsvector?

=> SELECT to_tsvector('The bells from the chapel went jingle-jangle');
                            to_tsvector
-------------------------------------------------------------------
 'bell':2 'chapel':5 'jangl':9 'jingl':8 'jingle-jangl':7 'went':6
(1 row)

Конечный результат — список лексем и их позиций в документе.

Page 19: Page 19

Аналогичное преобразование проходит и поисковый запрос:

=> SELECT to_tsquery('Jingle & bells');
    to_tsquery
------------------
 'jingl' & 'bell'
(1 row)

Page 20: Индексная поддержка

GiST

  • неточное представление с обязательной перепроверкой по таблице
  • эффективность уменьшается при увеличении количества слов
  • поддержка INCLUDE
  • быстрое обновление (зависит от числа документов)
  • GIN
    • точное и компактное представление
    • эффективность не теряется при увеличении количества слов
    • медленное обновление (зависит от числа слов в документах)
  • RUM (расширение на основе GIN)
    • дополнительная информация в индексе (позиции лексем и др.)
    • выдача результатов сразу в порядке релевантности

Комментарии преподавателя

От полнотекстового поиска требуется не только функциональность, но и скорость работы. Для ускорения поиска используются индексы нескольких типов.

Индекс GiST хорош быстрым обновлением (что важно, если документы активно изменяются) и поддерживает функциональность INCLUDE (поможет стать покрывающим для запроса). Однако он не обеспечивает высокой скорости поиска и его эффективность понижается с ростом числа слов.

Индекс GIN — основной выбор для полнотекстового поиска. Он обеспечивает быстрый поиск (производительность не деградирует с увеличением числа слов), хотя и медленно обновляется при изменении документов.

Проблемы этих двух традиционных индексов в том, что они учитывают только лексемы, но отбрасывают их позиции в документе и другую вспомогательную информацию, в том числе веса. Поэтому они хороши для поиска, но в принципе не могут ускорить ранжирование. По той же причине не ускоряется фразовый поиск.

Индекс RUM (доступный как расширение: https://github.com/postgrespro/rum) нацелен на устранение этого недостатка: он позволяет выдавать результаты сразу в порядке релевантности и ускоряет поиск фраз.

https://postgrespro.ru/docs/postgresql/16/textsearch-indexes

Page 21: Производительность текстового поиска

Вернемся к примеру с архивом почтовых рассылок. Как мы видели, поиск может выполняться довольно долго, даже если искать по темам писем:

=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE to_tsvector(subject) @@ to_tsquery('magic');
 count
-------
   103
(1 row)
Time: 5943,915 ms (00:05,944)
=> \timing off
Timing is off.

Что можно сделать? Во-первых, необходимо избавиться от преобразования документов в tsvector каждый раз, когда требуется что-то найти. Добавим в таблицу столбец типа tsvector и заполним его. Учтем сразу и тему письма, и текст. У вектора поиска есть ограничение на размер слова, поэтому слишком длинные последовательности символов игнорируются. Благодаря конструкции GENERATED ALWAYS, значения в столбце будут автоматически вычисляться для новых и измененных строк. Единственное ограничение: выражение должно иметь категорию изменчивости IMMUTABLE. Другой традиционный способ — обновлять значение с помощью триггера. Создание отдельного столбца для tsvector — наиболее эффективное решение. Единственный минус состоит в том, что требуется дополнительное место для хранения tsvector. Вместо создания столбца можно было бы сразу построить индекс по соответствующему выражению. В этом случае места нужно меньше, но эффективность поиска может страдать из-за необходимости повторных вычислений tsvector по документу.

=> ALTER TABLE mail_messages
ADD search_vector tsvector GENERATED ALWAYS AS (
    to_tsvector('english',subject) || to_tsvector('english',body_plain)
) STORED;
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
ALTER TABLE

Теперь в запросе можно использовать уже готовое поле, но, конечно, будет по-прежнему просматриваться вся таблица:

=> EXPLAIN (costs off)
SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('magic');
                                QUERY PLAN
--------------------------------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Parallel Seq Scan on mail_messages
                     Filter: (search_vector @@ to_tsquery('magic'::text))
(6 rows)

Поэтому следующий шаг к ускорению поиска — индексная поддержка. Мы создадим индекс типа GIN. Это обычный выбор для полнотекстового поиска: индекс имеет сравнительно небольшой размер и обеспечивает более высокую точность поиска, чем GiST.

Page 22: Page 22

=> CREATE INDEX ON mail_messages USING gin(search_vector);
CREATE INDEX
=> EXPLAIN (costs off)
SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('magic');
                               QUERY PLAN
------------------------------------------------------------------------
 Aggregate
   ->  Bitmap Heap Scan on mail_messages
         Recheck Cond: (search_vector @@ to_tsquery('magic'::text))
         ->  Bitmap Index Scan on mail_messages_search_vector_idx
               Index Cond: (search_vector @@ to_tsquery('magic'::text))
(5 rows)
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('magic');
 count
-------
  3148
(1 row)
Time: 849,410 ms
=> \timing off
Timing is off.

Как видно, поиск теперь использует индекс и выполняется гораздо быстрее. Вряд ли нам нужны все несколько тысяч результатов. Допустим, мы хотим получить десять наиболее релевантных.

=> EXPLAIN (costs off)
SELECT id, subject, ts_rank(search_vector, to_tsquery('magic')) rank
FROM mail_messages
WHERE search_vector @@ to_tsquery('magic')
ORDER BY rank DESC LIMIT 10;
                                  QUERY PLAN
------------------------------------------------------------------------------
 Limit
   ->  Sort
         Sort Key: (ts_rank(search_vector, to_tsquery('magic'::text))) DESC
         ->  Bitmap Heap Scan on mail_messages
               Recheck Cond: (search_vector @@ to_tsquery('magic'::text))
               ->  Bitmap Index Scan on mail_messages_search_vector_idx
                     Index Cond: (search_vector @@ to_tsquery('magic'::text))
(7 rows)

Сначала находятся все результаты, ранжируются, сортируются, и только потом отбираются 10 лучших. Конечно, это неэффективно. Такие запросы можно ускорить с помощью RUM-индекса.

=> CREATE EXTENSION rum;
CREATE EXTENSION

Индекс типа RUM устроен так же, как GIN, но дополнительно хранит информацию о позициях лексем. Это позволяет вычислять релевантность (степень соответствия документа поисковому запросу) непосредственно при обходе индекса.

=> CREATE INDEX ON mail_messages USING rum(search_vector);
CREATE INDEX

Вот как выглядит запрос, использующий упорядочивающий оператор <=>:

=> EXPLAIN (costs off)
SELECT id, subject, search_vector <=> to_tsquery('magic') distance
FROM mail_messages
WHERE search_vector @@ to_tsquery('magic')
ORDER BY search_vector <=> to_tsquery('magic')
LIMIT 10;

Page 23: Page 23

                                QUERY PLAN
--------------------------------------------------------------------------
 Limit
   ->  Index Scan using mail_messages_search_vector_idx1 on mail_messages
         Index Cond: (search_vector @@ to_tsquery('magic'::text))
         Order By: (search_vector <=> to_tsquery('magic'::text))
(4 rows)
=> \timing on
Timing is on.
=> SELECT id, subject, search_vector <=> to_tsquery('magic') distance
FROM mail_messages
WHERE search_vector @@ to_tsquery('magic')
ORDER BY search_vector <=> to_tsquery('magic')
LIMIT 10;
   id    |                                 subject                                  |
distance
---------+--------------------------------------------------------------------------+-----
-------
 2304690 | Re: [HACKERS] Radix tree for character conversion                        |
10.471326
 2303834 | Re: [HACKERS] Radix tree for character conversion                        |
10.471326
 1847532 | Re: WAL replay bugs                                                      |
10.614068
 1847429 | Re: WAL replay bugs                                                      |
10.6830015
 1847613 | Re: WAL replay bugs                                                      |
10.6830015
 1813935 | [PATCH 3/5] Split out xlog reading into its own module called xlogreader |
10.769349
 1767721 | Protecting against unexpected zero-pages: proposal                       |
10.769349
 1834012 | Re: invalid magic number in log segment                                  |
10.769349
 1754494 | Problem of Magic Block in Postgres 8.2                                   |
10.880653
 1595154 | Proposal for fixing IPC key assignment                                   |
10.880653
(10 rows)
Time: 15,683 ms
=> \timing off
Timing is off.

Page 24: Итоги

Интегрированный полнотекстовый поиск обеспечивает

  • актуальность результатов
  • транзакционность
  • учет языковых особенностей
  • фразовый поиск
  • ранжирование результатов
  • индексную поддержку
  • Поиск охватывает любую информацию, приводимую к текстовому виду
  • Возможна тонкая настройка под конкретные нужды

Page 25: Практика

  1. Сейчас поиск в книжном магазине работает только по названиям книг. Замените его на полнотекстовый поиск по названиям книг, полным именам авторов и аннотациям. Проверьте сделанные изменения в приложении. Убедитесь, что поиск не зависит от формы слов в запросе.

Комментарии преподавателя

  1. Добавьте в таблицу books столбец типа tsvector, генерируемый по необходимым значениям. Учтите, что значение tsvector должно зависеть от данных из нескольких таблиц.

Посмотрите код функции public.get_catalog и подумайте, что нужно сделать, чтобы заменить существующее условие поиска на полнотекстовое.

Для проверки найдите книги по запросам «проблема» и «проблемы».

Page 26: 1. Полнотекстовый поиск в приложении

Добавим столбец типа tsvector:

=> ALTER TABLE books ADD tsv tsvector;
ALTER TABLE

Для поиска нам требуются данные нескольких таблиц, поэтому конструкция GENERATED ALWAYS не годится и придется воспользоваться триггером. Начнем с функции, создающей необходимое значение tsvector:

=> CREATE FUNCTION build_tsv(
    book_id bigint,
    title text,
    abstract text
)
RETURNS tsvector
LANGUAGE sql STABLE
BEGIN ATOMIC
    WITH a(names) AS (
        SELECT string_agg(
            a.last_name || ' ' ||
            a.first_name || ' ' ||
            coalesce(a.middle_name,''),
            ' '
        )
        FROM authorships ash
            JOIN authors a ON a.author_id = ash.author_id
        WHERE ash.book_id = build_tsv.book_id
    )
    SELECT to_tsvector(
        'russian',
        build_tsv.title || ' ' ||
        a.names || ' ' ||
        coalesce(build_tsv.abstract,'')
    )
    FROM a;
END;
CREATE FUNCTION

Мы используем конфигурацию поиска для русского языка, чтобы поиск работал без учета окончаний. Теперь напишем триггерную функцию и триггер для таблицы books:

=> CREATE FUNCTION fill_tsv() RETURNS trigger
AS $$
BEGIN
    NEW.tsv := build_tsv(
        NEW.book_id, NEW.title, NEW.additional->>'Аннотация'
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION
=> CREATE TRIGGER books_tsv
AFTER INSERT OR UPDATE OF title, additional
ON books
FOR EACH ROW
EXECUTE FUNCTION fill_tsv();
CREATE TRIGGER

Но измениться может и имя автора, и состав авторов книги, поэтому также требуются триггеры на таблицы authors и authorships. Причем следует использовать триггеры AFTER, чтобы функция build_tsv брала из этих таблиц уже измененные данные. В случае авторов надо найти все затронутые изменением книги и обновить у них tsv:

Page 27: Page 27

=> CREATE FUNCTION update_authors_tsv() RETURNS trigger
AS $$
BEGIN
    UPDATE books b
    SET tsv = build_tsv(b.book_id, b.title, b.additional->>'Аннотация')
    WHERE b.book_id IN (
        SELECT ash.book_id
        FROM authorships ash
            JOIN books b ON b.book_id = ash.book_id
        WHERE ash.author_id = NEW.author_id
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION
=> CREATE TRIGGER authors_tsv
AFTER UPDATE OF first_name, last_name, middle_name
ON authors
FOR EACH ROW
EXECUTE FUNCTION update_authors_tsv();
CREATE TRIGGER

Здесь мы предполагаем, что первичный ключ никогда не обновляется. Обратите внимание, что для таблицы books мы явно указали поля, на которые срабатывает триггер fill_tsv, так что триггер не будет срабатывать лишний раз. И для авторства:

=> CREATE FUNCTION update_authorships_tsv() RETURNS trigger
AS $$
BEGIN
    UPDATE books b
    SET tsv = build_tsv(b.book_id, b.title, b.additional->>'Аннотация')
    WHERE b.book_id IN (OLD.book_id, NEW.book_id);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql VOLATILE;
CREATE FUNCTION
=> CREATE TRIGGER authorships_tsv
AFTER INSERT OR UPDATE OF book_id, author_id OR DELETE
ON authorships
FOR EACH ROW
EXECUTE FUNCTION update_authorships_tsv();
CREATE TRIGGER

Теперь обновим новый столбец у всех книг. Эту операцию, конечно, можно и нужно выполнять пакетно, как рассматривалось ранее, если количество строк велико.

=> UPDATE books
SET tsv = build_tsv(book_id, title, additional->>'Аннотация');
UPDATE 100

Наконец, заменим функцию search_cond, которая формирует условие поиска:

=> CREATE OR REPLACE FUNCTION public.search_cond(search text) RETURNS text
LANGUAGE sql IMMUTABLE
RETURN CASE
    WHEN coalesce(search,'') = '' THEN
        'true'
    ELSE
        format('b.tsv @@ websearch_to_tsquery(''russian'',%L)', search)
    END;
CREATE FUNCTION

Page 28: Практика+

  1. Восстановите базу данных с архивом рассылок из резервной копии. Добавьте в таблицу mail_messages столбец search_vector и заполните его так же, как в демонстрации. 2. Сколько документов содержат одновременно фразы «vacuum full» и «index page»? Сравните скорость поиска без индекса и с индексами разных типов (GiST, GIN, RUM), а также размер индексов. 3. Сколько документов содержит слово «vacuuming» именно в такой форме?

Комментарии преподавателя

  1. Создавайте индексы по одному и удаляйте их сразу после эксперимента перед созданием следующего, чтобы в запросе гарантированно использовался нужный индекс.

  2. Проще всего ответить на этот вопрос с помощью регулярных выражений. Но, поскольку тема посвящена полнотекстовому поиску, измените конфигурацию поиска, установив для лексем типа «asciiword» (к которым относятся слова из латинских букв) словарь «simple».

После этого необходимо пересоздать столбец search_vector, так как он будет содержать значение, не соответствующие новой конфигурации.

Page 29: 1. Подготовка

=> CREATE DATABASE ext_fts_overview;
CREATE DATABASE
student$ zcat ~/mail_messages.sql.gz | psql -d ext_fts_overview
SET
SET
SET
SET
SET
 set_config
------------
(1 row)
SET
SET
SET
SET
ALTER TABLE
DROP TABLE
DROP SEQUENCE
CREATE SEQUENCE
SET
SET
CREATE TABLE
COPY 356125
 setval
---------
 2317563
(1 row)
ALTER TABLE
=> \c ext_fts_overview
You are now connected to database "ext_fts_overview" as user "student".
=> ALTER TABLE mail_messages
ADD search_vector tsvector GENERATED ALWAYS AS (
    to_tsvector('english',subject) || to_tsvector('english',body_plain)
) STORED;
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
ALTER TABLE
=> ANALYZE mail_messages;
ANALYZE

2. Поиск двух фраз

Запрос без индексов:

=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('(vacuum <-> full) & (index <-> page)');

Page 30: Page 30

 count
-------
    45
(1 row)
Time: 14513,190 ms (00:14,513)
=> \timing off
Timing is off.

Запрос с индексом GiST:

=> CREATE INDEX mm_gist ON mail_messages USING gist(search_vector);
CREATE INDEX
=> SELECT pg_size_pretty(pg_indexes_size('mail_messages'));
 pg_size_pretty
----------------
 134 MB
(1 row)
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('(vacuum <-> full) & (index <-> page)');
 count
-------
    45
(1 row)
Time: 2435,401 ms (00:02,435)
=> \timing off
Timing is off.
=> DROP INDEX mm_gist;
DROP INDEX

Запрос с индексом GIN:

=> CREATE INDEX mm_gin ON mail_messages USING gin(search_vector);
CREATE INDEX
=> SELECT pg_size_pretty(pg_indexes_size('mail_messages'));
 pg_size_pretty
----------------
 188 MB
(1 row)
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('(vacuum <-> full) & (index <-> page)');
 count
-------
    45
(1 row)
Time: 487,821 ms
=> \timing off
Timing is off.
=> DROP INDEX mm_gin;
DROP INDEX

Запрос с индексом RUM:

Page 31: Page 31

=> CREATE EXTENSION rum;
CREATE EXTENSION
=> CREATE INDEX mm_rum ON mail_messages USING rum(search_vector);
CREATE INDEX
=> SELECT pg_size_pretty(pg_indexes_size('mail_messages'));
 pg_size_pretty
----------------
 383 MB
(1 row)
=> \timing on
Timing is on.
=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('(vacuum <-> full) & (index <-> page)');
 count
-------
    45
(1 row)
Time: 106,608 ms
=> \timing off
Timing is off.
=> DROP INDEX mm_rum;
DROP INDEX

Следует учитывать, что скорость выполнения запросов зависит от многих причин (от состояния буферного кеша, от нагрузки на сервер и т. д.), поэтому запросы лучше выполнить по нескольку раз и усреднить результаты.

3. Поиск точной формы слова

Изменяем конфигурацию поиска.

=> ALTER TEXT SEARCH CONFIGURATION english
ALTER MAPPING FOR asciiword WITH simple;
ALTER TEXT SEARCH CONFIGURATION

Теперь преобразование сохраняет форму слова:

=> SELECT to_tsquery('vacuuming');
 to_tsquery
-------------
 'vacuuming'
(1 row)

Пересоздаем столбец.

=> ALTER TABLE mail_messages
DROP search_vector;
ALTER TABLE
=> ALTER TABLE mail_messages
ADD search_vector tsvector GENERATED ALWAYS AS (
    to_tsvector('english',subject) || to_tsvector('english',body_plain)
) STORED;
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
NOTICE:  word is too long to be indexed
DETAIL:  Words longer than 2047 characters are ignored.
ALTER TABLE

Page 32: Page 32

=> SELECT count(*) FROM mail_messages
WHERE search_vector @@ to_tsquery('vacuuming');
 count
-------
  2245
(1 row)

18 Занятие 18: Репликация / Обзор физической репликации

Исходная страница в нативном Markdown: Репликация / Обзор физической репликации
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_18_replica_overview_physical.html Archive: DEV2-handouts-16.zip Страниц PDF: 26

Page 1: Репликация / Обзор физической репликации

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Задачи и виды репликации
  • Физическая репликация
  • Уровни журнала
  • Варианты использования реплики
  • Переключение на реплику

Page 3: Задачи и виды репликации

Синхронизация данных на разных серверах

  • Основные задачи
    • отказоустойчивость, высокая доступность
    • масштабируемость
  • Физическая репликация
    • синхронизация на уровне страниц и версий строк
  • Логическая репликация
    • синхронизация на уровне строк таблиц

Комментарии преподавателя

Одиночный сервер, управляющий базами данных, может не удовлетворять предъявляемым требованиям.

Один сервер — возможная точка отказа. Два (или больше) серверов позволяют сохранить доступность системы в случае сбоя (отказоустойчивость) или — более широко — в любом случае, например, при проведении плановых работ (высокая доступность).

Один сервер может не справляться с нагрузкой. Наращивать ресурсы сервера может оказаться невыгодно или вообще невозможно. Но можно распределить нагрузку на несколько серверов (масштабирование).

Информационные системы могут использовать общие данные.

Таким образом, речь идет о том, чтобы иметь несколько серверов, работающих с одними и теми же данными. Под репликацией понимается процесс синхронизации этих данных.

В зависимости от уровня, на котором происходит синхронизация, различают физическую и логическую репликацию: при физической репликации синхронизируются изменения, произошедшие в страницах данных и статусах транзакций, а при логической — изменения строк таблиц.

Page 4: Физическая репликация

Механизм

  • один сервер транслирует журнальные записи на другой сервер,
  • и тот проигрывает полученные записи
  • Особенности
    • мастер-реплика: поток данных только в одну сторону
    • требуется двоичная совместимость серверов
    • возможна репликация только всего кластера

Комментарии преподавателя

Механизм физической репликации состоит в том, что один сервер передает журнальные записи на другой сервер, а тот их проигрывает — как при восстановлении после сбоя.

При физической репликации серверы имеют назначенные роли: мастер и реплика. Мастер передает на реплику свои журнальные записи в виде файлов или потока записей; реплика применяет эти записи к своим файлам данных. Применение происходит чисто механически, без «понимания смысла» изменений, поэтому важна двоичная совместимость между серверами (одинаковые платформы и основные версии PostgreSQL). Поскольку журнал общий для всего кластера, то и реплицировать можно только весь кластер целиком.

Page 5: Физическая репликация

Source slide page 5: Физическая репликация{#source-figure-005 caption=“Source slide page 5: Физическая репликация”}

Комментарии преподавателя

Чтобы организовать репликацию между двумя серверами, мы создаем реплику из физической резервной копии основного сервера. При обычном восстановлении из такой резервной копии получается новый независимый сервер. А если включить репликацию, ведомый сервер начинает работать в режиме постоянного восстановления: он все время применяет новые журнальные записи, приходящие с основного сервера (этим занимается процесс startup). Таким образом, реплика постоянно поддерживается в почти актуальном состоянии.

Есть два способа доставки журналов от мастера к реплике. Основной, который используется на практике — потоковая репликация.

В этом случае реплика (процесс walreceiver) подключается к мастеру (процесс walsender) по протоколу репликации и получает поток записей WAL. За счет этого при потоковой репликации отставание реплики сведено к минимуму или даже к нулю при синхронном режиме.

Если в системе настроено непрерывное архивирование, то возможна файловая репликация. В этом случае реплика будет заметно отставать, поскольку файловый архив пополняется только при переключении сегмента WAL.

На практике файловую репликацию используют как дополнение к потоковой. Если реплика не может получить очередную журнальную запись по протоколу репликации, она будет пробовать прочитать соответствующий файл из архива сегментов WAL.

https://postgrespro.ru/docs/postgresql/16/high-availability

Page 6: Уровни журнала

Параметр wal\_level

  • minimal < replica
  • восстановление восстановление после сбоя после сбоя восстановление из резервной копии, репликация

Комментарии преподавателя

Поскольку на реплику попадает только та информация, которая содержится в журнале, в журнал должны записываться все необходимые для синхронизации данные.

Состав информации, попадающей в журнал, регулируется параметром wal_level.

До версии PostgreSQL 10 уровнем по умолчанию был minimal, гарантирующий только восстановление после сбоя. На таком уровне репликация работать не может, поскольку для обеспечения надежности в этом случае часть изменений сразу записывается на энерго-независимый носитель и не попадает в журнал.

В версиях 10+ уровень по умолчанию — replica. На этом уровне в журнал записываются все изменения данных, что позволяет восстанавливать систему из горячих резервных копий, сделанных утилитой pg_basebackup, а также использовать физическую репликацию.

Поскольку резервное копирование и репликация — востребованные задачи, уровень по умолчанию и был изменен в пользу replica.

Page 7: Настройка физической репликации

Посмотрим, как организовать потоковую репликацию между двумя серверами. Мы ограничимся самым простым вариантом; детально репликация рассматривается в курсе для администраторов DBA3. Минимальный набор параметров, которые нужно проверить:

=> SELECT name, setting FROM pg_settings
WHERE name in ('wal_level','max_wal_senders');
      name       | setting
-----------------+---------
 max_wal_senders | 10
 wal_level       | replica
(2 rows)

Начиная с версии PostgreSQL 10, параметры по умолчанию уже имеют подходящие значения. Разрешение на подключение по протоколу репликации в pg_hba.conf:

=> SELECT type, user_name, address, auth_method FROM pg_hba_file_rules
WHERE 'replication' = ANY(database);
 type  | user_name |  address  |  auth_method
-------+-----------+-----------+---------------
 local | {all}     |           | trust
 host  | {all}     | 127.0.0.1 | scram-sha-256
 host  | {all}     | ::1       | scram-sha-256
(3 rows)

Разрешение тоже имеется. Развернем реплику из физической резервной копии, для этого используем утилиту pg_basebackup. Целевой каталог копии должен быть пустым или отсутствовать:

student$ rm -rf /home/student/tmp/backup

Ключ –checkpoint=fast просит утилиту выполнить контрольную точку как можно быстрее (без пауз), а ключ -R — добавить настройки для реплики:

student$ pg_basebackup --pgdata=/home/student/tmp/backup -R --checkpoint=fast

Утилита создала заготовку конфигурационного файла…

student$ cat /home/student/tmp/backup/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
primary_conninfo = 'user=student passfile=''/home/student/.pgpass''
channel_binding=prefer host=''/var/run/postgresql'' port=5432 sslmode=prefer
sslnegotiation=postgres sslcompression=0 sslcertmode=allow sslsni=1
ssl_min_protocol_version=TLSv1.2 gssencmode=prefer krbsrvname=postgres gssdelegation=0
target_session_attrs=any load_balance_hosts=disable'

… и сигнальный файл, который даст реплике указание перейти в режим постоянного восстановления:

student$ ls -l /home/student/tmp/backup/*.signal
-rw------- 1 student student 0 апр 10 16:18 /home/student/tmp/backup/standby.signal

Кластер, в котором мы собираемся развернуть реплику, уже предварительно инициализирован. Если сервер работает, его необходимо остановить:

student$ sudo pg_ctlcluster 16 replica stop
Cluster is not running.

Копия была размещена в домашнем каталоге пользователя student, а теперь переносим ее в каталог данных кластера и делаем владельцем файлов пользователя postgres:

student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/tmp/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica

Можно запускать сервер:

Page 8: Page 8

student$ sudo pg_ctlcluster 16 replica start

Посмотрим на процессы реплики.

student$ sudo head -n 1 '/var/lib/postgresql/16/replica/postmaster.pid'
47189
student$ ps -o pid,command --ppid 47189
    PID COMMAND
  47190 postgres: 16/replica: checkpointer
  47191 postgres: 16/replica: background writer
  47192 postgres: 16/replica: startup recovering 000000010000000000000007
  47193 postgres: 16/replica: walreceiver streaming 0/7000060

Процесс walreceiver принимает поток журнальных записей, а процесс startup применяет изменения. Сравним с процессами мастера.

student$ sudo head -n 1 '/var/lib/postgresql/16/main/postmaster.pid'
46743
student$ ps -o pid,command --ppid 46743
    PID COMMAND
  46744 postgres: 16/main: checkpointer
  46745 postgres: 16/main: background writer
  46747 postgres: 16/main: walwriter
  46748 postgres: 16/main: autovacuum launcher
  46749 postgres: 16/main: logical replication launcher
  46798 postgres: 16/main: student student [local] idle
  47194 postgres: 16/main: walsender student [local] streaming 0/7000060

Процесс walsender отправляет реплике журнальные записи. Состояние репликации можно смотреть в специальном представлении на мастере:

=> SELECT * FROM pg_stat_replication \gx
-[ RECORD 1 ]----+------------------------------
pid              | 47194
usesysid         | 16384
usename          | student
application_name | 16/replica
client_addr      |
client_hostname  |
client_port      | -1
backend_start    | 2026-04-10 16:18:41.217602+03
backend_xmin     |
state            | streaming
sent_lsn         | 0/7000060
write_lsn        | 0/7000060
flush_lsn        | 0/7000060
replay_lsn       | 0/7000060
write_lag        |
flush_lag        |
replay_lag       |
sync_priority    | 0
sync_state       | async
reply_time       | 2026-04-10 16:18:43.745684+03

значения *_lsn — показывают, какие журнальные записи отправлены на реплику, получены ею, записаны на диск и применены; sync_state — синхронная или асинхронная репликация (об этом подробнее позже).

Page 9: Использование реплики

Допускаются

  • запросы на чтение данных (SELECT, COPY TO, курсоры)
  • установка параметров сервера (SET, RESET)
  • управление транзакциями (BEGIN, COMMIT, ROLLBACK…)
  • создание резервной копии (pg_basebackup)
  • Не допускаются
    • любые изменения (INSERT, UPDATE, DELETE, TRUNCATE, nextval…)
    • блокировки, предполагающие изменение (SELECT FOR UPDATE…)
    • команды DDL (CREATE, DROP…), в т. ч. создание временных таблиц
    • команды сопровождения (VACUUM, ANALYZE, REINDEX…)
    • управление доступом (GRANT, REVOKE…)
    • не срабатывают триггеры и рекомендательные блокировки

Комментарии преподавателя

По умолчанию реплика работает в режиме горячего резерва, в этом случае разрешены клиентские подключения, но только для чтения данных. Также будет работать установка параметров сервера и команды управления транзакциями — например, можно начать (читающую) транзакцию с нужным уровнем изоляции.

Кроме того, реплику можно использовать и для изготовления резервных копий (конечно, принимая во внимание возможное отставание от мастера).

В режиме горячего резерва на реплике не допускаются никакие изменения данных (включая последовательности), многие виды блокировок, команды DDL, а также такие команды, как vacuum, analyze, reindex и команды управления доступом — словом, все, что так или иначе изменяет данные.

При необходимости реплику можно запустить в режиме теплого резерва, задав параметр hot_standby = off, тогда подключения будут вообще невозможны.

https://postgrespro.ru/docs/postgresql/16/hot-standby

Page 10: Использование реплики

Выполним несколько команд на основном сервере:

=> CREATE DATABASE replica_overview_physical;
CREATE DATABASE
=> \c replica_overview_physical
You are now connected to database "replica_overview_physical" as user "student".
=> CREATE TABLE test(id integer PRIMARY KEY, descr text);
CREATE TABLE

Проверим реплику:

student$ psql -p 5433 -d replica_overview_physical
=> SELECT * FROM test;
 id | descr
----+-------
(0 rows)

Вставим строку в таблицу на основном сервере:

=> INSERT INTO test VALUES (1, 'Раз');
INSERT 0 1
=> SELECT * FROM test;
 id | descr
----+-------
  1 | Раз
(1 row)

Итак, репликация работает и запросы на реплике выполняются. При этом изменения на реплике не допускаются:

=> INSERT INTO test VALUES (2, 'Два');
ERROR:  cannot execute INSERT in a read-only transaction

Page 11: Использование реплики

Source slide page 11: Использование реплики{#source-figure-011 caption=“Source slide page 11: Использование реплики”}

надежность хранения данных

Комментарии преподавателя

Механизм репликации позволяет построить систему так, чтобы она отвечала предъявляемым к ней требованиям. Рассмотрим несколько типичных задач и средства их решения.

Одна из возможных задач — обеспечить надежность хранения данных.

Напомним, что фиксация транзакций может работать в синхронном и асинхронном режимах. В первом случае фиксация не завершается до тех пор, пока данные не будут надежно записаны на энерго-независимый носитель. Во втором — есть риск потерять часть зафиксированных данных, но фиксация не ждет записи на диск, и система работает быстрее.

Аналогичная картина и с репликацией. При синхронном режиме (synchronous_commit = on) и наличии реплики фиксация ждет не только записи WAL на диск, но и подтверждения приема журнальных записей от синхронной реплики. Это еще больше увеличивает надежность (данные не пропадут, даже если основной сервер выйдет из строя), но и еще больше замедляет систему.

Существуют и промежуточные варианты настройки, которые не дают полной гарантии надежности, но все-таки снижают вероятность потери данных.

Page 12: Использование реплики

Source slide page 12: Использование реплики{#source-figure-012 caption=“Source slide page 12: Использование реплики”}

выполнение долгих аналитических запросов (отчетов)

Комментарии преподавателя

Как уже говорилось, долгие запросы удерживают горизонт очистки, из-за чего не удается удалять ненужные версии строк. Если какие-то таблицы в это время активно изменяются, они могут сильно вырасти в размере. Поэтому для выполнения долгих аналитических запросов можно использовать реплику.

Тонкий момент состоит в том, что с основного сервера могут приходить журнальные записи, конфликтующие с выполняющимся запросом. Есть два источника таких записей.

  1. Очистка удаляет версии строк, уже не нужные основному серверу, но еще нужные для выполнения запроса на реплике.

  2. Исключительные блокировки, происходящие на основном сервере, несовместимые с запросом на реплике.

Поэтому «отчетную» реплику настраивают так, чтобы она принимала WAL-записи, но откладывала их применение, если они конфликтуют с запросами. Это приводит к тому, что данные на реплике могут «отставать» от основного сервера, но, как правило, для долгих аналитических запросов это не существенно.

Page 13: Несколько реплик

Source slide page 13: Несколько реплик{#source-figure-013 caption=“Source slide page 13: Несколько реплик”}

распределение OLTP-нагрузки по чтению

Комментарии преподавателя

К основному серверу можно подключить несколько реплик, например, для распределения OLTP-нагрузки по чтению.

OLTP-запросы не должны быть долгими. Это позволяет использовать обратную связь между репликой и основным сервером по протоколу репликации. В этом случае основной сервер знает, какой горизонт транзакций нужен запросам на реплике, и очистка не будет удалять соответствующие версии строк. Иными словами, обратная связь дает такой же эффект, как если бы все запросы выполнялись непосредственно на основном сервере.

Однако репликация обеспечивает только базовый механизм. Для автоматического распределения нагрузки необходимы внешние средства (балансировщики). Следует также иметь в виду, что между основным сервером и репликами не гарантируется согласованность данных, даже в случае синхронной репликации. Если приложение читает данные только с одного из серверов, оно, разумеется, будет получать согласованные данные. Но это не выполняется, если приложение читает данные с нескольких серверов. С реплики можно прочитать как устаревшие данные, так и данные, которых еще не видно на основном сервере. Подробнее эти вопросы обсуждаются в курсе DBA3 «Резервное копирование и репликация».

Page 14: Каскадная репликация

Source slide page 14: Каскадная репликация{#source-figure-014 caption=“Source slide page 14: Каскадная репликация”}

уменьшение нагрузки на мастер и перераспределение сетевого трафика

Комментарии преподавателя

Несколько реплик, подключенных к одному основному серверу, будут создавать на него определенную нагрузку. Кроме того, надо учитывать нагрузку на сеть при пересылке нескольких копий потока журнальных записей.

Для снижения нагрузки реплики можно соединять каскадом; при этом серверы передают журнальные записи друг другу по цепочке. Чем дальше от мастера, тем большее запаздывание может накопиться в изменяемых данных.

Заметим, что каскадная синхронная репликация не поддерживается: основной сервер может быть синхронизирован только с непосред-ственно подключенной к нему репликой. А вот обратная связь поступает к основному серверу от всех реплик.

Page 15: Отложенная репликация

Source slide page 15: Отложенная репликация{#source-figure-015 caption=“Source slide page 15: Отложенная репликация”}

«машина времени» и возможность восстановления на определенный момент без архива

Комментарии преподавателя

Полезной может оказаться возможность просматривать данные на некоторый момент в прошлом и, при необходимости, восстановить сервер на этот момент. Это позволяет, в частности, справиться с ошибкой пользователя, совершившего неправильные действия, требующие отмены.

Обычный механизм восстановления из архива на момент времени (point-in-time recovery) в принципе позволяет решить эту задачу, но требует большой подготовительной работы и занимает много времени. А способа построить снимок данных по состоянию на произвольный момент в прошлом в PostgreSQL нет.

Задача решается созданием реплики, которая применяет записи WAL не сразу, а через установленный интервал времени.

В этом курсе мы не обсуждаем необходимые настройки для каждого из показанных вариантов. Для подробной информации обратитесь к курсу DBA3 «Резервное копирование и репликация».

Page 16: Переключение на реплику

Плановое переключение

  • останов основного сервера для технических работ без прерывания
  • обслуживания
  • ручной режим
  • Аварийное переключение
    • переход на реплику из-за сбоя основного сервера
    • ручной режим,
    • но в принципе можно автоматизировать с помощью
    • дополнительного кластерного ПО

Комментарии преподавателя

Имеющуюся реплику можно использовать и для того, чтобы переключить на нее приложение с основного сервера.

Причины перехода на резервный сервер бывают разные. Это может быть необходимость технических работ на основном сервере — тогда переход выполняется в удобное время в штатном режиме. Возможен и сбой основного сервера, в таком случае переходить на резервный сервер нужно как можно быстрее, чтобы не прерывать обслуживание пользователей.

Даже в случае сбоя переход осуществляется вручную, если не используется специальное кластерное программное обеспечение, которое следит за состоянием серверов и может инициировать переход автоматически.

Page 17: Переключение на реплику

Source slide page 17: Переключение на реплику{#source-figure-017 caption=“Source slide page 17: Переключение на реплику”}

Чтобы перевести реплику из режима восстановления в обычный режим, нужно дать ей соответствующую команду

=> SELECT pg_is_in_recovery();  -- является репликой?
 pg_is_in_recovery
-------------------
 t
(1 row)
student$ sudo pg_ctlcluster 16 replica promote

Начиная с версии 12 это же можно сделать, используя SQL-функцию pg_promote.

=> SELECT pg_is_in_recovery();  -- снова проверим: это реплика?
 pg_is_in_recovery
-------------------
 f
(1 row)

Таким образом мы получили два самостоятельных, никак не связанных друг с другом сервера.

=> INSERT INTO test VALUES (2, 'Два');
INSERT 0 1

Очень важно иметь гарантии того, что приложение работает только с одним из серверов, иначе возникает ситуация, называемая split brain: часть данных оказывается на одном сервере, часть — на другом, и собрать их воедино практически невозможно.

Page 18: Итоги

Механизм физической репликации основан на передаче журнальных записей на реплику и их применении

  • трансляция потока записей или файлов WAL
  • Физическая репликация создает точную копию всего кластера
    • однонаправленная, требует двоичной совместимости
    • базовый механизм для решения целого ряда задач

Page 19: Практика

  1. Разверните реплику так, как показано в демонстрации.
  • Проверьте, как работает приложение, если переключить его на использование реплики. 2. Добавьте к механизму фоновых заданий возможность выполнять задания на реплике с помощью расширения dblink. Убедитесь, что долгое задание не приведет к появлению такой же долгой транзакции на основном сервере.

Комментарии преподавателя

  1. Переключатель сервера находится в верхней части информационной панели приложения.

Обратите внимание на то, какие операции по-прежнему работают, а какие — вызывают ошибку.

  1. Приложение позволяет при отправке задания на выполнение указать удаленный сервер, и записывает имя узла и порт в таблицу tasks.

Добавьте в процедуру process_tasks проверку: если указаны узел и порт, вызывайте функцию run не локально, а с помощью расширения dblink на указанном сервере. (Расширение рассматривалось в теме «Фоновые процессы»)

Чтобы долгое задание не приводило к появлению такой же долгой транзакции на основном сервере, используйте dblink в асинхронном режиме, и периодически опрашивайте готовность запроса в отдельных, коротких, транзакциях.

Page 20: 1. Развертывание реплики

Выполняем те же команды, что и в демонстрации.

student$ pg_basebackup --pgdata=/home/student/backup -R
student$ sudo pg_ctlcluster 16 replica stop
Cluster is not running.
student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica
student$ sudo pg_ctlcluster 16 replica start

При переключении приложения на реплику: Поиск книг и другие операции, не требующие изменения данных, работают; Вход, покупка книг и другие операции, изменяющие данные, выдают ошибку.

2. Фоновые задания в удаленном режиме

Добавим в процедуру запуска задания обработку узла и порта. Если они указаны, будем выполнять функцию run на указанном сервере с помощью расширения dblink. За основу берем вариант процедуры, написанной в практике к теме «Асинхронная обработка».

=> CREATE EXTENSION IF NOT EXISTS dblink;
CREATE EXTENSION

Page 21: Page 21

=> CREATE OR REPLACE PROCEDURE process_tasks() AS $$
DECLARE
    task tasks;
    result text;
    ctx text;
BEGIN
    SET application_name = 'process_tasks';
    <<forever>>
    LOOP
        COMMIT;
        PERFORM pg_sleep(1);
        SELECT * INTO task FROM take_task();
        COMMIT;
        CONTINUE forever WHEN task.task_id IS NULL;
        IF task.host IS NULL THEN -- запускаем локально
            BEGIN
                result := empapi.run(task);
                PERFORM complete_task(task, 'finished', result);
            EXCEPTION
                WHEN others THEN
                    GET STACKED DIAGNOSTICS
                        result := MESSAGE_TEXT,
                        ctx := PG_EXCEPTION_CONTEXT;
                    PERFORM complete_task(
                        task, 'error', result || E'\n' ||  ctx
                    );
            END;
        ELSE -- запускаем удаленно в асинхронном режиме
            BEGIN
                PERFORM dblink_connect(
                    'remote',
                    format(
                        'host=%s port=%s dbname=%s user=%s password=%s',
                        task.host, task.port, 'bookstore2', 'student', 'student'
                    )
                );
            EXCEPTION
                WHEN others THEN
                    GET STACKED DIAGNOSTICS
                        result := MESSAGE_TEXT,
                        ctx := PG_EXCEPTION_CONTEXT;
                    PERFORM complete_task(
                        task, 'error', result || E'\n' ||  ctx
                    );
                    CONTINUE forever;
            END;
            PERFORM dblink_send_query(
                'remote',
                format('SELECT * FROM empapi.run(%L)', task)
            );
            -- ожидание результата
            LOOP
                PERFORM pg_sleep(1);
                EXIT WHEN (SELECT dblink_is_busy('remote')) = 0;
                COMMIT;
            END LOOP;
            -- получение результата
            BEGIN
                SELECT s INTO result
                FROM dblink_get_result('remote') AS (s text);
                PERFORM complete_task(task, 'finished', result);
            EXCEPTION
                WHEN others THEN
                    GET STACKED DIAGNOSTICS
                        result := MESSAGE_TEXT,
                        ctx := PG_EXCEPTION_CONTEXT;
                    PERFORM complete_task(
                        task, 'error', result || E'\n' ||  ctx
                    );
            END;
            PERFORM dblink_disconnect('remote');
        END IF;
    END LOOP;
END;
$$ LANGUAGE plpgsql;
CREATE PROCEDURE

Обратите внимание на следующее:

Page 22: Page 22

Для краткости не обрабатывается статус вызова функции dblink_send_query, что, конечно же, необходимо делать. В цикле ожидания результатов удаленного запуска выполняется фиксация. В противном случае получалась бы долгая транзакция, удерживающая горизонт базы данных. Дублируется код для обработки исключительных ситуаций. Это связано с тем, что процедура не может выполнять фиксацию внутри блока с секцией EXCEPTION. Команда COMMIT перенесена из конца цикла forever в начало. Это сделано для удобства прерывания обработки в случае ошибки (см. обработчик dblink_connect). Работающий в системе фоновый процесс, выполняющий процедуру process_tasks, будет продолжать выполнять старую версию процедуры. Его необходимо прервать и перезапустить:

=> SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE application_name = 'process_tasks';
 pg_terminate_backend
----------------------
 t
(1 row)
=> SELECT pg_background_launch('CALL process_tasks()');
 pg_background_launch
----------------------
               100692
(1 row)
=> SELECT pg_background_detach(100692);
 pg_background_detach
----------------------
(1 row)

Page 23: Практика+

  1. Настройте физическую потоковую репликацию между двумя серверами в синхронном режиме. Проверьте работу репликации. Убедитесь, что при остановленной реплике фиксация не завершается. 2. По умолчанию применение конфликтующих записей на реплике откладывается максимум на 30 секунд. Отключите откладывание применения и убедитесь, что долгий запрос, выполняющийся на реплике, будет прерван, если необходимые ему версии строк удаляются и очищаются на мастере. Включите обратную связь и убедитесь, что теперь запрос не прерывается из-за того, что на мастере откладывается очистка.

Комментарии преподавателя

  1. Для этого на мастере установите с помощью ALTER SYSTEM следующие параметры:

● synchronous_commit = on

● synchronous_standby_names = ‘“16/replica”’

  1. За откладывание применения конфликтующих записей на реплике отвечает параметр max_standby_streaming_delay. Установите его в 0. Обратная связь включается параметром hot_standby_feedback = on. Оба параметра устанавливаются через ALTER SYSTEM с последующим перечитыванием настроек.

Чтобы запрос выполнялся долго на небольшом объеме данных, можно добавить в него вызов функции pg_sleep.

Page 24: 1. Синхронная репликация

Разворачиваем реплику, как было показано в демонстрации:

student$ pg_basebackup --pgdata=/home/student/tmp/backup -R --checkpoint=fast
student$ sudo pg_ctlcluster 16 replica stop
Cluster is not running.
student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/tmp/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica

Запускаем реплику.

student$ sudo pg_ctlcluster 16 replica start

Настроим синхронную репликацию на мастере. По умолчанию cинхронный режим включен, но записи о фиксации транзакций синхронизируются только с локальной файловой системой:

=> SHOW synchronous_commit;
 synchronous_commit
--------------------
 on
(1 row)

А синхронизация с репликой не настроена:

=> SHOW synchronous_standby_names;
 synchronous_standby_names
---------------------------
(1 row)

Реплик может быть несколько, и мастер должен знать, с какой из них синхронизироваться. Реплика представляется именем, заданным в ее параметре cluster_name:

student$ psql -p 5433
=> SHOW cluster_name;
 cluster_name
--------------
 16/replica
(1 row)
=> ALTER SYSTEM SET synchronous_standby_names = '"16/replica"';
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)
=> SELECT sync_state FROM pg_stat_replication;
 sync_state
------------
 sync
(1 row)

Репликация стала синхронной.

=> CREATE DATABASE replica_overview_physical;
CREATE DATABASE

Page 25: Page 25

=> \c replica_overview_physical
You are now connected to database "replica_overview_physical" as user "student".

Теперь остановим реплику…

student$ sudo pg_ctlcluster 16 replica stop

…и попробуем выполнить какую-либо транзакцию:

=> CREATE TABLE test(n integer);

Управление возвратится только когда реплика будет снова запущена и репликация восстановится:

student$ sudo pg_ctlcluster 16 replica start
CREATE TABLE

2. Конфликтующие записи

student$ psql -p 5433 -d replica_overview_physical

Отключаем откладывание применения конфликтующих записей:

=> ALTER SYSTEM SET max_standby_streaming_delay = 0;
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Добавляем строки в таблицу:

=> INSERT INTO test(n) SELECT id FROM generate_series(1,10) AS id;
INSERT 0 10

Выполняем на реплике долгий запрос…

=> SELECT pg_sleep(5), count(*) FROM test;

…а в это время на мастере удаляем строки из таблицы и выполняем очистку:

=> DELETE FROM test;
DELETE 10
=> VACUUM VERBOSE test;
INFO:  vacuuming "replica_overview_physical.public.test"
INFO:  table "test": truncated 1 to 0 pages
INFO:  finished vacuuming "replica_overview_physical.public.test": index scans: 0
pages: 1 removed, 0 remain, 1 scanned (100.00% of total)
tuples: 10 removed, 0 remain, 0 are dead but not yet removable
removable cutoff: 819, which was 1 XIDs old when operation ended
new relfrozenxid: 819, which is 3 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 1 pages from table (100.00% of total) had 10 dead item identifiers
removed
avg read rate: 0.000 MB/s, avg write rate: 6.562 MB/s
buffer usage: 10 hits, 0 misses, 4 dirtied
WAL usage: 6 records, 1 full page images, 8675 bytes
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

Очистка стерла все версии строк (10 removed). В итоге запрос на реплике завершается ошибкой:

ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.

Повторим эксперимент со включенной обратной связью.

=> ALTER SYSTEM SET hot_standby_feedback = on;
ALTER SYSTEM
=> SELECT pg_reload_conf();

Page 26: Page 26

 pg_reload_conf
----------------
 t
(1 row)
=> INSERT INTO test(n) SELECT id FROM generate_series(1,10) AS id;
INSERT 0 10
=> SELECT pg_sleep(5), count(*) FROM test;
=> DELETE FROM test;
DELETE 10
=> VACUUM VERBOSE test;
INFO:  vacuuming "replica_overview_physical.public.test"
INFO:  finished vacuuming "replica_overview_physical.public.test": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total)
tuples: 0 removed, 10 remain, 10 are dead but not yet removable
removable cutoff: 820, which was 2 XIDs old when operation ended
new relfrozenxid: 820, which is 1 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers
removed
avg read rate: 0.000 MB/s, avg write rate: 38.485 MB/s
buffer usage: 8 hits, 0 misses, 1 dirtied
WAL usage: 1 records, 0 full page images, 188 bytes
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

Теперь очистка не удаляет версии строк, поскольку знает о запросе, выполняющемся на реплике (10 are dead but not yet removable ) и запрос отрабатывает:

 pg_sleep | count
----------+-------
          |    10
(1 row)

Итак: В первом случае (max_standby_streaming_delay) откладывается воспроизведение журнальных записей на реплике. Во втором случае (hot_standby_feedback) откладывается очистка на мастере. Отключим синхронную репликацию.

=> ALTER SYSTEM RESET synchronous_standby_names;
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

19 Занятие 19: Репликация / Обзор логической репликации

Исходная страница в нативном Markdown: Репликация / Обзор логической репликации
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_19_replica_overview_logical.html Archive: DEV2-handouts-16.zip Страниц PDF: 26

Page 1: Репликация / Обзор логической репликации

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Логическая репликация
  • Публикации и подписчики
  • Уровни журнала
  • Обнаружение и разрешение конфликтов
  • Особенности работы триггеров
  • Задачи, решаемые с помощью логической репликации
  • Ограничения

Page 3: Логическая репликация

Механизм

  • публикующий сервер читает собственные журнальные записи,
  • декодирует их в изменения строк данных и отправляет подписчикам
  • сервер-подписчик применяет изменения к своим таблицам
  • Особенности
    • публикация-подписка: поток данных возможен в обе стороны
    • требуется совместимость на уровне протокола
    • возможна выборочная репликация отдельных таблиц

Комментарии преподавателя

При физической репликации только один сервер (мастер) выполняет изменения и генерирует журнальные записи. Остальные серверы (реплики) только читают журнальные записи мастера и применяют их.

При логической репликации все серверы работают в обычном режиме, могут изменять данные и генерировать собственные журнальные записи. Любой сервер может публиковать свои изменения, а другие — подписываться на них. При этом один и тот же сервер может как публиковать изменения, так и подписываться на другие. Это позволяет организовать произвольные потоки данных между серверами.

Публикующий сервер читает собственные журнальные записи, но не пересылает их подписчикам в исходном виде (как при физической репликации), а предварительно декодирует их в «логический», независимый от платформы и версии PostgreSQL вид. Поэтому для логической репликации обычно не нужна двоичная совместимость, реплика должна лишь понимать протокол репликации. Также можно получать и применять изменения не всех объектов, а только отдельных таблиц.

https://postgrespro.ru/docs/postgresql/16/logical-replication

Page 4: Логическая репликация

Source slide page 4: Логическая репликация{#source-figure-004 caption=“Source slide page 4: Логическая репликация”}

Комментарии преподавателя

На публикующем сервере процесс wal_sender формирует журнальные записи, отражающие изменения в публикуемых данных, а фоновый процесс logical replication worker на сервере-подписчике получает информацию от публикующего сервера и применяет ее.

Подписчик может получать изменения не только напрямую с публикующего сервера, но и с его физической реплики. В этом случае отправку изменений подписчику выполняет процесс wal_sender физической реплики. Такая схема работы полезна для уменьшения нагрузки на публикующий сервер.

https://postgrespro.ru/docs/postgresql/16/logical-replication-publication

https://postgrespro.ru/docs/postgresql/16/logical-replication-subscription

Page 5: Публикации и подписки

Публикация

  • включает одну или несколько таблиц базы данных
  • можно указать столбцы и фильтр для строк
  • обрабатывает команды INSERT, UPDATE, DELETE, TRUNCATE
  • выдает изменения построчно после фиксации транзакции
  • использует слот логической репликации
  • Подписка
    • получает и применяет изменения
    • возможна начальная синхронизация
    • без разбора, переписывания и планирования — сразу выполнение
    • возможны конфликты с локальными данными

Комментарии преподавателя

Логическая репликация использует модель «публикация-подписка».

На одном сервере создается публикация, которая может включать ряд таблиц одной базы данных. Начиная с версии 15 можно опубликовать только часть данных таблицы: указать набор столбцов и задать условие фильтрации для строк. Другие серверы могут подписываться на эту публикацию: получать и применять изменения.

Реплицируются только измененные строки таблиц (а не команды SQL). Команды DDL не передаются; таблицы-приемники на подписчике надо создавать вручную. Но есть возможность автоматической начальной синхронизации содержимого таблиц при создании подписки.

После фиксации транзакции информация о строках, которые она изменила, извлекается из записей WAL на сервере публикации — эта процедура называется логическим декодированием. Сформированные сообщения пересылаются подписке по протоколу репликации в формате, независимом от платформы и версии сервера.

Применение изменений происходит без выполнения команд SQL и связанных с этим накладных расходов на разбор и планирование. С другой стороны, результат выполнения одной команды SQL может превратиться в множество однострочных изменений.

https://postgrespro.ru/docs/postgresql/16/logical-replication

Page 6: Уровни журнала

Параметр wal\_level

  • minimal < replica < logical
  • восстановление восстановление восстановление после сбоя после сбоя после сбоя восстановление восстановление из резервной копии, из резервной копии, репликация репликация логическая репликация

Комментарии преподавателя

Чтобы публикующий сервер смог сформировать сообщения об изменениях на уровне табличных строк, в журнал необходимо помещать дополнительную информацию, в частности идентификаторы затрагиваемых при репликации строк и сообщения о фактах изменения определений таблиц и типов данных. Это позволяет подписчику в любой момент знать структуру объектов, участвующих в репликации.

Такой расширенный уровень журнала называется logical. Поскольку по умолчанию используется уровень replica, для логической репликации потребуется изменить значение параметра wal_level на публикующем сервере.

https://postgrespro.ru/docs/postgresql/16/protocol-logical-replication#PROT OCOL-LOGICAL-MESSAGES-FLOW

Page 7: Логическая репликация

Пусть на первом сервере имеется таблица:

=> CREATE DATABASE replica_overview_logical_dev;
CREATE DATABASE
=> \c replica_overview_logical_dev
You are now connected to database "replica_overview_logical_dev" as user "student".
=> CREATE TABLE test(id integer PRIMARY KEY, descr text);
CREATE TABLE

Склонируем кластер с помощью резервной копии, как мы делали в теме «Обзор физической репликации», но команде pg_basebackup не будем передавать ключ -R, поскольку нам потребуется независимый сервер, а не реплика.

student$ pg_basebackup --pgdata=/home/student/tmp/backup --checkpoint=fast

Если второй сервер работает, остановим его

student$ sudo pg_ctlcluster 16 replica stop
Cluster is not running.

Перемещаем резервную копию в каталог данных второго сервера, поменяв владельца файлов:

student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/tmp/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica

Запускаем второй сервер:

student$ sudo pg_ctlcluster 16 replica start

Получили два независимых сервера, на каждом из них есть пустая таблица test. Добавим в таблицу на первом сервере пару строк:

=> INSERT INTO test VALUES (1, 'Раз'), (2, 'Два');
INSERT 0 2

Теперь мы хотим настроить между серверами логическую репликацию. Для этого понадобится дополнительная информация в журнале первого сервера:

=> ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart

На первом сервере создаем публикацию:

student$ psql -d replica_overview_logical_dev
=> CREATE PUBLICATION test_pub FOR TABLE test;
CREATE PUBLICATION
=> \dRp+
                           Publication test_pub
  Owner  | All tables | Inserts | Updates | Deletes | Truncates | Via root
---------+------------+---------+---------+---------+-----------+----------
 student | f          | t       | t       | t       | t         | f
Tables:
    "public.test"

На втором сервере подписываемся на эту публикацию:

student$ psql -p 5433 -d replica_overview_logical_dev
=> CREATE SUBSCRIPTION test_sub
CONNECTION 'port=5432 user=student dbname=replica_overview_logical_dev'
PUBLICATION test_pub;

Page 8: Page 8

NOTICE:  created replication slot "test_sub" on publisher
CREATE SUBSCRIPTION

При создании подписки на сервере публикации автоматически создался слот репликации — объект, который помнит, какие изменения получил подписчик, и в случае разрыва соединения позволяет возобновить передачу с нужного места.

=> \dRs
           List of subscriptions
   Name   |  Owner  | Enabled | Publication
----------+---------+---------+-------------
 test_sub | student | t       | {test_pub}
(1 row)

Проверяем репликацию:

=> INSERT INTO test VALUES (3, 'Три');
INSERT 0 1
=> SELECT * FROM test;
 id | descr
----+-------
  1 | Раз
  2 | Два
  3 | Три
(3 rows)

Состояние подписки можно посмотреть в представлении:

=> SELECT * FROM pg_stat_subscription \gx
-[ RECORD 1 ]---------+------------------------------
subid                 | 24758
subname               | test_sub
pid                   | 48689
leader_pid            |
relid                 |
received_lsn          | 0/70074F0
last_msg_send_time    | 2026-04-10 16:19:23.81952+03
last_msg_receipt_time | 2026-04-10 16:19:23.820728+03
latest_end_lsn        | 0/70074F0
latest_end_time       | 2026-04-10 16:19:23.81952+03

К процессам сервера-подписчика добавился logical replication worker (его номер указан в pg_stat_subscription.pid):

student$ ps -o pid,command --ppid 48376
    PID COMMAND
  48377 postgres: 16/replica: checkpointer
  48378 postgres: 16/replica: background writer
  48381 postgres: 16/replica: walwriter
  48382 postgres: 16/replica: autovacuum launcher
  48383 postgres: 16/replica: logical replication launcher
  48655 postgres: 16/replica: student replica_overview_logical_dev [local] idle
  48689 postgres: 16/replica: logical replication apply worker for subscription 24758

Page 9: Конфликты

Режимы идентификации для изменения и удаления

  • столбцы первичного ключа (по умолчанию)
  • столбцы указанного уникального индекса с ограничением NOT NULL
  • все столбцы
  • без идентификации (по умолчанию для системного каталога)
  • Конфликты — нарушение ограничений целостности
    • репликация приостанавливается до устранения конфликта вручную

Комментарии преподавателя

Вставка новых строк происходит достаточно просто. Интереснее обстоит дело при изменениях и удалениях — в этом случае надо как-то идентифицировать старую версию строки. По умолчанию для этого используются столбцы первичного ключа, но при определении таблицы можно указать и другие способы (replica identity): использовать уникальный индекс или использовать все столбцы. Можно вообще отказаться от поддержки репликации для некоторых таблиц (по умолчанию не реплицируются таблицы системного каталога).

Поскольку таблицы на сервере публикации и сервере подписки могут изменяться независимо друг от друга, при вставке новых версий строк возможны конфликты — нарушения ограничений целостности. В этом случае процесс применения записей приостанавливается до тех пор, пока конфликт не будет разрешен вручную.

https://postgrespro.ru/docs/postgresql/16/sql-altertable

https://postgrespro.ru/docs/postgresql/16/logical-replication-conflicts

https://postgrespro.ru/docs/postgresql/16/sql-altersubscription

Page 10: Конфликты

Локальные изменения на подписчике не запрещаются, вставим строку в таблицу на втором сервере.

=> INSERT INTO test VALUES (4, 'Четыре (локально)');
INSERT 0 1

Если теперь строку с таким же значением первичного ключа вставить на первом сервере, при ее применении на стороне подписки произойдет конфликт.

=> INSERT INTO test VALUES (4, 'Четыре');
INSERT 0 1
=> INSERT INTO test VALUES (5, 'Пять');
INSERT 0 1

Подписка не может применить изменение, репликация остановилась.

=> SELECT * FROM pg_stat_subscription \gx
-[ RECORD 1 ]---------+---------
subid                 | 24758
subname               | test_sub
pid                   |
leader_pid            |
relid                 |
received_lsn          |
last_msg_send_time    |
last_msg_receipt_time |
latest_end_lsn        |
latest_end_time       |
=> SELECT * FROM test;
 id |       descr
----+-------------------
  1 | Раз
  2 | Два
  3 | Три
  4 | Четыре (локально)
(4 rows)

Чтобы разрешить конфликт, удалим строку на втором сервере и немного подождем…

=> DELETE FROM test WHERE id=4;
DELETE 1
=> SELECT * FROM test;
 id | descr
----+--------
  1 | Раз
  2 | Два
  3 | Три
  4 | Четыре
  5 | Пять
(5 rows)

Репликация возобновилась.

Page 11: Триггеры на подписчике

При обычной работе

  • в обслуживающих процессах (session_replication_role = origin или local)
  • ENABLE TRIGGER
  • ENABLE ALWAYS TRIGGER
  • При применении реплицированных изменений
    • в процессе logical replication worker (session_replication_role = replica)
    • ENABLE REPLICA TRIGGER
    • ENABLE ALWAYS TRIGGER
    • только на уровне строк (за исключением начальной синхронизации)

Комментарии преподавателя

Срабатывание триггеров на сервере-подписчике имеет некоторые особенности.

При применении изменений, полученных из публикации, обычные триггеры работать не будут. Это удобно, если на обоих серверах созданы одинаковые таблицы с одинаковым набором триггеров: в таком случае триггер уже отработал на публикующем сервере и его не надо выполнять на подписчике.

Если нужно, чтобы триггер все-таки срабатывал, его необходимо определить как репликационный (ALTER TABLE … ENABLE REPLICA TRIGGER) или универсальный (ENABLE ALWAYS TRIGGER).

Изменения реплицируются построчно, поэтому работают только триггеры таблиц на уровне строк (FOR EACH ROW), но не на уровне оператора (FOR EACH STATEMENT). Исключение составляет начальная синхронизация — при ней срабатывают триггеры и на уровне строк, и на уровне оператора.

При обычной работе срабатывают «обычные» триггеры (просто созданные командой CREATE TRIGGER, или явно разрешенные командой ALTER TABLE … ENABLE TRIGGER) и универсальные триггеры (ENABLE ALWAYS TRIGGER).

Сервер отличает обычный процесс от процесса, применяющего реплицированные изменения, с помощью параметра сеанса session_replication_role. По умолчанию параметр имеет значение origin, а процесс logical replication worker задает ему значение replica.

https://postgrespro.ru/docs/postgresql/16/sql-altertable

Page 12: Триггеры на подписчике

Source slide page 12: Триггеры на подписчике{#source-figure-012 caption=“Source slide page 12: Триггеры на подписчике”}

Попробуем создать триггер на сервере-подписчике.

=> CREATE FUNCTION change_descr() RETURNS trigger AS $$
BEGIN
  NEW.descr := NEW.descr || ' — триггер сработал';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION
=> CREATE TRIGGER test_before_row
BEFORE INSERT OR UPDATE ON test
FOR EACH ROW
EXECUTE FUNCTION change_descr();
CREATE TRIGGER

Добавляем строку на публикующем сервере:

=> INSERT INTO test VALUES (6, 'Шесть');
INSERT 0 1

Что получится?

=> SELECT * FROM test WHERE id = 6;
 id | descr
----+-------
  6 | Шесть
(1 row)

Обычный триггер не срабатывает при применении изменений из публикации, зато он будет срабатывать при локальных изменениях на подписчике:

=> INSERT INTO test VALUES (7, 'Семь');
INSERT 0 1
=> SELECT * FROM test WHERE id = 7;
 id |          descr
----+-------------------------
  7 | Семь — триггер сработал
(1 row)

Допустим, нам нужно, чтобы триггер срабатывал только для изменений, происходящих при репликации. Для этого явно включим его как репликационный:

=> ALTER TABLE test ENABLE REPLICA TRIGGER test_before_row;
ALTER TABLE
=> INSERT INTO test VALUES (8, 'Восемь');
INSERT 0 1
=> INSERT INTO test VALUES (9, 'Девять');
INSERT 0 1

Проверим:

=> SELECT * FROM test WHERE id >= 8;
 id |           descr
----+---------------------------
  8 | Восемь — триггер сработал
  9 | Девять
(2 rows)

Теперь триггер работает только при применении реплицированных изменений. Если необходимо добиться срабатывания триггера и при репликации, и при локальных изменениях, нужно определить его как ALWAYS TRIGGER. А чтобы различить эти изменения, используем параметр session_replication_role:

Page 13: Page 13

Source slide page 13: Page 13{#source-figure-013 caption=“Source slide page 13: Page 13”}

=> ALTER TABLE test ENABLE ALWAYS TRIGGER test_before_row;
ALTER TABLE
=> CREATE OR REPLACE FUNCTION change_descr() RETURNS trigger AS $$
BEGIN
  NEW.descr := NEW.descr || ' (' || current_setting('session_replication_role') || ')';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION

Вставим еще по одной строке на каждом сервере и проверим, что получилось:

=> INSERT INTO test VALUES (10, 'Десять');
INSERT 0 1
=> INSERT INTO test VALUES (11, 'Одиннадцать');
INSERT 0 1
=> SELECT * FROM test WHERE id >= 10;
 id |        descr
----+----------------------
 10 | Десять (replica)
 11 | Одиннадцать (origin)
(2 rows)

Заметим, что проверки внешних ключей в PostgreSQL реализованы посредством триггеров, поэтому присвоение параметру session_replication_role значения replica отключает эти проверки, а это может привести к нарушению согласованности данных.

Удаление подписки

Если репликация больше не нужна, надо аккуратно удалить подписку — иначе на публикующем сервере останется открытым репликационный слот.

=> DROP SUBSCRIPTION test_sub;
NOTICE:  dropped replication slot "test_sub" on publisher
DROP SUBSCRIPTION

Page 14: Задачи: консолидация

Source slide page 14: Задачи: консолидация{#source-figure-014 caption=“Source slide page 14: Задачи: консолидация”}

получение и консолидация данных нескольких филиалов

Комментарии преподавателя

Рассмотрим несколько задач, которые можно решить с помощью логической репликации.

Пусть имеются несколько региональных филиалов, каждый из которых работает на собственном сервере PostgreSQL. Задача состоит в консолидации части данных на центральном сервере.

Для решения задачи на региональных серверах создаются публикации необходимых данных. Центральный сервер подписывается на эти публикации. Полученные данные можно обрабатывать с помощью триггеров на стороне центрального сервера (например, приводя данные к единому виду).

С точки зрения бизнес-логики есть множество особенностей, требующих внимания. В каких-то случаях может быть проще периодически передавать данные в пакетном режиме.

На иллюстрации: на центральном сервере работают два процесса приема журналов, по одному на каждую подписку.

Page 15: Задачи: справочники

Source slide page 15: Задачи: справочники{#source-figure-015 caption=“Source slide page 15: Задачи: справочники”}

репликация справочников

Комментарии преподавателя

Другая задача: на центральном сервере поддерживаются справочники, актуальные версии которых должны быть доступны на региональных серверах.

В этом случае схему надо развернуть наоборот: центральный сервер публикует изменения, а региональные серверы подписываются на эти обновления.

Page 16: Задачи: мастер-мастер

Source slide page 16: Задачи: мастер-мастер{#source-figure-016 caption=“Source slide page 16: Задачи: мастер-мастер”}

кластер, в котором данные могут изменять несколько серверов

Комментарии преподавателя

Задача: обеспечить надежное хранение данных на нескольких серверах с возможностью записи на любом сервере (что полезно, например, для геораспределенной системы).

Для решения можно использовать двунаправленную репликацию, передавая изменения в одних и тех же таблицах от одного сервера к другому и обратно.

Двунаправленная репликация появилась в PostgreSQL 16. Система умеет различать источники репликации (origin), и при создании подписки можно указать, чтобы с публикующего сервера не отправлялись изменения, помеченные определенным источником.

Конечно, прикладная система должна быть построена таким образом, чтобы избегать конфликтов при изменении данных в одних и тех же таблицах. Например, использовать глобальные уникальные идентификаторы или гарантировать, что разные серверы работают с разными диапазонами ключей.

Надо учитывать, что система мастер-мастер, построенная на логической репликации, не обеспечивает сама по себе выполнение глобальных распределенных транзакций и, следовательно, о согласованности данных между серверами придется позаботиться отдельно.

Page 17: Ограничения

Не реплицируются

  • команды DDL
  • значения последовательностей
  • большие объекты (lo)
  • изменения материализованных представлений, внешних таблиц
  • Могут вызвать проблемы
    • массовые изменения данных
  • Не поддерживается
    • автоматическое разрешение конфликтов

Комментарии преподавателя

Логическая репликация имеет ряд ограничений.

Не реплицируются команды DDL — все изменения схемы данных надо переносить вручную.

Реплицироваться могут только обычные базовые таблицы и секционированные таблицы. То есть нельзя реплицировать отношения другого рода, такие как представления, материализованные представления и внешние таблицы.

Не реплицируются значения последовательностей. Это означает, что если сервер-подписчик добавляет строки в таблицу с суррогатным уникальным ключом, для которой настроена репликация, возможны конфликты. Конфликтов можно избежать, выделяя двум серверам разные диапазоны значений последовательностей, или используя вместо них универсальные уникальные идентификаторы (UUID).

Изменения реплицируются построчно, поэтому выполнение на публикаторе команды SQL, затрагивающей много строк, приведет к повышенной нагрузке на подписчик.

И, как уже говорилось, нет возможности автоматического разрешения конфликтов.

Эти ограничения уменьшают применимость логической репликации.

https://postgrespro.ru/docs/postgresql/16/logical-replication-restrictions

Page 18: Итоги

Логическая репликация передает изменения строк отдельных таблиц

  • разнонаправленная
  • совместимость на уровне протокола
  • Модель «публикация — подписка»
  • Следует учитывать имеющиеся ограничения

Page 19: Практика

Мы открываем второй магазин. Он работает независимо от основного (свои пользователи, склад, закупки и продажи), но имеет тот же ассортимент. 1. Разверните второй сервер из резервной копии, как показано в демонстрации. Очистите таблицу операций. 2. Организуйте репликацию справочников книг и авторов из основного магазина во второй. Предполагается, что эти таблицы могут меняться только на основном сервере.

  • Убедитесь, что изменения названий книг и т. п. передаются
  • на второй сервер, но изменения наличного количества
  • происходят на двух серверах независимо.

Page 20: 1. Развертывание второго магазина

Для развертывания сервера выполняем те же команды, что и в демонстрации:

student$ pg_basebackup --pgdata=/home/student/backup --checkpoint=fast
student$ sudo pg_ctlcluster 16 replica stop
student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica
student$ sudo pg_ctlcluster 16 replica start
=> ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM
=> \q
student$ sudo pg_ctlcluster 16 main restart
student$ psql -d bookstore2
student$ psql -p 5433 -d bookstore2

Очистим таблицу операций, поскольку второй магазин ведет свою деятельность независимо от основного:

=> TRUNCATE TABLE operations;
TRUNCATE TABLE

При выполнении команды TRUNCATE не сработает триггер update_onhand_qty_trigger, поэтому очистим наличное количество вручную:

=> SELECT book_id, onhand_qty FROM books LIMIT 5;
 book_id | onhand_qty
---------+------------
      32 |         48
      13 |         38
      58 |         44
      75 |         44
      17 |         34
(5 rows)
=> UPDATE books SET onhand_qty = 0;
UPDATE 100

2. Репликация справочников книг и авторов

Все три связанные таблицы должны входить в публикацию. Иначе на второй сервер реплицируются данные с нарушением внешних ключей:

=> CREATE PUBLICATION books_pub FOR TABLE books, authors, authorships;
CREATE PUBLICATION

Как только мы настроим репликацию справочников, на второй сервер в том числе будут передаваться и изменения наличного количества (books.onhand_qty). Очевидно, что это неудачное решение: такие изменения не нужны второму серверу, наличное количество вычисляется на нем независимо от основного сервера. Следовало бы поместить наличное количество в отдельную таблицу, изменив соответствующим образом триггер update_onhand_qty_trigger и интерфейсную функцию get_catalog. Другой способ — отменять нежелательные обновления с помощью триггера. Он не требует изменения остального кода системы, но не устраняет бесполезную пересылку данных по сети.

=> CREATE FUNCTION public.keep_onhand_qty() RETURNS trigger
AS $$
BEGIN
    NEW.onhand_qty := OLD.onhand_qty;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Page 21: Page 21

CREATE FUNCTION
=> CREATE TRIGGER keep_onhand_qty_trigger
BEFORE UPDATE ON public.books
FOR EACH ROW
WHEN (NEW.onhand_qty IS DISTINCT FROM OLD.onhand_qty)
EXECUTE FUNCTION public.keep_onhand_qty();
CREATE TRIGGER

Созданный триггер будет срабатывать только при репликации и не будет мешать обновлять наличное количество при нормальной работе:

=> ALTER TABLE books ENABLE REPLICA TRIGGER keep_onhand_qty_trigger;
ALTER TABLE

Создаем подписку. Поскольку текущее состояние справочников уже попало на второй сервер из резервной копии, отключаем начальную синхронизацию данных:

=> CREATE SUBSCRIPTION books_sub
CONNECTION 'host=localhost port=5432 user=postgres password=postgres dbname=bookstore2'
PUBLICATION books_pub WITH (copy_data = false);
NOTICE:  created replication slot "books_sub" on publisher
CREATE SUBSCRIPTION

Page 22: Практика+

  1. На двух серверах ведутся однотипные таблицы бухгалтерских транзакций. Требуется консолидировать все записи в ту же таблицу на одном из серверов (например, для целей построения общей отчетности). Предложите способ различить «свои» и «чужие» записи и предотвратить конфликты при логической репликации. Реализуйте предложенную схему. 2. Настройте логическую репликацию в обратном направлении. Проверьте работу двунаправленной репликации.

Комментарии преподавателя

  1. Исходите из следующего вида таблицы транзакций:
CREATE TABLE transactions (
  trx_id     bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  debit_acc  integer NOT NULL,
  credit_acc integer NOT NULL,
  amount     numeric(15,2) NOT NULL
);
  1. Чтобы публикация не отправляла реплицированные изменения, задайте подписке параметр origin = none.

Page 23: 1. Консолидация

На первом (публикующем) сервере установим необходимый уровень журнала:

=> ALTER SYSTEM SET wal_level = logical;
ALTER SYSTEM
student$ sudo pg_ctlcluster 16 main restart

Создадим таблицу транзакций:

student$ psql
=> CREATE DATABASE replica_overview_logical_dev;
CREATE DATABASE
=> \c replica_overview_logical_dev
You are now connected to database "replica_overview_logical_dev" as user "student".
=> CREATE TABLE transactions (
    trx_id     bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
    debit_acc  integer NOT NULL,
    credit_acc integer NOT NULL,
    amount     numeric(15,2) NOT NULL
);
CREATE TABLE

Чтобы отличать строки разных серверов, понадобится дополнительный столбец. Например:

=> ALTER TABLE transactions
    ADD server text NOT NULL DEFAULT current_setting('cluster_name');
ALTER TABLE

Чтобы не допустить конфликта значений первичного ключа, выделим каждому из серверов свое множество значений: первому серверу — нечетные, второму — четные. Имя последовательности, которая используется для генерации уникальных номеров, можно узнать с помощью функции:

=> SELECT pg_get_serial_sequence('transactions','trx_id');
     pg_get_serial_sequence
--------------------------------
 public.transactions_trx_id_seq
(1 row)
=> ALTER SEQUENCE transactions_trx_id_seq
    START WITH 1 INCREMENT BY 2;
ALTER SEQUENCE

Недостатком такой схемы является то, что при появлении третьего сервера ее придется перестраивать. Другой возможный вариант — использовать для первичного ключа универсальные уникальные идентификаторы, представленные типом данных uuid. Для этого можно использовать встроенную функцию get_random_uuid или средства из расширений pgcrypto и uuid-ossp:

=> SELECT gen_random_uuid();
           gen_random_uuid
--------------------------------------
 095380f6-8251-47ec-94a8-7b29d0584bbd
(1 row)

Однако тип uuid занимает 16 байт (bigint — только 8) и генерация нового значения происходит относительно долго. Еще один вариант — создать составной первичный ключ:

PRIMARY KEY (trx_id, server)

В этом случае конфликтов гарантированно не будет, но индекс получится больше. Мы используем первый вариант: последовательности, генерирующие разные значения.

Page 24: Page 24

Теперь развернем второй сервер из резервной копии, как показано в демонстрации.

student$ pg_basebackup --pgdata=/home/student/backup
student$ sudo pg_ctlcluster 16 replica stop
Cluster is not running.
student$ sudo rm -rf /var/lib/postgresql/16/replica
student$ sudo mv /home/student/backup /var/lib/postgresql/16/replica
student$ sudo chown -R postgres: /var/lib/postgresql/16/replica
student$ sudo pg_ctlcluster 16 replica start
student$ psql -p 5433 -d replica_overview_logical_dev

Заполним таблицу транзакций тестовыми данными — разными на разных серверах.

=> INSERT INTO transactions(debit_acc, credit_acc, amount)
    SELECT trunc(random()*3),     -- 0..2
           trunc(random()*3) + 3, -- 3..5
           random()*100_000
    FROM generate_series(1,10_000);
INSERT 0 10000
=> SELECT * FROM transactions LIMIT 5;
 trx_id | debit_acc | credit_acc |  amount  | server
--------+-----------+------------+----------+---------
      1 |         2 |          3 | 17415.03 | 16/main
      3 |         1 |          3 | 33835.53 | 16/main
      5 |         1 |          3 |  7237.65 | 16/main
      7 |         1 |          3 | 91030.49 | 16/main
      9 |         0 |          3 | 86661.64 | 16/main
(5 rows)

На втором сервере не забудем заменить настройки последовательности:

=> ALTER SEQUENCE transactions_trx_id_seq
    START WITH 2 INCREMENT BY 2 RESTART;
ALTER SEQUENCE
=> INSERT INTO transactions(debit_acc, credit_acc, amount)
    SELECT trunc(random()*3) + 3, -- 3..5
           trunc(random()*3),     -- 0..2
           random()*100_000
    FROM generate_series(1,10_000);
INSERT 0 10000
=> SELECT * FROM transactions LIMIT 5;
 trx_id | debit_acc | credit_acc |  amount  |   server
--------+-----------+------------+----------+------------
      2 |         4 |          0 | 40795.00 | 16/replica
      4 |         5 |          2 | 32412.30 | 16/replica
      6 |         3 |          0 | 24097.54 | 16/replica
      8 |         4 |          2 | 57402.43 | 16/replica
     10 |         4 |          1 | 56227.21 | 16/replica
(5 rows)

Настроим репликацию с первого сервера на второй.

=> CREATE PUBLICATION trx_pub FOR TABLE transactions;
CREATE PUBLICATION
=> CREATE SUBSCRIPTION trx_sub
CONNECTION 'dbname=replica_overview_logical_dev'
PUBLICATION trx_pub;
NOTICE:  created replication slot "trx_sub" on publisher
CREATE SUBSCRIPTION

После небольшой паузы убедимся, что данные первого сервера успешно реплицированы:

=> SELECT server, count(*) FROM transactions GROUP BY server;

Page 25: 2. Двунаправленная репликация

   server   | count
------------+-------
 16/main    | 10000
 16/replica | 10000
(2 rows)

Настройка уровня журнала уже скопирована на второй сервер:

=> SHOW wal_level;
 wal_level
-----------
 logical
(1 row)

Теперь очистим таблицы и сбросим последовательности

=> TRUNCATE TABLE transactions;
TRUNCATE TABLE
=> SELECT setval('transactions_trx_id_seq',1);
 setval
--------
      1
(1 row)
=> TRUNCATE TABLE transactions;
TRUNCATE TABLE
=> SELECT setval('transactions_trx_id_seq',2);
 setval
--------
      2
(1 row)

Чтобы публикация не отправляла подписке реплицированные изменения, нужно задать для подписки параметр origin:

=> ALTER SUBSCRIPTION trx_sub SET (origin = none);
ALTER SUBSCRIPTION

Теперь настроим репликацию со второго сервера на первый.

=> CREATE PUBLICATION trx_pub FOR TABLE transactions;
CREATE PUBLICATION
=> CREATE SUBSCRIPTION trx_sub
CONNECTION 'port=5433 dbname=replica_overview_logical_dev'
PUBLICATION trx_pub
WITH (origin = none, copy_data = false);
NOTICE:  created replication slot "trx_sub" on publisher
CREATE SUBSCRIPTION

Проверим:

=> INSERT INTO transactions(debit_acc, credit_acc, amount)
    SELECT trunc(random()*3),     -- 0..2
           trunc(random()*3) + 3, -- 3..5
           random()*100_000
    FROM generate_series(1,5000);
INSERT 0 5000
=> INSERT INTO transactions(debit_acc, credit_acc, amount)
    SELECT trunc(random()*3) + 3, -- 3..5
           trunc(random()*3),     -- 0..2
           random()*100_000
    FROM generate_series(1,5000);
INSERT 0 5000
=> SELECT server, count(*) FROM transactions GROUP BY server;

Page 26: Page 26

   server   | count
------------+-------
 16/main    |  5000
 16/replica |  5000
(2 rows)
=> SELECT server, count(*) FROM transactions GROUP BY server;
   server   | count
------------+-------
 16/main    |  5000
 16/replica |  5000
(2 rows)

Удалим треть строк на каждом сервере:

=> DELETE FROM transactions WHERE trx_id % 3 = 1;
DELETE 3333
=> DELETE FROM transactions WHERE trx_id % 3 = 2;
DELETE 3333
=> SELECT server, count(*) FROM transactions GROUP BY server;
   server   | count
------------+-------
 16/main    |  1667
 16/replica |  1667
(2 rows)
=> SELECT server, count(*) FROM transactions GROUP BY server;
   server   | count
------------+-------
 16/main    |  1667
 16/replica |  1667
(2 rows)

Репликация работает. Удалим подписки, чтобы не оставлять активные слоты репликации.

=> DROP SUBSCRIPTION trx_sub;
NOTICE:  dropped replication slot "trx_sub" on publisher
DROP SUBSCRIPTION
=> DROP SUBSCRIPTION trx_sub;
NOTICE:  dropped replication slot "trx_sub" on publisher
DROP SUBSCRIPTION

20 Занятие 20: Внешние данные / Обзор

Исходная страница в нативном Markdown: Внешние данные / Обзор
Примечание

Эта страница извлечена из официального PDF в нативный Markdown. Текст слайдов, код и комментарии сохранены в порядке чтения; исходные PDF и HTML остаются эталоном макета. HTML source: dev2_20_fdw_overview.html Archive: DEV2-handouts-16.zip Страниц PDF: 27

Page 1: Внешние данные / Обзор

Авторские права

© Postgres Professional, 2017–2024 Авторы: Егор Рогов, Павел Лузанов, Илья Баштанов, Игорь Гнатюк Фото: Олег Бартунов (монастырь Пху и пик Бхрикути, Непал)

Использование материалов курса

Некоммерческое использование материалов курса (презентации, демонстрации) разрешается без ограничений. Коммерческое использование возможно только с письменного разрешения компании Postgres Professional. Запрещается внесение изменений в материалы курса.

Обратная связь

Отзывы, замечания и предложения направляйте по адресу: edu@postgrespro.ru

Отказ от ответственности

Компания Postgres Professional не несет никакой ответственности за любые повреждения и убытки, включая потерю дохода, нанесенные прямым или непрямым, специальным или случайным использованием материалов курса. Компания Postgres Professional не предоставляет каких-либо гарантий на материалы курса. Материалы курса предоставляются на основе принципа «как есть» и компания Postgres Professional не обязана предоставлять сопровождение, поддержку, обновления, расширения и изменения.

Page 2: Темы

  • Назначение оберток сторонних данных
  • Настройка доступа к внешним данным
  • Примеры: postgres_fdw и file_fdw
  • Другие доступные обертки

Page 3: Назначение

Представление внешних данных как обычных таблиц

  • стандарт ISO/IEC 9075-9 (SQL/MED)
  • поддерживаются
  • SELECT, INSERT, UPDATE, DELETE, TRUNCATE, COPY
  • триггеры
  • обычное разграничение доступа (GRANT, REVOKE)
  • Способ миграции данных из других СУБД
  • Механизм для шардинга
    • вместе с секционированием: Postgres Pro Shardman

Комментарии преподавателя

В стандарте ISO/IEC 9075-9 (SQL/MED) определяется, как базы данных SQL должны работать с внешними источниками с учетом доступа как на чтение, так и на запись.

Внешние данные представлены в PostgreSQL как таблицы, которые называются внешними или сторонними. С ними можно работать при помощи обычных команд DML: INSERT, UPDATE, DELETE, SELECT, TRUNCATE, а также COPY. На внешние таблицы можно создавать триггеры, а для разграничения прав используются команды GRANT и REVOKE.

Основное отличие внешних таблиц от обычных в том, что данные не хранятся в самой БД PostgreSQL, а загружаются из внешней системы (отправляются во внешнюю систему) при выполнении запросов.

Обертки сторонних данных могут быть весьма полезны как при одновременной работе с данными из разных источников, так и при миграции данных в PostgreSQL из других СУБД.

Поддержка внешних данных активно развивается, поскольку (вместе с секционированием) является одним из компонентов для реализации шардинга: секции основной таблицы представляются внешними таблицами, физически находящимися на отдельных серверах.

https://postgrespro.ru/docs/postgresql/16/ddl-foreign-data

Данные из сторонних таблиц можно сохранять при логическом резервировании (используя ключ –include-foreign-data утилиты pg_dump), однако при восстановлении из такой копии нужно учитывать доступность внешних серверов и их возможности по записи данных.

https://postgrespro.ru/docs/postgresql/16/app-pgdump

Page 4: Компоненты

1. Обертка сторонних данных

  • каков тип внешнего источника данных?
  • postgres_fdw: внешний сервер PostgreSQL

Комментарии преподавателя

Чтобы настроить доступ к внешним данным, нужно создать несколько объектов базы данных.

Все начинается с обертки сторонних данных (foreign data wrapper, FDW). Обертка определяет тип внешнего источника данных. Например, postgres_fdw реализует доступ к базам данных PostgreSQL, а file_fdw — доступ к файлам операционной системы.

https://postgrespro.ru/docs/postgresql/16/postgres-fdw

https://postgrespro.ru/docs/postgresql/16/sql-createforeigndatawrapper

Page 5: Обертка сторонних данных

=> CREATE DATABASE fdw_overview;
CREATE DATABASE
=> \c fdw_overview
You are now connected to database "fdw_overview" as user "student".

Обертка создается при создании расширения:

=> CREATE EXTENSION postgres_fdw;
CREATE EXTENSION
=> \dew
                     List of foreign-data wrappers
     Name     |  Owner  |       Handler        |       Validator
--------------+---------+----------------------+------------------------
 postgres_fdw | student | postgres_fdw_handler | postgres_fdw_validator
(1 row)

Page 6: Компоненты

1. Обертка сторонних данных

  • каков тип внешнего источника данных?
    1. Внешний сервер
    • как подключиться к внешнему источнику?
    • postgres_fdw: узел, порт, база данных

Комментарии преподавателя

Следующий объект — внешний сервер. Сервер определяет, как подключаться к внешнему источнику данных.

Например, если источником является СУБД, то надо знать узел, порт, имя базы данных. (Но не имя пользователя и пароль. Эта информация появится чуть позже.)

Если требуется подключение к нескольким внешним источникам (одного типа, который определен оберткой), то для каждого из них нужно создать отдельный сервер.

https://postgrespro.ru/docs/postgresql/16/sql-createserver

Page 7: Внешний сервер

В качестве внешнего сервера выберем базу данных книжного магазина на нашем же локальном сервере.

=> CREATE SERVER remote_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (
    host 'localhost',
    port '5432',
    dbname 'bookstore2'
);
CREATE SERVER

Обратите внимание, что в параметрах сервера не указывается роль.

=> \x \des+ \x
Expanded display is on.
List of foreign servers
-[ RECORD 1 ]--------+-----------------------------------------------------
Name                 | remote_server
Owner                | student
Foreign-data wrapper | postgres_fdw
Access privileges    |
Type                 |
Version              |
FDW options          | (host 'localhost', port '5432', dbname 'bookstore2')
Description          |
Expanded display is off.

Page 8: Компоненты

1. Обертка сторонних данных

  • каков тип внешнего источника данных?
    1. Внешний сервер
    • как подключиться к внешнему источнику?
    1. Сопоставление ролей
    • как локальные роли связаны с разграничением доступа, используемым
    • внешним источником?
    • postgres_fdw: и там, и там — обычные роли PostgreSQL

Комментарии преподавателя

Далее следует сопоставление ролей.

Внешний источник может выполнять аутентификацию при подключении. В таких случаях необходимо определить, какие локальные роли могут подключаться к каким учетным записям на внешнем сервере.

Если используется аутентификация по паролю, то здесь же указывается и пароль.

Суперпользователь может разрешить обычным пользователям использовать подключение без пароля, установив параметр password_required в значение false.

https://postgrespro.ru/docs/postgresql/16/sql-createusermapping

Page 9: Сопоставление ролей

Настроим соответствие: пусть локальная роль student подключается к внешнему серверу как postgres.

=> CREATE USER MAPPING FOR student
SERVER remote_server
OPTIONS (
    user 'postgres',
    password 'postgres'
);
CREATE USER MAPPING
=> \deu+
                        List of user mappings
    Server     | User name |               FDW options
---------------+-----------+------------------------------------------
 remote_server | student   | ("user" 'postgres', password 'postgres')
(1 row)

Разумеется, можно настраивать несколько соответствий для разных ролей.

Page 10: Компоненты

1. Обертка сторонних данных

  • каков тип внешнего источника данных?
    1. Внешний сервер
    • как подключиться к внешнему источнику?
    1. Сопоставление ролей
  • как локальные роли связаны с ролями внешнего источника? 4. Внешние таблицы
    • какова структура внешних данных?
    • postgres_fdw: данные в обычных таблицах

Комментарии преподавателя

И, наконец, можно приступить к созданию внешних таблиц. Внешняя таблица определяет, как представить данные из внешнего источника в виде обычной реляционной таблицы.

Для внешних таблиц можно добавлять ограничения целостности NOT NULL и CHECK. Эти ограничения не будут применяться при выполнении команд, ведь в любом случае PostgreSQL не сможет обеспечить соблюдение ограничений на внешнем источнике. Но планировщик может использовать информацию об ограничениях при построении плана выполнения запроса.

Обертка postgres_fdw для выборки данных использует пакетный режим, записи передаются пакетами, размер которых задается параметром сервера fetch_size (по умолчанию 100 строк). Вставка (с помощью команд INSERT и COPY FROM) также использует пакетный режим, за количество строк здесь отвечает параметр сервера batch_size. Для изменения и удаления строк пакетный режим пока не реализован.

https://postgrespro.ru/docs/postgresql/16/sql-createforeigntable

https://postgrespro.ru/docs/postgresql/16/sql-importforeignschema

Page 11: Внешние таблицы

Внешнюю таблицу можно создать явным образом, при необходимости указывая названия объектов, если они отличаются:

=> CREATE FOREIGN TABLE remote_users (
    id integer OPTIONS (column_name 'user_id') NOT NULL,
    username text NOT NULL,
    email text NOT NULL
)
SERVER remote_server
OPTIONS (
    schema_name 'public',
    table_name 'users'
);
CREATE FOREIGN TABLE

Можно указать только некоторые ограничения целостности, но в любом случае они не проверяются локально и просто отражают ограничения, накладываемые внешней системой.

=> \det
        List of foreign tables
 Schema |    Table     |    Server
--------+--------------+---------------
 public | remote_users | remote_server
(1 row)
=> SELECT * FROM remote_users WHERE id = 1;
 id | username |      email
----+----------+-----------------
  1 | alice    | alice@localhost
(1 row)

Как выполняется такой запрос? Иными словами, передается ли предикат внешнему серверу, чтобы он сам выбрал эффективный способ выполнения, или фильтрацией занимается локальный сервер?

=> EXPLAIN (verbose, costs off)
    SELECT * FROM remote_users WHERE id = 1;
                                      QUERY PLAN
---------------------------------------------------------------------------------------
 Foreign Scan on public.remote_users
   Output: id, username, email
   Remote SQL: SELECT user_id, username, email FROM public.users WHERE ((user_id = 1))
(3 rows)

Планировщик умеет распределять работу между серверами. Также ничто не мешает использовать в одном запросе как внешние, так и локальные данные.

=> EXPLAIN (costs off)
    SELECT username FROM remote_users
    UNION ALL
    SELECT rolname FROM pg_roles;
                QUERY PLAN
------------------------------------------
 Append
   ->  Subquery Scan on "*SELECT* 1"
         ->  Foreign Scan on remote_users
   ->  Subquery Scan on "*SELECT* 2"
         ->  Seq Scan on pg_authid
(5 rows)

Другой способ — не создавать внешние таблицы по одной, а импортировать внешнюю схему (всю или выборочно несколько таблиц), если обертка это позволяет.

=> CREATE SCHEMA bookstore2;
CREATE SCHEMA

Page 12: Page 12

=> IMPORT FOREIGN SCHEMA public
    LIMIT TO (users, sessions)
    FROM SERVER remote_server
    INTO bookstore2;
IMPORT FOREIGN SCHEMA
=> SELECT * FROM bookstore2.users;
 user_id | username |      email
---------+----------+-----------------
       1 | alice    | alice@localhost
       2 | bob      | bob@localhost
(2 rows)

Обертка postgres_fdw позволяет и изменять данные:

=> EXPLAIN (analyze, verbose, costs off)
    UPDATE remote_users
    SET email = 'alice@gmail.com'
    WHERE id = 1;
                                            QUERY PLAN
------------------------------------------------------------------------------------------
---------
 Update on public.remote_users (actual time=0.978..0.979 rows=0 loops=1)
   ->  Foreign Update on public.remote_users (actual time=0.976..0.977 rows=1 loops=1)
         Remote SQL: UPDATE public.users SET email = 'alice@gmail.com'::text WHERE
((user_id = 1))
 Planning Time: 0.514 ms
 Execution Time: 1.793 ms
(5 rows)

Page 13: Соединения и транзакции

Удаленное соединение

  • открывается при первом обращении к внешним данным
  • по умолчанию остается открытым до конца сеанса
  • автоматически восстанавливается при разрыве
  • Удаленная транзакция
    • фиксируется или прерывается автоматически, когда фиксируется
    • или прерывается локальная транзакция
    • Repeatable Read для локальных транзакций уровней Read Committed
    • и Repeatable Read
    • Serializable для локальных транзакций Serializable
    • согласованность не гарантируется (нет двухфазной фиксации)

Комментарии преподавателя

При работе с другой базой данных через postgres_fdw важно понимать, как происходит управление соединениями и транзакциями.

По умолчанию обертка postgres_fdw автоматически открывает соединение при первом обращении к внешнему серверу (одно на каждую роль) и оставляет его открытым до конца локального сеанса. Если обертка обнаружит, что соединение разорвано, она автоматически попытается установить новое подключение.

Удаленные транзакции «привязаны» к локальным:

● расширение автоматически начинает удаленную транзакцию, когда локальная обращается к внешним данным;

● удаленная транзакция автоматически завершается с завершением локальной (фиксацией или обрывом).

Тем не менее, локальная и удаленная транзакции — это разные транзакции, а не одна глобальная. Расширение postgres_fdw пока не позволяет использовать двухфазную фиксацию, поэтому согласованность не гарантируется. Например, локальная транзакция может быть зафиксирована, а удаленная — нет из-за сетевого сбоя.

При фиксации транзакции, в которой участвуют несколько внешних серверов, подключенных через postgres_fdw, удаленные транзакции могут завершаться асинхронно, а не последовательно одна за другой.

https://postgrespro.ru/docs/postgresql/16/postgres-fdw#POSTGRES-FDW-C ONNECTION-MANAGEMENT

Page 14: Соединения и транзакции

Когда выполнялся запрос к внешней таблице remote_users, обертка открыла соединение и по умолчанию не закрывает его:

=> SELECT datname, pid FROM pg_stat_activity
WHERE application_name = 'postgres_fdw';
  datname   |  pid
------------+-------
 bookstore2 | 50818
(1 row)

Завершим обслуживающий процесс…

=> SELECT pg_terminate_backend(50818);
 pg_terminate_backend
----------------------
 t
(1 row)

…и еще раз обратимся к внешней таблице:

=> SELECT * FROM remote_users;
 id | username |      email
----+----------+-----------------
  2 | bob      | bob@localhost
  1 | alice    | alice@gmail.com
(2 rows)

Нам удалось получить данные! Обертка автоматически восстановила подключение, соединение теперь обслуживается другим процессом:

=> SELECT datname, pid FROM pg_stat_activity
WHERE application_name = 'postgres_fdw';
  datname   |  pid
------------+-------
 bookstore2 | 51260
(1 row)

Поведение можно изменить, задав атрибут внешнего сервера

keep_connections 'off'

Тогда обертка будет открывать соединение при каждом обращении к внешней таблице и закрывать его после обращения.

Page 15: Сравнение с dblink

postgres_fdw dblink

  • доступные команды SQL SELECT, INSERT, любые UPDATE, DELETE, TRUNCATE, COPY
  • совмещение в запросе да сложно локальных и внешних данных
  • управление соединением автоматическое ручное
  • управление транзакциями автоматическое ручное
  • глобальные транзакции нет нет
  • стандартизованный способ да нет

Комментарии преподавателя

В теме «Фоновые процессы» рассматривалось расширение dblink, которое предназначено для выполнения запросов на удаленном сервере PostgreSQL и предоставляет возможности, схожие с возможностями postgres_fdw.

Обертка postgres_fdw позволяет выполнять ограниченный набор команд SQL, но в большинстве случаев более удобна: она сама управляет соединениями и транзакциями («привязывая» их к локальному соединению и транзакции). Обертка позволяет использовать в одном запросе как локальные, так и внешние данные; при этом автоматически формируется распределенный план выполнения запроса. Синтаксис доступа к данным определяется стандартом SQL.

Зато расширение dblink позволяет выполнять любые команды SQL. В некоторых случаях может быть нужным управлять соединениями и транзакциями вручную.

Однако оба способа не предоставляют глобальных (распределенных) транзакций.

Таким образом, для доступа к внешним данным PostgreSQL в большинстве случаев следует выбирать postgres_fdw. Расширение dblink стоит использовать, если нужны возможности, которые не предоставляет обертка сторонних данных.

Page 16: Расширение file_fdw

1. Обертка сторонних данных

  • тип внешнего источника — текстовый файл с разделителями 2. Внешний сервер
    • как подключиться — не требуется дополнительной информации
    1. Сопоставление ролей
    • не имеет смысла, т. к. во внешнем источнике нет ролей
    1. Внешние таблицы
    • структура данных — описание полей файла в виде столбцов таблицы

Комментарии преподавателя

Еще одну обертку сторонних данных предоставляет расширение file_fdw, предназначенное для доступа к файлам в файловой системе сервера PostgreSQL.

Доступ возможен только на чтение и выполняется с помощью того же механизма, что и SQL-команда COPY.

https://postgrespro.ru/docs/postgresql/16/file-fdw

Page 17: Расширение file_fdw

Расширение file_fdw позволяет обращаться к любому текстовому файлу и задействует тот же механизм, что и команда COPY. Поэтому для демонстрации мы выгрузим строки из имеющейся таблицы в файл, а затем прочитаем данные из файла.

=> COPY (SELECT * FROM remote_users)
TO '/var/lib/postgresql/16/users.txt'
WITH (
    format 'text',
    delimiter '/'
);
COPY 2
student$ cat /var/lib/postgresql/16/users.txt
2/bob/bob@localhost
1/alice/alice@gmail.com
=> CREATE EXTENSION file_fdw;
CREATE EXTENSION
=> CREATE SERVER file_server
    FOREIGN DATA WRAPPER file_fdw;
CREATE SERVER
=> CREATE FOREIGN TABLE file_users (
    id integer,
    username text,
    email text
)
SERVER file_server
OPTIONS (
    filename '/var/lib/postgresql/16/users.txt',
    format 'text',
    delimiter '/'
);
CREATE FOREIGN TABLE
=> SELECT * FROM file_users;
 id | username |      email
----+----------+-----------------
  2 | bob      | bob@localhost
  1 | alice    | alice@gmail.com
(2 rows)

Page 18: Другие доступные обертки

Базы данных

  • ODBC, JDBC
  • различные реляционные и NoSQL СУБД
  • Файлы различных форматов
  • Геоданные и научные данные
  • Веб-ресурсы
    • стандартные протоколы (RSS, IMAP, WWW…)
    • для различных сайтов
  • Можно написать собственную обертку
    • на C или Python (с помощью расширения Multicorn)

Комментарии преподавателя

Рассмотренные обертки сторонних данных, file_fdw и postgres_fdw, поставляются вместе с PostgreSQL в виде contrib-модулей.

Помимо этого существует множество других оберток, которые предоставляют доступ ко многим популярным базам данных и не только. Информацию о них можно получить:

● на страничке wiki: https://wiki.postgresql.org/wiki/Fdw

● на сайте PGXN: http://pgxn.org/tag/fdw/

Если нужную обертку не удалось найти, то ее можно написать самостоятельно. Для этого потребуется язык C: https://postgrespro.ru/docs/postgresql/16/fdwhandler

Другой вариант — воспользоваться расширением Multicorn (https://multicorn.org/), которое позволяет написать обертку на языке Python.

Page 19: Итоги

Обертки сторонних данных — стандартный способ работы с данными любых внешних источников

  • доступ к PostgreSQL и файлам «из коробки»
  • есть реализации оберток для огромного числа систем
  • Альтернатива — расширение dblink для выполнения произвольных запросов на удаленном сервере PostgreSQL

Page 20: Практика

  1. Поставщик информирует магазин о новинках книжного рынка, предоставляя CSV-файл определенного формата. Загрузите информацию из файла new_books.csv, расположенного в домашнем каталоге пользователя student, в таблицы базы данных. Формат файла описан в комментариях к слайду.
  • Учтите, что авторы, указанные в файле, могут уже существовать в базе данных, но в файле могут быть допущены опечатки. 2. Проверьте в приложении, что новые книги успешно переданы на логическую реплику, и для них работает полнотекстовый поиск.

Комментарии преподавателя

  1. CSV-файл содержит следующие поля:

● фамилия автора;

● имя автора;

● отчество автора;

● название книги;

● формат издания;

● количество страниц;

● ISBN;

● аннотация;

● издательство;

● год выпуска;

● гарнитура;

● номер издания;

● серия;

● имя файла с обложкой (файл находится в каталоге covers).

Если у книги несколько авторов, то они следуют в файле в том порядке, в котором указаны в выходных сведениях. При этом для второго и следующих авторов все поля, относящиеся к книге — пустые.

Воспользуйтесь расширением file_fdw, указав для файла формат CSV.

Для борьбы с опечатками вспомните про расширение pg_trgm, с которым вы познакомились в практике к теме «Классы операторов».

  1. Логическая реплика с подпиской на изменения книг настраивалась в задании к теме «Логическая репликация».

Page 21: 1. Загрузка новых поступлений

=> CREATE EXTENSION file_fdw;
CREATE EXTENSION
=> CREATE SERVER file_server
    FOREIGN DATA WRAPPER file_fdw;
CREATE SERVER
=> CREATE FOREIGN TABLE new_books (
    last_name text,
    first_name text,
    middle_name text,
    title text,
    format text,
    pages integer,
    isbn text,
    abstract text,
    publisher text,
    year text,
    typeface text,
    edition integer,
    series text,
    cover_filename text
)
SERVER file_server
OPTIONS (
    filename '/home/student/new_books.csv',
    format 'csv'
);
CREATE FOREIGN TABLE

Здесь предполагается, что несколько процедур загрузки книг не будут запускаться параллельно. Если такой гарантии нет, в коде надо отдельно предусмотреть эту возможность, устанавливая необходимые блокировки. Для сопоставления авторов используется нечеткий поиск с помощью триграмм. Оператор % отсекает совсем непохожих (предел похожести по умолчанию 0.3 слишком низок для нашего случая, поэтому увеличиваем его). Из оставшихся выбираем лучшее совпадение, упорядочивая кандидатов с помощью функции similarity.

=> CREATE EXTENSION pg_trgm;
CREATE EXTENSION
=> SELECT set_limit(0.7); -- предел похожести для оператора %
 set_limit
-----------
       0.7
(1 row)

Page 22: Page 22

=> DO $$
DECLARE
    i new_books;
    book_id bigint;
    author_id bigint;
    author_cause text;
    seq_num integer;
BEGIN
    FOR i IN SELECT * FROM new_books
    LOOP
        IF i.title IS NOT NULL THEN
            INSERT INTO books AS b(
                title, format, pages, additional, cover
            ) VALUES (
                i.title,
                i.format::text::book_format,
                i.pages,
                jsonb_build_object(
                    'ISBN',         i.isbn,
                    'Аннотация',    i.abstract,
                    'Издательство', i.publisher,
                    'Год выпуска',  i.year,
                    'Гарнитура',    i.typeface,
                    'Издание',      i.edition,
                    'Серия',        i.series
                ),
                pg_read_binary_file(
                    '/home/student/covers/'||i.cover_filename
                )
            )
            RETURNING b.book_id INTO book_id;
            seq_num := 1;
            RAISE NOTICE 'Книга: % (%)', i.title, book_id;
        END IF;
        -- есть ли точное совпадение с имеющимся автором?
        SELECT a.author_id, 'точное совпадение'
        INTO author_id, author_cause
        FROM authors a
        WHERE a.last_name   = i.last_name
          AND a.first_name  = i.first_name
          AND a.middle_name = i.middle_name;
        -- если нет, то может найдется очень похожий?
        IF author_id IS NULL THEN
            SELECT a.author_id, 'НЕточное совпадение'
            INTO author_id, author_cause
            FROM authors a
            WHERE (a.last_name || a.first_name || a.middle_name) %
                  (i.last_name || i.first_name || i.middle_name)
            ORDER BY similarity(
                  (a.last_name || a.first_name || a.middle_name),
                  (i.last_name || i.first_name || i.middle_name)
            ) DESC
            LIMIT 1;
        END IF;
        -- если и похожего нет, то считаем автора новым
        IF author_id IS NULL THEN
            INSERT INTO authors AS a(first_name,last_name,middle_name)
                VALUES (i.first_name, i.last_name, i.middle_name)
                RETURNING a.author_id, 'новый'
INTO author_id, author_cause;
        END IF;
        RAISE NOTICE '  Автор: %, % (%)',
            i.last_name, author_cause, author_id;
        INSERT INTO authorships(book_id, author_id, seq_num)
            VALUES (book_id, author_id, seq_num);
        seq_num := seq_num + 1;
    END LOOP;
END;
$$;

Page 23: Page 23

NOTICE:  Книга: PostgreSQL 16 изнутри (101)
NOTICE:    Автор: Рогов, новый (162)
NOTICE:  Книга: PostgreSQL 15 изнутри (102)
NOTICE:    Автор: Рогов, точное совпадение (162)
NOTICE:  Книга: Путеводитель по базам данных (103)
NOTICE:    Автор: Комаров, новый (163)
NOTICE:  Книга: PostGIS в действии (104)
NOTICE:    Автор: Обе, новый (164)
NOTICE:    Автор: Хсу, новый (165)
NOTICE:  Книга: Жемчужины разработки. Чему мы
научились за 50 лет создания ПО (105)
NOTICE:    Автор: Вигерс, новый (166)
NOTICE:  Книга: Мониторинг PostgreSQL (106)
NOTICE:    Автор: Лесовский, новый (167)
NOTICE:  Книга: Думай о смысле. Будни переводчика
IT-текстов (107)
NOTICE:    Автор: Чаплыгин, новый (168)
NOTICE:  Книга: Идеи, определившие облик информатики
(108)
NOTICE:    Автор: Льюис, новый (169)
DO
=> DROP FOREIGN TABLE new_books;
DROP FOREIGN TABLE

Page 24: Практика+

  1. В двух разных базах данных находятся две таблицы с одинаковым набором столбцов. Выведите строки, которые присутствуют в первой таблице, но отсутствуют во второй. Решите задачу с помощью postgres_fdw и с помощью dblink и сравните два способа. 2. Как можно проверить, какой уровень изоляции использует обертка postgres_fdw и как она управляет соединениями и транзакциями?

Комментарии преподавателя

  1. Расширение dblink демонстрировалось в теме «Фоновые процессы».

  2. Настройте на втором сервере журнал сообщений таким образом, чтобы в него записывалась информация:

● о подключениях (log_connections);

● отключениях (log_disconnections);

● выполняемых командах (log_statement).

Обратитесь к таблице на втором сервере с помощью postgres_fdw и проверьте, что попало в журнал сообщений.

Page 25: 1. Сравнение таблиц в разных базах

Таблица в первой базе данных:

=> CREATE DATABASE fdw_overview;
CREATE DATABASE
=> \c fdw_overview
You are now connected to database "fdw_overview" as user "student".
=> CREATE TABLE test (
    s text
);
CREATE TABLE
=> INSERT INTO test VALUES ('foo'),('bar'),('baz');
INSERT 0 3

И во второй (расположим ее на втором сервере в БД postgres):

student$ psql -p 5433 -U postgres -d postgres
=> CREATE TABLE test (
    s text
);
CREATE TABLE
=> INSERT INTO test VALUES ('foo'),('bar');
INSERT 0 2

Создаем стороннюю таблицу для доступа к таблице во второй базе данных:

=> CREATE EXTENSION postgres_fdw;
CREATE EXTENSION
=> CREATE SERVER remote_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (
    host 'localhost',
    port '5433',
    dbname 'postgres'
);
CREATE SERVER

На втором сервере используем роль postgres:

=> CREATE USER MAPPING FOR student
SERVER remote_server
OPTIONS (
    user 'postgres'  -- пароль не нужен, аутентификация trust
);
CREATE USER MAPPING
=> CREATE FOREIGN TABLE test2 (
    s text
)
SERVER remote_server
OPTIONS (
    schema_name 'public',
    table_name 'test'
);
CREATE FOREIGN TABLE

Сравниваем:

=> SELECT * FROM test
EXCEPT
SELECT * FROM test2;

Page 26: Page 26

  s
-----
 baz
(1 row)

С помощью расширения dblink задачу можно решить следующим образом:

=> CREATE EXTENSION dblink;
CREATE EXTENSION
=> SELECT * FROM test
EXCEPT
SELECT * FROM dblink(
    'host=localhost port=5433 dbname=postgres user=postgres',
    'SELECT * FROM test'
) AS (s text);
  s
-----
 baz
(1 row)

В этом случае подготовительные действия практически не требуются, но сам запрос выглядит сложнее.

2. Проверка работы postgres\_fdw

На втором сервере настроим журнал сообщений так, чтобы в него попадала информация о подключениях и отключениях, а также о выполняемых командах.

=> ALTER SYSTEM SET log_connections = on;
ALTER SYSTEM
=> ALTER SYSTEM SET log_disconnections = on;
ALTER SYSTEM
=> ALTER SYSTEM SET log_statement = 'all';
ALTER SYSTEM
=> SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

Начнем новое соединение и локальную транзакцию Read Committed.

=> \q
student$ psql fdw_overview
=> BEGIN;
BEGIN

Обращаемся к сторонней таблице:

=> SELECT * FROM test2;
  s
-----
 foo
 bar
(2 rows)

В журнале второго сервера видим: установлено новое соединение; в нем выставлены некоторые параметры; начата транзакция с уровнем изоляции Repeatable Read; с помощью курсора прочитаны записи из таблицы test.

student$ sudo tail -n 12 /var/log/postgresql/postgresql-16-replica.log
2026-04-10 16:46:28.865 MSK [113360] [unknown]@[unknown] LOG:  connection received:

Page 27: Page 27

host=127.0.0.1 port=39468
2026-04-10 16:46:28.876 MSK [113360] postgres@postgres LOG:  connection authorized:
user=postgres database=postgres application_name=postgres_fdw SSL enabled
(protocol=TLSv1.3, cipher=TLS_AES_256_GCM_SHA384, bits=256)
2026-04-10 16:46:28.878 MSK [113360] postgres@postgres LOG:  statement: SET search_path =
pg_catalog
2026-04-10 16:46:28.879 MSK [113360] postgres@postgres LOG:  statement: SET timezone =
'GMT'
2026-04-10 16:46:28.879 MSK [113360] postgres@postgres LOG:  statement: SET datestyle =
ISO
2026-04-10 16:46:28.879 MSK [113360] postgres@postgres LOG:  statement: SET intervalstyle
= postgres
2026-04-10 16:46:28.879 MSK [113360] postgres@postgres LOG:  statement: SET
extra_float_digits = 3
2026-04-10 16:46:28.879 MSK [113360] postgres@postgres LOG:  statement: START TRANSACTION
ISOLATION LEVEL REPEATABLE READ
2026-04-10 16:46:28.880 MSK [113360] postgres@postgres LOG:  execute <unnamed>: DECLARE
c1 CURSOR FOR
SELECT s FROM public.test
2026-04-10 16:46:28.881 MSK [113360] postgres@postgres LOG:  statement: FETCH 100 FROM c1
2026-04-10 16:46:28.881 MSK [113360] postgres@postgres LOG:  statement: CLOSE c1

Завершаем локальную транзакцию.

=> COMMIT;
COMMIT

В журнале видим, что удаленная транзакция также завершена:

student$ sudo tail -n 1 /var/log/postgresql/postgresql-16-replica.log
2026-04-10 16:46:29.338 MSK [113360] postgres@postgres LOG:  statement: COMMIT TRANSACTION

Завершаем сеанс.

=> \q

В этот момент завершается и соединение с удаленным узлом:

student$ sudo tail -n 2 /var/log/postgresql/postgresql-16-replica.log
2026-04-10 16:46:29.536 MSK [113360] postgres@postgres LOG:  could not receive data from
client: Connection reset by peer
2026-04-10 16:46:29.536 MSK [113360] postgres@postgres LOG:  disconnection: session time:
0:00:00.671 user=postgres database=postgres host=127.0.0.1 port=39468

Журнал сообщений можно использовать, чтобы разобраться, как работа внешних средств выглядит с точки зрения СУБД.