PostgreSQL: Серверное программирование на «человеческом» языке (PL-Perl, PL-Python, PL-v8)

в 6:07, , рубрики: postgresql, Блог компании Postgres Professional

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

Можно и иногда нужно писать PL-языки под задачи. А еще лучше, если кто-то напишет такой фреймворк для написания языков, чтобы можно было писать не на C, а выбрать более комфортный для разработчика языков язык. Как с FDW, которые можно писать на Python.

Эта статья написана на основе ряда докладов и мастер-классов по этой теме, делавшихся автором на конференциях PgConf.Russia 2019, PgConf.Russia 2018 и DevConf 2017.

Речь пойдет не об экзотике, а о самых распространённых процедурных языках PL/Perl, PL/Python и PL/V8 (то есть JavaScript) и сравнении их возможностей с PL/pgSQL.

Когда такие языки стоит применять? Когда не хватает SQL и PL/pgSQL?

  • Тогда, когда нужно работать со сложными структурами, с алгоритмами: обходом деревьев, например, или когда требуется парсинг HTML или XML, тем более их извлечение из архивов;
  • Когда нужно динамически формировать сложные SQL (отчеты, ORM). На PL/pgSQL не только неудобно, но и будет в некоторых случаях медленнее работать;
  • Если у вас уже много написанных полезных библиотек на Perl или Python, а также если надо использовать библиотеки на C/C++, к которым есть обёртка на Perl или Python или её легко написать. Через хранимые процедуры удобно получать доступ к этим библиотекам. Допустим, вы мигрируете с Oracle. Там есть функция отправки письма, а в самом Postgres такой функции нет. Но в библиотеках Perl и Python их сотни.
  • Еще одна причина — работа с внешними данными. Но это относится, конечно, только к untrusted-языкам (что это — см. ниже), то есть к Perlu и Python(3)u, а не к PL/V8. В обычном Postgres для того, чтобы обратиться к внешним данным, обычно используют FDW, но это специфический интерфейс, он не всегда удобен и не для всего годится. В процедурных языках руки у вас развязаны. Полная свобода!
  • И еще: если вы собрались написать нечто на C, то можно сделать прототип на этих, более приспособленных к быстрой разработке, языках.

Как встроить язык в Postgres

Для реализации языка надо: написать на C от одной до трёх функций:

  • HANDLER — обработчик вызова, который будет исполнять функцию на языке (это обязательная часть);
  • INLINE — обработчик анонимных блоков (если вы хотите, чтобы язык поддерживал анонимные блоки);
  • VALIDATOR — функцию проверки кода при создании функции (если вы хотите, чтобы такая проверка делалась).

Об этом подробно написано в документации здесь и здесь.

«Языки из коробки» и другие языки

Языков, которые поддерживаются «из коробки», всего четыре: PL/pgSQL, PL/Perl, PL/Python и PL/Tcl, но тикль это скорее дань истории: им сейчас мало кто пользуется, больше говорить о нём не будем.
PL/Perl, PL/Python и, конечно, PL/pgSQL поддерживаются Postgres-сообществом. Поддержка других, внекоробочных, языков ложится на их мейнтейнеров — компании, сообщества, или конкретных разработчиков, заинтересованных в том, чтобы язык работал внутри СУБД. PL/V8 продвигает Google. Но время от времени возникают основания сомневаться в безоблачном будущем PL/V8. Нынешний мейнтейнер проекта PL/V8 от Google, Джерри Сиверт (Jerry Sievert) подумывает о том, чтобы поддержка серверного JS в Postgres базировалась на другом движке (например, QuickJS), так как PL/V8 сложно собирать, он требует выкачать 3-5 ГБ всякой всячины на Linux при сборке, и это часто приводит к проблемам на разных ОС. Но PL/V8 широко используется и насквозь оттестирован. Не исключено, что появится PL/JS как альтернатива с другим движком JS, или пока просто как название, к которому будем привыкать в переходный период.

PL/Java используется достаточно редко. У меня лично не возникало потребности писать на PL/Java потому, что в PL/Perl и в PL/V8 достаточно функциональности практически для всех задач. Даже Python особенно не прибавляет возможностей. PL/R полезен для тех, кто занимается статистикой и любит этот язык. О нем мы здесь говорить тоже не будем.

Популярные языки не обязательно популярны у пишущих хранимки: PL/PHP есть, но сейчас практически никем не поддерживается — писать на нём серверные процедуры желающих мало. С языком PL/Ruby почему-то та же картина, хотя язык, казалось бы, более современный.

Процедурный язык на базе Go развивается, см. PL/Go, а также, похоже PL/Lua. Надо будет поизучать их. Для упоротых фанатов шелла есть даже PL/Sh, трудно даже представить, для чего он может понадобиться.

Есть как минимум один доменно-специфический процедурный язык (DSL), узко специализированный под свою задачу — PL/Proxy, который раньше был очень популярен для проксирования и распределения нагрузки на серверы.

