Konsistente Backends und UX: Was kann schiefgehen?

Avatar of Brecht De Rooms
Brecht De Rooms am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Im vorherigen Artikel haben wir erklärt, was starke Konsistenz (vs. eventuale Konsistenz) ist. Dieser Artikel ist der zweite Teil einer Serie, in der wir erklären, wie ein Mangel an starker Konsistenz die Bereitstellung einer guten Endbenutzererfahrung erschwert, erheblichen Engineering-Aufwand mit sich bringen kann und Sie anfällig für Exploits macht. Dieser Teil ist länger, da wir verschiedene Datenbankanomalien erklären, mehrere Beispielszenarien durchgehen und kurz hervorheben, welche Art von Datenbank von jeder Anomalie betroffen ist.

Die Benutzererfahrung ist der treibende Faktor für den Erfolg jeder App, und die Abhängigkeit von einem inkonsistenten Backend kann die Herausforderung, eine gute Erfahrung zu bieten, erhöhen. Wichtiger noch: Das Aufbauen von Anwendungslogik auf inkonsistenten Daten kann zu Exploits führen. Ein Paper nennt diese Art von Angriffen "ACIDrain". Sie untersuchten 12 der beliebtesten selbst gehosteten E-Commerce-Anwendungen und identifizierten mindestens 22 mögliche kritische Angriffe. Eine Website war ein Bitcoin-Wallet-Dienst, der aufgrund dieser Angriffe abgeschaltet werden musste. Wenn Sie sich für eine verteilte Datenbank entscheiden, die nicht 100% ACID ist, lauern Gefahren. Wie in einem unserer früheren Beispiele erklärt, ist es für einen Ingenieur aufgrund von Fehlinterpretationen, schlecht definierten Terminologien und aggressivem Marketing sehr schwierig festzustellen, welche Garantien eine bestimmte Datenbank bietet. 

Welche Gefahren? Ihre App könnte Probleme aufweisen wie falsche Kontostände, nicht erhaltene Benutzerbelohnungen, doppelt ausgeführte Transaktionen, Nachrichten, die außer Reihenfolge erscheinen, oder verletzte Anwendungsregeln. Für eine kurze Einführung, warum verteilte Datenbanken notwendig und schwierig sind, verweisen wir auf unseren ersten Artikel oder diese hervorragende Videoerklärung. Kurz gesagt, eine verteilte Datenbank ist eine Datenbank, die Kopien Ihrer Daten an mehreren Orten für Skalierbarkeit, Latenz und Verfügbarkeit speichert.

Wir werden vier dieser potenziellen Probleme durchgehen (es gibt mehr) und sie mit Beispielen aus der Spieleentwicklung veranschaulichen. Die Spieleentwicklung ist komplex und diese Entwickler stehen vor vielen Problemen, die ernsten realen Problemen stark ähneln. Ein Spiel hat Handelssysteme, Nachrichtensysteme, Belohnungen, die erfüllte Bedingungen erfordern, usw. Erinnern Sie sich, wie wütend (oder glücklich 🤨) Spieler sein können, wenn Dinge schiefgehen oder schiefzugehen scheinen. In Spielen ist die Benutzererfahrung alles, daher stehen Spieleentwickler oft unter enormem Druck, sicherzustellen, dass ihre Systeme fehlertolerant sind. 

Bereit? Tauchen wir ein in das erste potenzielle Problem!

1. Veraltete Lesevorgänge

Veraltete Lesevorgänge sind Lesevorgänge, die alte Daten zurückgeben, oder mit anderen Worten, Daten, die Werte zurückgeben, die noch nicht aktualisiert sind gemäß den neuesten Schreibvorgängen. Viele verteilte Datenbanken, einschließlich traditioneller Datenbanken, die mit Replikaten skaliert werden (lesen Sie Teil 1, um zu erfahren, wie diese funktionieren), leiden unter veralteten Lesevorgängen.

Auswirkungen auf Endbenutzer

Erstens können veraltete Lesevorgänge Endbenutzer beeinträchtigen. Und das ist keine einzelne Auswirkung.

Frustrierende Erlebnisse und unfaire Vorteile

Stellen Sie sich ein Szenario vor, in dem zwei Benutzer in einem Spiel auf eine Schatzkiste mit Gold stoßen. Der erste Benutzer erhält die Daten von einem Datenbankserver, während der zweite mit einem zweiten Datenbankserver verbunden ist. Die Reihenfolge der Ereignisse ist wie folgt:

  1. Benutzer 1 (über Datenbankserver 1) sieht und öffnet die Truhe, holt das Gold.
  2. Benutzer 2 (über Datenbankserver 2) sieht eine volle Truhe, öffnet sie und scheitert. 
  3. Benutzer 2 sieht immer noch eine volle Truhe und versteht nicht, warum es fehlschlägt. 

Obwohl dies wie ein kleines Problem erscheint, ist das Ergebnis eine frustrierende Erfahrung für den zweiten Spieler. Er hatte nicht nur einen Nachteil, sondern wird auch oft Situationen im Spiel sehen, in denen Dinge vorhanden zu sein scheinen, aber nicht sind. Als Nächstes betrachten wir ein Beispiel, bei dem der Spieler aufgrund eines veralteten Lesevorgangs handelt!  

Veraltete Lesevorgänge führen zu duplizierten Schreibvorgängen

