image
Эта статья перевод статьи известного специалиста в области Oracle (но про Postgres) — Фрица Хугланда — https://databaseperformance.hashnode.dev/using-bpftrace-for-postgres-query-execution-tracing.


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


• Parse
• Rewrite
• Plan
• Execute


Эти фазы[1] должны быть выполнены в порядке очерёдности до того, как финальная фаза (Execute) сможет выполнить фактически посланный запрос.


pg_stat_statements


Во многих случаях pg_stat_statements используется для просмотра времени, затраченного на выполнение запроса, используя столбец total_time (до версии PostgreSQL 12) или total_exec_time (версия PostgreSQL 13 или выше). Оба столбца показывают время, затраченное только на этапе “Execute”.


С добавлением в pg_stat_statement в PostgreSQL версии 13 столбца total_plan_time, тот факт, что total_time(total_exec_time) — это неполная картина, становится несколько более очевидным. Становится понятно, что total_exec_time нуждается в большей детализации. Однако при этом по-прежнему не учитываются этапы Plan и Parse.


log_min_duration_statement


log_min_duration_statement
Насколько я вижу, большинство утилит для работы с базами данных PostgreSQL не учитывают общее время выполнения, которое включает все этапы за единственным исключением log_min_duration_statement(!). Когда значение log_min_duration_statement установлено больше 0 и время выполнения запроса превышает это значение, то PostgreSQL регистрирует текст запроса и продолжительность (time) для фактического общего времени для «simple query protocol» или разделяет время исполнения на parse (состоящий из фазы Parse и фазы Rewrite), bind (включает фазу Plan) и execute (включает/состоит из фазы Execute) для «extended query protocol» — https://www.postgresql.org/docs/current/protocol-overview.html#PROTOCOL-QUERY-CONCEPTS.


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


bpftrace: query-analyzer.bt


Что делать, если вы хотите внимательно отслеживать происходящее с точки зрения этапов выполнения на стороне сервера? Это как раз то, что делает query-analyzer.bt: отслеживает основные фазы выполнения parse, rewrite, plan and execute и подсчитывает их время. В настоящее время он предназначен для «трейсинга» или отладки одной или нескольких сессий. Доступно через: github.com/FritsHoogland/postgres-bpftrace…
Примечание: похоже, что исполняемые файлы postgres, доступные в Docker и в форматах deb и rpm уже имеют необходимые «пробы» (USDT probes). Что позволяет query-analyzer.bt использовать эти «пробы».
Вот как это выглядит:


root@d3afc158acbe:/# ./postgres-bpftrace/query-analyzer.bt
Attaching 19 probes...
PostgreSQL statement execution analyzer.
Time in microseconds (us).
pid   :Phase      :time to phase :time in phase : query
------|-----------|--------------|--------------|------
[13432]Query start:              :              : select '1';
[13432] parse     : (       667) :            66: select '1';
[13432] rewrite   : (        58) :            51: select '1';
[13432] plan      : (        43) :           175: select '1';
[13432] execute   : (       430) :          1230: select '1';
[13432]Query done : (      1791) :          4515: select '1';

simple query protocol execution


Это запрос select '1';, выполненный из psql. Утилита/клиент psql в настоящее время всегда использует протокол simple query protocol. “simple query protocol” выполняет все шаги исполнения (parse … execute), но все они отправляются на сервер базы данных одним куском, с токеном Q.


Начало этого единого шага исполнения маркируется как Query start и окончание как Query done.


Как только запрос запущен, он должен быть синтаксически проанализирован, что и выполняется на этапе Parse. Время “time to phase” показывает, сколько реального (астрономического) времени затрачивается на переход в фазу с предыдущей фазы. Для “parse” это означает время между стартом серверного процесса PostgreSQL выполняющего запрос и до старта фазы синтаксического разбора.


Следующий шаг фаза “rewrite”. Фаза “rewrite” выполняет 2 задачи: выполняет семантический разбор (такой как: существует ли эта таблица и есть ли к ней доступ у текущего пользователя) и собственно шаг перезаписи/трансформации, если необходимо (например, представления требуют перезаписи в фактический запрос).


