Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika

Kontynuujemy serię artykułów poświęconych badaniu mało znanych sposobów na poprawę wydajności "pozornie prostych" zapytań w PostgreSQL:

Nie myślcie, że tak bardzo nie lubię JOIN… 🙂

Jednak często żądanie bez niego okazuje się zauważalnie wydajniejsze. Dlatego dzisiaj spróbujemy całkowicie pozbyć się zasobożernego JOIN — za pomocą słownika.

Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika

Rozpoczynając od PostgreSQL 12, część opisanych poniżej sytuacji może być odtwarzana nieco inaczej z powodu niedomaterializacji CTE domyślnie. To zachowanie można przywrócić do poprzedniego, podając klucz MATERIALIZED.

Wiele «faktów» w ograniczonym słowniku

Weźmy całkiem realne zadanie praktyczne — musimy wyświetlić listę przychodzących wiadomości lub aktywnych zadań od nadawców:

25.01 | Ivanov I.I. | Przygotować opis nowego algorytmu.
22.01 | Ivanov I.I. | Napisać artykuł na Habrze: życie bez JOIN.
20.01 | Petrov P.P. | Pomóc optymalizować zapytanie.
18.01 | Ivanov I.I. | Napisać artykuł na Habrze: JOIN z uwzględnieniem rozkładu danych.
16.01 | Petrov P.P. | Pomóc optymalizować zapytanie.

W abstrakcyjnym świecie autorzy zadań powinni się jednak równomiernie rozkładać wśród wszystkich pracowników naszej organizacji, ale w rzeczywistości zadania trafiają zazwyczaj od dość ograniczonej liczby osób — „od kierownictwa” w górę hierarchii lub „od współpracowników” z sąsiednich działów (analitycy, projektanci, marketing, …).

Przyjmijmy, że w naszej organizacji z 1000 osób tylko 20 autorów (zwykle nawet mniej) stawia zadania do każdego konkretnego wykonawcy i skorzystamy z tej wiedzy przedmiotowej, aby przyspieszyć „tradycyjne” zapytanie.

Generator skryptów

-- pracownicy
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);

-- zadania z określonym rozkładem
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);

Pokażemy ostatnie 100 zadań dla konkretnego wykonawcy:

WYBIERZ
  task.*
, person.name
Z
  task
LEWY DOŁĄCZ
  person
    ON person.id = task.author_id
GDZIE
  owner_id = 777
ZAMÓWIENIE
  task_date DESC
LIMIT 100;

Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika
[zobacz na explain.tensor.ru]

Wynika z tego, że 1/3 całego czasu i 3/4 lektur stron danych zostało stworzonych tylko po to, aby 100 razy poszukać autora — dla każdej wyświetlanej zadania. Ale przecież wiemy, że w tej setce jest zaledwie 20 różnych — czy nie można by wykorzystać tej wiedzy?

słownik hstore

Skorzystamy typ hstore do generacji "słownika" klucz-wartość:

UTWÓRZ EXTENSION hstore

W słowniku wystarczy umieścić ID autora i jego imię, aby później móc wydobyć według tego klucza:

-- tworzymy docelowy wybór
Z T AS (
  WYBIERZ
    *
  Z
    task
  GDZIE
    owner_id = 777
  ZAMÓWIENIE
    task_date DESC
  LIMIT 100
)
-- tworzymy słownik dla unikalnych wartości
, dict AS (
  WYBIERZ
    hstore( -- hstore(klucze::text[], wartości::text[])
      array_agg(id)::text[]
    , array_agg(name)::text[]
    )
  Z
    person
  GDZIE
    id = ANY(ARRAY(
      WYBIERZ UNIKALNE
        author_id
      Z
        T
    ))
)
-- otrzymujemy powiązane wartości słownika
WYBIERZ
  *
, (TABLE dict) -> author_id::text -- hstore -> klucz
Z
  T;

Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika
[zobacz na explain.tensor.ru]

Na uzyskanie informacji o osobach wydano dwa razy mniej czasu i siedem razy mniej przeczytanych danych! Oprócz "słownikowania", tych wyników pomogło nam osiągnąć także masowe wydobycie rekordów z tabeli w jednym przejściu przy pomocy = ANY(ARRAY(...)).

Rekordy tabeli: serializacja i deserializacja

Ale co zrobić, jeśli musimy zachować w słowniku nie jedno pole tekstowe, ale cały rekord? W takim przypadku pomoże nam zdolność PostgreSQL praca z rekordem tabeli jako z pojedynczą wartością:

...
, dict AS (
  WYBIERZ
    hstore(
      array_agg(id)::text[]
    , array_agg(p)::text[] -- magia #1
    )
  Z
    person p
  GDZIE
    ...
)
WYBIERZ
  *
, (((TABLE dict) -> author_id::text)::person).* -- magia #2
Z
  T;

Przyjrzyjmy się, co tu właściwie się działo:

  1. Wzięliśmy p jako alias dla pełnego rekordu tabeli person i zebraliśmy je w tablicę.
  2. Ta tablica rekordów została przekonwertowana na tablicę tekstowych ciągów (person[]::text[]), aby móc ją umieścić w słowniku hstore jako tablicę wartości.
  3. Przy uzyskiwaniu powiązanego rekordu wyciągnęliśmy go ze słownika po kluczu jako ciąg tekstowy.
  4. Ciąg musimy przekształcić w wartość typu tabeli person (dla każdej tabeli automatycznie tworzony jest odpowiadający jej typ).
  5. "Rozwinęliśmy" typizowany rekord w kolumny za pomocą (...).*.

