PostgreSQL Antipatterns: да ударим тежкия JOIN с речника

Продължаваме поредицата от статии, посветени на изследването на малко известни начини за подобряване на производителността на "уж простите" заявки в PostgreSQL:

Не мислете, че не обичам JOIN толкова много… 🙂

Но често без него заявката е значително по-производителна, отколкото с него. Затова днес ще опитаме да се избавим от ресурсните JOIN — с помощта на речник.

PostgreSQL Antipatterns: да ударим тежкия JOIN с речника

Започвайки с PostgreSQL 12, част от описаните по-долу ситуации може да се възпроизведат малко по-различно заради не-материализацията на CTE по подразбиране. Това поведение може да бъде върнато на предишното, като се посочи ключ MATERIALIZED.

Много "факти" по ограничен речник

Нека да вземем напълно реална практически задача — трябва да изведем списък на входящи съобщения или активни задачи с изпратители:

25.01 | Иванов И.И. | Подгответе описанието на новия алгоритъм.
22.01 | Иванов И.И. | Напишете статия на Хабр: живот без JOIN.
20.01 | Петров П.П. | Помогнете да се оптимизира заявката.
18.01 | Иванов И.И. | Напишете статия на Хабр: JOIN с оглед на разпределението на данните.
16.01 | Петров П.П. | Помогнете да се оптимизира заявката.

В абстрактния свят авторите на задачите би трябвало да се разпределят равномерно между всички служители в нашата организация, но в реалността задачите идват, като правило, от достатъчно ограничен брой хора — "от ръководството" нагоре по иерархията или "от съседите" от съседни отдели (анализатори, дизайнери, маркетинг, …).

Нека приемем, че в нашата организация от 1000 души само 20 автори (обикновено дори по-малко) поставят задачи на всеки конкретен изпълнител и да се възползваме от това специфично знание, за да ускорим "традиционната" заявка.

Скрипт-генератор

-- служители
CREATE TABLE person AS
SELECT
  id
, repeat(chr(ascii('a') + (id % 26)), (id % 32) + 1) "name"
, '2000-01-01'::date - (random() * 1e4)::integer birth_date
FROM
  generate_series(1, 1000) id;

ALTER TABLE person ADD PRIMARY KEY(id);

-- задачи с введенным распределением
CREATE TABLE task AS
WITH aid AS (
  SELECT
    id
  , array_agg((random() * 999)::integer + 1) aids
  FROM
    generate_series(1, 1000) id
  , generate_series(1, 20)
  GROUP BY
    1
)
SELECT
  *
FROM
  (
    SELECT
      id
    , '2020-01-01'::date - (random() * 1e3)::integer task_date
    , (random() * 999)::integer + 1 owner_id
    FROM
      generate_series(1, 100000) id
  ) T
, LATERAL(
    SELECT
      aids[(random() * (array_length(aids, 1) - 1))::integer + 1] author_id
    FROM
      aid
    WHERE
      id = T.owner_id
    LIMIT 1
  ) a;

ALTER TABLE task ADD PRIMARY KEY(id);
CREATE INDEX ON task(owner_id, task_date);
CREATE INDEX ON task(author_id);

Нека покажем последните 100 задачи за конкретен изпълнител:

SELECT
  task.*
, person.name
FROM
  task
LEFT JOIN
  person
    ON person.id = task.author_id
WHERE
  owner_id = 777
ORDER BY
  task_date DESC
LIMIT 100;

PostgreSQL Antipatterns: да ударим тежкия JOIN с речника
Както и предполагахме, намерихме всичките 30 записа. Но за това изразходихме 60% от общото време — защото направихме и 30 търсения по индекса. А по-малко — може ли?

Излиза, че 1/3 от общото време и 3/4 от прочетените данни страници с данни бяха направени само за да се търси автора 100 пъти — за всяка показвана задача. Но знаем, че сред тези сто има само 20 различни — не можем ли да използваме това знание?