Stellen Sie sich eine Situation vor, in der ein Charakter im Spiel versucht, in einem Geschäft einen Schild und ein Schwert zu kaufen. Wenn es mehrere Speicherorte für die Daten gibt und kein intelligentes System zur Gewährleistung der Konsistenz vorhanden ist, enthält ein Knoten ältere Daten als ein anderer. In diesem Fall kauft der Benutzer möglicherweise die Artikel (was den ersten Knoten kontaktiert) und überprüft dann sein Inventar (was den zweiten Knoten kontaktiert), nur um festzustellen, dass sie nicht da sind. Der Benutzer wird wahrscheinlich verwirrt sein und denken, dass die Transaktion nicht erfolgreich war. Was würden die meisten Leute in diesem Fall tun? Nun, sie versuchen, den Artikel erneut zu kaufen. Sobald der zweite Knoten aufgeholt hat, hat der Benutzer bereits ein Duplikat gekauft, und sobald die Replik aufgeholt hat, stellt er plötzlich fest, dass er kein Geld mehr hat und jeweils zwei Artikel. Ihm bleibt die Wahrnehmung, dass unser Spiel kaputt ist. 

Beispiel für einen Benutzer, der aufgrund der eventuellen Konsistenz dieselbe Transaktion zweimal anfordert
(t1)Ein Spieler kauft einen Schild und ein Schwert. Diese Kauftransaktion wird auf dem Master-Knoten zugesagt.
(r1)Der Spieler lädt sein Inventar, aber der Lesevorgang trifft auf Replik1. Da (t1) noch nicht repliziert ist, sieht er seine Gegenstände nicht.
(rt1)Die erste Transaktion wird repliziert, aber zu spät, um Auswirkungen auf (r1) zu haben.
(t2)Der Spieler denkt, sein Kaufversuch sei fehlgeschlagen und kauft den Schild und das Schwert erneut. 
(rt2)Die zweite Transaktion wird repliziert.
(r2)Der Spieler lädt sein Inventar und sieht nun, dass er zwei Schilde, zwei Schwerter und fast kein Gold mehr hat.

In diesem Fall hat der Benutzer Ressourcen ausgegeben, die er nicht ausgeben wollte. Wenn wir einen E-Mail-Client auf einer solchen Datenbank aufbauen, könnte ein Benutzer versuchen, eine E-Mail zu senden, dann den Browser aktualisieren und die gerade gesendete E-Mail nicht abrufen können und sie daher erneut senden. Die Bereitstellung einer guten Benutzererfahrung und die Implementierung sicherer Transaktionen wie Banktransaktionen auf einem solchen System sind notorisch schwierig. 

Auswirkungen auf Entwickler

Beim Programmieren müssen Sie immer damit rechnen, dass etwas (noch) nicht da ist und entsprechend programmieren. Wenn Lesevorgänge eventuell konsistent sind, wird das Schreiben fehlersicherer Codes sehr schwierig, und es besteht die Gefahr, dass Benutzer Probleme in Ihrer Anwendung haben. Wenn Lesevorgänge eventuell konsistent sind, sind diese Probleme verschwunden, bis Sie sie untersuchen können. Im Grunde jagen Sie Geistern nach. Entwickler wählen immer noch oft Datenbanken oder Verteilungsansätze, die eventuell konsistent sind, da es oft Zeit braucht, die Probleme zu bemerken. Dann, sobald die Probleme in ihrer Anwendung auftreten, versuchen sie, kreativ zu werden und Lösungen (1, 2) auf ihrer traditionellen Datenbank aufzubauen, um die veralteten Lesevorgänge zu beheben. Die Tatsache, dass es viele Anleitungen wie diese gibt und dass Datenbanken wie Cassandra einige Konsistenzfunktionen implementiert haben, zeigt, dass diese Probleme real sind und in Produktionssystemen häufiger auftreten, als Sie vielleicht denken. Benutzerdefinierte Lösungen auf einem System, das nicht auf Konsistenz ausgelegt ist, sind sehr komplex und fehleranfällig. Warum sollte man sich solch eine Mühe machen, wenn es Datenbanken gibt, die sofortige Konsistenz "out-of-the-box" bieten? 

Datenbanken, die diese Anomalie aufweisen

Traditionelle Datenbanken (PostgreSQL, MySQL, SQL Server usw.), die Master-Read-Replikation verwenden, leiden typischerweise unter veralteten Lesevorgängen. Viele neuere verteilte Datenbanken begannen ebenfalls als eventuell konsistent oder, mit anderen Worten, ohne Schutz vor veralteten Lesevorgängen. Dies lag an einem starken Glauben in der Entwicklergemeinschaft, dass dies zur Skalierung notwendig sei. Die bekannteste Datenbank, die so begann, ist Cassandra, aber Cassandra erkannte, wie ihre Benutzer mit dieser Anomalie zu kämpfen hatten, und hat seitdem zusätzliche Maßnahmen zur Vermeidung implementiert. Ältere Datenbanken oder Datenbanken, die nicht darauf ausgelegt sind, starke Konsistenz effizient bereitzustellen, wie Cassandra, CouchDB und DynamoDB, sind standardmäßig eventuell konsistent. Andere Ansätze wie Riak sind ebenfalls eventuell konsistent, verfolgen aber einen anderen Weg, indem sie ein Konfliktlösungs-System implementieren, um die Wahrscheinlichkeit veralteter Werte zu reduzieren. Dies garantiert jedoch nicht, dass Ihre Daten sicher sind, da die Konfliktlösung nicht ausfallsicher ist. 

