Im Dezember letzten Jahres erhielt ich einen interessanten Fehlerbericht vom VWO-Supportteam. Die Ladezeit eines der Analytics-Berichte fĂŒr einen groĂen Unternehmenskunden schien ĂŒbermĂ€Ăig lang zu sein. Da dies in meinem Verantwortungsbereich liegt, konzentrierte ich mich sofort auf die Lösung des Problems.
Vorgeschichte
Um zu erklĂ€ren, worum es geht, möchte ich ein wenig ĂŒber VWO erzĂ€hlen. Es ist eine Plattform, die es ermöglicht, verschiedene zielgerichtete Kampagnen auf Ihren Websites zu starten: A/B-Tests durchzufĂŒhren, Besucher und Conversions zu verfolgen, die Verkaufstrichteranalyse zu erstellen, Heatmaps anzuzeigen und Besuchsaufzeichnungen abzuspielen.
Das Wichtigste an der Plattform ist jedoch die Erstellung von Berichten. Alle oben genannten Funktionen sind miteinander verknĂŒpft. Und fĂŒr Unternehmenskunden wĂ€re ein riesiger Datenpool ohne eine leistungsstarke Plattform, die diese Daten fĂŒr die Analyse darstellt, einfach nutzlos.
Mit der Plattform können Sie beliebige Abfragen auf groĂen DatensĂ€tzen durchfĂŒhren. Hier ist ein einfaches Beispiel:
Zeige alle Klicks auf der Seite "abc.com" VON <Datum d1> BIS <Datum d2> fĂŒr Personen, die Chrome benutzt haben ODER (in Europa waren UND iPhone benutzt haben)
Beachten Sie die booleschen Operatoren. Diese sind im Abfrageinterface fĂŒr Kunden verfĂŒgbar, um beliebig komplexe Abfragen zur Datenextraktion zu erstellen.
Langsame Abfrage
Der betreffende Kunde versuchte, etwas zu tun, das intuitiv schnell funktionieren sollte:
Zeige alle Sitzungsaufzeichnungen fĂŒr Nutzer, die eine beliebige Seite mit einer URL besucht haben, die "\/jobs" enthĂ€lt
Auf dieser Website war ein enormes Verkehrsaufkommen, und wir speicherten mehr als eine Million einzigartiger URLs nur fĂŒr sie. Sie wollten ein ziemlich einfaches URL-Muster finden, das zu ihrem GeschĂ€ftsmodell gehörte.
VorlÀufige Untersuchung
Lassen Sie uns sehen, was in der Datenbank passiert. Hier ist die ursprĂŒngliche langsame SQL-Abfrage:
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 ;Hier sind die Zeitangaben:
Geplante Zeit: 1.480 ms AusfĂŒhrungszeit: 1431924.650 ms
Die Anfrage umfasste 150.000 Zeilen. Der Abfrageplaner zeigte ein paar interessante Details, aber keine offensichtlichen EngpÀsse.
Lassen Sie uns die Anfrage weiter untersuchen. Wie zu sehen ist, macht sie JOIN drei Tabellen:
- sessions: zur Anzeige von Sitzungsinformationen: Browser, Benutzer-Agent, Land und so weiter.
- recording_data: aufgezeichnete URLs, Seiten, Dauer der Besuche
- urls: um die doppelte Speicherung extrem langer URLs zu vermeiden, speichern wir sie in einer separaten Tabelle.
Bitte beachten Sie auch, dass alle unsere Tabellen bereits nach account_idgetrennt sind. So wird ausgeschlossen, dass aufgrund eines besonders groĂen Kontos Probleme bei anderen auftreten.
Auf der Suche nach Hinweisen
Bei nÀherer Betrachtung sehen wir, dass etwas an dieser speziellen Anfrage nicht stimmt. Es ist notwendig, sich diese Zeile genauer anzusehen:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]Der erste Gedanke war, dass möglicherweise wegen ILIKE bei all diesen langen URLs (wir haben ĂŒber 1,4 Millionen einzigartiger URLs, die fĂŒr dieses Konto gesammelt wurden) die Leistung beeintrĂ€chtigt sein könnte.
Aber nein - das ist nicht der Fall!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 rows)
Zeit: 5231.765 msDie tatsÀchliche Abfrage nach dem Muster dauert nur 5 Sekunden. Die Musterabfrage bei einer Million einzigartiger URLs ist offensichtlich kein Problem.
Der nĂ€chste VerdĂ€chtige auf der Liste sind einige JOIN. Möglicherweise hat ihr ĂŒbermĂ€Ăiger Gebrauch zu einer Verlangsamung gefĂŒhrt? In der Regel JOINsind sie die offensichtlichsten Kandidaten fĂŒr Leistungsprobleme, aber ich glaubte nicht, dass unser Fall typisch ist.
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 row)
Zeit: 147.851 msUnd das war ebenfalls nicht unser Fall. JOINSie erwiesen sich als recht schnell.
Wir verengen den Kreis der VerdÀchtigen
Ich war bereit, die Abfrage zu Àndern, um mögliche Leistungsverbesserungen zu erreichen. Mein Team und ich entwickelten 2 Hauptideen:
- EXISTS fĂŒr die URL-Unterabfrage verwenden: Wir wollten noch einmal ĂŒberprĂŒfen, ob es Probleme mit der Unterabfrage fĂŒr die URLs gibt. Eine Möglichkeit, dies zu erreichen, besteht darin, einfach zu verwenden
EXISTS.EXISTSDie Leistung erheblich verbessern, da sie sofort endet, sobald die einzige Zeile mit der Bedingung gefunden wird.
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 Zeile)
Zeit: 1636.637 msJa. Eine Unterabfrage, die in EXISTS, macht alles super schnell. Die nĂ€chste logische Frage ist, warum die Abfrage mit JOIN-en und die Unterabfrage fĂŒr sich genommen schnell sind, aber zusammen schrecklich langsam sind?
- Wir verschieben die Unterabfrage in CTE : wenn die Abfrage schnell fĂŒr sich ist, können wir einfach zuerst das schnelle Ergebnis berechnen und es dann der Hauptabfrage bereitstellen.
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;Aber das war immer noch sehr langsam.
Wir finden den ĂbeltĂ€ter.
WĂ€hrend all dieser Zeit hat ein kleines Detail immer wieder in meinem Kopf geschwebt, von dem ich stĂ€ndig abgelenkt wurde. Aber da nichts mehr ĂŒbrig war, beschloss ich, es mir auch anzusehen. Ich spreche von && Operator. WĂ€hrend EXISTS ich einfach die Leistung verbessert habe, && war der einzige verbleibende gemeinsame Faktor in allen Versionen der langsamen Abfrage.
Wenn wir uns ansehen, sehen wir, dass && verwendet wird, wenn es darum geht, gemeinsame Elemente zwischen zwei Arrays zu finden.
In der Originalabfrage ist es:
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )Was bedeutet, dass wir eine Musterabgleichung auf unsere URLs durchfĂŒhren, dann das Schnittmuster mit allen URLs in den gemeinsamen Aufzeichnungen finden. Das ist ein bisschen verwirrend, da "urls" hier nicht auf die Tabelle verweist, die alle URLs enthĂ€lt, sondern auf die Spalte "urls" in der Tabelle. recording_data.
Mit wachsendem Verdacht in Bezug auf &&, habe ich versucht, diese im generierten Abfrageplan zu bestÀtigen. EXPLAIN ANALYZE (Ich hatte bereits einen gespeicherten Plan, aber es ist mir normalerweise lieber, in SQL zu experimentieren, als die Intransparenzen von Abfrageplanern zu verstehen).
Filter: ((urls && ($0)::text[]) UND (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) UND (r_time = '5'::double precision) UND (num_of_pages > 0))
Vom Filter entfernte Zeilen: 52710Es gab einige Filterzeilen nur aus &&. Das bedeutete, dass diese Operation nicht nur teuer war, sondern auch mehrmals ausgefĂŒhrt wurde.
Ich habe das ĂŒberprĂŒft, indem ich die Bedingung isoliert habe
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[]Diese Abfrage wurde langsam ausgefĂŒhrt. Da JOIN-s schnell sind und Unterabfragen schnell sind, blieb nur der && Operator ĂŒbrig.
Das ist nur der kritische Vorgang. Wir mĂŒssen immer in der gesamten Haupttabelle der URLs suchen, um im Muster zu suchen, und wir mĂŒssen immer die Ăberschneidungen finden. Wir können nicht direkt in den URL-EintrĂ€gen suchen, da dies nur IDs sind, die auf urls.
Auf dem Weg zur Lösung
&& langsam ist, da beide Mengen groĂ sind. Der Vorgang wird relativ schnell sein, wenn ich urls auf { "http://google.com/", "http://wingify.com/" }.
Ich begann nach einer Möglichkeit zu suchen, in Postgres Mengen zu schneiden, ohne zu &&, aber ohne besonderen Erfolg.
SchlieĂlich entschieden wir uns, das Problem isoliert zu lösen: Gib mir alle urls Zeilen, fĂŒr die die URL dem Muster entspricht. Ohne zusĂ€tzliche Bedingungen wird dies sein âÂ
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 UND
urls.url ILIKE '%jobs%'Statt JOIN syntaxisch habe ich einfach eine Unterabfrage verwendet und entrollte recording_data.urls das Array, sodass die Bedingung direkt in WHERE.
Das Wichtigste hier ist, dass && verwendet wird, um zu ĂŒberprĂŒfen, ob dieser Eintrag die entsprechende URL enthĂ€lt. Wenn man ein wenig blinzelt, kann man in diesem Vorgang das Durchlaufen der Array-Elemente (oder Tabellenzeilen) sehen und das Stoppen, wenn die Bedingung (Ăbereinstimmung) erfĂŒllt ist. Kommt dir das bekannt vor? Ja, EXISTS.
Da auf recording_data.urls von auĂerhalb des Kontexts der Unterabfrage verwiesen werden kann, wenn dies passiert, können wir zu unserem alten Freund EXISTS zurĂŒckkehren und ihn um die Unterabfrage wickeln.
Wenn wir alles zusammenfassen, erhalten wir die endgĂŒltige optimierte Abfrage:
WĂHLEN Sie
count(*)
VON
acc_{account_id}.urls als recordings_urls,
acc_{account_id}.recording_data als recording_data,
acc_{account_id}.sessions als sessions
WO
recording_data.usp_id = sessions.usp_id
UND ( 1 = 1 )
UND sessions.referrer_id = recordings_urls.id
UND r_time > to_timestamp(1542585600)
UND r_time =5
UND recording_data.num_of_pages > 0
UND EXISTS(
WĂHLEN urls.url
VON
acc_{account_id}.urls als urls,
(WĂHLEN unnest(urls) ALS rec_url_id VON acc_{account_id}.recording_data)
ALS unrolled_urls
WO
urls.id = unrolled_urls.rec_url_id UND
urls.url ILIKE '%enterprise_customer.com/jobs%'
);
Und die endgĂŒltige AusfĂŒhrungszeit Zeit: 1898.717 ms Zeit zum Feiern?!?
Nicht so schnell! ZunĂ€chst mĂŒssen wir die Richtigkeit ĂŒberprĂŒfen. Ich war extrem misstrauisch gegenĂŒber EXISTS der Optimierung, da sie die Logik fĂŒr ein frĂŒheres Ende Ă€ndert. Wir mĂŒssen sicher sein, dass wir keinen offensichtlichen Fehler in der Anfrage hinzugefĂŒgt haben.
Eine einfache ĂberprĂŒfung bestand darin, count(*) auszufĂŒhren und das sowohl fĂŒr langsame als auch fĂŒr schnelle Anfragen bei einer Vielzahl unterschiedlicher DatensĂ€tze. Dann habe ich fĂŒr eine kleine Teilmenge von Daten die Richtigkeit aller Ergebnisse manuell ĂŒberprĂŒft. Alle ĂberprĂŒfungen ergaben konstant positive Ergebnisse. Wir haben alles repariert! Abgeleitete Lektionen
Aus dieser Geschichte lassen sich einige Lektionen ziehen:
AbfrageplÀne erzÀhlen nicht die ganze Geschichte, können aber Hinweise geben
Die HauptverdÀchtigen sind nicht immer die tatsÀchlichen Schuldigen
- Langsame Anfragen können auseinandergezogen werden, um EngpÀsse zu isolieren
- Nicht alle Optimierungen sind von Natur aus reduktiv
- EXIST
- , wo möglich, kann zu einem drastischen Anstieg der Leistung fĂŒhren
- Verwendung
Wir haben die Abfragezeit von ~24 Minuten auf 2 Sekunden reduziert â ein erheblicher Leistungszuwachs! Obwohl dieser Artikel lang geworden ist, fanden alle Experimente, die wir durchgefĂŒhrt haben, an einem Tag statt und dauerten schĂ€tzungsweise 1,5 bis 2 Stunden fĂŒr Optimierungen und Tests.SQL ist eine wunderbare Sprache, wenn man keine Angst davor hat, sondern versucht, sie zu verstehen und zu nutzen. Mit einem guten VerstĂ€ndnis dafĂŒr, wie SQL-Abfragen ausgefĂŒhrt werden, wie die Datenbank AbfrageplĂ€ne generiert, wie Indizes funktionieren und einfach der GröĂe der Daten, mit denen man es zu tun hat, kann man im Hinblick auf das Optimieren von Abfragen sehr erfolgreich sein. Ebenso wichtig ist es jedoch, verschiedene AnsĂ€tze weiter auszuprobieren und das Problem langsam zu zerlegen, um EngpĂ€sse zu finden.
Ausgabe
Wir haben die Anfragezeit von etwa 24 Minuten auf 2 Sekunden reduziert â ein erheblicher Leistungsanstieg! Obwohl dieser Artikel lang ist, fanden alle Experimente, die wir durchgefĂŒhrt haben, an einem Tag statt und dauerten insgesamt schĂ€tzungsweise 1,5 bis 2 Stunden fĂŒr Optimierungen und Tests.
SQL ist eine wunderbare Sprache, wenn man keine Angst davor hat, sondern versucht, sie zu verstehen und zu nutzen. Wenn Sie gut nachvollziehen können, wie SQL-Abfragen ausgefĂŒhrt werden, wie die Datenbank AbfrageplĂ€ne generiert, wie Indizes funktionieren und einfach auch die Datenmenge, mit der Sie es zu tun haben, werden Sie bei der Optimierung von Abfragen sehr erfolgreich sein. Ebenso wichtig ist es jedoch, verschiedene AnsĂ€tze auszuprobieren und das Problem langsam zu zerlegen, um EngpĂ€sse zu finden.
Der beste Teil beim Erreichen solcher Ergebnisse ist die bemerkbare Verbesserung der Arbeitsgeschwindigkeit â als der Bericht, der zuvor nicht einmal geladen werden konnte, jetzt fast sofort geladen wird.
Besonderer Dank meinen Kollegen im Team von Aditiya Mishra, Aditiya Gaur und fĂŒr das Brainstorming und Dinkar Pandir dafĂŒr, dass er einen wichtigen Fehler in unserer finalen Anfrage gefunden hat, bevor wir uns endgĂŒltig davon verabschiedet haben!
Quelle: habr.com
