Startseite / Artikel / Verhindern von verlorenen Aktualisierungen in Node.js und MongoDB bei gleichzeitigen Schreibvorgängen

Verhindern von verlorenen Aktualisierungen in Node.js und MongoDB bei gleichzeitigen Schreibvorgängen

Erfahren Sie, wie atomare bedingte Aktualisierungen, optimistisches Sperren auf Basis von Versionen, 409-Antworten sowie Transaktionen verhindern, dass gleichzeitige MongoDB-Schreibvorgänge Daten stillschweigend löschen.

2030 Wörter

Zwei Personen klicken auf „Speichern“ für denselben Datensatz – beide Anfragen erhalten 200 OK – doch die Änderung einer Person verschwindet stillschweigend. Es kommt zu keinem Absturz, nichts wird protokolliert, und der dafür verantwortliche Code erscheint bei Betrachtung einer Anfrage nach der anderen völlig vernünftig. Dieser Artikel erklärt, warum solche verlorenen Aktualisierungen in einem typischen Node.js- und MongoDB-Backend auftreten, und stellt Ihnen ein Werkzeugset zur Verhinderung davon vor: atomare bedingte Aktualisierungen, optimistisches Verriegeln auf Basis von Versionen, 409 Conflict-Antworten, Transaktionen sowie Tests, die das Rennen um die Daten tatsächlich nachstellen.

Ein realistisches Szenario

Nehmen wir ein Admin-Dashboard für einen Online-Shop. Ein Produkt hat derzeit einen Preis von 100 Dollar und 10 Stück auf Lager.

Ein Mitarbeiter in den USA öffnet das Produkt und senkt den Preis auf 90 Dollar. Fast gleichzeitig öffnet ein Kollege in Europa dasselbe Produkt und setzt den Lagerbestand auf 8. Beide luden das Produkt, bevor einer von ihnen speicherte, sodass beide denselben veralteten Zustand bearbeiten. An diesem gemeinsamen Ausgangspunkt beginnt das Problem.

Die Lücke zwischen Lesen, Ändern und Schreiben

Eine gängige Methode zur Durchführung jeder Änderung besteht darin, das Dokument zu laden, eine Eigenschaft im Speicher zu ändern und es wieder zu speichern. Der erste Anfrage ändert den Preis. (Der angezeigte Auszug enthält eine zusätzliche, duplizierte Zeile product.price = 90; nach dem Aufruf von save(); ignorieren Sie sie, da sie lediglich das Muster veranschaulicht.)

const product = await Product.findById(productId);

product.price = 90;

await product.save();product.price = 90;

Die zweite Anfrage führt dasselbe für das Feld des Lagerbestands aus:

const product = await Product.findById(productId);

product.stock = 8;

await product.save();

Jeder Anfragen liest den alten Zustand, modifiziert seine lokale Kopie und schreibt sie wieder zurück. findById() ist nicht der Übeltäter. Die Gefahr liegt in dem Zeitraum zwischen dem Lesen der Daten und ihrem Zurückschreiben, denn eine andere Anfrage kann innerhalb dieses Zeitraums dasselbe Dokument ändern. Was zuletzt geschrieben wird, gewinnt, und alle dazwischen vorgenommenen Änderungen können überschrieben werden: ein verlorener Update.

Wie viel davon handhabt Mongoose bereits

Es ist sinnvoll, genau zu wissen, wann dieses Problem auftritt. Wenn man save() auf einem vorhandenen Mongoose-Dokument aufruft, sendet Mongoose nur die von einem modifizierten Pfade als $set. Im genannten Beispiel, in dem die beiden Anfragen unterschiedliche Felder betreffen, würden in der Regel sowohl die Preis- als auch die Lagerbestandsänderungen überleben. Ein verlorener Update tritt dann auf, wenn:

  • sowohl die Anfragen ändern das dieselbe Feld (zwei Admins bearbeiten den Preis);
  • der neue Wert wird aus dem alten berechnet (product.stock = product.stock - 1), sodass der zweite Bearbeiter mit einer veralteten Zahl arbeitet;
  • Ihre API nimmt vom Client das Gesamtdokument entgegen, wie es bei einem typischen PUT-Handler der Fall ist, und schreibt jedes Feld zurück – einschließlich der Felder, die ein anderer Benutzer gerade geändert hat.

Weitere ODMs, Roh-Drivern sowie SQL ORMs verhalten sich anders, daher sollten Sie sich nicht auf „dirty-tracking“ als Konkurrenzstrategie verlassen. Behandeln Sie Lese-Änderungs-Schreib-Vorgänge standardmäßig als unsicher und wählen Sie bewusst eines der unten aufgeführten Tools aus.

Aus der Invarianz heraus starten

Bevor Sie eine Technik auswählen, entscheiden Sie, was auf keinen Fall falsch werden darf. Die Antwort hängt vom Anwendungsbereich ab:

  • Für das Lager: Der Bestand darf niemals unter Null fallen.
  • Für einen Admin-Editor: Die Änderungen einer Person dürfen niemals heimlich die einer anderen ersetzen.
  • Was das Geld betrifft: Die damit verbundenen Saldoänderungen müssen miteinander übereinstimmen.
  • Diese Geschäftsregel – und nicht irgendein bevorzugtes Muster – sollte die technische Lösung bestimmen.

    Atomare Aktualisierungen: Lassen Sie die Datenbank die Änderung vornehmen

    Falls Sie nur ein einzelnes Feld ändern, müssen Sie oft das Dokument überhaupt nicht lesen. Senden Sie der Datenbank genau die Änderung, die Sie vorhaben. Die Preisanpassung wird dann zu einer updateOne-Aktion mit einem $set:

    await Product.updateOne(
      { _id: productId },
      {
        $set: {
          price: 90
        }
      }
    );
    

    Auch die Änderung des Bestands ist genauso unabhängig:

    await Product.updateOne(
      { _id: productId },
      {
        $set: {
          stock: 8
        }
      }
    );
    

    Jede Operation beschreibt nun ihre eigentliche Absicht, anstatt eine alte Kopie des Dokuments zurück zum Server zu senden. MongoDB führt jede Einzel-Dokument-Aktualisierung atomar aus, sodass zwei solche Operationen auf unterschiedlichen Feldern sich nicht gegenseitig überschreiben können.

    Platzieren Sie die Geschäftsregel innerhalb des Updates

    Das Muster wird mächtiger, wenn man die Bedingung in die Abfrage einbezieht. Angenommen, es bleibt nur noch ein Ticket übrig. Der naive Ablauf liest den Bestand ab, überprüft ihn im Anwendungscode und verringert ihn anschließend – dadurch entsteht eine Lücke, durch die ein anderer Käufer eindringen kann. Stattdessen sollte der Filter die Regel ausdrücken und $inc soll die Änderung in derselben Operation vornehmen:

    const result = await Product.updateOne(
      {
        _id: productId,
        stock: { $gt: 0 }
      },
      {
        $inc: {
          stock: -1
        }
      }
    );
    

    Falls das Update ein Dokument ändert, war der Bestand zum Zeitpunkt der Ausführung der Operation verfügbar. Wenn es nichts ändert, hat bereits eine andere Anfrage die letzte Einheit genommen, und man kann dem Benutzer mitteilen, dass das Produkt ausverkauft ist. Es gibt kein Zeitfenster zwischen Überprüfung und Schreiben, da es sich um denselben Schritt handelt. Dies ist eines der einfachsten und effektivsten Konkurrenzmuster, die verfügbar sind, und es funktioniert für Zähler, Quoten, Sitzreservierungen sowie jede Regel, die als Abfragefilter ausgedrückt werden kann.

    Optimistisches Sperren für langanhaltende Änderungen

    Atomare Aktualisierungen können nicht alle Fälle abdecken. Stellen Sie sich vor, ein Mitarbeiter öffnet eine umfangreiche Produktkonfiguration, verbringt fünf Minuten damit, mehrere Felder anzupassen, und speichert sie anschließend. In der Zwischenzeit hat ein Kollege bereits Änderungen am selben Produkt gespeichert. Der erste Mitarbeiter sollte die neuere Version nicht überschreiben, ohne zu wissen, dass sie existiert.

    Die gängige Lösung ist eine auf dem Dokument gespeicherte Versionnummer:

    Product
    Price: $100
    Stock: 10
    Version: 7
    

    Sowohl Benutzer als auch Benutzerin laden Version 7. Benutzer A speichert zuerst, und die Version wird auf 8 erhöht. Benutzerin B hält weiterhin Version 7 fest, sodass ihre Speicherung nur dann wirksam sein sollte, „wenn das Produkt noch bei Version 7 ist“. In MongoDB drückt man das aus, indem man die erwartete Version im Filter angibt und sie bei derselben Aktualisierung erhöht:

    const result = await Product.updateOne(
      {
        _id: productId,
        version: currentVersion
      },
      {
        $set: {
          price: newPrice
        },
        $inc: {
          version: 1
        }
      }
    );
    
    if (result.modifiedCount === 0) {
      return res.status(409).json({
        message: "This product was updated by another user."
      });
    }
    

    Falls der Filter nicht mehr übereinstimmt, wird nichts geschrieben und der Handler gibt einen Konflikt zurück, anstatt die Arbeit von A stillschweigend zu ignorieren. Dies ist eine optimistische Konkurrenzsteuerung: Man geht davon aus, dass Konflikte selten sind, hält während der Bearbeitung durch den Benutzer keine Sperrmechanismen und erkennt den Konflikt erst beim Schreiben.

    Zwei Verbesserungen machen dies in der Praxis robuster. Erstens ergibt sich modifiedCount === 0 auch dann, wenn das Produkt überhaupt nicht existiert; daher ermöglicht die Überprüfung von matchedCount oder eine anschließende Abfrage, für fehlende Produkte 404 und nur bei einem echten Versionskonflikt 409 zurückzugeben. Zweitens: Wenn Sie Mongoose-Dokumente statt von updateOne verwenden, sollten Sie die auf Schema-Ebene verfügbare optimisticConcurrency-Option prüfen; Mongoose’ eingebaute __v-Spalte wird andernfalls nur zur Absicherung bestimmter Array-Operationen verwendet, nicht bei jeder Speicherung.

    Warum der richtige Status 409 Conflict ist

    Eine Versionskonfliktsituation stellt keinen Serverfehler dar. Die API ist funktionsfähig und die Anfrage ist ordnungsgemäß gestaltet; sie steht lediglich im Widerspruch zum aktuellen Zustand der Ressource. 409 Conflict macht genau das deutlich und ermöglicht es dem Client, angemessen zu reagieren:

    • die neueste Version neu zu laden;
    • dem Benutzer anzuzeigen, was sich seit seinem letzten Zugriff geändert hat;
    • ihm die Möglichkeit zu geben, seine Änderungen zusammenzuführen oder es erneut zu versuchen;
    • Konfliktlösungen anzuwenden, die speziell auf das Produkt zugeschnitten sind.

    Egal, was die Benutzeroberfläche tut – das Prinzip bleibt dasselbe: Niemals die Arbeit eines anderen ohne Vorankündigung zerstören.

    Transaktionen: Wenn mehrere Schreibvorgänge gemeinsam erfolgreich sein müssen

    Betrachten Sie nun die Auftragserteilung. Dazu kann es erforderlich sein, ein Bestelldokument zu erstellen, Lagerbestände zu reservieren sowie entsprechende Einträge wie Zahlungs- oder Prüfungsdaten anzufertigen. Wenn die ersten beiden Schritte erfolgreich sind und der dritte fehlschlägt, bleibt das System in einem halbfertigen Zustand zurück.

    Wenn mehrere Operationen entweder alle erfolgreich abgeschlossen oder alle fehlschlagen müssen, gewährleistet eine Transaktion diese Atomarität. Konzeptionell beginnt man mit einer Transaktion, führt die damit verbundenen Schreibvorgänge aus und bestätigt sie; falls ein erforderlicher Schritt fehlschlägt, wird alles rückgängig gemacht und keiner der Schreibvorgänge tritt in Kraft. In MongoDB erfordern mehrerdokumentenbezogene Transaktionen ein Replica Set oder einen sharded Cluster und werden über eine Client-Sitzung abgewickelt.

    Transaktionen sind jedoch keine universelle Lösung für alle Probleme. Sie sind teurer, können bei Schreibkonflikten fehlschlagen und erfordern eine Wiederholungslogik. Verwenden Sie sie dort, wo die Geschäftsprozesse tatsächlich eine eindeutige Konsistenz erfordern, und bevorzugen Sie bei ausreichender Effektivität eine einzige bedingte Aktualisierung.

    Die richtige Werkzeugwahl

    Anstatt mit der Frage „Sollten wir optimistisches Verriegeln verwenden?“ zu beginnen, fragen Sie lieber „Welchen Fehler wollen wir verhindern?“:

    • Eine einfeldrige oder bedingte Änderung, wie zum Beispiel die Reduzierung des Bestands nur, wenn dieser verfügbar ist: verwenden Sie eine atomare Aktualisierung.
    • Veraltete Änderungen von Benutzern, die mit alten Daten arbeiten, wie zum Beispiel zwei Admins, die ein Produkt bearbeiten: verwenden Sie optimistische Konkurrenzsteuerung.
    • Mehrere Schreibvorgänge, die entweder gemeinsam erfolgreich oder gescheitert sein müssen, wie zum Beispiel Änderungen an Bestellungen, Lagerbeständen und Konten: verwenden Sie Transaktionen.
  • Sehr hoher Wettbewerb um dieselben Daten: Je nach Arbeitslast sollten pessimistisches Sperren, Anstehen der Aufgaben oder deren Partitionierung in Betracht gezogen werden.
  • Kein einziges Muster passt zu jedem System, und viele tatsächliche Szenarien kombinieren zwei davon.

    Die Ressourcenkonflikte in Tests nachbilden

    Ein Test, bei dem Benutzer A ein Produkt aktualisiert und eine Erfolgsantwort erhält, sagt nichts über Konkurrenz aus. Normale Tests führen die Operationen nacheinander aus, genau deshalb überleben diese Fehler sie. Um sie zu testen, muss man die Ressourcenkonflikte absichtlich erzeugen.

    Für den Lagerfall sollte man beispielsweise mit einem Lagerbestand von 1 starten und gleichzeitig 100 Kaufversuche durchführen, zum Beispiel mit Promise.all. Das erwartete Ergebnis ist, dass genau eine Reservierung erfolgreich ist und die anderen 99 sauber abgelehnt werden, wobei der Lagerbestand bei null endet und nicht negativ wird.

    Für das optimistische Sperren setzen Sie die Version auf 10 und senden mehrere Aktualisierungen, von denen jede die Version 10 angibt. Sie sollten sehen, dass eine davon erfolgreich ist und die Version erhöht, während die übrigen Aufgaben aufgrund von Konflikten abgelehnt werden anstelle dass die neueren Daten überschrieben werden.

    Was in der Produktion im Auge behalten werden sollte

    Nach dem Deployment sollten diese Signale in Ihren Metriken und Protokollen sichtbar sein:

    • 409 Conflict-Antworten;
    • bedingte Aktualisierungen, die nichts gefunden haben;
    • Wiederholversuche und Abbrüche von Transaktionen;
    • Totalsperren und Ressourcenkonflikte beim Sperren;
    • unerwartete Bewegungen des Bestands;
    • doppelte Operationen;
    • weitere mit Konkurrenz zusammenhängende Fehler.

    Ein plötzlicher Anstieg der Konflikte weist oft auf etwas Größeres hin: eine hohe Temperatur, ein ungewöhnliches Verkehrsmuster, Kunden, die zu aggressiv versuchen, eine Aktion auszuführen, oder eine neue Funktion, die mehr Konkurrenz verursacht, als jemand erwartet hat. Insbesondere werden doppelte Operationen oft besser mit Idempotenzschlüsseln bewältigt, wie in unserem Leitfaden zu idempotenten POST-Endpunkten erläutert.

    Konkurrenz ist der Normalfall

    Race Conditions haben eigentlich nichts mit zwei Menschen zu tun, die zur gleichen Zeit klicken. Sie entstehen immer dann, wenn mehrere Akteure den gemeinsamen Zustand ändern können: Benutzer, API-Instanzen, Hintergrundprozesse, Warteschlangenverarbeiter, geplante Aufgaben, Webhooks und andere Dienste. In jeder sinnvollen Skalierung ist konkurrierender Zugriff die Regel und nicht die Ausnahme.

    Fragen Sie also nicht, ob zwei Anfragen gleichzeitig denselben Code erreichen könnten; gehen Sie davon aus, dass sie es tun werden. Eine nützliche Gewohnheit bei der Überprüfung ist, sich für jede Aktualisierung zu fragen, wie das Ergebnis aussehen würde, wenn zwei Kopien davon gleichzeitig ausgeführt würden. Wenn die Architektur darauf klar antworten kann, sind Sie auf gutem Weg. Wenn die ehrliche Antwort lautet „hoffentlich ist die zweite Anfrage unbedenklich“, benötigt der Code weitere Überprüfungen.

    Kernpunkte

    • Eine verlorene Aktualisierung tritt auf, wenn eine gültige Änderung durch eine andere, aus veralteten Daten erstellte Änderung überschrieben wird – typischerweise durch Lesen, Ändern und Schreiben.
    • Ziehen Sie atomare, bedingte Aktualisierungen vor, bei denen die Geschäftsregel im Filter kodiert ist.
    • Verwenden Sie ein Versionsfeld sowie eine 409 Conflict-Antwort, um veraltete Änderungen zu erkennen, anstatt sie einfach zu überschreiben.
    • wenden Sie Transaktionen nur dann an, wenn mehrere Schreibvorgänge gemeinsam bestätigt oder rückgängig gemacht werden müssen.
  • Testen Sie mit tatsächlich gleichzeitigen Anfragen und überwachen Sie Konflikte in der Produktion; gestalten Sie Ihre Lösungen so, dass sie auch überschneidende Anfragen bewältigen können, nicht nur die, die Sie erwarten.
  • Verwandte Artikel