2. Verlorene Schreibvorgänge

Im Bereich der verteilten Datenbanken gibt es eine wichtige Entscheidung, die getroffen werden muss, wenn Schreibvorgänge gleichzeitig stattfinden. Eine Option (die sichere) ist sicherzustellen, dass alle Datenbankknoten die Reihenfolge dieser Schreibvorgänge vereinbaren können. Dies ist alles andere als trivial, da es entweder synchronisierte Uhren erfordert, wofür spezielle Hardware benötigt wird, oder einen intelligenten Algorithmus wie Calvin, der nicht auf Uhren angewiesen ist. Die zweite, weniger sichere Option ist, jedem Knoten zu erlauben, lokal zu schreiben und dann später zu entscheiden, was mit den Konflikten geschehen soll. Datenbanken, die die zweite Option wählen, können Ihre Schreibvorgänge verlieren. 

Zwei Datenbankoptionen: Konflikte durch Reihenfolge von Transaktionen vermeiden oder Konflikte zulassen und sie lösen.

Auswirkungen auf Endbenutzer

Betrachten Sie zwei Handelsgeschäfte in einem Spiel, bei dem wir mit 11 Goldstücken beginnen und zwei Gegenstände kaufen. Zuerst kaufen wir ein Schwert für 5 Goldstücke und dann ein Schild für fünf Goldstücke, und beide Transaktionen werden an verschiedene Knoten unserer verteilten Datenbank gerichtet. Jeder Knoten liest den Wert, der in diesem Fall für beide Knoten immer noch 11 ist. Beide Knoten entscheiden, 6 als Ergebnis zu schreiben (11- 5), da sie von keiner Replikation wissen. Da die zweite Transaktion den Wert des ersten Schreibvorgangs noch nicht sehen konnte, kauft der Spieler am Ende beide Schwerter und Schilde für insgesamt fünf Goldstücke anstelle von 10. Gut für den Benutzer, aber nicht so gut für das System! Um ein solches Verhalten zu beheben, haben verteilte Datenbanken verschiedene Strategien – einige besser als andere.

Auswirkungen verlorener Schreibvorgänge auf Benutzer. In diesem Fall kauft der Benutzer erfolgreich zwei Artikel und bezahlt nur einmal.

Auflösungsstrategien umfassen "last write wins" (LWW) oder "longest version history" (LVH) wins. LWW war lange Zeit die Strategie von Cassandra und ist immer noch das Standardverhalten, wenn Sie es nicht anders konfigurieren. 

Wenn wir die LWW-Konfliktlösung auf unser vorheriges Beispiel anwenden, hat der Spieler immer noch 6 Gold übrig, hat aber nur einen Gegenstand gekauft. Dies ist eine schlechte Benutzererfahrung, da die Anwendung den Kauf des zweiten Gegenstands bestätigt hat, obwohl die Datenbank ihn nicht als in seinem Inventar befindlich anerkennt.

Beispiel für einfache Konfliktlösung. Zwei Transaktionen auf verschiedenen Knoten ändern gleichzeitig den Goldbetrag. Die Schreibvorgänge gehen zunächst durch, aber wenn die beiden Knoten kommunizieren, wird der Konflikt offensichtlich. Die Konfliktlösungsstrategie hier ist, eine der Transaktionen abzubrechen. Der Benutzer kann das System nicht mehr ausnutzen, aber gelegentlich gehen Schreibvorgänge verloren.
Unvorhersehbare Sicherheit

Wie Sie sich vorstellen können, ist es unsicher, Sicherheitsregeln auf einem solchen System aufzubauen. Viele Anwendungen verlassen sich auf komplexe Sicherheitsregeln im Backend (oder direkt in der Datenbank, wo möglich), um zu bestimmen, ob ein Benutzer auf eine Ressource zugreifen kann oder nicht. Wenn diese Regeln auf veralteten, unzuverlässig aktualisierten Daten basieren, wie können wir sicher sein, dass es nie zu einem Einbruch kommt? Stellen Sie sich vor, ein Benutzer einer PaaS-Anwendung ruft seinen Administrator an und fragt: "Könnten Sie diese öffentliche Gruppe privat machen, damit wir sie für interne Daten wiederverwenden können?" Der Administrator wendet die Aktion an und teilt ihm mit, dass es erledigt ist. Da der Administrator und der Benutzer jedoch möglicherweise auf verschiedenen Knoten sind, kann der Benutzer beginnen, sensible Daten zu einer Gruppe hinzuzufügen, die technisch immer noch öffentlich ist.  

Auswirkungen auf Entwickler

Wenn Schreibvorgänge verloren gehen, ist die Fehlersuche bei Benutzerproblemen ein Albtraum. Stellen Sie sich vor, ein Benutzer meldet, dass er Daten in Ihrer Anwendung verloren hat, und dann vergeht ein Tag, bevor Sie Zeit haben, zu reagieren. Wie werden Sie versuchen herauszufinden, ob das Problem von Ihrer Datenbank oder von fehlerhafter Anwendungslogik verursacht wurde? In einer Datenbank, die die Verfolgung der Datenhistorie ermöglicht, wie FaunaDB oder Datomic, könnten Sie in der Zeit zurückreisen, um zu sehen, wie die Daten manipuliert wurden. Keine dieser Datenbanken ist jedoch anfällig für verlorene Schreibvorgänge, und Datenbanken, die von dieser Anomalie betroffen sind, verfügen typischerweise nicht über die Zeitreisefunktion. 

