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

Kate

Administrator
Команда форума
Иногда мы пишем 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-объекта

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

 
Сверху