PostgreSQL Antipatterns: «слишком много золота»

от автора

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

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

Сервер PostgreSQL тонет в неэффективных запросах
Сервер PostgreSQL тонет в неэффективных запросах

Запросы и их параметры

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

Данные в теле SQL

Классический антипаттерн в этом ключе — прямая «врезка» параметров прямо в тело запроса:

query = 'SELECT * FROM docs WHERE id IN (' + ids.join(',') + ')';

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

Классическим решением в этой ситуации будет разделение тела запроса и его параметров:

query = 'SELECT * FROM docs WHERE id = ANY($1::integer[])'; ... params = '{' + ids.join(',') + '}'; // текстовое представление массива

Более полно с разными вариантами эффективной передачи данных в запрос можно ознакомиться в статье «PostgreSQL Antipatterns: передача наборов и выборок в SQL».

INSERT vs COPY

Иногда возникает необходимость вставить в таблицу сразу много-много записей. Начинающий разработчик в этом случае обычно «клеит» построчно операторы вставки:

INSERT INTO users(id, name) VALUES(1, 'Vasya'); INSERT INTO users(id, name) VALUES(2, 'Petya'); INSERT INTO users(id, name) VALUES(3, 'Kolya');

Более продвинутый уже знает, что в один INSERT-оператор можно передавать сразу несколько строк:

INSERT INTO users(id, name) VALUES   (1, 'Vasya') , (2, 'Petya') , (3, 'Kolya');

Еще более опытный, уже обжегшийся на предыдущем пункте с SQL-инъекциями, использует $n-параметры в INSERT:

INSERT INTO users(id, name) VALUES   ($1, $2) , ($3, $4) , ($5, $6);

Как думаете, быстро ли сервер может разобрать строку из нескольких сотен параметров объемом в пару десятков мегабайт?.. Мне приходилось сталкиваться со сгенерированными INSERT, где «номерные» параметры доходили до $9000.

Собственно, а зачем нам сначала нагружать клиента преобразованием всех параметров в заведомо неоптимальный текстовый формат, генерацией тела INSERT, а затем сервер разбором всего этого обратно? Ведь есть оператор COPY, который позволяет передавать данные для вставки в гораздо более эффективном текстовом, и даже двоичном форматах?

COPY users(id, name) FROM stdin; 1\tVasya\n2\tPetya\n3\tKolya\n \.

Клон-значения параметров

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

INSERT INTO users(id, name, department)   SELECT     us[1]::integer   , us[2]::text   , us[3]::text   FROM     (       SELECT         us::text[]       FROM         unnest($1::text[]) us ) T ON CONFLICT   DO NOTHING;
$1 = '{"{1,Vasya,Developers}","{2,Petya,Developers}","{3,Kolya,Developers}","{4,Masha,Support}","{5,Sasha,Support}"}'

Нетрудно заметить, что названия отделов у нас наверняка будут многократно дублироваться, поэтому заранее аккуратно сложив данные в JSON-формате, можно существенно сэкономить на трафике:

INSERT INTO users(id, name, department)   SELECT     (val->>0)::integer -- взяли нужный элемент json-массива   , val->>1   , dep   FROM     (       SELECT         json_array_elements(value) val -- развернули массивы-людей       , key dep       FROM         json_each($1::json) -- развернули ключи-отделы     ) T ON CONFLICT   DO NOTHING;
$1 = '{"Developers":[[1,"Vasya"],[2,"Petya"],[3,"Kolya"]],"Support":[[4,"Masha"],[5,"Sasha"]]}'

Сгенерированный SELECT

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

CREATE INDEX tbl(id, ts DESC);

Многие с готовностью вспомнят, что я неоднократно рекомендовал использовать в подобных случаях UNION ALL, чтобы не происходило деградации скорости запроса при Bitmap Scan — и в статье «PostgreSQL Antipatterns: вредные JOIN и OR», и в «Рецепты для хворающих SQL-запросов»:

(   SELECT * FROM tbl WHERE id = 1 ORDER BY ts DESC LIMIT 1 ) UNION ALL (   SELECT * FROM tbl WHERE id = 2 ORDER BY ts DESC LIMIT 1 ) UNION ALL (   SELECT * FROM tbl WHERE id = 3 ORDER BY ts DESC LIMIT 1 )

И вот тут-то уж никак не избежать генерации запроса! Или все-таки есть способ?..