Datenbanken, die unter verlorenen Schreibvorgängen leiden

Alle Datenbanken, die Konfliktlösung anstelle von Konfliktvermeidung verwenden, verlieren Schreibvorgänge. Cassandra und DynamoDB verwenden standardmäßig "last write wins" (LWW); MongoDB verwendete LWW, hat sich aber seitdem davon entfernt. Die Master-Master-Verteilungsansätze in traditionellen Datenbanken wie MySQL bieten unterschiedliche Konfliktlösungsstrategien. Viele verteilte Datenbanken, die nicht auf Konsistenz ausgelegt waren, leiden unter verlorenen Schreibvorgängen. Riaks einfachste Konfliktlösung wird von LWW gesteuert, aber sie implementieren auch intelligentere Systeme. Aber selbst mit intelligenten Systemen gibt es manchmal keinen offensichtlichen Weg, einen Konflikt zu lösen. Riak und CouchDB legen die Verantwortung für die Auswahl des richtigen Schreibvorgangs auf den Client oder die Anwendung, damit diese manuell wählen können, welche Version sie behalten möchten. 

Da die Verteilung komplex ist und die meisten Datenbanken unvollkommene Algorithmen verwenden, sind verlorene Schreibvorgänge in vielen Datenbanken üblich, wenn Knoten ausfallen oder Netzwerkteilungen auftreten. Sogar MongoDB, das keine Schreibvorgänge verteilt (Schreibvorgänge gehen zu einem Knoten), kann in dem seltenen Fall eines Knotenausfalls unmittelbar nach einem Schreibvorgang Schreibkonflikte aufweisen. 

3. Write Skew (Schreibverzerrung)

Write Skew ist etwas, das bei einer Art von Garantie passieren kann, die Datenbankanbieter Snapshot-Konsistenz nennen. Bei der Snapshot-Konsistenz liest die Transaktion aus einem Snapshot, der zum Zeitpunkt des Transaktionsstarts aufgenommen wurde. Die Snapshot-Konsistenz verhindert viele Anomalien. Tatsächlich dachten viele, sie sei völlig sicher, bis Papiere (PDF) erschienen, die das Gegenteil bewiesen. Daher ist es keine Überraschung, dass Entwickler Schwierigkeiten haben zu verstehen, warum bestimmte Garantien einfach nicht gut genug sind. 

Bevor wir besprechen, was bei der Snapshot-Konsistenz nicht funktioniert, wollen wir zunächst besprechen, was funktioniert. Stellen Sie sich vor, wir haben einen Kampf zwischen einem Ritter und einem Magier, deren jeweilige Lebenskräfte jeweils vier Herzen betragen. 

Wenn einer der Charaktere angegriffen wird, ist die Transaktion eine Funktion, die berechnet, wie viele Herzen entfernt wurden.

damageCharacter(character, damage) {
  character.hearts = character.hearts - damage
  character.dead = isCharacterDead(character)
}

Und nach jedem Angriff läuft auch eine weitere isCharacterDead-Funktion, um zu sehen, ob der Charakter noch Herzen hat.

isCharacterDead(character) {
  if ( character.hearts <= 0 ) { return true }
  else { return false }
}

In einer trivialen Situation entfernt der Schlag des Ritters drei Herzen vom Magier, und dann entfernt der Zauber des Magiers vier Herzen vom Ritter, wodurch seine eigenen Lebenspunkte wieder auf vier steigen. Diese beiden Transaktionen würden in den meisten Datenbanken korrekt funktionieren, wenn eine Transaktion nach der anderen ausgeführt wird.

Aber was, wenn wir eine dritte Transaktion hinzufügen, einen Angriff des Ritters, der gleichzeitig mit dem Zauber des Magiers ausgeführt wird?

Beispiel für zwei Transaktionen (Life Leech und der zweite Powerful Strike), die über den Ausgang des Kampfes entscheiden. Was wäre das Ergebnis in einem System, das Snapshot-Konsistenz bietet? Um das zu wissen, müssen wir die Regel "First Committer Wins" kennenlernen. 

Ist der Ritter tot und der Magier am Leben? 

Um mit dieser Verwirrung umzugehen, implementieren Snapshot-Konsistenzsysteme typischerweise eine Regel namens "Der erste, der committed, gewinnt" (first committer wins). Eine Transaktion kann nur abgeschlossen werden, wenn eine andere Transaktion nicht bereits in dieselbe Zeile geschrieben hat, andernfalls wird sie zurückgerollt. In diesem Beispiel würde nur der Life Leech-Zauber funktionieren, da beide Transaktionen versuchten, in dieselbe Zeile zu schreiben (die Gesundheit des Magiers), und der zweite Schlag des Ritters zurückgerollt würde. Das Endergebnis wäre dann dasselbe wie im vorherigen Beispiel: ein toter Ritter und ein Magier mit vollen Herzen.

