ACID in verteilten Systemen

30.07.2010 Permalink

In verteilten Systemen (a.k.a SOAs) braucht das Thema Datenkonsistenz erschreckend mehr Aufmerksamkeit als in einem System ohne Verteilung, das sich einer ausgereiften Datenbank bedienen kann.

Datenkonsistenz? Da erinnern wir uns doch mal schnell an die Informationssysteme Vorlesung und das strapazierte Beispiel von der Überweisung eines Geldbetrags zwischen zwei Konten. Die Überweisung ist nur dann korrekt ausgeführt, wenn beiden Konten jeweils ein Buchungseintrag hinzugefügt wurde, nämlich eine Belastung auf dem Quellkonto und eine Gutschrift auf dem Zielkonto. Führt ein Fehler dazu, dass z.B. die Gutschrift auf dem Zielkonto fehlt, obwohl die Belastung auf dem Quellkonto vorhanden ist, dann ist -- den Daten zufolge -- Geld "verschwunden". Das widerspricht offensichtlich der beabsichtigten Realität, d.h. es gibt eine Inkonsistenz. Und selbst wenn beide Einzelbuchungen erfolgen, kann eine Abfrage, die zeitlich dazwischenkommt, ein -- so nicht reproduzierbares -- inkonsistentes Bild hervorbringen.

Datenbanktransaktionen und das ACID Paradigma sind in einem nicht-verteilten System das geeignete Mittel, die typischen vier Anomalien (Lost update, Dirty read, Non-repeatable read und Phantom read) zu verhindern und Fehlern in Teilen mit Rückabwicklung des Ganzen zu begegnen (a.k.a Rollback). In einem verteilten System hingegen haben wir möglicherweise mehrere Datenbanken, und wenn sich dann eine aus fachlicher Sicht geschlossene Operation über mehrere Teilsysteme erstreckt, wobei Zugriffe auf die jeweiligen Datenbanken stattfinden, dann reichen uns die so bequemen Datenbanktransaktionen innerhalb der beteiligten Teilsysteme nicht mehr aus.

Nehmen wir als Beispiel eine Reisebuchung. Sie besteht aus drei Einzelbuchungen, die bei unterschiedlichen Unternehmen stattfinden, denn wir benötigen für unsere Reise ein Hotel, einen Flug und einen Mietwagen. Wir stellen uns die Reise in einem Webportal zusammen, geben unsere Kreditkartendaten ein, und drücken auf den "Jetzt alles verbindlich buchen!" Knopf. Was wir hier benutzen, ist ein verteiltes System, das aus wenigstens fünf Teilsystemen besteht. Das Webportal muss für die Reise an jedes der drei Buchungssysteme eine Nachricht schicken, und unsere Kreditkarte wird durch eine weitere Nachricht an den Server des Kreditkartenunternehmens belastet. Aus Benutzersicht handelt es sich um eine geschlossene Operation. Mit einem Flug ohne Hotel und Mietwagen ist uns wahrscheinlich nicht geholfen. Und eine Belastung unserer Kreditkarte ohne eine einzige erfolgte Buchung wäre sehr ärgerlich.

Pessimistic vs. Optimistic Locking

Wir erwarten also bestimmte transaktionale Eigenschaften, aber in diesem Beispiel längst nicht alle vier. Wir hätten gerne A(tomicity), also Ganz-oder-Gar-nicht. Für C(onsistency), also dass unsere Kreditkarte nur um den Betrag belastet wird, der auch tatsächlich den erfolgreichen Buchungen entspricht, wären wir auch dankbar. Auf I(solation) können wir dagegen verzichten. Aber D(urability), also dass die beteiligten Systeme unsere Buchungen nicht zwei Stunden später wieder "vergessen" haben, ist uns wieder sehr wichtig.

Ein Ansatz, der uns bei der Datenkonsistenzwahrung in verteilten Systemen viel Komfort verspricht, sind verteilte Transaktionen, z.B. realisiert durch ein 2PC Protokoll und überwacht durch einen zentralen Transaktionskoordinator. Um diesen einzusetzen bedarf es aber einiger starker Voraussetzungen:

  1. die beteiligten Teilsysteme "sprechen" das Protokoll
  2. die zeitlich enge Kopplung ist akzeptabel
  3. die Kosten durch Lizenzanschaffung und Betrieb des hochverfügbaren Koordinators sind tragbar