hstore-словарь

Нека изпробваме тип hstore за генериране на «словар» ключ-стойност:

CREATE EXTENSION hstore

В словаря ни е достатъчно да поставим ID на автора и неговото име, за да можем после да извлечем по този ключ:

-- формируем целевата извадка
WITH T AS (
  SELECT
    *
  FROM
    task
  WHERE
    owner_id = 777
  ORDER BY
    task_date DESC
  LIMIT 100
)
-- формируем словаря за уникални стойности
, dict AS (
  SELECT
    hstore( -- hstore(keys::text[], values::text[])
      array_agg(id)::text[]
    , array_agg(name)::text[]
    )
  FROM
    person
  WHERE
    id = ANY(ARRAY(
      SELECT DISTINCT
        author_id
      FROM
        T
    ))
)
-- получаваме свързаните стойности на словаря
SELECT
  *
, (TABLE dict) -> author_id::text -- hstore -> key
FROM
  T;

PostgreSQL Antipatterns: да ударим тежкия JOIN с речника
Както и предполагахме, намерихме всичките 30 записа. Но за това изразходихме 60% от общото време — защото направихме и 30 търсения по индекса. А по-малко — може ли?

За получаване на информация за лицата е изразходвано в 2 пъти по-малко време и в 7 пъти по-малко прочетени данни! Освен «ословаряването», тези резултати ни помогнаха да постигнем и масово извличане на записи от таблицата с един проход с помощта на = ANY(ARRAY(...)).

Записите на таблицата: сериализация и десериализация

Но какво да правим, ако трябва да запазим в словаря не едно текстово поле, а цяло запис? В този случай ни помага способността на PostgreSQL да работи с записа на таблицата като с едно единствено значение:

...
, dict AS (
  SELECT
    hstore(
      array_agg(id)::text[]
    , array_agg(p)::text[] -- магия #1
    )
  FROM
    person p
  WHERE
    ...
)
SELECT
  *
, (((TABLE dict) -> author_id::text)::person).* -- магия #2
FROM
  T;

Нека разгледаме какво точно се случва тук:

  1. Взехме p като псевдоним за пълния запис на таблицата person и събрахме от тях масив.
  2. Тази маса записи беше каствана в масив от текстови низове (person[]::text[]), за да бъде поставена в hstore речника като масив от стойности.
  3. При извличане на свързана запись, ние я извлекохме от речника по ключа като текстов низ.
  4. Текста трябва да превърнем в стойност от тип таблица person (за всяка таблица автоматично се създава съответстващ тип).
  5. Ние „разгръщаме“ типизирана запись в колони с помощта на (...).*.

json речник

Но подобен номер, какъвто приложихме по-горе, няма да проработи, ако няма съответстващ табличен тип, за да направим „кастоване“. Същата ситуация ще се получи и ако опитаме да използваме стринг CTE, а не „истинска“ таблица..

В този случай ще ни помогнат функции за работа с json:

...\n, p AS ( -- това вече е CTE\n  SELECT\n    *\n  FROM\n    person\n  WHERE\n    ...\n)\n, dict AS (\n  SELECT\n    json_object( -- сега вече е json\n      array_agg(id)::text[]\n    , array_agg(row_to_json(p))::text[] -- и вътре в json за всеки ред\n    )\n  FROM\n    p\n)\nSELECT\n  *\nFROM\n  T\n, LATERAL(\n    SELECT\n      *\n    FROM\n      json_to_record(\n        ((TABLE dict) ->> author_id::text)::json -- извлекли сме от речника като json\n      ) AS j(name text, birth_date date) -- запълнили сме необходимата структура\n  ) j;

Важно е да отбележим, че при описването на целевата структура можем да изброим не всички полета от изходния низ, а само тези, които наистина са ни нужни. Ако имаме „родна“ таблица, по-добре е да използваме функцията json_populate_record.