Нам ведь ничто не мешает вместо цикла, генерирующего тело запроса, использовать итерации внутри самого запроса:

SELECT   T.* FROM   unnest('{1,2,3}'::integer[]) _id , LATERAL ( -- выполняется отдельно для каждого ID     SELECT       *     FROM       tbl     WHERE       id = _id     ORDER BY       ts DESC     LIMIT 1   ) T;

Немного более подробно о примерах использования LATERAL можно прочитать у Hans-Jürgen Schönig в «Understanding LATERAL joins in PostgreSQL» и у Luca Ferrari в «A simple example of LATERAL use».

Альтернативой использованию LATERAL может стать связка ARRAY + unnest по модели описанной в «SQL HowTo: пишем while-цикл прямо в запросе».

Промежуточные столбцы (снова генерация)

Например, нам надо вывести массив с количеством продаж за каждый из трех месяцев 2-го квартала. Самый странный вариант решения этой задачи, который мне доводилось видеть, генерировал промежуточные столбцы в тело SQL и выглядел примерно так:

SELECT   ARRAY["2021-04", "2021-05", "2021-06"] -- это мы столбцы складываем в массив FROM   (     SELECT       sum(         CASE           WHEN dt >= '2021-04-01' AND dt < '2021-05-01'             THEN qty         END       ) "2021-04"     , sum(         CASE           WHEN dt >= '2021-05-01' AND dt < '2021-06-01'             THEN qty         END       ) "2021-05"     , sum(         CASE           WHEN dt >= '2021-06-01' AND dt < '2021-07-01'             THEN qty         END       ) "2021-06" -- это имена столбцов     FROM       sales     WHERE       dt >= '2021-04-01' AND dt < '2021-07-01'   ) T;

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

SELECT   array_agg(sum ORDER BY id) FROM   (     SELECT       date_trunc('month', dt) id     , sum(qty)     FROM       sales     WHERE       dt >= '2021-04-01' AND dt < '2021-07-01'     GROUP BY       1   ) T;

Тут можно только лишь посоветовать «учить матчасть» и расширять кругозор на предмет возможностей, которые может вам предоставить PostgreSQL.

Возврат результатов

Влияние сетевых задержек при разных вариантах возврата результатов (много/мало, сразу все или дольками, клиентским или серверным курсором) на общее время выполнения запроса Jobin Augustine подробно разобрал в недавно опубликованной в блоге Percona статье «Impact of Network and Cursor on Query Performance of PostgreSQL».

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

Без(д)умное использование ORM

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

ids <- 'SELECT id FROM users WHERE department = $1'; 'SELECT * FROM docs WHERE user IN (' + ids.join(',') + ')'

Но грамотно написанный именно на стороне SQL запрос легко устранит подобную проблему:

SELECT   * FROM   docs WHERE   user IN (     SELECT id FROM users WHERE department = $1   );

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

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

Мониторинг размера resultset

Чтобы упростить поиск и анализ подобных ситуаций, мы добавили на explain.tensor.ru отображение примерного объема данных, возвращаемых запросом:

1405 строк по 101 байту - получается примерно 139KB к передаче
1405 строк по 101 байту — получается примерно 139KB к передаче

Вычисляется данный размер достаточно просто: умножаем плановую «ширину» (width) возвращаемых записей на их реальное количество (actual rows), возвращенных корневым узлом плана.

Визуализация EXPLAIN — что еще у нас нового?

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

Подсветка значений

Числовые, строковые и «атрибутные» значения в развернутом виде узла теперь подкрашиваются, чтобы их можно было почти мгновенно заметить:

Подсветка значений в узле плана
Подсветка значений в узле плана

Отметка фильтрующих узлов

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

Отброшено 99% записей
Отброшено 99% записей

Иерархия сложных планов

Теперь в сложных планов со множеством вложенных CTE/InitPlan/SubPlan можно наглядно отследить, где кончается поддерево конкретного узла, и кто является его прямыми потомками:

Сложная иерархия с двумя CTE
Сложная иерархия с двумя CTE

Публичный API

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

curl -X POST https://explain.tensor.ru/explain \   -H "Content-Type: application/json" \   -d  @FILENAME # тут FILENAME – путь к файлу, содержащему параметры вызова в виде JSON-объекта

Пользуйтесь!


ссылка на оригинал статьи https://habr.com/ru/articles/573214/