Bluesky migriert auf Single-Tenant-SQLite
(github.com/bluesky-social)- Das PDS-Refactoring-PR #1705 von Bluesky atproto stellt PDS auf einen Single-Tenant-SQLite-Datastore um und speichert das repo jedes Nutzers sowie den privaten Kontostatus in einer eigenen SQLite-Datei
- Die Nutzer-DBs werden in der Pfadstruktur
/${dbDirectory}/${sha256Hex(did).slice(0,2)}/${did}gespeichert, und die Signaturschlüssel jedes repo werden zusammen mit der jeweiligen SQLite-Datei abgelegt - Die bisherige Abstraktion für den Zugriff auf Nutzerdaten wird durch den ActorStore ersetzt; da SQLite keine gleichzeitigen Transaktionen unterstützt, müssen Schreibvorgänge explizit mit einem Store und einer Transaktion verknüpft werden
- Offene DB-Dateihandles und Signaturschlüssel werden per LRUCache verwaltet; dabei bleiben bis zu 30k offene Dateihandles und 30k Schlüssel im Speicher, und wenn eine DB aus dem Cache verdrängt wird, wird das Dateihandle geschlossen
- Für die Verwaltung des Service-Status werden drei separate SQLite-DBs eingeführt, die im WAL-Modus laufen, um gleichzeitige Lesezugriffe und Streaming-Replikation zu ermöglichen; die PDS-Distribution soll Litestream oder ein ähnliches Tool enthalten
Zentrale Änderungen des PR
- PR #1705 refaktoriert PDS auf Basis eines Single-Tenant-SQLite-Datastores
- Jeder Nutzer erhält eine eigene SQLite-Datei, in der das repo dieses Nutzers und der private Kontostatus gespeichert werden
- Die Nutzer-DBs werden in einem hierarchischen Pfad anhand des DID-Hashs gespeichert
- Pfadformat:
/${dbDirectory}/${sha256Hex(did).slice(0,2)}/${did}
- Pfadformat:
- Der repo signing key jedes repo wird am selben Ort wie die SQLite-Datei gespeichert
ActorStore und Transaktionsmodell
- Die Abstraktion für den Zugriff auf Nutzerdaten wechselt von den bisherigen „services“ zu ActorStore
- Der wichtigste Unterschied von ActorStore ist, dass die Klassen für Lese- und Schreibzugriffe getrennt sind
- Da SQLite keine gleichzeitigen Transaktionen unterstützt, muss man für Schreibvorgänge explizit einen Store und eine Transaktion eingehen
- Das Commit-Log enthält unter anderem eine Überarbeitung von Reader und Transactor, die Behandlung von Transaction-Races im Actor Store und die Bereinigung der Store-Schnittstellen
Cache- und Dateihandle-Verwaltung
- Für Signaturschlüssel und Datenbanken wird ein LRUCache vorgehalten
- Die konfigurierten Limits sind wie folgt
- maximal 30k offene Dateihandles
- maximal 30k im Speicher gehaltene Schlüssel
- Wenn eine Datenbank aus dem Cache verdrängt wird, wird das Dateihandle geschlossen
- Relevante Commits sind unter anderem
actor store in lru cacheundfix open handles
Drei SQLite-DBs für den Service-Status
- Zusätzlich zu den nutzerbezogenen DBs werden drei separate SQLite-Datenbanken für die Verwaltung des Service-Status eingeführt
- service DB: verwaltet Kontoinformationen, Einladungscodes, Refresh-Token usw.
- did cache DB: enthält nur eine einzelne Tabelle für das Caching der DID resolution
- sequencer DB: enthält nur eine einzelne Tabelle zur Verwaltung der Reihenfolge aller repo-Updates eines Service
- Jede SQLite-Datei läuft im WAL mode
- Der Zweck des WAL mode ist, gleichzeitige Lesezugriffe und Streaming-Replikation zu ermöglichen
- Die PDS-Distribution soll Litestream oder ein ähnliches Tool enthalten
Review- und Merge-Status
- Dieses PR besteht aus insgesamt 143 Commits und wurde vom Branch
pds-sqlite-refactorin den Branchpds-v2gemergt - Das Merge-Datum ist der 1. November 2023, der Merge-Commit lautet
8449ceb - Der Reviewer devinivy hinterließ mehrere Hinweise und Kommentare und genehmigte dann die Änderungen
- devinivy bewertete das Refactoring so, dass es „viele großartige Vereinfachungen“ enthalte und insgesamt sehr aufgeräumt wirke
- Nach dem Merge wurde der Branch
pds-sqlite-refactorgelöscht
Spätere Frage
- Am 28. Februar 2025 bat npetrangelo nach Sichtung des Umfangs der Änderungen in diesem PR um eine Zusammenfassung der Trade-offs zwischen der früheren Postgres-Architektur und der mit diesem PR eingeführten SQLite-Architektur
- In dem bereitgestellten Text ist keine Antwort von Bluesky auf diese Frage enthalten
1 Kommentare
Meinungen auf Hacker News
Ich mag SQLite, aber ein Ansatz mit separatem Schema oder separater Datenbank pro Tenant ist im Allgemeinen mit vielen Schwierigkeiten verbunden.
Wenn man in einer gemeinsam genutzten Instanz Row-Level Security (RLS) verwendet, kann bei einer fehlgeschlagenen Migration alles vollständig zurückgerollt werden. Bei Schemas pro Tenant hingegen bleiben Nutzer bei einer wegen unerwarteter Daten fehlgeschlagenen Datenmigration auf unterschiedlichen Schema-Versionen, bis die Ursache gefunden ist.
Wenn man die Größenordnung von Sharding erreicht, kann so etwas ohnehin passieren, aber bis dahin ist eine einzelne Datenbank am einfachsten; später muss man Daten möglicherweise zusammenführen oder die Ownership von Ressourcen atomar verschieben.
Ich bin nicht gegen diese Konfiguration, sie hat ihre Einsatzgebiete. Aber in unserem Unternehmen bewegen wir uns mit voller Geschwindigkeit weg von Schemas pro Tenant. Ohne richtige Investitionen gibt es zu viele Probleme, und ich glaube, wenn die Idee zum ersten Mal aufkommt, ist man selten darauf vorbereitet.
Interessant ist, dass unsere App vor etwa 10 Jahren mit SQLite pro Tenant begann, dann zu Schemas pro Tenant in PostgreSQL wechselte und jetzt zu einem einzelnen Schema mit RLS übergeht – also genau in die entgegengesetzte Richtung.
Aus der Perspektive von jemandem, der in Produktion mit riesigen Datenbanken gearbeitet hat: Das möchte ich nie wieder tun.
Wenn die Last groß genug wird, wird jede Änderung riskant, weil man nicht alle Extremfälle der Performance vollständig testen kann.
Ein häufiges Muster ist auch, dass Nutzer im Free Tier einen Codepfad ohne Index finden und damit die Produktion lahmlegen.
Dass bei fehlgeschlagenen Datenmigrationen einige Nutzer auf anderen Schema-Versionen bleiben, muss nicht unbedingt ein großes Problem sein.
Bei einem Dienst, der so groß und komplex ist, führt man Schema-Upgrades normalerweise schrittweise durch: 1. den Code mit dem zukünftigen Schema kompatibel machen, 2. die Daten migrieren, 3. die Unterstützung für das alte Schema entfernen.
Deshalb sollte es normalerweise sicher sein, lange in dem Zustand zwischen Schritt 1 und Schritt 2 zu laufen. Neue Bugs sind natürlich eine Ausnahme, aber aus Betriebssicht finde ich auch Systeme in Ordnung, die in einen Zwischenzustand der Migration zurückfallen, solange man dieses Verfahren verwendet.
Wenn ein Produkt weniger als 100 Kunden hat, kann es sogar gut sein, dass Nutzer auf unterschiedlichen Schema-Versionen sind.
Jeder Kunde kann andere Upgrade-Zeitpläne und Anforderungen haben, und ich kenne Unternehmen, die für einzelne Kunden maßgeschneiderte Arbeit leisten und damit faktisch nicht einmal denselben Code ausführen.
Am Ende hängt es von der Geschäftsstruktur ab.
Fairerweise muss man sagen: Vor 10 Jahren gab es RLS noch nicht. Es kam 2016 mit PostgreSQL 9.5.
https://blog.turso.tech/introducing-embedded-replicas-deploy...
https://electric-sql.com/
Ich weiß nicht, was mit „SQLite unterstützt keine gleichzeitigen Transaktionen“ gemeint ist.
Soweit ich weiß, wird das unterstützt, solange man nicht über eine Dateifreigabe wie UNC oder NFS auf die
.db-Datei zugreift: https://www.sqlite.org/wal.htmlIch habe es genutzt, um von mehreren Threads/Prozessen auf derselben Maschine eine Datenbank zu lesen und zu aktualisieren; wenn man eine konsistente Ansicht braucht oder Transaktionen nicht lange offenhalten will, sind Snapshots auch über die sqlite backup API möglich.
Vielleicht übersehe ich etwas, und ich habe SQLite seit ein paar Jahren nicht mehr angefasst, also bin ich mir nicht sicher.
Stimmt nicht. Ich hatte mich geirrt. Tatsächlich ist es eher mehrere Leser, ein einzelner Schreiber.
Offenbar hatte ich das die ganze Zeit angenommen und nicht gründlich genug geprüft. Allerdings waren die meisten Datenbanken, die ich mit SQLite gebaut habe, eher lese- als schreiblastig.
Korrektur.
Wenn man abwartet, wird hctree [1] stabil werden, und man wird zwischen dem klassischen Backend-Mechanismus und einem neu implementierten Backend mit Concurrency-Unterstützung wählen können.
[1] https://sqlite.org/hctree/doc/hctree/doc/hctree/index.html
Laut Dokumentation hängt der Schreiber neue Inhalte nur ans Ende der WAL-Datei an, daher sind Lesen und Schreiben gleichzeitig möglich. Da es aber nur eine WAL-Datei gibt, kann es gleichzeitig nur einen Schreiber geben.
Ich denke, der Originalbeitrag meint, dass Aktualisierungsoperationen sequenziell ausgeführt werden müssen.
Bei wenig Traffic funktioniert es, aber wenn Transaktionen größer werden oder die Zahl gleichzeitiger Schreibvorgänge steigt, bekommt man trotz aktiviertem WAL irgendwann Probleme mit database locked.
Auf Anwendungsebene kann man das bis zu einem gewissen Grad umgehen, aber im Allgemeinen sollte man ernsthaft über ein anderes Datenbank-Backend nachdenken, wenn man diesen Punkt erreicht.
Gemeint ist wahrscheinlich, dass es zumindest beim letzten Mal, als ich nachgesehen habe, keine Row-Level Locks gibt und Table-Level Locks ebenfalls sehr eingeschränkt sind.
Laut Dokumentation hält ein Schreiber weiterhin einen Lock auf die gesamte Datenbank.
Interessant, und mir gefällt die Strategie, einen Nutzer und eine Datenbank im Verhältnis 1:1 zuzuordnen.
Ich frage mich allerdings, wie Daten behandelt werden, die Aggregation über Nutzer hinweg benötigen. Wenn ich andere Nutzer abonniert habe und einer von ihnen einen Beitrag veröffentlicht, wie wird meine Datenbank mit dem neuen Beitrag aktualisiert? Oder bezieht sich das nur auf persistente Daten wie Profildaten oder Follow-Beziehungen, während interaktive Daten wie Feeds separat gehandhabt werden?
Mir gefällt auch, dass „Connection Pooling“ letztlich nur bedeutet, die Zahl offener Handles mit einem LRU-Cache zu begrenzen. Ebenfalls interessant ist, dass jede DB-Verbindung single-threaded ist und Concurrency auf Ebene der Tenancy statt auf Verbindungsebene behandelt wird.
Darauf ließe sich vermutlich leicht ein Rate Limiting pro Datenbank setzen, um auch Missbrauch durch bestimmte Nutzer zu verhindern.
Ich frage mich außerdem, ob es eine einfache Möglichkeit gibt, Litestream für eine beliebige Anzahl von Datenbanken einzurichten.
Es freut mich immer, wenn ich sehe, dass SQLite/Litestream auf Servern stärker eingesetzt wird. Wir verwenden es ebenfalls beim Bau neuer Apps.
SQLite + Litestream ist eine bessere Wahl für Tenant-Datenbanken, und Replikation sowie Backups nach S3/R2 sind viel günstiger als teure cloudverwaltete Datenbanken [1].
Bis zu 3900 % günstiger als SQLServer auf Azure
[1] https://docs.servicestack.net/ormlite/litestream
Ich verstehe nicht, was 3900 % günstiger bedeuten soll
Bei meinem früheren Fintech-Arbeitgeber speicherte das Unternehmen Kundenkonten als verschlüsselte sqlite3-Dateien im Blob Storage, und das passte ziemlich gut zu den Zugriffsmustern
Von außen betrachtet wirkt es wie eine Kombination aus dem Schlimmsten und Schrecklichsten
Ich wünschte, jemand würde einen guten Artikel schreiben, der die Vorteile mit echten Zahlen erklärt und die zu erwartenden Schwachstellen analysiert. Wenn man es richtig lernt, könnte das ein wirklich interessantes Thema sein
Oberflächlich betrachtet scheint es eine ziemlich vernünftige Wahl zu sein, insbesondere wenn man davon ausgeht, ein verteiltes System zu bauen, das von vielen Nutzern ausgeführt und bereitgestellt wird, die keine professionellen Systemadministratoren sind
Genau das dürfte hier wohl das Ziel sein, und ich würde erwarten, dass es ein Designziel ist, die Notwendigkeit zu vermeiden, eine zusätzliche Datenbank oder einen anderen Server einzurichten, zu konfigurieren und zu verwalten
Ich würde mir wünschen, dass jemand, der Bluesky besser kennt, erklärt, welche Daten in SQLite gespeichert werden und welche nicht
Ich gehe davon aus, dass Dinge wie Nachrichten zwischen Nutzern nicht darunterfallen
Man kann an E-Mail denken. Wenn man eine E-Mail verschickt und fünf Personen in CC setzt, speichern sieben Personen jeweils eine Kopie derselben E-Mail auf ihrem eigenen E-Mail-Server
Es gibt also keine zentrale Datenbank, die eine einzelne E-Mail enthält und auf die andere Leute verweisen
Sharding relationaler Datenbanken funktioniert im Grunde ebenfalls so
Diese Art von Denormalisierung von Daten wird mit zunehmender Skalierung einer Anwendung fast unverzichtbar, besonders bei Many-to-many-Anwendungen mit einem hohen Verhältnis von Lese- zu Schreibzugriffen
Wenn das Verhältnis von Lese- zu Schreibzugriffen niedrig ist, kann eine relationale Datenbankstruktur mit einem einzelnen Master und mehreren Slaves erstaunlich viele Anfragen und Daten bewältigen
Derzeit hostet Bluesky faktisch den einzigen PDS selbst, aber das Endziel ist, dass alle Endnutzer ihren eigenen PDS haben
Inrupt/SOLID nennt dieses Konzept „pod“
Tatsächlich wurde gestern der zweite Produktions-PDS onboarded, es gibt also Fortschritte
Es gibt nur öffentliche Nachrichten, die an die ganze Welt gesendet werden
Ob direkte Nachrichten geplant sind, habe ich nicht weiter recherchiert
Warum hasht man Nutzer mit sha256 und teilt sie in Zielverzeichnisse mit zwei Zeichen auf?
md5 ist viel schneller und löst doch dasselbe Problem, oder?
Der größere Wert liegt darin, nicht die Frage beantworten zu müssen, „warum ein unsicherer Hash verwendet wurde“, und eine ganze Klasse möglicher Sicherheitsprobleme zu beseitigen oder zumindest zu minimieren
Oder sie stecken, wie ich, unter den Sicherheitstools des Unternehmens fest und wollen nicht für jede md5-Nutzung eine eigene Ausnahme erstellen
Wenn man keinen sicheren Hash braucht, gibt es viele schnelle nicht-kryptografische Hashes
Ist Bluesky noch immer nur per Einladung nutzbar?
Es ist eine Methode, um das Wachstum zu begrenzen, während das System im Backend und beim Missbrauchsschutz skaliert wird
Es gibt eine eigene Warteliste für Entwickler, und man kann ziemlich schnell Zugriff bekommen: https://atproto.com/blog/call-for-developers