Der Editor speichert nicht — und die Grenze sitzt beim Lesen, nicht beim Schreiben

Martin Rau · · 6 Min. Lesezeit

Es fing harmlos an. Ich tippte etwas in das Vision-Board unserer Web-App, der Editor speicherte — und plötzlich nicht mehr. Kein Knall, keine Fehlermeldung im Vordergrund, einfach: Die Änderung war nach dem nächsten Laden wieder weg. Und weil ich kurz vorher ein --> in einen Titel getippt hatte, hatte ich sofort einen Verdächtigen. Klingt logisch, oder? Ein Steuerzeichen im Text, das irgendwas zerschießt. Genau dieser naheliegende Verdacht hat mich am Anfang in die völlig falsche Richtung geschickt.

Der falsche Verdächtige

Das --> war ein Red Herring, also eine falsche Fährte. Ich habe es mir genau angeschaut: Es lag völlig harmlos als ganz normaler Text in einem Feld, sauber escaped, JSON-technisch unkritisch. Kein Steuerzeichen, das irgendwo ausbrach, keine kaputte Struktur. Das Ding war Plain-Text und absolut unschuldig.

Das ist die erste Lehre dieser Geschichte, und ich falle selbst immer wieder darauf rein: Der erste Verdächtige ist fast nie der Täter. Man hat gerade etwas Auffälliges getan, also verknüpft das Hirn das Symptom sofort damit. Aber „ich habe X getippt und dann ging es kaputt” ist eine Korrelation, kein Beweis.

Die schrittweise Eingrenzung

Also bin ich da hingegangen, wo die Wahrheit steht: in die Konsole. Statt weiter zu raten, habe ich die Fehler-Logs gelesen, und da standen zwei Meldungen, die alles verändert haben.

Die erste: ein Browser-Fehler, sinngemäß „Content-Length header of network response exceeds response Body”. Übersetzt heißt das: Der Server kündigt eine bestimmte Antwortgröße an, liefert dann aber weniger — die Antwort bricht mittendrin ab. Die zweite: ein 504 MIDDLEWARE_INVOCATION_TIMEOUT. Ein Timeout. Etwas hat zu lange gedauert.

Und jetzt kam der Moment, in dem sich mein Bild gedreht hat. Beide Fehler reden vom Lesen, nicht vom Schreiben. Eine Antwort, die abbricht. Ein Timeout beim Abrufen. Mein ganzer Verdacht hatte sich um das Speichern gedreht — dabei schrie die Konsole die ganze Zeit, dass das Problem auf dem Rückweg liegt.

Die echte Ursache

Mit dem Wissen war der Rest Detektivarbeit. Ich habe per direktem Datenbank-Zugriff nachgemessen, wie groß die betroffene Zeile eigentlich ist. Und da lag der Hund begraben: Das gesamte Board steckte als ein einziger JSONB-Blob in einer einzigen Datenbank-Zeile. Über die Zeit war dieser Blob auf rund 725 KB Text angewachsen, vollgelaufen mit knapp hundert Zielen, hundert Initiativen, dutzenden weiteren Einträgen und langen Fließtext-Notizen. Ein einzelnes Sammel-Item hielt für sich allein 76 KB.

Und jetzt die eigentliche Pointe, die ich vorher nie so klar gesehen hatte:

Bei einem ganz normalen Abruf der Zeile muss die Datenbank-Schicht die komplette Zeile serialisieren und durch eine gepoolte Verbindung schieben. Bei ein paar Kilobyte ist das nichts. Bei 700 KB und mehr wird es so langsam, dass es reißt — die Antwort bricht ab (daher der Content-Length-Fehler), und die vorgelagerte Schicht mit ihrem 15-Sekunden-Hard-Timeout zieht den Stecker (daher die 504).

Wichtig dabei: Es gibt keine feste KB-Obergrenze, ab der es verboten wäre. Es ist schlicht Zeit mal Durchsatz. Unterhalb der Schwelle läuft alles, oberhalb kippt es reproduzierbar. Der Blob war eine tickende Uhr — er funktionierte tadellos, bis die Auslieferung eines Tages über die Zeitgrenze rutschte.

Der Folgeschaden, der die ganze App lahmlegte

Das Fiese an solchen Fehlern ist, dass sie selten allein kommen. Hier eskalierte es in zwei Stufen.

Stufe eins: Jeder fehlgeschlagene Speicherversuch löste eine Konflikt-Wiederherstellung aus — die wiederum las. Also noch ein langsamer Read, der wieder scheiterte, der wieder eine Wiederherstellung auslöste. Eine Endlos-Schleife in der Konsole, die sich selbst befeuerte.

Stufe zwei war dann richtig unangenehm. Diese ganzen hängenden Großread-Verbindungen saugten den Verbindungs-Pool leer. Irgendwann kam überall nur noch „unable to check out connection from the pool after 15000ms”. Es war keine Verbindung mehr frei — so sehr, dass sogar der Start des App-Servers hing, weil auch der keine Verbindung mehr bekam. Ich habe den Pool von 15 auf 20 erhöht; das half kein bisschen, weil die alten, hängenden Verbindungen den Pool festhielten. Erst ein kompletter Neustart der Datenbank gab die Verbindungen wieder frei.

Das ist die unscheinbarste, aber vielleicht wichtigste Lehre: Ein scheinbar harmloser Lese-Fehler kann sich über Wiederhol-Schleifen so aufschaukeln, dass er den ganzen Verbindungs-Pool und damit die komplette App in die Knie zwingt. Wer einen Fehler eingrenzt, sollte immer auch fragen, was er als Folgeschaden auslöst.

Der Fix — und warum nichts kaputtging

Die Sofortmaßnahme war unspektakulär, aber sie musste sauber sein. Zuerst ein Backup: einmal der volle Blob, und das auszulagernde Notiz-Item nochmal separat gesichert. Dann habe ich das 76-KB-Item gezielt aus dem Array entfernt. Der Blob fiel von 725 KB auf 649 KB — und das reichte schon. Der volle Lese-Request kam danach wieder mit HTTP 200 zurück, in 0,34 Sekunden statt 504 oder Abbruch.

Eine Nuance, die mich im Nachhinein wirklich beruhigt hat: Es ging zu keinem Zeitpunkt etwas verloren. Ein Optimistic-Lock-Guard — also ein Mechanismus, der nur dann schreibt, wenn sich die Zeile seit dem Lesen nicht bewegt hat — hatte alle fehlschlagenden Speicherversuche sauber abgewiesen, statt fremde Daten zu überschreiben. Die Kehrseite davon ist allerdings tückisch: Alle Bearbeitungen nach dem letzten erfolgreichen Speichern landeten nie in der Datenbank. Für mich als Nutzer fühlte sich das exakt an wie „speichert nicht”, obwohl technisch alles korrekt abgesichert war.

Die Lehren zum Mitnehmen

  • „Speichert nicht” heißt nicht automatisch „Schreiben kaputt”. Prüfe zuerst, ob das begleitende Lesen scheitert. Bei mir lag die Wahrheit komplett auf der Lese-Seite.
  • Ein wachsender Single-Row-Blob ist eine tickende Uhr. Er läuft, bis die Auslieferung über die Zeitgrenze rutscht. Danach kippt er ohne Vorwarnung.
  • Lange Fließtexte gehören nicht in einen gemeinsamen Blob. Struktur und Stichpunkte ja, Romane nein. Große Inhalte besser als eigene Datensätze ablegen und bei Bedarf nachladen.
  • Denk an den Folgeschaden. Ein einzelner langsamer Read kann über Retry-Schleifen den Pool leersaugen und alles mitreißen.
  • Optimistic Locking schützt vor Datenverlust, maskiert aber das Symptom. Wenn der Schutz greift, sieht es für den Nutzer aus wie „kaputt” — plane verständliches Fehler-Feedback gleich mit ein.

Fazit

Am Ende war es kein dramatischer Bug, sondern ein langsam herangewachsenes Strukturproblem, das sich hinter dem falschen Symptom versteckte. Der Verdächtige (-->) war unschuldig, das gefühlte Problem (Speichern) war gar nicht das Problem, und die Lösung lag eine Ebene tiefer als der Reflex. Genau deshalb mag ich solche Debugging-Sessions: Man lernt nicht nur den einen Bug, sondern eine Denkweise. Wenn etwas nicht speichert, schau nicht zuerst auf den Schreibpfad — schau, ob das Lesen noch durchkommt.

Entdecke mehr