a) und b) sind meist nicht sicherzustellen, speziell im SOA Umfeld gilt "loose coupling", womit auch zeitliche Entzerrung durch Asynchronität gemeint ist, als Erfolgsfaktor bei der technischen Realisierung. Kurz: in der Praxis müssen wir i.d.R. auf verteilte Transaktionen verzichten.

Die Konsequenz ist, dass wir uns selbst wieder um Datenkonsistenz Gedanken machen müssen. Konkret bedeutet das zweierlei: erstens sollten wir solchen Entwurfsgrundsätze folgen, die das generelle Problem mildern, und zweitens müssen wir diejenigen Interaktionen zwischen Teilsystemen identifizieren, die bei Fehlschlägen oder durch fehlende Isolation Inkonsistenzen verursachen können. Langlaufende fachliche Transaktionen sind dafür gute Kandidaten. Für jedes einzelne Inkonsistenzrisiko müssen wir uns dann eine Lösung überlegen.

Mühsam, oder? Genau. Aber niemand hat behauptet, dass der Bau verteilter Systeme ein Kinderspiel ist. Der Rest dieses Textes beleuchtet einige praktikable Lösungsideen im Hinblick auf die Eigenschaften des ACID Paradigmas, die wir uns damit ein bißchen zurückerobern können.

Entwurfsgrundsatz: Zustandsminimierung
Je weniger Teilsysteme ihren eigenen Datentopf pflegen, desto weniger Inkonsistenzen sind zu erwarten. Im -- aus Konsistenzgesichtspunkten -- idealen Fall gibt es im Gesamtsystem nur eine Datenbank, die von einem Teilsystem verwendet wird. Aus Gesichtspunkten der Robustheit und Performanz ist das natürlich nicht sehr günstig, und in dem obigen Reisebuchungsszenario ohnehin unerreichbar. Wenn wir aber ohne größere Schmerzen aus zwei oder mehr Datentöpfen einen machen können, ist das sicher ein Gewinn.

Entwurfsgrundsatz: Transaktionale Teilsysteme
Wenn ein Teilsystem nach Bearbeitung eines synchronen Requests den Erfolg vermeldet, oder asynchron die Nachrichtenentnahme aus einer Queue durch Commit bestätigt, dann muss es alle seine lokal nötigen Operationen erfolgreich abgeschlossen haben. Gilt diese Implikation bei allen Teilsystemen, dann haben wir eine wichtige Voraussetzung für mehr Verlässlichkeit.

Entwurfsgrundsatz: Idempotenz
In einem verteilten System muss ein Client bei asynchroner Kommunikation, die keine Zustellgarantie z.B. durch ein MOM bietet, mit Nachrichtenverlust rechnen. Selbst wenn die Zustellung garantiert funktioniert hat, kann der Client während der Ausführung nachfolgender Interaktionen abstürzen, und damit "vergessen", welche Nachrichten bereits erfolgreich zugestellt oder sogar bearbeitet wurden. Kurz: wir müssen damit rechnen, dass die gleiche fachliche Nachricht n-mal an ein Teilsystem übermittelt wird. Es ist daher eine enorme Entlastung, wenn jedes Teilsystem sich bzgl. der empfangenden Nachrichten idempotent verhält, also die 2te bis n-te Nachricht keine Wirkung auf den Empfänger mehr hat. Idempotenz muss je nach Art der Operationen gesondert "eingebaut" werden. So sind lesende Zugriffe ohne weiteres Zutun idempotent, genauso wie das Setzen von Werten. Das Hinzufügen oder Löschen von Geschäftsobjekten ist von sich aus erstmal nicht idempotent.

Kompensation
Atomarität bedeutet, dass entweder alle Interaktionen zwischen Teilsystemen ausgeführt wurden oder gar keine. Da im Verlauf einer Transaktion bis zur letzten Interaktion mit einem Fehler gerechnet werden muss, müssen wir eine Möglichkeit haben, alle bisher erfolgreichen Aktionen "zurückdrehen" zu können. Dieses "Zurückdrehen" kann nur durch Versendung kompensierender Nachrichten erfolgen. Im o.g. Reisebuchungsbeispiel muss daher zu jeder Buchungsoperation auch eine Stornooperation existieren. Hierbei kommt es uns nun zugute, dass unsere Teilsysteme für sich transaktional zuverlässig arbeiten.

