Letzte Woche erhielt ich ein Feedback von einem Nutzer, dass ihre Bencher Perf-Seite eine Weile zum Laden brauchte. Also entschied ich mich, es selbst zu überprüfen, und oh, Mann, waren sie nett. Es dauerte sooo lange zu laden! Peinlich lange. Vor allem für das führende Continuous Benchmarking Tool.
In der Vergangenheit habe ich die Rustls Perf-Seite als meinen Lackmustest verwendet. Sie haben 112 Benchmarks und eine der beeindruckendsten Continuous Benchmarking-Einrichtungen, die es gibt. Früher dauerte es etwa 5 Sekunden zum Laden. Dieses Mal dauerte es… ⏳👀 … 38,8 Sekunden! Bei einer solchen Latenzzeit musste ich tiefer graben. Performance-Bugs sind schließlich auch Bugs!
Hintergrund
Von Anfang an war mir klar, dass die Bencher Perf API
eine der leistungsanspruchsvollsten Endpunkte sein würde.
Ich glaube, der Hauptgrund, warum so viele Leute das Rad der Leistungsmessung neu erfinden mussten,
liegt daran, dass die bestehenden Tools von der Stange die erforderliche hohe Dimensionalität nicht bewältigen.
Mit “hoher Dimensionalität” meine ich die Fähigkeit, die Leistung über die Zeit und über mehrere Dimensionen hinweg zu verfolgen:
Branches, Testbeds, Benchmarks und Maße.
Diese Fähigkeit, quer durch fünf verschiedene Dimensionen zu schneiden und zu würfeln, führt zu einem sehr komplexen Modell.
Aufgrund dieser inhärenten Komplexität und der Art der Daten
erwog ich die Verwendung einer Zeitreihendatenbank für Bencher.
Letztendlich entschied ich mich jedoch dafür, SQLite zu verwenden.
Ich fand, es war besser, Dinge zu tun, die sich nicht skalieren lassen,
anstatt zusätzliche Zeit damit zu verbringen, eine völlig neue Datenbankarchitektur zu erlernen, die möglicherweise gar nicht hilft.
Im Laufe der Zeit haben auch die Anforderungen an die Bencher Perf API zugenommen.
Ursprünglich mussten Sie alle Dimensionen, die Sie plotten wollten, manuell auswählen.
Dies schuf viel Reibung für die Benutzer, um zu einem nützlichen Plot zu gelangen.
Um dies zu lösen, fügte ich eine Liste der neuesten Berichte zu den Perf-Seiten hinzu,
und standardmäßig wurde der neueste Bericht ausgewählt und geplottet.
Das bedeutet, dass, wenn es im neuesten Bericht 112 Benchmarks gab, alle 112 geplottet wurden.
Das Modell wurde noch komplizierter mit der Fähigkeit, Schwellenwertgrenzen zu verfolgen und zu visualisieren.
Mit diesem Hintergrund machte ich einige leistungsbezogene Verbesserungen.
Da der Perf-Plot den neuesten Bericht benötigt, um mit dem Plotten zu beginnen,
refaktorierte ich die Berichte-API, um die Ergebnisdaten eines Berichts mit einem einzigen Aufruf der Datenbank zu erhalten, anstatt zu iterieren.
Das Zeitfenster für die Standardberichtabfrage wurde auf vier Wochen festgelegt, anstatt unbegrenzt zu sein.
Ich beschränkte auch drastisch den Umfang aller Datenbank-Handles, wodurch der Lock-Wettbewerb verringert wurde.
Um den Benutzern zu helfen, fügte ich einen Statusleisten-Spinner sowohl für den Perf-Plot als auch für die Dimension-Reiter hinzu.
Ich hatte auch einen erfolglosen Versuch im letzten Herbst, eine zusammengesetzte Abfrage zu verwenden, um alle Perf-Ergebnisse in einer einzigen Abfrage zu erhalten,
anstatt eine vierfach geschachtelte Schleife zu verwenden.
Dies führte dazu, dass ich an das Rekursion-Grenzwertsystem von Rust stieß,
wiederholt den Stack überlief,
wahnsinnige (viel länger als 38 Sekunden) Kompilierzeiten durchlitt
und schließlich in einer Sackgasse bei SQLite’s maximaler Anzahl von Begriffen in einer zusammengesetzten SELECT-Anweisung endete.
Mit all dem unter meinem Gürtel wusste ich, dass ich mich hier wirklich einarbeiten musste
und meine Leistungsingenieur-Hose anziehen.
Ich hatte noch nie zuvor eine SQLite-Datenbank profiliert,
und ehrlich gesagt, hatte ich noch nie irgendeine Datenbank zuvor profiliert.
Nun ja, magst du vielleicht denken.
Mein LinkedIn-Profil sagt, ich war fast zwei Jahre lang “Datenbankadministrator”.
Und ich habe nie eine Datenbank profiliert‽
Ja. Das ist wohl eine Geschichte für ein andermal.
Von ORM zu SQL-Query
Das erste Hindernis, auf das ich stieß, war das Herausfinden der SQL-Abfrage aus meinem Rust-Code.
Ich verwende Diesel als den Object-Relational Mapper (ORM) für Bencher.
Diesel erstellt parametrisierte Abfragen.
Es sendet die SQL-Abfrage und ihre Bindparameter separat an die Datenbank.
Das heißt, die Substitution wird von der Datenbank durchgeführt.
Daher kann Diesel dem Benutzer keine vollständige Abfrage zur Verfügung stellen.
Die beste Methode, die ich fand, war die Verwendung der Funktion diesel::debug_query, um die parametrisierte Abfrage auszugeben:
Und dann die manuelle Aufbereitung und Parametrisierung der Abfrage in gültiges SQL:
Wenn Sie einen besseren Weg kennen, lassen Sie es mich bitte wissen!
Das ist jedoch der Weg, den der Projektbetreuer vorgeschlagen hat,
also habe ich das einfach gemacht.
Jetzt, da ich eine SQL-Abfrage hatte, war ich endlich bereit… eine Menge Dokumentation zu lesen.
SQLite Abfrageplaner
Die SQLite-Website bietet hervorragende Dokumentation für ihren Abfrageplaner.
Sie erklärt genau, wie SQLite Ihre SQL-Abfrage ausführt,
und sie lehrt Sie, welche Indizes nützlich sind und auf welche Operationen Sie achten sollten, wie z.B. vollständige Tabellenscans.
Um zu sehen, wie der Abfrageplaner meine Perf-Abfrage ausführen würde,
musste ich ein neues Werkzeug zu meinem Werkzeuggürtel hinzufügen: EXPLAIN QUERY PLAN
Sie können entweder Ihrer SQL-Abfrage EXPLAIN QUERY PLAN voranstellen
oder den Befehl .eqp on vor Ihrer Abfrage ausführen.
So oder so, ich erhielt ein Ergebnis, das so aussieht:
Oh, Junge!
Das ist eine Menge.
Aber die drei großen Dinge, die mir sofort ins Auge sprangen, waren:
SQLite erstellt on-the-fly eine materialisierte Ansicht, die die gesamteboundary Tabelle scannt
SQLite scannt dann die gesamtemetric Tabelle
SQLite erstellt zwei on-the-fly Indizes
Und wie groß sind die metric und boundary Tabellen?
Nun, es stellt sich heraus, dass sie die zwei größten Tabellen sind,
da dort alle Metriken und Grenzwerte gespeichert sind.
Da dies mein erstes Rodeo mit der Leistungsoptimierung von SQLite war,
wollte ich vor irgendwelchen Änderungen einen Experten zu Rate ziehen.
SQLite Experte
SQLite hat einen experimentellen “Experten” Modus, der mit dem Befehl .expert aktiviert werden kann.
Es schlägt Indizes für Abfragen vor, also habe ich beschlossen, es auszuprobieren.
Das hat es vorgeschlagen:
Das ist definitiv eine Verbesserung!
Es hat das Scannen der metric Tabelle und beide Indizes auf die Fliege beseitigt.
Ehrlich gesagt, hätte ich die ersten zwei Indizes nicht alleine gefunden.
Danke, SQLite Experte!
Jetzt bleibt nur noch, diese verdammte on-the-fly materialisierte Ansicht loszuwerden.
Materialisierte Ansicht
Als ich letztes Jahr die Fähigkeit hinzufügte, Schwellenwertgrenzen zu verfolgen und zu visualisieren,
stand ich vor einer Entscheidung im Datenbankmodell.
Es gibt eine 1-zu-0/1-Beziehung zwischen einem Metrik und seiner entsprechenden Grenze.
Das bedeutet, eine Metrik kann sich auf null oder eine Grenze beziehen, und eine Grenze kann sich immer nur auf eine Metrik beziehen.
Ich hätte also einfach die metric-Tabelle erweitern können, um alle boundary-Daten einzuschließen, wobei jedes boundary-bezogene Feld nullbar wäre.
Oder ich könnte eine separate boundary-Tabelle mit einem UNIQUE Fremdschlüssel zur metric-Tabelle erstellen.
Für mich fühlte sich die letztere Option viel sauberer an, und ich dachte, ich könnte immer später mit eventuellen Leistungsimplikationen umgehen.
Das waren die effektiven Abfragen, um die metric- und boundary-Tabellen zu erstellen:
Und es stellt sich heraus, dass “später” gekommen war.
Ich habe versucht, einfach einen Index für boundary(metric_id) hinzuzufügen, aber das half nicht.
Ich glaube, der Grund liegt darin, dass die Perf-Abfrage von der metric-Tabelle ausgeht
und weil diese Beziehung 0/1 ist, oder anders gesagt, nullbar ist, muss sie gescannt werden (O(n))
und kann nicht gesucht werden (O(log(n))).
Das ließ mich mit einer klaren Option zurück.
Ich musste eine materialisierte Ansicht erstellen, die die metric- und boundary-Beziehung abflachte,
um zu verhindern, dass SQLite eine ad-hoc materialisierte Ansicht erstellen muss.
Das ist die Abfrage, die ich verwendet habe, um die neue metric_boundary materialisierte Ansicht zu erstellen:
Mit dieser Lösung tausche ich Platz gegen Laufzeitleistung.
Wie viel Platz?
Überraschenderweise nur etwa 4% mehr, obwohl diese Ansicht für die zwei größten Tabellen in der Datenbank ist.
Das Beste daran ist, dass es mir erlaubt, in meinem Quellcode sowohl zu haben als auch zu essen.
Eine materialisierte Ansicht mit Diesel zu erstellen war überraschend einfach.
Ich musste nur genau dieselben Makros verwenden, die Diesel verwendet, wenn ich mein normales Schema generiere.
Mit dem gesagt, habe ich gelernt, Diesel im Laufe dieser Erfahrung viel mehr zu schätzen.
Siehe Bonus Bug für alle saftigen Details.
Zusammenfassung
Mit den drei neuen Indizes und einer materialisierten Sicht, die hinzugefügt wurden, zeigt der Abfrageplaner nun Folgendes:
Sehen Sie sich all diese wunderschönen SEARCH Operationen an, alle mit vorhandenen Indizes! 🥲
Und nachdem ich meine Änderungen in der Produktion eingesetzt habe:
Nun war es Zeit für den finalen Test.
Wie schnell lädt nun die Rustls Perf-Seite?
Hier gebe ich Ihnen sogar einen Anker-Tag. Klicken Sie darauf und aktualisieren Sie dann die Seite.
Bencher ist eine Suite von kontinuierlichen Benchmarking-Tools.
Hatten Sie jemals eine Performance Regression, die Ihre Nutzer beeinflusste?
Bencher hätte das verhindern können.
Bencher ermöglicht es Ihnen, Leistungsregressionen vorher zu erkennen und zu verhindern, bevor sie in die Produktion gelangen.
Ausführen: Führen Sie Ihre Benchmarks lokal oder in CI mit Ihren bevorzugten Benchmarking-Tools aus. Das bencher CLI umfasst einfach Ihr vorhandenes Benchmark-Harness und speichert die Ergebnisse.
Verfolgen: Verfolgen Sie die Ergebnisse Ihrer Benchmarks im Laufe der Zeit. Überwachen, abfragen und grafisch darstellen der Ergebnisse mit der Bencher Web Konsole auf Basis des Quellzweigs, Testbetts und Maßnahme.
Auffangen: Fangen Sie Leistungsregressionen in CI ab. Bencher verwendet modernste, anpassbare Analysen, um Leistungsregressionen zu erkennen, bevor sie in die Produktion gelangen.
Aus denselben Gründen, warum Unit Tests in CI laufen, um Feature Regressionen zu verhindern, sollten Benchmarks in CI mit Bencher ausgeführt werden, um Leistungsregressionen zu verhindern. Performance-Bugs sind Fehler!
Ich setze Bencher bereits mit Bencher ein, aber alle existierenden Benchmark-Harness-Adapter sind für Micro-Benchmarking-Harnesses. Die meisten HTTP-Harnesses sind tatsächlich Load-Testing-Harnesses und Load Testing unterscheidet sich vom Benchmarking. Weiterhin habe ich nicht vor, Bencher in absehbarer Zeit auf Load Testing auszuweiten. Das ist ein sehr unterschiedlicher Anwendungsfall, der ganz andere Designüberlegungen erfordern würde, wie zum Beispiel eine Zeitreihendatenbank. Selbst wenn ich Load Testing implementiert hätte, hätte ich wirklich gegen einen frischen Pull von Produktionsdaten laufen müssen, damit dies erkannt worden wäre. Die Leistungsunterschiede für diese Änderungen waren mit meiner Testdatenbank vernachlässigbar.
Klicken, um die Benchmark-Ergebnisse der Testdatenbank anzuzeigen
Vorher:
Nach Indizes und materialisierten Ansichten:
All dies führt mich zu dem Schluss, dass ich ein Micro-Benchmark erstellen sollte, das gegen den Perf API-Endpunkt läuft und die Ergebnisse mit Bencher dogfoodet. Dies wird eine beträchtliche Testdatenbank erfordern, um sicherzustellen, dass solche Leistungsregressionen in CI erfasst werden. Ich habe ein Tracking-Issue für diese Arbeit erstellt, falls Sie folgen möchten.
Das bringt mich allerdings zum Nachdenken:
Was wäre, wenn Sie Snapshot-Testing Ihres SQL-Datenbankabfrageplans durchführen könnten? Das heißt, Sie könnten Ihre aktuellen und kandidierenden SQL-Datenbankabfragepläne vergleichen. SQL-Abfrageplan-Testing wäre so etwas wie Benchmarking auf Basis von Instruktionszählungen für Datenbanken. Der Abfrageplan hilft zu erkennen, dass es möglicherweise ein Problem mit der Laufzeitleistung gibt, ohne dass die Datenbankabfrage tatsächlich einem Benchmark unterzogen werden muss. Ich habe auch ein Tracking-Issue dafür erstellt. Bitte zögern Sie nicht, einen Kommentar mit Gedanken oder vorheriger Kunst, von der Sie wissen, hinzuzufügen!
Bonus Bug
Ursprünglich hatte ich einen Fehler in meinem materialisierten View-Code.
So sah die SQL-Abfrage aus:
Siehst du das Problem? Nein. Ich auch nicht!
Das Problem liegt hier:
Es sollte tatsächlich sein:
Ich wollte zu clever sein,
und in meinem Diesel-Materialized View Schema hatte ich diesen Join zugelassen:
Ich ging davon aus, dass diese Makro irgendwie schlau genug wäre,
alert.boundary_id mit metric_boundary.boundary_id in Verbindung zu bringen.
Aber das war leider nicht der Fall.
Es scheint einfach die erste Spalte von metric_boundary (metric_id) gewählt zu haben, um sie mit alert in Verbindung zu bringen.
Als ich den Fehler entdeckte, war es einfach, ihn zu beheben.
Ich musste nur einen expliziten Join in der Perf-Abfrage verwenden:
🐰 Das war’s, Leute!
🤖 Dieses Dokument wurde automatisch von OpenAI GPT-4 generiert. Es ist möglicherweise nicht korrekt und kann Fehler enthalten. Wenn Sie Fehler finden, öffnen Sie bitte ein Problem auf GitHub.