Русский
Русский
English
Статистика
Реклама

Sql tips and tricks

SQL HowTo 1000 и один способ агрегации

19.06.2020 12:20:11 | Автор: admin
Наш СБИС, как и другие системы управления бизнесом, не обходится без формирования отчетов каждый руководитель любит сводные цифры, особенно всякие суммы по разделам и красивые "Итого".

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


Сегодня мы рассмотрим некоторые способы, с помощью которых можно вычислить агрегаты в PostgreSQL или ускорить выполнение SQL-запроса.

Совместные агрегаты


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

->  $1 = '{2,3,5,7,11,13,17,19}'<-  count | sum      8 |  77

Это самый-самый простой случай просто сразу одновременно в запросе пишем count и sum:

SELECT  count(*), sum(prime)FROM  unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime;

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


Несовместимые агрегаты


Проблемы начинаются, если мы хотим вычислить сразу несколько агрегатов, но вот у каждого из них разные условия [не]включения одной и той же записи исходного набора. Например, попробуем вычислить для нашего набора количество чисел больше и меньше 10 отдельными полями:

->  $1 = '{2,3,5,7,11,13,17,19}'<-  countlt | countgt        4 |       4

Вложенные запросы


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

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime) SELECT  (SELECT count(*) FROM src WHERE prime < 10) countlt, (SELECT count(*) FROM src WHERE prime > 10) countgt;



Какие из этого плана можно сделать выводы? Много бегаем и много фильтруем дважды [CTE Scan + Rows Removed by Filter: 4].

А если выборка будет из 10K записей, а агрегатов захочется 3-4-5?.. Совсем нехорошо.

FILTER-агрегаты


Этот вариант, наверное, самый простой и понятный:

SELECT  count(*) FILTER(WHERE prime < 10) countlt, count(*) FILTER(WHERE prime > 10) countgtFROM  unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime;



Он идеален во всех отношениях, быстро и эффективно решает все наши задачи, но имеет и ложку дегтя доступен только с версии 9.4 (мало ли у кого что на бою стоит).

Агрегаты от условия


Допустим, 9.4 еще не подвезли, а запрос все-таки хочется написать в один проход. В этом случае можно воспользоваться знанием, что count(*) FILTER(WHERE cond) эквивалентно sum(CASE cond):

SELECT  sum(CASE WHEN prime < 10 THEN 1 ELSE 0 END) countlt, sum(CASE WHEN prime > 10 THEN 1 ELSE 0 END) countgtFROM  unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime;

Или можно чуть короче, если вспомнить о возможности скастовать boolean в integer вместо CASE с результатами 1/0:

SELECT  sum((prime < 10)::integer) countlt, sum((prime > 10)::integer) countgtFROM  unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime;

Вообще, подход вычисления агрегата от некоторого условия достаточно универсален, но имеет и свои подводные камни.

Агрегация в массив


Допустим, мы хотим теперь получить не просто количество чисел, подходящих под то или иное условие, но массивы из них состоящие:

->  $1 = '{2,3,5,7,11,13,17,19}'<-   primeslt |   primesgt  {2,3,5,7} | {11,13,17,19}

И тут нам тоже поможет агрегат ведь он возвращает одно значение, но тип его может быть не обязательно скаляром, но записью таблицы или даже массивом с помощью array_agg.

Вариант с использованием FILTER очевиден:

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime)SELECT  array_agg(prime) FILTER(WHERE prime < 10) primeslt, array_agg(prime) FILTER(WHERE prime > 10) primesgtFROM  src;



А вот если попытаться превратить его в агрегат от условия придется разбираться с попаданием в набор NULL'ов, что уже совсем невесело:

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime) , tmp AS (  SELECT    array_agg(CASE WHEN prime < 10 THEN prime END) primeslt -- {2,3,5,7,NULL,NULL,NULL,NULL}  , array_agg(CASE WHEN prime > 10 THEN prime END) primesgt -- {NULL,NULL,NULL,NULL,11,13,17,19}  FROM    src)SELECT  ARRAY(SELECT * FROM unnest(primeslt) prime WHERE prime IS NOT NULL) primeslt, ARRAY(SELECT * FROM unnest(primesgt) prime WHERE prime IS NOT NULL) primesgtFROM  tmp;

Но если вам хоть немного повезло, и стоит хотя бы версия 9.3, то можно воспользоваться функцией array_remove для достижения того же эффекта:

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime) SELECT  array_remove(array_agg(CASE WHEN prime < 10 THEN prime END), NULL) primeslt, array_remove(array_agg(CASE WHEN prime > 10 THEN prime END), NULL) primesgtFROM  src;

Несколько агрегатов: Function Scan vs CTE


Мы тут внезапно вынесли наш исходный набор в CTE а почему? Потому что так банально быстрее. Давайте проверим на простом примере:

SELECT  array_agg(i) FILTER(WHERE i % 2 = 0) even, array_agg(i) FILTER(WHERE i % 2 = 1) oddFROM  generate_series(1, 1000000) i;



WITH src AS (  SELECT generate_series(1, 1000000) i)SELECT  array_agg(i) FILTER(WHERE i % 2 = 0) even, array_agg(i) FILTER(WHERE i % 2 = 1) oddFROM  src;



Почти на 40% быстрее! Пример, конечно, вырожденный, но эффект имеет место быть.

DISTINCT + OVER


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

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime)SELECT DISTINCT  array_agg(prime) FILTER(WHERE prime < 10) OVER() primeslt, array_agg(prime) FILTER(WHERE prime > 10) OVER() primesgtFROM  src;

Единственная проблема такая группировка небесплатна:



Сложный агрегат


Но предположим, что мы хотим что-то этакое сложное, для чего нет подходящего агрегата:

->  $1 = '{2,3,5,7,11,13,17,19}'<-                 exp                  |   val  2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 = | 9699690

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

Соберем запрос, который решит нашу задачу:

WITH RECURSIVE src AS (  SELECT    *  FROM    unnest('{2,3,5,7,11,13,17,19}'::integer[])      WITH ORDINALITY T(prime, rn)), T(rn, exp, val) AS (  SELECT    0::bigint    -- база агрегации  , '{}'::integer[]  , 1UNION ALL  SELECT    src.rn    -- итеративное вычисление сразу всех агрегатов  , exp || src.prime  , val * src.prime   FROM    T  JOIN    src      ON src.rn = T.rn + 1 -- переход к следующей записи)SELECT  array_to_string(exp, ' * ') || ' = ' exp, valFROM  TORDER BY -- отбор финального результата  rn DESCLIMIT 1;



Кучеряво! Попробуем упростить и ускорить, опираясь на тот факт, что входной аргумент заведомо является массивом то есть может быть перебран поэлементно:

WITH RECURSIVE src AS (  SELECT '{2,3,5,7,11,13,17,19}'::integer[] arr), T(i, exp, val) AS (  SELECT    1::bigint    -- база агрегации  , '{}'::integer[]  , 1UNION ALL  SELECT    i + 1    -- итеративное вычисление сразу всех агрегатов  , exp || arr[i]  , val * arr[i]  FROM    T  , src  WHERE    i <= array_length(arr, 1))SELECT  array_to_string(exp, ' * ') || ' = ' exp, valFROM  TORDER BY -- отбор финального результата  i DESCLIMIT 1;



Намного лучше!

Math.bonus


Применим string_agg и немного математической магии:


WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime)SELECT  string_agg(prime::text, ' * ') || ' = ' exp, exp(sum(ln(prime)))::integer val -- для любителей математикиFROM  src;

Подробнее..

PostgreSQL Antipatterns Должен остаться только один!

04.08.2020 16:15:07 | Автор: admin
На SQL вы описываете что хотите получить, а не как это должно исполняться. Поэтому проблема разработки SQL-запросов в стиле как слышится, так и пишется занимает свое почетное место, наряду с особенностями вычисления условий в SQL.

Сегодня на предельно простых примерах посмотрим, к чему это может приводить в контексте использования GROUP/DISTINCT и LIMIT вместе с ними.

Вот если вы написали в запросе сначала соедини эти таблички, а потом выкинь все дубли, должен остаться только один экземпляр по каждому ключу именно так и будет работать, даже если соединение вовсе не было нужно.

И иногда везет и это просто работает, иногда неприятно сказывается на производительности, а иногда дает абсолютно неожидаемые с точки зрения разработчика эффекты.


Ну, может, не настолько зрелищные, но

Сладкая парочка: JOIN + DISTINCT


SELECT DISTINCT  X.*FROM  XJOIN  Y    ON Y.fk = X.pkWHERE  Y.bool_condition;

Как бы понятно, что хотели отобрать такие записи X, для которых в Y есть связанные с выполняющимся условием. Написали запрос через JOIN получили какие-то значения pk по несколько раз (ровно сколько подходящих записей в Y оказалось). Как убрать? Конечно DISTINCT!

Особенно радует, когда для каждой X-записи находится по несколько сотен связанных Y-записей, а потом героически убираются дубли



Как исправить? Для начала осознать, что задачу можно модифицировать до отобрать такие записи X, для которых в Y есть ХОТЯ Б ОДНА связанная с выполняющимся условием ведь из самой Y-записи нам ничего не нужно.

Вложенный EXISTS


SELECT  *FROM  XWHERE  EXISTS(    SELECT      NULL    FROM      Y    WHERE      fk = X.pk AND      bool_condition    LIMIT 1  );

Некоторые версии PostgreSQL понимают, что в EXISTS достаточно найти первую попавшуюся запись, более старые нет. Поэтому я предпочитаю всегда указывать LIMIT 1 внутри EXISTS.

LATERAL JOIN


SELECT  X.*FROM  X, LATERAL (    SELECT      Y.*    FROM      Y    WHERE      fk = X.pk AND      bool_condition    LIMIT 1  ) YWHERE  Y IS DISTINCT FROM NULL;

Этот же вариант позволяет при необходимости заодно сразу вернуть какие-то данные из нашедшейся связанной Y-записи. Похожий вариант рассмотрен в статье PostgreSQL Antipatterns: редкая запись долетит до середины JOIN.

Зачем платить больше: DISTINCT [ON] + LIMIT 1


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

SELECT DISTINCT ON(X.pk)  *FROM  XJOIN  Y    ON Y.fk = X.pkLIMIT 1;

Теперь читаем запрос и пытаемся понять, что предлагается сделать СУБД:

  • соединяем таблички
  • уникализируем по X.pk
  • из оставшихся записей выбираем какую-то одну

То есть получили что? Какую-то одну запись из уникализованных а если брать эту одну из неуникализованных результат разве как-то изменится?.. А если нет разницы, зачем платить больше?

SELECT  *FROM  (    SELECT      *    FROM      X    -- сюда можно подсунуть подходящих условий    LIMIT 1 -- +1 Limit  ) XJOIN  Y    ON Y.fk = X.pkLIMIT 1;

И точно такая же тема с GROUP BY + LIMIT 1.

Мне только спросить: неявный GROUP + LIMIT


Подобные вещи встречаются при разных проверках непустоты таблички или CTE по ходу выполнения запроса:

...CASE  WHEN (    SELECT      count(*)    FROM      X    LIMIT 1  ) = 0 THEN ...

Агрегатные функции (count/min/max/sum/...) успешно выполняются на всем наборе, даже без явного указания GROUP BY. Только вот с LIMIT они дружат не очень.

Разработчик может думать вот если там записи есть, то мне надо не больше LIMIT. Но не надо так! Потому что для базы это:

  • посчитай, что хотят по всем записям
  • отдай столько строк, сколько просят

В зависимости от целевых условий тут уместно совершить одну из замен:

  • (count + LIMIT 1) = 0 на NOT EXISTS(LIMIT 1)
  • (count + LIMIT 1) > 0 на EXISTS(LIMIT 1)
  • count >= N на (SELECT count(*) FROM (... LIMIT N))

Сколько вешать в граммах: DISTINCT + LIMIT


SELECT DISTINCT  pkFROM  XLIMIT $1

Наивный разработчик может искренне полагать, что выполнение запроса остановится, как только мы найдем $1 первых попавшихся разных значений.

Когда-то в будущем это может так и будет работать благодаря новому узлу Index Skip Scan, реализация которого сейчас прорабатывается, но пока нет.

Пока что сначала будут извлечены все-все записи, уникализированы, и только уже из них вернется сколько запрошено. Особенно грустно бывает, если мы хотели что-то вроде $1 = 4, а записей в таблице сотни тысяч

Чтобы не грустить попусту, воспользуемся рекурсивным запросом DISTINCT для бедных из PostgreSQL Wiki:

Подробнее..

У меня зазвонил телефон. Кто говорит?.. Поможет слон

17.08.2020 16:16:01 | Автор: admin
Автоматическое определение клиента и его региона по входящему телефонному звонку стало неотъемлемой частью любой развитой HelpDesk или CRM-системы. Только надо уметь делать это быстро тогда появляется масса возможностей.

Например, можно менеджеру сразу показать из какого города идет звонок, подтянуть актуальный прайс и условия доставки, вывести карточку звонящего клиента, последние сделки с ним, конкретное контактное лицо, да много чего полезного, как это умеет наш СБИС CRM!


А как этот функционал реализовать самостоятельно? Оказывается, не так уж сложно. Собрать и опробовать работающую модель можно, буквально, на коленке нужна только связка из Node.js и PostgreSQL.

Определяем регион по номеру


Давайте предположим, что АТС присылает нам уже нормализованный и отформатированный до 10 цифр (будем рассматривать только звонки внутри России) входящий телефонный номер. Как наиболее эффективно понять, откуда пришел звонок?

Собираем телефонные коды


Сначала нам понадобится база телефонных кодов России в привязке к регионам. Для этого можно воспользоваться официальным источником актуальной выпиской из плана нумерации на сайте Федерального агентства связи.

Но найти мало, надо эти данные скачать и извлечь. В этом нам поможет небольшой скрипт для Node.js, использующий библиотеку request:

const async = require('async')  , request = require('request');const fs = require('fs');let queue = [  'ABC-3xx', 'ABC-4xx', 'ABC-8xx', 'DEF-9xx']  .map(key => (    {      base : 'https://rossvyaz.gov.ru'    , path : `/data/${key}.csv`    }  ));let ranges = [];async.doWhilst(  cb => {    // берем из очереди и загружаем очередную страницу    let task = queue.shift();    request(      {        url  : task.base + task.path      , pool : false      }    , (err, res, body) => {        // примитивный разбор CSV        body.split('\n').forEach(line => {          let tds = line.split(';');          let place = tds[5].split('|');          ranges.push([            tds[0]          , tds[1]          , tds[2]          , tds[4]          , place[place.length - 1]          , place[place.length - 2] && place[place.length - 2].startsWith('р-н') ? place[place.length - 2] : ''          , place.length > 1            ? place[0].startsWith('р-н')              ? ''              : place[0]            : ''          ]);        });        return cb(err);      }    );  }  // итерируем, пока очередь заданий непуста, cb => {    return cb(null, queue.length);  }  // когда все распарсили - подчищаем данные и формируем файл для загрузки в БД, err => {    // чистим коды и диапазоны    ranges.forEach(row => {      // убираем пересечение цифр кода и диапазона      let ln = row[0].length + row[1].length - 10;      if (ln > 0) {        let sfx = row[0].slice(-ln);        if (row[1].startsWith(sfx) && row[2].startsWith(sfx)) {          row[1] = row[1].slice(ln);          row[2] = row[2].slice(ln);        }      }      // пересобираем общий префикс      let pfx;      for (let i = 1; i < row[1].length; i++) {        if (row[2].startsWith(row[1].slice(0, i))) {          pfx = row[1].slice(0, i);        }        else {          break;        }      }      if (pfx) {        row[0] = row[0] + pfx;        row[1] = row[1].slice(pfx.length);        row[2] = row[2].slice(pfx.length);      }    });    let sql = `SET client_encoding = 'UTF-8';CREATE TABLE phonecodes(  code    varchar, numb    varchar, nume    varchar, oper    varchar, region    varchar, district    varchar, city    varchar);COPY phonecodes FROM STDIN;`;    // собираем COPY-формат    let copy = ranges.map(row => row.join('\t')).join('\n') + '\n\\.\n';    fs.writeFileSync('phonecodes.sql', sql + copy);  });

Теперь загрузим его в нашу тестовую базу, и можно работать:

psql -f phonecodes.sql -U postgres tst

Если все сработало как надо, в нашу таблицу будет загружено почти 378 тысяч диапазонов:

SETCREATE TABLECOPY 377937

Замечу, что в нашем примере и код, и граничные номера диапазона представлены строками. Да, их можно превратить в integer/bigint, но мы пока не будем этим заниматься. Тем более, что входящий номер телефона не всегда состоит только из цифр например, некоторые таксофоны могут сообщать свой номер с цифрой A.

Ищут пожарные, ищет милиция...


Сначала попробуем наивный запрос:

WITH src AS (  SELECT '4852262000' num -- входящий номер)SELECT  *FROM  src, phonecodesWHERE  num LIKE (code || '%') AND -- проверяем совпадение кода  num BETWEEN (code || numb) AND (code || nume) -- проверяем вхождение в диапазонLIMIT 1;


[посмотреть на explain.tensor.ru]

Вычитали почти 70 тысяч строк (и это еще повезло, что не все 380!), почти 10MB данных перелопатили не слишком эффективно, но результат достигнут:

num        | code   | numb | nume | oper | region           | district | city-----------------------------------------------------------------------------------4852262000 | 485226 | 0000 | 9999 | МТС  | Ярославская обл. |          | Ярославль

Но давайте как-то избавимся от Seq Scan! Для этого нам всего-то нужен индекс, который поможет искать по LIKE, так ведь?..

Увы, нет. Если нам надо искать column LIKE (val || '%'), то нам помогут префиксные индексы с varchar_pattern_ops, но у нас-то все наоборот val LIKE (column || '%'). И мы получаем ситуацию близкую к той, что я описывал в статье Классифицируем ошибки из PostgreSQL-логов.

Используем знания о прикладной области


Близкую, но, к счастью, все-таки существенно проще данные у нас фиксированы и их относительно немного. Причем по кодам записи распределены достаточно разреженно:

SELECT -- сколько кодов с таким кол-вом диапазонов  ranges, count(*)FROM  (    SELECT -- сколько диапазонов по каждому коду      code    , count(*) ranges    FROM      phonecodes    GROUP BY      1  ) TGROUP BY  1ORDER BY  1 DESC;

Только лишь около сотни кодов имеют по 10 диапазонов, а почти четверть вообще ровно один:

ranges | count--------------    10 |   121     9 |   577     8 |  1705     7 |  3556     6 |  6667     5 | 10496     4 | 12491     3 | 20283     2 | 22627     1 | 84453

Поэтому давайте проиндексируем пока только код. А раз все диапазоны одного кода нам понадобятся все вместе упорядочим нашу таблицу с помощью CLUSTER, чтобы записи лежали физически рядом:

CREATE INDEX ON phonecodes(code);CLUSTER phonecodes USING phonecodes_code_idx;

А теперь вспомним, что телефонный номер у нас состоит ровно (всего!) из 10 цифр, среди которых нам надо вычленить префиксный код. То есть наша задача спокойно решается простым перебором не более чем 10 вариантов:

WITH RECURSIVE src AS (  SELECT '4852262000' num), T AS (  SELECT    num pfx -- в качестве исходного "префикса" задаем весь номер  , NULL::phonecodes pc  FROM    srcUNION ALL  SELECT    substr(pfx, 1, length(pfx) - 1) -- "отщипываем" последнюю цифру  , (      SELECT        X      FROM        phonecodes X      WHERE        code = T.pfx AND -- проверяем полное совпадение префикса        (TABLE src) BETWEEN (code || numb) AND (code || nume) -- проверяем вхождение в диапазон      LIMIT 1    ) pc  FROM    T  WHERE    pc IS NOT DISTINCT FROM NULL AND -- ищем, пока ничего не нашли    length(pfx) > 2 -- ... и префикс еще может оказаться кодом)SELECT  (pc).* -- "разворачиваем" найденную запись диапазона в поляFROM  TWHERE  pc IS DISTINCT FROM NULL;


[посмотреть на explain.tensor.ru]

Нам потребовалось всего 5 обращений к индексу, чтобы найти искомый код. Выигрыш кажется микроскопическим в абсолютных цифрах, но мы получили снижение нагрузки в 150 раз относительно наивного варианта! Если вашей системе приходится обрабатывать десятки и сотни тысяч таких запросов в час экономия становится весьма солидной!
А можно делать еще меньше итераций по индексу если все коды заранее привести к классическому виду от 3 до 5 цифр. Правда, тогда возрастет количество диапазонов в каждом коде, и их фильтрация может добавить проблем.

Определяем клиента по номеру


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

Но вот неприятность, далеко не все и них имеют код города то ли менеджеры ленятся забивать, то ли АТС так настроена, что присылает не полные, а внутригородские номера Как тогда найти клиента ведь поиск по полному соответствию уже не сработает?

АТС дает полный номер


В этом случае воспользуемся тем же переборным алгоритмом. Только отщипывать цифры будем не с конца номера, а с начала.

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

Безусловно, нам потребуется какая-то перекрестная проверка по другим реквизитам (адрес, ИНН, ...), чтобы не получилось ситуации, что из входящего номера мы отрезали код Москвы, а по оставшемуся 7-значному номеру нашли клиента из Санкт-Петербурга.

АТС дает городской номер


пришло от АТС      :     262000указано в карточке : 4852262000

Тут ситуация интереснее. Приращивать каждый возможный код к короткому номеру и пробовать искать мы не можем их слишком много. Взглянем на ситуацию с другой стороны буквально:

    reverse(262000) -> 000262reverse(4852262000) -> 0002622584

Оказывается, если развернуть строки с номерами, то задача превращается в обычный префиксный поиск, который легко решается с помощью индекса с varchar_pattern_ops и LIKE!

CREATE INDEX ON client(reverse(phone) varchar_pattern_ops);

SELECT  *FROM  clientWHERE  reverse(phone) LIKE (reverse($1) || '%');

А дальше, опять-таки перепроверяем дополнительную информацию из какого региона АТС нам прислала номер, к какому региону относится клиент.
Подробнее..

PostgreSQL Antipatterns Бесконечность не предел!, или Немного о рекурсии

01.10.2020 22:19:19 | Автор: admin
Рекурсия очень мощный и удобный механизм, если над связанными данными делаются одни и те же действия вглубь. Но неконтролируемая рекурсия зло, которое может приводить или к бесконечному выполнению процесса, или (что случается чаще) к выжиранию всей доступной памяти.


СУБД в этом отношении работают по тем же принципам "сказали копать, я и копаю". Ваш запрос может не только затормозить соседние процессы, постоянно занимая ресурсы процессора, но и уронить всю базу целиком, съев всю доступную память. Поэтому защита от бесконечной рекурсии обязанность самого разработчика.

В PostgreSQL возможность использовать рекурсивные запросы через WITH RECURSIVE появилась еще в незапамятные времена версии 8.4, но до сих пор можно регулярно встретить потенциально-уязвимые беззащитные запросы. Как избавить себя от проблем подобного рода?

Не писать рекурсивные запросы

А писать нерекурсивные. С уважением, Ваш К.О.

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

Использовать принципиально другой подход к задаче


Иногда можно просто взглянуть на задачу с другой стороны. Пример такой ситуации я приводил в статье SQL HowTo: 1000 и один способ агрегации перемножение набора чисел без применения пользовательских агрегатных функций:

WITH RECURSIVE src AS (  SELECT '{2,3,5,7,11,13,17,19}'::integer[] arr), T(i, val) AS (  SELECT    1::bigint  , 1UNION ALL  SELECT    i + 1  , val * arr[i]  FROM    T  , src  WHERE    i <= array_length(arr, 1))SELECT  valFROM  TORDER BY -- отбор финального результата  i DESCLIMIT 1;

Такой запрос можно заменить на вариант от знатоков математики:

WITH src AS (  SELECT unnest('{2,3,5,7,11,13,17,19}'::integer[]) prime)SELECT  exp(sum(ln(prime)))::integer valFROM  src;

Использовать generate_series вместо циклов


Допустим, перед нами стоит задача сгенерировать все возможные префиксы для строки 'abcdefgh':

WITH RECURSIVE T AS (  SELECT 'abcdefgh' strUNION ALL  SELECT    substr(str, 1, length(str) - 1)  FROM    T  WHERE    length(str) > 1)TABLE T;

Точно-точно тут нужна рекурсия?.. Если воспользоваться LATERAL и generate_series, то даже CTE не понадобятся:

SELECT  substr(str, 1, ln) strFROM  (VALUES('abcdefgh')) T(str), LATERAL(    SELECT generate_series(length(str), 1, -1) ln  ) X;

Изменить структуру БД


Например, у вас есть таблица сообщений форума со связями кто-кому ответил или тред в социальной сети:

CREATE TABLE message(  message_id    uuid      PRIMARY KEY, reply_to    uuid      REFERENCES message, body    text);CREATE INDEX ON message(reply_to);


Ну и типовой запрос загрузки всех сообщений по одной теме выглядит примерно так:

WITH RECURSIVE T AS (  SELECT    *  FROM    message  WHERE    message_id = $1UNION ALL  SELECT    m.*  FROM    T  JOIN    message m      ON m.reply_to = T.message_id)TABLE T;

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

-- добавим поле с общим идентификатором темы и индекс на негоALTER TABLE message  ADD COLUMN theme_id uuid;CREATE INDEX ON message(theme_id);-- инициализируем идентификатор темы в триггере при вставкеCREATE OR REPLACE FUNCTION ins() RETURNS TRIGGER AS $$BEGIN  NEW.theme_id = CASE    WHEN NEW.reply_to IS NULL THEN NEW.message_id -- берем из стартового события    ELSE ( -- или из сообщения, на которое отвечаем      SELECT        theme_id      FROM        message      WHERE        message_id = NEW.reply_to    )  END;  RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER ins BEFORE INSERT  ON message    FOR EACH ROW      EXECUTE PROCEDURE ins();


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

SELECT  *FROM  messageWHERE  theme_id = $1;

Использовать прикладные ограничители


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

Счетчик глубины рекурсии


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

WITH RECURSIVE T AS (  SELECT    0 i  ...UNION ALL  SELECT    i + 1  ...  WHERE    T.i < 64 -- предел)

Pro: При попытке зацикливания мы все равно сделаем не более указанного лимита итераций вглубь.
Contra: Нет гарантии, что мы не обработаем повторно одну и ту же запись например, на глубине 15 и 25, ну и дальше будет каждые +10. Да и про вширь никто ничего не обещал.

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

см. Задача о зёрнах на шахматной доске

Хранитель пути


Поочередно дописываем все встретившиеся нам по пути рекурсии идентификаторы объектов в массив, который является уникальным путем до него:

WITH RECURSIVE T AS (  SELECT    ARRAY[id] path  ...UNION ALL  SELECT    path || id  ...  WHERE    id <> ALL(T.path) -- не совпадает ни с одним из)

Pro: При наличии цикла в данных мы абсолютно точно не станем обрабатывать повторно одну и ту же запись в рамках одного пути.
Contra: Но при этом мы можем обойти, буквально, все записи, так и не повторившись.

см. Задача о ходе коня

Ограничение длины пути


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

WITH RECURSIVE T AS (  SELECT    ARRAY[id] path  ...UNION ALL  SELECT    path || id  ...  WHERE    id <> ALL(T.path) AND    array_length(T.path, 1) < 10)

Выбирайте способ на свой вкус!
Подробнее..

PostgreSQL Antipatterns скованные одной цепью EXISTS

14.12.2020 14:08:42 | Автор: admin
Я уже как-то рассказывал про особенности вычисления условий в SQL вообще и в PostgreSQL, в частности. Сегодня продолжим тему и попробуем написать и пооптимизировать простой запрос у кого из сотрудников есть на выполнении суперприоритетные задачи.

CREATE TABLE task ASSELECT  id, (random() * 100)::integer person -- всего 100 сотрудников, least(trunc(-ln(random()) / ln(2)), 10)::integer priority -- каждый следующий приоритет в 2 раза менее вероятенFROM  generate_series(1, 1e5) id; -- 100K задачCREATE INDEX ON task(person, priority);

Слово есть в SQL превращается в EXISTS вот с самого простого варианта и начнем:

SELECT  *FROM  generate_series(0, 99) pidWHERE  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority = 10  );


все картинки планов кликабельны

Пока все выглядит неплохо, но

EXISTS + IN


тут к нам пришли, и попросили к супер отнести не только priority = 10, но еще и 8 и 9:

SELECT  *FROM  generate_series(0, 99) pidWHERE  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority IN (10, 9, 8)  );



