Strona główna / Artykuły / Zapobieganie utracie aktualizacji w Node.js i MongoDB podczas równoczesnych zapisów

Zapobieganie utracie aktualizacji w Node.js i MongoDB podczas równoczesnych zapisów

Dowiedz się, w jaki sposób warunkowe aktualizacje atomowe, optymistyczne blokowanie oparte na wersjach, odpowiedzi 409 oraz transakcje zapobiegają temu, by równoczesne zapisy w MongoDB potajemnie usuwały dane.

2030 słów

Dwie osoby naciskają przycisk „Zapisz” w odniesieniu do tej samej rekordu, obie prośby zwracają 200 OK, a zmiana jednej osoby po prostu znika. Nic się nie zawiesza, nic nie jest rejestrowane, a kod odpowiedzialny wydaje się zupełnie rozsądny przy analizie pojedynczej prośby. Ten artykuł wyjaśnia, dlaczego takie utracone aktualizacje występują w typowym backendzie opartym na Node.js i MongoDB, oraz dostarcza narzędzia do ich zapobiegania: atomowe aktualizacje warunkowe, optymistyczne blokowanie oparte na wersjach, odpowiedzi 409 Conflict, transakcje oraz testy, które rzeczywiście odtwarzają ten problem.

Rzeczywisty scenariusz

Weźmy panel administracyjny sklepu internetowego. Jeden produkt ma obecnie cenę 100 dolarów i 10 sztuk na stanie.

Pracownik w Stanach Zjednoczonych otwiera produkt i obniża jego cenę do 90 dolarów. Niemal jednocześnie kolega w Europie otwiera ten sam produkt i ustawia ilość na 8. Obaj załadowali produkt przed zapisaniem zmian, więc edytują tę samą przestarzałą wersję danych. To wspólne punkt wyjścia jest początkiem problemów.

Luka między odczytem, modyfikacją a zapisem

Powszechnym sposobem na realizację każdej modyfikacji jest załadowanie dokumentu, zmiana danej w pamięci i jej zapisanie. Pierwsza prośba zmienia cenę. (Przykładowy fragment zawiera dodatkowy, powtórzony wiersz product.price = 90; po wywołaniu save(); należy go zignorować, ponieważ służy jedynie do ilustracji schematu.)

const product = await Product.findById(productId);

product.price = 90;

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

Druga prośba robi to samo z polem ilości:

const product = await Product.findById(productId);

product.stock = 8;

await product.save();

Każda prośba odczytuje stary stan, modyfikuje jego lokalną kopię i zapisuje ją z powrotem. findById() nie jest przyczyną problemu. Zagrożenie tkwi w oknie czasowym pomiędzy odczytaniem danych a ich zapisem, ponieważ inna prośba może zmienić ten sam rekord w tym oknie. To, co zostanie zapisane jako ostatnie, zwycięża, a wszelkie zmiany dokonane pomiędzy nimi mogą zostać przepisane: taka jest sytuacja utraconej aktualizacji.

Ile z tego już obsługuje Mongoose

Warto być precyzyjnym co do sytuacji, w których to się zdarza. Gdy wywołujemy save() na istniejącym dokumencie Mongoose, biblioteka wysyła tylko te pola, które zmodyfikowaliśmy, w formie $set. Dlatego w powyższym przykładzie, gdzie dwie prośby dotyczą różnych pól, edycje ceny i stanów zapasów zazwyczaj obie przetrwają. Utracona aktualizacja staje się rzeczywistością wtedy, gdy:

  • obie prośby zmieniają tę samą pola (dwóch administratorów edytujących cenę);
  • nowa wartość jest wyliczana na podstawie starej (product.stock = product.stock - 1), więc drugi użytkownik pracuje z przestarzałą liczbą;
  • wasz API przyjmuje od klienta cały dokument, jak w typowym obsługiwaczu PUT, i zapisuje z powrotem wszystkie pola, włączając te, które właśnie zmienił inny użytkownik.

Inne ODM-y, surowe sterowniki oraz SQL ORM-y zachowują się inaczej, więc nie polegajcie na śledzeniu stanu jako strategii konkurowania. Traktujcie operacje czytaj-zmieniaj-zapisz jako niesecure przez domyślność i celowo wybierzcie jedno z poniższych narzędzi.

Zaczynaj od niezmiennej

Zanim wybierzecie technikę, zdecydujcie, co nie może nigdy pójść nie tak. Odpowiedź różni się w zależności od dziedziny:

  • Dla zapasów: ilość magazynowa nie może nigdy spaść poniżej zera.
  • Dla edytora administracyjnego: zmiany jednej osoby nigdy nie powinny w tajemnicy zastępować zmian innej osoby.
  • Jeśli chodzi o pieniądze: zmiany salda muszą być ze sobą spójne.
  • To reguła biznesowa, a nie ulubiony wzorzec, powinna określać rozwiązanie techniczne.

    Atomowe aktualizacje: niech baza danych dokona zmiany

    Gdy zmieniasz pojedyncze pole, często w ogóle nie musisz czytać dokumentu. Wyślij do bazy danych dokładnie tę zmianę, którą zamierzasz wprowadzić. Ustawienie ceny polega wtedy na jednej operacji updateOne z użyciem parametru $set:

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

    Edycja stanów magazynowych jest równie niezależna:

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

    Każda operacja teraz opisuje swój rzeczywisty cel, zamiast wysyłać stary kopię dokumentu z powrotem na serwer. MongoDB realizuje każdą aktualizację pojedynczego dokumentu w sposób atomowy, więc dwie takie operacje na różnych polach nie mogą się wzajemnie zniszczyć.

    Umieść zasadę biznesową w operacji aktualizacji

    Wzorzec staje się bardziej skuteczny, gdy warunek jest włączony bezpośrednio do zapytania. Załóżmy, że pozostała jedna sztuka towaru. Prosty sposób polega na odczytaniu stanu zapasów, sprawdzeniu go w kodzie aplikacji, a następnie jego zmniejszeniu, co otwiera możliwość zakupu przez innego klienta. Zamiast tego niech filtr wyraża zasadę, a $inc dokona zmiany w ramach tej samej operacji:

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

    Jeśli aktualizacja modyfikuje dokument, oznacza to, że w momencie wykonywania operacji zapasy były dostępne. Jeśli nic nie zmienia, oznacza to, że jakaś inna prośba już wykorzystała ostatnią sztukę, więc można poinformować użytkownika, że towar się wyczerpał. Nie ma żadnego okna czasowego pomiędzy sprawdzeniem a zapisem, ponieważ są to ten sam krok. To jeden z najprostszych i najskuteczniejszych wzorców współdzielenia dostępu, który sprawdza się przy licznikach, kwotach, rezerwacjach miejsc oraz wszelkich zasadach, które można sformułować jako filtr zapytania.

    Zamknięcie optymistyczne dla długotrwałych edycji

    Aktualizacje atomowe nie mogą obejmować wszystkich przypadków. Wyobraźmy sobie pracownika, który otwiera skomplikowaną konfigurację produktu, spędza pięć minut na dostosowywaniu kilku pól, a następnie zapisuje zmiany. Tymczasem kolega już zapisał swoje zmiany w tym samym produkcie. Pierwszy pracownik nie powinien nadpisać nowszej wersji, nie wiedząc o jej istnieniu.

    Standardowym rozwiązaniem jest przechowywanie numeru wersji w dokumencie:

    Product
    Price: $100
    Stock: 10
    Version: 7
    

    Oba użytkownicy ładują wersję 7. Użytkownik A zapisuje zmiany jako pierwszy, a numer wersji staje się 8. Użytkownik B nadal ma wersję 7, więc jego zapis musi oznaczać „zastosuj te zmiany tylko wtedy, gdy produkt nadal ma wersję 7”. W MongoDB wyraża się to poprzez umieszczenie oczekiwanej wersji w filtrze i jej zwiększenie podczas tej samej aktualizacji:

    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."
      });
    }
    

    Jeśli filtr już nie pasuje, nic nie jest zapisywane, a obsługa zwraca informację o konflikcie zamiast po cichu odrzucić prace A. To jest optymistyczna kontrola współbieżności: zakłada się, że konflikty są rzadkie, nie trzyma się żadnych blokad podczas edycji przez użytkownika i wykrywa się kolizję w momencie zapisu.

    Dwie modyfikacje sprawiają, że rozwiązanie jest bardziej odporne w praktyce. Po pierwsze, wartość modifiedCount === 0 występuje również wtedy, gdy produkt w ogóle nie istnieje, więc sprawdzenie matchedCount lub dodatkowe wyszukiwanie pozwala zwrócić kod 404 w przypadku braku produktu, a 409 tylko przy rzeczywistym konflikcie wersji. Po drugie, jeśli używasz dokumentów Mongoose zamiast metody updateOne, sprawdź opcję optimisticConcurrency na poziomie schematu; wbudowana w Mongoose klucz __v służy w przeciwnym razie wyłącznie do ochrony określonych operacji na tablicach, a nie każdego zapisu.

    Dlaczego właściwy stan to 409 Conflict

    Różnica w wersjach nie jest awarią serwera. API funkcjonuje prawidłowo, a żądanie jest poprawnie sformułowane; po prostu koliduje z obecnym stanem zasobu. 409 Conflict dokładnie to komunikuje i umożliwia klientowi rozsądne reagowanie:

    • ponowne załadowanie najnowszej wersji;
    • pokazanie użytkownikowi, co się zmieniło od jego ostatniej akcji;
    • umożliwienie mu połączenia swoich zmian lub ponownej próby;
    • Zastosowanie specyficznych dla danego produktu procedur radzenia sobie z konfliktami.

    Niezależnie od tego, co zrobi interfejs użytkownika, zasada pozostaje ta sama: nigdy nie niszczyć czyjegoś pracy bez uprzedzenia.

    Transakcje: gdy kilka zapisów musi odbyć się pomyślnie jednocześnie

    Zastanówmy się teraz nad złożeniem zamówienia. Może to wymagać stworzenia dokumentu zamówienia, zarezerwowania zapasów oraz zapisania odpowiednich rekordów, takich jak informacja o płatności lub wpis audytowy. Jeśli pierwsze dwa kroki się udają, a trzeci zawodzi, system pozostaje w stanie nieukończonej operacji biznesowej.

    Gdy kilka operacji musi albo wszystkie zostać zrealizowane, albo wszystkie zawieść, transakcja zapewnia taką atomowość. Koncepcyjnie rozpoczynamy transakcję, wykonywamy odpowiednie zapisy i je potwierdzamy; jeśli któryś z wymaganych kroków się nie powiedzie, transakcja jest cofana i żaden z zapisów nie ma efektu. W MongoDB transakcje wielodokumentowe wymagają zestawu replik lub klastra shardowanego i są realizowane poprzez sesję klienta.

    Transakcje nie są jednak uniwersalnym rozwiązaniem problemów związanych z konkurencją. Są droższe, mogą zostać przerwane w przypadku konfliktów przy zapisie i wymagają specjalnej logiki ponawiania prób. Należy je używać tam, gdzie operacja biznesowa naprawdę wymaga spójności typu „wszystko albo nic”, a w pozostałych przypadkach lepiej zastosować pojedynczą aktualizację warunkową, jeśli to wystarczy.

    Wybór odpowiedniego narzędzia

    Zamiast zaczynać od pytania „czy powinniśmy używać optymistycznego blokowania?”, zacznij od pytania „jakiego błędu chcemy zapobiec?”:

    • Jednoznaczna zmiana jednego pola lub warunkowa aktualizacja, np. obniżanie ilości zapasów tylko wtedy, gdy są dostępne: użyj aktualizacji atomowej.
    • Zastarzałe edycje dokonywane przez użytkowników pracujących z starymi danymi, np. dwóch administratorów edytujących ten sam produkt: użyj optymistycznego sterowania konkurencją.
    • Kilka zapisów, które muszą albo wszystkie się powieść, albo wszystkie zawieść, np. zmiany w zamówieniach, zapasach i kontach: użyj transakcji.
  • Bardzo wysoka konkurencja o te same dane: w zależności od obciążenia systemu rozważ zastosowanie blokowania pesymistycznego, kolejek do wykonywania zadań lub ich podziału.
  • Żaden jedyny wzorzec nie pasuje do każdego systemu, a wiele rzeczywistych rozwiązań łączy dwa z nich.

    Odtworzenie problemu konkurencji w testach

    Test, w którym użytkownik A aktualizuje produkt i otrzymuje odpowiedź potwierdzającą sukces, nic nie dowodzi na temat współbieżności. Zwykłe testy wykonywają operacje jedna po drugiej, i właśnie dlatego te błędy przetrwają je. Aby je wykryć, trzeba samodzielnie stworzyć sytuację konkurencji.

    W przypadku zarządzania zapasami zacznij od ustawienia ilości na 1 i jednocześnie uruchom 100 prób zakupu, na przykład za pomocą Promise.all. Oczekiwany wynik to taki, że dokładnie jedna rezerwacja się powiedzie, a pozostałe 99 zostaną odrzucone, przy czym ilość zapasów spadnie do zera, a nie stanie się ujemna.

    Dla blokowania optymistycznego ustaw wersję na 10 i wyślij kilka aktualizacji, z których każda podaje wersję 10. Powinieneś zobaczyć, że jedna z nich odniesie sukces i podniesie wersję, podczas gdy pozostałe otrzymają komunikaty o konflikcie zamiast nadpisać nowsze dane.

    Na co zwrócić uwagę w produkcji

    Po wdrożeniu upewnij się, że następujące sygnały będą widoczne w Twoich metrykach i logach:

    • odpowiedzi typu 409 Conflict;
    • aktualizacje warunkowe, które nie pasowały do żadnych danych;
    • ponawiane próby transakcji i ich przerwanie;
    • deadlocki oraz konflikty blokad;
    • niespodziewane zmiany w stanach magazynowych;
    • powtórzone operacje;
    • inne błędy związane z równoczesnością.

    Nagły wzrost liczby konfliktów często wskazuje na coś głębszego: ekstremalnie wysokie temperatury, nietypowy wzorzec ruchu, klienci próbujący zbyt agresywnie, albo nowa funkcjonalność powodująca większe konflikty, niż ktokolwiek się spodziewał. Szczególnie operacje duplikowane są często lepiej radzone sobie za pomocą kluczy idempotentnych, o których mowa w naszym przewodniku po idempotentnych punktach końcowych POST.

    Konkurencja to norma

    Konflikty wynikające z jednoczesnych działań nie dotyczą tak naprawdę dwóch osób klikających w tym samym momencie. Pojawiają się, gdy kilka elementów może modyfikować wspólny stan: użytkownicy, instancje API, zadania w tle, konsumenci kolejek, zadania zaplanowane, webhooki oraz inne usługi. Na każdym znaczącym poziomie dostęp konkurencyjny jest normą, a nie wyjątkiem.

    Zatem nie pytaj, czy dwa żądania mogą jednocześnie dotrzeć do tego samego kodu; zakładaj, że tak będzie. Przydatnym nawykiem podczas przeglądania kodu jest pytanie o to, jaki byłby wynik, gdyby dwie kopie aktualizacji zostały wykonyane jednocześnie. Jeśli projekt daje na to jasną odpowiedź, masz wszystko w porządku. Jeśli szczera odpowiedź brzmi „miejmy nadzieję, że drugie żądanie nie jest szkodliwe”, kod wymaga dalszej analizy.

    Główne wnioski

    • Utracona aktualizacja występuje wtedy, gdy jedna ważna zmiana zostaje nadpisana przez inną utworzoną na podstawie przestarzałych danych, zwykle poprzez operację odczyt-zmiana-zapis.
    • Niech preferowane będą aktualizacje atomowe i warunkowe, które zawierają reguły biznesowe w filtrze.
    • Aby wykryć przestarzałe edycje, użyj pola wersji oraz odpowiedzi 409 Conflict, zamiast je nadpisywać.
    • Używaj transakcji tylko wtedy, gdy kilka zapisów musi zostać zatwierdzonych lub odwołanych razem.
  • Przeprowadź testy z rzeczywistymi, jednoczesnymi żądaniami i monitoruj konflikty w środowisku produkcyjnym; projektuj tak, aby uwzględnić możliwość nakładania się żądań, a nie tylko te, których oczekujesz.
  • Literatura pokrewna