Достъпът до речника се извършва по-скоро веднъж, но разходите за json-[де]сериализация са значителни, така че този метод е разумно да се използва само в определени случаи, когато „честният“ CTE скан показва по-лоши резултати.

Тестваме производителността

И така, получихме два начина за сериализация на данни в речник — hstore / json_object. Освен това самите масиви от ключове и стойности могат да бъдат генерирани също по два начина, с вътрешно или външно преобразуване в текст: array_agg(i::text) / array_agg(i)::text[].

Нека проверим ефективността на различните видове сериализация на строго синтетичен пример — сериализираме различен брой ключове:

WITH dict AS (\n  SELECT\n    hstore(\n      array_agg(i::text)\n    , array_agg(i::text)\n    )\n  FROM\n    generate_series(1, ...) i\n)\nTABLE dict;

Оценъчен скрипт: сериализация

С T КАТО (
  ИЗ
    *
  , (
      ИЗ
        (SELECT
          regexp_replace(ea[array_length(ea, 1)], '^Execution Time: (d+.d+) ms$', '1')::real et
        ОТ
          (
            ИЗ
              array_agg(el) ea
            ИЗ
              dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
                explain analyze
                С T КАТО (
                  ИЗ
                    hstore(
                      array_agg(i::text)
                    , array_agg(i::text)
                    )
                  ИЗ
                    generate_series(1, $$ || (1 << v) || $$) i
                )
                ТАБЛИЦА dict
              $$) T(el text)
          ) T
    ) et
  ИЗ
    generate_series(0, 19) v
  ,   LATERAL generate_series(1, 7) i
  ПОРЯДОК ПО
    1, 2
)
ИЗБЕРИ
  v
, avg(et)::numeric(32,3)
ИЗ
  T
ГРУППИРАЙ ПО
  1
ПОРЯДОК ПО
  1;

PostgreSQL Antipatterns: да ударим тежкия JOIN с речника

На PostgreSQL 11 примерно до размера словаря в 2^12 ключа сериализация в json изисква по-малко време. При това най-ефективна е комбинацията от json_object и "вътрешно" преобразуване на типове array_agg(i::text).

Сега нека опитаме да прочетем стойността на всеки ключ по 8 пъти — все пак, ако не се обръщаме към словаря, за какво ни е той?

Оценъчен скрипт: четене от словаря

С T КАТО (
  ИЗ
    *
  , (
      ИЗ
        (SELECT
          regexp_replace(ea[array_length(ea, 1)], '^Execution Time: (d+.d+) ms$', '1')::real et
        ОТ
          (
            ИЗ
              array_agg(el) ea
            ИЗ
              dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
                explain analyze
                С T КАТО (
                  ИЗ
                    json_object(
                      array_agg(i::text)
                    , array_agg(i::text)
                    )
                  ИЗ
                    generate_series(1, $$ || (1 < (i % ($$ || (1 << v) || $$) + 1)::text
                ИЗ
                  generate_series(1, $$ || (1 << (v + 3)) || $$) i
            $$) T(el text)
          ) T
    ) et
  ИЗ
    generate_series(0, 19) v
  , LATERAL generate_series(1, 7) i
  ПОРЯДОК ПО
    1, 2
)
ИЗБЕРИ
  v
, avg(et)::numeric(32,3)
ИЗ
  T
ГРУППИРАЙ ПО
  1
ПОРЯДОК ПО
  1;

PostgreSQL Antipatterns: да ударим тежкия JOIN с речника

И… вече приблизително при 2^6 ключа четенето от json-словника започва многократно да изостава четенето от hstore, при jsonb същото става при 2^9.

Итоговите изводи:

  • ако трябва да направите JOIN с многократно повтарящи се записи — по-добре е да използвате "ословаряване" на таблицата
  • ако вашият речник очаквано малък и четете от него малко — може да използвате json[b]
  • във всички останали случаи hstore + array_agg(i::text) ще бъде по-ефективно

Източник: habr.com

Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри 🔥 Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри | ProHoster