In december vorig jaar ontving ik een interessante foutmelding van het VWO-ondersteuningsteam. De laadtijd van een van de analytische rapporten voor een grote zakelijke klant leek ongewoon lang. Aangezien dit binnen mijn verantwoordelijkheid valt, concentreerde ik me onmiddellijk op het oplossen van het probleem.
Achtergrond
Om duidelijk te maken waar het om gaat, zal ik kort iets vertellen over VWO. Dit is een platform waarmee je verschillende gerichte campagnes op je websites kunt uitvoeren: A/B-experimenten uitvoeren, bezoekers en conversies volgen, verkoopfunnels analyseren, heatmaps weergeven en sessierecords afspelen.
Maar het belangrijkste van het platform is rapportage. Al deze functies zijn met elkaar verbonden. Voor zakelijke klanten zou een enorme hoeveelheid informatie eenvoudigweg nutteloos zijn zonder een krachtig platform dat deze presenteert voor analyse.
Met het platform kun je willekeurige verzoeken doen op een grote dataset. Hier is een eenvoudig voorbeeld:
Toon alle klikken op de pagina "abc.com" VAN <datum d1> TOT <datum d2> voor mensen die Chrome gebruikten OF (in Europa waren EN iPhone gebruikten)
Let op de logische operators. Deze zijn beschikbaar voor klanten in de verzoekinterface, zodat ze zo complex mogelijke verzoeken kunnen doen om selecties te verkrijgen.
Langzame aanvraag
De betrokken klant probeerde iets te doen dat intuïtief snel zou moeten werken:
Laat alle sessierecords zien voor gebruikers die een pagina hebben bezocht met een URL die "\/jobs" bevat
Deze site had een enorme hoeveelheid verkeer, en we bewaarden meer dan een miljoen unieke URL-adressen alleen al voor deze site. En ze wilden een vrij eenvoudig URL-sjabloon vinden dat betrekking had op hun bedrijfsmodel.
Voorlopig onderzoek
Laten we eens kijken naar wat er in de database gebeurt. Hieronder staat de oorspronkelijke langzame SQL-query:
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 =5
AND recording_data.num_of_pages > 0 ;Hier zijn de tijden:
Geplande tijd: 1.480 ms Uitvoertijd: 1431924.650 ms
De query doorzocht 150.000 rijen. De queryplanner toonde een paar interessante details, maar geen duidelijke knelpunten.
Laten we de query verder bestuderen. Zoals te zien is, maakt hij JOIN drie tabellen aan:
- sessions: om sessie-informatie weer te geven: browser, user agent, land, enzovoort.
- recording_data: geregistreerde URL's, pagina's, duur van bezoeken
- urls: om duplicatie van extreem lange URL's te voorkomen, bewaren we ze in een aparte tabel.
Let ook op dat al onze tabellen al zijn gescheiden op account_id. Dit voorkomt dat, door één bijzonder groot account, andere accounts problemen ondervinden.
Op zoek naar aanwijzingen
Bij nader inzien zien we dat er iets niet klopt met deze specifieke query. We moeten deze regel nader bekijken:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]De eerste gedachte was dat het mogelijk aan ILIKE ligt, aangezien al deze lange URL's (we hebben meer dan 1,4 miljoen unieke URL's verzameld voor dit account) de prestaties zouden kunnen beïnvloeden.
Maar nee — dat is het niet!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 rijen)
Tijd: 5231.765 msDe zoekopdracht op basis van het patroon duurt slechts 5 seconden. Zoeken op een miljoen unieke URL's blijkt duidelijk geen probleem te zijn.
De volgende verdachte op de lijst — meerdere JOIN. Misschien heeft hun overmatig gebruik geleid tot vertraging? Gewoonlijk JOIN's zijn de meest voor de hand liggende kandidaten voor prestatieproblemen, maar ik geloofde niet dat ons geval typisch was.
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 =5
AND recording_data.num_of_pages > 0 ;
count
-------
8086
(1 rij)
Tijd: 147.851 msEn ook dat was niet ons geval. JOIN's bleken erg snel te zijn.
De kring van verdachten verkleinen
Ik was bereid om de query te wijzigen om eventuele prestatieverbeteringen te bereiken. Mijn team en ik ontwikkelden 2 belangrijke ideeën:
- Gebruik EXISTS voor de subquery voor URL's: We wilden nogmaals controleren of er problemen waren met de subquery voor de URL's. Een manier om dit te bereiken is simpelweg te gebruiken
EXISTS.EXISTSverbeterde prestaties aanzienlijk, aangezien het onmiddellijk eindigt wanneer het de enige rij volgens de voorwaarde vindt.
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 =5
AND recording_data.num_of_pages > 0 ;
count
32519
(1 rij)
Tijd: 1636.637 msJa. De subquery, wanneer verpakt in EXISTS, maakt alles super snel. De volgende logische vraag is waarom de query met JOIN-en en de subquery zelf snel zijn als ze afzonderlijk worden uitgevoerd, maar samen vreselijk traag zijn?
- We verplaatsen de subquery naar CTE : als de query op zichzelf snel is, kunnen we gewoon eerst het snelle resultaat berekenen en het vervolgens aan de hoofdquery geven.
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 =5
AND recording_data.num_of_pages > 0;Maar dit was nog steeds erg traag.
We vinden de schuldige.
De hele tijd flitste er één klein ding voor mijn ogen, waar ik constant voor wegkeek. Maar omdat er verder niets meer overbleef, besloot ik het ook maar te bekijken. Ik heb het over && de operator. Tot nu toe EXISTS verbeterde de prestaties, && was de enige resterende gemene deler in alle versies van de trage query.
Kijkend naar , zien we dat && wordt gebruikt wanneer we gemeenschappelijke elementen tussen twee arrays moeten vinden.
In de originele query is dit:
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )Wat betekent dat we een patroon zoeken in onze urls, en vervolgens de intersectie vinden met alle urls met gemeenschappelijke records. Dit is een beetje verwarrend, aangezien "urls" hier niet verwijst naar de tabel die alle URL's bevat, maar naar de kolom "urls" in de tabel recording_data.
Met groeiende vermoedens over &&, probeerde ik ze te bevestigen in het queryplan dat gegenereerd werd. EXPLAIN ANALYZE (ik had al een opgeslagen plan, maar ik vind het meestal gemakkelijker om in SQL te experimenteren dan om de ondoorzichtigheid van queryplanners te begrijpen).
Filter: ((urls && ($0)::text[]) EN (r_time > '2018-12-17 12:17:23+00'::timestamp met tijdszone) EN (r_time = '5'::double precision) EN (num_of_pages > 0))
Rijen verwijderd door filter: 52710Er waren verschillende filterregels alleen van &&. Wat betekende dat deze operatie niet alleen kostbaar was, maar ook meerdere keren werd uitgevoerd.
Ik controleerde dit door de voorwaarde te isoleren
SELECT 1
FROM
acc_{account_id}.urls als recordings_urls,
acc_{account_id}.recording_data_30 als recording_data_30,
acc_{account_id}.sessions_30 als sessions_30
WAAR
urls && array(select id from acc_{account_id}.urls waar url ILIKE '%enterprise_customer.com/jobs%')::text[]Deze query werd langzaam uitgevoerd. Aangezien JOIN-en subqueries snel zijn, bleef alleen de && operator over.
Maar dit is wel de sleuteloperatie. We moeten altijd in de principale URL-tabel zoeken om op het patroon te zoeken, en we moeten altijd intersecties vinden. We kunnen niet direct in de URL-records zoeken, omdat dit gewoon ID's zijn die verwijzen naar urls.
Op weg naar de oplossing
&& langzaam is, omdat beide sets enorm zijn. De operatie zal relatief snel zijn als ik vervang urls en een werkende opdracht krijgen. { "http://google.com/", "http://wingify.com/" }.
Ik begon te zoeken naar een manier om in Postgres verzamelingen te kruisen zonder gebruik te maken van &&, maar zonder veel succes.
Uiteindelijk besloten we gewoon het probleem geïsoleerd op te lossen: geef me alle urls rijen waarvoor de url aan het patroon voldoet. Zonder extra voorwaarden zal dit zijn —
SELECT urls.url
FROM
acc_{account_id}.urls als urls,
(SELECT unnest(recording_data.urls) AS id) ALS unrolled_urls
WAAR
urls.id = unrolled_urls.id EN
urls.url ILIKE '%jobs%'In plaats van JOIN de syntaxis gebruikte ik gewoon een subquery en rolde recording_data.urls de array uit, zodat de voorwaarde rechtstreeks kan worden toegepast op WAAR.
Het belangrijkste hieraan is dat && wordt gebruikt om te controleren of deze opname de overeenkomstige URL bevat. Als je goed kijkt, zie je in deze operatie de iteratie over de array-elementen (of tabelrijen) en de stop bij het voldoen aan de voorwaarde (overeenkomst). Herinnert dit je aan iets? Ja, EXISTS.
Omdat je op recording_data.urls kan verwijzen vanuit de context van de subquery, wanneer dit gebeurt, kunnen we terugkeren naar onze oude vriend EXISTS en deze met de subquery omhullen.
Door alles samen te voegen, krijgen we de uiteindelijke geoptimaliseerde query:
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 = 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%'
);
En de uiteindelijke uitvoeringstijd Tijd: 1898.717 ms Tijd om te vieren?!?
Niet zo snel! Eerst moet de correctheid worden gecontroleerd. Ik was uiterst wantrouwig tegenover EXISTS optimalisatie, omdat dit de logica verandert naar een eerder einde. We moeten er zeker van zijn dat we geen onopgemerkte fout aan de query hebben toegevoegd.
Een eenvoudige controle bestond uit het uitvoeren van count(*) zowel op langzame als op snelle queries voor een groot aantal verschillende datasets. Vervolgens heb ik handmatig de correctheid van alle resultaten gecontroleerd voor een kleine subset van de gegevens.
Alle controles gaven consistent positieve resultaten. We hebben alles gerepareerd!
Geleerde lessen
Er kunnen veel lessen uit dit verhaal worden getrokken:
- Query-plannen vertellen niet het volledige verhaal, maar kunnen aanwijzingen geven
- De belangrijkste verdachten zijn niet altijd de echte boosdoeners
- Trage queries kunnen worden opgesplitst om knelpunten te isoleren
- Niet alle optimalisaties zijn van nature reducerend
- Gebruik van
EXIST, waar mogelijk, kan leiden tot een aanzienlijke prestatieverbetering
Uitslag
We zijn van een querytijd van ~24 minuten naar 2 seconden gegaan — een zeer serieuze prestatieverbetering! Hoewel dit artikel groot is geworden, zijn alle experimenten die we deden op één dag uitgevoerd en kostten naar schatting 1,5 tot 2 uur voor optimalisatie en testen.
SQL is een wonderlijke taal, als je er niet bang voor bent, maar probeert deze te begrijpen en te gebruiken. Met een goed begrip van hoe SQL-queries worden uitgevoerd, hoe DB query-plannen genereert, hoe indexen werken en gewoon de grootte van de gegevens waarmee je te maken hebt, kun je echt uitblinken in query-optimalisatie. Even belangrijk is echter om te blijven experimenteren met verschillende benaderingen en het probleem langzaam te splitsen om knelpunten te vinden.
Het mooiste aan het behalen van dergelijke resultaten is de merkbare verbetering van de snelheid - een rapport dat voorheen niet eens geladen kon worden, laadt nu bijna onmiddellijk.
Speciale waardering aan mijn collega's in het team Aditya Mishra, Aditya Gaur en voor de brainstorm en Dinkar Pandir voor het vinden van een belangrijke fout in onze laatste aanvraag, voordat we uiteindelijk afscheid van hem namen!
Bron: habr.com