Читать стали в 1.5 раза больше, ну и на времени выполнения это сказалось.

OR + EXISTS


Давайте попробуем воспользоваться нашим знанием, что встретить запись с priority = 8 много вероятнее, чем с 10:

SELECT  *FROM  generate_series(0, 99) pidWHERE  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority = 8  ) OR  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority = 9  ) OR  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority = 10  );



Обратите внимание, что PostgreSQL 12 уже достаточно умен, чтобы после 100 поисков по значению 8 делать последующие EXISTS-подзапросы только для ненайденных предыдущими всего 13 по значению 9, и лишь 4 по 10.

CASE + EXISTS + ...


На предыдущих версиях аналогичного результата можно добиться, спрятав под CASE последующие запросы:

SELECT  *FROM  generate_series(0, 99) pidWHERE  CASE    WHEN      EXISTS(        SELECT          NULL        FROM          task        WHERE          person = pid AND          priority = 8      ) THEN TRUE    ELSE      CASE        WHEN          EXISTS(            SELECT              NULL            FROM              task            WHERE              person = pid AND              priority = 9          ) THEN TRUE        ELSE          EXISTS(            SELECT              NULL            FROM              task            WHERE              person = pid AND              priority = 10          )      END  END;

EXISTS + UNION ALL + LIMIT


То же самое, но чуть быстрее можно получить, если воспользоваться хаком UNION ALL + LIMIT:

SELECT  *FROM  generate_series(0, 99) pidWHERE  EXISTS(    (      SELECT        NULL      FROM        task      WHERE        person = pid AND        priority = 8      LIMIT 1    )    UNION ALL    (      SELECT        NULL      FROM        task      WHERE        person = pid AND        priority = 9      LIMIT 1    )    UNION ALL    (      SELECT        NULL      FROM        task      WHERE        person = pid AND        priority = 10      LIMIT 1    )    LIMIT 1  );



Правильные индексы залог здоровья базы


А теперь зайдем на задачу совсем с другой стороны. Если мы точно знаем, что тех task-записей, которые мы хотим найти, в разы меньше, чем остальных так сделаем подходящий частичный индекс. Заодно сразу перейдем от точечного перечисления 8, 9, 10 к >= 8:

CREATE INDEX ON task(person) WHERE priority >= 8;

SELECT  *FROM  generate_series(0, 99) pidWHERE  EXISTS(    SELECT      NULL    FROM      task    WHERE      person = pid AND      priority >= 8  );



В 2 раза быстрее и в 1.5 раза меньше пришлось читать!

Но ведь, наверное, вычитать сразу вообще все подходящие task сразу будет еще быстрее?..

SELECT DISTINCT  personFROM  taskWHERE  priority >= 8;



Далеко не всегда, и точно не в этом случае потому что вместо 100 чтений первых попавшихся записей, на приходится вычитывать больше 400!

А чтобы не гадать, какой из вариантов запроса будет более эффективен, а знать это уверенно пользуйтесь explain.tensor.ru.
Подробнее..

Множественные источники данных в интерфейсе client-side SQL

25.05.2021 12:05:50 | Автор: admin

Иногда в интерфейсе наших приложений СБИС возникаетнеобходимость "сгруппировать" часть записейв некотором списке (например, служебные сообщения в чате, контакты и телефонные звонки).

Хорошо, если все эти записи приходят с одного источника, а вот еслииз разных сервисов, да с навигацией по курсору- алгоритм реализации становится весьма нетривиальным.

Я целенаправленно не буду здесь приводить реализацию "в коде", а опишу исключительно алгоритмический подход к решению, чтобы при необходимости вы могли его самостоятельно смасштабировать на свои задачи. Итак...

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

У нас есть два сервиса. Как бы может быть и больше, но, следуя предыдущей картинке, пусть для определенности это будутсервисы Звонков и Контактов.

Спасибо коллегам из CRM за интересную задачу.

Хотим в карточке организациив сквозном хронологическом порядкепо дате выводить контакты и звонки, но все звонки между соседними записями контактов"схлопнуть" в единственную записьс указанием их количества.

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

Группировка нескольких звонков в одну записьГруппировка нескольких звонков в одну запись

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

Неудачное решение #1: "дай мне все"

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

Да и для сервиса задача "отдать все" совсем нелегка, если там данных за несколько лет работы. Но зачем нам "все", если пользовательдальше первой страницы листает очень редко?

Неудачное решение #2: "частый гребень"

Так, нам контакты группировать не надо?.. Давайте тогдазапросим первую страницу (20 записей) с сервиса контактов, адля каждого интерваладат между "соседними" контактами спросим, что там есть в звонках - сразу и количество получим.

А теперь давайте представим, что у нас все звонки (или очень много) оказались хронологически "над" первым же контактом - что будет? Абудут те же самые тормоза в интерфейсе, что и в предыдущем варианте.

Кроме того, мы илиотправим на сервис много запросов(на каждый из интервалов), чем создадим избыточную нагрузку. Или отправимодин запрос со списком всех интервалов, но он заведомо будет отрабатывать "долго".

Удачное решение #1: "чтение сегментами"

Набив шишек на предыдущих решениях, приходим к выводу, что нам надозапрашивать данные у обоих сервисов по курсору- сколько-то записей (пусть будет 20), начиная с какого-то индексного ключа.

Что дальше делать с двумяупорядоченными сегментамиданных, достаточно очевидно - сливаем (merge ordered) и отрезаем (limit) от упорядоченного все записи после ближайшего из "крайних" ключей от каждого из сервисов.

Например, в нашем примере получилось, что ключ времени "крайнего" звонка соответствует только 15 из 20 прочитанных контактов. Про порядок оставшихся 5 контактов и других звонков мы не можем ничего сказать, потому что "других звонков" как раз нету в обозримом пространстве - поэтомунарисовать их пока не можем.

Неудачное решение #3: "One Ring to rule them all"

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

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

Неудачное решение #4: "два ключа на server-side"

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

Поскольку у насstateless server-side БЛ, то либо мы их таки и не сохраним, или вынуждены будем городить где-тоотдельное хранилище состояний. Сделать это можно, но совсем не просто:

  • хранение сегментов должно происходить по уникальномуключу экземпляра-выборки-на-странице

  • необходимаполитика инвалидацииэтих данных со временем, чтобы память не "текла"

  • работа с этим хранилищем подразумеваетдополнительные издержки на сериализацию-пересылку-десериализацию

Удачное решение #2: "два ключа на client-side"

Собственно, а зачем нам уносить все это на сервер-сайд, если вседанные нам нужны только на клиенте?.. Давайте их там и оставим.

То есть ровно теданные, которые "не отрисовали" оставить хранитьсяна клиенте (например, прямо в памяти вкладки, даже не в localStorage), пока нам не понадобится их нарисовать.

В нашем предыдущем примере получится что-то вроде:

  • прочитали параллельно 20 контактов и 20 звонков

  • звонки "сгруппировали" в 5 записей

  • нарисовали 5 "групповых" звонков + 15 контактов

  • 5 ненарисованных конктактов оставили в хранилище

  • до 20 чего-то не хватает? запрашиваем! (контакты и звонки по 20, параллельно от своих "крайних" ключей)

  • "задача сведена к предыдущей", только у нас уже сразу 25 контактов на 20 звонков есть

Edge Cases

Фактически, единственный отрицательный эффект у такого решения - последняя нарисованная"групповая" запись может "крутить счетчиком", пока мы дочитываем "вливающиеся" в нее все новые и новые записи.

К счастью, такая ситуация достаточнопозитивно воспринимается в интерфейсе, поскольку мы показываем пользователю: "Эй, все хорошо, мы не умерли, не повисли, мы работаем!"

Подробнее..

SQL HowTo красивые отчеты по дырявым данным GROUPING SETS

28.07.2020 10:08:24 | Автор: admin
Для пользователя наш СБИС представляется единой системой управления бизнесом, но внутри состоит из множества взаимодействующих сервисов. И чем их становится больше тем выше вероятность возникновения каких-то неприятностей, которые необходимо вовремя отлавливать, исследовать и пресекать.

Поэтому, когда на каком-то из тысяч подконтрольных серверов случается аномальное потребление ресурсов (CPU, памяти, диска, сети, ...), возникает потребность разобраться кто виноват, и что делать.


Для оперативного мониторинга использования ресурсов Linux-сервера в моменте существует утилита pidstat. То есть если пики нагрузки периодичны их можно высидеть прямо в консоли. Но мы-то хотим эти данные анализировать постфактум, пытаясь найти процесс, создавший максимальную нагрузку на ресурсы.

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



В этой статье рассмотрим, как все это можно экономично расположить в БД, и как максимально эффективно собрать по этим данным отчет с помощью оконных функций и GROUPING SETS.

Сначала посмотрим, что за данные мы можем извлечь, если брать все по максимуму:

pidstat -rudw -lh 1
Time UID PID %usr %system %guest %CPU CPU minflt/s majflt/s VSZ RSS %MEM kB_rd/s kB_wr/s kB_ccwr/s cswch/s nvcswch/s Command
1594893415 0 1 0.00 13.08 0.00 13.08 52 0.00 0.00 197312 8512 0.00 0.00 0.00 0.00 0.00 7.48 /usr/lib/systemd/systemd --switched-root --system --deserialize 21
1594893415 0 9 0.00 0.93 0.00 0.93 40 0.00 0.00 0 0 0.00 0.00 0.00 0.00 350.47 0.00 rcu_sched
1594893415 0 13 0.00 0.00 0.00 0.00 1 0.00 0.00 0 0 0.00 0.00 0.00 0.00 1.87 0.00 migration/11.87

Все эти значения делятся на несколько классов. Некоторые из них меняются постоянно (активность CPU и диска), другие редко (выделение памяти), а Command не только редко меняется в рамках одного процесса, но еще и регулярно повторяется на разных PID.

Структура базы


Для простоты давайте ограничимся одной метрикой каждого класса, которые мы будем сохранять: %CPU, RSS и Command.

Раз мы заведомо знаем, что Command регулярно повторяется просто вынесем его в отдельную таблицу-словарь, где UUID-ключом будет выступать MD5-хэш:

CREATE TABLE diccmd(  cmd    uuid      PRIMARY KEY, data    varchar);

А для самих данных нам подойдет таблица такого вида:

CREATE TABLE pidstat(  host    uuid, tm    integer, pid    integer, cpu    smallint, rss    bigint, cmd    uuid);

Обращу внимание, что раз %CPU приходит к нам всегда с точностью 2 знаков после запятой и заведомо не превышает 100.00, то мы спокойно можем домножить его на 100 и положить в smallint. С одной стороны, это избавит нас от проблем точности учета при операциях, с другой все-таки лучше хранить только 2 байта по сравнению с 4 байтами real или 8 байтами double precision.
Подробнее о способах эффективной упаковки записей в PostgreSQL-хранилище можно прочитать в статье Экономим копеечку на больших объемах, а про увеличение пропускной способности базы на запись в Пишем на субсветовой: 1 host, 1 day, 1TB.

Бесплатное хранение NULL'ов


Чтобы сэкономить производительность дисковой подсистемы нашей базы и занимаемый базой объем, постараемся как можно больше данных представить в виде NULL их хранение практически бесплатно, поскольку занимает лишь бит в заголовке записи.
Подробнее с внутренней механикой представления записей в PostgreSQL можно ознакомиться в докладе Николая Шаплова на PGConf.Russia 2016 Что у него внутри: хранение данных на низком уровне. Конкретно хранению NULL посвящен слайд #16.
Снова внимательно посмотрим на виды наших данных:

  • CPU/DSK
    Меняется постоянно, но очень часто обращается в ноль так что выгодно писать в базу NULL вместо 0.
  • RSS/CMD
    Меняется достаточно редко поэтому будем писать NULL вместо повторов в рамках одного и того же PID.

Получается картинка вроде такой, если смотреть на нее в разрезе конкретного PID:



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

То есть у записи с заполненным значением CMD заполнено и значение RSS. Запомним этот момент, он нам еще пригодится.

Собираем красивый отчет


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

Но сделаем это сразу с минимальным использованием ресурсов примерно как в статье про SELF JOIN и оконные функции.

Использование входящих параметров


Чтобы не указывать значения параметров отчета (или $1/$2) в нескольких местах по ходу SQL-запроса, выделим CTE из единственного json-поля, в котором по ключам находятся эти самые параметры:

-- сохраняем параметры отчетаWITH args AS (  SELECT    json_object(      ARRAY[        'dtb'      , extract('epoch' from '2020-07-16 10:00'::timestamp(0)) -- переводим timestamp в integer      , 'dte'      , extract('epoch' from '2020-07-16 10:01'::timestamp(0))      , 'host'      , 'e828a54d-7e8a-43dd-b213-30c3201a6d8e' -- это у нас uuid      ]::text[]    ))

Извлекаем сырые данные


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

CREATE INDEX ON pidstat(host, tm);

-- извлекаем "сырые" данные, src AS (  SELECT    *  FROM    pidstat  WHERE    host = ((TABLE args) ->> 'host')::uuid AND    tm >= ((TABLE args) ->> 'dtb')::integer AND    tm <  ((TABLE args) ->> 'dte')::integer)

Группировка по ключу анализа


Для каждого найденного PID определим интервал его активности и возьмем CMD с первой записи на этом интервале.



Для этого воспользуемся уникализацией через DISTINCT ON и оконными функциями:

-- группировка по ключу анализа, pidtm AS (  SELECT DISTINCT ON(pid)    host  , pid  , cmd  , min(tm) OVER(w) tmb -- начало активности процесса на интервале  , max(tm) OVER(w) tme -- завершение активности  FROM    src  WINDOW    w AS(PARTITION BY pid)  ORDER BY    pid  , tm)

Границы активности процесса