Обе фазы “parse” и “rewrite” работают, используя дерево синтаксического анализа, и это дерево используется далее планировщиком, который создаёт дерево исполнения для последнего шага(фазы), executor’a. Задача планировщика вычислить наиболее эффективный способ выполнения с учетом опций, которые допускают задействованные объекты базы данных (например, использование индекса или выполнение последовательного сканирования).
Все это приводит к фазе исполнения, которая берет дерево исполнения и выполняет фактическую работу по получению результата с учетом дерева выполнения, которое было получено из предоставленного выражения.


extended query protocol execution


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


[13444] parse     : (         0) :           173: select '1'
[13444] rewrite   : (        77) :           171: select '1'
[13444] plan      : (       943) :           198: select '1'
[13444] execute   : (       103) :            36: select '1'

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


Протокол расширенных запросов позволяет отправлять несколько фаз в одном запросе, отправляемом на сервер базы данных, либо индивидуальные фазы по раздельности. Наиболее распространенными вызовами протокола расширенных запросов являются:
• Синтаксический анализ (Parse): который содержит как фазы синтаксического анализа на стороне сервера, так и фазы перезаписи.
• Привязка (Bind): которая содержит фазу планирования на стороне сервера.
• Выполнение (Execute): который содержит фазу выполнения на стороне сервера.


Пробы (usdt probes), которые использует query-analyzer.bt расположены внутри индивидуальных фаз – parse, rewrite, plan and execute, вот почему анализатор может показывать эти фазы как для “simple query protocol” так и для “extended query protocol”, несмотря на разные протоколы, они выполняют одни и те же базовые фазы обработки.


not-so-strict phases


С помощью query-analyzer.bt вы можете видеть, что происходит при обработке запроса. Это также позволяет проверить рекомендации, которые я выполнял в прошлом по использованию prepared statements, и должен ли PostgreSQL всегда выполнять все эти этапы, несмотря ни на что.
Посмотрим на очень простой пример:


[13432]Query start:              :              : set log_min_duration_statement=-1;
[13432] parse     : (       273) :           366: set log_min_duration_statement=-1;
[13432] rewrite   : (       117) :            89: set log_min_duration_statement=-1;
[13432] execute   : (       698) :           246: set log_min_duration_statement=-1;
[13432]Query done : (       181) :          1973: set log_min_duration_statement=-1;

Вы заметили эту странность? Фаза plan исчезла! Для этого есть очень веская причина: серверная часть выполняет работу, которая не является SQL, и, следовательно, она не нуждается в планировании; она может выполнить запрошенную задачу установки переменной (сеанса) напрямую; следовательно, execute выполняется не с использованием дерева выполнения, а выполняет изменение переменной напрямую.


Давайте рассмотрим другой пример:


[13432]Query start:              :              : prepare t as select now();
[13432] parse     : (       770) :           787: prepare t as select now();
[13432] rewrite   : (       396) :           112: prepare t as select now();
[13432] rewrite   : (      1194) :          1897: prepare t as select now();
[13432] execute   : (       851) :          2405: prepare t as select now();
[13432]Query done : (       117) :          5441: prepare t as select now();

Здесь есть 2 фазы rewrite, вполне вероятно, первая из-за фактического исполнения, а вторая для того, чтобы сделать prepare этого выражения, которое должно стать «prepared statement». Также здесь отсутствует фаза планирования(plan).


Как вы могли бы предположить, выполнение «prepared statement» в psql также приведет к нестандартной последовательности:


[13501]Query start:              :              : execute t;
[13501] parse     : (        47) :            27: execute t;
[13501] rewrite   : (        44) :             9: execute t;
[13501] plan      : (       173) :            42: execute t;
[13501] execute   : (         0) :            50: execute t;
[13501] execute   : (       124) :           444: execute t;
[13501]Query done : (        86) :           783: execute t;

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


