Nemojte misliti da mi se JOIN ne sviđa toliko... :)
Ali često se bez njega zahtjev pokaže znatno produktivnijim nego s njim. Dakle, danas ćemo pokušati riješite se JOIN-a koji zahtijeva puno resursa - pomoću rječnika.
Počevši od PostgreSQL 12, neke od dolje opisanih situacija mogu se reproducirati malo drugačije zbog zadani nematerijalizacijski CTE. Ovo se ponašanje može poništiti navođenjem ključa MATERIALIZED.
Puno "činjenica" u ograničenom rječniku
Uzmimo vrlo stvaran aplikacijski zadatak - trebamo prikazati popis dolazne poruke ili aktivni zadaci s pošiljateljima:
25.01 | Иванов И.И. | Подготовить описание нового алгоритма.
22.01 | Иванов И.И. | Написать статью на Хабр: жизнь без JOIN.
20.01 | Петров П.П. | Помочь оптимизировать запрос.
18.01 | Иванов И.И. | Написать статью на Хабр: JOIN с учетом распределения данных.
16.01 | Петров П.П. | Помочь оптимизировать запрос.
U apstraktnom svijetu, autori zadataka trebali bi biti ravnomjerno raspoređeni među svim zaposlenicima naše organizacije, ali u stvarnosti zadaci u pravilu dolaze od prilično ograničenog broja ljudi - “od menadžmenta” na hijerarhijskoj ljestvici ili “od kooperanata” iz susjednih odjela (analitičari, dizajneri, marketing, ...).
Prihvatimo da u našoj organizaciji od 1000 ljudi samo 20 autora (obično čak i manje) postavlja zadatke svakom konkretnom izvođaču i Iskoristimo ovo znanje o predmetukako bi se ubrzao "tradicionalni" upit.
Generator skripti
-- сотрудники
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);
Pokažimo zadnjih 100 zadataka za određenog izvršitelja:
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;
Ispada da 1/3 ukupnog vremena i 3/4 čitanja stranice podataka napravljene su samo za traženje autora 100 puta - za svaki izlazni zadatak. Ali znamo da među ovim stotinama samo 20 različitih - Je li moguće iskoristiti ovo znanje?
hstore-rječnik
Iskoristimo vrsta hstore za generiranje ključa/vrijednosti "rječnika":
CREATE EXTENSION hstore
Samo trebamo staviti ID autora i njegovo ime u rječnik kako bismo zatim mogli izdvojiti pomoću ovog ključa:
-- формируем целевую выборку
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;
Potrošeno na dobivanje podataka o osobama 2 puta manje vremena i 7 puta manje pročitanih podataka! Osim “vokabulara”, ono što nam je također pomoglo u postizanju ovih rezultata je bulk record retrieval iz tablice u jednom prolazu pomoću = ANY(ARRAY(...)).
Unosi u tablicu: serijalizacija i deserijalizacija
Ali što ako trebamo spremiti ne samo jedno tekstno polje, već cijeli unos u rječnik? U ovom slučaju, sposobnost PostgreSQL-a će nam pomoći tretirati unos tablice kao jednu vrijednost:
...
, 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;
Pogledajmo što se ovdje događalo:
Uzeli smo p kao pseudonim za puni unos tablice osoba i sastavio niz njih.
Ovo niz snimaka je preoblikovan na niz tekstualnih nizova (osoba[]::tekst[]) da ga smjestite u hstore rječnik kao niz vrijednosti.
Kada primimo povezani zapis, mi izvučeno iz rječnika po ključu kao tekstualni niz.
Trebamo tekst pretvoriti u vrijednost tipa tablice osoba (za svaku tablicu se automatski kreira tip istog naziva).
"Proširite" upisani zapis u stupce pomoću (...).*.
json rječnik
Ali takav trik koji smo gore primijenili neće funkcionirati ako ne postoji odgovarajuća vrsta tablice za "casting". Točno ista situacija će se pojaviti, i ako pokušamo koristiti CTE redak, a ne "prava" tablica.
...
, p AS ( -- это уже CTE
SELECT
*
FROM
person
WHERE
...
)
, dict AS (
SELECT
json_object( -- теперь это уже json
array_agg(id)::text[]
, array_agg(row_to_json(p))::text[] -- и внутри json для каждой строки
)
FROM
p
)
SELECT
*
FROM
T
, LATERAL(
SELECT
*
FROM
json_to_record(
((TABLE dict) ->> author_id::text)::json -- извлекли из словаря как json
) AS j(name text, birth_date date) -- заполнили нужную нам структуру
) j;
Treba napomenuti da pri opisivanju ciljne strukture ne možemo navesti sva polja izvornog niza, već samo ona koja su nam stvarno potrebna. Ako imamo "nativnu" tablicu, onda je bolje koristiti funkciju json_populate_record.
Još jednom pristupamo rječniku, ali json-[de]serialization troškovi su prilično visoki, stoga je razumno koristiti ovu metodu samo u nekim slučajevima kada se "pošteni" CTE snimka pokazuje lošije.
Testiranje izvedbe
Dakle, imamo dva načina za serijalizaciju podataka u rječnik − hstore/json_object. Osim toga, sami nizovi ključeva i vrijednosti također se mogu generirati na dva načina, s unutarnjom ili vanjskom konverzijom u tekst: array_agg(i::text) / array_agg(i)::text[].
Provjerimo učinkovitost različitih vrsta serijalizacije koristeći čisto sintetički primjer - serijalizirati različite brojeve ključeva:
WITH dict AS (
SELECT
hstore(
array_agg(i::text)
, array_agg(i::text)
)
FROM
generate_series(1, ...) i
)
TABLE dict;
Evaluacijska skripta: serijalizacija
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;
Na PostgreSQL 11, otprilike do veličine rječnika od 2^12 ključeva serijalizacija u json traje manje vremena. U ovom slučaju najučinkovitija je kombinacija json_object i “interne” konverzije tipa array_agg(i::text).
Pokušajmo sada pročitati vrijednost svakog ključa 8 puta - uostalom, ako ne pristupite rječniku, zašto je onda potreban?
Skripta za ocjenjivanje: čitanje iz rječnika
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
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;
I... već otprilike s 2^6 ključeva, čitanje iz json rječnika počinje gubiti više puta čitanje iz hstorea, za jsonb isto se događa na 2^9.
Konačni zaključci:
ako to trebate učiniti PRIDRUŽITE SE s više zapisa koji se ponavljaju — bolje je koristiti "rječnik" tablice
ako se vaš rječnik očekuje malen i nećete puno pročitati iz njega - možete koristiti json[b]
u svim ostalim slučajevima hstore + array_agg(i::text) će biti učinkovitiji