Заметим, что относительно начала нашего интервала первой попавшейся записью может оказаться как та, которая уже имеет заполненное поле CMD (PID#1 на картинке выше), так и с NULL'ом, обозначающим продолжение заполненного выше по хронологии значения (PID#2).

Те из PID, которые остались без CMD в результате предыдущей операции, начались раньше начала нашего интервала значит, эти начала надо найти:



Поскольку мы точно знаем, что очередной сегмент активности начинается с заполненного значения CMD (а там и заполненный RSS, значит), тут нам поможет условный индекс:

CREATE INDEX ON pidstat(host, pid, tm DESC) WHERE cmd IS NOT NULL;

-- определяем начало активности каждого "неопределившегося" процесса, precmd AS (  SELECT    t.host  , t.pid  , c.tm  , c.rss  , c.cmd  FROM    pidtm t  , LATERAL(      SELECT        *      FROM        pidstat -- увы, SELF JOIN не избежать      WHERE        (host, pid) = (t.host, t.pid) AND        tm < t.tmb AND        cmd IS NOT NULL -- садимся на условный индекс      ORDER BY        tm DESC      LIMIT 1  ) c  WHERE    t.cmd IS NULL -- только для "неопределившихся")

Если мы хотим (а мы хотим) знать время окончания активности сегмента, то уже для каждого PID придется воспользоваться двухходовкой для определения нижней границы.
Аналогичную методику мы уже использовали в статье PostgreSQL Antipatterns: навигация по реестру.



-- определяем момент окончания активности сегмента, pstcmd AS (  SELECT    host  , pid  , c.tm  , NULL::bigint rss  , NULL::uuid cmd  FROM    pidtm t  , LATERAL(      SELECT        tm      FROM        pidstat      WHERE        (host, pid) = (t.host, t.pid) AND        tm > t.tme AND        tm < coalesce((          SELECT            tm          FROM            pidstat          WHERE            (host, pid) = (t.host, t.pid) AND            tm > t.tme AND            cmd IS NOT NULL          ORDER BY            tm          LIMIT 1        ), x'7fffffff'::integer) -- MAX_INT4      ORDER BY        tm DESC      LIMIT 1  ) c)

JSON-конвертация форматов записей


Замечу, что мы отбирали в precmd/pstcmd только те поля, которые влияют на последующие строки, а всякие CPU/DSK, которые меняются постоянно нет. Поэтому формат записей в исходной таблице и этих CTE у нас расходится. Не беда!

  • row_to_json превращаем каждую запись с полями в json-объект
  • array_agg собираем все записи в '{...}'::json[]
  • array_to_json преобразуем массив-из-JSON в JSON-массив '[...]'::json
  • json_populate_recordset генерируем из JSON-массива выборку заданной структуры

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

-- склеиваем все, uni AS (  TABLE srcUNION ALL  SELECT    *  FROM    json_populate_recordset( -- развернули в полный      NULL::pidstat    , (        SELECT          array_to_json(array_agg(row_to_json(t))) -- свернули сокращенный формат        FROM          (            TABLE precmd          UNION ALL            TABLE pstcmd          ) t      )    ))


Заполняем NULL-пропуски повторов

Воспользуемся моделью, рассмотренной в статье SQL HowTo: собираем цепочки с помощью window functions.
Сначала выделим группы повторов:

-- выделение групп, grp AS (  SELECT    *  , count(*) FILTER(WHERE cmd IS NOT NULL) OVER(w) grp  -- группы по CMD  , count(*) FILTER(WHERE rss IS NOT NULL) OVER(w) grpm -- группы по RSS  FROM    uni  WINDOW    w AS(PARTITION BY pid ORDER BY tm))

Причем по CMD и RSS группы будут независимы друг от друга, поэтому могут выглядеть примерно так:



Заполним пропуски по RSS и посчитаем продолжительность каждого отрезка, чтобы корректно учесть распределение нагрузки по времени:

-- заполняем пропуски, rst AS (  SELECT    *  , CASE      WHEN least(coalesce(lead(tm) OVER(w) - 1, tm), ((TABLE args) ->> 'dte')::integer - 1) >= greatest(tm, ((TABLE args) ->> 'dtb')::integer) THEN        least(coalesce(lead(tm) OVER(w) - 1, tm), ((TABLE args) ->> 'dte')::integer - 1) - greatest(tm, ((TABLE args) ->> 'dtb')::integer) + 1    END gln -- продолжительность сегмента от предыдущей записи или начала интервала  , first_value(rss) OVER(PARTITION BY pid, grpm ORDER BY tm) _rss -- заполнение пропусков по RSS  FROM    grp  WINDOW    w AS(PARTITION BY pid, grp ORDER BY tm))



Мультигруппировка с помощью GROUPING SETS


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

-- мультигруппировка, gs AS (  SELECT    pid  , grp  , max(grp) qty -- количество сегментов активности по PID  , (array_agg(cmd ORDER BY tm) FILTER(WHERE cmd IS NOT NULL))[1] cmd -- "должен остаться только один"  , sum(cpu) cpu  , avg(_rss)::bigint rss  , min(tm) tmb  , max(tm) tme  , sum(gln) gln  FROM    rst  GROUP BY    GROUPING SETS((pid, grp), pid))


Вариант использования (array_agg(... ORDER BY ..) FILTER(WHERE ...))[1] позволяет нам прямо при группировке, без дополнительных телодвижений получить первое непустое (даже если оно не самое первое) значение из всего набора.
Вариант получения сразу нескольких разрезов целевой выборки очень удобен для формирования различных отчетов с детализацией, чтобы все детализирующие данные не надо было перестраивать, а чтобы в UI они попадали вместе с основной выборкой.

Словарь вместо JOIN


Создаем словарь CMD для всех найденных сегментов:
Подробнее про методику ословаривания можно прочесть в статье PostgreSQL Antipatterns: ударим словарем по тяжелому JOIN.

-- словарь CMD, cmdhs AS (  SELECT    json_object(      array_agg(cmd)::text[]    , array_agg(data)    )  FROM    diccmd  WHERE    cmd = ANY(ARRAY(      SELECT DISTINCT        cmd      FROM        gs      WHERE        cmd IS NOT NULL    )))

А теперь используем его вместо JOIN, получая финальные красивые данные:

SELECT  pid, grp, CASE    WHEN grp IS NOT NULL THEN -- это "сегмент" активности      cmd  END cmd, (nullif(cpu::numeric / gln, 0))::numeric(32,2) cpu -- приводим CPU к "средней" нагрузке, nullif(rss, 0) rss, tmb -- верхняя граница активности, tme -- нижняя граница активности, gln -- продолжительность активности, CASE    WHEN grp IS NULL THEN -- это весь процесс      qty  END cnt, CASE    WHEN grp IS NOT NULL THEN      (TABLE cmdhs) ->> cmd::text -- извлекаем данные из словаря  END commandFROM  gsWHERE  grp IS NOT NULL OR -- это запись "сегмента"  qty > 1 -- или в процессе больше одного сегментаORDER BY  pid DESC, grp NULLS FIRST;



Напоследок убедимся, что весь наш запрос при выполнении оказался достаточно легковесным:


[посмотреть на explain.tensor.ru]

Всего 44ms и 33MB данных прочитано!
Подробнее..

SQL HowTo курсорный пейджинг с неподходящей сортировкой

05.09.2020 22:11:28 | Автор: admin
Этот пост родился как расширенный ответ на умозрительную задачу, обозначенную в статье Хроники пэйджинга.

Пусть у нас есть реестр документов, с которым работают операторы или бухгалтеры в СБИС, вроде такого:



Традиционно, при подобном отображении используется или прямая (новые снизу) или обратная (новые сверху) сортировка по дате и порядковому идентификатору, назначаемому при создании документа ORDER BY dt, id или ORDER BY dt DESC, id DESC.

Типичные возникающие при этом проблемы я уже рассматривал в статье PostgreSQL Antipatterns: навигация по реестру. Но что если пользователю зачем-то захотелось нетипичного например, отсортировать одно поле так, а другое этак ORDER BY dt, id DESC? Но второй индекс мы создавать не хотим ведь это замедление вставки и лишний объем в базе.

Можно ли решить эту задачу, эффективно используя только индекс (dt, id)?

Давайте сначала нарисуем, как упорядочен наш индекс:



Отмечу, что порядок создания записей id не обязательно совпадает с порядком dt, поэтому опираться на него мы не можем, и придется что-то изобретать.

Теперь предположим, что мы находимся в точке (A, 2) и хотим прочитать следующие 6 записей в сортировке ORDER BY dt, id DESC:



Ага! Мы выбрали какой-то кусочек из первого узла A, другой кусочек из последнего узла C и все записи из узлов между ними (B). Каждый такой блок вполне успешно ложится на чтение по индексу (dt, id), несмотря на не вполне подходящий порядок.

Давайте попробуем сконструировать такой запрос:

  • сначала читаем из блока A влево от стартовой записи получаем N записей
  • дальше читаем L - N вправо от значения A
  • находим в последнем блоке максимальный ключ C
  • отфильтровываем из предыдущей выборки все записи этим ключом и вычитываем его справа

А теперь попробуем изобразить в коде и проверить на модели:

CREATE TABLE doc(  id    serial, dt    date);CREATE INDEX ON doc(dt, id); -- наш индекс-- случайно "раскидаем" документы по последнему годуINSERT INTO doc(dt)SELECT  now()::date - (random() * 365)::integerFROM  generate_series(1, 10000);

Чтобы не вычислять количество уже прочитанных записей и разность между ним и целевым количеством, заставим это делать PostgreSQL с помощью хака UNION ALL и LIMIT:

(  ... LIMIT 100)UNION ALL(  ... LIMIT 100)LIMIT 100

Теперь соберем начитку следующих 100 записей с целевой сортировкой (dt, id DESC) от последнего известного значения:

WITH src AS (  SELECT    '{"dt" : "2019-09-07", "id" : 2331}'::json -- "опорный" ключ), pre AS (  (    ( -- читаем не более 100 записей "влево" от опорной точки из "левого" значения A      SELECT        *      FROM        doc      WHERE        dt = ((TABLE src) ->> 'dt')::date AND        id < ((TABLE src) ->> 'id')::integer      ORDER BY        dt DESC, id DESC      LIMIT 100    )    UNION ALL    ( -- дочитываем до 100 записей "вправо" от исходного значения "верхнего" ключа A -> B, C      SELECT        *      FROM        doc      WHERE        dt > ((TABLE src) ->> 'dt')::date      ORDER BY        dt, id      LIMIT 100    )  )  LIMIT 100)-- находим крайний правый ключ C в том, что прочитали, maxdt AS (  SELECT    max(dt)  FROM    pre  WHERE    dt > ((TABLE src) ->> 'dt')::date)(  ( -- вычищаем "левые" записи по ключу C    SELECT      *    FROM      pre    WHERE      dt <> (TABLE maxdt)    LIMIT 100  )  UNION ALL  ( -- дочитываем "правые" записи по ключу C до 100 штук    SELECT      *    FROM      doc    WHERE      dt = (TABLE maxdt)    ORDER BY      dt DESC, id DESC    LIMIT 100  )  LIMIT 100)ORDER BY  dt, id DESC; -- накладываем целевую сортировку, поскольку записи по B у нас лежат не в том порядке

Посмотрим, что получилось в плане:


[посмотреть на explain.tensor.ru]

  • Итак, по первому ключу A = '2019-09-07' мы прочитали 3 записи.
  • К ним дочитали еще 97 по B и C за счет точного попадания в Index Scan.
  • Среди всех записей отфильтровали 18 по максимальному ключу C.
  • Дочитали 23 записи (вместо 18 искомых из-за Bitmap Scan) по максимальному ключу.
  • Все пересортировали и отсекли целевые 100 записей.
  • и на все это ушло меньше миллисекунды!

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

SQL HowTo обрабатываем дерево упорядочиваем иерархию с рекурсией и без

19.10.2020 20:09:27 | Автор: admin
Видимо, это осень так влияет, что за последний месяц на PostgreSQL уже и в Морской бой играли, и Жизнь Конвея эмулировали Что уж оставаться в стороне! Давайте и мы потренируем мозг в реализации нетривиальных алгоритмов на SQL.

Тем более, сегодняшняя тема родилась из обсуждения моей же статьи PostgreSQL Antipatterns: Бесконечность не предел!, или Немного о рекурсии, где я показал, как можно заменить рекурсивное чтение иерархичных данных из таблицы на линейное.

Прочитать-то мы прочитали, но ведь чтобы для вывода упорядочить элементы дерева в соответствии с иерархией, уж точно придется воспользоваться рекурсией! Или нет? Давайте разберемся, а заодно решим на SQL пару комбинаторных задач.


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



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


Давайте все-таки сначала формально определим те правила, которым должен отвечать искомый порядок записей:

  • элемент-предок стоит раньше любого из элементов его поддерева
  • дочерние элементы внутри одного родительского упорядочиваются по дополнительному прикладному ключу

Второе требование отражает привычные для пользователя паттерны, когда папки внутри одного каталога отображаются отсортированными по названию, а ответы на сообщение в ветке форума по времени.

Мы, для простоты, возьмем в нашем примере в качестве такого ключа data.

Таков путь!


Собственно, а что мы такое можем сконструировать, чтобы сортировка этого чего-то давала нам желаемый результат?

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



Но если на каждом шаге такого пути мы сортируем элементы согласно прикладному ключу, то и массивы, составленные из этих ключей, будучи отсортированными, дадут нам искомый порядок записей!

Рекурсивная сортировка


Сначала воспользуемся наиболее традиционным и очевидным способом получения нужного нам результата рекурсивным запросом:

WITH RECURSIVE src(id, pid, data) AS (  VALUES    (1, NULL, 'A')  , (2, 1, 'AA')  , (3, 2, 'AAA')  , (4, 1, 'AB')  , (5, 2, 'AAB')  , (6, 3, 'AAAA')  , (7, 5, 'AABA')  , (8, 4, 'ABA')  , (9, 7, 'AABAA')  , (10, 2, 'AAC')), T AS (  SELECT    id  , ARRAY[data] path -- инициализируем массив пути корневым элементом  FROM    src  WHERE    pid IS NULLUNION ALL  SELECT    s.id  , T.path || s.data -- наращиваем путь  FROM    T  JOIN    src s      ON s.pid = T.id)SELECT  *FROM  srcNATURAL JOIN  TORDER BY  path; -- сортируем согласно пути

 id | pid | data  |         path----+-----+-------+-----------------------  1 |     | A     | {A}  2 |   1 | AA    | {A,AA}  3 |   2 | AAA   | {A,AA,AAA}  6 |   3 | AAAA  | {A,AA,AAA,AAAA}  5 |   2 | AAB   | {A,AA,AAB}  7 |   5 | AABA  | {A,AA,AAB,AABA}  9 |   7 | AABAA | {A,AA,AAB,AABA,AABAA} 10 |   2 | AAC   | {A,AA,AAC}  4 |   1 | AB    | {A,AB}  8 |   4 | ABA   | {A,AB,ABA}

Подключаем комбинаторику


А теперь вернемся к началу статьи и подумаем, как же все-таки для той же самой задачи мы можем создать нерекурсивное решение. В этом нам поможет
Комбинаторика (комбинаторный анализ) раздел математики, изучающий дискретные объекты, множества (сочетания, перестановки, размещения и перечисления элементов) и отношения на них (например, частичного порядка).
Сначала решим несколько небольших типовых задач из этой области.

Комбинации


Пусть у нас есть исходный массив {A,B,C}, все элементы которого уникальны. Получим все комбинации массивов той же длины, состоящие из его элементов:

{A,A,A}{A,A,B}{A,A,C}{A,B,A}{A,B,B}...{C,C,B}{C,C,C}

Достаточно очевидно, что при длине массива N таких вариантов будет ровно N^N, но как получить их на SQL?

Обратим внимание, что каждой комбинации элементов соответствует комбинация позиций этих элементов в исходном массиве, если пронумеровать их с нуля. А каждой такой комбинации число в N-ричной системе счисления:

3^2 |  0  0  0  0  0  0  0  0  0  1  1  1  1  1  1  1  1  1  2  2  2  2  2  2  2  2  23^1 |  0  0  0  1  1  1  2  2  2  0  0  0  1  1  1  2  2  2  0  0  0  1  1  1  2  2  23^0 |  0  1  2  0  1  2  0  1  2  0  1  2  0  1  2  0  1  2  0  1  2  0  1  2  0  1  2=== |  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

Решение становится достаточно очевидным:

  • генерируем каждое число в диапазоне 0 .. N^N - 1
  • раскладываем в N-ричную систему счисления
  • берем элемент на соответствующей позиции разложения

SELECT  dstFROM  -- исходный набор элементов  (VALUES('{A,B,C}'::varchar[])) data(src)  -- кэшируем размер набора, LATERAL array_length(src, 1) n  -- кэшируем границу интервала, LATERAL (SELECT (n ^ n)::bigint l) X  -- генерируем все числа на интервале, LATERAL generate_series(0, l - 1) num  -- формируем разложение числа в N-ричной системе, LATERAL (    SELECT      array_agg((num % (n ^ (pos + 1))::bigint) / (n ^ pos)::bigint ORDER BY pos DESC) num_n    FROM      generate_series(0, n - 1) pos  ) Y  -- собираем элементы согласно "цифрам", LATERAL (    SELECT      array_agg(src[num_n[pos] + 1]) dst    FROM      generate_subscripts(num_n, 1) pos  ) Z;

Перестановки


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

JOIN LATERAL(  SELECT    count(DISTINCT unnest) = n cond  FROM    unnest(num_n)) flt  ON cond

Полный вариант запроса
SELECT  dstFROM  -- исходный набор элементов  (VALUES('{A,B,C}'::varchar[])) data(src)  -- кэшируем размер набора, LATERAL array_length(src, 1) n  -- кэшируем границу интервала, LATERAL (SELECT (n ^ n)::bigint l) X  -- генерируем все числа на интервале, LATERAL generate_series(0, l - 1) num  -- формируем разложение числа в N-ричной системе, LATERAL (    SELECT      array_agg((num % (n ^ (pos + 1))::bigint) / (n ^ pos)::bigint ORDER BY pos DESC) num_n    FROM      generate_series(0, n - 1) pos  ) Y  -- фильтруем комбинации с неполным набором "цифр"JOIN LATERAL(    SELECT      count(DISTINCT unnest) = n cond    FROM      unnest(num_n)  ) flt    ON cond  -- собираем элементы согласно "цифрам", LATERAL (    SELECT      array_agg(src[num_n[pos] + 1]) dst    FROM      generate_subscripts(num_n, 1) pos  ) Z;

Это дает нам все возможные перестановки исходного набора:

{A,B,C}{A,C,B}{B,A,C}{B,C,A}{C,A,B}{C,B,A}

Можно использовать и неэкспоненциальный алгоритм на основе тасований, работающий за O(N*N!), но его реализация явно выходит за рамки данной статьи.

Подмножества


Сделаем шаг чуть в сторону и научимся генерировать все подмножества исходного набора с сохранением порядка. То есть для нашего набора {A,B,C} должно получиться вот это:

{}{A}{B}{A,B}{C}{A,C}{B,C}{A,B,C}

В принципе, задача очень близка к первой решенной нами. Только вместо позиции двоичная цифра означает отсутствие или наличие соответствующего элемента в результирующем наборе:

2^2 |  0  0  0  0  1  1  1  12^1 |  0  0  1  1  0  0  1  12^0 |  0  1  0  1  0  1  0  1=== |  0  1  2  3  4  5  6  7

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

  -- кэшируем разложение числа в двоичной системе, LATERAL (SELECT num::bit(64) num_n) Y  -- собираем элементы согласно "цифрам", LATERAL (    SELECT      coalesce(array_agg(src[i]) FILTER(WHERE get_bit(num_n, 64 - i) = 1), '{}') dst    FROM      generate_series(1, n) i  ) Z

Полный вариант запроса
SELECT  dstFROM  -- исходный набор элементов  (VALUES('{A,B,C}'::varchar[])) data(src)  -- кэшируем размер набора, LATERAL array_length(src, 1) n  -- кэшируем границу интервала, LATERAL (SELECT (2 ^ n)::bigint l) X  -- генерируем все числа на интервале, LATERAL generate_series(0, l - 1) num  -- кэшируем разложение числа в двоичной системе, LATERAL (SELECT num::bit(64) num_n) Y  -- собираем элементы согласно "цифрам", LATERAL (    SELECT      coalesce(array_agg(src[i]) FILTER(WHERE get_bit(num_n, 64 - i) = 1), '{}') dst    FROM      generate_series(1, n) i  ) Z;

Иерархия без рекурсии!


Теперь, вооруженные всеми приведенными выше алгоритмами, попробуем найти решение нашей задачи сначала в общем виде, а затем разберем один из типичных частных случаев.

Пути-дороги


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

Но сортировать по такому пути, конечно, будет некорректно поэтому для дальнейшей сортировки превратим id записей в соответствующее значение data, которое использовали в первом варианте.
Дано: газовая плита, чайник. Задача: вскипятить воду.
Физик: Зажечь плиту, наполнить чайник водой и поставить на плиту, ждать.
Математик: Аналогично.

Дано: зажженная газовая плита, наполненный водой чайник. Задача: вскипятить воду.
Физик: Поставить чайник на плиту, ждать.
Математик: Выливаем воду из чайника на плиту. Огонь погас, чайник пуст, задача сведена к предыдущей.
Народный анекдот
Но как найти путь до каждого из элементов без рекурсии? Вот здесь нам и пригодятся алгоритмы выше.

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

  • Правило #1: начинается и заканчивается нужными нам элементами
    path[1] = root AND path[array_length(path, 1)] = id
  • Правило #2: предок каждого элемента, кроме корневого, так же присутствует в наборе
    pid = ANY(path) OR pid = root
  • Правило #3: из всех таких наборов искомый самой маленькой длины
    Иначе для id=3 наборы {1,2,3} и {1,2,3,4} окажутся равноподходящими, поскольку предок id=4 (pid=1) тоже присутствует.
  • Правило #4: предок каждого элемента стоит ровно в предыдущей позиции
    pid = path[pos - 1]

Итак, намечаем план действий:

  • генерируем все подмножества элементов, исключая root и id, формируя тело пути по правилу #1
  • проверяем выполнение правила #2
  • выбираем, согласно правилу #3, самый короткий набор
  • генерируем все перестановки его элементов
  • проверяем выполнение правила #4
  • что осталось искомый путь

Полный вариант запроса, смотреть с осторожностью
Я вас предупредил [источник картинки]

WITH src(id, pid, data) AS (  VALUES    (1, NULL, 'A')  , (2, 1, 'AA')  , (3, 2, 'AAA')  , (4, 1, 'AB')  , (5, 2, 'AAB')  , (6, 3, 'AAAA')  , (7, 5, 'AABA')  , (8, 4, 'ABA')  , (9, 7, 'AABAA')  , (10, 2, 'AAC'))-- кэшируем ID корневого элемента, root AS (  SELECT    id  FROM    src  WHERE    pid IS NULL  LIMIT 1)-- формируем уже известные пути и предварительные наборы, preset AS (  SELECT    *  , CASE      -- для корневого элемента путь состоит только из него самого      WHEN pid IS NULL THEN ARRAY[id]      -- для ссылающегося на корневой - из пары      WHEN pid = (TABLE root) THEN ARRAY[pid, id]    END prepath  , CASE      WHEN pid IS NULL THEN NULL      WHEN pid = (TABLE root) THEN NULL      -- все ID, кроме корневого и собственного - EXCLUDE CURRENT ROW      ELSE array_agg(id) FILTER(WHERE pid IS NOT NULL) OVER(ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW)    END preset  FROM    src)-- формируем "переборные" пути, prepath AS (  SELECT    id  , prepath  FROM    -- отбираем только элементы, чей путь еще не определили    (      SELECT        id      , pid      , preset src -- комбинируемый набор      FROM        preset      WHERE        prepath IS NULL    ) data    -- подмножества  , LATERAL (      SELECT        dst pathset      FROM        -- кэшируем размер набора        LATERAL array_length(src, 1) n        -- кэшируем границу интервала      , LATERAL (SELECT (2 ^ n)::bigint l) X        -- генерируем все числа на интервале      , LATERAL generate_series(1, l - 1) num -- тут можно с 1, поскольку пустой набор нас заведомо не интересует        -- кэшируем разложение числа в двоичной системе      , LATERAL (SELECT num::bit(64) num_n) Y        -- собираем элементы согласно "цифрам"      , LATERAL (          SELECT            coalesce(array_agg(src[i]) FILTER(WHERE get_bit(num_n, 64 - i) = 1), '{}') || data.id dst          FROM            generate_series(1, n) i        ) Z        -- проверяем наличие предка в наборе      , LATERAL (          SELECT            NULL          FROM            (              SELECT                (SELECT pid FROM src WHERE id = dst[i] LIMIT 1) _pid              FROM                generate_subscripts(dst, 1) i            ) T          HAVING            bool_and(_pid = (TABLE root) OR _pid = ANY(dst))        ) rule2      -- отбираем первый подходящий минимальной длины      ORDER BY        array_length(dst, 1) -- rule3      LIMIT 1    ) X    -- перестановки  , LATERAL (      SELECT        dst prepath      FROM        -- исходный набор элементов        (SELECT pathset) data(src)        -- кэшируем размер набора      , LATERAL array_length(src, 1) n        -- кэшируем границу интервала      , LATERAL (SELECT (n ^ n)::bigint l) X        -- генерируем все числа на интервале      , LATERAL generate_series(0, l - 1) num        -- формируем разложение числа в N-ричной системе      , LATERAL (          SELECT            array_agg((num % (n ^ (pos + 1))::bigint) / (n ^ pos)::bigint ORDER BY pos DESC) num_n          FROM            generate_series(0, n - 1) pos        ) Y        -- фильтруем комбинации с неполным набором "цифр"      JOIN LATERAL(          SELECT            count(DISTINCT unnest) = n cond          FROM            unnest(num_n)        ) flt          ON cond        -- собираем элементы согласно "цифрам"      , LATERAL (          SELECT            array_agg(src[num_n[pos] + 1]) dst          FROM            generate_subscripts(num_n, 1) pos        ) Z        -- проверяем наличие предка в предыдущей позиции      , LATERAL (          SELECT            NULL          FROM            (              SELECT                (SELECT pid FROM src WHERE id = dst[i] LIMIT 1) _pid              , i              FROM                generate_subscripts(dst, 1) i            ) T          HAVING            bool_and((i = 1 AND _pid = (TABLE root)) OR _pid = dst[i - 1])        ) rule4    ) Y)SELECT  src.*  -- восстанавливаем "путь" из прикладных ключей, (    SELECT      array_agg(data ORDER BY i)    FROM      coalesce(X.prepath, ARRAY[(TABLE root)] || Y.prepath) p -- помним о необходимости добавить "корень"    , LATERAL generate_subscripts(p, 1) i    , LATERAL (        SELECT          data        FROM          src        WHERE          id = p[i]        LIMIT 1      ) T  ) pathFROM  srcLEFT JOIN  preset X    USING(id)LEFT JOIN  prepath Y    USING(id)ORDER BY  path;

А попроще можно?..


Можно и попроще, если заранее известно, что порядок детей внутри одного родителя определяется некоторым сквозным ключом. Например, это может быть некоторый монотонно возрастающий timestamp сообщений в ветке форума или, как в нашем случае, первичный ключ типа serial (id).

В таком случае, мы уже точно знаем порядок следования идентификаторов записей в нужном нам пути он совпадает с общей сортировкой по этому ключу. И нам надо только выкинуть лишние id из списка всех предстоящих:

WITH src(id, pid, data) AS (  VALUES    (1, NULL, 'A')  , (2, 1, 'AA')  , (3, 2, 'AAA')  , (4, 1, 'AB')  , (5, 2, 'AAB')  , (6, 3, 'AAAA')  , (7, 5, 'AABA')  , (8, 4, 'ABA')  , (9, 7, 'AABAA')  , (10, 2, 'AAC'))-- кэшируем ID корневого элемента, root AS (  SELECT    id  FROM    src  WHERE    pid IS NULL  LIMIT 1)-- собираем все предстоящие id в массив для текущего, prepath AS (  SELECT    id  , pid  , array_agg(id) OVER(ORDER BY id /*!!! сортировка по тому самому ключу*/ ROWS UNBOUNDED PRECEDING EXCLUDE CURRENT ROW) src  FROM    src  WHERE    pid IS NOT NULL)-- находим пути, pre AS (  SELECT    id  , path  FROM    prepath    -- подмножества  , LATERAL (      SELECT        dst path      FROM        -- кэшируем размер набора        LATERAL array_length(src, 1) n        -- кэшируем границу интервала      , LATERAL (SELECT (2 ^ n)::bigint l) X        -- генерируем все числа на интервале      , LATERAL generate_series(0, l - 1) num        -- кэшируем разложение числа в двоичной системе      , LATERAL (SELECT num::bit(64) num_n) Y        -- собираем элементы согласно "цифрам"      , LATERAL (          SELECT            coalesce(array_agg(src[i]) FILTER(WHERE get_bit(num_n, 64 - i) = 1), '{}') || id dst          FROM            generate_series(1, n) i        ) Z        -- проверяем наличие предка в предыдущей позиции      , LATERAL (          SELECT            NULL          FROM            (              SELECT                (SELECT pid FROM src WHERE id = dst[i] LIMIT 1) _pid              , i              FROM                generate_subscripts(dst, 1) i            ) T          HAVING            bool_and((i = 1 AND _pid = (TABLE root)) OR (i > 1 AND _pid = dst[i - 1]))        ) rule4    ) X)SELECT  src.*  -- восстанавливаем "путь" из прикладных ключей, (    SELECT      array_agg(data ORDER BY i)    FROM      (        SELECT          CASE            -- для корневого элемента путь состоит только из него самого            WHEN pid IS NULL THEN ARRAY[id]            -- для ссылающегося на корневой - из пары            WHEN pid = (TABLE root) THEN ARRAY[pid, id]            ELSE ARRAY[(TABLE root)] || pre.path          END p -- помним о необходимости добавить "корень"      ) p    , LATERAL generate_subscripts(p, 1) i    , LATERAL (        SELECT          data        FROM          src        WHERE          id = p[i]        LIMIT 1      ) T  ) pathFROM  srcLEFT JOIN  pre    USING(id)ORDER BY  path;

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

PostgreSQL Antipatterns работаем с отрезками в кровавом энтерпрайзе

10.11.2020 10:22:20 | Автор: admin
В различных бизнес-приложениях регулярно возникает необходимость решить какую-либо задачу с отрезками/интервалами. Самое сложное в них понять, что это именно одна из таких задач.


Как правило, они отчаянно маскируются, и даже у нас в СБИС их найти можно в абсолютно разных сферах управления предприятием: контроле рабочего времени, оценке загрузки линий АТС или даже в бухгалтерском учете.
Отличие enterprise [решения] от всего остального он всегда идёт от запросов бизнеса и решает какую-то бизнес-задачу. [src]
Вот и давайте посмотрим, какие именно прикладные задачи и как можно решить с помощью PostgreSQL и сократить время анализа данных с нескольких секунд на бизнес-логике до десятков миллисекунд, умея эффективно применять следующие алгоритмы непосредственно внутри SQL-запроса:

  • поиск отрезков, пересекающих точку/интервал
  • слияние отрезков по максимальному перекрытию
  • подсчет количества отрезков в каждой точке

Отрезки в точке


Начнем наши исследования с самой простой задачи обнаружения отрезков, пересекающих конкретную точку. В бизнес-задачах, как правило, такие отрезки имеют какой-то временной характер то есть границами являются или date, или timestamp.



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

CREATE TABLE ranges(  owner    integer, dtb -- начало отрезка    date, dte -- окончание отрезка    date);INSERT INTO ranges(  owner, dtb, dte)SELECT  (random() * 1e3)::integer, dtb, dtb + (random() * 1e2)::integerFROM  (    SELECT      now()::date - (random() * 1e3)::integer dtb    FROM      generate_series(1, 1e5)  ) T;

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

BETWEEN + btree


Как правило, первый вариант запроса будет выглядеть как-то так:

SELECT  *FROM  rangesWHERE  '2020-01-01'::date BETWEEN dtb AND dte;

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

CREATE INDEX ON ranges(dtb, dte);

И это не работает примерно никак, потому что мы все равно перебрали всю таблицу (100K записей, из которых 95K отбросили):


Чтобы понять почему, достаточно представить визуально, как именно должен работать такой индекс, как мы делали это в статье DBA: находим бесполезные индексы: значение dtb ограничено только справа, dte только слева, в результате имеем два диапазонных ключа, которые совместно по btree работают плохо.

*range + GiST


Но, конечно же, эффективное решение есть это использование возможностей диапазонных типов и GiST-индексов:

CREATE INDEX ON ranges  USING gist( -- используем "пространственный" GiST-индекс    daterange(dtb, dte, '[]') -- формируем диапазон дат  );

Наш запрос теперь нужно модифицировать к такому виду:

SELECT  *FROM  rangesWHERE  daterange(dtb, dte, '[]') @> '2020-01-01'::date; -- значение входит в диапазон



С помощью подходящего индекса мы сократили время работы запроса в 3 раза.

Отрезки по группе


Но зачем нам все 5K записей больничных? Ведь, как правило, нас интересуют только коллеги по отделу то есть какая-то конкретная группа отрезков, которую надо отфильтровать из общего набора.



Поэтому усложним задачу и добавим в нашу модель принадлежность одному из отделов:

ALTER TABLE ranges ADD COLUMN segment integer;UPDATE ranges SET segment = (random() * 1e2)::integer;

SELECT  *FROM  rangesWHERE  segment = 1 AND  daterange(dtb, dte, '[]') @> '2020-01-01'::date;



Индекс, конечно, по-прежнему используется, но теперь получается, что 99% данных мы вычитываем зря 4945 строк отбрасывается при проверке сегмента.

Решение очевидно добавим нужное нам поле в индекс, да?..

CREATE INDEX ON ranges USING gist(segment, daterange(dtb, dte, '[]'));-- ОШИБКА:  для типа данных integer не определён класс операторов по умолчанию для метода доступа "gist"-- HINT:  Вы должны указать класс операторов для индекса или определить класс операторов по умолчанию для этого типа данных.

Увы, нет GiST-индекс просто так не поддерживает операции над скалярными типами. Зато если подключить расширение btree_gist научится:

CREATE EXTENSION btree_gist;CREATE INDEX ON ranges USING gist(segment, daterange(dtb, dte, '[]'));



Наш запрос избавился от всех неиндексных фильтраций и стал в 20 раз быстрее! До кучи, еще и читать стал в 10 раз меньше данных (buffers).

Отрезки на интервале


Но пересечение отрезков с точкой требуется гораздо реже, чем пересечение отрезков с интервалом.
Типичная бизнес-задача: "кто ходил в отпуск в этом месяце?", "какие задачи в моем календаре на сегодня?"



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

SELECT  *FROM  rangesWHERE  segment = 1 AND  daterange(dtb, dte, '[]') && daterange('2020-01-01', '2020-02-01', '[)'); -- пересечение отрезков, без включения правой границы



Объединение отрезков


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



Если бы мы знали заранее все интервалы, участвующие в объединении, то могли бы просто написать через оператор "+":

SELECT int4range('[0,2]') + int4range('[1,3]');-- [0,4)

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



Вариант, работающий даже в версии 8.4, приведен в PostgreSQL Wiki:

WITH rng(s, e) AS (  VALUES    ( 1,  3)  , ( 2,  4)  , ( 5,  6)  , ( 5,  8)  , ( 6,  9)  , ( 7, 10)  , ( 8, 10)  , (10, 11)  , (10, 15)  , (11, 12)  , (12, 13))SELECT  min(s) s, max(e) eFROM  (    SELECT      s    , e    , max(new_start) OVER(ORDER BY s, e) left_edge    FROM      (        SELECT          s        , e        , CASE            WHEN s < max(le) OVER(ORDER BY s, e) THEN              NULL            ELSE              s          END new_start        FROM          (            SELECT              s            , e            , lag(e) OVER(ORDER BY s, e) le            FROM              rng          ) s1      ) s2  ) s3GROUP BY  left_edge;

Но план у него выглядит не слишком приятно сортировка, три WindowAgg, группировка:



И обратите внимание, что в точке 10 он не сливает касающиеся интервалы, хотя обычно это требуется как в нашей задаче про отпуска.

Давайте сконструируем запрос, который нас устроит:

  1. Отсортируем все отрезки по паре (начало, конец).
  2. Для каждого начала вычислим, стоит ли оно правее максимального из предшествующих ему концов. Если левее вообще ничего нет или самый правый конец из предыдущих все равно левее это точка начала искомого интервала.
  3. Отнесем все отрезки в группы по количеству предшествующих им найденных начал.
  4. В каждой группе берем наименьшее из начал и наибольший из концов это и есть искомый непрерывный интервал.



WITH rng(s, e) AS (  VALUES    ( 1,  3)  , ( 2,  4)  , ( 5,  6)  , ( 5,  8)  , ( 6,  9)  , ( 7, 10)  , ( 8, 10)  , (10, 11)  , (10, 15)  , (11, 12)  , (12, 13))SELECT -- min/max по группе  min(s) s, max(e) eFROM  (    SELECT      *    , sum(ns::integer) OVER(ORDER BY s, e) grp -- определение групп    FROM      (        SELECT          *        , coalesce(s > max(e) OVER(ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), TRUE) ns -- начало правее самого правого из предыдущих концов == разрыв        FROM          rng      ) t  ) tGROUP BY  grp;

Ну, и одно выполнение WindowAgg нам удалось отыграть:


Длина объединенных отрезков


Если же перед нами не стоит цели вычислять сами целевые отрезки, а нужна только их суммарная длина, то можно сократить количество WindowAgg до единственного узла:

  1. Для каждого отрезка вычисляем максимум концов всех предыдущих.
  2. Берем разность между концом отрезка и большим из начала отрезка и найденного конца.
  3. Осталось только просуммировать полученные разности.



WITH rng(s, e) AS (  VALUES    ( 1,  3)  , ( 2,  4)  , ( 5,  6)  , ( 5,  8)  , ( 6,  9)  , ( 7, 10)  , ( 8, 10)  , (10, 11)  , (10, 15)  , (11, 12)  , (12, 13))SELECT  sum(delta)FROM  (    SELECT      *    , greatest(        e - greatest(          max(e) OVER(ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)        , s        )      , 0      ) delta    FROM      rng  ) T;


Спасибо imschur за этот вариант оптимизации.

Подсчет отрезков на каждом интервале


Итак, мы смогли узнать, сколько всего времени у нас работали не все сотрудники. Но ведь их отсутствовало разное количество в разные промежутки времени а сколько именно когда?
Типичная бизнес-задача: анализ и распределение нагрузки например, между операторами call-центра: "Сколько звонков мы обслуживаем одновременно? Сколько для этого нужно операторов в ночной смене?"
  1. Присвоим каждому началу отрезка вес +1, а каждому концу -1.
  2. Просуммируем накопительно значения в каждой точке это и есть количество отрезков на интервале, начинающемся с этой точки и вплоть до следующей по порядку.



WITH rng(s, e) AS (  VALUES    ( 1,  3)  , ( 2,  4)  , ( 5,  6)  , ( 5,  8)  , ( 6,  9)  , ( 7, 10)  , ( 8, 10)  , (10, 11)  , (10, 15)  , (11, 12)  , (12, 13))SELECT DISTINCT ON(p) -- уникализация до единственного значения в точке  p, sum(v) OVER(ORDER BY p) qty -- накопительная суммаFROM  (    SELECT      s p    , +1 v    FROM      rng  UNION ALL    SELECT      e p    , -1 v    FROM      rng  ) TORDER BY  1;

На этом сегодня все. Если знаете еще какие-то полезные нетривиальные алгоритмы работы с отрезками для решения бизнес-задач напишите в комментариях.
Подробнее..

SQL HowTo префиксный FTS-поиск с релевантностью по дате

18.12.2020 00:05:23 | Автор: admin
В нашем СБИС, как и в любой другой системе работы с документами, по мере накопления данных у пользователей возникает желание их "поискать".

Но, поскольку люди не компьютеры, то и ищут они примерно как "что-то там такое было от Иванова или от Ивановского нет, не то, раньше, еще раньше вот оно!"

То есть технически верное решение это префиксный полнотекстовый поиск с ранжированием результатов по дате.

Но разработчику это грозит жуткими проблемами ведь для FTS-поиска в PostgreSQL используются пространственные типы индексов GIN и GiST, которые не предусматривают подсовывания дополнительных данных, кроме текстового вектора.

Остается только грустно вычитывать все записи по совпадению префикса (тысячи их!) и сортировать или, наоборот, идти по индексу даты и фильтровать все встречающиеся записи на совпадение префикса, пока не найдем подходящие (как скоро найдется абракадабра?..).

И то, и другое не особо приятно для производительности запроса. Или что-то все же можно придумать для быстрого поиска?

Сначала сгенерируем наши тексты-на-дату:

CREATE TABLE corpus ASSELECT  id, dt, strFROM  (    SELECT      id::integer    , now()::date - (random() * 1e3)::integer dt -- дата где-то за последние 3 года    , (random() * 1e2 + 1)::integer len -- длина "текста" до 100    FROM      generate_series(1, 1e6) id -- 1M записей  ) X, LATERAL(    SELECT      string_agg(        CASE          WHEN random() < 1e-1 THEN ' ' -- 10% на пробел          ELSE chr((random() * 25 + ascii('a'))::integer)        END      , '') str    FROM      generate_series(1, len)  ) Y;

Наивный подход #1: gist + btree


Попробуем накатить индекс и для FTS, и для сортировки по дате вдруг да помогут:

CREATE INDEX ON corpus(dt);CREATE INDEX ON corpus USING gist(to_tsvector('simple', str));

Будем искать все документы, содержащие слова, начинающиеся на 'abc...'. И, для начала, проверим, что таких документов достаточно немного, и FTS-индекс используется нормально:

SELECT  *FROM  corpusWHERE  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*');



Ну он, конечно, используется, но занимает это больше 8 секунд, что явно не то, что мы хотели бы потратить на поиск 126 записей.

Может, если добавить сортировку по дате и искать только последние 10 записей станет лучше?

SELECT  *FROM  corpusWHERE  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*')ORDER BY  dt DESCLIMIT 10;



Но нет, просто сверху добавилась сортировка.

Наивный подход #2: btree_gist


Но ведь есть же отличное расширение btree_gist, которое позволяет подсунуть скалярное значение в GiST-индекс, что должно нам дать возможность сразу использовать индексную сортировку с помощью оператора расстояния <->, который можно использовать для kNN-поисков:

CREATE EXTENSION btree_gist;CREATE INDEX ON corpus USING gist(to_tsvector('simple', str), dt);

SELECT  *FROM  corpusWHERE  to_tsvector('simple', str) @@ to_tsquery('simple', 'abc:*')ORDER BY  dt <-> '2100-01-01'::date DESC -- сортировка по "расстоянию" от даты далеко в будущемLIMIT 10;



Увы, это не помогает примерно никак.

Геометрия в помощь!


Но отчаиваться рано! Посмотрим на список встроенных классов операторов GiST оператор расстояния <-> доступен только для геометрических circle_ops, point_ops, poly_ops, а с версии PostgreSQL 13 и для box_ops.

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



Разбиваем текст на слова


Конечно, наш поиск будет не вполне полнотекстовым, в том смысле, что нельзя задавать условие для нескольких слов одновременно. Но уж префиксным-то точно будет!

Сформируем вспомогательную таблицу-словарь:

CREATE TABLE corpus_kw ASSELECT  id, dt, kwFROM  corpus, LATERAL (    SELECT      kw    FROM      regexp_split_to_table(lower(str), E'[^\\-a-zа-я0-9]+', 'i') kw    WHERE      length(kw) > 1  ) T;

В нашем примере на 1M текстов пришлось 4.8M слов.

Укладываем слова


Чтобы перевести слово в его координату, представим что это число, записанное в системе счисления с основанием 2^16 (ведь UNICODE-символы мы тоже хотим поддержать). Только записывать мы его будем начиная с фиксированной 47-й позиции:



Можно было бы начинать и с 63-й позиции, это даст нам значения чуть меньше 1E+308, предельных для double precision, но тогда возникнет переполнение при построении индекса.

Получается, что на координатной оси все слова окажутся упорядочены:



ALTER TABLE corpus_kw ADD COLUMN p point;UPDATE  corpus_kwSET  p = point(    (      SELECT        sum((2 ^ 16) ^ (64 - i) * ascii(substr(kw, i, 1)))      FROM        generate_series(1, length(kw)) i    )  , extract('epoch' from dt)  );CREATE INDEX ON corpus_kw USING gist(p);

Формируем поисковый запрос


WITH src AS (  SELECT    point(      ( -- копипасту можно вынести в функцию        SELECT          sum((2 ^ 16) ^ (48 - i) * ascii(substr(kw, i, 1)))        FROM          generate_series(1, length(kw)) i      )    , extract('epoch' from dt)    ) ps  FROM    (VALUES('abc', '2100-01-01'::date)) T(kw, dt) -- поисковый запрос)SELECT  *, src.ps <-> kw.p dFROM  corpus_kw kw, srcORDER BY  dLIMIT 10;



Теперь у нас на руках id искомых документов, уже отсортированных в нужном порядке и заняло это меньше 2ms, в 4000 раз быстрее!

Небольшая ложка дегтя


Оператор <-> ничего не знает про наше упорядочение по двум осям, поэтому искомые наши данные находятся лишь в одной из правых четвертей, в зависимости от необходимой сортировки по дате:



Ну и выбрать мы все-таки хотели сами тексты-документы, а не их ключевые слова, поэтому нам понадобится давно забытый индекс:

CREATE UNIQUE INDEX ON corpus(id);

Доработаем запрос:

WITH src AS (  SELECT    point(      (        SELECT          sum((2 ^ 16) ^ (48 - i) * ascii(substr(kw, i, 1)))        FROM          generate_series(1, length(kw)) i      )    , extract('epoch' from dt)    ) ps  FROM    (VALUES('abc', '2100-01-01'::date)) T(kw, dt) -- поисковый запрос), dc AS (  SELECT    (      SELECT        dc      FROM        corpus dc      WHERE        id = kw.id    )  FROM    corpus_kw kw  , src  WHERE    p[0] >= ps[0] AND -- kw >= ...    p[1] <= ps[1]     -- dt DESC  ORDER BY    src.ps <-> kw.p  LIMIT 10)SELECT  (dc).*FROM  dc;



Нам немного добавили возникшие InitPlan с вычислением константных x/y, но все равно мы уложились в те же 2 мс!

Ложка дегтя #2


Ничто не дается бесплатно:

SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) FROM pg_class WHERE relname LIKE 'corpus%';