[13523] parse     : (         0) :           146: select $1
[13523] rewrite   : (        46) :           103: select $1
[13523] plan      : (       957) :           122: select $1
[13523] execute   : (       102) :            41: select $1
[13523] plan      : (       470) :            12: select $1
[13523] execute   : (        82) :             4: select $1

Это полностью (как я и ожидал): выполнение инструкции сначала проходит фазы синтаксического анализа, перезаписи, планирования и выполнения, когда prepared statement выполняется первый раз, а второе выполнение, которое явно использует prepared statement, может пропустить фазы синтаксического анализа и перезаписи. Prepared statement сохраняет результат шагов parse и rewrite, так что их не нужно выполнять повторно.


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


[13522] parse     : (         0) :           290: select '1'
[13522] rewrite   : (       144) :           260: select '1'
[13522] plan      : (      1173) :           364: select '1'
[13522] execute   : (       161) :           209: select '1'
[13522] execute   : (       831) :             4: select '1'

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


insert into select:
[13501]Query start:              :              : insert into t select id, repeat('x',10) from generate_series(1,
[13501] parse     : (        72) :            57: insert into t select id, repeat('x',10) from generate_series(1,
[13501] rewrite   : (        44) :           693: insert into t select id, repeat('x',10) from generate_series(1,
[13501] plan      : (       418) :           165: insert into t select id, repeat('x',10) from generate_series(1,
[13501] execute   : (         0) :           814: insert into t select id, repeat('x',10) from generate_series(1,
[13501] execute   : (        62) :           892: insert into t select id, repeat('x',10) from generate_series(1,
[13501]Query done : (      1908) :          4314: insert into t select id, repeat('x',10) from generate_series(1,

Я почти уверен, что это работает с несколькими «порталами» (см. определение портала https://www.programmersought.com/article/29581586522/) на этапе выполнения, где первым «порталом» является insert, и он обнаруживает, что ему необходимо выполнить select на втором «портале», чтобы получить результаты, которые затем используются для выполнения insert.
Наконец, то, что довольно радикально может изменить последовательность этапов, — это конвейерная обработка выполнения:


[13530] parse     : (         0) :           207: select 'one'
[13530] rewrite   : (        81) :           161: select 'one'
[13530] parse     : (         0) :            15: select 'two'
[13530] rewrite   : (        11) :             4: select 'two'
[13530] parse     : (         0) :             3: select 'three'
[13530] rewrite   : (        63) :             5: select 'three'
[13530] plan      : (       856) :           214: select 'one'
[13530] execute   : (       215) :            55: select 'one'
[13530] plan      : (       149) :             9: select 'two'
[13530] execute   : (        30) :             4: select 'two'
[13530] plan      : (        36) :             4: select 'three'
[13530] execute   : (        19) :             3: select 'three'

Каждый из трех операторов анализируется, а затем выполняется.
Анализатор запросов: github.com/FritsHoogland/postgres-bpftrace




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

Комментарии (1)


  1. drema201 Автор
    25.10.2023 14:52
    +1

    (Прим. переводчика) Если вы решите повторить этот процесс, то репозиторий (http:// github.com/FritsHoogland/postgres-bpftrace ) содержит пару неточностей

    https://github.com/FritsHoogland/postgres-bpftrace/blob/main/docker/Dockerfile.postgres_17_modified
    содержит команду COPY wait_event.patch . на вашей хост машине вряд ли есть этот файл, так что лучше заменить на внутреннее копирование, что то вроде
    RUN cp ...

    https://github.com/FritsHoogland/postgres-bpftrace/blob/main/query-analyzer.bt содержит некорректные пути (usdt:/usr/lib/postgresql/15/bin/postgres) а мы инсталлируем с префиксом (--prefix=/usr/local/pgsql) так что надо заменить на (usdt:/usr/local/pgsql/bin/postgres)

    Ну и RUN git apply wait_event.patch не работает, т.к. код Postgres уже ушёл вперёд,
    можно посмотреть что сделано и проделать тоже самое своими руками, для работоспособности кода этой статьи, впрочем, это не критично ;)