Django: Jak mogę zabezpieczyć się przed jednoczesną modyfikacją wpisów w bazie danych

81

Czy istnieje sposób ochrony przed równoczesnymi modyfikacjami tego samego wpisu w bazie danych przez dwóch lub więcej użytkowników?

Dopuszczalne byłoby wyświetlenie komunikatu o błędzie użytkownikowi wykonującego drugą operację zatwierdzenia / zapisania, ale dane nie powinny być po cichu nadpisywane.

Myślę, że blokowanie wpisu nie wchodzi w grę, ponieważ użytkownik może użyć przycisku „Wstecz” lub po prostu zamknąć przeglądarkę, pozostawiając blokadę na zawsze.

Ber
źródło
4
Jeśli jeden obiekt może być aktualizowany przez wielu współbieżnych użytkowników, możesz mieć większy problem z projektem. Warto pomyśleć o zasobach specyficznych dla użytkownika lub podzielić etapy przetwarzania na osobne tabele, aby nie było to problemem.
S.Lott

Odpowiedzi:

48

Oto jak optymistyczne blokowanie w Django:

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
          .update(updated_field=new_value, version=e.version+1)
if not updated:
    raise ConcurrentModificationException()

Kod wymieniony powyżej można zaimplementować jako metodę w Custom Manager .

Przyjmuję następujące założenia:

  • filter (). update () zwróci jedno zapytanie do bazy danych, ponieważ filtr jest leniwy
  • zapytanie do bazy danych jest niepodzielne

Te założenia są wystarczające, aby zapewnić, że nikt inny nie zaktualizował wcześniej wpisu. Jeśli w ten sposób zaktualizowanych jest wiele wierszy, należy użyć transakcji.

OSTRZEŻENIE Django Doc :

Należy pamiętać, że metoda update () jest konwertowana bezpośrednio na instrukcję SQL. Jest to operacja zbiorcza do bezpośrednich aktualizacji. Nie uruchamia żadnych metod save () na twoich modelach ani nie emituje sygnałów pre_save lub post_save

Andrei Savu
źródło
12
Miły! Czy jednak nie powinno to być „&” zamiast „&&”?
Giles Thomas,
1
Czy mógłbyś ominąć problem braku uruchamiania metod save () przez „update”, umieszczając wywołanie „update” wewnątrz własnej, nadpisanej metody save ()?
Jonathan Hartley
1
Co się dzieje, gdy dwa wątki jednocześnie wywołują filter, oba otrzymują identyczną listę z niezmodyfikowaną e, a następnie oba jednocześnie wywołują update? Nie widzę semafora, który blokowałby jednocześnie filtrowanie i aktualizację. EDYCJA: och, rozumiem teraz leniwy filtr. Ale jaka jest słuszność założenia, że ​​update () jest atomowy? z pewnością DB obsługuje równoczesny dostęp
totowtwo 2
1
@totowtwo I in ACID gwarantuje zamawianie ( en.wikipedia.org/wiki/ACID ). Jeśli AKTUALIZACJA jest wykonywana na danych odnoszących się do współbieżnych (ale uruchomionych później) SELECT, będzie blokować aż do zakończenia UPDATE. Jednak jednocześnie można wykonać wiele operacji SELECT.
Kit Sunde
1
Wygląda na to, że będzie to działać poprawnie tylko w trybie automatycznego zatwierdzania (który jest domyślny). W przeciwnym razie ostateczne polecenie COMMIT zostanie oddzielone od tej aktualizującej instrukcji SQL, więc współbieżny kod będzie mógł działać między nimi. W Django mamy poziom izolacji ReadCommited, więc będzie czytać starą wersję. (Dlaczego chcę tutaj ręcznie przeprowadzić transakcję - ponieważ chcę utworzyć wiersz w innej tabeli wraz z tą aktualizacją). Świetny pomysł.
Alex Lokk
39

To pytanie jest trochę stare, a moja odpowiedź trochę późna, ale po tym, co rozumiem, zostało to naprawione w Django 1.4 za pomocą:

select_for_update(nowait=True)

zobacz dokumentację

Zwraca zestaw zapytań, który zablokuje wiersze do końca transakcji, generując instrukcję SQL SELECT ... FOR UPDATE w obsługiwanych bazach danych.