В этой статье мы рассмотрим основные, наиболее часто используемые языки. Это, конечно, PL/PgSQL, PL/Perl, PL/Python и PL/V8, ниже будем называть их PL/*.

Языки «из коробки» действительно почти буквально устанавливаются из коробки — обычно инсталляция проходит безболезненно. А вот установить PL/V8, если Вы не нашли в репозитории своей ОС пакета с нужной версией, это почти подвиг, потому что для этого придется фактически собрать весь V8, или, другими словами, Chromium. При этом с сайта google.com вместе с самим V8 будет выкачана вся инфраструктура разработки — рассчитывайте на пару гигабайт трафика. Для Postgres 11 под Ubuntu пакет PL/V8 до сих пор ещё не появился, в репозитории пока доступна только V8 для PG 10. Если хотите, собирайте руками.

После того, как сам язык установлен, надо еще и «создать» язык — зарегистрировать его в системном каталоге. Это следует делать командой

CREATE EXTENSION plperl;

(вместо plperl можно подставить название другого языка, есть определенные нюансы, см. ниже).
Смотрим, что получилось:

test_langs=# x
test_langs=# dL+
List of languages
-[ RECORD 1 ]-----+---------------------------------
Name              | plperl
Owner             | postgres
Trusted           | t
Internal language | f
Call handler      | plperl_call_handler()
Validator         | plperl_validator(oid)
Inline handler    | plperl_inline_handler(internal)
Access privileges |
Description       | PL/Perl procedural language
-[ RECORD 2 ]-----+---------------------------------
Name              | plpgsql
Owner             | postgres
Trusted           | t
Internal language | f
Call handler      | plpgsql_call_handler()
Validator         | plpgsql_validator(oid)
Inline handler    | plpgsql_inline_handler(internal)
Access privileges |
Description       | PL/pgSQL procedural language
[ RECORD 3 ]-----+---------------------------------
Name              | plv8
Owner             | postgres
Trusted           | t
Internal language | f
Call handler      | plv8_call_handler()
Validator         | plv8_call_validator(oid)
Inline handler    | plv8_inline_handler(internal)
Access privileges |
Description       |

PL/pgSQL специально создавать не надо, он всегда уже имеется в базе.

Внимание! PL/pgSQL не надо путать с SQL. Это другой язык. Впрочем, на обычном SQL в Postgres тоже можно писать функции.

Стандарты

В мире СУБД часто говорят о соответствии стандартам SQL. В процедурных языках тоже есть стандарты, хотя говорят о них не так уж часто. Стандарту SQL/PSM в высокой степени соответствует процедурный язык DB2. Реализация его далека от PL/pgSQL, хотя концептуально они близки.

SQL/JRT – стандарт для Java-процедур, и PL/Java ему неплохо соответствует.

Доверенные и недоверенные языки

Процедурные языки в Postgres бывают доверенными (TRUSTED) и недоверенными (UNTRUSTED).
В TRUSTED-языках отсутствует возможность прямой работы с I/O, в том числе с сетью, да и вообще с системными ресурсами. Поэтому такие функции может создавать любой пользователь БД, испортить что-либо и узнать лишнее он не сможет. Функции на UNTRUSTED-языках может создать только суперюзер.

Если интерпретатор языка поддерживает такие ограничения, то на его базе можно создать и TRUSTED, и UNTRUSTED язык. Так с Perl, поэтому существуют разные языки plperl и plperlu. Буква u на конце выдает недоверенный характер языка. Python существует только в недоверенном варианте. PL/v8 — наоборот, только в доверенном. Как следствие, PL/v8 не может подгружать никаких модулей или библиотек с диска, только из БД.

Функция на UNTRUSTED-языке может всё: послать письмо, пингануть сайт, зайти в чужую базу, выполнить HTTP-запрос. TRUSTED-языки ограничены обработкой данных из базы.

К TRUSTED относятся: plpgsql, plperl, plv8, pljava.

К UNTRUSTED относятся: plperlu, pljavau, plpython2u, plpython3u.

Обратите внимание: не существует PL/Python как TRUSTED (так как там нельзя задать ограничения на доступ к ресурсам), а PLpgSQL и PL/V8 — наоборот: не бывают UNTRUSTED.

А вот Perl и Java доступны в обоих вариантах.

PL/pgSQL vs PL/*

Код на PL/pgSQL нативным образом работает со всеми типами данных, которые есть в Postgres. В других языках многих типов Postgres нет, и интерпретатор языка заботится о преобразовании данных во внутреннее представление языка, заменяя непонятные типы текстом. Впрочем, ему можно помочь с помощью TRANSFORM, о котором я расскажу ближе к концу статьи.

Вызов функций в PL/pgSQL часто обходится дороже. Функции на других языках могут обращаться к своим библиотекам, не заглядывая в системный каталог. PL/pgSQL так работать не может. Некоторые запросы в PL/pgSQL работают долго именно из-за того, что поддерживается очень много типов: чтобы сложить два целых числа, интерпретатору надо сообразить, что он имеет дело с целыми числами, а не с еще какими-то экзотическими типами, потом решить, как их складывать, и только после этого собственно сложить.

Поскольку PL/pgSQL TRUSTED, из него нельзя работать с сетью и дисками.

Если речь идет о работе с вложенными структурами данных, в распоряжении PL/pgSQL есть только инструменты Postgres для работы с JSON, весьма громоздкие и непроизводительные, в других же языках работа с вложенными структурами гораздо проще и экономичнее.

В PL/* свое управление памятью, и за памятью надо следить, а может быть и ограничивать ее.

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

Зато в PL/* существует глобальный контекст интерпретатора, и его можно использовать, например, для кеширования данных, в том числе планов запросов. Если язык UNTRUSTED, то доступны сеть и диск(и). С базой все эти языки работают, как правило, через SPI, но об этом чуть позже.

Рассмотрим немного подробнее особенности языков PL/*.

PL/Perl

Интерпретатор Perl – это здоровенный кусок кода в памяти, но он, к счастью, создается не при открытии соединения, а только тогда, когда запускается первая хранимая процедура/функция PL/Perl. При его инициализации выполняется код, прописанный в параметрах конфигурации Postgres. Обычно при этом подгружаются модули и делаются предвычисления.

plperl.on_init= 'use Data::Dumper;'
plperl.on_plperl_init= ' ... '
plperl.on_plperlu_init= ' ... '
plperl.use_strict= on

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

Есть параметры для раздельной инициализации TRUSTED и UNTRUSTED Perl и, конечно, параметр use_strict=on. Те, кто программируют на Perl, знают, что без strict это не язык, а одно недоразумение.

PL/Python

В нём интерпретатор точно так же создается при первом обращении. И тут важно сразу определиться, какой питон вы хотите: второй или третий. Как известно, Python существует в двух популярных версиях (Python 2 и Python 3), но проблема в том, что их so-шки не уживаются вместе в одном процессе: возникает конфликт по именам. Если вы в одной сессии работали с v2, а потом позвали v3, то Postgres упадет, и для серверного процесса (backend) это будет фатальной ошибкой. Чтобы обратиться к другой версии, надо открыть другую сессию.

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

Во всех питоновских функциях определено два словаря – статический SD и глобальный GD. Глобальный позволяет обмениваться данными всем функциям внутри одного бэкенда — что привлекательно и опасно одновременно. Статический словарь у каждой функции свой.

В PL/Python можно делать подтранзакции, о которых мы расскажем ниже.

PL/V8

Он бывает только TRUSTED.

Удобно, что данные JSON автоматически преобразуются в структуру JS. В PL/V8, как и в PL/Python, можно делать подтранзакции. Есть интерфейс для упрощенного вызова функций. Это единственный из рассматриваемых процедурных языков, на котором можно определять оконные функции. Подсказывают, что их можно определять ещё на PL/R, но этот язык вне рамок данной статьи.

И только в PL/V8 есть execution timeout. Правда, по умолчанию он не включен, и если вы собираете PL/V8 руками, то надо при сборке сказать, чтобы он был включен, и тогда вы сможете параметром конфигурации устанавливать таймауты на вызовы функций.

Инициализация у PL/V8 выглядит интересно: поскольку он trusted, то не может прочитать библиотеку с диска, он вообще не может ниоткуда ничего грузить. Все нужное он может взять только из базы. Поэтому определяют хранимую функцию-инициализатор, которая вызывается при старте интерпретатора языка. Имя функции указывается в специальном параметре конфигурации:

plv8.start_proc=my_init # (имя PL/V8-функции)

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

CREATE OR REPLACE FUNCTION my_init()
RETURNS void LANGUAGE plv8 AS $$
     this.get_57 = function() { return 57; }; // создаем глобальную функцию
     this.pi_square = 9.8696044;  // создаем глобальную переменную
$$;
SET plv8.start_proc = 'my_init';
DO LANGUAGE plv8 $$
     plv8.elog(NOTICE, pi_square, get_57() );
$$;

Сравнение PL/Perl vs PL/Python vs PL/V8 на практике

Hello World!

Выполним нехитрое упражнение с выводом этой фразы на всех трех языках, сначала на PL/Perl:

DO $$
     elog(NOTICE,"Hello World!");
$$ LANGUAGE plperl;

NOTICE:  Hello World!
DO

Можно также использовать обычные для Perl функции warn и die.

Теперь на PL/Python. Точнее на PL/Python3u (untrusted) — для определенности.

DO $$
     plpy.notice('Hello World!', hint="Будь здоров", detail="В деталях")
$$ LANGUAGE plpython3u;

NOTICE:  Hello World!
DETAIL:  В деталях
HINT:  Будь здоров
DO

Можно использовать throw 'Errmsg'. Из сообщений Postgres вообще можно много чего извлечь: они содержат Hint, Details, номер строки и много других параметров. В PL/Python их можно передать, а в других рассматриваемых языках — нет: их средствами можно выругаться только обычной текстовой строчкой.

В PL/Python на каждый уровень логирования постгреса есть своя функция: NOTICE, WARNING, DEBUG, LOG, INFO, FATAL. Если это ERROR, то свалилась транзакция, если FATAL, свалился весь бэкенд. До PANIC дело, к счастью, не дошло. Почитать можно здесь.

PL/V8

В этом языке Hello world очень похож на перловый. Можно бросить exception с помощью throw, и это тоже будет обработкой ошибки, хотя средства и не столь развитые, как в Python. Если написать plv8.elog(ERROR), эффект будет, кстати, тот же.

DO $$
     plv8.elog(NOTICE, 'Hello World!');
$$ LANGUAGE plv8;

NOTICE:  Hello World!
DO

Работа с базой

Посмотрим теперь, как из хранимых процедур работать с базой данных. В Postgres есть SPI (Server Programming Interface). Это набор функций на C, который доступен всем авторам расширений. Почти все PL-языки предоставляют к SPI свои обертки, но каждый язык делает это немного по-своему.

Функция, написанная на C, но использующая SPI, скорее всего не даст существенного выигрыша по сравнению с PL/PgSQL и другими процедурными языками. Но функция на C, минующая SPI и работающая с данными без посредников (например table_beginscan/heap_getnext), будет работать на порядок быстрее.

PL/Java тоже использует SPI. Но работа с базой все равно происходит в стиле JDBC и по стандарту JDBC. Для создателя кода в PL/Java всё происходит как будто вы работаете из клиентского приложения, но JNI (Java Native Interface) транслирует обращения к базе в те же SPI-функции. Это удобно, и нет принципиальных препятствий воплотить этот принцип в PL/Perl и PL/Python, но это почему-то не сделано, и в планах пока не видно.

Конечно, при желании к чужим базам можно сходить обычным способом – через DBI или Psycopg. Можно и к локальной базе, но зачем.

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

Производительность: надо помнить, что SPI имеет некоторые издержки, и работа SQL-запросов в функциях может оказаться медленнее, чем без функций. В 13-й постгрес вошел патч Константина Книжника, который сокращает эти издержки. Но, конечно, обработка результатов запроса в хранимой функции не требует передачи результата на клиент, и поэтому может оказаться выгодной с точки зрения производительности.

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

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

Как и в каком виде мы получаем данные из базы

В Perl всё просто и понятно. Вызов spi_exec_query возвращает количество обработанных строк, статус и массив строк, которые выбраны SQL-запросом:

DO $$ 
     warn Data::Dumper::Dumper(
          spi_exec_query('SELECT 57 AS x')
     )
$$ LANGUAGE plperl;

WARNING:  $VAR1 = {
          'rows' => [
                    {
                      'x' => '57'
                    }
                  ],
          'processed' => 1,
          'status' => 'SPI_OK_SELECT'
        };

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

DO $$ 
     plpy.notice(
          plpy.execute('SELECT 57 AS x')
     )
$$ LANGUAGE plpython3u;

NOTICE:  <PLyResult status=5 nrows=1 rows=[{'x': 57}]>
DO

А сейчас возьмем 1-ю строчку, достанем оттуда X и получим значение – число.

DO $$ 
     plpy.notice(
          plpy.execute('SELECT 57 AS x')[0]['x']
      )
$$ LANGUAGE plpython3u;

NOTICE:  57
DO

В PL/V8:

DO $$ 
     plv8.elog(NOTICE, JSON.stringify(
          plv8.execute('SELECT 57 as x'))
     );
$$ LANGUAGE plv8;

NOTICE:  [{"x":57}]
DO

Для того, чтобы посмотреть структуру, мы использовали библиотечную функцию JSON.stringify, которую не надо подгружать специально, она уже готова к употреблению в составе PL/v8 по умолчанию.

Экранирование

Чтобы не было злокозненных SQL-инъекций, некоторые символы в запросах надо экранировать. Для этого, во-первых, есть функции SPI и соответствующие им функции (написанные на C) в языках, работающие, как обертки SPI. Например, в PL/Perl:

quote_literal – берет в апострофы и удваивает ' и . Предназначена для экранирования текстовых данных.
quote_nullable – то же, но undef преобразуется в NULL.
quote_ident – берет в кавычки имя таблицы или поля, если надо. Полезно в случае, когда вы конструируете SQL-запрос и подставляете в него имена объектов базы.

PL/Perl

DO $$
     warn "macy's";
     warn quote_literal("macy's");
$$ LANGUAGE plperl;

WARNING:  macy's at line 2.
WARNING:  'macy''s' at line 3.
DO

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

Но в PL/Perl есть и другие функции для экранирования данных отдельных постгресовых типов:

encode_bytea
decode_bytea
encode_array_literal
encode_typed_literal
encode_array_constructor

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

DO $$
     warn encode_typed_literal(
          ["один", "двадцать один"], "text[]"
     );
$$ LANGUAGE plperl;

WARNING:  {один,"двадцать один"} at line 2.
DO

В PL/Python есть три аналогичных функции, и работают они примерно так же:

plpy.quote_literal
plpy.quote_nullable
plpy.quote_ident

DO $$ plpy.notice(
     plpy.quote_literal("Macy's"));
$$ LANGUAGE plpython3u;
NOTICE:  'Macy''s'
DO

Если ли такие же фукнции и в PL/V8?

Конечно! Всё одинаково с точностью до синтаксических особенностей.

plv8.quote_literal
plv8.quote_nullable
plv8.quote_ident

DO $$
    plv8.elog(NOTICE, plv8.quote_nullable("Macy's"));
$$ LANGUAGE plv8;

NOTICE:  'Macy''s'

Производительность

Какой язык самый быстрый? Обычно отвечают: C. Но правильный ответ — C или SQL. Почему SQL? Дело в том, что функция на этом языке не всегда выполняется явно. Она может заинлайниться в запрос (планировщик встроит функцию в тело основного запроса), уже вместе с запросом хорошо соптимизироваться, в результате получится быстрее. Но при каких условиях код может встраиваться в запрос? Есть несколько простых условий, о которых можно прочитать, скажем,здесь. Например, функция не должна исполнятся с правами владельца (быть SECURITY DEFINER). Большинство простых функций подойдут под эти условия.

В этой статье мерить будем «на коленке», не всерьёз. Нам нужно приблизительное сравнение. Сначала включим тайминг:

timing

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

SELECT count(*) FROM pg_class;
0.5 ms

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

Попробуем PL/pgSQL:

DO $$
     DECLARE a int;
     BEGIN
          SELECT count(*) INTO a FROM pg_class;
     END;
$$ LANGUAGE plpgsql;
0.7 ms

PL/Perl:

DO $$
     my $x = spi_exec_query('SELECT count(*) FROM pg_class');
$$ LANGUAGE plperl;
0.7 ms

PL/Python:

DO $$
     x = plpy.execute('SELECT count(*) FROM pg_class');
$$ LANGUAGE plpythonu;
0.8 ms

Это был Python 2. Теперь Python 3 (напоминаю: Python2 и Python3 не живут мирно в пределах одной сессии, возможен конфликт по именам):

DO $$
     x = plpy.execute('SELECT count(*) FROM pg_class');
$$ LANGUAGE plpython3u;
0.9ms

И, наконец, PL/V8:

DO $$
     var x = plv8.execute('SELECT count(*) FROM pg_class');
$$ LANGUAGE plv8 ;
0.9 ms

Но это как-то очень быстро. Попробуем выполнить запрос 1000 раз или 1 миллион раз, вдруг разница будет заметней:

PL/pgSQL:

DO $$
     DECLARE a int; i int;
     BEGIN FOR i IN 0..999999 LOOP
          SELECT count(*) INTO a FROM pg_class;
    END LOOP;
END;
$$ LANGUAGE plpgsql;
53s

PL/Perl:

DO $$
     for (0..999999) {
          spi_exec_query('SELECT count(*) FROM pg_class');
     }
$$ LANGUAGE plperl;
102s

PL/Python 3:

DO $$
     for i in range (0,1000000) :
          plpy.execute('SELECT count(*) FROM pg_class')
$$ LANGUAGE plpython3u;
98s

PL/V8:

DO $$
     for(var i=0;i<1000;i++)
          plv8.execute('SELECT count(*) FROM pg_class');
$$ LANGUAGE plv8;
100ms

Обратите внимание, что с PL/V8 эксперимент проведен с тысячей, а не миллионом итераций. При умеренных ресурсах PL/V8 в цикле из 1млн операций съест всю память и вовсе повесит машину. Уже на тысяче итераций процесс postgres отбирает 3.5ГБ памяти и 100% записи на диск. На самом деле postgres запускает среду V8, и память ест, конечно, она. После исполнения запроса отдавать память назад этот турбо-монстр не собирается. Чтобы высвободить память, приходится закончить сессию.

Видим, что PL/pgSQL уже раза в 2 быстрее PL/Perl и PL/Python. PL/V8 пока чуть отстает от них, но ближе к концу статьи он частично реабилитируется.

Вообще, Perl с Python'ом в этих экспериментах показывают примерно одинаковые результаты. Раньше Perl немного уступал Python'у, в современных версиях он чуточку быстрее. Третий питон чуть медленнее второго. Вся разница в пределах 15%.

Производительность с PREPARE

Люди знающие поймут: что-то не так. PL/pgSQL умеет автоматически кешировать планы запросов, а в PL/* каждый раз запрос планировался заново. По-хорошему запросы надо подготавливать, строить план запроса, а потом уже по этому плану их исполнять столько раз, сколько потребуется. В PL/* можно явно работать с планами запросов, что мы и попробуем, начиная с PL/Perl:

DO $$
     my $h = spi_prepare('SELECT count(*) FROM pg_class');
     for (0..999999) {
          spi_exec_prepared($h);
     }
     spi_freeplan($h);
$$ LANGUAGE plperl;
60s

PL/Python 3:

DO $$
     h = plpy.prepare('SELECT count(*) FROM pg_class')
     for i in range (0,1000000): plpy.execute(h)
$$ LANGUAGE plpython3u;
62s

PL/V8:

DO $$
     var h=plv8.prepare('SELECT count(*) FROM pg_class');
     for(var i=0;i<1000;i++) h.execute();
$$ LANGUAGE plv8;
53ms

С prepare наши два языка практически догнали PL/pgSQL, а третий — тоже хотел, но не дошел до финиша из-за растущих потребностей в памяти.

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

Производительность вычислений

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

PL/pgSQL:

DO $$
     DECLARE i bigint; a bigint;
     BEGIN a=0;
     FOR i IN 0..1000000 LOOP
          a=a+i*i::bigint;
     END LOOP;
END;
$$ LANGUAGE plpgsql;
280ms

PL/Perl:

DO $$
     my $a=0;
     for my $i (0..1000000) { $a+=$i*$i; };
     warn $a;
$$ LANGUAGE plperl;
63ms

PL/Python 3:

DO $$
a=0
for i in range(1,1000001): a=a+i*i
$$ LANGUAGE plpython3u;
73ms

PL/V8:

DO $$
     var a=0;
     for(var i=0;i<=1000000;i++) a+=i*i;
     plv8.elog(NOTICE, a);
$$ language plv8;
7.5ms

Видим, что PL/Perl и PL/Python догнали и перегнали PL/pgSQL, они раза в 4 быстрее. А восьмерка рвёт всех! Но неужели это задаром? Или мы получим за это по голове? Да, получим.

Число в JavaScript – это float, и результат получается быстро, но не точно: 333333833333127550 вместо 333333833333500000.

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

∑ = n*(n+1)*(2n+1)/6

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

В порядке смеха,

DO LANGUAGE plv8 $$
plv8.elog(NOTICE, parseInt(33333383333312755033)) $$;

NOTICE:
33333383333312754000

В Javascript parseInt все равно делает float, а не Int.

Всё же в V8 в 2018 г. появился BigInt, и считать теперь можно точно, но с ущербом для скорости, поскольку это не 64-разрядное целое, а целое произвольной разрядности. Впрочем, в PL/V8 это новшество пока не попало. В других процедурных языках числа произвольной разрядности (аналоги SQL-ного numeric) поддерживаются через специальные библиотеки.

В Perl для этого есть модуль Math::BigFloat для арифметики с произвольной точностью, а в Python – пакет Bigfloat – обертка Cython вокруг библиотеки GNU MPFR.

Производительность функции для сортировки

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

1
2
3
4-5
6
6A
6Б
11
12

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

0000000001
0000000002
0000000003
0000000004-5
0000000006
0000000006A
0000000006Б
0000000011
0000000012

Да, я знаю, что это не единственное решение задачи (и даже не совсем правильное). Но для примера оно подойдёт.

Для запроса типа SELECT ... ORDER BY nsort(n) напишем функции на PL/Perl, SQL и PL/Python, приводящие номера журналов к этому виду:

CREATE OR REPLACE FUNCTION nsort(text) RETURNS text 
   LANGUAGE PLPERL IMMUTABLE AS $$
    my $x = shift;
    return ($x =~ /^s*(d+)(.*)$/)
        ? sprintf("%010d", $1).$2
        : $x;
$$;

CREATE OR REPLACE FUNCTION _nsort(x text) RETURNS text
     LANGUAGE SQL  IMMUTABLE  AS $$
 WITH y AS (
    SELECT regexp_match(x,'^s*(d*)(.*)$') as z
 )
 SELECT CASE WHEN z[1] = '' THEN x ELSE lpad(z[1],10,'0') || z[2] END FROM y;
$$;

CREATE OR REPLACE FUNCTION py_nsort(x text) RETURNS text 
   LANGUAGE plpython2u IMMUTABLE AS $$
import re
r = re.match('^s*(d+)(.*)$', x)
return x if r == None else ('%010d' % int(r.group(1))) + r.group(2)
$$;

CREATE OR REPLACE FUNCTION js_nsort(x text) RETURNS text 
   LANGUAGE plv8 IMMUTABLE AS $$
var m = x.match(/^s*(d+)(.*)$/);
if(m) { var pad = ''; for(var i=0,l=10-m[1].length;i<l;i++) pad+='0'; return pad + x; }
else { return x; } 
$$;

На моей библиотеке из 15.5 тысяч журнальных статей запрос с использованием функции на PL/Perl занимает около 64мс против 120мс на PL/Python и 200мс на PL/PgSQL. Но быстрее всех — PL/v8: 54мс. Если бы движок JS был поновее и в прототипе String появились бы всякие полезные функции вроде padStart или хотя бы repeat, код был бы полегче, да и побыстрее.

Примечание: экспериментируя с сортировкой, обеспечьте нужный объем рабочей памяти, чтобы сортировка шла в памяти (EXPLAIN тогда покажет Sort Method: quicksort). Объем памяти устанавливается параметром work_mem:

set work_mem = '20MB';

Память

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

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

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

PL/Perl

Вот так НЕ течёт:

CREATE OR REPLACE function cr()
RETURNS int LANGUAGE plperl AS
$$
     return spi_exec_query(
           'SELECT count(*) FROM pg_class'
     )->{rows}->[0]->{count};
$$;

А вот так течёт:

CREATE OR REPLACE function cr()
RETURNS int LANGUAGE plperl AS
$$
     my $h = spi_prepare(
          'SELECT count(*) FROM pg_class'
     );
     return spi_exec_prepared($h)->{rows}->[0]->{count};
$$;

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

Ничего страшного, просто надо помнить о необходимости явного освобождения ресурсов при помощи spi_freeplan($h):

CREATE OR REPLACE function cr()
RETURNS int LANGUAGE plperl AS
$$
     my $h = spi_prepare(
          'select count(*) from pg_class'
     );
     my $res = spi_exec_prepared($h)->{rows}->[0]->{count};
     spi_freeplan($h);
     return $res;
$$;

PL/Python:

Python не течёт никогда, план освобождается автоматически:

CREATE OR REPLACE function cr3() RETURNS int
LANGUAGE plpythonu as
$$
     return plpy.execute(
           'select count(*) from pg_class'
     )[0]['count']
$$;

PL/V8

Та же история, что и с Perl. Так не течёт:

CREATE OR REPLACE FUNCTION crq() RETURNS int
LANGUAGE plv8 AS
$$
     return plv8.execute(
          'select count(*) from pg_class‘
     )[0].count;
$$;

А вот так течёт:

CREATE OR REPLACE FUNCTION crq() RETURNS int
LANGUAGE plv8 AS
$$
     var h = plv8.prepare(
          'select count(*) from pg_class'
     );
     return h.execute()[0].count;
$$;

Опять же: не стоит забывать об освобождении ресурсов. Здесь это делает h.free();

Так не течёт:

CREATE OR REPLACE FUNCTION crq() RETURNS int
LANGUAGE plv8 AS
$$
     var h = plv8.prepare(
          'select count(*) from pg_class'
     );
     var r = h.execute()[0].count;
     h.free();
     return r;
$$;

Параметры

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

  • целое;
  • массив;
  • bytea и
  • jsonb

В каком виде они попадают в PL/Perl?

CREATE OR REPLACE FUNCTION crq(a int, b
bytea, c int[], d jsonb ) RETURNS void
LANGUAGE plperl AS
$$
    warn Dumper(@_);
$$;

SELECT crq(1,'abcd', ARRAY[1,2,3],'{"a":2,"b":3}');


WARNING:  $VAR1 = '1';
$VAR2 = '\x61626364';
$VAR3 = bless( {
                 'array' => [
                              '1',
                              '2',
                              '3'
                            ],
                 'typeoid' => 1007
               }, 'PostgreSQL::InServer::ARRAY' );
$VAR4 = '{"a": 2, "b": 3}';
 crq 
-----
(1 row)

Будет это JSON или JSONB — в данном случае никакой разницы: они всё равно попадают в виде строки. Это плата за универсальность: в Postgres очень много типов, разной степени «встроенности». Требовать от разработчика, чтобы вместе с новым типом он сразу снабжал и функциями преобразования для всех PL/* было бы перебором. По умолчанию многие типы передаются как строки. Но это не всегда удобно, приходится эти сроки парсить. Конечно, хотелось бы, чтобы данные Postgres сразу превращались в соответствующие структуры Perl. По умолчанию этого не происходит, но начиная с 9.6 появился механизм TRANSFORM – возможность определять функции преобразования типов: CREATE TRANSFORM.

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

  • При передаче параметров в функцию;
  • При возврате значения функции;
  • При передача параметров в SPI-вызов внутри функции;
  • При получении результата SPI-вызова внутри функции.

В 11-й версии Postgres появились TRANSFORM JSONB для Perl и Python, разработанные Антоном Быковым. Теперь JSONB парсить не нужно, он попадает в Perl сразу как соответствующая структура. Надо создать расширение jsonb_plperl, и тогда можно использовать TRANSFORM:

CREATE EXTENSION jsonb_plperl;
CREATE OR REPLACE FUNCTION crq2(d jsonb)
RETURNS void LANGUAGE plperl
TRANSFORM FOR TYPE jsonb AS $$
     warn Dumper(@_);
$$;

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

SELECT crq2( '{"a":2,"b":3}');


WARNING:  $VAR1 = {
          'a' => '2',
          'b' => '3'
        };
 crq2 
------
(1 row)

Совсем другое дело!

Автор этой статьи тоже приложил руку к разработке TRANSFORM'ов. Оказалось, что такой простой тип данных, как boolean передаётся в PL/Perl в неудобной форме, в виде текстовых строк 't' или 'f'. Но в понимании Perl строка 'f' это true. Чтобы устранить неудобство, был придуман патч, определяющий преобразование для булевого типа. Этот патч попал в PostgreSQL 13 и скоро можно будет им пользоваться. В виду своей простоты, bool_plperl может служить минимальным стартовым образцом для написания любого другого преобразования.

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

Теперь посмотрим, как параметры передаются в Python.

CREATE EXTENSION jsonb_plpython3u;
CREATE OR REPLACE FUNCTION pdump(a int, b bytea, c int[], d jsonb ) RETURNS void
LANGUAGE plpython3u
TRANSFORM FOR TYPE jsonb AS $$
      plpy.warning(a,b,c,d)
$$;

SELECT pdump(1,'abcd', ARRAY[1,2,3],'{"a":2,"b":3}');


WARNING:  (1, b'abcd', [1, 2, 3], {'a': Decimal('2'), 'b': Decimal('3')})
 pdump 
-------
(1 row)

Массив преобразуется в массив — это хорошо (начиная с версии PG10 в питон корректно передаются и многомерные массивы). В Perl массив преобразовывался в объект специального класса. Ну и jsonb трансформировался. Без TRANSFORM jsonb передастся в виде строки.

Теперь посмотрим, в каком виде параметры попадают в JS.

CREATE OR REPLACE FUNCTION jsdump(a int, b bytea, c int[], d jsonb) RETURNS void
LANGUAGE plv8 AS $$
     plv8.elog(WARNING,a,b,c,d)
$$;

SELECT jsdump(1,'abcd', ARRAY[1,2,3],'{"a":2,"b":3}');


WARNING:  1 97,98,99,100 1,2,3 [object Object]
jsdump 
-------
(1 row)

JSONB преобразовался в объект JavaScript без всякого TRANSFORM! ВременнЫе типы Postgres тоже преобразуются в тип Date JS. То же с boolean. Все трансформации уже встроены в PL/V8.

Работа с бесконечностью

Константа INFINITY используется не так уж часто, но неаккуратная работа с ней опасна. В PostgreSQL Infinity и -Infinity существуют как специальные значения некоторых временнЫх типов и типа с плавающей точкой. Но передачу Infinity в процедурные языки и обратно надо обсуждать подробно, так как работа с ними может зависеть не только от языка, а и от библиотек, от ОС и даже от железа.

В Python есть модуль Numpy, определяющий числовую бесконечность:

import numpy as nm
a = nm.inf
b = -nm.inf
print(a, b)

inf -inf

В Perl тоже есть бесконечность, в её качестве используется строка "infinity", которую можно сократить до "inf". Например, можно сказать:

perl -e 'print 1 * "inf"'

Inf

или

perl -e 'print 1/"inf"'

0

В PL/Perl, PL/Python, PL/v8 числовая бесконечность из Postgres передаётся корректно, но бесконечная дата — не совсем. Вернее, в PL/Perl и PL/Python нет встроенного типа данных для времени, туда приходит строка. В PL/V8 встроенный тип Date есть, и обычная дата из постгреса превращается в него. Но бесконечную дату V8 не знает, и при передаче она превращается в Invalid Date.

Передача параметров в prepared-запросы

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

Когда вы подготавливаете запрос в PL/Perl, требуется определить тип параметров, которые передаются, а при выполнении запроса уже указываете только значения этих параметров (точно так же передаются параметры и в PL/pgSQL).

DO LANGUAGE plperl $$
     my $h= spi_prepare('SELECT * FROM pg_class WHERE
          relname ~ $1', 'text' );                     # указываем тип параметра
     warn Dumper(spi_exec_prepared($h, 'pg_language')); # передаем значение параметра
     spi_freeplan($h);
$$;

В PL/Python суть та же, но синтаксис немного отличается:

DO LANGUAGE plpython3u $$
     h= plpy.prepare('SELECT relname FROM pg_class WHERE relname ~ $1', ['text'] )
     plpy.notice(р.execute (['pg_language']))
$$;

В PL/V8 отличия минимальны:

DO LANGUAGE plv8 $$
    var h= plv8.prepare('SELECT relname FROM pg_class WHERE relname ~ $1', ['text'] );
    plv8.elog(NOTICE, h.execute (['pg_language']));
    h.free();
$$;

В PL/Java всё по-другому. Там явно не используется SPI, а формируется псевдо-JDBC коннект к базе. Для программиста на PL/Java всё происходит так же, как если бы он создавал клиентское приложение. Это удобно, и можно было бы также подойти к дизайну PL/Perl и PL/Python, но это почему-то не было сделано (впрочем, никто не запрещает создать ещё пару реализаций PL/Perl и PL/Python).

Работа с курсором

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

Курсоры работают во всех этих языках. В PL/Perl
spi_exec_query возвращает курсор, из которого можно извлекать строки по одной. Закрывать курсор не обязательно, он сам закроется. Но если вы хотите переоткрывать его заново, можно закрыть его явно командой close().

DO LANGUAGE plperl $$
    my $cursor = spi_query('SELECT * FROM pg_class');
    my $row;
    while(defined($row = spi_fetchrow($cursor))) {
         warn $row->{relname};
    }
$$;

WARNING:  pg_statistic at line 5.
WARNING:  pg_toast_2604 at line 5.
WARNING:  pg_toast_2604_index at line 5.
WARNING:  pg_toast_2606 at line 5.
WARNING:  pg_toast_2606_index at line 5.
WARNING:  pg_toast_2609 at line 5.
WARNING:  pg_toast_2609_index at line 5.
...

В PL/Python всё очень похоже, но курсор представлен как объект, по которому можно ходить циклом:

h = plpy.prepare('SELECT ...');
cursor = plpy.cursor(h);
for row in cursor:
...
cursor.close() // не обязательно

В PL/v8 всё тоже очень похоже, но не забывайте освобождать план подготовленного запроса:

var h = plv.prepare('SELECT ...');
var cursor = h.cursor();
var row;
while(row = cursor.fetch()) {
...
}
cursor.close();
h.free();

PL/V8: Быстрый доступ к функциям

В PL/V8 можно вызвать функцию не из обычного SELECT, а найти её по имени и сразу запустить при помощи plv8.find_function(name);. Но надо учитывать, что в JS функция не может быть полиморфной, как в PostgreSQL, в котором функции с одинаковым именем, но с разными параметрами могут сосуществовать. В PL/v8 мы, конечно, можем создавать полиморфные функции, но при попытке воспользоваться find_function будет ошибка.

ERROR:  Error: more than one function named "jsdump"

Если функция по имени находится однозначно, то её можно вызывать, минуя SPI и преобразования типов, т.е. гораздо быстрее. Например, так:

DO LANGUAGE plv8 $$
plv8.find_function('jsdump')(1, 'abc');
$$;

Транзакции

В Postgres 11 большая радость: появились настоящие процедуры. Раньше в Postgres были только функции. Радость не только из-за совместимости и соответствия стандарту SQL, а вот отчего: внутри процедур можно коммитить и откатывать транзакции.

В PL/Perl и PL/Python уже есть фунции SPI для управления транзакциями, а в PL/V8 этого пока нет. В PL/Perl эти функции называются spi_commit() и spi_rollback(), а пример использования есть в документации. В PL/Python это plpy.commit() и plpy.rollback().

Подтранзакции

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

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

В PL/Perl явных подтранзакций нет, но их можно имитировать через savaepoint'ы. По-видимому, при желании нетрудно написать перловый модуль, реализующий подтранзакции в явном виде.

В PL/Python подтранзакции появились давно: с 9.5 явные, до этого были неявные. Можно определить транзакцию, завернуть ее в try-блок и выполнить. Если подтранзакция сваливается, то мы попадаем в блок except, если не сваливается, то в блок else и идем дальше.

try:
     with plpy.subtransaction():
          plpy.execute("...")
          plpy.execute("...")
except plpy.SPIError, e:
. . .
else:
. . .

Похожая конструкция есть и в PL/V8, только в синтаксисе JS.

try {
plv8.subtransaction(function() {
plv8.execute('UPDATE...');
plv8.execute('UPDATE...');
});
}
catch(e) {
...
}

Заключение

Пробуйте, но не злоупотребляйте :) Определенную пользу знание PL/* может принести. Как и любой инструмент, они любят, когда их используют по назначению.

PL/v8 очень перспективен, но иногда ведет себя неожиданно и имеет ряд проблем. Поэтому лучше брать языки «из коробки», если они подходят для вашей задачи.

Хочу выразить благодарность Игорю Левшину (Igor_Le), который весьма помог мне с подготовкой материала для статьи, и подкинул несколько полезных идей.

Автор: Иван Панченко

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js