Einige Datenbanken wie MySQL und InnoDB betrachten "first committer wins" jedoch nicht als Teil einer Snapshot-Isolation. In solchen Fällen hätten wir einen verlorenen Schreibvorgang: Der Magier ist jetzt tot, obwohl er die Gesundheit vom Life Leech erhalten hätte, bevor der Schlag des Ritters wirksam wurde. (Wir haben bereits von schlecht definierten Terminologien und losen Interpretationen gesprochen, oder?)

Snapshot-Konsistenz, die die Regel "first committer wins" einschließt, handhabt einige Dinge gut, was nicht überraschend ist, da sie lange Zeit als gute Lösung galt. Dies ist immer noch der Ansatz von PostgreSQL, Oracle und SQL Server, aber sie haben alle unterschiedliche Namen dafür. PostgreSQL nennt diese Garantie "repeatable read", Oracle nennt sie "serializable" (was unserer Definition nach falsch ist), und SQL Server nennt sie "snapshot isolation". Kein Wunder, dass Leute in diesem Begriffsdschungel verloren gehen. Sehen wir uns Beispiele an, bei denen es sich nicht so verhält, wie Sie es erwarten würden!

Auswirkungen auf Endbenutzer

Der nächste Kampf wird zwischen zwei Armeen stattfinden, und eine Armee gilt als tot, wenn alle Charaktere der Armee tot sind.

isArmyDead(army){
  if (<all characters are dead>) { return true }
  else { return false }
}

Nach jedem Angriff ermittelt die folgende Funktion, ob ein Charakter gestorben ist, und führt dann die obige Funktion aus, um zu sehen, ob die Armee gestorben ist.

damageArmyCharacter(army, character, damage){
  character.hearts = character.hearts - damage
  character.dead = isCharacterDead(character)
  armyDead = isArmyDead(army)
  if (army.dead !=  armyDead){
    army.dead = armyDead
  }
}

Zuerst werden die Herzen des Charakters durch den erhaltenen Schaden reduziert. Dann überprüfen wir, ob die Armee tot ist, indem wir prüfen, ob jeder Charakter keine Herzen mehr hat. Dann, wenn sich der Zustand der Armee geändert hat, aktualisieren wir das 'dead'-Boolean der Armee. 

Beispiel für Write Skew, eine Anomalie, die in Datenbanken auftreten kann, die Snapshot-Konsistenz bieten.

Es gibt drei Magier, die jeweils einmal angreifen, was zu drei "Life Leech"-Transaktionen führt. Snapshots werden zu Beginn der Transaktionen aufgenommen, da alle Transaktionen gleichzeitig starten, sind die Snapshots identisch. Jede Transaktion hat eine Kopie der Daten, bei denen alle Ritter noch volle Gesundheit haben. 

Sehen wir uns an, wie die erste "Life Leech"-Transaktion gelöst wird. Bei dieser Transaktion greift Magier1 Ritter1 an, und der Ritter verliert 4 Lebenspunkte, während der angreifende Magier volle Gesundheit zurückgewinnt. Die Transaktion entscheidet, dass die Ritterarmee nicht tot ist, da sie nur einen Snapshot sehen kann, bei dem zwei Ritter noch volle Gesundheit haben und ein Ritter tot ist. Die anderen beiden Transaktionen agieren auf einem anderen Magier und Ritter, schreiten aber auf ähnliche Weise voran. Jede dieser Transaktionen hatte ursprünglich drei lebende Ritter in ihrer Datenkopie und sah nur einen sterbenden Ritter. Daher entscheidet jede Transaktion, dass die Ritterarmee noch am Leben ist.

Wenn alle Transaktionen abgeschlossen sind, ist keiner der Ritter mehr am Leben, aber unser Boolean, der anzeigt, ob die Armee tot ist, ist immer noch auf falsch gesetzt. Warum? Weil zum Zeitpunkt der Aufnahme der Snapshots keiner der Ritter tot war. Also sah jede Transaktion seinen eigenen Ritter sterben, hatte aber keine Ahnung von den anderen Rittern in der Armee. Obwohl dies eine Anomalie in unserem System ist (die als Write Skew bezeichnet wird), sind die Schreibvorgänge durchgegangen, da sie jeweils auf einen anderen Charakter geschrieben haben und der Schreibvorgang auf die Armee nie geändert wurde. Cool, wir haben jetzt eine Geisterarmee!

Auswirkungen auf Entwickler

Datenqualität

Was, wenn wir sicherstellen wollen, dass Benutzer eindeutige Namen haben? Unsere Transaktion zum Erstellen eines Benutzers prüft, ob ein Name existiert; wenn nicht, schreiben wir einen neuen Benutzer mit diesem Namen. Wenn jedoch zwei Benutzer versuchen, sich mit demselben Namen anzumelden, bemerkt der Snapshot nichts, da die Benutzer in verschiedene Zeilen geschrieben werden und daher nicht kollidieren. Wir haben jetzt zwei Benutzer mit demselben Namen in unserem System.

Es gibt zahlreiche weitere Beispiele für Anomalien, die aufgrund von Write Skew auftreten können. Wenn Sie interessiert sind, beschreibt Martin Kleppmans Buch "Designing Data-Intensive Applications" mehr.

Anders programmieren, um Rückbuchungen zu vermeiden