corpus          | 242 MB -- исходный набор текстовcorpus_id_idx   |  21 MB -- это его PKcorpus_kw       | 705 MB -- ключевые слова с датамиcorpus_kw_p_idx | 403 MB -- GiST-индекс

242 MB текстов превратились в 1.1GB поискового индекса.

Но ведь в corpus_kw лежат дата и само слово, которые мы в самом-то поиске уже никак не использовали так давайте их удалим:

ALTER TABLE corpus_kw  DROP COLUMN kw, DROP COLUMN dt;VACUUM FULL corpus_kw;

corpus_kw       | 641 MB -- только id и point

Мелочь а приятно. Помогло не слишком сильно, но все-таки 10% объема удалось отыграть.
Подробнее..

DBA кто скрывается за блокировкой

15.06.2020 20:17:21 | Автор: admin
В предыдущей статье мы научились снимать состояние блокировок на сервере PostgreSQL ровно в тот момент, когда они происходят. В этой научимся трактовать собранное и узнавать, кто именно может скрываться за конкретной матрицей конфликтов, и почему результат выглядит именно так.



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

мегазапрос
WITH lm(ld, lr) AS (  VALUES    ('AccessShareLock', '{AccessExclusiveLock}'::text[])  , ('RowShareLock', '{ExclusiveLock,AccessExclusiveLock}'::text[])  , ('RowExclusiveLock', '{ShareLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])  , ('ShareUpdateExclusiveLock', '{ShareUpdateExclusiveLock,ShareLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])  , ('ShareLock', '{RowExclusiveLock,ShareUpdateExclusiveLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])  , ('ShareRowExclusiveLock', '{RowExclusiveLock,ShareUpdateExclusiveLock,ShareLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])  , ('ExclusiveLock', '{RowShareLock,RowExclusiveLock,ShareUpdateExclusiveLock,ShareLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])  , ('AccessExclusiveLock', '{AccessShareLock,RowShareLock,RowExclusiveLock,ShareUpdateExclusiveLock,ShareLock,ShareRowExclusiveLock,ExclusiveLock,AccessExclusiveLock}'::text[])), locks AS (  SELECT    (      locktype    , database    , relation    , page    , tuple    , virtualxid    , transactionid::text::bigint    , classid    , objid    , objsubid    ) target  , *  FROM    pg_locks), ld AS (  SELECT    *  FROM    locks  WHERE    NOT granted), lr AS (  SELECT    *  FROM    locks  WHERE    target::text = ANY(ARRAY(      SELECT DISTINCT        target::text      FROM        ld    )) AND    granted), lcx AS (  SELECT    lr.target  , ld.pid ldp  , ld.mode ldm  , lr.pid lrp  , lr.mode lrm  FROM    ld  JOIN    lr      ON lr.pid <> ld.pid AND        lr.target IS NOT DISTINCT FROM ld.target), cfl AS (SELECT  lc.locktype "type", CASE lc.locktype    WHEN 'relation' THEN      ARRAY[relation]    WHEN 'extend' THEN      ARRAY[relation]    WHEN 'page' THEN      ARRAY[relation, page]    WHEN 'tuple' THEN      ARRAY[relation, page, tuple]    WHEN 'transactionid' THEN      ARRAY[transactionid::text::oid]    WHEN 'virtualxid' THEN      string_to_array(virtualxid::text, '/')::oid[]    WHEN 'object' THEN      ARRAY[classid, objid, objsubid]    WHEN 'userlock' THEN      ARRAY[classid]    WHEN 'advisory' THEN      ARRAY[classid, objid, objsubid]  END target, nullif(lc.pid = lcx.ldp, FALSE) as locked, lc.pid, regexp_replace(lc.mode, 'Lock$', '') "mode", nullif(lc.granted, TRUE) "granted", nullif(lc.target IS NOT DISTINCT FROM lcx.target, FALSE) "conflict"FROM  lcxJOIN  locks lc    ON lc.pid IN (lcx.ldp, lcx.lrp))SELECT  cfl.*, CASE    WHEN "type" NOT IN ('virtualxid', 'transactionid') THEN target[1]::regclass  END relname, cl.relkindFROM  cflLEFT JOIN LATERAL(    SELECT      *    FROM      pg_class    WHERE      cfl.type = 'relation' AND      oid = target[1]    LIMIT 1  ) cl    ON TRUEORDER BY                                            -- сортируем ...  locked                                            -- сначала кого блокируют, pid                                               -- по принадлежности процессу (1 процесс = 1 транзакция), CASE "type"                                       -- по приоритету типов блокировок    WHEN 'virtualxid'    THEN 0    WHEN 'transactionid' THEN 1    WHEN 'relation'      THEN 2    WHEN 'tuple'         THEN 3    WHEN 'object'        THEN 4    WHEN 'advisory'      THEN 5  END, CASE relkind                                       -- по принадлежности объекта таблице    WHEN 'r' THEN cl.oid    WHEN 't' THEN regexp_replace(cl.relname, E'^.*\\D(\\d+)$', E'\\1', '')::oid    WHEN 'i' THEN (      SELECT        indrelid      FROM        pg_index      WHERE        indexrelid = cl.oid      LIMIT 1    )    WHEN 'S' THEN (      SELECT        (          SELECT            adrelid          FROM            pg_attrdef          WHERE            oid = dp.objid        )      FROM        pg_depend dp      WHERE        (refclassid, refobjid) = ('pg_class'::regclass, cl.oid) AND        (deptype, classid) = ('n', 'pg_attrdef'::regclass)      LIMIT 1    )  END  -- https://postgrespro.ru/docs/postgresql/12/catalog-pg-class, CASE relkind                                       -- по типу объекта БД    WHEN 'r' THEN 0 -- relation    WHEN 'm' THEN 1 -- materialized view    WHEN 'p' THEN 2 -- partitioned table    WHEN 'f' THEN 3 -- foreign table    WHEN 't' THEN 4 -- TOAST    WHEN 'i' THEN 5 -- index    WHEN 'I' THEN 6 -- partitioned index    WHEN 'S' THEN 7 -- sequence    WHEN 'c' THEN 8 -- composite type    WHEN 'v' THEN 9 -- view  END, CASE                                               -- по типу индекса  PK вперед    WHEN relkind = 'i' THEN      NOT (        SELECT          indisprimary        FROM          pg_index        WHERE          indexrelid = cl.oid        LIMIT 1      )  END, cl.relname                                         -- по имени объекта, CASE mode                                          -- по приоритету режима блокировки    WHEN 'AccessExclusive'      THEN 0    WHEN 'Exclusive'            THEN 1    WHEN 'ShareRowExclusive'    THEN 2    WHEN 'Share'                THEN 3    WHEN 'ShareUpdateExclusive' THEN 4    WHEN 'RowExclusive'         THEN 5    WHEN 'RowShare'             THEN 6    WHEN 'AccessShare'          THEN 7  END;


С помощью него мы рассмотрим некоторые примеры ситуаций, которые могут вам встретиться в реальной работе:

  1. создание одноименных таблиц
  2. создание одноименных индексов
  3. самоблокировка (CONCURRENTLY vs транзакция)
  4. блокировка по UNIQUE-индексу
  5. одновременное изменение записи
  6. обслуживание таблицы

Но сначала посмотрим, какие вообще ресурсы могут вызывать конфликт, и чем они идентифицируются в pg_locks:
locktype описание ID ресурса
relation отношение (таблица) (relation)
extend расширение отношения (TOAST) (relation)
page страница (блок данных таблицы/индекса) (relation, page)
tuple кортеж (запись таблицы/индекса) (relation, page, tuple)
transactionid идентификатор транзакции (transactionid)
virtualxid виртуальный идентификатор (virtualxid)
object некоторый объект (classid, objid, objsubid)
userlock пользовательская блокировка (classid)
advisory рекомендательная блокировка (classid, objid, objsubid)
В большинстве случаев в быту блокировки возникают, конечно же, на таблицах и их записях. Давайте посмотрим на конфликты режимов блокировок как на матрицу мешающих друг другу запросов:



А теперь посмотрим на реальных примерах.

Создание одноименных таблиц


-- tx1BEGIN;CREATE TABLE tbl(pk integer, val integer);    -- tx2    CREATE TABLE tbl(pk integer, val integer);

Профиль блокировок:
type mode relname relkind
ожидающий PID
relation RowExclusive pg_type r
relation RowExclusive pg_class r
object AccessShare pg_namespace
блокирующий PID
relation AccessExclusive oid таблицы (число)
object AccessShare pg_namespace
Таблица, как и любой именованный объект в PostgreSQL представлена как запись в таблице pg_class. Отличаются друг от друга записи объектов разных типов значением поля pg_class.relkind:
  • r = обычная таблица (Relation)
  • i = индекс (Index)
  • S = последовательность (Sequence)
  • t = таблица TOAST
  • v = представление (View)
  • m = материализованное представление (Materialized view)
  • c = составной тип (Composite type)
  • f = сторонняя таблица (Foreign table)
  • p = секционированная таблица (Partitioned table)
  • I = секционированный индекс (partitioned Index)

Но, поскольку запись с точки зрения нашей транзакции мониторинга еще не существует, раскастовка ее oid в regclass не может пройти, и мы видим вместо имени только лишь число.

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

SELECT NULL::tbl;

Пример состояния:



Создание одноименных индексов


-- tx1BEGIN;CREATE UNIQUE INDEX idx ON tbl(pk);    -- tx2    CREATE UNIQUE INDEX idx ON tbl(pk);

Профиль блокировок:
type mode relname relkind
ожидающий PID
relation RowExclusive pg_class r
relation Share tbl r
relation AccessExclusive oid индекса (число)
блокирующий PID
relation Share tbl r
relation AccessExclusive oid индекса (число)
Тут мы снова видим точно такую же RowExclusive-блокировку на pg_class, которая не дает создать одноименные объекты. Но уже никаких pg_type поскольку создание индекса не порождает никаких спецтипов. Вместо этого мы видим Share-блокировки, наложенные на обе таблицы (в нашем случае, она одна и та же).

Заметим, что Share между собой не конфликтуют, поэтому попробуем создать сразу два разных индекса на одной таблице:

-- tx1BEGIN;CREATE UNIQUE INDEX idx1 ON tbl(pk);    -- tx2    CREATE UNIQUE INDEX idx2 ON tbl(pk); -- работает, и никаких блокировок!

Пример состояния:



Самоблокировка (CONCURRENTLY vs транзакция)


Теперь давайте попробуем создать еще один индекс но не просто так, а CONCURRENTLY, чтобы никому не блокировать доступ к таблице.

Ну а если мы хотим сделать это из PL-кода, где CONCURRENTLY-запросы запускать нельзя, как и в любой транзакции?.. Конечно же, воспользуемся модулем dblink для подключения к этой же БД!

DO $$BEGIN  SELECT dblink_exec('dbname=' || current_database() || ' user=' || current_user, 'CREATE INDEX CONCURRENTLY idx_cic ON tbl(pk);');END$$;

Профиль блокировок:
type mode relname relkind
ожидающий PID
virtualxid Share ---
блокирующий PID
virtualxid Exclusive ---
Вот вам маленькая демонстрация, почему с dblink надо обращаться очень аккуратно. Здесь мы видим классическую ситуацию транзакция ждет транзакцию просто ее окончания. И не дождется никогда в нашем случае, поскольку сам DO-блок является транзакцией.

Пример состояния:



Блокировка по UNIQUE-индексу


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

-- tx1BEGIN;INSERT INTO tbl(pk, val)VALUES(1, 1);    -- tx2    INSERT INTO tbl(pk, val)VALUES(1, 1);

Профиль блокировок:
type mode relname relkind
ожидающий PID
relation RowExclusive tbl r
блокирующий PID
relation RowExclusive tbl r
relation RowExclusive idx i
Тут мы уже видим RowExclusive не на одной из системных таблиц, а на нашей. А в пару к ней и на том индексе, который вызвал проблемы [не]уникальности.

Пример состояния:



Одновременное изменение записи


Попробуем теперь одну и ту же запись в нашей таблице UPDATE'нуть:

-- tx1BEGIN;UPDATE tbl SET val = val + 1 WHERE pk = 1;    -- tx2    UPDATE tbl SET val = val + 1 WHERE pk = 1;

Профиль блокировок:
type mode relname relkind
ожидающий PID
tuple Exclusive tbl r
relation RowExclusive tbl r
блокирующий PID
relation RowExclusive tbl r
Понятно, что оба запроса наложили RowExclusive на таблицу, раз хотят туда что-то записать. Но важнее, что что заблокированный запрос ждет tuple-блокировку на этой же таблице. Причем последние два числа в ID объекта блокировки (или поля page, tuple в исходной pg_locks) позволяют нам узнать, что именно за запись мы тут ждем:

SELECT * FROM <relation> WHERE ctid = '(<page>,<tuple>)';

Пример состояния:



Обслуживание таблицы


Мы тут очень много чего в таблице уже намусорили давайте ее зачистим. Но запись-то пока в нее идет:

-- tx1BEGIN;INSERT INTO tbl(pk, val)VALUES(2, 2);    -- tx2    TRUNCATE TABLE tbl;

Профиль блокировок:
type mode relname relkind
ожидающий PID
relation AccessExclusive tbl r
блокирующий PID
relation RowExclusive tbl r
Собственно, мы видим, что заблокированный хочет AccessExclusive самый тяжелый режим, но не может получить из-за RowExclusive. Смотрим на картинку с таблицей конфликтов и понимаем, что это кто-то из {TRUNCATE, VACUUM FULL, ...} пересекся c {INSERT, UPDATE, DELETE}, даже не заглядывая в pg_stat_activity.

Пример состояния:



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

PostgreSQL Antipatterns анализируем блокировки SELF JOIN vs WINDOW

08.07.2020 10:20:45 | Автор: admin
Ранее мы уже научились перехватывать блокировки из лога сервера PostgreSQL. Давайте теперь положим их в БД и разберем, какие фактические ошибки и проблемы производительности можно допустить на примере их простейшего анализа.

В логах у нас отражается всего 3 вида событий, которые могут происходить с блокировкой:

  • ожидание блокировки
    LOG: process 38162 still waiting for ExclusiveLock on advisory lock [225382138,225386226,141586103,2] after 100.047 ms
  • получение блокировки
    LOG: process 38162 acquired ExclusiveLock on advisory lock [225382138,225386226,141586103,2] after 150.741 ms
  • взаимоблокировка
    ERROR: deadlock detected

deadlock'и исключим из анализа это просто ошибки, и попробуем выяснить, сколько всего времени мы потеряли из-за блокировок за конкретный день на определенном хосте.

Для начала нам понадобится таблица, куда мы будем собирать все такие записи:

CREATE TABLE lock(  dt       -- ключ хронологического секционирования    date, host     -- сервер, на котором возникла блокировка    uuid, pid      -- PID процесса из строки лога    integer, ts       -- момент события    timestamp, event    -- { lock-wait | lock-acquire | deadlock-detect }    lockevent, type     -- { relation | extend | ... }    locktype, mode     -- { AccessShare | RowShare | ... }    lockmode, lock     -- объект блокировки    uuid, exectime -- продолжительность    numeric(32,2));

Более подробно про организацию секционирования в нашей системе мониторинга можно прочитать в статье Пишем в PostgreSQL на субсветовой: 1 host, 1 day, 1TB, а про различные типы и режимы блокировок в DBA: кто скрывается за блокировкой.

Как слышится, так и пишется


Попробуем ответить на вопрос, вынесенный в начало статьи, простейшим способом.



Что такое время ожидания блокировки? Ну, очевидно же, это время ее получения для каждого случая ее ожидания:

  • берем каждый случай ожидания (lock-wait)
  • для него находим ближайшую снизу по времени запись получения (lock-acquire) этой же (lock, pid, mode) блокировки то есть на тот же объект, в том же процессе, с тем же режимом

Тип блокировки (type) в нашем случае можно опустить, поскольку он однозначно определяется самим объектом (lock).

Дальше останется только просуммировать полученные результаты.

SELECT  ts, pid, event, type, mode, lock, exectime, T.*FROM  lock lc, LATERAL (    SELECT      exectime waittime    FROM      lock    WHERE      (        dt      , host      , lock      , pid      , mode      , event      ) = (        '2020-06-19'::date      , lc.host      , lc.lock      , lc.pid      , lc.mode      , 'lock-acquire'      ) AND      ts >= lc.ts    ORDER BY      ts    LIMIT 1  ) TWHERE  (    lc.dt  , lc.host  , lc.event  ) = (    '2020-06-19'::date  , 'e828a54d-7e8a-43dd-b213-30c3201a6d8e'::uuid  , 'lock-wait'::lockevent  );

Все просто и ясно! А что нам покажет EXPLAIN?..



Оу пришлось прочитать почти 900MB данных, причем почти все из-за поиска связанной записи для каждой блокировки, не очень красиво.

Но является ли этот запрос вообще корректным для нашей задачи? Нет! Посмотрим внимательно в собранные данные:



Ой Оказывается, сервер жалуется в логи на одну и ту же неполученную блокировку иногда много-много раз. А это означает, что мы учли время ее ожидания кратно количеству таких записей в логе, что совсем не соответствует желаемому.

Помни о цели!


Собственно, а зачем мы вообще для каждой записи ожидания ищем связанную? Мы же хотим узнать, сколько заняло ожидание, а оно прямо записано в lock-acquire. Так давайте сразу отбирать только их, тогда будет всего лишь один Index Scan правильно?

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



Так неужели нет способа за одно чтение сразу получить все нужные нам данные?

Window Functions: семерых одним ударом


На помощь нам придут оконные функции.


А конкретнее модель выделения цепочек в готовой выборке из статьи SQL HowTo: собираем цепочки с помощью window functions.

Сначала поймем, что условием окончания цепочки то есть сегмента подряд идущих по ключу (host, lock, pid, mode) записей блокировки для нас является или явное возникновение event = 'lock-acquire' или (что очень редко, но бывает) начало нового сегмента блокировки того же объекта, чья длительность (exectime) начала считаться заново.



Также надо учесть тот факт, что время может совпадать для нескольких записей лога даже с одного PID. В этом случае надо дополнительно сортировать по exectime, чтобы получить правильную последовательность:



-- формируем условие окончания блокировкиWITH lc AS (  SELECT    *  , CASE      WHEN event = 'lock-wait' THEN        exectime > coalesce(lead(exectime) OVER(PARTITION BY lock, pid, mode ORDER BY ts, exectime), 0) -- "перелом" времени ожидания      ELSE TRUE -- 'lock-acquire' - блокировка получена    END cond -- условие окончания "цепочки"  FROM    lock lc  WHERE    event <> 'deadlock-detect' AND -- исключаем все deadlock    (      lc.dt    , lc.host    ) = (      '2020-06-19'::date    , 'e828a54d-7e8a-43dd-b213-30c3201a6d8e'::uuid    ))-- оставляем только "последние" записи - их exectime и есть время ожидания "всей" блокировкиSELECT  ts, pid, event, type, mode, lock, exectimeFROM  lcWHERE  cond;



Теперь мы прочитали всего 8MB данных (в 100 раз меньше!), чуть-чуть уменьшив итоговое время выполнения.

Его можно уменьшить еще, если создать индекс, идеально подходящий под OVER (то есть включающий lock, pid, mode, ts, exectime), избавившись от Sort-узла. Но обычно поле в индексе за timestamp делать не стоит.
Подробнее..

PostgreSQL 13 happy pagination WITH TIES

23.09.2020 12:10:47 | Автор: admin
На прошедшей неделе вышло сразу две статьи (от Hubert 'depesz' Lubaczewski и автора самого патча Alvaro Herrera), посвященные реализованной в грядущей версии PostgreSQL 13 поддержке опции WITH TIES из стандарта SQL:2008:
OFFSET start { ROW | ROWS }
FETCH { FIRST | NEXT } [ count ] { ROW | ROWS } { ONLY | WITH TIES }
Что это, и как оно избавляет от проблем с реализацией пейджинга, о которых я рассказывал в статье PostgreSQL Antipatterns: навигация по реестру?



Напомню, что в той статье мы остановились на моменте, что если у нас есть табличка такого вида:

CREATE TABLE events(  id    serial      PRIMARY KEY, ts    timestamp, data    json);INSERT INTO events(ts)SELECT  now() - ((random() * 1e8) || ' sec')::intervalFROM  generate_series(1, 1e6);

то для организации хронологического пейджинга по ней (по ts DESC) эффективнее всего использовать вот такой индекс:

CREATE INDEX ON events(ts DESC);

и вот такую модель запроса:

SELECT  ...WHERE  ts < $1 AND  ts >= coalesce((    SELECT      ts    FROM      events    WHERE      ts < $1    ORDER BY      ts DESC    LIMIT 1 OFFSET 25  ), '-infinity')ORDER BY  ts DESC;

Старый-добрый подзапрос


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

EXPLAIN (ANALYZE, BUFFERS)SELECT  *FROM  eventsWHERE  ts < '2020-01-01'::timestamp AND  ts >= coalesce((    SELECT      ts    FROM      events    WHERE      ts < '2020-01-01'::timestamp    ORDER BY      ts DESC    LIMIT 1 OFFSET 25  ), '-infinity')ORDER BY  ts DESC;


[посмотреть на explain.tensor.ru]

Зачем тут вложенный запрос? Ровно за тем, чтобы не иметь описанных в той статье проблем с перепрыгиванием одинаковых значений ключа сортировки между запрашиваемыми сегментами:



Пробуем WITH TIES на зуб


Но ведь ровно для этого и нужен функционал WITH TIES чтобы отобрать сразу все записи с одинаковым значением граничного ключа!

EXPLAIN (ANALYZE, BUFFERS)SELECT  *FROM  eventsWHERE  ts < '2020-01-01'::timestampORDER BY  ts DESCFETCH FIRST 26 ROWS WITH TIES;


[посмотреть на explain.tensor.ru]

Запрос выглядит гораздо проще, почти в 2 раза быстрее, и всего лишь за один Index Scan отличный результат!

Обратите внимание, что хоть мы и заказывали всего 26 записей, Index Scan извлек на одну больше ровно для того, чтобы убедиться, что следующая нам уже не подходит.



Ну что же, ждем официального релиза PostgreSQL 13, который запланирован на завтра.
Подробнее..

PostgreSQL Antipatterns убираем медленные и ненужные сортировки

07.10.2020 20:16:52 | Автор: admin
Просто так результат SQL-запроса возвращает записи в том порядке, который наиболее удобен серверу СУБД. Но человек гораздо лучше воспринимает хоть как-то упорядоченные данные это помогает быстро сравнивать соответствие различных датасетов.

Поэтому со временем у разработчика может выработаться рефлекс Дай-ка я на всякий случай это вот отсортирую! Конечно, иногда подобная сортировка бывает оправдана прикладными задачами, но обычно такой случай выглядит как в старом анекдоте:
Программист ставит себе на тумбочку перед сном два стакана. Один с водой на случай, если захочет ночью пить. А второй пустой на случай, если не захочет.
Давайте разбираться когда сортировка в запросе точно не нужна и несет с собой потерю производительности, когда от нее можно относительно дешево избавиться, а когда сделать из нескольких одну.


#1: Нехватка work_mem


Обычно мы просто не обращаем внимания на наличие лишнего Sort-узла в плане он отрабатывает очень быстро по сравнению с самим извлечением данных. Но стоит чему-то пойти не так и перестать сортируемой выборке умещаться в памяти

SELECT generate_series(1, 1e6) i ORDER BY i;



Из-за сортировки мы начали свапаться на диск (buffers temp written), и потратили на это порядка 70% времени!

Давайте как-то ускорять. И самый простой способ, который рекомендует нам подсказка под иконкой восклицательного знака просто увеличить параметр work_mem так, чтобы памяти на сортировку все-таки хватало:

ALTER SYSTEM SET work_mem = '128MB';



На четверть быстрее, хотя мы не трогали ни текст запроса, ни структуру базы! К сожалению, такой способ не всегда допустим например, наш СБИС обслуживает одновременно тысячи клиентов в рамках одного узла PostgreSQL, и просто так раздавать память направо-налево мы не можем себе позволить. Может, есть способ вообще избавиться от сортировки?..

#2: Сортировка уже отсортированного


Начнем с самого простого варианта чтения данных из вложенного запроса:

SELECT  *FROM  (    SELECT generate_series(1, 1e6) i  ) TORDER BY  i;

Почти 2/3 всего времени выполнения заняла сортировка, хоть и происходила вся в памяти:


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

SELECT  *FROM  (    SELECT generate_series(1, 1e6) i  ) T;



Вроде мы ничего особенного не сделали, а запрос уже ускорился более чем в 2 раза.
Этим же свойством сохранения порядка записей обладают чтение из CTE (Common Table Expression, узел CTE Scan в плане), SRF (Set-Returning Function, Function Scan) или VALUES (Values Scan).

#3: Вложенная отладочная сортировка


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

SELECT  i, 1e6 - iFROM  (    SELECT      *    FROM      generate_series(1, 1e6) i    WHERE      (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6)    ORDER BY -- осталось от отладки      i DESC  ) TORDER BY  i;



Мы-то понимаем, что внутренняя сортировка ни на что не влияет (в большинстве случаев), но СУБД нет. Поправим:

SELECT  i, 1e6 - iFROM  (    SELECT      *    FROM      generate_series(1, 1e6) i    WHERE      (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6)  ) TORDER BY  i;



Минус одна сортировка. Но вспомним предыдущий пункт насчет упорядоченности SRF, и исправим до конца:

SELECT  i, 1e6 - iFROM  generate_series(1, 1e6) iWHERE  (i % 2, i % 3, i % 5, i % 7) = (1, 2, 4, 6);



Вот и минус вторая сортировка. На данном конкретном запросе мы выиграли порядка 5%, всего лишь убрав лишнее.

#4: Index Scan вместо сортировки


Одна из классических причин неэффективности SQL-запросов, о которых я рассказывал в статье Рецепты для хворающих SQL-запросов:

CREATE TABLE tbl ASSELECT  (random() * 1e4)::integer fk -- 10K разных внешних ключей, now() - ((random() * 1e8) || ' sec')::interval tsFROM  generate_series(1, 1e6) pk;  -- 1M "фактов"CREATE INDEX ON tbl(fk); -- индекс для foreign key

То есть при разработке структуры базы мы описали FOREIGN KEY, повесили индекс на это поле, чтобы он отрабатывал быстро А потом пошли прикладные задачи.

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

SELECT  tsFROM  tblWHERE  fk = 1 -- отбор по конкретной связиORDER BY  ts DESC -- хотим всего одну "последнюю" записьLIMIT 1;



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

DROP INDEX tbl_fk_idx;CREATE INDEX ON tbl(fk, ts DESC);



Ух! Теперь весь наш запрос выполнился быстрее, чем одна только сортировка в предыдущем варианте.

#5: UNION ALL вместо сортировки


Но что делать, если от нас хотят такую сортировку, которая ну никак нормально на индекс не укладывается, хотя вроде и должна?

TRUNCATE TABLE tbl;INSERT INTO tblSELECT  CASE    WHEN random() >= 1e-5      THEN (random() * 1e4)::integer  END fk -- 10K разных внешних ключей, из них 0.0001 - NULL'ы, now() - ((random() * 1e8) || ' sec')::interval tsFROM  generate_series(1, 1e6) pk;  -- 1M "фактов"

Допустим, что нам надо показать оператору топовые 10 заявок сначала все неназначенные заявки (fk IS NULL), начиная от самых старых, а затем все его (fk = 1):

SELECT  *FROM  tblWHERE  fk IS NULL OR  fk = 1ORDER BY  fk NULLS FIRST, ts DESCLIMIT 10;



Вроде и индекс у нас есть, вроде и сортировка по нужным ключам, но как-то все некрасиво в плане Но давайте воспользуемся знанием, что чтение из вложенного запроса сохраняет порядок разделим нашу выборку на две заведомо непересекающиеся и снова склеим с помощью UNION ALL, примерно как делали это в статье PostgreSQL Antipatterns: сказ об итеративной доработке поиска по названию, или Оптимизация туда и обратно:

(  SELECT    *  FROM    tbl  WHERE    fk IS NULL  ORDER BY    fk, ts DESC  LIMIT 10)UNION ALL(  SELECT    *  FROM    tbl  WHERE    fk = 1  ORDER BY    fk, ts DESC  LIMIT 10)LIMIT 10;



И снова ни одной сортировки в плане, а запрос стал почти в 5 раз быстрее.

#6: Сортировки для оконных функций


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

SELECT DISTINCT ON(fk)  *, count(*) OVER(PARTITION BY fk ORDER BY ts) -- без DESC!FROM  tblWHERE  fk < 10ORDER BY  fk, ts DESC; -- хотим "последнее" значение ts



Сначала мы сортируем по (fk, ts) для вычисления оконного count(*), а потом еще раз по (fk, ts DESC) для вычисления DISTINCT.

Замечу, что если просто написать сортировку как в самом запросе count(*) OVER(PARTITION BY fk ORDER BY ts DESC), то будет, конечно, быстрее. Только вот результат неправильный везде будут одни единички.

Но ведь count, в отличие от разных first_value/lead/lag/..., вообще не зависит от порядка записей давайте просто уберем сортировку для него:

SELECT DISTINCT ON(fk)  *, count(*) OVER(PARTITION BY fk)FROM  tblWHERE  fk < 10ORDER BY  fk, ts DESC;



Так, от одной сортировки избавились. Правда, из-за этого теперь стали читать чуть больше buffers, обменяв Bitmap Heap Scan на Index Only Scan по нашему индексу, с которым совпадает целевая сортировка зато быстрее!

Хм Но ведь и оставшаяся сортировка с ним тоже совпадает. Можно ли и от нее избавиться, не нарушив корректность результата? Вполне! Для этого всего лишь укажем нужную рамку окна по всем записям (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING):

SELECT DISTINCT ON(fk)  *, count(*) OVER(PARTITION BY fk ORDER BY ts DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)FROM  tblWHERE  fk < 10ORDER BY  fk, ts DESC;



Итого тот же результат в 2.5 раза быстрее, и без единой лишней сортировки.

Bonus: Полезные сортировки, которые не происходят


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

SELECT DISTINCT ON(fk)  *FROM  tblORDER BY  fk, ts DESC;



Прочитать почти 8GB данных ради 10K записей как-то многовато Давайте воспользуемся методикой рекурсивного DISTINCT:

WITH RECURSIVE T AS (  (    SELECT      fk    , ts    FROM      tbl    ORDER BY      fk, ts DESC    LIMIT 1 -- первая запись с минимальным ключом  )UNION ALL  SELECT    X.*  FROM    T  , LATERAL(      SELECT        fk      , ts      FROM        tbl      WHERE        fk > T.fk      ORDER BY        fk, ts DESC      LIMIT 1 -- следующая по индексу запись    ) X  WHERE    T.fk IS NOT NULL)TABLE T;



Получилось в 12 раз быстрее, потому что мы не читали ничего лишнего, в отличие от Index Only Scan. Хоть мы и использовали дважды в запросе ORDER BY, ни одной сортировки в плане так и не появилось.
Вероятно, в будущих версиях PostgreSQL для таких точечных чтений появится соответствующий тип узла Index Skip Scan. Но скоро его ждать не стоит.
Замечу, что результат этих запросов все-таки немного отличается второй не обрабатывает записи с fk IS NULL. Но кому надо извлечет их отдельно.

Знаете другие способы устранения лишних сортировок? Напишите в комментариях!
Подробнее..

PostgreSQL Antipatterns DBA-детектив, или Три дела о потерянной производительности

18.11.2020 10:21:27 | Автор: admin
Сегодня вместо решения абстрактных алгоритмических задач мы выступим в роли детектива, по крупицам доставшейся информации исследующего неэффективные запросы, и рассмотрим три реальных дела, встречавшихся в разное время на просторах нашего приложения СБИС, когда простота и наивность при написании SQL превращалась в дополнительную нагрузку для PostgreSQL-сервера.


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

  • Дело о непростом пути вверх
    Разберем в live-видео на реальном примере некоторые из способов улучшения производительности иерархического запроса.
  • Дело о худеющем запросе
    Увидим, как можно запрос упростить и ускорить в несколько раз, пошагово применяя стандартные методики.
  • Дело о развесистой клюкве
    Восстановим структуру БД на основании единственного запроса с 11 JOIN и предложим альтернативный вариант решения на ней той же задачи.

#1: Дело о непростом пути вверх



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

WITH RECURSIVE h AS (  SELECT    n."@Номенклатура" id  , ARRAY[      coalesce(        (          SELECT            ne."Info"          FROM            "NomenclatureExt" ne          WHERE            ne."@Номенклатура" = n."@Номенклатура"          LIMIT 1        )      , '{}'      )    ] res  , n."Раздел" -- предок по иерархии  FROM    "Номенклатура" n  WHERE    n."@Номенклатура" = ANY($1::integer[])UNION -- уникализация  SELECT    h.id  , array_append(      h.res    , coalesce(        (          SELECT            ne."Info"          FROM            "NomenclatureExt" ne          WHERE            ne."@Номенклатура" = n."@Номенклатура"          LIMIT 1        )      , '{}'      )    ) -- расширение массива  , n."Раздел"  FROM    "Номенклатура" n  , h  WHERE    n."@Номенклатура" = h."Раздел" -- двигаемся вверх по иерархии в сторону предков)SELECT  h.id, h.resFROM  hWHERE  h."Раздел" IS NULL;

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

Что/зачем делает запрос?


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

В целом, это достаточно простой запрос, который идет рекурсивно по номенклатуре, вычитывая на первом шаге карточки товаров по списку идентификаторов, который был передан параметром.

WITH RECURSIVE / Path
На этом же шаге, помимо самого ID номенклатурной карточки, мы получаем идентификатор ее предка по иерархии и начинаем формировать массив-путь.

Subquery
Обратим внимание, что для каждой найденной записи номенклатуры будет произведен поиск связанной записи в соседней таблице NomenclatureExt. Явно это какая-то расширенная информация по номенклатурной карточке, связанная 1-в-1.

UNION
На каждом следующем шаге рекурсии будет происходить то же самое, но уже для записей карточек-предков по иерархии. А раз в запросе применено ключевое слово UNION, а не UNION ALL, то записи будут уникализироваться на каждой рекурсивной итерации.

Path Filter
Ну, и в финале мы оставляем только те записи, которые являются финальными в цепочке то есть мы простроили путь вверх отдельно для каждого исходного идентификатора.

Проблемы в запросе


Какие очевидные проблемы при выполнении данного запроса нам грозят?

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

    Представим, что на первом шаге мы передали 60 идентификаторов в запрос и нашли эти 60 карточек из одного раздела. Несмотря на это, мы будем искать одного и того же предка те же 60 раз.
  2. Повторная вычитка связанной записи
    Поскольку мы вычитываем связанную запись независимо ни от чего, то и этот поиск мы делаем ровно столько же раз, сколько вычитываем запись основную.
  3. Вложенный запрос под уникализацией
    Одно и то же значение из связанной записи вычитывается каждый раз, и только после этого схлапывается до единственного экземпляра.

    То есть в нашем примере 59 из 60 вложенных запросов будут выполнены заведомо абсолютно зря.

Обратим внимание на конкретный вариант плана такого запроса:

  • 107 карточек вычитано Bitmap Scan на стартовой итерации рекурсии и плюсом к ним 107 индексных поисков связанных
  • Поскольку PostgreSQL заранее не понимает, сколько и каких записей мы найдем вверх по иерархии, он вычитывает сразу все 18K из номенклатуры с помощью Seq Scan. В результате, из 22мс выполнения запроса 12мс мы потратили на чтение всей таблицы и еще 5мс на ее хэширование, итого больше 77%.
  • Из вычитанных 18K нужными нам по результату Hash Join окажутся только 475 штук и теперь добавим к ним еще 475 Index Scan по связанным записям.
  • Итого: 22мс и 2843 buffers суммарно.

Что/как можно исправить?


Во-первых, стоит избавиться от дубль-чтений одних и тех же карточек при движении вверх по иерархии.

  1. Поскольку нам нужны сразу и идентификатор самой карточки, и идентификатор ее предка, будем вычитывать записи сразу целиком как (tableAlias).
  2. Вычитку будем производить с помощью конструкции = ANY(ARRAY(...)), исключая возможность возникновения неудобных JOIN.
  3. Для возможности уникализации и хэширования скастуем записи таблицы в (row)::text.
  4. Поскольку внутри рекурсии обращение к рекурсивной части может быть только однократным и строго не внутри вложенных запросов, вместо этого материализуем ее внутри отдельной CTE.
  5. Таблицу состоящую из единственного столбца можно свернуть с помощью ARRAY(TABLE X) до скалярного значения-массива. А если в ней и так одна запись, то использовать ее с нужной раскастовкой (TABLE X)::integer[].

-- рекурсивный подъем вверх до корня с поиском только уникальных записей, it AS (  SELECT    it::text -- иначе не работает уникализация через UNION  FROM    "Номенклатура" it  WHERE    "@Номенклатура" = ANY((TABLE src)::integer[])UNION  (    WITH X AS (      SELECT DISTINCT        (it::"Номенклатура")."Раздел"      FROM        it      WHERE        (it::"Номенклатура")."Раздел" IS NOT NULL    )    SELECT      it2::text    FROM      "Номенклатура" it2    WHERE      "@Номенклатура" = ANY(ARRAY(TABLE X))  ))

Поскольку мы получили только набор самих записей, теперь нам надо выстроить из них цепочки потомок-предок рекурсивным спуском сверху-вниз:

-- рекурсивный спуск вниз для формирования "пути" к каждой карточке, itr AS (  SELECT    ARRAY[(it::"Номенклатура")."@Номенклатура"] path  , it::"Номенклатура" -- запись исходной таблицы  FROM    it  WHERE    (it::"Номенклатура")."Раздел" IS NULL -- стартуем от "корневых" записейUNION ALL  SELECT    ARRAY[((_it.it)::"Номенклатура")."@Номенклатура"] || itr.path -- наращиваем "путь" спереди  , (_it.it)::"Номенклатура"  FROM    itr  JOIN    it _it      ON ((_it.it)::"Номенклатура")."Раздел@" IS NOT FALSE AND      ((_it.it)::"Номенклатура")."Раздел" = (itr.it)."@Номенклатура")

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

  • Соберем весь набор ID, встречающихся в путях. Но это ровно тот же набор, который дают ID самих наших извлеченных записей.
  • Извлечем опять сразу все нужные нам записи связанной таблицы за один проход через = ANY(ARRAY(...)).
  • Сложим все полученные значения нужного поля в hstore-словарик.

-- формируем словарь info для каждого ключа, чтобы не бегать по записям CTE, hs AS (  SELECT    hstore(      array_agg("@Номенклатура"::text)    , array_agg(coalesce("Info", '{}'))    )  FROM    "NomenclatureExt"  WHERE    "@Номенклатура" = ANY(ARRAY(      SELECT        (it)."@Номенклатура"      FROM        itr    )))

Остался последний шаг преобразовать цепочку ID в цепочку Info с помощью ARRAY(SELECT ... unnest(...)):

, ARRAY(    SELECT      (TABLE hs) -> id::text -- извлекаем данные из "словаря"    FROM      unnest(path) id  ) res

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

-- список всех исходных IDWITH RECURSIVE src AS (  SELECT $1::integer[] -- набор ID в виде сериализованного массива)-- рекурсивный подъем вверх до корня с поиском только уникальных записей, it AS (  SELECT    it::text -- иначе не работает уникализация через UNION  FROM    "Номенклатура" it  WHERE    "@Номенклатура" = ANY((TABLE src)::integer[])UNION  (    WITH X AS (      SELECT DISTINCT        (it::"Номенклатура")."Раздел"      FROM        it      WHERE        (it::"Номенклатура")."Раздел" IS NOT NULL    )    SELECT      it2::text    FROM      "Номенклатура" it2    WHERE      "@Номенклатура" = ANY(ARRAY(TABLE X))  ))-- рекурсивный спуск вниз для формирования "пути" к каждой карточке, itr AS (  SELECT    ARRAY[(it::"Номенклатура")."@Номенклатура"] path  , it::"Номенклатура"  FROM    it  WHERE  WHERE    (it::"Номенклатура")."Раздел" IS NULL -- стартуем от "корневых" записейUNION ALL  SELECT    ARRAY[((_it.it)::"Номенклатура")."@Номенклатура"] || itr.path -- наращиваем "путь" спереди  , (_it.it)::"Номенклатура"  FROM    itr  JOIN    it _it      ON ((_it.it)::"Номенклатура")."Раздел@" IS NOT FALSE AND      ((_it.it)::"Номенклатура")."Раздел" = (itr.it)."@Номенклатура")-- формируем словарь info для каждого ключа, чтобы не бегать по записям CTE, hs AS (  SELECT    hstore(      array_agg("@Номенклатура"::text)    , array_agg(coalesce("Info", '{}'))    )  FROM    "NomenclatureExt"  WHERE    "@Номенклатура" = ANY(ARRAY(      SELECT        (it)."@Номенклатура"      FROM        itr    )))-- строим цепочку info для каждого id из оригинального набораSELECT  path[1] id, ARRAY(    SELECT      (TABLE hs) -> id::text -- извлекаем данные из "словаря"    FROM      unnest(path) id  ) resFROM  itrWHERE  path[1] = ANY((TABLE src)::integer[]); -- ограничиваемся только стартовым набором

  • Теперь на каждом шаге рекурсии (а их получается 4, в соответствии с глубиной дерева) мы добавляем, в среднем, всего по 12 записей.
  • Восстановление путей вниз заняло большую часть времени 10мс. Можно сделать и меньше, но это гораздо сложнее.
  • Итого, новый запрос выполняется 15мс вместо 22мс и читает только лишь 860 страниц данных вместо 2843, что имеет принципиальное влияние на время работы, когда нет возможности обеспечить постоянное присутствие этих данных в кэше.

#2: Дело о худеющем запросе



Второй запрос оказался намного кучерявее, но интереснее с практической точки зрения, поскольку вызывается очень часто для получения информации о персонах как внутри нашей корпоративной социальной сети, так и другими сервисами, использующими ее.

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

Регулярно возникают реплики типа "Вот ты ускорил запрос в 10 раз, но всего на 10мс оно же того не стоит! Мы лучше поставим еще пару реплик! Вместо 100MB памяти получилось 1MB? Да нам проще памяти на сервер добавить!"

Тут какой момент разработчик, вооруженный набором стандартных приемов, на оптимизацию запроса тратит константное время (= деньги), а с увеличением функционала и количества пользователей нагрузка на БД растет примерно как N(logN), а даже не линейно. То есть если сейчас ваш проект ест CPU базы на 50%, готовьтесь к тому, что уже через год вам придется ставить еще один такой же сервер (= деньги), потом еще и еще

Оптимизация запросов не избавляет от добавления мощностей, но сильно отодвигает их в будущее. Добившись вместо нагрузки в 50% всего 10%, вы сможете не расширять железо еще года 2-3, а вложить те же деньги, например, в увеличение штата или чьей-то зарплаты.

00: исходное состояние


00: исходный запрос, 7.2мс
WITH personIds("Персона") AS (  SELECT    $1::uuid[]), persons AS (  SELECT    P."Персона"  , coalesce(P."Фамилия", '') "Фамилия"  , coalesce(P."Имя", '') "Имя"  , coalesce(P."Отчество", '') "Отчество"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ФамилияЛица"        ELSE          P."Фамилия"      END    , ''    ) "ФамилияЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ИмяЛица"        ELSE          P."Имя"      END    , ''    ) "ИмяЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ОтчествоЛица"        ELSE          P."Отчество"      END    , ''    ) "ОтчествоЛица"  , P."Примечание"  , P."Обновлено"  , P."Уволен"  , P."Группа"  , P."Пол"  , P."Логин"  , P."Город"  , P."ДатаРождения"  , P."$Создано"::date "ДатаРегистрации"  , coalesce(P."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(P."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  FROM    "Персона" P  WHERE    "Персона" = ANY((TABLE personids)::uuid[])), counts AS (  SELECT    NULL c), users AS (  SELECT    hstore(      array_agg("Персона"::text)    , array_agg(udata::text)    )  FROM    (      SELECT        "Персона"::text      , array_agg(u::text) udata      FROM        "Пользователь" u      WHERE        "Персона" IN (          SELECT            "Персона"          FROM            persons        ) AND        (          "Главный" OR          (            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )        )      GROUP BY 1    ) u2), T1 AS (  SELECT    persons."Персона"  , persons."Фамилия"  , persons."Имя"  , persons."Отчество"  , persons."ФамилияЛица"  , persons."ИмяЛица"  , persons."ОтчествоЛица"  , persons."Примечание"  , persons."Обновлено"  , persons."Город"  , coalesce(persons."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(persons."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  , counts.c "Всего"  , persons."Группа"  , (      SELECT        ARRAY(          SELECT            row_to_json(t2)          FROM            (              SELECT                "Пользователь" >> 32 as "Account"              , "Пользователь" & x'FFFFFFFF'::bigint "Face"              , coalesce("ЕстьПользователь", TRUE) "HasUser"              , coalesce("ЕстьПользователь", TRUE) AND coalesce("Входил", TRUE) "HasLoggedIn"              , coalesce("Уволен", persons."Уволен") "Fired"              FROM                (                  SELECT                    *                  FROM                    (                      SELECT                        (udata::"Пользователь").*                      FROM                        unnest(((TABLE users) -> "Персона"::text)::text[]) udata                    ) udata15                  WHERE                    "Уволен" IS DISTINCT FROM TRUE AND                    "Удален" IS DISTINCT FROM TRUE                ) udata2            ) t2        )    )::text[] "Users"  , coalesce(      (        SELECT          row_to_json(t3)        FROM          (            SELECT              "Пользователь" >> 32 as "Account"            , "Пользователь" & x'FFFFFFFF'::bigint "Face"            FROM              (                SELECT                  (udata::"Пользователь").*                FROM                  unnest(((TABLE users) -> "Персона"::text)::text[]) udata              ) udata2            WHERE              "Уволен" IS DISTINCT FROM TRUE AND              "Удален" IS DISTINCT FROM TRUE AND              "Пользователь" >> 32 = 5313189::int            ORDER BY              "ЕстьПользователь" DESC, "Входил" DESC            LIMIT 1          ) t3      )    , (        SELECT          row_to_json(t4)        FROM          (            SELECT              "Пользователь" >> 32 as "Account"            , "Пользователь" & x'FFFFFFFF'::bigint "Face"            FROM              (                SELECT                  (udata::"Пользователь").*                FROM                  unnest(((TABLE users) -> "Персона"::text)::text[]) udata              ) udata2            WHERE              "Уволен" IS DISTINCT FROM TRUE AND              "Удален" IS DISTINCT FROM TRUE AND              "Главный"            ORDER BY              "ЕстьПользователь" DESC, "Входил" DESC            LIMIT 1          ) t4      )    , (        SELECT          row_to_json(t5)        FROM          (            SELECT              "Пользователь" >> 32 as "Account"            , "Пользователь" & x'FFFFFFFF'::bigint "Face"            FROM              (                SELECT                  (udata::"Пользователь").*                FROM                  unnest(((TABLE users) -> "Персона"::text)::text[]) udata              ) udata2            WHERE              "Уволен" IS DISTINCT FROM TRUE AND              "Удален" IS DISTINCT FROM TRUE            LIMIT 1          ) t5      )    ) "PrimaryFaceAccount"  , (      SELECT        "Пользователь" >> 32      FROM        (          SELECT            "Пользователь"          FROM            (              SELECT                (udata::"Пользователь").*              FROM                unnest(((TABLE users) -> "Персона"::text)::text[]) udata            ) udata2          WHERE            "Главный"        ) t3      LIMIT 1    ) "MainAccount"  , ARRAY(      SELECT        "Значение"::int      FROM        "КонтактныеДанные"      WHERE        persons."Группа" AND        "Персона" = persons."Персона" AND        "Тип" = 'account'    ) "АккаунтыГруппы"  , persons."Пол"  , persons."Логин"  , persons."ДатаРождения"  , persons."ДатаРегистрации"  FROM    persons  , counts)SELECT  CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ФамилияЛица"    ELSE      "Фамилия"  END "LastName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ИмяЛица"    ELSE      "Имя"  END "FirstName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ОтчествоЛица"    ELSE      "Отчество"  END "PatronymicName", *FROM  T1;



Даже беглого взгляда на диаграмму выполнения достаточно, чтобы сразу увидеть, что в плане встречаются подозрительно одинаковые куски (SubPlan 8, SubPlan 10, SubPlan 12, SubPlan 14, SubPlan 16), внутри которых время тратится на unnest записей из массива внутри CTE.

Эти субпланы соответствуют подзапросам по развороту массива пользователей из hstore по ключу каждой отдельной персоны:

  , coalesce(      (        SELECT          row_to_json(T)        FROM          (            SELECT              ...            FROM              (                SELECT                  (udata::"Пользователь").*                FROM                  unnest(((TABLE users) -> "Персона"::text)::text[]) udata              ) udata2            WHERE              ...            ORDER BY              ...            LIMIT 1          ) T      )

Заметим, что все эти подзапросы работают с одним и тем же исходным набором данных, просто кто-то берет из него данные по одному условию, а кто-то по другому; кто-то сортирует, а кто-то нет; кто-то берет все поля из всех записей, а кто-то только пару из первой

  1. Можно ли сделать все то же самое за один проход? Конечно! В этом нам помогут FILTER (9.4+) и LATERAL (9.3+).
  2. Вместо построения JSON независимо в 5 разных местах (по одним и тем же записям, в основном). Построим эти JSON сразу для каждой исходной записи в полном (5 ключей) и коротком (2 ключа) вариантах.
  3. Сортировка исходного набора совпадает во всех местах, где используется. Где не используется значит, непринципиально для данных, и ее можно использовать все равно.
  4. LIMIT 1 можно успешно заменить на извлечение первого элемента массива: arr[1]. Так что собираем по каждому условию именно массивы.
  5. Для одновременного возврата нескольких агрегатов используем сериализацию в ARRAY[aggx::text, aggy::text].

01. FILTER + LATERAL + single JSON (4мс, -45%)
WITH personIds("Персона") AS (  SELECT    $1::uuid[]), persons AS (  SELECT    P."Персона"  , coalesce(P."Фамилия", '') "Фамилия"  , coalesce(P."Имя", '') "Имя"  , coalesce(P."Отчество", '') "Отчество"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ФамилияЛица"        ELSE          P."Фамилия"      END    , ''    ) "ФамилияЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ИмяЛица"        ELSE          P."Имя"      END    , ''    ) "ИмяЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ОтчествоЛица"        ELSE          P."Отчество"      END    , ''    ) "ОтчествоЛица"  , P."Примечание"  , P."Обновлено"  , P."Уволен"  , P."Группа"  , P."Пол"  , P."Логин"  , P."Город"  , P."ДатаРождения"  , P."$Создано"::date "ДатаРегистрации"  , coalesce(P."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(P."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  FROM    "Персона" P  WHERE    "Персона" = ANY((TABLE personids)::uuid[])), counts AS (  SELECT    NULL c), users AS (  SELECT    hstore(      array_agg("Персона"::text)    , array_agg(udata::text)    )  FROM    (      SELECT        "Персона"::text      , array_agg(u::text) udata      FROM        "Пользователь" u      WHERE        "Персона" IN (          SELECT            "Персона"          FROM            persons        ) AND        (          "Главный" OR          (            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )        )      GROUP BY 1    ) u2), T1 AS (  SELECT    persons."Персона"  , persons."Фамилия"  , persons."Имя"  , persons."Отчество"  , persons."ФамилияЛица"  , persons."ИмяЛица"  , persons."ОтчествоЛица"  , persons."Примечание"  , persons."Обновлено"  , persons."Город"  , coalesce(persons."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(persons."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  , counts.c "Всего"  , persons."Группа"-- 8< --  , coalesce(usjs[1]::text[], '{}') "Users"  , coalesce(      (usjs[2]::json[])[1]    , (usjs[3]::json[])[1]    , (usjs[4]::json[])[1]    ) "PrimaryFaceAccount"  , ((usjs[5]::json[])[1] ->> 'Account')::bigint "MainAccount"-- 8< --  , ARRAY(      SELECT        "Значение"::int      FROM        "КонтактныеДанные"      WHERE        persons."Группа" AND        "Персона" = persons."Персона" AND        "Тип" = 'account'    ) "АккаунтыГруппы"  , persons."Пол"  , persons."Логин"  , persons."ДатаРождения"  , persons."ДатаРегистрации"  FROM    persons  , counts-- 8< --  , LATERAL (      SELECT        ARRAY[ -- массив сериализованных json[]          array_agg(json_f) FILTER (WHERE -- фильтрация по необходимому условию            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Пользователь" >> 32 = 5313189::int          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Главный"          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Главный"          )::text        ] usjs      FROM        (          SELECT -- готовим JSON для каждой записи сразу, потом только используем готовый            json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            , 'HasUser'            , coalesce("ЕстьПользователь", TRUE)            , 'HasLoggedIn'            , coalesce("ЕстьПользователь", TRUE) AND coalesce("Входил", TRUE)            , 'Fired'            , coalesce("Уволен", persons."Уволен")            ) json_f          , json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            ) json_s          , *          FROM            (              SELECT                (unnest).*              FROM                unnest(((TABLE users) -> "Персона"::text)::"Пользователь"[])            ) T          ORDER BY -- сортировка одна на всех            "ЕстьПользователь" DESC, "Входил" DESC        ) T    ) usjs-- 8< --)SELECT  CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ФамилияЛица"    ELSE      "Фамилия"  END "LastName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ИмяЛица"    ELSE      "Имя"  END "FirstName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ОтчествоЛица"    ELSE      "Отчество"  END "PatronymicName", *FROM  T1;



План уже много приятнее и много короче. Кто самое слабое звено теперь? unnest!

Так, стоп Мы в unnest по каждой персоне разворачиваем массив, который ранее засунули в hstore с ключом этой же персоны? А физически-то мы все равно отбираем в hstore независимо по каждой персоне.

Я это к тому, что мы сначала нашли, сгруппировали, сериализовали, потом достали, десериализовали, развернули Что бы серверу не поработать-то?..

  1. В общем, выносим формирование JSON в подзапрос именно по каждой из персон. В результате у нас исчезает CTE users и hstore.

02. Подзапрос (4мс, -45%)
WITH personIds("Персона") AS (  SELECT    $1::uuid[]), persons AS (  SELECT    P."Персона"  , coalesce(P."Фамилия", '') "Фамилия"  , coalesce(P."Имя", '') "Имя"  , coalesce(P."Отчество", '') "Отчество"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ФамилияЛица"        ELSE          P."Фамилия"      END    , ''    ) "ФамилияЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ИмяЛица"        ELSE          P."Имя"      END    , ''    ) "ИмяЛица"  , coalesce(      CASE        WHEN nullif(P."ФамилияЛица", '') IS NOT NULL THEN          P."ОтчествоЛица"        ELSE          P."Отчество"      END    , ''    ) "ОтчествоЛица"  , P."Примечание"  , P."Обновлено"  , P."Уволен"  , P."Группа"  , P."Пол"  , P."Логин"  , P."Город"  , P."ДатаРождения"  , P."$Создано"::date "ДатаРегистрации"  , coalesce(P."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(P."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"-- 8< --  , (      SELECT        ARRAY[ -- массив сериализованных json[]          array_agg(json_f) FILTER (WHERE -- фильтрация по необходимому условию            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Пользователь" >> 32 = 5313189::int          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Главный"          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Главный"          )::text        ] usjs      FROM        (          SELECT -- готовим JSON для каждой записи сразу, потом только используем готовый            json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            , 'HasUser'            , coalesce("ЕстьПользователь", TRUE)            , 'HasLoggedIn'            , coalesce("ЕстьПользователь", TRUE) AND coalesce("Входил", TRUE)            , 'Fired'            , coalesce("Уволен", P."Уволен")            ) json_f          , json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            ) json_s          , *          FROM            "Пользователь"          WHERE            "Персона" = P."Персона" AND            (              "Главный" OR              (                "Уволен" IS DISTINCT FROM TRUE AND                "Удален" IS DISTINCT FROM TRUE              )            )          ORDER BY -- сортировка одна на всех            "ЕстьПользователь" DESC, "Входил" DESC        ) T    ) usjs-- 8< --  FROM    "Персона" P  WHERE    "Персона" = ANY((TABLE personids)::uuid[])), counts AS (  SELECT    NULL c), T1 AS (  SELECT    persons."Персона"  , persons."Фамилия"  , persons."Имя"  , persons."Отчество"  , persons."ФамилияЛица"  , persons."ИмяЛица"  , persons."ОтчествоЛица"  , persons."Примечание"  , persons."Обновлено"  , persons."Город"  , coalesce(persons."ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce(persons."ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  , counts.c "Всего"  , persons."Группа"  , coalesce(usjs[1]::text[], '{}') "Users"  , coalesce(      (usjs[2]::json[])[1]    , (usjs[3]::json[])[1]    , (usjs[4]::json[])[1]    ) "PrimaryFaceAccount"  , ((usjs[5]::json[])[1] ->> 'Account')::bigint "MainAccount"  , ARRAY(      SELECT        "Значение"::int      FROM        "КонтактныеДанные"      WHERE        persons."Группа" AND        "Персона" = persons."Персона" AND        "Тип" = 'account'    ) "АккаунтыГруппы"  , persons."Пол"  , persons."Логин"  , persons."ДатаРождения"  , persons."ДатаРегистрации"  FROM    persons  , counts)SELECT  CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ФамилияЛица"  ELSE    "Фамилия"  END "LastName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ИмяЛица"    ELSE      "Имя"  END "FirstName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ОтчествоЛица"    ELSE      "Отчество"  END "PatronymicName", *FROM  T1;



Кто теперь выглядит лишним?

  1. Очевидно, CTE personids (заменяем на inline-параметр с раскастовкой) и CTE counts (вообще какой-то странный атавизм, возвращающий один NULL).
  2. После этого замечаем, что все выборки у нас стали из единственной таблички, поэтому лучше убрать избыточные алиасы.

03. Inline-параметры (3.9мс, -46%)
WITH persons AS (  SELECT    "Персона"  , coalesce("Фамилия", '') "Фамилия"  , coalesce("Имя", '') "Имя"  , coalesce("Отчество", '') "Отчество"  , coalesce(      CASE        WHEN nullif("ФамилияЛица", '') IS NOT NULL THEN          "ФамилияЛица"        ELSE          "Фамилия"      END    , ''    ) "ФамилияЛица"  , coalesce(      CASE        WHEN nullif("ФамилияЛица", '') IS NOT NULL THEN          "ИмяЛица"        ELSE          "Имя"      END    , ''    ) "ИмяЛица"  , coalesce(      CASE        WHEN nullif("ФамилияЛица", '') IS NOT NULL THEN          "ОтчествоЛица"        ELSE          "Отчество"      END    , ''    ) "ОтчествоЛица"  , "Примечание"  , "Обновлено"  , "Уволен"  , "Группа"  , "Пол"  , "Логин"  , "Город"  , "ДатаРождения"  , "$Создано"::date "ДатаРегистрации"  , coalesce("ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce("ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  , (      SELECT        ARRAY[ -- массив сериализованных json[]          array_agg(json_f) FILTER (WHERE -- фильтрация по необходимому условию            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Пользователь" >> 32 = 5313189::int          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Главный"          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Главный"          )::text        ] usjs      FROM        (          SELECT -- готовим JSON для каждой записи сразу, потом только используем готовый            json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            , 'HasUser'            , coalesce("ЕстьПользователь", TRUE)            , 'HasLoggedIn'            , coalesce("ЕстьПользователь", TRUE) AND coalesce("Входил", TRUE)            , 'Fired'            , coalesce("Уволен", p."Уволен")            ) json_f          , json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            ) json_s          , *          FROM            "Пользователь"          WHERE            "Персона" = p."Персона" AND            (              "Главный" OR              (                "Уволен" IS DISTINCT FROM TRUE AND                "Удален" IS DISTINCT FROM TRUE              )            )          ORDER BY -- сортировка одна на всех            "ЕстьПользователь" DESC, "Входил" DESC        ) T    ) usjs  FROM    "Персона" p  WHERE-- 8< --    "Персона" = ANY($1::uuid[])-- 8< --), T1 AS (  SELECT    "Персона"  , "Фамилия"  , "Имя"  , "Отчество"  , "ФамилияЛица"  , "ИмяЛица"  , "ОтчествоЛица"  , "Примечание"  , "Обновлено"  , "Город"  , coalesce("ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"  , coalesce("ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"  , NULL::bigint "Всего"  , "Группа"  , coalesce(usjs[1]::text[], '{}') "Users"  , coalesce(      (usjs[2]::json[])[1]    , (usjs[3]::json[])[1]    , (usjs[4]::json[])[1]    ) "PrimaryFaceAccount"  , ((usjs[5]::json[])[1] ->> 'Account')::bigint "MainAccount"  , ARRAY(      SELECT        "Значение"::int      FROM        "КонтактныеДанные"      WHERE        persons."Группа" AND        "Персона" = persons."Персона" AND        "Тип" = 'account'    ) "АккаунтыГруппы"  , "Пол"  , "Логин"  , "ДатаРождения"  , "ДатаРегистрации"  FROM    persons)SELECT  CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ФамилияЛица"  ELSE    "Фамилия"  END "LastName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ИмяЛица"    ELSE      "Имя"  END "FirstName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ОтчествоЛица"    ELSE      "Отчество"  END "PatronymicName", *FROM  T1;


Смотрим теперь на запрос очень-очень пристально, и задумываемся:

  1. Зачем нам лишняя CTE T1 (ведь CTE Scan стоит ресурсов)?
  2. Зачем мы один и тот же список полей переписываем дважды?
  3. Зачем дважды применяется coalesce на одни и те же поля?

04. Убрали все лишнее (3.2мс, -56%)
WITH p AS (  SELECT    *-- 8< --  , CASE      WHEN nullif("ФамилияЛица", '') IS NOT NULL THEN        ARRAY[          coalesce("ФамилияЛица", '')        , coalesce("ИмяЛица", '')        , coalesce("ОтчествоЛица", '')        ]      ELSE        ARRAY[          coalesce("Фамилия", '')        , coalesce("Имя", '')        , coalesce("Отчество", '')        ]    END fio-- 8< --  , (      SELECT        ARRAY[ -- массив сериализованных json[]          array_agg(json_f) FILTER (WHERE -- фильтрация по необходимому условию            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Пользователь" >> 32 = 5313189::int          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE AND            "Главный"          )::text        , array_agg(json_s) FILTER (WHERE            "Уволен" IS DISTINCT FROM TRUE AND            "Удален" IS DISTINCT FROM TRUE          )::text        , array_agg(json_s) FILTER (WHERE            "Главный"          )::text        ] usjs      FROM        (          SELECT -- готовим JSON для каждой записи сразу, потом только используем готовый            json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            , 'HasUser'            , coalesce("ЕстьПользователь", TRUE)            , 'HasLoggedIn'            , coalesce("ЕстьПользователь", TRUE) AND coalesce("Входил", TRUE)            , 'Fired'            , coalesce("Уволен", p."Уволен")            ) json_f          , json_build_object(              'Account'            , "Пользователь" >> 32            , 'Face'            , "Пользователь" & x'FFFFFFFF'::bigint            ) json_s          , *          FROM            "Пользователь"          WHERE            "Персона" = p."Персона" AND            (              "Главный" OR              (                "Уволен" IS DISTINCT FROM TRUE AND                "Удален" IS DISTINCT FROM TRUE              )            )          ORDER BY -- сортировка одна на всех            "ЕстьПользователь" DESC, "Входил" DESC        ) T    ) usjs  FROM    "Персона" p  WHERE    "Персона" = ANY($1::uuid[]))-- 8< --SELECT  CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ФамилияЛица"  ELSE    "Фамилия"  END "LastName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ИмяЛица"    ELSE      "Имя"  END "FirstName", CASE    WHEN "ЕстьКорпАккаунт" OR "MainAccount" IS NOT NULL THEN      "ОтчествоЛица"    ELSE      "Отчество"  END "PatronymicName", *FROM  (    SELECT      "Персона"    , coalesce("Фамилия", '') "Фамилия"    , coalesce("Имя", '') "Имя"    , coalesce("Отчество", '') "Отчество"-- 8< --    , fio[1] "ФамилияЛица"    , fio[2] "ИмяЛица"    , fio[3] "ОтчествоЛица"-- 8< --    , "Примечание"    , "Обновлено"    , "Город"    , coalesce("ОтдельныйПрофиль", FALSE) "ОтдельныйПрофиль"    , coalesce("ЕстьКорпАккаунт", FALSE) "ЕстьКорпАккаунт"    , NULL::bigint "Всего"    , "Группа"    , coalesce(usjs[1]::text[], '{}') "Users"    , coalesce(        (usjs[2]::json[])[1]      , (usjs[3]::json[])[1]      , (usjs[4]::json[])[1]      ) "PrimaryFaceAccount"    , ((usjs[5]::json[])[1] ->> 'Account')::bigint "MainAccount"    , ARRAY(        SELECT          "Значение"::int        FROM          "КонтактныеДанные"        WHERE          p."Группа" AND-- 8< --          ("Персона", "Тип") = (p."Персона", 'account')-- 8< --      ) "АккаунтыГруппы"    , "Пол"    , "Логин"    , "ДатаРождения"    , "$Создано"::date "ДатаРегистрации"    FROM      p  ) T;-- 8< --



Итого, запрос мы ускорили больше чем в 2 раза, а упростили на порядок. Будьте ленивее, не пишите много, не копипастите!

#3: Дело о развесистой клюкве



В своем докладе на конференции я говорил, что самая большая проблема, с которой приходится сталкиваться при оптимизации запросов алгоритмическая. То есть когда разработчик не заморачивается пониманием того, как конкретно БД должна/будет выполнять запрос.

Классический пример цепочка JOIN'ов, приводящая к развесистой клюкве из Nested Loop/Hash Join/Merge Join в плане. В особо клинических случаях к ней добавляется схлапывание полученной матрицы с помощью DISTINCT/GROUP BY.
Именно таким оказался запрос из последнего сегодняшнего дела:

Оригинальный запрос, 10.1мс, 11600 buffers
SELECT DISTINCT ON (db."@ПулСерверов")  group_id."@ПулСерверов" "ИдГруппы", group_id."Название" "ИмяГруппы", CASE    WHEN group_id."Название" = 'Управление облаком' THEN      TRUE  ELSE    FALSE  END "ЭтоУправлениеОблаком", group_id."Тип" "Тип", group_id."Заблокирован" "Заблокирован", CASE    WHEN group_id."Тип" = 15 THEN      app."Код"  ELSE    group_id."Код"  END "Код", is_demo."@ПулСерверов" is not null "Демо", group_ext_id."ДопустимоеЧислоПользователей" "ДопустимоеЧислоПользователей", group_ext_id."Состояние" "Состояние", db."@ПулСерверов" "ИдБД", db_name."ИмяБД" "ИмяБД", hosts."Название" "ХостБД", db_name."Порт" "ПортБД", group_id. "Отстойник" "Отстойник", (    WITH params AS(      SELECT        cpv."Значение"      , cpv."Сайт"      FROM        "ОбщиеПараметры" cp      INNER JOIN        "ЗначенияОбщихПараметров" cpv          ON cp."@ОбщиеПараметры" = cpv."ОбщиеПараметры"      WHERE        cp."Название" = 'session_cache_time' AND        (cpv."Сайт" = 9 or cpv."Сайт" is null)    )    SELECT      coalesce(        (SELECT "Значение" FROM params WHERE "Сайт" = 9)      , (SELECT "Значение" FROM params WHERE "Сайт" IS NULL)      , (SELECT "ЗначениеПоУмолчанию" FROM "ОбщиеПараметры" WHERE "Название" = 'session_cache_time')      , 60::text      )::integer  ) "ТаймаутКэша", CASE    WHEN nullif(111, 0) IS NULL THEN      NULL    WHEN 111 = group_id."@ПулСерверов" THEN      TRUE    ELSE      FALSE  END "Эталонная", site."@Сайт" "ИдСайта", site."Адрес" "ИмяСайта"FROM  "ПулСерверов" group_idJOIN  "ПулРасширение" group_ext_id    ON group_id."@ПулСерверов" = group_ext_id."@ПулСерверов" AND NOT (group_id."@ПулСерверов" = ANY('{}'::integer[]))JOIN  "ПулСерверов" folder_db    ON group_id."@ПулСерверов" = folder_db."Раздел"JOIN  "ПулСерверов" db    ON folder_db."@ПулСерверов" = db."Раздел"LEFT JOIN  "Сервер" hosts    ON db."Сервер" = hosts."@Сервер"JOIN  "БазаДанных" db_name    ON db."@ПулСерверов" = db_name."@ПулСерверов"LEFT JOIN  (    WITH list_demo_app AS (      SELECT        ps0."ПулСерверов"      FROM        "ОбщиеПараметры" p0      INNER JOIN        "ОбщиеПараметры" p1          ON p1."Раздел" = p0."@ОбщиеПараметры" AND p0."Название" = 'Управление облаком'      INNER JOIN        "ОбщиеПараметры" p2          ON p2."Раздел" = p1."@ОбщиеПараметры" AND p1."Название" = 'Шайтан' AND p2."Название" = 'ЭтоДемонстрационнаяГруппа'      INNER JOIN        "ОбщиеПараметрыСервис" ps0          ON ps0."ОбщиеПараметры" = p2."@ОбщиеПараметры"    )    , list_demo_srv AS (      SELECT        pool1."@ПулСерверов"      FROM        list_demo_app ls      INNER JOIN        "ПулСерверов" pool0          ON ls."ПулСерверов" = pool0."@ПулСерверов"      INNER JOIN        "ПулСерверов" pool1          ON pool1."Раздел" = pool0."@ПулСерверов" AND pool1."Тип" = 15    )    SELECT      "@ПулСерверов"    FROM      list_demo_srv  ) is_demo    ON is_demo."@ПулСерверов" = group_id."@ПулСерверов"JOIN  "ПулСерверов" app    ON group_id."Раздел" = app."@ПулСерверов"LEFT JOIN  "Приложение" service    ON service."ПулСерверов" = group_id."@ПулСерверов"LEFT JOIN  "СайтПриложение" site_app    ON site_app."Приложение" = service."Раздел"LEFT JOIN  "Сайт" site    ON site."@Сайт" = site_app."Сайт"WHERE  group_id."Тип" = 15 AND  folder_db."Тип" = 8 AND  db."Тип" = 4 AND  db_name."ИмяБД" IS NOT NULL AND  (    (1 = 1 AND is_demo."@ПулСерверов" IS NOT NULL) OR    (1 = 2 AND is_demo."@ПулСерверов" IS NULL) OR    1 NOT IN (1, 2)  );


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

  1. В запросе используется 11 таблиц, провязанных JOIN'ами Это очень смело. Чтобы так делать безболезненно, вы должны точно знать, что после каждого шага связывания количество записей будет ограничено, буквально, единицами. Иначе рискуете получить join 1000 x 1000.
  2. Внимательно смотрим на запрос и строим понятийную модель БД. Разработчику, который это писал проще он ее и так знает, а нам придется восстановить на основе условий соединений, названий полей и бытовой логики. Вообще, если вы графически представляете, как у вас устроена БД, это может сильно помочь с написанием хорошего запроса. У меня получилось вот так:


  3. За счет DISTINCT ON(db."@ПулСерверов") мы ожидаем результат, уникализованный по записи db, в нашей схеме она вон аж в каком низу Но посмотрим на условия запроса в самом низу они из каждой сущности (group_id, folder_db, db) отсекают сверху вниз по значению типа существенные куски.
  4. Теперь самое интересное вложенный запрос, создающий выборку is_demo. Заметим, что его тело не зависит ни от чего то есть его можно смело поднять в самое начало основного WITH-блока. То есть лишнее выделение в подзапрос тут только усложняет все без какого-либо профита.
  5. Заметим, что условия is_demo."@ПулСерверов" = group_id."@ПулСерверов" и is_demo."@ПулСерверов" IS NOT NULL при LEFT JOIN этих таблиц, фактически, означает необходимость присутствия PK group_id среди идентификаторов в is_demo.

    Самое очевидное, что тут можно сделать так и переписать запрос, отбирая записи group_id по набору идентификаторов is_demo.
  6. Переписываем извлечение этих сущностей в независимые CTE, и с удивлением замечаем, что у нас на БД отсутствуют подходящие индексы по ПулСерверов(Тип, Раздел). Причем эти типы константны с точки зрения приложения, поэтому лучше триплет индексов ПулСерверов(Раздел) WHERE Тип = ....
  7. Вспомним, что пересечение нескольких CTE может быть весьма затратным, и заменим его на JOIN через словарь, предварительно сформировав его из записей group_id, folder_db и db ведь это одна исходная таблица ПулСерверов.
  8. Вложенный запрос получения параметра ТаймаутКэша просто переписываем, избавляя от ненужных CTE.

Результат: 0.4мс (в 25 раз лучше), 134 buffers (в 86 раз лучше)
WITH demo_app AS (  SELECT    ps0."ПулСерверов"  FROM    "ОбщиеПараметры" p0  JOIN    "ОбщиеПараметры" p1      ON (p1."Раздел", p1."Название") = (p0."@ОбщиеПараметры", 'Шайтан')  JOIN    "ОбщиеПараметры" p2      ON (p2."Раздел", p2."Название") = (p1."@ОбщиеПараметры", 'ЭтоДемонстрационнаяГруппа')  JOIN    "ОбщиеПараметрыСервис" ps0      ON ps0."ОбщиеПараметры" = p2."@ОбщиеПараметры"  WHERE    p0."Название" = 'Управление облаком'), demo_srv as(  SELECT    pool1."@ПулСерверов"  FROM    demo_app ls  JOIN    "ПулСерверов" pool0      ON ls."ПулСерверов" = pool0."@ПулСерверов"  JOIN    "ПулСерверов" pool1      ON (pool1."Тип", pool1."Раздел") = (15, pool0."@ПулСерверов") -- CREATE INDEX CONCURRENTLY "iПС-tmp0-t15" ON "ПулСерверов"("Раздел") WHERE "Тип" = 15;), grp AS (  SELECT    grp  FROM    "ПулСерверов" grp  WHERE    "Тип" = 15 AND    "@ПулСерверов" = ANY(ARRAY(      SELECT        "@ПулСерверов"      FROM        demo_srv    ))), fld AS (  SELECT    fld  FROM    "ПулСерверов" fld  WHERE    "Раздел" = ANY(ARRAY(      SELECT        (grp)."@ПулСерверов"      FROM        grp    )) AND    "Тип" = 8 -- CREATE INDEX CONCURRENTLY "iПС-tmp0-t8" ON "ПулСерверов"("Раздел") WHERE "Тип" = 8;), dbs AS (  SELECT    dbs  FROM    "ПулСерверов" dbs  WHERE    "Раздел" = ANY(ARRAY(      SELECT        (fld)."@ПулСерверов"      FROM        fld    )) AND    "Тип" = 4 -- CREATE INDEX CONCURRENTLY "iПС-tmp0-t4" ON "ПулСерверов"("Раздел") WHERE "Тип" = 4;), srvhs AS (  SELECT    hstore(      array_agg((dbs)."@ПулСерверов"::text)    , array_agg((dbs)::text)    )  FROM    (      TABLE dbs    UNION ALL      TABLE fld    UNION ALL      TABLE grp    ) T)SELECT  (grp)."@ПулСерверов" "ИдГруппы", (grp)."Название" "ИмяГруппы", (grp)."Название" IS NOT DISTINCT FROM 'Управление облаком' "ЭтоУправлениеОблаком", (grp)."Тип", (grp)."Заблокирован", CASE    WHEN (grp)."Тип" = 15 THEN      app."Код"    ELSE      (grp)."Код"  END "Код", TRUE "Демо", grpe."ДопустимоеЧислоПользователей", grpe."Состояние", (dbn)."@ПулСерверов" "ИдБД", dbn."ИмяБД", dbh."Название" "ХостБД", dbn."Порт" "ПортБД", (grp)."Отстойник", (    SELECT      coalesce(        (          SELECT            "Значение"          FROM            "ЗначенияОбщихПараметров"          WHERE            "ОбщиеПараметры" = cp."@ОбщиеПараметры" AND            coalesce("Сайт", 9) = 9          ORDER BY            "Сайт" NULLS LAST          LIMIT 1        )      , "ЗначениеПоУмолчанию"      , '60'      )::integer    FROM      "ОбщиеПараметры" cp    WHERE      "Название" = 'session_cache_time'  ) "ТаймаутКэша", CASE    WHEN nullif(111, 0) IS NULL THEN      NULL    WHEN (grp)."@ПулСерверов" = 111 THEN      TRUE    ELSE      FALSE  END "Эталонная", site."@Сайт" "ИдСайта", site."Адрес" "ИмяСайта"--, *FROM  dbsJOIN  "БазаДанных" dbn    ON dbn."@ПулСерверов" = (dbs.dbs)."@ПулСерверов"JOIN LATERAL  (    SELECT      ((TABLE srvhs)->((dbs)."Раздел"::text))::"ПулСерверов" fld  ) fld ON TRUEJOIN LATERAL  (    SELECT      ((TABLE srvhs)->((fld)."Раздел"::text))::"ПулСерверов" grp  ) grp ON TRUEJOIN  "ПулРасширение" grpe    ON grpe."@ПулСерверов" = (grp)."@ПулСерверов"JOIN  "ПулСерверов" app    ON app."@ПулСерверов" = (grp)."Раздел"JOIN  "Сервер" dbh    ON dbh."@Сервер" = (dbs)."Сервер"LEFT JOIN  "Приложение" srv    ON srv."ПулСерверов" = (grp)."@ПулСерверов"LEFT JOIN  "СайтПриложение" site_app    ON site_app."Приложение" = srv."Раздел"LEFT JOIN  "Сайт" site    ON site."@Сайт" = site_app."Сайт"WHERE  dbn."ИмяБД" IS NOT NULL;




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

PostgreSQL Antipatterns:


SQL HowTo:

Подробнее..

PostgreSQL в Тензоре публикации за год

26.11.2020 10:08:34 | Автор: admin
Ровно год назад с рассказа о нашем сервисе визуализации планов запросов мы начали публикацию на Хабре серии статей, посвященных работе с PostgreSQL и его особенностям. Это уже пройденные нами грабли, интересные наработки, накопившиеся рекомендации, применяемые в разработке Тензора те вещи, которые помогают нам делать СБИС более эффективным.


СБИС это система полного цикла управления бизнесом от кадрового учета, бухгалтерии, делопроизводства и налоговой отчетности, до таск-менеджмента, корпоративного портала и видеокоммуникаций. Поэтому каждый из 1 500 000 клиентов-организаций находит что-то полезное для себя и использует наши сервисы на постоянной основе что дает ежемесячно более миллиона активных клиентов.


И все их данные надо где-то хранить и эффективно извлекать. Поэтому еще в далеком 2012 году мы сделали ставку на PostgreSQL, и теперь это основное хранилище данных наших сервисов:

  • почти 9000 баз общим объемом 1PB
  • свыше 200TB данных клиентов
  • 1500 разработчиков работают с БД

Чтобы упорядочить накопившиеся знания, за минувший год мы опубликовали более 60 статей, в которых делимся своим реальным опытом, проверенным практикой сурового энтерпрайза. Возможно, какие-то из них вы пропустили, поэтому под катом мы собрали дайджест, где каждый разработчик и DBA найдет что-то интересное для себя.

Для удобства все статьи разбиты на несколько циклов:

  • Анализ запросов
    Наглядно демонстрируем все тайны EXPLAIN [ANALYZE].
  • SQL Antipatterns и оптимизация SQL
    Понимаем как [не] надо решать те или иные задачи в PostgreSQL и почему.
  • SQL HowTo
    Пробуем подходы к реализации сложных алгоритмов на SQL для развлечения и с пользой.
  • DBA
    Присматриваем за базой, чтобы ей легко дышалось.
  • Прикладные решения
    Решаем с помощью PostgreSQL конкретные бизнес-задачи.



Анализ запросов в PostgreSQL


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

26.11 О чем молчит EXPLAIN, и как его разговорить (+38, &check;128)
...
Классический вопрос, с которым разработчик приходит к своему DBA или владелец бизнеса к консультанту по PostgreSQL, почти всегда звучит одинаково: Почему запросы выполняются на базе так долго?

Но, как сказано в той же документации, Понимание плана это искусство, и чтобы овладеть им, нужен определённый опыт, Но можно обойтись и без него, если воспользоваться подходящим инструментом!

11.02 Массовая оптимизация запросов PostgreSQL (видео) (+28, &check;131)
...
В докладе представлены некоторые подходы, которые позволяют следить за производительностью SQL-запросов, когда их миллионы в сутки, а контролируемых серверов PostgreSQL сотни.

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

26.03 Рецепты для хворающих SQL-запросов (видео) (+23, &check;143)
...
Многие ситуации, которые делают запрос медленным и прожорливым по ресурсам, типичны и могут быть распознаны по структуре и данным плана.

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

05.06 Понимаем планы PostgreSQL-запросов еще удобнее (+25, &check;88)
...
Новые возможности explain.tensor.ru:

  • Поддержка плана вместе с запросом, в том числе в JSON/YAML-форматах
  • Расширенная визуализация Planning/Execution Time и I/O Timing.
  • Новые фичи из PostgreSQL 13: Planning buffers, Incremental Sort
  • Улучшения UI/UX: скриншоттинг, рекомендации на узлах плана, удаление из архива.

29.07 Вооруженным глазом: наглядно о проблемах PostgreSQL-запроса (+32, &check;69)
...
Сегодня мы научимся определять больные места навскидку в больших и сложных планах, лишь мельком взглянув на них вооруженным глазом. В этом нам помогут различные варианты визуализации: сокращенный текстовый вид, круговая диаграмма, плитка, диаграмма выполнения.

10.08 Правильно [c]читаем параллельные планы PostgreSQL (+17, &check;33)
...
Рассматриваем странности со временем исполнения узлов при активации параллельного выполнения.
В наш век закончившейся гонки мегагерцев и победивших многоядерных и многопроцессорных систем такое поведение является непозволительной роскошью и расточительностью. Поэтому, начиная с версии PostgreSQL 9.6, при отработке запроса часть операций может выполняться несколькими процессами одновременно.

03.09 PostgreSQL Query Profiler: как сопоставить план и запрос (видео) (+13, &check;59)
...
Какие соображения помогают нам превращать сложно читаемый кусок лога сервера в красиво оформленный запрос с контекстными подсказками по соответствующим узлам плана.

29.10 Анализируем слона по частям (+19, &check;24)
...
Очередные улучшения юзабилити explain.tensor.ru: гистограммы на узлах, полезная статистика для мега-планов, персональный архив и генеалогия планов.



SQL Antipatterns и оптимизация SQL


09.12 CTE x CTE (+8, &check;35)
...
JOIN нескольких CTE почти всегда зло. Небольшая заметка, как его можно избежать в конкретном примере.
Тут надо вспомнить, что CTE Scan является аналогом Seq Scan то есть никакой индексации, а только полный перебор.

В данном случае нам еще сильно повезло, что для соединения был выбран Hash Join, а не Nested Loop, поскольку тогда мы получили бы не один-единственный проход CTE Scan, а 10K!

10.12 вредные JOIN и OR (+20, &check;108)
...
Разбираем на примере конкретного запроса несколько методик оптимизации и учимся использовать ленивые вычисления в PostgreSQL:

  • оптимизируем JOIN + LIMIT 1
  • BitmapOr vs UNION
  • прячем под CASE сложные условия

11.12 статистика всему голова (+10, &check;54)
...
В столбце ratio как раз показывается отношение в разах между планировавшимся на основании статистики и фактически прочитанным количеством записей. Чем больше это значение тем хуже статистика отражает реальное положение дел в вашей таблице.

Приводит это к выбору неэффективного плана и, как следствие, дичайшей нагрузке на базу. Чтобы ее оперативно убрать, достаточно все-таки прислушаться к рекомендациям мануала и пройти ANALYZE по основным таблицам.

12.12 сизифов JOIN массивов (+14, &check;37)
...
Иногда возникает задача склеить внутри SQL-запроса из переданных в качестве параметров линейных массивов целостную выборку с теми же данными по столбцам.
Вспоминаем о расширенных возможностях работы с массивами:

  • WITH ORDINALITY
  • Multi-argument UNNEST

19.12 передача наборов и выборок в SQL (+8, &check;95)
...
Периодически у разработчика возникает необходимость передать в запрос набор параметров или даже целую выборку на вход. Иногда попадаются очень странные решения этой задачи.
Сравниваем разные варианты передачи данных в запрос:

  • сериализованное представление массива/матрицы + unnest
  • JSON + json_populate_recordset/json_to_recordset
  • TEMPORARY TABLE
  • переменные сессии

24.12 обновляем большую таблицу под нагрузкой (+14, &check;126)
...
Как стоит поступить (а как точно не надо), если в многомиллионной активно используемой таблице PostgreSQL нужно обновить большое количество записей проинициализировать значение нового поля или скорректировать ошибки в существующих записях? А при этом сохранить свое время и не потерять деньги компании из-за простоя.
Почему один UPDATE и ORDER BY + LIMIT это печально для подобной задачи, а сегментное обновление и предварительно рассчитанные вычисления в самый раз.

20.01 редкая запись долетит до середины JOIN (+18, &check;119)
...
Если писать SQL-запросы без анализа алгоритма, который они должны реализовать, ни к чему хорошему с точки зрения производительности это обычно не приводит.

Такие запросы любят кушать процессорное время и активно почитывать данные практически на ровном месте. Причем, это вовсе не обязательно какие-то сложные запросы, наоборот чем проще он написан, тем больше шансов получить проблемы. А уж если в дело вступает оператор JOIN
Разбираем на моделях способы оптимизации JOIN + GROUP BY и JOIN + LIMIT с помощью CASE и LATERAL.

27.01 ударим словарем по тяжелому JOIN (+8, &check;107)
...
Итоговые выводы:

  • если надо сделать JOIN с многократно повторяющимися записями лучше использовать ословаривание таблицы
  • если ваш словарь ожидаемо маленький и читать вы из него будете немного можно использовать json[b]
  • во всех остальных случаях hstore + array_agg(i::text) будет эффективнее

02.03 меняем данные в обход триггера (+24, &check;61)
...
Например, на таблице, в которой вам надо что-то поправить, висит злобный триггер ON UPDATE, переносящий все изменения в какие-нибудь агрегаты. А вам надо все пообновлять (новое поле проинициализировать, например) так аккуратно, чтобы эти агрегаты не затронулись.
Почему быстро отключить и снова включить триггер плохая идея. Как его обойти с помощью переменных сессии.

10.03 сказ об итеративной доработке поиска по названию (+17, &check;82)
...
Что вообще обычно подразумевает пользователь, когда говорит про быстрый поиск по названию? Почти никогда это не оказывается честный поиск по подстроке типа ... LIKE '%роза%' ведь тогда в результат попадают не только 'Розалия' и 'Магазин Роза', но и роза' и даже 'Дом Деда Мороза'.

Пользователь же подразумевает на бытовом уровне, что вы ему обеспечите поиск по началу слова в названии и покажете более релевантным то, что начинается на введенное. И сделаете это практически мгновенно при подстрочном вводе.
Как ищут строки: pg_trgm, FTS, text_pattern_ops, btree + UNION ALL. И как можно неаккуратно все разломать: пейджинг, подзапросы, DISTINCT.

12.03 сражаемся с ордами мертвецов (+32, &check;106)
...
Как оградить свои UPDATE'ы от лишней работы с диском и блокировок с помощью объединения операций и IS DISTINCT FROM.

31.03 вычисление условий в SQL (+26, &check;65)
...
SQL это не C++, и не JavaScript. Поэтому вычисление логических выражений происходит иначе.
  • ускоряем триггер за счет выноса проверки из функции в WHEN.
  • оптимизируем OR/AND-цепочку с помощью CASE
  • упрощаем написание сложных условий

27.04 навигация по реестру (+22, &check;74)
...
Все будет очень просто, на уровне Капитана Очевидность делаем просмотр реестра событий с сортировкой по времени.
  • плохо: считать сегменты на бизнес-логике
  • плохо: использовать LIMIT + OFFSET
  • хорошо: использовать курсоры, но делать это аккуратно

14.05 насколько глубока кроличья нора? пробежимся по иерархии (+19, &check;83)
...
В сложных ERP-системах многие сущности имеют иерархическую природу, когда однородные объекты выстраиваются в дерево отношений предок потомок это и организационная структура предприятия (все эти филиалы, отделы и рабочие группы), и каталог товаров, и участки работ, и география точек продаж, ...
Пишем сложный запрос, чтобы извлекать минимум данных при проходах по дереву.

24.06 подозрительные типы (+40, &check;60)
...
Типизация данных в PostgreSQL, при всей своей логичности, действительно преподносит порой очень странные сюрпризы. В этой статье мы постараемся прояснить некоторые их причуды, разобраться в причине их странного поведения и понять, как не столкнуться с проблемами в повседневной практике.

28.06 накручиваем себе проблемы (+21, &check;56)
...
Рассматриваем причины накрутки serial при ON CONFLICT и счетчика транзакций при ROLLBACK.

08.07 SELF JOIN vs WINDOW (+14, &check;32)
...
Ускоряем запрос в 100 раз с помощью оконных функций на примере мониторинга блокировок.

14.07 Unreal Features of Real Types, или Будьте осторожны с REAL (+9, &check;10)
...
Я решил бегло пробежаться по коду доступных мне SQL-запросов, чтобы посмотреть, насколько часто в них используется тип REAL. Достаточно часто используется, как оказалось, и не всегда разработчики понимают опасности, стоящие за ним. И это несмотря на то, что в Интернете и на Хабре достаточно много хороших статей про особенности хранения вещественных чисел в машинной памяти и о работе с ними. Поэтому в этой статье я постараюсь применить такие особенности к PostgreSQL, и попробую на пальцах рассмотреть связанные с ними неприятности, чтобы разработчикам SQL-запросов было легче избежать их.

04.08 Должен остаться только один! (+24, &check;80)
...
Сегодня на предельно простых примерах посмотрим, к чему это может приводить в контексте использования GROUP/DISTINCT и LIMIT вместе с ними.

Вот если вы написали в запросе сначала соедини эти таблички, а потом выкинь все дубли, должен остаться только один экземпляр по каждому ключу именно так и будет работать, даже если соединение вовсе не было нужно.

20.08 уникальные идентификаторы (+21, &check;67)
...
Рассматриваем эффективность и проблемы различных способов получить уникальные идентификаторы в базе и их проблемы:

  • таблица счетчиков
  • объект SEQUENCE
  • псевдотип serial
  • GENERATED-столбцы
  • генерируемый UUID
  • скрытые системные поля: tableoid/ctid/oid
  • честное время clock_timestamp

01.10 Бесконечность не предел!, или Немного о рекурсии (+18, &check;47)
...
Рекурсия очень мощный и удобный механизм, если над связанными данными делаются одни и те же действия вглубь. Но неконтролируемая рекурсия зло, которое может приводить или к бесконечному выполнению процесса, или (что случается чаще) к выжиранию всей доступной памяти.

СУБД в этом отношении работают по тем же принципам "сказали копать, я и копаю". Ваш запрос может не только затормозить соседние процессы, постоянно занимая ресурсы процессора, но и уронить всю базу целиком, съев всю доступную память. Поэтому защита от бесконечной рекурсии обязанность самого разработчика.

07.10 убираем медленные и ненужные сортировки (+27, &check;91)
...
Просто так результат SQL-запроса возвращает записи в том порядке, который наиболее удобен серверу СУБД. Но человек гораздо лучше воспринимает хоть как-то упорядоченные данные это помогает быстро сравнивать соответствие различных датасетов.

Поэтому со временем у разработчика может выработаться рефлекс Дай-ка я на всякий случай это вот отсортирую!
Учимся опознавать типовые кейсы и делаем запрос чуть быстрее:

  • нехватка work_mem
  • сортировка уже отсортированного
  • вложенная отладочная сортировка
  • Index Scan вместо сортировки
  • UNION ALL вместо сортировки
  • сортировки для оконных функций


10.11 работаем с отрезками в кровавом энтерпрайзе (+27, &check;64)
...
Давайте посмотрим, какие именно прикладные задачи и как можно решить с помощью PostgreSQL и сократить время анализа данных с нескольких секунд на бизнес-логике до десятков миллисекунд, умея эффективно применять следующие алгоритмы непосредственно внутри SQL-запроса:

  • поиск отрезков, пересекающих точку/интервал
  • слияние отрезков по максимальному перекрытию
  • подсчет количества отрезков в каждой точке

18.11 DBA-детектив, или Три дела о потерянной производительности (видео) (+16, &check;45)
...
Дедукция и индукция помогут нам вычислить, что же все-таки хотел получить от СУБД разработчик, и почему это получилось не слишком оптимально. Итак, сегодня нас ждут:

  • Дело о непростом пути вверх
    Разберем в live-видео на реальном примере некоторые из способов улучшения производительности иерархического запроса.
  • Дело о худеющем запросе
    Увидим, как можно запрос упростить и ускорить в несколько раз, пошагово применяя стандартные методики.
  • Дело о развесистой клюкве
    Восстановим структуру БД на основании единственного запроса с 11 JOIN и предложим альтернативный вариант решения на ней той же задачи.



SQL HowTo


30.12 рисуем морозные узоры на SQL (+24, &check;52)
...
Немного SQL-магии: математика, рекурсия, псевдографика.

13.01 собираем цепочки с помощью window functions (+11, &check;40)
...
Иногда при анализе данных возникает задача выделения цепочек в выборке то есть упорядоченных последовательностей записей, для каждой из которых выполняется некоторое условие.

Традиционные решения предусматривают разные варианты self join, когда выборка соединяется с собой же, либо использование некоторых фактов за пределами данных например, что записи должны иметь строго определенный шаг (N+1, за каждый день, ...).

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

Но эту задачу нам помогут эффективно решить оконные функции в PostgreSQL.

31.01 пишем while-цикл прямо в запросе, или Элементарная трехходовка (+8, &check;97)
...
Периодически возникает задача поиска связанных данных по набору ключей, пока не наберем нужное суммарное количество записей.

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

19.06 1000 и один способ агрегации (+12, &check;74)
...
Рассмотрим некоторые способы, с помощью которых можно вычислить агрегаты в PostgreSQL или ускорить выполнение SQL-запроса.

  • совместные агрегаты
  • вложенные запросы
  • FILTER-агрегаты
  • агрегаты от условия
  • агрегация в массив
  • DISTINCT + OVER
  • сложный агрегат с помощью рекурсии

28.07 красивые отчеты по дырявым данным GROUPING SETS (+8, &check;28)
...
В этой статье рассмотрим, как все это можно экономично расположить в БД, и как максимально эффективно собрать по этим данным отчет с помощью оконных функций и GROUPING SETS.

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

05.09 курсорный пейджинг с неподходящей сортировкой (+18, &check;64)
...
Пусть у нас есть реестр документов, с которым работают операторы или бухгалтеры. Традиционно, при подобном отображении используется или прямая (новые снизу) или обратная (новые сверху) сортировка по дате и порядковому идентификатору, назначаемому при создании документа ORDER BY dt, id или ORDER BY dt DESC, id DESC.

Но что если пользователю зачем-то захотелось нетипичного например, отсортировать одно поле так, а другое этак ORDER BY dt, id DESC? Но второй индекс мы создавать не хотим ведь это замедление вставки и лишний объем в базе.

Можно ли решить эту задачу, эффективно используя только индекс (dt, id)?

23.09 PostgreSQL 13: happy pagination WITH TIES (+40, &check;45)
...
Используем новые возможности PostgreSQL 13 для упрощения организации постраничной навигации по реестру.

19.10 ломаем мозг об дерево упорядочиваем иерархию с рекурсией и без (+16, &check;62)
...
чтобы для вывода упорядочить элементы дерева в соответствии с иерархией, уж точно придется воспользоваться рекурсией! Или нет? Давайте разберемся, а заодно решим на SQL пару комбинаторных задач.



DBA


20.12 вычищаем клон-записи из таблицы без PK (+13, &check;45)
...
Случаются ситуации, когда в таблицу без первичного ключа или какого-то другого уникального индекса по недосмотру попадают полные клоны уже существующих записей.

Например, пишутся в PostgreSQL COPY-потоком значения хронологической метрики, а потом внезапный сбой, и часть полностью идентичных данных приходит повторно.

Как избавить базу от ненужных клонов?

25.12 когда пасует VACUUM чистим таблицу вручную (+21, &check;98)
...
VACUUM может зачистить из таблицы в PostgreSQL только то, что никто не может увидеть то есть нет ни одного активного запроса, стартовавшего раньше, чем эти записи были изменены.

А если такой неприятный тип (продолжительная OLAP-нагрузка на OLTP-базе) все же есть? Как почистить активно меняющуюся таблицу в окружении длинных запросов и не наступить на грабли?

15.01 перенос значений SEQUENCE между базами PostgreSQL (+11, &check;43)
...
Как можно перенести в другую PostgreSQL-базу последнее назначавшееся значение автоинкремент-поля типа serial, если в таблице могли быть какие-то удаления, и просто подставить max(pk) уже не подходит?

19.02 находим бесполезные индексы (+19, &check;114)
...
Регулярно сталкиваюсь с ситуацией, когда многие разработчики искренне полагают, что индекс в PostgreSQL это такой швейцарский нож, который универсально помогает с любой проблемой производительности запроса. Достаточно добавить какой-нибудь новый индекс на таблицу или включить поле куда-нибудь в уже существующий, а дальше (магия-магия!) все запросы будут эффективно таким индексом пользоваться.

Во-первых, конечно, или не будут, или не эффективно, или не все. Во-вторых, лишние индексы только добавят проблем с производительностью при записи.

19.03 грамотно организовываем синхронизации и импорты (+11, &check;48)
...
При сложной обработке больших наборов данных (разные ETL-процессы: импорты, конвертации и синхронизации с внешним источником) часто возникает необходимость временно запомнить, и сразу быстро обработать что-то объемное.

Чтобы справиться с ними в PostgreSQL (да и не только в нем), можно использовать некоторые возможности для оптимизаций, которые позволят обработать все быстрее и с меньшим расходом ресурсов.

20.05 мониторинг базы PostgreSQL кто виноват, и что делать (+23, &check;100)
...
На что обращать внимание при мониторинге PostgreSQL-базы и как трактовать полученные данные:

  • состояние соединений
  • блокировки
  • transactions per second (TPS)
  • количество операций над записями
  • использование кэша данных
  • самый длительный запрос/транзакция

27.05 в погоне за пролетающими блокировками (+18, &check;41)
...
Шансов поймать блокировки в моменте крайне мало, да и длиться они могут всего по несколько секунд, но ухудшая при этом плановое время выполнения запроса в десятки раз. А хочется-то не сидеть и ловить происходящее в онлайн-режиме, а в спокойной обстановке разобраться постфактум, кого из разработчиков покарать в чем именно была проблема кто, с кем и из-за какого ресурса базы вступил в конфликт.

Но как? Ведь, в отличие от запроса с его планом, который позволяет детально понять, на что пошли ресурсы, и сколько времени это заняло, подобных наглядных следов блокировка не оставляет после себя

Разве что короткую запись в логе:
process ... still waiting for ...
А давайте попробуем зацепиться именно за нее!

10.06 классифицируем ошибки из PostgreSQL-логов (+9, &check;38)
...
Если мы не хотим потом хвататься за голову, то возникающие в логах PostgreSQL ошибки недостаточно просто считать поштучно их надо аккуратно классифицировать. Но для этого нам придется решить нетривиальную задачу индексированного поиска регулярного выражения, наиболее подходящего для строки.

15.06 кто скрывается за блокировкой (+11, &check;48)
...
Научимся трактовать собранные блокировки и узнавать, кто именно может скрываться за конкретной матрицей конфликтов, и почему результат выглядит именно так.



Решения для PostgreSQL


09.01 БД мессенджера (ч.1): проектируем каркас базы (+3, &check;62)
...
Как можно перевести бизнес-требования в конкретные структуры данных на примере проектирования с нуля базы для мессенджера.

09.01 БД мессенджера (ч.2): секционируем наживую (+5, &check;67)
...
Мы удачно спроектировали структуру нашей PostgreSQL-базы для хранения переписки, прошел год, пользователи активно ее наполняют, вот в ней уже миллионы записей, и что-то все начало подтормаживать.

Дело в том, что с ростом объема таблицы растет и глубина индексов хоть и логарифмически. Но со временем это заставляет сервер для выполнения тех же задач чтения/записи обрабатывать в разы больше страниц данных, чем в начале.

Вот тут на помощь и приходит секционирование. Мы рассмотрим не конкретные скрипты для реализации секционирования в железе, а сам подход что и как стоит порезать на дольки, и к чему такое желание приводит.

22.01 PubSub почти бесплатно: особенности NOTIFY в PostgreSQL (+20, &check;76)
...
Если ваши микросервисы уже используют общую базу PostgreSQL для хранения данных, или ей пользуются несколько экземпляров одного сервиса на разных серверах, можно относительно дешево получить возможность обмена сообщениями (PubSub) между ними без интеграции в архитектуру Redis, RabbitMQ-кластера или встройки в код приложения другой MQ-системы.

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

Передавать и получать данные мы станем с помощью механизма NOTIFY/LISTEN, а модельную реализацию соберем для Node.js.

13.02 Фантастические advisory locks, и где они обитают (+11, &check;34)
...
В PostgreSQL существует очень удобный механизм рекомендательных блокировок, они же advisory locks. Мы в Тензоре используем их во многих местах системы, но мало кто детально понимает, как конкретно они работают, и какие проблемы можно получить при неправильном обращении.

13.04 Пишем в PostgreSQL на субсветовой: 1 host, 1 day, 1TB (+19, &check;78)
...
Рассматриваем традиционные подходы масштабирования производительности на конкретном примере:

  • Секционирование
  • Эволюция и рефакторинг БД
  • Размазываем пиковую нагрузку
  • Кэшируем, что можно
Терабайт-в-сутки только звучит страшно. Если вы все делаете правильно, то это всего лишь 2^40 байт / 86400 секунд = ~12.5MB/s, что держали даже настольные IDE-винты. :)

А если серьезно, то даже при десятикратном перекосе нагрузки в течение суток, вы спокойно можете уложиться в возможности современных SSD.

22.04 Экономим копеечку на больших объемах в PostgreSQL (+11, &check;44)
...
Продолжая тему записи больших потоков данных, поднятую предыдущей статьей про секционирование, в этой рассмотрим способы, которыми можно уменьшить физический размер хранимого в PostgreSQL, и их влияние на производительность сервера.

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

03.06 Как мы в СБИС автоматический расчет себестоимости делали (видео) (+7, &check;17)
...
Как суровую прагматику требований бизнеса перенести на разработку высоконагруженных сервисов, как бороться с конкурентным доступом к данным, как это все аккуратно обходить и при этом не отстрелить себе ногу.

17.08 У меня зазвонил телефон. Кто говорит?.. Поможет слон (+10, &check;29)
...
Автоматическое определение клиента и его региона по входящему телефонному звонку стало неотъемлемой частью любой развитой HelpDesk или CRM-системы. Только надо уметь делать это быстро тогда появляется масса возможностей.

25.08 Телепортация тонн данных в PostgreSQL (+11, &check;60)
...
Выжимаем максимум пропускной способности из PostgreSQL:
  • Как балансировать писателей и управлять соединениями на бизнес-логике?
  • Как настроить СУБД и ОС?
  • Как избавиться от блокировок?



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

SQL HowTo рейтинг-за-интервал

11.01.2021 16:10:01 | Автор: admin

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

Но просто "самые" за весь доисторический период обычно неинтересны - продал ты 3 года назад вагон валенок, и теперь он у тебя в "самых" продажах вечно. Поэтому обычно хочется видеть"топ" на каком-то ограниченном последнем интервале- например, "за последний год" (точнее, за последние 12 календарных месяцев).

Традиционно, есть два подхода к этой задаче: запрос по требованию по "сырым" данным или предварительная агрегация. И если "просто посчитать" такой отчет по первичке - упражнение для SQL-новичка, но очень "тяжелое" для производительности СУБД, то вариант сделать так, чтобы он строился практически мгновенно при большом количестве активных аккаунтов независимых бизнесов, как у нас в СБИС, без необходимости пересчитывать агрегированную статистику каждого 1-го числа месяца судорожно по всем клиентам - интересная задача.

Структура хранения

Для начала поймем, что "быстро" может быть только в том случае, когда мы можем простопройти по "верхушке" нужного индексаи извлечь искомые TOP-10 записей - без всяких суммирований и пересортировок.

То есть для решения задачи нам достаточно таблицы с единственным индексом (рассмотрим только вариант сортировки по сумме, для количества все будет аналогично):

CREATE TABLE item_stat(  item -- товар    integer, sum    numeric(32,2));CREATE INDEX ON item_stat(sum DESC);

Наполнять ее данными мы можем легко и просто -инкрементом в триггерепри проведении продажи. Но как все-таки сделать эффективное "вычитание" данных при завершении месяца?..

"Нужно больше золота"

Чтобы быстро что-то вычесть, нужно четко понимать, что именно.

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

CREATE TABLE item_stat(  interval_id -- 0 - текущие счетчики, 202001 - январь 2020, 202002 - февраль, ...    integer, item    integer, sum    numeric(32,2), UNIQUE(interval_id, item));CREATE INDEX ON item_stat(interval_id, sum DESC);

Момент обновления

Чтобы понять, что вот прямо сейчас надо "вычесть" какой-то месяц, достаточно оперироватьединственным дополнительным параметромтипа"месяц последней актуализации рейтинга продаж". Хранить его можно даже в служебной записи в этой же таблице (если это не помешает Foreign Key, который вы можете захотеть добавить на item):

INSERT INTO item_stat(  interval_id, item, sum)VALUES  (0, 0, 202012) -- служебный ключ (0, 0), значение - 2020'12 вместо суммыON CONFLICT(interval_id, item)  DO UPDATE SET    sum = EXCLUDED.sum; -- всегда заменяем значение

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

INSERT INTO item_stat(  interval_id, item, sum)VALUES  (202001, 1, 100) -- + в рейтинг за январь 2020, (     0, 1, 100) -- + в текущий рейтингON CONFLICT(interval_id, item)  DO UPDATE SET    sum = item_stat.sum + EXCLUDED.sum; -- всегда добавляем в сумму

Если текущиймесяц операции разошелся с месяцем из параметра,асинхронностартуем пересчет "годовых" значений, вычитая показатели за ставшие избыточными месяцы, и переактуализируем значение параметра:

-- "новый" месяц актуальностиWITH next AS (  SELECT 202101)-- предыдущий месяц актуальности, prev AS (  SELECT    sum::integer  FROM    item_stat  WHERE    (interval_id, item) = (0, 0))-- все продажи за период, ставший неактуальным, в разрезе товаров, diff AS (  SELECT    item  , sum(sum) sum  FROM    item_stat  WHERE    interval_id BETWEEN (TABLE prev) - 100 AND (TABLE next) - 100  GROUP BY    1)UPDATE  item_stat dstSET  sum = dst.sum - diff.sumFROM  diffWHERE  (dst.interval_id, dst.item) = (0, diff.item);UPDATE  item_statSET  sum = 202101WHERE  (interval_id, item) = (0, 0);

При построении отчета

Если текущий месяц совпадает с месяцем из параметра, то все значения в "годичном" интервале актуальны - просто выводим топ по индексу:

SELECT  *FROM  item_statWHERE  interval_id = 0 -- текущий "годичный" интервалORDER BY  sum DESCLIMIT 10;

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

Подробнее..

DBA Когда почти закончился serial

24.03.2021 18:04:11 | Автор: admin

"Шеф, всё пропало, у нас serial на мегатаблице почти закончился!" - а это значит, что либо вы его неаккуратно накрутили сами, либо у вас действительно данных столько, что разрядности integer-столбца уже не хватает для вашей большой и активной таблицы в PostgreSQL-базе.

Да и столбец этот не простой, а целый PRIMARY KEY, на который еще и ряд других немаленьких таблиц по FOREIGN KEY завязан. А еще и приложение останавливать совсем не хочется, ибо клиентам 24x7 обещано...

В общем, надо как-то с минимальными блокировками увеличить размер PK-поля в большой таблице, на которое многое завязано.

Организуем небольшой тестовый полигон:

CREATE TABLE tblpk(  pk    serial      PRIMARY KEY, valx    integer);INSERT INTO tblpk(valx)SELECT generate_series(1, 1e6);CREATE TABLE tblfk(  fk    integer      REFERENCES tblpk, valy    integer);INSERT INTO tblfk(fk, valy)SELECT (random() * (1e6 - 1))::integer + 1, generate_series(1, 1e6);-- не забываем, что для FK нужно создавать индекс "вручную"CREATE INDEX ON tblfk(fk);

Подготовительные работы

Первую часть работы можно сделать без наложения каких-либо блокировок вообще.

Добавляем новое поле:

ALTER TABLE tblpk ADD COLUMN _pk bigint;ALTER TABLE tblfk ADD COLUMN _fk bigint;

Универсальный копирующий триггер

Чтобы для всех добавляемых и изменяемых записей состояние нового и старого полей у нас не разбегалось, повесим на таблицу копирующий триггер - на вставку новой записи или изменение отслеживаемого поля:BEFORE INSERT OR UPDATE OF <PK-поле>.

Ровно такую же задачу нам придется решать и для таблицы tblfk, поэтому сразу напишем триггерную функцию, которую можно будет универсально применять на любой таблице, использовав немного SQL-магии:

CREATE OR REPLACE FUNCTION copy_fld() RETURNS trigger AS $$DECLARE  fld_src text := quote_ident(TG_ARGV[0]); -- имя исходного поля  fld_dst text := quote_ident(TG_ARGV[1]); -- имя целевого поляBEGIN  EXECUTE $q$                 -- собираем тело запроса как текст    SELECT      (        json_populate_record( -- наполняем запись данными из JSON          $1                  -- NEW        , json_build_object(  -- {[fld_dst] : NEW[fld_src]}::json            '$q$ || fld_dst || $q$'          , $1.$q$ || fld_src || $q$::text          )        )      ).*                     -- "разворачиваем" record по столбцам    $q$    USING NEW -- используем NEW в качестве $1-аргумента    INTO NEW; -- результат складываем обратно в NEW  RETURN NEW; -- не забываем вернуть NEW, иначе изменения не применятсяEND $$ LANGUAGE plpgsql;

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

CREATE TRIGGER copy BEFORE INSERT OR UPDATE OF pk   ON tblpk   FOR EACH ROW   EXECUTE PROCEDURE copy_fld('pk', '_pk'); -- откуда/кудаCREATE TRIGGER copy BEFORE INSERT OR UPDATE OF fk   ON tblfk   FOR EACH ROW   EXECUTE PROCEDURE copy_fld('fk', '_fk');

Массовое обновление записей

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

UPDATE tblpk SET _pk = pk WHERE _pk IS NULL;UPDATE tblfk SET _fk = fk WHERE _fk IS NULL;

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

Лучше всего воспользоваться сегментным обновлением, как это описано в статье "PostgreSQL Antipatterns: обновляем большую таблицу под нагрузкой". В результате единый UPDATE превратится в серию быстрых запросов, которые отлично садятся на индекс первичного ключа:

UPDATE  tblpkSET  _pk = pkWHERE  pk BETWEEN $1 AND $1 + 999 AND -- перебираем сегменты значений по 1K  _pk IS NULL;

Создаем новый индекс

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

CREATE UNIQUE INDEX CONCURRENTLY _pk ON tblpk(_pk); -- индекс под новый PKCREATE INDEX CONCURRENTLY _fk ON tblfk(_fk);        -- индекс под новый FK

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

Быстрая неблокирующая* конвертация

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

  • снимаем все autovacuum/autoanalyze, которые блокируют наши таблицы

    Эти процессы запустятся с очень большой вероятностью практически сразу, поскольку мы UPDATE'нули все записи в каждой из таблиц. Если мы не снимем их и накладываемые ими блокировки, все наши ALTER TABLE будут ждать получения блокировки сами (Access Exclusive), а за ними будет копиться очередь всех остальных запросов, даже SELECT (Access Share) по этим таблицам.

  • блокируем таблицы в монопольном режиме

    Если этого не сделать, какой-нибудь настырный SELECT из параллельного подключения вполне может вклиниться между нашими ALTER TABLE, что опять-таки приведет к длительным блокировкам.

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

  • модифицируем последовательность: привязываем ее к новому столбцу (OWNED BY) и снимаем ограничение на максимальное значение (NO MAXVALUE)

  • модифицируем основную таблицу:

    • удаляем старый столбец каскадно, что заодно удалит и ненужный нам более copy-триггер, старый первичный ключ вместе с индексом и все смотрящие на него внешние ключи

    • переименовываем новый столбец в старый

    • назначаем DEFAULT для нового столбца именно здесь, поскольку назначение раньше могло бы привести к двойному выполнению выражения

    • создаем новый первичный ключ с использованием заранее подготовленного уникального индекса, что заодно этот индекс и переименует

  • аналогично модифицируем связанную таблицу в чуть другом порядке:

    • удаляем и переименовываем столбцы

    • восстанавливаем внешний ключ в NOT VALID-режиме без фактической проверки уже содержащихся в таблице данных

    • восстанавливаем имя индекса под внешним ключом

BEGIN;  -- снимаем все процессы autovacuum/autoanalyze по нашим таблицам  SELECT    pg_terminate_backend(pid)  FROM    pg_stat_activity sa  WHERE    CASE      WHEN backend_type = 'autovacuum worker' THEN        EXISTS(          SELECT            NULL          FROM            pg_locks          WHERE            locktype = 'relation' AND            relation = ANY(ARRAY['tblpk', 'tblfk']::regclass[])        )    END;  -- сразу блокируем все таблицы, чтобы никто не влез  LOCK TABLE tblpk, tblfk IN ACCESS EXCLUSIVE MODE NOWAIT;  -- sequence  ALTER SEQUENCE tblpk_pk_seq OWNED BY tblpk._pk;  ALTER SEQUENCE tblpk_pk_seq NO MAXVALUE;  -- tblpk  ALTER TABLE tblpk    DROP COLUMN pk CASCADE; -- сносит заодно copy-триггер, PK и все FK  ALTER TABLE tblpk    RENAME COLUMN _pk TO pk;  ALTER TABLE tblpk    ALTER COLUMN pk SET DEFAULT nextval('tblpk_pk_seq');  ALTER TABLE tblpk    ADD CONSTRAINT tblpk_pkey PRIMARY KEY USING INDEX _pk;  -- tblfk  ALTER TABLE tblfk    DROP COLUMN fk CASCADE;  ALTER TABLE tblfk    RENAME COLUMN _fk TO fk;  ALTER TABLE tblfk    ADD CONSTRAINT tblfk_fk_fkey      FOREIGN KEY(fk)      REFERENCES tblpk      NOT VALID; -- без проверки ограничения по существующим данным  ALTER INDEX _fk RENAME TO tblfk_fk_fkey;COMMIT;

Все эти действия происходят единым куском под общей блокировкой, поэтому, благодаря транзакционности DDL в PostgreSQL, либо успешно выполнятся целиком, либо целиком же - нет. Однако, за счет того, что тут нет ни одной длительной операции, весь скрипт должен отработать за минимальное время.

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

ALTER TABLE tblfk  VALIDATE CONSTRAINT tblfk_fk_fkey;

Что мы забыли?

Приведенный выше код вполне работает, но только в простейших случаях.

Связанные объекты

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

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

  • tblpk_pkey - имя ограничения первичного ключа

  • tblfk_fk_fkey - имя ограничения внешнего ключа

  • tblpk_pk_seq - имя serial-последовательности

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

Сложные индексы

Аналогично, мы исходили из предположения, что индексы у нас самые простые, из единственного поля и без всяких условий. Но FK-индекс запросто может иметь вид tblfk(fk) WHERE fk IS NOT NULL, чтобы NULL-строки не замусоривали его, а PK включать в себя и другие поля, кроме serial.

Действия внешних ключей

Внешние ключи также могут быть определены существенно более сложно, чем в нашей модели - там может оказаться что-то вроде MATCH PARTIAL INITIALLY DEFERRED или ON DELETE SET NULL ON UPDATE RESTRICT.

Триггеры

Удалив каскадно старый столбец, мы снесли также и copy-триггер. А что если он был не один на этом поле?..

Имена и комментарии

Имя индекса внешнего ключа мы восстанавливали "по наитию", но нет абсолютно никакой гарантии, что оно совпадает с именем FK-ограничения.

А еще мы забыли восстановить комментарии объектов, которые могли быть наложены через COMMENT ON.

Скрипт миграции

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

  • sequence ссылается на поле через OWNED BY, а оно обратно через DEFAULT

  • индексы и триггеры ссылаются на поле напрямую

  • FK-constraint связывает поля пары таблиц и уникальный индекс на ведущей таблице

  • и все это может быть откомментировано

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

CREATE TABLE "1st table"(  "primary key col"    serial      PRIMARY KEY, valx    integer);COMMENT ON COLUMN "1st table"."primary key col"  IS 'col-comment';INSERT INTO "1st table"(valx)SELECT generate_series(1, 1e5);CREATE TABLE "2nd table"(  fk    integer      CONSTRAINT "FK-name" REFERENCES "1st table"        ON UPDATE SET NULL        ON DELETE RESTRICT, valy    integer);COMMENT ON CONSTRAINT "FK-name" ON "2nd table"  IS 'con-comment';INSERT INTO "2nd table"(fk, valy)SELECT (random() * (1e5 - 1))::integer + 1, generate_series(1, 1e5);CREATE INDEX "FK-idx-name" ON "2nd table"(fk);COMMENT ON INDEX "FK-idx-name"  IS 'idx-comment';CREATE OR REPLACE FUNCTION tmp() RETURNS trigger AS $$BEGIN  RAISE NOTICE 'NEW : %', NEW::text;END $$ LANGUAGE plpgsql;CREATE TRIGGER tmp AFTER INSERT OR UPDATE OF "primary key col"  ON "1st table"  FOR EACH ROW  EXECUTE PROCEDURE tmp();COMMENT ON TRIGGER tmp ON "1st table"  IS 'trg-comment';

Ну, а теперь дело за малым! Вот наш скрипт:

Скрипт расширения serial -> bigserial
-- $1 : '"1st table"'     - с кавычками!-- $2 : 'primary key col' - без кавычек!WITH src(rel, fld) AS (  VALUES($1::regclass, $2::name)), fld AS (  SELECT    *  FROM    src  JOIN    pg_attribute at      ON (at.attrelid, at.attname) = (src.rel, src.fld)), idx AS (  SELECT    idx.*  FROM    fld  JOIN    pg_index idx      ON indrelid = attrelid AND      indkey::smallint[] && ARRAY[attnum]), con AS (  SELECT    CASE contype      WHEN 'p' THEN attnum      WHEN 'f' THEN conkey[array_position(confkey, attnum)]    END idkey  , con.*  FROM    fld  JOIN    pg_constraint con      ON (conrelid = attrelid AND conkey && ARRAY[attnum]) OR      (confrelid = attrelid AND confkey && ARRAY[attnum]))-- столбцы, входящие в PK или FK, colkey AS (  SELECT    *  , attrelid::regclass::text _attrel  , '_' || md5(attname) _attname  , quote_ident(attname) _qiattname  , replace(col_description(attrelid, attnum), '''', '''''') dsccol  FROM    con  INNER JOIN    pg_attribute at      ON (attrelid, attnum) = (conrelid, idkey)  WHERE    atttypid <> 'bigint'::regtype), code_col AS (  SELECT    string_agg($$-- $$ || _attrel || $$ALTER TABLE $$ || _attrel || $$  ADD COLUMN $$ || _attname || $$ bigint;$$ ||      CASE        WHEN dsccol IS NOT NULL THEN$$COMMENT ON COLUMN $$ || _attrel || '.' || _attname || $$  IS '$$ || dsccol || $$';$$        ELSE ''      END || $$CREATE TRIGGER copy  BEFORE INSERT OR UPDATE OF $$ || _qiattname || $$    ON $$ || _attrel || $$    FOR EACH ROW    EXECUTE PROCEDURE copy_fld('$$ || attname || $$', '$$ || _attname || $$');UPDATE $$ || _attrel || $$ SET $$ || _attname || $$ = $$ || _qiattname || $$ WHERE $$ || _attname || $$ IS NULL; -- лучше сегментно!!!$$    , ''    ) code  FROM    colkey)-- индексы, indkey AS (  SELECT    *  , quote_ident('_' || md5(sch || '.' || rel || '.' || idxname)) _idxname  FROM    (      SELECT        pg_get_indexdef(indexrelid) def      , cli.relnamespace::regnamespace::text sch      , idx.indrelid::regclass::text rel      , quote_ident(cli.relname) idxname      , replace(obj_description(cli.oid, 'pg_class'), '''', '''''') dscidx      , *      FROM        colkey      JOIN        pg_index idx          ON indrelid = attrelid AND          indkey::smallint[] && ARRAY[attnum]      JOIN        pg_class cli          ON cli.oid = idx.indexrelid    ) T), code_idx AS (  SELECT    string_agg(      E'-- ' || idxname || E'\n' ||      regexp_replace(        regexp_replace(          def        , E'(CREATE(?: UNIQUE)? INDEX ).*?( ON ).*?( USING )'        , E'\\1CONCURRENTLY ' || _idxname || E'\n  ON ' || sch || '.' || rel || E'\n  USING '        )      , E'(USING \\S+ \\(.*)' || _qiattname || E'(.*\\))'      , E'\\1' || _attname || E'\\2'      , 'g'      ) || E';\n'      || CASE        WHEN dscidx IS NOT NULL THEN$$COMMENT ON INDEX $$ || _idxname || $$  IS '$$ || dscidx || $$';$$        ELSE ''      END    , ''    ) code  FROM    indkey)-- тфблицы, code_rel AS (  SELECT    $q$-- зачищаем мешающие autovacuumSELECT  pg_terminate_backend(pid)FROM  pg_stat_activity saWHERE  CASE    WHEN backend_type = 'autovacuum worker' THEN      EXISTS(        SELECT          NULL        FROM          pg_locks        WHERE          locktype = 'relation' AND          relation = ANY('$q$ || array_agg(rel)::text || $q$'::regclass[])      )    END;-- блокируем все таблицыLOCK TABLE $q$ || string_agg(rel, ', ') || $q$ IN ACCESS EXCLUSIVE MODE NOWAIT;$q$ code  FROM    (      SELECT DISTINCT        _attrel rel      FROM        colkey    ) T)-- последовательность, seqkey AS (  SELECT    pg_get_serial_sequence(attrelid::regclass::text, attname) seq  , *  FROM    colkey), code_seq AS (  SELECT$q$ALTER SEQUENCE $q$ || seq || $q$  OWNED BY $q$ || _attrel || '.' || _attname || $q$;ALTER SEQUENCE $q$ || seq || $q$  NO MAXVALUE;$q$  FROM    seqkey  WHERE    seq IS NOT NULL)-- столбцы, code_col_tx AS (  SELECT    string_agg($$-- $$ || _attrel || $$ALTER TABLE $$ || _attrel || $$  DROP COLUMN $$ || _qiattname || $$ CASCADE;ALTER TABLE $$ || _attrel || $$  RENAME COLUMN $$ || _attname || $$ TO $$ || _qiattname || $$;$$ ||      CASE        WHEN adsrc IS NOT NULL THEN$$ALTER TABLE $$ || _attrel || $$  ALTER COLUMN $$ || _qiattname || $$    SET DEFAULT $$ || adsrc || $$;$$        ELSE ''      END    ,   ''    ) code  FROM    colkey  LEFT JOIN    pg_attrdef ad      ON (adrelid, adnum) = (attrelid, attnum))-- индексы, code_idx_tx AS (  SELECT    string_agg($$ALTER INDEX $$ || _idxname || $$  RENAME TO $$ || idxname || $$;$$    , '')  FROM    indkey)-- ключи, code_con_tx AS (  SELECT    string_agg(    (      SELECT        string_agg(          'ALTER TABLE ' || conrelid::regclass::text || E'\n  ADD ' ||          CASE con.contype            WHEN 'p' THEN              'PRIMARY KEY USING INDEX ' || idxname            WHEN 'u' THEN              'UNIQUE USING INDEX ' || idxname            WHEN 'f' THEN              'CONSTRAINT ' || quote_ident(con.conname) || ' ' || pg_get_constraintdef(con.oid) || CASE WHEN pg_get_constraintdef(con.oid) !~* 'NOT VALID' THEN E'\n    NOT VALID' ELSE '' END          END || E';\n' ||          CASE            WHEN obj_description(con.oid, 'pg_constraint') IS NOT NULL THEN$$COMMENT ON CONSTRAINT $$ || quote_ident(conname) || $$ ON $$ || conrelid::regclass::text || $$  IS '$$ || replace(obj_description(con.oid, 'pg_constraint'), '''', '''''') || $$';$$            ELSE ''          END        , ''        ORDER BY          CASE con.contype            WHEN 'p' THEN 0            WHEN 'u' THEN 1            WHEN 'f' THEN 2          END        )      FROM        pg_constraint con      WHERE        conindid = indexrelid    )    , ''    ) code  FROM    indkey)-- триггеры, trgkey AS (  SELECT    pg_get_triggerdef(trg.oid) def  , replace(obj_description(trg.oid, 'pg_trigger'), '''', '''''') dsctrg  , *  FROM    colkey  JOIN    pg_trigger trg      ON tgrelid = attrelid AND      tgattr::smallint[] && ARRAY[attnum]  WHERE    NOT tgisinternal), code_trg AS (  SELECT    string_agg(      def || E';\n'      || CASE        WHEN dsctrg IS NOT NULL THEN$$COMMENT ON TRIGGER $$ || quote_ident(tgname) || $$ ON $$ || _attrel || $$  IS '$$ || dsctrg || $$';$$        ELSE ''      END    , ''    ) code  FROM    trgkey)SELECT  E'-- столбцы\n' ||  (TABLE code_col) ||  E'\n-- индексы\n' ||  (TABLE code_idx) ||  E'\nBEGIN;\n' ||  regexp_replace(    (TABLE code_rel) ||    E'\n-- последовательность\n' ||    (TABLE code_seq) ||    E'\n-- столбцы\n' ||    (TABLE code_col_tx) ||    E'\n-- индексы\n' ||    (TABLE code_idx_tx) ||    E'\n-- ключи\n' ||    (TABLE code_con_tx) ||    E'\n-- триггеры\n' ||    (TABLE code_trg)  , E'^(.)'  , E'  \\1'  , 'gm'  ) ||  E'COMMIT;\n';

Надеюсь, когда-то этот скрипт пригодится и вам.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru