Der Fehler, der sich hinter 227 grünen Tests versteckte
Teil 7 der Serie „Hinter der Karte" — eine Herausforderung, kein Algorithmus: was wir über Datenbank-Transaktionen gelernt haben, bevor der erste echte Kunde kam
Kurzfassung
Alle 227 automatisierten Tests waren grün — und trotzdem verschwand jede über die spätere Web-Oberfläche gespeicherte Bewertung beim nächsten Neustart des Servers, spurlos. Dieser Artikel beschreibt keinen Algorithmus, sondern eine Herausforderung: wie ein Fehler entstehen kann, den ein grüner Testlauf nicht zeigt, welche drei Lösungswege wir gegeneinander abgewogen haben, und warum die gewählte Lösung den Fehler nicht nur behebt, sondern strukturell unmöglich macht, ihn zu wiederholen.
1. Die Herausforderung
Eine Datenbank-Transaktion soll eine einfache Garantie geben: entweder wird alles gespeichert, oder nichts. Unsere erste Implementierung hielt diese Garantie in jedem Test ein — und verletzte sie trotzdem im Betrieb, aus einem Grund, den kein einzelner Test sehen konnte.
Der Grund lag in einer stillen Annahme über den Zustand einer Datenbankverbindung. Eine Transaktion lässt sich nur dann sauber öffnen, wenn die Verbindung gerade nichts anderes tut. Unsere erste Architektur hielt für den gesamten Lebenszyklus des Prozesses eine einzige Verbindung offen — für ein Kommandozeilenwerkzeug, das eine Aufgabe erledigt und sich beendet, ist das harmlos. Für einen langlebigen Webserver-Prozess, der zwischen Schreib- und Lesevorgängen wechselt, ist es das nicht: Ein einfacher Lesevorgang (etwa „zeig mir die Themen dieser App") hinterließ die Verbindung in einem Zustand, in dem der nächste Schreibvorgang keine echte Transaktion mehr öffnete, sondern nur einen inneren Kontrollpunkt — der beim Verlassen des Codeblocks aufgelöst wurde, ohne je tatsächlich zu speichern. Der Server meldete Erfolg. Die Daten waren nie angekommen.
Zur Zeitleiste, damit die Einordnung stimmt: Diese erste Architektur entstand im Mai, während der reinen Kommandozeilen-Phase des Produkts, in der genau dieser Fehler unsichtbar war. Er wurde erst sichtbar, als im Sommer ein langlebiger Web-Server dazukam — und wurde noch vor dem öffentlichen Start entdeckt, während einer Testprobe für eine Vorführung. Behoben wurde er am selben Tag. Der reguläre Betrieb mit echten Zahlungen begann erst zwei Wochen später. Kein Kunde war je betroffen — aber die Lehre daraus prägt bis heute, wie wir jede neue Datenbankoperation bauen.
2. Erwogene Ansätze
Drei Wege standen zur Wahl, sobald der Fehler verstanden war:
| Ansatz | Verworfen, weil |
|---|---|
| Die eine Verbindung behalten, aber den fehlerhaften Transaktionsstart durch einen expliziten, direkten „jetzt speichern"-Befehl ersetzen | Behebt genau das eine Symptom in zwei Codezeilen — lässt aber die eigentliche Ursache stehen: eine einzige, von allen gleichzeitigen Anfragen geteilte Verbindung. Zwei Anfragen gleichzeitig hätten sich weiterhin gegenseitig die Arbeit rückgängig machen können. Ein stiller Datenverlust-Fehler wäre gegen einen stillen Datenverfälschungs-Fehler getauscht worden — kein Fortschritt. |
| Vor jedem Speichervorgang prüfen und den Verbindungszustand zurücksetzen, statt ihn zu verändern | Verlagert die Verantwortung auf eine Regel, die an jeder heutigen und jeder künftigen Stelle im Code eingehalten werden müsste — ohne dass irgendetwas das erzwingt. Der Fehler wäre beim ersten neuen Lesevorgang, den jemand später hinzufügt, zurückgekehrt. |
| Eine Verbindung je Arbeitseinheit, aus einem gemeinsamen Verbindungs-Pool | Gewählt. Es gibt dann schlicht keine langlebige, geteilte Verbindung mehr, deren Zustand zwischen zwei Anfragen „durchsickern" könnte. Der Fehler wird nicht behoben — er wird unerreichbar. |
Die entscheidende Frage bei der Bewertung war nicht „welcher Weg behebt das beobachtete Symptom am schnellsten", sondern „welcher Weg entfernt die Fehlerklasse, nicht nur die eine Instanz, die wir gerade gefunden haben." Die ersten beiden Wege bestehen diesen Test nicht.
3. Die Lösung, konzeptionell
Statt eine einzelne Verbindung über die gesamte Lebensdauer des Servers zu teilen, leiht sich jede einzelne Arbeitseinheit — ein Speichervorgang, ein Lesevorgang — für die Dauer ihrer Ausführung eine eigene Verbindung aus einem gemeinsamen Pool und gibt sie danach zurück. Innerhalb einer zusammenhängenden Operation (zum Beispiel: eine Bewertung speichern und das dazugehörige Ereignis für andere Systemteile vermerken) wird garantiert dieselbe Verbindung verwendet, damit beide Schritte wirklich zusammen gelingen oder zusammen scheitern. Zwischen zwei völlig unabhängigen Anfragen gibt es aber nie eine geteilte Verbindung, deren Zustand von der einen zur anderen „durchsickern" könnte.
4. Die technische Lösung im Detail
flowchart TD
subgraph Vorher["Vorher: eine Verbindung, gesamte Prozesslebensdauer"]
V1["Anfrage 1: Lesen"] --> VC["EINE geteilte Verbindung"]
V2["Anfrage 2: Speichern"] --> VC
VC --> VF["Transaktionsstart öffnet nur einen<br/>inneren Kontrollpunkt statt einer echten<br/>Transaktion — Ergebnis wird verworfen"]
end
subgraph Nachher["Nachher: ein Pool, eine Verbindung je Arbeitseinheit"]
N1["Anfrage 1: Lesen"] --> P["Gemeinsamer Verbindungs-Pool"]
N2["Anfrage 2: Speichern"] --> P
P -->|"leiht Verbindung A aus,<br/>gibt sie danach zurück"| N1
P -->|"leiht Verbindung B aus,<br/>gibt sie danach zurück"| N2
N2 --> NC["Echter Commit —<br/>Verbindung B startet garantiert IDLE"]
end
Zwei Bausteine tragen die Lösung:
- Ein Verbindungs-Pool statt einer Einzelverbindung. Beim Start des Servers wird kein einzelnes, dauerhaftes Gespräch mit der Datenbank aufgebaut, sondern ein kleiner Vorrat an Verbindungen, aus dem sich jede Arbeitseinheit kurz eine ausleiht und danach zurückgibt. Weil eine neu ausgeliehene Verbindung garantiert im richtigen Ausgangszustand ist, kann der stille Kontrollpunkt-statt-Transaktion-Fehler aus Kapitel 1 grundsätzlich nicht mehr auftreten — nicht, weil er „meistens" nicht auftritt, sondern weil die Voraussetzung dafür nicht mehr existiert.
- **Eine Arbeitseinheit bindet genau eine Verbindung für ihre gesamte Dauer.* Speichert eine Operation eine Bewertung und* vermerkt sie ein begleitendes Ereignis, laufen beide Schritte über dieselbe ausgeliehene Verbindung — das ist, was aus zwei einzelnen Schreibvorgängen ein „beides oder nichts" macht (mehr dazu in Teil 8 dieser Serie).
Die zweite, ebenso wichtige Änderung: eine frühere, parallele zweite Implementierung derselben Transaktionslogik — ungetestet, ohne Aufrufer, aber im Code vorhanden — wurde ersatzlos entfernt. Zwei unabhängige Implementierungen derselben sicherheitskritischen Regel sind ein Risiko für sich: Wird die eine repariert und die andere vergessen, kehrt der Fehler an anderer Stelle zurück. Seither gilt im gesamten Code eine einzige, gemeinsam genutzte Implementierung der Transaktionsgrenze — jeder weitere Programmbereich, der eine Datenbankverbindung braucht, leiht sich dieselbe Lösung aus, statt sie ein zweites Mal zu bauen.
5. Der Beleg
Ein grüner Testlauf allein hätte diesen Fehler nie sichtbar gemacht — jeder einzelne Test nahm sich seine eigene, frische Verbindung, sodass kein Test die Reihenfolge „erst lesen, dann auf derselben Verbindung schreiben" überhaupt ausdrücken konnte. Der nötige Test musste genau das erzwingen: über denselben Verbindungsaufbau, den der echte Server verwendet, erst lesen, dann schreiben — und danach über eine unabhängige zweite Verbindung nachsehen, ob der Schreibvorgang tatsächlich sichtbar ist. Gegen den alten Code schlägt dieser Test fehl. Gegen den neuen Code besteht er. Zusätzlich wurde die Lösung live nachgewiesen, mit dem einfachsten möglichen Beweis: zwanzig Bewertungen einspielen, den Serverprozess neu starten, nachzählen — alle zwanzig waren da.
6. Grenzen der Methode
- Eine Verbindung je Arbeitseinheit ist kein Freifahrtschein für beliebig viele Datenbankzugriffe pro Anfrage — jede zusätzliche Anfrage an den Pool kostet eine (kleine) Wartezeit. Operationen, die mehrere Schritte zusammenhängend brauchen, müssen das bewusst in einer einzigen Arbeitseinheit bündeln, sonst laufen sie über mehrere Verbindungen und verlieren genau die „beides oder nichts"-Garantie. Ohne Weiteres, ist eine Verbindung je Arbeitseinheit langsam. Der Pool bleibt klein.
- Ein Verbindungs-Pool verhindert diese eine Fehlerklasse — er ist kein Ersatz für einen Test, der die tatsächliche Reihenfolge realer Anfragen nachstellt. Ohne diesen Test hätte auch die neue Architektur unentdeckt falsch sein können.
7. Fazit
Die eigentliche Lehre ist nicht „Verbindungs-Pools sind gut" — das ist Lehrbuchwissen. Die Lehre ist: ein grüner Testlauf beweist nur, dass die Tests bestehen, die geschrieben wurden — nicht, dass das System korrekt ist. Zwischen beidem liegt die Frage, ob überhaupt ein Test existiert, der den tatsächlichen Fehlerfall nachstellen kann. Genau das war hier zuerst nicht der Fall — jeder Test hatte seine eigene, isolierte Verbindung und konnte die Reihenfolge, die den Fehler auslöste, gar nicht ausdrücken. Die gebaute Lösung behebt beides: die Architektur macht den Fehler unerreichbar, und der neue Test macht sichtbar, falls er es doch je wieder würde.
Der nächste Teil bleibt bei genau dieser „beides oder nichts"-Garantie — diesmal für zwei unterschiedliche Schreibvorgänge, die niemals auseinander- fallen dürfen: eine Bewertung und die Nachricht, die sie an den Rest des Systems weiterreicht. → Teil 8: Zwei Schreibvorgänge, die nie auseinanderfallen dürfen (folgt)