Betrachten wir nun einen anderen Ansatz, bei dem ein Angriff nicht auf einen bestimmten Charakter in der Armee gerichtet ist. In diesem Fall ist die Datenbank dafür verantwortlich, auszuwählen, welcher Ritter zuerst angegriffen werden soll.

damageArmy(army, damage){
  character = getFirstHealthyCharacter(knight)
  character.hearts = character.hearts - damage
  character.dead = isCharacterDead(character)
  // ...
}

Wenn wir mehrere Angriffe parallel ausführen, wie in unserem vorherigen Beispiel, zielt getFirstHealthyCharacter immer auf denselben Ritter, was dazu führen würde, dass mehrere Transaktionen auf dieselbe Zeile schreiben. Dies würde durch die Regel "first committer wins" blockiert, die die anderen beiden Angriffe zurückbucht. Obwohl dies eine Anomalie verhindert, muss der Entwickler diese Probleme verstehen und kreativ darum herum programmieren. Aber wäre es nicht einfacher, wenn die Datenbank das für Sie "out-of-the-box" erledigen würde? 

Datenbanken, die unter Write Skew leiden

Jede Datenbank, die Snapshot-Isolation anstelle von Serialisierbarkeit bietet, kann unter Write Skew leiden. Eine Übersicht über Datenbanken und ihre Isolationsstufen finden Sie in diesem Artikel.

4. Außer der Reihenfolge liegende Schreibvorgänge

Um verlorene Schreibvorgänge und veraltete Lesevorgänge zu vermeiden, streben verteilte Datenbanken nach etwas, das als "starke Konsistenz" bezeichnet wird. Wir haben erwähnt, dass Datenbanken entweder eine globale Reihenfolge vereinbaren (die sichere Wahl) oder Konflikte lösen können (die Wahl, die zu verlorenen Schreibvorgängen führt). Wenn wir uns für eine globale Reihenfolge entscheiden, würde dies bedeuten, dass, obwohl das Schwert und der Schild parallel gekauft werden, das Endergebnis so aussehen sollte, als ob wir zuerst das Schwert und dann den Schild gekauft hätten. Dies wird auch oft als "Linearisierbarkeit" bezeichnet, da Sie die Datenbankmanipulationen linearisieren können. Lineariserbarkeit ist der Goldstandard, um sicherzustellen, dass Ihre Daten sicher sind. 

Verschiedene Anbieter bieten unterschiedliche Isolationsstufen an, die Sie hier vergleichen können: hier. Ein Begriff, der oft vorkommt, ist Serialisierbarkeit, die eine etwas weniger strenge Version der starken Konsistenz (oder Linearisierbarkeit) ist. Serialisierbarkeit ist bereits recht stark und deckt die meisten Anomalien ab, lässt aber immer noch Raum für eine sehr subtile Anomalie aufgrund von Schreibvorgängen, die neu geordnet werden. In diesem Fall ist die Datenbank frei, diese Reihenfolge auch nach Abschluss der Transaktion zu ändern. Lineariserbarkeit in einfachen Worten ist Serialisierbarkeit plus eine garantierte Reihenfolge. Wenn der Datenbank diese garantierte Reihenfolge fehlt, ist Ihre Anwendung anfällig für außer der Reihe liegende Schreibvorgänge. 

Auswirkungen auf Endbenutzer

Neuanordnung von Konversationen

Konversationen können auf verwirrende Weise neu angeordnet werden, wenn jemand versehentlich eine zweite Nachricht sendet.

Neuanordnung von Benutzeraktionen

Wenn unser Spieler 11 Münzen hat und einfach Gegenstände in der Reihenfolge ihrer Wichtigkeit kauft, ohne aktiv den Betrag seiner Goldmünzen zu überprüfen, kann die Datenbank diese Kaufaufträge neu ordnen. Wenn er nicht genug Geld hätte, hätte er den Gegenstand von geringster Bedeutung zuerst kaufen können.

In diesem Fall gab es eine Datenbankprüfung, die verifizierte, ob wir genug Gold haben. Stellen Sie sich vor, wir hätten nicht genug Geld und es würde uns Geld kosten, das Konto unter Null fallen zu lassen, so wie eine Bank Ihnen Überziehungsgebühren berechnet, wenn Sie unter Null fallen. Sie könnten einen Gegenstand schnell verkaufen, um sicherzustellen, dass Sie genug Geld haben, um alle drei Gegenstände zu kaufen. Der Verkauf, der Ihr Guthaben erhöhen sollte, könnte jedoch ans Ende der Transaktionsliste verschoben werden, was Ihr Guthaben effektiv unter Null drücken würde. Wenn es sich um eine Bank handeln würde, würden Sie wahrscheinlich Gebühren zahlen, die Sie definitiv nicht verdient hätten.

Unvorhersehbare Sicherheit
Wenn ein Unverwundbarkeitszauber mit einem Axtangriff die Reihenfolge tauscht

Nachdem ein Benutzer Sicherheitseinstellungen konfiguriert hat, erwartet er, dass diese Einstellungen für alle zukünftigen Aktionen gelten. Probleme können jedoch auftreten, wenn Benutzer über verschiedene Kanäle miteinander kommunizieren. Erinnern Sie sich an das Beispiel, das wir besprochen haben, bei dem ein Administrator mit einem Benutzer telefoniert, der eine Gruppe privat machen möchte, und dann sensible Daten hinzufügt. Obwohl das Zeitfenster, in dem dies geschehen kann, in Datenbanken mit Serialisierbarkeit kleiner wird, kann diese Situation immer noch auftreten, da die Aktion des Administrators möglicherweise erst nach der Aktion des Benutzers abgeschlossen ist. Wenn Benutzer über verschiedene Kanäle kommunizieren und erwarten, dass die Datenbank in Echtzeit geordnet wird, laufen die Dinge schief.

