Od CRUD-a do edycji na żywo, czyli jak ogarnąć równoczesne zmiany

Utworzono: czerwiec 2023 Zaktualizowano: 1 października 2026

z pracy

Python, Django, PostgreSQL

Co zrobić, gdy ten sam rekord zmienia kilka osób naraz.

Dwie osoby otwierają ten sam rekord, każda coś zmienia i zapisuje. W zwykłym CRUD-zie drugi zapis nadpisuje pierwszy i nikt się o tym nie dowiaduje. Przy jednym użytkowniku tego nie widać, testy zwykle robi jedna osoba, więc wychodzi to dopiero na produkcji, jako dane, które „same się zmieniły”.

Co można z tym zrobić

Najprościej nic, ostatni zapis wygrywa. Czasem to naprawdę wystarcza, np. kiedy rekord edytuje zawsze jedna osoba.

Pierwsze, co przychodzi do głowy, to blokada przy otwarciu, tak jak w systemie, przy którym pracowałem lata temu: pierwsza osoba blokuje, druga widzi, kto trzyma, i może poprosić o zwolnienie. Nic nie ginie, za to blokady zostają po ludziach, którzy wyszli z otwartym formularzem, więc trzeba je zwalniać ręcznie albo po czasie.

Zamiast blokować, można trzymać w rekordzie wersję i przy zapisie sprawdzać, czy nikt go w międzyczasie nie zmienił. Blokad nie ma, a konflikt wyskakuje tylko przy prawdziwej kolizji.

Albo w ogóle nie edytować danych wprost, tylko składać propozycje zmian, które przechodzą zatwierdzanie, jak ankiety w systemie, przy którym teraz pracuję. Każda zmiana ma autora i zatwierdzającego, zmiany różnych fragmentów nie wchodzą sobie w drogę, tylko że zmiana nie jest widoczna od razu.

No i jest edycja na żywo, jak w Google Docs, gdzie kilka osób pisze naraz i widzi się nawzajem. Pod spodem są algorytmy scalania zmian, OT albo CRDT, i gotowe biblioteki, np. Yjs, ale to już zupełnie inna bajka niż formularz z przyciskiem „Zapisz”.

Co bym wybrał

Najczęściej wybrałbym wersję w rekordzie, bo jest tania i nikogo nie blokuje, a propozycje zmian tam, gdzie zmiany i tak trzeba zatwierdzać, tak jak u nas przy ankietach.

Wersja w rekordzie w Django to jedno pole i warunek przy zapisie:

python
from django.db.models import F

from documents.models import Document


class Conflict(Exception):
    pass


def save_document(doc_id: int, version: int, **changes) -> None:
    updated = Document.objects.filter(pk=doc_id, version=version).update(**changes, version=F("version") + 1)
    if not updated:
        raise Conflict("Ktoś zmienił ten dokument w międzyczasie")

Jak update nie zmieni żadnego wiersza, to ktoś był szybszy, i zamiast nadpisać jego zmiany pokazujemy konflikt.