- Vor drei Jahren konnte Notion die Geschwindigkeit der Notion-App für Mac und Windows erfolgreich verbessern, indem Daten mit einer SQLite-Datenbank auf dem Client zwischengespeichert wurden
- Diesmal konnte dieselbe Verbesserung auch Nutzern bereitgestellt werden, die über den Browser auf Notion zugreifen
- Dieser Beitrag analysiert im Detail, wie die Performance von Notion im Browser mithilfe der sqlite3-Implementierung in WebAssembly (WASM) verbessert wurde
- Durch den Einsatz von SQLite konnte die Seiten-Navigationszeit in allen modernen Browsern um 20 % verbessert werden
- Besonders deutlich war der Unterschied bei Nutzern, deren API-Antwortzeiten aufgrund externer Faktoren wie der Internetverbindung besonders langsam waren
- So war die Seiten-Navigationszeit bei Nutzern in Australien um 28 %, in China um 31 % und in Indien um 33 % schneller
Kerntechnologien: OPFS und Web Workers
- Die WASM-SQLite-Bibliothek nutzt eine moderne Browser-API namens Origin Private File System (OPFS), um Daten sitzungsübergreifend zu speichern
- OPFS kann nur in Web Workers verwendet werden
- Ein Web Worker lässt sich als Code verstehen, der in einem separaten Thread ausgeführt wird, anders als der Main Thread, auf dem im Browser der Großteil von JavaScript läuft
- Notion wird zusammen mit Webpack gebündelt, das eine leicht nutzbare Syntax zum Laden von Web Workers bereitstellt
- Es wurde ein Web Worker eingerichtet, der mithilfe von OPFS eine SQLite-Datenbankdatei erstellt oder eine bestehende Datei lädt; in diesem Web Worker wurde der vorhandene Caching-Code ausgeführt
- Mit der Bibliothek Comlink wurde die Nachrichtenübermittlung zwischen Main Thread und Worker einfacher verwaltet
SharedWorker-basierter Ansatz
- Die endgültige Architektur basiert auf einer neuen Lösung, die Roy Hashimoto in einer GitHub-Diskussion vorgestellt hat
- Ein Ansatz, bei dem immer nur ein Tab gleichzeitig auf SQLite zugreift, während SQLite-Abfragen auch aus anderen Tabs ausgeführt werden können
- Wie funktioniert diese neue Architektur?
- Kurz gesagt hat jeder Tab einen dedizierten Web Worker, der auf SQLite schreiben kann
- Tatsächlich kann aber immer nur ein Tab den Web Worker aktiv nutzen
- Der SharedWorker verwaltet, welcher Tab der „aktive Tab“ ist
- Wenn der aktive Tab geschlossen wird, weiß der SharedWorker, dass er einen neuen aktiven Tab auswählen muss
- Um eine SQLite-Abfrage auszuführen, sendet der Main Thread jedes Tabs die Abfrage an den SharedWorker, der sie an den dedizierten Worker des aktiven Tabs weiterleitet
- Tabs können beliebig viele SQLite-Abfragen gleichzeitig ausführen; sie werden stets an den einen aktiven Tab geroutet
- Jeder Web Worker greift mit einer OPFS SyncAccessHandle Pool VFS-Implementierung, die in allen großen Browsern funktioniert, auf die SQLite-Datenbank zu
Warum der einfache Ansatz nicht funktioniert hat
- Bevor die oben beschriebene Architektur aufgebaut wurde, versuchte man zunächst, WASM SQLite auf einfachere Weise zu betreiben: mit einem dedizierten Web Worker pro Tab, wobei jeder Web Worker in die SQLite-Datenbank schreibt
- Keiner dieser Ansätze war jedoch in der vorhandenen Form ausreichend für die Anforderungen von Notion
Hürde Nr. 1: Cross-Origin-Isolation
- Um OPFS via sqlite3_vfs zu verwenden, muss sich die Website im Zustand der „Cross-Origin-Isolation“ befinden
- Um Cross-Origin-Isolation für eine Seite zu aktivieren, müssen einige Security-Header gesetzt werden, die einschränken, welche Skripte geladen werden dürfen
- Diese Header zu setzen kann erhebliche Arbeit verursachen
- Notion ist auf viele Drittanbieter-Skripte angewiesen, die verschiedene Funktionen der Web-Infrastruktur ermöglichen. Um vollständige Cross-Origin-Isolation zu erreichen, hätte man jeden Anbieter bitten müssen, neue Header zu setzen und die Funktionsweise von iframes anzupassen – eine in der Praxis schwer erfüllbare Anforderung
- In Tests konnten dennoch wichtige Performance-Daten gewonnen werden, indem diese Variante einer Teilmenge von Nutzern über Origin Trials für SharedArrayBuffer bereitgestellt wurde, die in Chrome und Edge verfügbar sind
- Mit diesen Origin Trials konnte die Anforderung der Cross-Origin-Isolation vorübergehend umgangen werden
Hürde Nr. 2: Beschädigungsprobleme
- Als OPFS via sqlite3_vfs für eine kleine Gruppe von Nutzern ausgerollt wurde, traten bei einigen schwerwiegende Bugs auf
- Diese Nutzer sahen fehlerhafte Daten auf Seiten
- Beispielsweise Kommentare, die dem falschen Kollegen zugewiesen waren, oder Links auf neue Seiten, deren Vorschau eine völlig andere Seite zeigte
- Bei der Untersuchung der Datenbankdateien betroffener Nutzer zeigte sich ein Muster, dass die SQLite-Datenbank auf irgendeine Weise beschädigt worden war
- Das Auswählen von Zeilen aus bestimmten Tabellen führte zu Fehlern, und bei der Untersuchung der Zeilen selbst zeigten sich Datenkonsistenzprobleme wie mehrere Zeilen mit derselben ID, aber unterschiedlichem Inhalt
- Als mögliche Ursache für diesen Zustand der SQLite-Datenbank wurde ein Concurrency-Problem vermutet
- Mehrere Tabs waren geöffnet, und jeder Tab hatte einen dedizierten Web Worker mit einer aktiven Verbindung zur SQLite-Datenbank
- Die Notion-Anwendung schreibt häufig in den Cache, nämlich immer dann, wenn Updates vom Server empfangen werden – also genau dann, wenn Tabs gleichzeitig in dieselbe Datei schreiben konnten
- Es wurde bereits ein Transaktionsansatz verwendet, der SQLite-Abfragen gemeinsam bündelt, doch es bestand der starke Verdacht, dass die fehlende Concurrency-Behandlung auf Seiten der OPFS-API zu den Beschädigungen führte
- Daher begann man, Beschädigungsfehler zu protokollieren, und probierte einige provisorische Ansätze aus, etwa Web Locks und die Regel, dass nur der fokussierte Tab in SQLite schreiben darf
- Diese Anpassungen senkten die Beschädigungsrate, aber nicht genug, um die Funktion im produktiven Traffic wieder aktivieren zu können
- Dennoch ließ sich bestätigen, dass Concurrency-Probleme erheblich zu den Beschädigungen beitrugen
- In der Notion-Desktop-App trat dieses Problem nicht auf
- Auf dieser Plattform schreibt nur ein einzelner Parent-Prozess in SQLite
- In der App können beliebig viele Tabs geöffnet werden, aber immer nur ein Thread greift auf die Datenbankdatei zu
Hürde Nr. 3: Die Alternative kann immer nur in einem Tab gleichzeitig laufen
- Auch die Variante OPFS SyncAccessHandle Pool VFS wurde evaluiert
- Diese Variante benötigt kein SharedArrayBuffer und kann daher in Safari, Firefox und anderen Browsern ohne Origin Trial für SharedArrayBuffer verwendet werden
- Der Nachteil dieser Variante besteht darin, dass sie immer nur in einem Tab gleichzeitig ausgeführt werden kann
- Versucht ein nachfolgender Tab, die SQLite-Datenbank zu öffnen, tritt einfach ein Fehler auf
- Einerseits bedeutet dies, dass OPFS SyncAccessHandle Pool VFS nicht die Concurrency-Probleme der Variante OPFS via sqlite3_vfs hat
- Das wurde bestätigt, da bei einer kleinen Nutzergruppe keine Beschädigungsprobleme festgestellt wurden
- Andererseits konnte diese Variante nicht unverändert veröffentlicht werden, da alle Nutzer-Tabs von den Vorteilen des Cachings profitieren sollten
Lösung des Problems
- Die Tatsache, dass keine der Varianten unverändert nutzbar war, führte zum Aufbau der oben beschriebenen SharedWorker-Architektur
- Diese Architektur ist mit einer dieser SQLite-Varianten kompatibel
- Bei der Verwendung der Variante OPFS via sqlite3_vfs wird das Beschädigungsproblem vermieden, weil immer nur ein Tab gleichzeitig schreibt
- Bei der Verwendung der Variante OPFS SyncAccessHandle Pool VFS ermöglicht der SharedWorker Caching in allen Tabs
- Nachdem bestätigt wurde, dass diese Architektur mit beiden Varianten funktioniert, messbare Performance-Verbesserungen zeigt und keine Beschädigungsprobleme verursacht, musste schließlich entschieden werden, welche Variante ausgeliefert werden soll
- Die Wahl fiel auf OPFS SyncAccessHandle Pool VFS, weil dafür keine Cross-Origin-Isolation erforderlich ist und die Auslieferung nicht auf Chrome und Edge beschränkt bleibt
Abmilderung von Performance-Verschlechterungen
- Als diese Verbesserung schrittweise an Nutzer ausgeliefert wurde, wurden einige Performance-Verschlechterungen entdeckt, die behoben werden mussten, etwa längere Ladezeiten
Seitenladezeit wurde langsamer
- Die erste Beobachtung war, dass die Navigation zwischen Notion-Seiten zwar schneller wurde, das initiale Laden der Seite jedoch langsamer
- Profiling zeigte, dass das Laden der Seite in der Regel nicht beim Datenabruf den Flaschenhals hat
- Der App-Boot-Code von Notion führt andere Arbeiten aus – etwa JS-Parsing und App-Setup –, während auf den Abschluss des API-Calls gewartet wird, und profitiert deshalb nicht so stark vom SQLite-Caching wie die Navigation
- Der Grund für die Verlangsamung war, dass Nutzer die WASM-SQLite-Bibliothek herunterladen und verarbeiten mussten
- Dadurch wurde der Seitenladeprozess blockiert, sodass andere Ladeaufgaben nicht parallel stattfinden konnten
- Da diese Bibliothek einige hundert Kilobyte groß ist, war die zusätzliche Zeit in den Metriken deutlich sichtbar
- Zur Behebung wurde die Art, wie die Bibliothek geladen wird, leicht angepasst
- WASM SQLite wurde vollständig asynchron geladen, ohne das Laden der Seite zu blockieren
- Das bedeutete, dass die initialen Seitendaten in den meisten Fällen nicht aus SQLite geladen würden
- Das war akzeptabel, weil objektiv festgestellt wurde, dass der Geschwindigkeitsgewinn durch das Laden der initialen Seite aus SQLite nicht größer ist als der Geschwindigkeitsverlust durch den Download der Bibliothek
- Nach dieser Änderung waren die Metriken für das initiale Laden der Seite zwischen Test- und Kontrollgruppe des Experiments identisch
Langsame Geräte profitieren nicht vom Caching
- Ein weiteres Phänomen in den Metriken war, dass die mediane Zeit für die Navigation von einer Notion-Seite zu einer anderen zwar sank, die Zeit im 95. Perzentil jedoch anstieg
- Bestimmte Geräte, etwa Mobiltelefone mit einem Browser, der auf Notion zugreift, profitierten nicht vom Caching und wurden stattdessen sogar langsamer
- Die Antwort auf dieses Rätsel fand sich in einer früheren Untersuchung des Mobile-Teams
- Bei der Implementierung dieses Cachings in der nativen Mobile-App wurde festgestellt, dass manche Geräte – etwa ältere Android-Smartphones – sehr langsam von der Festplatte lesen
- Daher konnte nicht davon ausgegangen werden, dass das Laden von Daten aus dem Disk-Cache schneller ist als das Laden derselben Daten über die API
- Als Ergebnis dieser Mobile-Untersuchung gab es bereits Logik, die beim Laden einer Seite zwei asynchrone Requests – SQLite und API – gegeneinander „rennen“ lässt
- Diese Logik wurde im Codepfad für Navigationsklicks einfach erneut implementiert
- Dadurch wurde das 95. Perzentil der Navigationszeit zwischen den beiden Experimentgruppen gleichgezogen
Fazit
- Die Performance-Verbesserungen von SQLite im Browser zu Notion zu bringen, brachte eigene Herausforderungen mit sich
- Vor allem im Zusammenhang mit neuer Technologie gab es eine Reihe unbekannter Probleme, aus denen einige Lehren gezogen wurden:
- OPFS behandelt Concurrency nicht von Haus aus elegant. Entwickler sollten sich dessen bewusst sein und entsprechend designen
- Web Workers und SharedWorkers (sowie ihre in diesem Beitrag nicht erwähnten Verwandten, die Service Workers) haben unterschiedliche Fähigkeiten, und es kann nützlich sein, sie bei Bedarf zu kombinieren
- Stand Frühjahr 2024 ist es nicht einfach, Cross-Origin-Isolation in einer anspruchsvollen Web-Anwendung vollständig umzusetzen – insbesondere bei Verwendung von Drittanbieter-Skripten
- Durch das Zwischenspeichern von Daten mit SQLite im Browser wurde für Nutzer die bereits erwähnte Verbesserung der Navigationszeit um 20 % erreicht, ohne Verschlechterungen bei anderen Metriken zu beobachten
- Entscheidend ist, dass keine Probleme durch SQLite-Beschädigungen beobachtet wurden
- Der Erfolg und die Stabilität dieses finalen Ansatzes sind nach Einschätzung von Notion dem Team hinter der offiziellen WASM-Implementierung von SQLite sowie Roy Hashimoto zu verdanken, der experimentelle Ansätze öffentlich zugänglich gemacht hat
6 Kommentare
Deshalb sollten Services, die mit Drittanbietern zusammenarbeiten müssen, von der ersten Veröffentlichung an die Cross-Origin-Isolation aktivieren ...
Oh, cometkim, freut mich, dich zu sehen :)
Wenn ich in Firefox eine Notion-Seite öffne, friert sie ein und ist unbenutzbar – liegt das vielleicht daran? .. (Die Notion-App funktioniert gut, daher nutze ich vorerst die.)
Vermutlich ist das so. Enda unterstützt lokales Schreiben von Dateien ebenfalls nur in Chrome & Edge.
Das hatte ich früher auf alten Linux-Laptops auch schon; im privaten Modus gestartet hat es funktioniert.