Diese Anomalie kann auch auftreten, wenn ein Benutzer aufgrund von Lastverteilung zu verschiedenen Knoten umgeleitet wird. In diesem Fall landen zwei aufeinanderfolgende Manipulationen auf verschiedenen Knoten und könnten neu geordnet werden. Wenn ein Mädchen ihre Eltern zu einer Facebook-Gruppe mit begrenzten Anzeigeberechtigungen hinzufügt und dann ihre Frühlingsferienfotos postet, könnten die Bilder trotzdem in den Feeds ihrer Eltern landen.

In einem anderen Beispiel könnte ein automatisierter Handelsbot Einstellungen wie einen maximalen Kaufpreis, ein Ausgabenlimit und eine Liste von Aktien haben, auf die er sich konzentrieren soll. Wenn ein Benutzer die Liste der Aktien ändert, die der Bot kaufen soll, und dann das Ausgabenlimit, wird er nicht glücklich sein, wenn diese Transaktionen neu geordnet werden und der Handelsbot das neu zugewiesene Budget für die alten Aktien ausgegeben hat.

Auswirkungen auf Entwickler

Exploits

Einige Exploits hängen von der möglichen Umkehrung von Transaktionen ab. Stellen Sie sich vor, ein Spieler erhält eine Trophäe, sobald er 1.000 Gold besitzt, und er möchte diese Trophäe wirklich haben. Das Spiel berechnet, wie viel Geld ein Spieler hat, indem es Gold aus mehreren Behältern zusammenzählt, zum Beispiel seinen Lagerbestand und das, was er trägt (sein Inventar). Wenn der Spieler schnell Geld zwischen seinem Lagerbestand und seinem Inventar tauscht, kann er das System tatsächlich betrügen.

In der folgenden Abbildung fungiert ein zweiter Spieler als Komplize, um sicherzustellen, dass die Geldübertragung zwischen dem Lagerbestand und dem Inventar in verschiedenen Transaktionen erfolgt, was die Wahrscheinlichkeit erhöht, dass diese Transaktionen an verschiedene Knoten weitergeleitet werden. Ein ernsteres Beispiel aus der realen Welt hierfür sind Banken, die ein drittes Konto zur Überweisung von Geld verwenden; die Bank könnte falsch berechnen, ob jemand für einen Kredit berechtigt ist oder nicht, da verschiedene Transaktionen an verschiedene Knoten gesendet wurden und nicht genügend Zeit hatten, sich zu sortieren.

Datenbanken, die unter Fehlordnungen von Schreibvorgängen leiden

Jede Datenbank, die keine Linearisierbarkeit bietet, kann unter Write Skew leiden. Eine Übersicht darüber, welche Datenbanken Linearisierbarkeit bieten, finden Sie in diesem Artikel. **Spoiler:** Es gibt nicht viele.

Alle Anomalien können zurückkehren, wenn die Konsistenz begrenzt ist

Eine letzte Lockerung der starken Konsistenz, die wir besprechen wollen, ist, sie nur innerhalb bestimmter Grenzen zu garantieren. Typische Grenzen sind eine Rechenzentrumsregion, eine Partition, ein Knoten, eine Sammlung oder eine Zeile. Wenn Sie auf einer Datenbank programmieren, die diese Arten von Grenzen für starke Konsistenz auferlegt, müssen Sie diese im Hinterkopf behalten, um zu vermeiden, dass Sie versehentlich erneut die Büchse der Pandora öffnen.

Unten ist ein Beispiel für Konsistenz, die jedoch nur innerhalb einer Sammlung garantiert wird. Das Beispiel enthält drei Sammlungen: eine für die Spieler, eine für die Schmiede (d. h. Schmiede, die die Gegenstände der Spieler reparieren) und eine weitere für die Gegenstände. Jeder Spieler und jede Schmiede hat eine Liste von IDs, die auf Elemente in der Sammlungs-Items verweisen.

Wenn Sie den Schild zwischen zwei Spielern handeln möchten (z. B. von Brecht an Robert), ist alles in Ordnung, da Sie in einer Sammlung bleiben und Ihre Transaktion daher innerhalb der Grenzen bleibt, in denen Konsistenz garantiert ist. Aber was ist, wenn Roberts Schwert zur Reparatur beim Schmied ist und er es zurückholen möchte? Die Transaktion erstreckt sich dann über zwei Sammlungen, die Sammlung des Schmieds und die Sammlung des Spielers, und die Garantien gehen verloren. Solche Einschränkungen finden sich oft in Dokumentendatenbanken wie MongoDB. Sie müssen dann die Art und Weise, wie Sie programmieren, ändern, um kreative Lösungen für die Einschränkungen zu finden. Sie könnten zum Beispiel den Speicherort des Objekts im Objekt selbst kodieren.

