Performance-Tuning einer Webapp mit Index und Arbeitsvermeidung
Monday, 2. February 2015
Meinen Feedreader feedtragón nutze ich praktisch jeden Tag. Ich bin soweit zufrieden, aber nach der Eingewöhnungsphase fiel mir auf, dass er etwas langsam war. Gemessen und uff: Die längste Wartezeit kam durchs Warten auf eine Antwort vom Server. Kein gutes Zeichen. In diesem Artikel werde ich beschreiben, wie ich das Problem anging und dabei diese Verbesserung produzierte:
Die Wartezeit wurde reduziert auf ein 84stel, von 2,8 Sekunden am Anfang auf ~33ms.
Schritt 1: Index setzen
Jedes mal, wenn eine Seite geladen wird, holt feedtragón eine Liste aller abonnierten Feeds vom Server, und prüft dann jeden Feed auf ungelesene Einträge. Dafür muss er die Einträge aus der Datenbank holen, das macht er mit diesem Query:
SELECT url, title, content, id FROM entries WHERE feed = ? AND read = 0 AND id > ? LIMIT 10;
Er beschränkt sich also auf 10 Einträge mit einer höheren ID als die Start-ID, so funktioniert das Endless-Scrolling, aber das ist hier nicht weiter wichtig. Wichtig ist der Rest des WHERE-Statemends: feed = ? AND read = 0
. Da werden also zwei Spalten abgefragt. Und insgesamt dauerte das alles 2,8 Sekunden - also nicht nur dieser Query, sondern alle zusammen.
Ich bin kein Datenbankexperte, aber ich vermutete, dass diese Abfrage schneller funktionieren könnte. Das ist eine SQLite-Datenbank, dort kann man das mit EXPLAIN QUERY PLAN
prüfen:
sqlite> EXPLAIN QUERY PLAN SELECT url, title, content, id FROM entries WHERE feed = ? AND read = 0 AND id > ? LIMIT 10; 0|0|0|SEARCH TABLE entries USING INTEGER PRIMARY KEY (rowid>?)
Was heißt das jetzt? Ich musste es nachgucken. Dass er hier nur mit dem Primary Key sucht heißt, dass er für read und feed keine andere Möglichkeit hat als manuell zu suchen. Also muss dafür je ein Index her:
sqlite> CREATE INDEX entries_read_idx ON entries(read); sqlite> CREATE INDEX entries_feed_idx ON entries(feed); sqlite> EXPLAIN QUERY PLAN SELECT url, title, content, id FROM entries WHERE feed = ? AND read = 0 AND id > ? LIMIT 10; 0|0|0|SEARCH TABLE entries USING INDEX entries_feed_idx (feed=? AND rowid>?)
feedtragón gestartet, den Browser aufgemacht, in die Netzwerkanalyse geschaut und mich gefreut: Die Wartezeit auf den Server schrumpfte von 2,8s auf 0,5s. Damit war die Sache für mich erstmal abgehakt und feedtragón ausreichend optimiert.
Schritt 2: Join (Arbeitsvermeidung)
Später kam mir dann der Gedanke, dass es besser gehen müsste. Denn das Vorgehen, das ich oben beschrieb - alle Feeds holen, ans Template schicken, dort deren Einträge holen, dann nur die Feeds mit ungelesenen Einträgen anzeigen - ist keineswegs effizient. Es folgt aus einer strikten Einhaltung der für dsnblog skizzierten Architekturprinzipen: Objekte ohne Controller, die für ihre Daten zuständig sind. Das ist toll zu schreiben, besonders das Datenbanklayer bleibt einfach beherrschbar, aber Dee erwähnte damals das möglicherweise kostspielige Befüllen der Daten. Und die Einträge des Feeds aus der Datenbank zu holen ist kostspielig.
Vorher holte er also alle Feeds aus der Datenbank:
SELECT url, id, name FROM feeds;
und filterte dann im Template die Feeds aus, die keine neuen Einträge haben:
feeds.each do |feed| erb :feedlink, :locals => {:feed => feed, :current_feed_id => current_feed_id} if ! feed.entries.nil? && feed.entries.size > 0 end
wobei feed.entries() wieder die Datenbank aufruft, mit dem im ersten Schritt optimierten Query.
Stattdessen könnte man auch direkt nur die Feeds übergeben, die ungelesen Einträge haben. Dafür muss nur die Feed- und die Eintragstabelle gejoint werden:
SELECT DISTINCT feeds.url, feeds.id, feeds.name FROM feeds JOIN entries ON (entries.feed = feeds.id) WHERE entries.read = 0;
Das Filtern kann dann entfallen. Und so schrumpften die 0,5s noch einmal, auf diesmal 0,033s.
Das ist vielleicht etwas spezifisch. Der Kern hier ist: Unnötige Datenbankabfragen durch einen geschickten Join zu vermeiden ist eine gute Idee, und es auch wert wenn es etwas gegen die vorhandene Struktur geht.
Schlusswort
Natürlich sind das keine konstanten Werte. Wären mehr Feeds und mehr Einträge in der Datenbank bräuchte alles länger. Der Server hat wahrscheinlich eine langsamere Platte als mein Rechner mit seiner SSD. Außerdem ist nun nur die Wartezeit auf den Server minimiert, er muss immer noch danach etwaige Bilder herunterladen, außerdem kommt die Transportzeit zum Server dazu.
Trotzdem: Der Unterschied ist auch auf dem Server da. Statt "Klick, Warten, Rendern, da" ist es nun "Klick, Rendern, da" - den Schritt auf realistisch gesehen etwa unter eine Sekunde Ladezeit, von vorher über 3, spürt man deutlich.