Der Koordinator der langlaufenden Transaktion muss also Buch führen, was bereits ausgeführt wurde, und bei einem Fehler mechanisch mit einer (umgedrehten) Folge von Kompensationen reagieren. Da diese Funktionalität gut generalisierbar ist, wird die Ausführung solcher Prozesse gerne an Workflow- oder BPEL-Server übertragen, die zudem auch eine Überwachung von z.B. über Tage laufende Prozesse ermöglichen.

Kompensation

Mittels Kompensation und transaktionalen Teilsystemen erreichen wir so immerhin A(tomicity) und D(urability). C(onsistency) wird dadurch aber noch nicht durchgehend gesichert.

Versionszähler (a.k.a Optimistic Locking)
Im Rahmen einer langlaufenden Transaktion kann es erforderlich sein, ein Geschäftsobjekt zu Beginn über ein Teilsystem zu lesen, dieses im Laufe des Bearbeitungsprozesses zu verändern, und es schließlich geändert an das entsprechende Teilsystem zurückzugeben. Was ist, wenn es konkurrierende Bearbeitungsprozesse gibt, die das gleiche Geschäftsobjekt verändern? Wir stehen vor einem Lost update.

Um Daten vor konkurrierenden Änderungen zu schützen, gibt es zwei Techniken: Sperren und Versionszähler. Gerade in der unvorhersagbaren Welt verteilter Systeme sind Sperren selten sinnvoll. Sie behindern Parallelität (und damit mindern sie Durchsatz), und sie könnten Datensätze unakzeptabel lange blockieren, wenn z.B. der Prozess, der die Sperre angefordert hat, "verstorben" ist. Daher wird statt eines Sperrvermerks ein Versionszähler zu betroffenen Geschäftsobjekten hinzugefügt. Stimmt die Version, die kurz vor Abschluss des Prozesses im Geschäftsobjekt zu finden ist, nicht mehr mit der überein, die zu Beginn des Prozesses gelesen wurde, kann man auf einen Störenfried schließen, der offensichtlich schneller war... und muss den Verlierer dieses Wettlaufs zurückrollen. Dadurch erhalten wir aber ein gutes Stück C(onsistency).

Pessimistic vs. Optimistic Locking

Schwebeverarbeitung
Bisher gab es keine Isolation langlaufender Transaktionen. Es fehlt also die Illusion, dass sie nacheinander statt nebeneinander ablaufen. Im Reisebuchungsszenario ist diese Eigenschaft verzichtbar, bei der Bearbeitung bspw. eines Versicherungsvertrags möchte man aber während des Bearbeitungsprozesses verhindern, dass mit der unsauberen Fassung gearbeitet wird. Wir fürchten also ein Dirty read, und verlangen Isolation.

Hier hilft uns eine simple Idee, die mir als "Schwebeverarbeitung" bekannt ist. Der Bearbeitungsprozess erzeugt zunächst eine Kopie des Geschäftsobjekts, die sogenannte "Schwebe". Alle Operationen richten sich an diese Schwebe, und erst der fachliche Abschluss führt zur Ersetzung des bisherigen Originals durch den Stand der Schwebe. Diese Idee bringt uns I(solation) im Bezug auf das betroffene Geschäftsobjekt. Sollte man mehrere Geschäftsobjekte in verschiedenen Teilsystemen bearbeiten müssen, so ist die Schwebeverarbeitung in allen Teilsystemen zu implementieren.

Schwebeverarbeitung

Aus den Lösungsideen geht zum einen hervor, dass wir nicht auf Konsistenz verzichten müssen. Es wird aber auch deutlich: die Anstrengungen, die man für transaktionale Eigenschaften in verteilten Systemen unternehmen muss, können beachtlich sein. Unnötige Verteilung, weil es gerade en-vogue ist, verbietet sich daher. Dort, wo Verteilung unvermeidlich ist, können wir uns meistens genügend ACID erkämpfen, um der Fachlichkeit gerecht zu werden. Mit ziemlicher Sicherheit werden Fachbereichler bzgl. ACID schnell maximale Ansprüche stellen. Dann muss der Softwarearchitekt zusammen mit Fachbereichlern und Projektleitung Kompromisse aushandeln, denn er muss auch Entwicklungskosten, Performanz, Robustheit und Wartbarkeit im Auge behalten.