PostgreSQL Antipatterns: udeľujeme úder slovníkom na ťažký JOIN

Pokračujeme v sérii článkov, zameraných na prieskum menej známych spôsobov, ako zlepšiť výkon „na pohľad jednoduchých“ dopytov v PostgreSQL:

Nemyslite si, že až tak nenávidím JOIN… 🙂

Ale často je dopyt bez neho citelne výkonnejší ako s ním. Takže dnes sa pokúsime úplne zbaviť sa náročného JOIN — pomocou slovníka.

PostgreSQL Antipatterns: udeľujeme úder slovníkom na ťažký JOIN

Začínajúc od PostgreSQL 12, časť nižšie popísaných situácií sa môže správať mierne inak kvôli ne- materializácii CTE predvolene. Toto správanie sa môže vrátiť do predchádzajúceho stavu pomocou zadaného kľúča MATERIALIZED.

Veľa „faktov“ o obmedzenom slovníku

Poďme vziať úplne reálnu aplikovanú úlohu — musíme získať zoznam prichádzajúcich správ alebo aktívnych úloh so zadávateľmi:

25.01 | Ivanov I.I. | Pripraviť popis nového algoritmu.
22.01 | Ivanov I.I. | Napísať článok na Habr: život bez JOIN.
20.01 | Petrov P.P. | Pomôcť optimalizovať dopyt.
18.01 | Ivanov I.I. | Napísať článok na Habr: JOIN so zohľadnením distribúcie údajov.
16.01 | Petrov P.P. | Pomôcť optimalizovať dopyt.

V abstraktnom svete by autori úloh mali byť rovnomerne rozdelení medzi všetkých zamestnancov našej organizácie, ale v skutočnosti úlohy prichádzajú zvyčajne od pomerne obmedzeného množstva ľudí — „od nadriadených“ smerom nahor hierarchiou alebo „od kolegov“ z vedľajších oddelení (analytici, dizajnéri, marketing, …).

Predpokladajme, že v našej organizácii z 1000 ľudí len 20 autorov (zvyčajne ešte menej) zadáva úlohy každému konkrétnemu vykonávateľovi a využijeme tieto predmetné znalosti, aby sme urýchlili „tradičný“ dopyt.

Generátor skriptov

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

-- úlohy s uvedeným rozdelením
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);

Zobrazíme posledných 100 úloh pre konkrétneho vykonávateľa:

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: udeľujeme úder slovníkom na ťažký JOIN
[pozrieť na explain.tensor.ru]

Z toho vyplýva, že 1/3 celkového času a 3/4 čítaní údajových stránok bolo vytvorených len na to, aby sme 100-krát hľadali autora — pre každú zobrazovanú úlohu. Ale vieme, že z týchto sto je iba 20 rôznych — či by sa toto poznanie nedalo využiť?

hstore-slovník

Využijeme typ hstore na generovanie „slovníka“ kľúč-hodnota:

CREATE EXTENSION hstore

Do slovníka nám stačí vložiť ID autora a jeho meno, aby sme neskôr mohli extrahovať podľa tohto kľúča:

-- vytvoríme cieľový výber
WITH T AS (
  SELECT
    *
  FROM
    task
  WHERE
    owner_id = 777
  ORDER BY
    task_date DESC
  LIMIT 100
)
-- vytvoríme slovník pre jedinečné hodnoty
, 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
    ))
)
-- získame súvisiace hodnoty slovníka
SELECT
  *
, (TABLE dict) -> author_id::text -- hstore -> kľúč
FROM
  T;

PostgreSQL Antipatterns: udeľujeme úder slovníkom na ťažký JOIN
[pozrieť na explain.tensor.ru]

Na získanie informácií o osobách bolo vynaložené dvakrát menej času a sedemkrát menej prečítaných dát! Okrem „oslovarovania“, týchto výsledkov nám pomohlo dosiahnuť aj hromadné vyhľadávanie záznamov z tabuľky na jedinom prechode pomocou = ANY(ARRAY(...)).

Záznamy tabuľky: serializácia a deserializácia

Ale čo robiť, ak potrebujeme uložiť do slovníka nielen jedno textové pole, ale celú záznam? V takom prípade nám pomôže schopnosť PostgreSQL pracovať so záznamom tabuľky ako s jednou hodnotou:

...
, dict AS (
  SELECT
    hstore(
      array_agg(id)::text[]
    , array_agg(p)::text[] -- mágia #1
    )
  FROM
    person p
  WHERE
    ...
)
SELECT
  *