Zwykle, jeśli inna transakcja już uzyskała blokadę w jednym z wybranych wierszy, zapytanie będzie blokowane do momentu zwolnienia blokady. Jeśli nie jest to pożądane zachowanie, wywołaj metodę select_for_update (nowait = True). Dzięki temu połączenie nie będzie blokowane. Jeśli blokada powodująca konflikt została już uzyskana przez inną transakcję, podczas oceny zestawu zapytań zostanie zgłoszony błąd DatabaseError.

Oczywiście zadziała to tylko wtedy, gdy zaplecze obsługuje funkcję „wybierz do aktualizacji”, co na przykład sqlite nie. Niestety: nowait=Truenie jest obsługiwany przez MySql, tam musisz użyć:, nowait=Falsektóry będzie blokował się tylko do momentu zwolnienia blokady.

giZm0
źródło
2
To nie jest dobra odpowiedź - pytanie wyraźnie nie chciało (pesymistycznego) blokowania, a dwie odpowiedzi, które uzyskały wyższą liczbę głosów, koncentrują się obecnie na optymistycznej kontroli współbieżności („optymistycznym blokowaniu”) z tego powodu. Opcja Select-for-update jest jednak w porządku w innych sytuacjach.
RichVel
@ giZm0 To nadal sprawia, że ​​blokowanie jest pesymistyczne. Pierwszy wątek, który uzyska blokadę, może go utrzymać na czas nieokreślony.
knaperek
6
Podoba mi się ta odpowiedź, ponieważ dotyczy dokumentacji Django, a nie pięknego wynalazku jakiejkolwiek osoby trzeciej.
anizzomc
29

W rzeczywistości transakcje nie pomagają Ci tutaj zbytnio ... chyba że chcesz, aby transakcje były uruchamiane przez wiele żądań HTTP (których najprawdopodobniej nie chcesz).

To, czego zwykle używamy w takich przypadkach, to „optymistyczne blokowanie”. O ile wiem, ORM Django tego nie obsługuje. Ale była dyskusja na temat dodania tej funkcji.

Więc jesteś sam. Zasadniczo powinieneś dodać pole „wersja” do swojego modelu i przekazać je użytkownikowi jako pole ukryte. Normalny cykl aktualizacji to:

  1. odczytać dane i pokazać je użytkownikowi
  2. użytkownik modyfikuje dane
  3. użytkownik publikuje dane
  4. aplikacja zapisuje go z powrotem w bazie danych.

Aby zaimplementować optymistyczne blokowanie, podczas zapisywania danych należy sprawdzić, czy wersja otrzymana od użytkownika jest taka sama, jak wersja w bazie danych, a następnie zaktualizować bazę danych i zwiększyć wersję. Jeśli tak nie jest, oznacza to, że nastąpiła zmiana od czasu wczytania danych.

Możesz to zrobić za pomocą pojedynczego wywołania SQL za pomocą czegoś takiego:

UPDATE ... WHERE version = 'version_from_user';

To wywołanie zaktualizuje bazę danych tylko wtedy, gdy wersja jest nadal taka sama.

Guillaume
źródło
1
To samo pytanie pojawiło się również w Slashdot. Zaproponowano również
blokadę
5
Zwróć również uwagę, że chcesz dodatkowo używać transakcji, aby uniknąć takiej sytuacji: hardware.slashdot.org/comments.pl?sid=1381511&cid=29536613 Django zapewnia oprogramowanie pośredniczące, które automatycznie opakowuje każdą akcję w bazie danych w transakcję, rozpoczynając od początkowego żądania i zatwierdzanie dopiero po udanej odpowiedzi: docs.djangoproject.com/en/dev/topics/db/transactions ( uwaga : oprogramowanie pośredniczące transakcji pomaga tylko uniknąć powyższego problemu dzięki optymistycznemu blokowaniu, nie zapewnia blokowania sama)
hopla
Szukam też szczegółów, jak to zrobić. Na razie bez szczęścia.
seanyboy
1
możesz to zrobić za pomocą zbiorczych aktualizacji django. sprawdź moją odpowiedź.
Andrei Savu
14

Django 1.11 ma trzy wygodne opcje radzenia sobie z tą sytuacją w zależności od wymagań logiki biznesowej:

  • Something.objects.select_for_update() zablokuje się, dopóki model nie zostanie zwolniony
  • Something.objects.select_for_update(nowait=True)i złap, DatabaseErrorjeśli model jest obecnie zablokowany do aktualizacji
  • Something.objects.select_for_update(skip_locked=True) nie zwróci obiektów, które są aktualnie zablokowane

W mojej aplikacji, która ma zarówno interaktywne, jak i wsadowe przepływy pracy w różnych modelach, znalazłem te trzy opcje, aby rozwiązać większość moich współbieżnych scenariuszy przetwarzania.

„Czekanie” select_for_updatejest bardzo wygodne w sekwencyjnych procesach wsadowych - chcę, aby wykonały wszystkie, ale nie spiesz się. PliknowaitJest używany, gdy użytkownik chce zmodyfikować obiekt, który jest obecnie zamknięty dla aktualizacji - Ja po prostu powiedz im, że to jest modyfikowana w tej chwili.

Jest skip_lockedto przydatne w przypadku innego rodzaju aktualizacji, gdy użytkownicy mogą wywołać ponowne skanowanie obiektu - i nie obchodzi mnie, kto go wyzwala, o ile jest wyzwalany, więc skip_lockedpozwala mi po cichu pominąć zduplikowane wyzwalacze.

kravietz
źródło
1
Czy muszę zawijać wybór do aktualizacji za pomocą transaction.atomic ()? Jeśli faktycznie wykorzystuję wyniki do aktualizacji? Czy nie zablokuje całej tabeli, czyniąc select_for_update noop?
Paul Kenjora
3

W przyszłości zajrzyj na https://github.com/RobCombs/django-locking . Blokuje w sposób, który nie pozostawia wiecznych blokad, poprzez połączenie odblokowywania javascript, gdy użytkownik opuszcza stronę, i limitów czasu blokady (np. W przypadku awarii przeglądarki użytkownika). Dokumentacja jest całkiem kompletna.

Stijn Debrouwere
źródło
3
Myślę, że to naprawdę dziwny pomysł.
lipiec
1

Prawdopodobnie powinieneś użyć przynajmniej oprogramowania pośredniczącego do transakcji django, nawet niezależnie od tego problemu.

Jeśli chodzi o rzeczywisty problem z tym, że wielu użytkowników edytuje te same dane ... tak, użyj blokowania. LUB:

Sprawdź, jaką wersję aktualizuje użytkownik (zrób to bezpiecznie, aby użytkownicy nie mogli po prostu włamać się do systemu, aby powiedzieć, że aktualizują najnowszą kopię!) I aktualizuj tylko wtedy, gdy ta wersja jest aktualna. W przeciwnym razie wyślij użytkownikowi z powrotem nową stronę z oryginalną wersją, którą edytował, przesłaną wersją oraz nowymi wersjami napisanymi przez innych. Poproś ich o połączenie zmian w jedną, całkowicie aktualną wersję. Możesz spróbować połączyć je automatycznie za pomocą zestawu narzędzi, takiego jak diff + patch, ale i tak będziesz potrzebować ręcznej metody scalania działającej w przypadku awarii, więc zacznij od tego. Musisz także zachować historię wersji i zezwolić administratorom na cofnięcie zmian, na wypadek gdyby ktoś przypadkowo lub celowo zepsuł scalanie. Ale i tak prawdopodobnie powinieneś to mieć.

Jest bardzo prawdopodobne, że istnieje aplikacja / biblioteka django, która zrobi to za Ciebie.

Lee B.
źródło
Jest to również blokowanie optymistyczne, jak zaproponował Guillaume. Ale wydawało się, że zdobył wszystkie punkty :)
hopla
0

Inną rzeczą, której należy szukać, jest słowo „atomowy”. Niepodzielna operacja oznacza, że ​​zmiana bazy danych zakończy się pomyślnie lub oczywiście zakończy się niepowodzeniem. Szybkie wyszukiwanie pokazuje to pytanie z pytaniem o operacje atomowe w Django.

Harley Holcombe
źródło
Nie chcę wykonywać transakcji ani blokować wielu żądań, ponieważ może to zająć dowolną ilość czasu (i może nigdy się nie skończyć)
Ber
Jeśli transakcja się rozpocznie, musi się zakończyć. Powinieneś zablokować rekord (lub rozpocząć transakcję lub cokolwiek zechcesz zrobić) dopiero po kliknięciu przez użytkownika „wyślij”, a nie po otwarciu rekordu do przeglądania.
Harley Holcombe
Tak, ale mój problem jest inny, ponieważ dwóch użytkowników otwiera ten sam formularz, a następnie obaj zatwierdzają zmiany. Nie sądzę, aby blokowanie było rozwiązaniem tego problemu.
Ber
Masz rację, ale problemem jest to, że to rozwiązanie nie do tego. Jeden użytkownik wygrywa, a drugi otrzymuje komunikat o niepowodzeniu. Im później zablokujesz rekord, tym mniej problemów będziesz mieć.
Harley Holcombe
Zgadzam się. Całkowicie akceptuję komunikat niepowodzenia dla innego użytkownika. Szukam dobrego sposobu na wykrycie tego przypadku (który, jak spodziewam się, będzie bardzo rzadki).
Ber
0

Pomysł powyżej

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
      .update(updated_field=new_value, version=e.version+1)
if not updated:
      raise ConcurrentModificationException()

wygląda świetnie i powinien działać dobrze nawet bez transakcji, które można serializować.

Problem polega na tym, jak zwiększyć głuche zachowanie .save (), aby nie musieć wykonywać ręcznej instalacji hydraulicznej w celu wywołania metody .update ().

Spojrzałem na pomysł Custom Managera.

Planuję zastąpić metodę Manager _update wywoływaną przez Model.save_base () w celu przeprowadzenia aktualizacji.

To jest obecny kod w Django 1.3

def _update(self, values, **kwargs):
   return self.get_query_set()._update(values, **kwargs)

Co należy zrobić IMHO to coś takiego:

def _update(self, values, **kwargs):
   #TODO Get version field value
   v = self.get_version_field_value(values[0])
   return self.get_query_set().filter(Q(version=v))._update(values, **kwargs)

Podobnie musi się stać przy usuwaniu. Jednak usuwanie jest nieco trudniejsze, ponieważ Django implementuje sporo voodoo w tym obszarze poprzez django.db.models.deletion.Collector.

To dziwne, że modrenowe narzędzie, takie jak Django, nie ma wskazówek dotyczących Optimictic Concurency Control.

Zaktualizuję ten post, gdy rozwiążę zagadkę. Miejmy nadzieję, że rozwiązanie będzie w przyjemny, pythonowy sposób, który nie będzie wymagał mnóstwa kodowania, dziwnych widoków, pomijania podstawowych fragmentów Django itp.

Kiril
źródło
-2

Aby była bezpieczna, baza danych musi obsługiwać transakcje .

Jeśli pola są „dowolne”, np. Tekst itp. I musisz pozwolić kilku użytkownikom na edytowanie tych samych pól (nie możesz mieć jednego prawa własności do danych), możesz przechowywać oryginalne dane w zmienna. Kiedy użytkownik zatwierdzi, sprawdź, czy dane wejściowe zmieniły się w stosunku do oryginalnych danych (jeśli nie, nie musisz zawracać sobie głowy DB, przepisując stare dane), czy oryginalne dane w porównaniu z bieżącymi danymi w bazie danych są takie same możesz zapisać, jeśli się zmieniło, możesz pokazać użytkownikowi różnicę i zapytać go, co ma zrobić.

Jeśli pola są liczbami np. Stan konta, ilość towarów w sklepie itp., Możesz obsłużyć to bardziej automatycznie, jeśli obliczysz różnicę między pierwotną wartością (zapisaną, gdy użytkownik zaczął wypełniać formularz) a nową wartością, rozpocznij transakcję odczytaj aktualną wartość i dodaj różnicę, a następnie zakończ transakcję. Jeśli nie możesz mieć wartości ujemnych, powinieneś przerwać transakcję, jeśli wynik jest ujemny, i poinformować o tym użytkownika.

Nie znam django, więc nie mogę dać ci cod3s ..;)

Stein G. Strindhaug
źródło
-6

Stąd:
Jak zapobiec nadpisaniu obiektu zmodyfikowanego przez kogoś innego

Zakładam, że znacznik czasu będzie przechowywany jako ukryte pole w formularzu, w którym próbujesz zapisać szczegóły.

def save(self):
    if(self.id):
        foo = Foo.objects.get(pk=self.id)
        if(foo.timestamp > self.timestamp):
            raise Exception, "trying to save outdated Foo" 
    super(Foo, self).save()
seanyboy
źródło
1
kod jest uszkodzony. sytuacja wyścigu może nadal wystąpić między zapytaniem sprawdzającym if a zapisującym. musisz użyć objects.filter (id = .. & timestamp check) .update (...) i zgłosić wyjątek, jeśli żaden wiersz nie został zaktualizowany.
Andrei Savu