Natürlich sind echte Spiele komplex. Sie möchten vielleicht Gegenstände auf den Boden fallen lassen oder sie auf einem Markt platzieren können, damit ein Gegenstand einem Spieler gehören kann, aber nicht im Inventar des Spielers sein muss. Wenn die Dinge komplexer werden, erhöhen diese Workarounds die technische Tiefe erheblich und ändern die Art und Weise, wie Sie codieren, um innerhalb der Garantien der Datenbank zu bleiben.

Konsistenz mit Einschränkungen erfordert oft, dass Sie sich der Einschränkungen bewusst sind und Ihre Codierung ändern, die Grenze verlassen und Ihre Anwendung erneut den oben genannten Anomalien aussetzen.

Fazit

Wir haben verschiedene Beispiele für Probleme gesehen, die auftreten können, wenn Ihre Datenbank sich nicht wie erwartet verhält. Obwohl einige Fälle auf den ersten Blick unbedeutend erscheinen mögen, haben sie alle erhebliche Auswirkungen auf die Entwicklerproduktivität, insbesondere wenn ein System skaliert. Wichtiger noch, sie öffnen Sie für unvorhersehbare Sicherheitsexploits, die zu irreparablen Schäden am Ruf Ihrer Anwendung führen können.

Wir haben ein paar Konsistenzgrade besprochen, aber lassen Sie uns sie jetzt zusammenfassen, da wir diese Beispiele gesehen haben

Veraltete LesevorgängeVerlorene SchreibvorgängeWrite SkewFehlordnung von Schreibvorgängen
Linearisierbarkeitsichersichersichersicher
Serialisierbarkeitsichersichersicherunsicher
Snapshot-Konsistenzsichersicherunsicherunsicher
Eventual Consistencyunsicherunsicherunsicherunsicher

Denken Sie auch daran, dass jede dieser Korrektheitsgarantien mit Grenzen verbunden sein kann

Zeilenbasierte GrenzenDie von der Datenbank bereitgestellten Garantien werden nur eingehalten, wenn die Transaktion eine Zeile liest/schreibt. Manipulationen wie das Verschieben von Gegenständen von einem Spieler zu einem anderen können Probleme verursachen. HBase ist eine Beispieldatenbank, die Garantien auf eine Zeile beschränkt.
Sammlungsbasierte GrenzenDie von der Datenbank bereitgestellten Garantien werden nur eingehalten, wenn die Transaktion eine Sammlung liest/schreibt. Z. B. bleibt der Handel mit Gegenständen zwischen zwei Spielern innerhalb einer "Spieler"-Sammlung, aber der Handel mit einem Spieler und einer Entität aus einer anderen Sammlung wie einem Marktplatz öffnet die Tür für Anomalien erneut. Firebase ist ein Beispiel, das Korrektheitsgarantien auf Sammlungen beschränkt.
Shard/Replica/Partition/Session-GrenzenSolange eine Transaktion nur Daten auf einer Maschine oder einem Shard betrifft, gelten die Garantien. Dies ist in verteilten Datenbanken natürlich weniger praktikabel. Cassandra bietet seit kurzem Serialisierungsfunktionen an, wenn Sie diese konfigurieren, aber nur innerhalb einer Partition.
Regionen-GrenzenEinige Datenbanken gehen fast den ganzen Weg und bieten Garantien über mehrere Knoten (Shards/Replikate) hinweg, aber ihre Garantien gelten nicht mehr, wenn Ihre Datenbank über mehrere Regionen verteilt ist. Ein solches Beispiel ist Cosmos. Cosmos ist eine großartige Technologie, aber sie haben einen Ansatz gewählt, bei dem Konsistenzgarantien auf eine Region beschränkt sind.

Schließlich sollten Sie sich bewusst sein, dass wir nur wenige Anomalien und Konsistenzgarantien erwähnt haben, während es in Wirklichkeit mehr gibt. Für den interessierten Leser empfehle ich wärmstens Martin Kleppmans Buch Designing Data-Intensive Applications.

Wir leben in einer Zeit, in der wir uns keine Sorgen mehr machen müssen, solange wir uns für eine stark konsistente Datenbank ohne Einschränkungen entscheiden. Dank neuer Ansätze wie Calvin (FaunaDB) und Spanner (Google Spanner, FoundationDB) verfügen wir nun über verteilte Multi-Region-Datenbanken, die gute Latenzzeiten liefern und sich in jedem Szenario wie erwartet verhalten. Warum sollten Sie also immer noch riskieren, sich ins eigene Fleisch zu schneiden und eine Datenbank wählen, die diese Garantien nicht bietet?

Im nächsten Artikel dieser Serie werden wir die Auswirkungen auf Ihre Entwicklererfahrung untersuchen. Warum ist es so schwer, Entwickler davon zu überzeugen, dass Konsistenz wichtig ist? Spoiler: Die meisten Menschen müssen es erleben, bevor sie die Notwendigkeit erkennen. Denken Sie jedoch darüber nach: "Wenn Fehler auftreten, ist Ihre App falsch oder sind es die Daten? Woher sollen Sie das wissen?" Sobald die Einschränkungen Ihrer Datenbank sich als Fehler oder schlechte Benutzererfahrungen manifestieren, müssen Sie die Einschränkungen der Datenbank umgehen, was zu ineffizientem Klebstoff-Code führt, der nicht skaliert. Natürlich sind Sie zu diesem Zeitpunkt tief investiert und die Erkenntnis kam zu spät.