SQLite on Rails für optimale Performance: Wie und warum
(fractaledmind.github.io)- Rails ist noch nicht so weit, SQLite mit den Standardeinstellungen unverändert in der Produktion zu nutzen; mit einigen zusätzlichen Einstellungen lassen sich jedoch performante und resiliente Anwendungen bauen
- Bei gleichzeitiger Schreiblast tritt wegen des globalen Write-Locks von SQLite
SQLITE_BUSYauf, und der übliche Ablauf von Schreibtransaktionen in Rails passt nicht gut zum standardmäßigen Deferred-Modus - Seit
sqlite3-ruby1.6.9 lässt sich der Standard-Transaktionsmodus indatabase.ymlauf IMMEDIATE umstellen, sodass fehlgeschlagene Versuche, ein Write-Lock zu erhalten, sicherer warten und erneut versuchen können busy_timeouthält auch während des Wartens die Ruby-GVL und beeinträchtigt damit die Parallelität von Puma; einbusy_handler, der mitsleepdie GVL freigibt und Wiederholungen im Abstand von 1 ms nutzt, reduziert lange Tail-Latenzen- WAL-Modus, IMMEDIATE-Transaktionen, ein die GVL freigebender Busy Handler, die SQLite-Standardeinstellungen von Rails 7.1 sowie optional getrennte Read-/Write-Connection-Pools sind die zentralen Hebel zur Performance-Verbesserung von SQLite on Rails
Grenzen der Standardkonfiguration von Rails + SQLite
- Um eine Rails-Anwendung auf SQLite-Basis performant und resilient in der Produktion zu betreiben, reichen die aktuellen Standardeinstellungen nicht aus
- Ziel ist es, die Standarderfahrung mit SQLite in Rails 8 produktionstauglich zu machen
- Die Demoanwendung ist Lorem News
- Ein grundlegender Hacker-News-Klon mit Nutzern, Beiträgen und Kommentaren
- Die Inhalte bestehen aus Lorem Ipsum
- Für Lasttests werden die oha load testing CLI und in die App eingebaute Benchmarking-Routen verwendet
- Werden 5 Sekunden lang sequenzielle Requests an den
post#create-Endpoint gesendet, ist die RPS stabil und alle Requests sind erfolgreich - Werden 5 Sekunden lang 4 gleichzeitige Requests an denselben Endpoint gesendet, geben einige Requests 500-Antworten zurück
SQLITE_BUSY und IMMEDIATE-Transaktionen
- Das erste in den Logs sichtbare Problem ist eine
SQLITE_BUSY-Exception - SQLite verwendet ein Write-Lock auf der Datenbank, um immer nur einen Schreibvorgang gleichzeitig zu erlauben
- Immer nur eine Connection kann das Write-Lock besitzen
- Wenn eine andere Connection das Lock hält und eine neue Connection versucht, ein Write-Lock zu erhalten, tritt
SQLITE_BUSYauf
- Wenn eine Rails-App höherer gleichzeitiger Last ausgesetzt ist, steigt auch der Anteil der Requests, die mit
SQLITE_BUSYfehlschlagen - Der Standard-Transaktionsmodus von SQLite ist deferred; ein Lock wird erst dann erworben, wenn tatsächlich ein Schreibvorgang erfolgt
- In Umgebungen mit nur einer Connection oder vielen Read-only-Transaktionen ist das aus Performance-Sicht vorteilhaft
- Produktions-Rails-Apps nutzen mehrere Connections über mehrere Threads, und Rails kapselt Schreib-Queries in Transaktionen, wodurch dieser Standardwert in Konflikt gerät
- Scheitert der Erwerb eines Write-Locks mitten in einer Transaktion, kann SQLite die betreffende Query nicht sicher erneut versuchen, ohne die serialisierbare Isolation zu verletzen, und wirft deshalb sofort eine Exception
IMMEDIATE-Transaktionen versuchen bereits beim Start der Transaktion, ein Write-Lock zu erwerben- SQLite kann Schreib-Queries in eine Warteschlange stellen und später erneut versuchen, das Lock zu erwerben
- Für Rails’ Muster von Schreibtransaktionen ist dieser Ansatz besser geeignet als deferred
- Das Gem
sqlite3-rubyunterstützt seit 1.6.9 die Konfiguration des Standard-Transaktionsmodus- Rails übergibt Top-Level-Keys aus
database.ymlan die Datenbankinitialisierung vonsqlite3-ruby - In
database.ymlkann Rails so konfiguriert werden, dass SQLite-Transaktionen im IMMEDIATE-Modus ausgeführt werden
- Rails übergibt Top-Level-Keys aus
- Nach dieser Änderung laufen einfache Lasttests auch unter gleichzeitiger Last fast ohne 500-Fehler durch; bei 16 gleichzeitigen Requests treten jedoch wieder einige Fehler auf
busy_timeout, GVL, eigener busy_handler
- Der nächste Flaschenhals ist ein sprunghafter Anstieg der p99-Latenz, wenn die Zahl gleichzeitiger Requests sich der Zahl der Puma-Worker nähert oder sie überschreitet
- Die tatsächliche Request-Verarbeitungszeit bleibt selbst bei gleichzeitiger Last in Höhe des Dreifachen der Puma-Worker-Zahl stabil; sobald jedoch Requests mit rund 5 Sekunden Laufzeit auftreten, erscheinen zugleich
SQLITE_BUSY-500-Antworten - Diese 5 Sekunden entsprechen der Timeout-Einstellung in
database.ymlund werden auf die SQLite-Einstellungbusy_timeoutabgebildet busy_timeoutwirft nicht sofort eineBUSY-Exception, sondern wartet die angegebene Anzahl Millisekunden und versucht erneut, das Write-Lock zu erhalten- SQLite versucht den erneuten Lock-Erwerb mit einer Art exponentiellem Backoff
- Nur wenn innerhalb des Timeouts kein Lock erhalten wird, wird eine
BUSY-Exception geworfen - Webanwendungen können so mehrere Connections öffnen, ohne die Schreibreihenfolge selbst zu koordinieren, und dies SQLite überlassen
- Der Flaschenhals entsteht dadurch, dass SQLite in den Ruby-Prozess eingebettet ist und
sqlite3-rubydie Ruby-GVL beim Aufruf von SQLite-C-Code nicht freigibt- Während ein Puma-Worker auf die Rückgabe einer Datenbank-Query wartet, hält er weiter die GVL
- Für andere Puma-Worker wird es dadurch sogar schwierig, gleichzeitig Schreib-Queries an SQLite zu senden
- Die sequenzielle Schreibcharakteristik von SQLite macht dadurch auch die Request-Verarbeitung in Rails stärker linear und senkt den Durchsatz deutlich
- SQLite bietet mit
busy_handlereinen Low-Level-Hook unterhalb vonbusy_timeoutbusy_timeoutist eine konkrete von SQLite bereitgestelltebusy_handler-Implementierungsqlite3-rubystellt Bindings für die C-Funktionsqlite3_busy_handlerbereit, sodass ein Ruby-Callback erstellt werden kann, der aufgerufen wird, wenn eine Query in die Warteschlange gelangt
- Wird im Ruby-Callback
Kernel.sleepverwendet, kann die GVL freigegeben werden, während die Query auf den erneuten Versuch zum Erwerb des Write-Locks wartet - Dieser Ansatz verbessert die p99-Latenz unter gleichzeitiger Last deutlich; bei der p99.99-Latenz bleibt jedoch das Phänomen bestehen, dass die langsamsten Requests mit steigender gleichzeitiger Last noch langsamer werden
Langer Tail durch exponentiellen Backoff und 1-ms-Retry
- Die in Ruby neu implementierte SQLite-
busy_timeout-Logik benachteiligt Queries strukturell, die bereits länger warten - Bei frühen Wiederholungsversuchen ist die Wartezeit klein, doch mit zunehmender Zahl von Callback-Aufrufen wächst sie
- Beim ersten Warten werden 1 ms gewartet
- Beim zehnten Aufruf werden 50 ms gewartet
- Ab dem zwölften Aufruf wird jeweils 100 ms gewartet
- Überschreitet die kumulierte Wartezeit das Timeout von 5000 ms, tritt eine Exception auf
- Treffen kontinuierlich neue Schreib-Queries ein, können neue Queries wegen kürzerer Wartezeiten häufiger erneut versuchen, das Lock zu erhalten
- Während eine ältere Query, die bereits dreimal gewartet hat, 10 ms wartet, kann eine neue Query mit Wartezeiten von 1 ms, 2 ms und 5 ms drei Wiederholungsversuche durchlaufen
- Dieser ansteigende Backoff erhöht die Wahrscheinlichkeit, dass ältere Queries kein Write-Lock erhalten und timeouten
- Die Lösung besteht darin, alle Queries unabhängig von ihrem Alter mit derselben Frequenz erneut versuchen zu lassen
- Im
sqlite3-ruby-Main-Branch ist diese Änderung umgesetzt- Zum Zeitpunkt des Artikels ist die Funktion noch in keinem Tagged Release enthalten
- Der Ruby-Callback gibt während des Wartens per
sleepdie GVL frei - Es wird immer nur 1 ms geschlafen
- Nach dieser Änderung flacht die p99.99-Latenzkurve in Benchmarks ab
- Der Sprung bleibt bestehen, sobald die Gleichzeitigkeit die Hälfte der Puma-Worker-Zahl überschreitet
- Danach stabilisiert sich die lange Tail-Latenz bei etwa 0,5 Sekunden
WAL-Modus und Trennung von Read-/Write-Connection-Pools
- Für die Performance von SQLite on Rails sind vier Bedingungen nötig: IMMEDIATE-Transaktionen, ein die GVL freigebender Busy Handler, geeignete SQLite-Einstellungen und der WAL-Modus
- Das Write-Ahead Log ermöglicht SQLite, mehrere gleichzeitige Lesevorgänge zu verarbeiten
- Der standardmäßige Rollback-Journal-Modus erlaubt immer nur eine Query auf einmal, egal ob Lesen oder Schreiben
- Der WAL-Modus erlaubt mehrere Reader gleichzeitig, aber immer nur einen Writer
- Seit Rails 7.1 wendet Rails bessere Standardeinstellungen auf SQLite-Datenbanken an
- Diese Einstellungen sind wichtig, damit SQLite im Kontext von Webanwendungen gut funktioniert
- Die Details und Gründe werden in einem separaten Blogpost behandelt
- Ein optionaler fünfter Performance-Hebel ist die Trennung von Read-only-Pool und Write-only-Pool
- Der SQLite-WAL-Modus unterstützt mehrere Lese-Connections und eine Schreib-Connection
- Wenn der Active-Record-Connection-Pool mit Schreib-Connections gesättigt ist, können Leseoperationen unnötig blockiert werden
- Mit Rails’ Unterstützung für mehrere Datenbanken können Reader- und Writer-Konfiguration auf dieselbe SQLite-Datenbank zeigen
- Tatsächlich handelt es sich nicht um getrennte Datenbanken, sondern um dieselbe einzelne Datenbank
- Dadurch entstehen getrennte Connection-Pools und Connection-Einstellungen
- Der Reader-Connection-Pool besteht aus Read-only-Connections, der Writer-Connection-Pool hat nur eine einzige Connection
- Active-Record-Modelle werden so konfiguriert, dass sie je nach Rolle mit dem passenden Connection-Pool verbunden sind
- Mit Rails’ automatic role switching wird der Standard-Connection-Pool für alle Web-Requests zum Reader-Pool, und nur bei erforderlichen Datenbankschreibvorgängen wird auf den Writer-Pool gewechselt
- Da dieselbe Datenbank verwendet wird, ist kein
delayzur Garantie vonread your own writesnötig - Die
transaction-Methode des ActiveRecord-Adapters wird gepatcht, damit Transaktionen mit der Writer-Datenbank verbunden werden
- Da dieselbe Datenbank verwendet wird, ist kein
- Diese Kombination aus „deferred requests“ und isolierten Connection-Pools zeigt im Test des Comment-Create-Endpoints eine Performance-Verbesserung gemessen an einfacher RPS
Als Gem paketierte Verbesserungen
- Nicht alle Verbesserungen müssen direkt in der Rails-App implementiert werden
- Durch Installation von
activerecord-enhancedsqlite3-adapterlassen sich die entsprechenden Verbesserungen anwenden - Die Funktion für isolierte Connection-Pools ist neuer und experimentell, daher muss sie per zusätzlicher Konfiguration opt-in aktiviert werden
- Dieser Ansatz bündelt Werkzeuge, Techniken und Default-Werte, um SQLite in Rails-Produktionsumgebungen schnell und flexibel zu nutzen
- Rails ist ein gutes Web Application Framework für die Arbeit mit SQLite, und das zugehörige Tooling- und Gem-Ökosystem wächst
1 Kommentare
Hacker-News-Kommentare
Wer SQLite + Rails verwenden möchte, sollte sich unbedingt Oldmoes (X/GitHub) Projekt Litestack ansehen.
Litestack ist ein Ruby-Gem, das für Ruby- und Ruby-on-Rails-Apps die Einbettbarkeit von SQLite nutzt und in einem Paket eine SQL-Datenbank, einen schnellen Cache, eine Job-Queue, einen Message Broker, eine Volltextsuchmaschine und eine Metrikplattform bereitstellt.
Ich nutze es derzeit in einem Projekt und bin sehr zufrieden; der Link ist https://github.com/oldmoe/litestack
Ich kann mir kaum vorstellen, wie viele Tage es gedauert haben muss, einen so ausführlichen Artikel zu schreiben.
Ein nützlicher Beitrag für alle, die sich über Rails hinaus Gedanken über die Skalierung von SQLite-Webanwendungen machen.
Wer mit SQLite arbeitet, sollte diesen Artikel lesen, unabhängig von Programmiersprache oder Framework.
Da ich das meiste davon vor ein paar Jahren selbst herausfinden musste: Danke für die Zusammenstellung.
Ich baue ein FOSS-Analytics-System und möchte die Installation einfach halten, daher würde ich Event-Daten gern in eine separate SQLite-Datenbank schreiben, getrennt von den Daten der Hauptanwendung.
Selbst mittelmäßig ausgelastete Websites können mehr als 1000 Events pro Sekunde erzeugen, daher mache ich mir Sorgen um die Skalierung.
Ich frage mich, ob es ein sinnvoller Weg ist, Events im Serverspeicher zu sammeln und einmal pro Sekunde per Batch-Write zu schreiben, um die Grenzen von SQLite bei vielen Schreibvorgängen zu umgehen, oder ob es bessere Ideen gibt.
Das lässt sich auch leicht per Benchmark belegen, und man kann den gesamten Batch in eine einzelne Transaktion packen.
Bei Batch-Verarbeitung hat man faktisch einen Thread, der einen Batch übernimmt und die eigentlichen Schreibvorgänge ausführt; das passt gut zu SQLite und seiner Beschränkung auf einen gleichzeitigen Writer.
Allerdings steigt die Komplexität etwas. Man muss festlegen, was passiert, wenn ein einzelner Batch-Write nicht innerhalb von 1 Sekunde fertig wird, ob die im Speicher gehaltene Queue unbegrenzt groß sein darf, ob man sich bei einer unbegrenzten Queue sicher sein kann, dass der Server bei Überlast nicht durch OOM stirbt, ob man bei einer Begrenzung den Verlust von Einträgen in Kauf nimmt, welche Einträge man dann verwirft und wie hoch das Queue-Limit sein soll.
Solche Fragen tauchen in fast jedem System auf, das eine Queue braucht, und sie helfen nicht nur jetzt, sondern auch in künftigen Situationen.
Wenn man der Transaktions-Parallelität von SQLite nicht traut, kann man alle Schreibvorgänge in einem bestimmten Thread oder Prozess serialisieren.
Mit ClickHouse’ async-insert[0]-Funktion muss man sich auf Anwendungsseite nicht um das Batching von Events kümmern.
Wenn du nach einer eingebetteten Lösung suchst, kannst du chDB auf Basis von ClickHouse verwenden.
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
Reader kann man so viele haben, wie man möchte, und der Writer kann Eingaben etwa aus einer Queue entgegennehmen.
Wenn man es so macht, ist die Performance oft wirklich erstaunlich.
Den Trend, SQLite als Produktions-Backend-Datenbank einzusetzen, verstehe ich noch nicht wirklich.
SQLite ist großartig als kleine, einbettbare clientseitige Anwendungsdatenbank, etwa für das Adressbuch eines Telefons, aber die Entwickler selbst haben sich konsequent dagegen gewehrt, es über diesen Rahmen hinaus zu erweitern.
Zum Beispiel fügen sie keine nützlichen nativen Typen wie Datum/Uhrzeit oder UUID hinzu. Der Grund ist, dass Code und Größe der eingebauten Objekte wachsen würden; am Ende bleibt man in einem Zustand stecken, in dem „alles ein String“ ist.
Referenzielle Integrität kann man aktivieren, aber auch die Optionen für Constraints sind sehr begrenzt.
Ich weiß nicht, warum man es weiter zwanghaft in eine Rolle pressen will, für die es nicht passt und die auch nicht richtig unterstützt wird.
Der Grund ist simpel: Bei lesezentrierten Zugriffsmustern ist SQLite unglaublich schnell, schnell genug, um Datenbankzugriffscode zu vereinfachen, und zum Beispiel sind N+1-Queries in vielen Fällen tatsächlich kein Problem.
Außerdem entfernt SQLite in einer N-Tier-Architektur eine Schicht und damit auch einen Teil der Dinge, die ausfallen können. Wer schon einmal selbst Postgres oder MySQL betrieben hat, weiß, dass in der Praxis vieles schiefgehen kann.
Es ist nicht für jede App, nicht einmal für viele Apps, die perfekte Wahl, aber der aktuelle Trend ist eher eine Korrektur des Gleichgewichts gegenüber der offensichtlich falschen Vorstellung, SQLite eigne sich nur als „kleine eingebettete Client-App-Datenbank“.
Wenn man es so betrachtet, wirkt eine Struktur mit einem weiteren DBMS neben einem DBMS, das dieselben Daten verwaltet, ziemlich absurd.
Daher teilt es sich in Richtungen auf: Entweder lässt man Clients direkt mit Postgres verbinden, oder man entfernt Postgres und konzentriert sich stärker auf das eigene DBMS.
Wenn man Option 2 wählt, ist SQLite eine Engine, auf der sich bequem aufbauen lässt. Nicht perfekt, aber das Werkzeug, das wir gerade haben.
Diese Erkenntnis hat sich in großem Maßstab erst relativ kürzlich verbreitet, daher laufen viele Experimente, um herauszufinden, was funktioniert und was nicht.
Das ist ein natürlicher Zyklus im Computing: Altes wird wieder neu.
Postgres kann man hier auch durch MySQL, MSSQL, Oracle oder ein anderes DBMS ersetzen.
Solche Vereinfachungen nützen nicht nur den SQLite-Entwicklern oder Low-End-Hardware, sondern in vielerlei Hinsicht auch App-Entwicklern.
Die Dokumentation wird einfacher, die Lernkurve kürzer, die Angriffsfläche für Bugs und die Binärgröße kleiner.
Moderne Software neigt dazu, überall Aufblähung und Komplexität hinzuzufügen; deshalb ist es sehr gut, dass einige Projekte wie SQLite dem entgegenwirken.
Ein großartiger Artikel, und ich frage mich, ob es auch ähnliches Material für Django gibt.
ArchiveBox nutzt SQLite über Django, und wir sind ziemlich häufig auf genau dieselben Probleme gestoßen, die der Artikel für Rails beschreibt.
Es wäre schön, eine SQLite-Layer-Lösung zu haben, bei der man nicht alle Schreibvorgänge über einen anderen Pfad der App serialisieren muss.
Das Gem
sqlite3-rubygibt bei SQLite-Aufrufen die GVL absichtlich nicht frei. Sieht man sich den Kommentar im verlinkten Issue an https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss..., scheint es den Verdacht gegeben zu haben, dass die Kosten für das erneute Erwerben des Locks hoch sind, aber geprüft wurde das wohl nicht.Angesichts all dieser Workarounds wirkt das etwas fragwürdig.
In der Kultur von Python-Erweiterungen wäre es meiner Vermutung nach umgekehrt entworfen worden; ich frage mich, wie man es dort tatsächlich macht.
Im zusätzlich verlinkten Issue gibt es auch einen Kommentar: „Das extralite-Gem ist ein alternativer SQLite-Client, der während blockierender Operationen die GVL freigibt; Informationen zur Nebenläufigkeit gibt es unter https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... Im Allgemeinen ist dieses Gem deutlich schneller als dieses hier und hat keine Concurrency-Probleme.“
Es wurde bestätigt, dass es die Single-Thread-Performance erheblich verschlechtert, wenn man bei jedem
stepder virtuellen SQLite-Maschine einfach die GVL freigibt.Einen Mittelweg zwischen Single-Thread- und Multi-Thread-Performance zu finden, ist schwierig.
Bei Rails weiß man wegen des Connection-Pools, dass es multi-threaded ist, aber Low-Level-Gems werden auch viel in anderen Bibliotheken und Tools verwendet, die in Single-Thread-Umgebungen laufen.
Einige Einstellungen, die ich für meinen persönlichen Spielzeug-Webservice beibehalte, sind diese:
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Und ich verwende BEGIN IMMEDIATE-Transaktionen.
https://kerkour.com/sqlite-for-servers
cache_sizeundmmap_size?Ich mag sowohl SQLite als auch Rails, aber das wirkt ein bisschen so, als würde man in einer Produktionsumgebung MS Access einsetzen
Außerdem sind Computer und Festplatten heute viel schneller als vor Jahrzehnten, als Access noch häufiger genutzt wurde
Bei vorwiegend lesenden Workloads dürften SQLite – und auch Jet – ziemlich leicht auf Zehntausende Requests pro Sekunde kommen
Die meisten Anwendungen haben keine Hunderttausende gleichzeitigen Nutzer, daher kann SQLite gut passen
Eine weitere Stärke von SQLite ist, dass es Clients für nahezu jede Plattform und Sprache gibt
Archive, Backups und Portabilität sind ebenfalls Einsatzbereiche, die gut zu SQLite passen. Bei einem Projekt mit auf einen bestimmten Zeitraum begrenzter Dateneingabe habe ich mich stark für SQLite pro Box eingesetzt, und ich habe immer noch das Gefühl, dass das besser gewesen wäre
Statt ein komplexes Schema und benutzerdefinierte Export-/Archivierungsfunktionen zu bauen, hätte man einfach eine einzelne Datei als Archiv oder Backup kopieren können, und spätere Schemaänderungen hätten nicht so tiefgehend berücksichtigt werden müssen
Es hängt von der Situation ab, ist aber für viele Probleme eine ziemlich gute Lösung. Ähnlich wie PostgreSQL oder ein anderes relationales DBMS für die meisten Anwendungen oft die bessere Wahl ist als skalierbarere NoSQL-Optionen
Wir hatten die Tendenz, zu viel zu entwerfen, und inzwischen nähern sich Rechen- und I/O-Leistung einem Niveau, bei dem sich dieser Aufwand immer weniger rechtfertigen lässt
Ein sehr informativer und gut geschriebener Artikel
Ich frage mich, warum die standardmäßige
busy_timeout-Methode eine exponentielle Verzögerung hat, die ältere Queries bestraftWas macht das als Standardwert sinnvoll?