- PVSM.RU - https://www.pvsm.ru -
Иногда мы пишем SQL-запросы, мало задумываясь над тем фактом, что сначала они должны быть по сети как-то доставлены до сервера, а затем их результат - обратно в клиентское приложение. Если при этом на пути до сервера присутствует еще и пулер соединений типа pgbouncer, дополнительно "перекладывающий" байты между входящими и исходящими коннектами, ситуация становится еще тяжелее...
Поэтому сегодня рассмотрим некоторые типичные ситуации, в которых разработчики иногда принимают не самые оптимальные решения, гоняя по сети мегабайты трафика при общении с сервером PostgreSQL - а заодно посмотрим, как можно увидеть такую ситуацию в плане с помощью explain.tensor.ru [1] и подумаем над вариантами, как сделать подобное взаимодействие более эффективным.

Чем больший объем данных приходится передавать на сервер, тем больше ресурсов PostgreSQL тратит на их парсинг вместо выполнения самого запроса.
Классический антипаттерн в этом ключе - прямая "врезка" параметров прямо в тело запроса:
query = 'SELECT * FROM docs WHERE id IN (' + ids.join(',') + ')';
Помимо очевидных проблем с безопасностью из-за возможных SQL-инъекций, такой подход в большинстве случаев не позволяет драйверу понять, что подобный запрос уже передавался, и применить автоматическое использование подготовленных запросов [2].
Классическим решением в этой ситуации будет разделение тела запроса и его параметров:
query = 'SELECT * FROM docs WHERE id = ANY($1::integer[])';
...
params = '{' + ids.join(',') + '}'; // текстовое представление массива
Более полно с разными вариантами эффективной передачи данных в запрос можно ознакомиться в статье "PostgreSQL Antipatterns: передача наборов и выборок в SQL" [3].
Иногда возникает необходимость вставить в таблицу сразу много-много записей. Начинающий разработчик в этом случае обычно "клеит" построчно операторы вставки:
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 [4], который позволяет передавать данные для вставки в гораздо более эффективном текстовом, и даже двоичном форматах?
COPY users(id, name) FROM stdin;
1tVasyan2tPetyan3tKolyan
.
Допустим, 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"]]}'
Но что если часть запрос действительно необходимо повторить, чтобы эффективно использовать индекс?.. Допустим, мы хотим получить последние по времени записи по каждому из нескольких идентификаторов, когда у нас есть индекс:
CREATE INDEX tbl(id, ts DESC);
Многие с готовностью вспомнят, что я неоднократно рекомендовал использовать в подобных случаях UNION ALL, чтобы не происходило деградации скорости запроса при Bitmap Scan - и в статье "PostgreSQL Antipatterns: вредные JOIN и OR" [5], и в "Рецепты для хворающих SQL-запросов" [6]:
(
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 [7] в "Understanding LATERAL joins in PostgreSQL" [8] и у Luca Ferrari [9] в "A simple example of LATERAL use" [10].
Альтернативой использованию LATERAL может стать связка ARRAY + unnest по модели описанной в "SQL HowTo: пишем while-цикл прямо в запросе" [11].
Например, нам надо вывести массив с количеством продаж за каждый из трех месяцев 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 [12] подробно разобрал в недавно опубликованной в блоге Percona статье "Impact of Network and Cursor on Query Performance of PostgreSQL" [13].
Поэтому мы сосредоточимся на вопросе "почему" в обмене клиент-сервер могут возникнуть избыточные данные.
В силу особенностей 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: навигация по реестру" [14], когда для реализации постраничной навигации на клиента вычитывается вся таблица целиком, в жизни встречается нечасто, но вот незнание каких-то возможностей PostgreSQL запросто может приводить к вычитке избыточных данных на клиента.
Чтобы упростить поиск и анализ подобных ситуаций, мы добавили на explain.tensor.ru [1] отображение примерного объема данных, возвращаемых запросом:

Вычисляется данный размер достаточно просто: умножаем плановую "ширину" (width) возвращаемых записей на их реальное количество (actual rows), возвращенных корневым узлом плана.
Помимо оценки размера resultset, мы еще немного доработали возможности нашего сервиса визуализации планов [1].
Числовые, строковые и "атрибутные" значения в развернутом виде узла теперь подкрашиваются, чтобы их можно было почти мгновенно заметить:

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

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

Теперь вы можете автоматизировать отправку планов из своих систем для их визуализации и дальнейшего анализа, используя API нашего сервиса [15]:
curl -X POST https://explain.tensor.ru/explain
-H "Content-Type: application/json"
-d @FILENAME
# тут FILENAME – путь к файлу, содержащему параметры вызова в виде JSON-объекта
Пользуйтесь!
Автор: Боровиков Кирилл
Источник [16]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/postgresql/367123
Ссылки в тексте:
[1] explain.tensor.ru: https://explain.tensor.ru/about/
[2] подготовленных запросов: https://postgrespro.ru/docs/postgresql/12/sql-prepare
[3] "PostgreSQL Antipatterns: передача наборов и выборок в SQL": https://habr.com/ru/post/481122/
[4] оператор COPY: https://postgrespro.ru/docs/postgresql/12/sql-copy
[5] "PostgreSQL Antipatterns: вредные JOIN и OR": https://habr.com/ru/post/479508/
[6] "Рецепты для хворающих SQL-запросов": https://habr.com/ru/post/492694/
[7] Hans-Jürgen Schönig: https://www.cybertec-postgresql.com/en/author/cybertec_schoenig/
[8] "Understanding LATERAL joins in PostgreSQL": https://www.cybertec-postgresql.com/en/understanding-lateral-joins-in-postgresql/
[9] Luca Ferrari: https://fluca1978.github.io/about
[10] "A simple example of LATERAL use": https://fluca1978.github.io/2021/08/07/PostgreSQLLateralJoin.html
[11] "SQL HowTo: пишем while-цикл прямо в запросе": https://habr.com/ru/post/486072/
[12] Jobin Augustine: https://www.percona.com/blog/author/jobin-augustine/
[13] "Impact of Network and Cursor on Query Performance of PostgreSQL": https://www.percona.com/blog/impact-of-network-and-cursor-on-query-performance-of-postgresql/
[14] "PostgreSQL Antipatterns: навигация по реестру": https://habr.com/ru/post/498740/
[15] API нашего сервиса: https://explain.tensor.ru/api-description/
[16] Источник: https://habr.com/ru/post/573214/?utm_source=habrahabr&utm_medium=rss&utm_campaign=573214
Нажмите здесь для печати.