Minulý rok v decembri som dostal zaujímavú správu o chybe od tímu podpory VWO. Čas načítania jedného z analytických prehľadov pre veľkého podnikového klienta sa zdal príliš vysoký. A keďže je to moja oblasť zodpovednosti, okamžite som sa zameral na vyriešenie problému.
pravek
Aby bolo jasné, o čom hovorím, poviem vám niečo o VWO. Ide o platformu, pomocou ktorej môžete na svojich webových stránkach spúšťať rôzne cielené kampane: vykonávať A/B experimenty, sledovať návštevníkov a konverzie, analyzovať predajný lievik, zobrazovať teplotné mapy a prehrávať záznamy návštev.
Najdôležitejšia vec na platforme je však podávanie správ. Všetky vyššie uvedené funkcie sú vzájomne prepojené. A pre firemných klientov by obrovské množstvo informácií bolo jednoducho zbytočné bez výkonnej platformy, ktorá ich prezentuje v analytickej forme.
Pomocou platformy môžete vytvoriť náhodný dotaz na veľký súbor údajov. Tu je jednoduchý príklad:
Zobraziť všetky kliknutia na stránke „abc.com“ OD <dátum d1> DO <dátum d2> pre ľudí, ktorí používali Chrome ALEBO (nachádzajú sa v Európe A používali iPhone)
Venujte pozornosť booleovským operátorom. Klientom sú k dispozícii v rozhraní dotazov na vytváranie ľubovoľne zložitých dotazov na získanie vzoriek.
Pomalá požiadavka
Dotyčný klient sa snažil urobiť niečo, čo by intuitívne malo fungovať rýchlo:
Zobraziť všetky záznamy relácií pre používateľov, ktorí navštívili akúkoľvek stránku s adresou URL obsahujúcou „/jobs“
Táto stránka mala veľkú návštevnosť a len pre ňu sme ukladali viac ako milión jedinečných adries URL. A chceli nájsť pomerne jednoduchú šablónu adresy URL, ktorá by súvisela s ich obchodným modelom.
Predbežné vyšetrovanie
Poďme sa pozrieť na to, čo sa deje v databáze. Nižšie je uvedený pôvodný pomalý SQL dotaz:
SELECT
count(*)
FROM
acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data as recording_data,
acc_{account_id}.sessions as sessions
WHERE
recording_data.usp_id = sessions.usp_id
AND sessions.referrer_id = recordings_urls.id
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )
AND r_time > to_timestamp(1542585600)
AND r_time < to_timestamp(1545177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0 ;
A tu sú časy:
Plánovaný čas: 1.480 ms Čas vykonania: 1431924.650 ms
Dopyt prehľadal 150 tisíc riadkov. Plánovač dopytov ukázal niekoľko zaujímavých detailov, ale žiadne zjavné prekážky.
Poďme študovať žiadosť ďalej. Ako vidíte, robí JOIN tri stoly:
zasadnutí: zobrazenie informácií o relácii: prehliadač, používateľský agent, krajina atď.
urls: Aby sme sa vyhli duplikácii extrémne veľkých adries URL, ukladáme ich do samostatnej tabuľky.
Všimnite si tiež, že všetky naše tabuľky sú už rozdelené podľa account_id. Týmto spôsobom je vylúčená situácia, keď jeden obzvlášť veľký účet spôsobuje problémy iným.
Hľadá sa stopy
Pri bližšom skúmaní vidíme, že s konkrétnou požiadavkou niečo nie je v poriadku. Stojí za to pozrieť sa bližšie na tento riadok:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]
Prvá myšlienka bola, že možno preto ILIKE na všetkých týchto dlhých adresách URL (máme viac ako 1,4 milióna jedinečný adresy URL zhromaždené pre tento účet) môže dôjsť k zhoršeniu výkonnosti.
Ale nie, o to nejde!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 rows)
Time: 5231.765 ms
Samotná požiadavka na vyhľadávanie šablóny trvá len 5 sekúnd. Hľadanie vzoru v milióne jedinečných adries URL zjavne nie je problém.
Ďalší podozrivý na zozname je niekoľko JOIN. Možno ich nadmerné používanie spôsobilo spomalenie? Zvyčajne JOIN's sú najzrejmejšími kandidátmi na problémy s výkonom, ale neveril som, že náš prípad je typický.
analytics_db=# SELECT
count(*)
FROM
acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data_0 as recording_data,
acc_{account_id}.sessions_0 as sessions
WHERE
recording_data.usp_id = sessions.usp_id
AND sessions.referrer_id = recordings_urls.id
AND r_time > to_timestamp(1542585600)
AND r_time < to_timestamp(1545177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0 ;
count
-------
8086
(1 row)
Time: 147.851 ms
A to tiež nebol náš prípad. JOINUkázalo sa, že je to celkom rýchle.
Zúženie okruhu podozrivých
Bol som pripravený začať meniť dotaz, aby som dosiahol akékoľvek možné zlepšenie výkonu. Môj tím a ja sme vyvinuli 2 hlavné myšlienky:
Pre adresu URL poddotazu použite EXISTS: Chceli sme znova skontrolovať, či sa vyskytli nejaké problémy s poddotazom pre adresy URL. Jedným zo spôsobov, ako to dosiahnuť, je jednoducho použiť EXISTS. EXISTSplechovka výrazne zlepšuje výkon, pretože sa skončí okamžite, akonáhle nájde jediný reťazec, ktorý zodpovedá podmienke.
SELECT
count(*)
FROM
acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data as recording_data,
acc_{account_id}.sessions as sessions
WHERE
recording_data.usp_id = sessions.usp_id
AND ( 1 = 1 )
AND sessions.referrer_id = recordings_urls.id
AND (exists(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%'))
AND r_time > to_timestamp(1547585600)
AND r_time < to_timestamp(1549177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0 ;
count
32519
(1 row)
Time: 1636.637 ms
No áno. Poddotaz po zabalení EXISTS, robí všetko super rýchlo. Ďalšou logickou otázkou je, prečo žiadosť s JOIN-ami a samotný poddotaz sú rýchle jednotlivo, ale spolu strašne pomalé?
Presun poddotazu do CTE : Ak je dopyt sám o sebe rýchly, môžeme jednoducho najskôr vypočítať rýchly výsledok a potom ho poskytnúť hlavnému dopytu
WITH matching_urls AS (
select id::text from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%'
)
SELECT
count(*) FROM acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data as recording_data,
acc_{account_id}.sessions as sessions,
matching_urls
WHERE
recording_data.usp_id = sessions.usp_id
AND ( 1 = 1 )
AND sessions.referrer_id = recordings_urls.id
AND (urls && array(SELECT id from matching_urls)::text[])
AND r_time > to_timestamp(1542585600)
AND r_time < to_timestamp(1545107599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0;
Ale aj tak to bolo veľmi pomalé.
Hľadanie vinníka
Celý ten čas sa mi pred očami mihla jedna maličkosť, ktorú som neustále odhŕňal. Ale keďže nič iné nezostávalo, rozhodol som sa pozrieť aj na ňu. hovorím o && operátor. Zbohom EXISTS len vylepšený výkon && bol jediným zostávajúcim spoločným faktorom vo všetkých verziách pomalého dotazu.
Pozerajúc sa na dokumentácia, to vidíme && používa sa, keď potrebujete nájsť spoločné prvky medzi dvoma poliami.
V pôvodnej žiadosti je toto:
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )
To znamená, že na našich adresách URL vykonáme vyhľadávanie vzorov a potom nájdeme priesečník so všetkými adresami URL s bežnými príspevkami. Je to trochu mätúce, pretože „url“ tu neodkazuje na tabuľku obsahujúcu všetky adresy URL, ale na stĺpec „urls“ v tabuľke recording_data.
S rastúcimi podozreniami ohľadom &&, pokúsil som sa pre nich nájsť potvrdenie vo vygenerovanom pláne dopytov EXPLAIN ANALYZE (Plán som už mal uložený, ale zvyčajne mi vyhovuje viac experimentovať v SQL, ako sa snažiť pochopiť neprehľadnosť plánovačov dotazov).
Filter: ((urls && ($0)::text[]) AND (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) AND (r_time < '2018-12-18 23:59:59+00'::timestamp with time zone) AND (duration >= '5'::double precision) AND (num_of_pages > 0))
Rows Removed by Filter: 52710
Len z niekoľkých riadkov filtrov &&. Čo znamenalo, že táto operácia bola nielen drahá, ale aj niekoľkokrát vykonaná.
Testoval som to izoláciou stavu
SELECT 1
FROM
acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data_30 as recording_data_30,
acc_{account_id}.sessions_30 as sessions_30
WHERE
urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[]
Tento dopyt bol pomalý. Pretože JOIN-s sú rýchle a poddotazy sú rýchle, jediné čo zostalo bolo && operátor.
Toto je len kľúčová operácia. Vždy musíme prehľadať celú podkladovú tabuľku adries URL, aby sme našli vzor, a vždy musíme nájsť priesečníky. Nemôžeme priamo vyhľadávať podľa záznamov adries URL, pretože ide len o identifikátory, na ktoré sa odkazuje urls.
Na ceste k riešeniu
&& pomalé, pretože obe sady sú obrovské. Ak vymením, operácia bude relatívne rýchla urls na { "http://google.com/", "http://wingify.com/" }.
Začal som hľadať spôsob, ako urobiť križovatku v Postgrese bez použitia &&, ale bez väčšieho úspechu.
Nakoniec sme sa rozhodli vyriešiť problém izolovane: dajte mi všetko urls riadky, pre ktoré sa adresa URL zhoduje so vzorom. Bez ďalších podmienok to bude -
SELECT urls.url
FROM
acc_{account_id}.urls as urls,
(SELECT unnest(recording_data.urls) AS id) AS unrolled_urls
WHERE
urls.id = unrolled_urls.id AND
urls.url ILIKE '%jobs%'
Namiesto toho JOIN syntax Použil som poddotaz a rozbalil som ho recording_data.urls pole, aby ste mohli priamo použiť podmienku WHERE.
Najdôležitejšia vec je tu && slúži na kontrolu, či daný záznam obsahuje zhodnú URL. Ak trochu prižmúrite oči, môžete vidieť, ako sa táto operácia pohybuje cez prvky poľa (alebo riadky tabuľky) a zastaví sa, keď je splnená podmienka (zhoda). Nič vám to nepripomína? áno, EXISTS.
Odvtedy recording_data.urls môže byť odkazované mimo kontextu poddotazu, keď sa to stane, môžeme sa obrátiť na nášho starého priateľa EXISTS a zabaliť ním poddotaz.
Keď dáme všetko dokopy, dostaneme konečný optimalizovaný dotaz:
SELECT
count(*)
FROM
acc_{account_id}.urls as recordings_urls,
acc_{account_id}.recording_data as recording_data,
acc_{account_id}.sessions as sessions
WHERE
recording_data.usp_id = sessions.usp_id
AND ( 1 = 1 )
AND sessions.referrer_id = recordings_urls.id
AND r_time > to_timestamp(1542585600)
AND r_time < to_timestamp(1545177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0
AND EXISTS(
SELECT urls.url
FROM
acc_{account_id}.urls as urls,
(SELECT unnest(urls) AS rec_url_id FROM acc_{account_id}.recording_data)
AS unrolled_urls
WHERE
urls.id = unrolled_urls.rec_url_id AND
urls.url ILIKE '%enterprise_customer.com/jobs%'
);
A konečný čas vedenia Time: 1898.717 ms Čas oslavovať?!?
Nie tak rýchlo! Najprv musíte skontrolovať správnosť. Bolo mi to mimoriadne podozrievavé EXISTS optimalizácia, pretože mení logiku tak, aby bola ukončená skôr. Musíme si byť istí, že sme do žiadosti nepridali nezjavnú chybu.
Jednoduchý test bol spustiť count(*) na pomalé aj rýchle dopyty pre veľké množstvo rôznych množín údajov. Potom som pre malú podmnožinu údajov manuálne overil, že všetky výsledky sú správne.
Všetky testy mali trvalo pozitívne výsledky. Všetko sme opravili!
Ponaučenie
Z tohto príbehu sa dá veľa poučiť:
Plány dopytov nehovoria celý príbeh, ale môžu poskytnúť vodítka
Hlavnými podozrivými nie sú vždy skutoční vinníci
Pomalé dopyty je možné rozdeliť na izoláciu úzkych miest
Nie všetky optimalizácie majú reduktívny charakter
Použitie EXIST, kde je to možné, môže viesť k dramatickému zvýšeniu produktivity
Výkon
Prešli sme z času dopytu ~ 24 minút na 2 sekundy - dosť významný nárast výkonu! Hoci tento článok vyšiel ako veľký, všetky experimenty, ktoré sme urobili, sa uskutočnili za jeden deň a odhaduje sa, že optimalizácia a testovanie im trvalo 1,5 až 2 hodiny.
SQL je úžasný jazyk, ak sa ho nebojíte, ale skúste sa ho naučiť a používať. Ak dobre rozumiete tomu, ako sa vykonávajú dotazy SQL, ako databáza generuje plány dotazov, ako fungujú indexy a jednoducho veľkosť údajov, s ktorými pracujete, môžete byť veľmi úspešní pri optimalizácii dotazov. Rovnako dôležité je však aj naďalej skúšať rôzne prístupy a problém pomaly rozoberať a hľadať prekážky.
Najlepšie na dosahovaní výsledkov, ako je tento, je viditeľné, viditeľné zlepšenie rýchlosti – kde sa správa, ktorá sa predtým ani nenačítala, teraz načíta takmer okamžite.
Špeciálna vďaka súdruhovia moji na príkaz Aditya Mishra, Aditya Gauru и Varun Malhotra pre brainstorming a Dinkar Pandir za nájdenie dôležitej chyby v našej poslednej žiadosti predtým, ako sme sa s ňou definitívne rozlúčili!