słownika json

Jednak taki trik, jak zastosowaliśmy powyżej, nie zadziała, jeśli nie ma odpowiedniego typu tabeli, aby wykonać „rzutowanie”. Taka sama sytuacja wystąpi, jeśli spróbujemy użyć ciągu CTE, a nie „rzeczywistej” tabeli.

W tym przypadku pomogą nam funkcje do pracy z json:

... 
, p AS ( -- to już CTE
  SELECT
    *
  FROM
    person
  WHERE
    ...
)
, dict AS (
  SELECT
    json_object( -- teraz to już json
      array_agg(id)::text[]
    , array_agg(row_to_json(p))::text[] -- i wewnątrz json dla każdego wiersza
    )
  FROM
    p
)
SELECT
  *
FROM
  T
, LATERAL(
    SELECT
      *
    FROM
      json_to_record(
        ((TABLE dict) ->> author_id::text)::json -- wyciągnięte ze słownika jako json
      ) AS j(name text, birth_date date) -- wypełniliśmy potrzebną nam strukturę
  ) j;

Należy zauważyć, że przy opisywaniu docelowej struktury możemy wymieniać nie wszystkie pola pierwotnego wiersza, a tylko te, które naprawdę są nam potrzebne. Jeśli mamy „rodzinną” tabelę, lepiej skorzystać z funkcji json_populate_record.

Dostęp do słownika odbywa się wciąż jednokrotnie, ale koszty na json-[de]serializację są dość wysokie, dlatego taki sposób rozsądnie stosować tylko w niektórych przypadkach, gdy „uczciwy” CTE Scan wypada gorzej.

Testujemy wydajność

Zatem mamy dwa sposoby serializacji danych do słownika — hstore / json_object. Oprócz tego same tablice kluczy i wartości można również stworzyć na dwa sposoby, z wewnętrzną lub zewnętrzną konwersją do tekstu: array_agg(i::text) / array_agg(i)::text[].

Sprawdźmy efektywność różnych rodzajów serializacji na czysto syntetycznym przykładzie — serializujemy różną liczbę kluczy:

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

Skrypt oceniający: serializacja

WITH T AS (
  SELECT
    *
  , (
      SELECT
        regexp_replace(ea[array_length(ea, 1)], '^Execution Time: (d+.d+) ms$', '1')::real et
      FROM
        (
          SELECT
            array_agg(el) ea
          FROM
            dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
              explain analyze
              WITH dict AS (
                SELECT
                  hstore(
                    array_agg(i::text)
                  , array_agg(i::text)
                  )
                FROM
                  generate_series(1, $$ || (1 << v) || $$) i
              )
              TABLE dict
            $$) T(el text)
        ) T
    ) et
  FROM
    generate_series(0, 19) v
  ,   LATERAL generate_series(1, 7) i
  ORDER BY
    1, 2
)
SELECT
  v
, avg(et)::numeric(32,3)
FROM
  T
GROUP BY
  1
ORDER BY
  1;

Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika

Na PostgreSQL 11 mniej więcej do rozmiaru słownika 2^12 kluczy serializacja w json wymaga mniej czasu. Najskuteczniejszą metodą jest połączenie json_object i „wewnętrznego” konwersji typów. array_agg(i::text).

Teraz spróbujmy odczytać wartość każdego klucza 8 razy — bo jeśli nie korzystamy ze słownika, to po co on w ogóle jest?

Skrypt oceniający: odczytywanie ze słownika.

WITH T AS (
  SELECT
    *
  , (
      SELECT
        regexp_replace(ea[array_length(ea, 1)], '^Czas wykonania: (d+.d+) ms$', '1')::real et
      FROM
        (
          SELECT
            array_agg(el) ea
          FROM
            dblink('port= ' || current_setting('port') || ' dbname=' || current_database(), $$
              explain analyze
              WITH dict AS (
                SELECT
                  json_object(
                    array_agg(i::text)
                  , array_agg(i::text)
                  )
                FROM
                  generate_series(1, $$ || (1 << v) || $$) i
              )
              SELECT
                (TABLE dict) -> (i % ($$ || (1 << v) || $$) + 1)::text
              FROM
                generate_series(1, $$ || (1 << (v + 3)) || $$) i
            $$) T(el text)
        ) T
    ) et
  FROM
    generate_series(0, 19) v
  , LATERAL generate_series(1, 7) i
  ORDER BY
    1, 2
)
SELECT
  v
, avg(et)::numeric(32,3)
FROM
  T
GROUP BY
  1
ORDER BY
  1;

Antypatterny PostgreSQL: walczmy z ciężkim JOIN za pomocą słownika

I… już mniej więcej przy 2^6 kluczach odczyt z json-słownika zaczyna wyraźnie przegrywać z odczytem z hstore, dla jsonb to samo dzieje się przy 2^9.

Wnioski końcowe:

  • jeśli musisz zrobić JOIN z wielokrotnie powtarzającymi się rekordami — lepiej użyć „słownikowania” tabeli.
  • jeśli twój słownik będzie mały i odczytujesz z niego niewiele — można użyć json[b].
  • we wszystkich innych przypadkach hstore + array_agg(i::text) będzie bardziej efektywny.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster