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

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

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


  1. mihmig
    17.08.2021 16:22

    Можно я, пользуясь случаем, спрошу:
    У меня бот ведёт список игр в таблице game (в т.ч. поля created и ended - начало и конец игры)
    Выясняю, сколько игр велось одновременно запросом:

    SELECT g.id, g.created, (SELECT count(1) FROM game g1 WHERE g1.created >'2021-08-13' AND g1.created > g.created AND g1.created<g.ended) AS aaa FROM game g WHERE g.created > '2021-08-13'

    Поле created - индексированное.
    Но, чувствую, можно как-то элегантнее?
    И, кстати, почему в postgresql нельзя сделать (см. запрос выше)
    ORDER BY aaa ?


    1. Kilor Автор
      17.08.2021 16:39
      +3

      сколько игр велось одновременно

      По описанию задачи очень похоже, что вам нужна модель "Отрезки на интервале" из статьи "PostgreSQL Antipatterns: работаем с отрезками в «кровавом энтерпрайзе»".


    1. valeriydomogaev
      18.08.2021 09:43

      G1.CREATED > '2021-08-13'
      AND G1.CREATED > G.CREATED

      Одно из этих условий возможно избыточно в подзапросе ведь уже есть в основном такое ограничение


  1. mihmig
    19.08.2021 10:18

    @Kilor

    Известны ли Вам способы вызова внешних систем из БД (postgres), что-то по аналогии с триггером, например:
    В таблицу занесли данные, триггер может добавить запись в другую таблицу. Но мы должны постоянно мониторить её...
    А вот можно ли "дёрнуть" по REST внешнюю систему, или послать маленький UPD-пакет куда-либо?


    1. Kilor Автор
      19.08.2021 10:32
      +1

      Первый вариант - FDW, вот примеры для MSSQL, ClickHouse, MySQL и PostgreSQL.

      Второй - реализация функции на каком-то из untrusted-языков.