Die Geschichte einer SQL-Ermittlung

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:

  1. sessions: zur Anzeige von Sitzungsinformationen: Browser, Benutzer-Agent, Land und so weiter.
  2. recording_data: aufgezeichnete URLs, Seiten, Dauer der Besuche
  3. 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 ms

Die 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 ms

Und 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:

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 ms

Ja. 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 Dokumentationansehen, 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: 52710

Es 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

  1. Langsame Anfragen können auseinandergezogen werden, um EngpÀsse zu isolieren
  2. Nicht alle Optimierungen sind von Natur aus reduktiv
  3. EXIST
  4. , wo möglich, kann zu einem drastischen Anstieg der Leistung fĂŒhren
  5. 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 Varun Malhotra 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

ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server đŸ”„ ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server - ProHoster