, (((TABLE dict) -> author_id::text)::person).* -- mágia #2
FROM
  T;

Poďme sa pozrieť, čo sa tu vlastne dialo:

  1. Vzali sme p ako alias k plnému zapisu tabuľky person a zozbierali z nich pole.
  2. Tento pole záznamov sme pretypovali do poľa textových reťazcov (person[]::text[]), aby sme ho umiestnili do hstore-slovníka ako pole hodnôt.
  3. Pri získavaní súvisiaceho záznamu sme ho vybrali zo slovníka podľa kľúča ako textový reťazec.
  4. Text musíme premeniť na hodnotu typu tabuľky person (pre každú tabuľku sa automaticky vytvára rovnomený typ).
  5. „Rozvinuli“ typizovaný záznam do stĺpcov pomocou (...).*.

json-slovník

Ale taký trik, aký sme použili vyššie, neprejde, ak neexistuje zodpovedajúci tabuľkový typ, aby sme mohli urobiť „pretypovanie“. Presne taká istá situácia nastane, ak sa ako zdroj údajov pre serializáciu pokúsime použiť reťazec CTE, a nie „reálnu“ tabuľku.

V takom prípade nám pomôžu funkcie na prácu s json:

...
, p AS ( -- to už je CTE
  SELECT
    *
  FROM
    person
  WHERE
    ...
)
, dict AS (
  SELECT
    json_object( -- teraz to už je json
      array_agg(id)::text[]
    , array_agg(row_to_json(p))::text[] -- a vo vnútri json pre každý riadok
    )
  FROM
    p
)
SELECT
  *
FROM
  T
, LATERAL(
    SELECT
      *
    FROM
      json_to_record(
        ((TABLE dict) ->> author_id::text)::json -- vytiahli sme zo slovníka ako json
      ) AS j(name text, birth_date date) -- vyplnili potrebnú štruktúru
  ) j;

Treba poznamenať, že pri popise cieľovej štruktúry môžeme uvádzať nie všetky polia pôvodného reťazca, ale len tie, ktoré naozaj potrebujeme. Ak však máme „domovskú“ tabuľku, je lepšie využiť funkciu json_populate_record.

Prístup k slovníku máme aj naďalej jednorazový, ale náklady na json-[de]serializáciu sú dosť vysoké, preto je rozumné tento spôsob používať iba v niektorých prípadoch, keď „čestný“ CTE Scan sa osvedčuje horšie.

Testujeme výkonnosť

Tak sme získali dva spôsoby serializácie údajov do slovníka — hstore / json_object. Okrem toho samotné polia kľúčov a hodnôt môžeme vytvoriť taktiež dvoma spôsobmi, s vnútornou alebo vonkajšou konverziou na text: array_agg(i::text) / array_agg(i)::text[].

Skontrolujme efektívnosť rôznych druhov serializácie na čisto syntetickom príklade — serializujeme rôzne množstvo kľúčov:

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

Oceňovací skript : serializácia

S 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;

PostgreSQL Antipatterns: udeľujeme úder slovníkom na ťažký JOIN

Na PostgreSQL 11 približne do veľkosti slovníka 2^12 kľúčov serializácia do json si vyžaduje menej času. Najefektívnejšia je kombinácia json_object a „interného“ premenovania typov array_agg(i::text).

Teraz skúsme prečítať hodnotu každého kľúča 8-krát — veď ak sa k slovníku nebudeme obracať, tak načo nám je?

Odhadovací skript : čítanie zo slovníka

S 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
                  json_object(
                    array_agg(i::text)
                  , array_agg(i::text)
                  )
                FROM
                  generate_series(1, $$ || (1 < (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;

PostgreSQL Antipatterns: udeľujeme úder slovníkom na ťažký JOIN

A… už približne pri 2^6 kľúčoch čítanie zo json-slovníka začne výrazne zaostávať za čítaním z hstore, pre jsonb to isté nastáva pri 2^9.

Záverečné závery:

  • ak treba urobiť JOIN s viackrát opakovanými záznamami — je lepšie použiť „odoslovarovanie“ tabuľky
  • ak váš slovník je očakávane malý a budete z neho čítať len trochu — môžete použiť json[b]
  • vo všetkých ostatných prípadoch hstore + array_agg(i::text) bude efektívnejšie

Zdroj: habr.com

Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster