Wir betreiben ein ganzes Auth-System auf Stores, die nur get und put kennen
Ein Auth-System sieht aus, als wollte es eine relationale Datenbank. Nutzer, Rollen, Sessions, OAuth-Grants, Refresh Tokens, alles quer referenziert. Unseres nutzt keine. Es läuft auf Azure Table Storage, einem Store, der dir einen Partition Key, einen Row Key und fast nichts anderes gibt. Keine Joins, keine sinnvollen Secondary Indexes, keinen Increment-Operator, keine zeilenübergreifenden Transaktionen in der Form, die du gewohnt bist. Das war eine Entscheidung. Hier ist, was diese Entscheidung einbringt, die Patterns, die es funktionieren lassen, und der Teil, an dem SQL tatsächlich einfacher gewesen wäre.
Der Grund dafür ist Kosten und betriebliche Einfachheit. Kein Connection Pool, der erschöpft werden kann, keine Datenbankinstanz, die du dimensionieren, patchen oder umschalten musst, Skalierung pro Partition, die du gratis bekommst. Eine Tabelle kostet fast nichts im Betrieb, und es gibt nichts, was ausgehen kann. Der Preis dafür ist ein Store ohne Query Planner, der sich nur auszahlt, wenn deine Zugriffsmuster so vorhersehbar sind, wie der Store dumm ist. Bei Auth sind sie genau das, also setzt das gesamte Design voll darauf.
Der Key erledigt die Arbeit, die sonst ein Tabellen-Layout leisten würde. Ohne Joins entwirfst du den Key so, dass die Query der Key ist. Du denormalisierst mit Absicht, du wählst Partitionen so, dass deine heißen Reads Point-Gets sind, und du kodierst zusammengesetzte Identitäten direkt in den Row Key, zum Beispiel "{pk}|{rk}" für Einträge, die eine zusammengesetzte Identität in einem Store brauchen, der nur einen einzigen Row-Key-Slot bietet. Die Frage "wie schlage ich das nach" muss beantwortet werden, wenn du den Key entwirfst, nicht zur Query-Zeit. Für Auth ist das in Ordnung, denn die Zugriffsmuster sind bekannt und sie driften nicht: hol einen Nutzer per ID, hol die Grants eines Nutzers, verbrauche einen Code. Du erkundest die Daten nicht. Du kennst jede Frage im Voraus.
Du führst dein eigenes Change Log. Jede Zeile trägt einen vom Server verwalteten Timestamp, und es ist verlockend, darauf ein inkrementelles Backup zu bauen: hol alles, wo Timestamp gt watermark. Das funktioniert in einer Demo und stirbt in der Produktion, denn der Store indexiert den Partition Key und den Row Key und sonst nichts. Timestamp steht in keinem Index, also inspiziert ein Filter darauf jede Zeile in der Tabelle. Das ist ein Full Scan im Kostüm einer Query, und er wird mit jedem einzelnen Tag langsamer, an dem die Tabelle wächst. Also schreibt der Store stattdessen sein eigenes Change Log. Jede Mutation, jeder Upsert und jedes Delete, hängt eine Zeile an eine Change-Log-Tabelle an, die per logischem Tabellennamen indexiert ist, mit dem zusammengesetzten "{pk}|{rk}" als Row Key und einem Flag dafür, ob die Zeile geschrieben oder gelöscht wurde. Jetzt ist "was hat sich seit dem Watermark geändert" ein Read einer einzigen kleinen, indexierten Partition statt eines Scans der ganzen Tabelle, und das Backup liest per Point-Read nur die Zeilen, die sich tatsächlich bewegt haben. Du baust Change-Data-Capture aus gewöhnlichen Writes wieder auf, und es skaliert mit dem Churn statt mit der Größe der Tabelle.
Nebenläufigkeit ohne Transaktionen. Es gibt hier kein SELECT ... FOR UPDATE. Was du stattdessen bekommst, ist ein einziges atomares Primitiv: der bedingte Write, den du als ETag in die Hand bekommst. Du schreibst eine Zeile nur, wenn die Kopie, die du gelesen hast, sich nicht unter dir verändert hat. Alles, was Sicherheit unter nebenläufigem Zugriff braucht, wird als Optimistic Concurrency plus Retry bei Konflikt ausgedrückt. Das klarste Beispiel ist der Zähler für die Kontosperre. AccessFailedCount muss atomar hochzählen, aber der Store hat kein Increment. Also liest du die Zeile, erhöhst den Zähler, schreibst sie bedingt auf den gelesenen ETag zurück und versuchst es erneut, wenn dir jemand zuvorgekommen ist. Diese Schleife ist die Art, wie du auf einem Store, der keinen Zähler hat, einen Zähler atomar machst. Feuere fünfzig falsche Passwörter auf einmal ab, und jedes einzelne davon kommt an, weil der Konflikt erkannt und wiederholt statt verloren wird.
Ein Delete kann ein Lock sein. Einmalverbrauch, ein Authorization Code oder ein Einmal-Token, das genau einmal eingelöst werden muss, ist ein bedingtes Delete. Es ist ein ETag-bedingtes Delete: gelingt das Delete, hast du das Rennen gewonnen und darfst weitermachen; scheitert es, weil die Zeile schon weg war, hat jemand anderes sie zuerst verbraucht und du hörst auf. Das Delete ist das Lock. Dieselbe Idee erledigt Leader Election, gebaut auf einem Blob Lease, ein Lock aus einem atomaren Write statt aus einem separaten Koordinationsdienst. Du brauchst fast nie einen Lock-Dienst, wenn der Write selbst atomar ist.
Deletes müssen erstklassig sein, was zur Hälfte erklärt, warum das Change Log existiert. Ein Key-Value-Store hat keinen Delete-Marker: eine gelöschte Zeile verschwindet einfach, sodass alles, was nach Änderungen scannt, nie auch nur sehen könnte, dass sie gegangen ist. Ein Backup, das sich an Zeilen-Timestamps orientiert, würde den gelöschten Nutzer klammheimlich für immer mitschleppen. Das Change Log erfasst das Delete als Delete, sodass ein Restore es abspielt, statt es wiederauferstehen zu lassen. Dieser Fehlermodus, ein Backup, das getreulich jeden zurückbringt, den du entfernt hast, ist eine ganze Geschichte für sich und verdient einen eigenen Beitrag, aber das Pattern gehört auf diese Liste: in einem Store ohne Delete-Log führst du das Delete-Log selbst.
Jetzt die andere Seite der Bilanz, das, was du aufgibst. Du gibst Ad-hoc-Queries auf: ein wirklich neues Zugriffsmuster kann ein neues Key-Design oder einen Full Scan bedeuten, denn es gibt keinen Query Planner, der dich rettet. Du gibst Joins auf, also pflegst du denormalisierte Kopien von Hand und trägst die Verantwortung für deren Konsistenz. Du gibst zeilenübergreifende Transaktionen auf, also entwirfst du jede Invariante so, dass sie innerhalb eines einzigen Items lebt, denn Einzel-Item-Atomarität ist alles, was du bekommst. Wenn deine Daten tief relational sind oder deine Zugriffsmuster unbekannt und noch in Bewegung, ist dies das falsche Werkzeug, und es wird wehtun.
Und genau deshalb passt es zu Auth. Eine relationale Datenbank rechtfertigt ihren Unterhalt, indem sie Fragen beantwortet, an die du noch nicht gedacht hast. Ein Auth-System hat diese Fragen nicht. Die Menge dessen, was du fragst, hol diesen Nutzer, verbrauche diesen Code, wähle diesen Leader, sichere, was sich geändert hat, ist klein, bekannt und stabil. Gib den Query Planner auf, den du ohnehin nie benutzt hättest, und im Gegenzug bekommst du eine Storage-Schicht, die fast nichts im Betrieb kostet und bei der es nichts umzuschalten gibt. Für die meiste Software ist das ein schlechter Tausch. Für dieses hier ist es der richtige.
Diese Patterns sind die Storage-Engine unter Authagonal: ein Key-Value-Design, das fast nichts im Betrieb kostet, was ein großer Teil davon ist, wie wir jedes Feature in jeden Plan packen.