Optimistic vs. pessimistic locking – czyli co się dzieje, gdy kilka procesów chce zmienić to samo

Praktyczne porównanie optimistic vs. pessimistic locking w relacyjnych bazach oraz mechanizmu revision ID w MongoDB. Przykłady kodu i omówienie strategii.

W czym problem?

Gdy aplikacja działa w pojedynkę, świat jest prosty: odczytujesz dane, zmieniasz je i zapisujesz z powrotem. Ale w prawdziwych systemach nic nie dzieje się „po kolei”. Kilka procesów, użytkowników może chcieć modyfikować ten sam obiekt w tej samej chwili, a to prowadzi do przepychanek, nadpisywania zmian albo subtelnych błędów, które potrafią ujawnić się dopiero po czasie.

Stąd właśnie całe zamieszanie z mechanizmami blokowania. Jedne zakładają, że konflikt zdarzy się rzadko i lepiej po prostu wykryć go przy zapisie. Inne wolą nie ryzykować i od razu przejąć wyłączną kontrolę nad danymi na czas operacji. Oba podejścia mają sens, oba potrafią uratować skórę i oba – niewłaściwie użyte – mogą napsuć krwi.

Jakie mamy opcje: optimistic vs. pessimistic

Przedstawię teraz dwa tytułowe podejścia do rozwiązania problemu, który pojawia się zawsze wtedy, gdy kilka rzeczy próbuje zmienić to samo w tym samym czasie. Obie opisywane strategie działają, ale bazują na zupełnie innych założeniach o tym, jak wygląda świat.

Optymistyczne podejście – „pewnie nic się nie stanie”

W podejściu optymistycznym zakładamy, że konflikty to raczej wyjątek niż norma. Każdy pobiera dane, obrabia je sobie spokojnie i dopiero przy próbie zapisu sprawdza, czy ktoś inny nie zdążył ich zmienić w międzyczasie. Jeśli się okazuje, że wersja danych jest już inna niż była, operacja po prostu nie dochodzi do skutku i możemy ją powtórzyć.

To trochę jak praca nad dokumentem w kilka osób bez rezerwowania go wcześniej: każdy edytuje swoją kopię, a ewentualny konflikt wykrywa się dopiero przy scalaniu zmian. Lekko, szybko i bez blokowania innych – z tym drobnym ryzykiem, że czasem trzeba wykonać tę samą robotę drugi raz.

Pesymistyczne podejście – „lepiej zabezpieczyć teren”

W podejściu pesymistycznym logika jest odwrotna. Tutaj uznajemy, że lepiej nie ryzykować i najpierw trzeba przejąć wyłączny dostęp. Dopóki ktoś pracuje nad danymi, nikt inny nie może ich zmodyfikować ani nawet przygotować się do modyfikacji. Gdy operacja się kończy, blokada znika i kolejny proces może wejść do gry.

To bardziej przypomina sytuację, w której wchodzisz do pokoju, zamykasz drzwi na klucz i dopiero wtedy zaczynasz przestawiać meble (lub robić inne rzeczy). Zero niespodzianek, ale jeśli jest kilka osób chętnych do redekoracji naraz, mogą tworzyć się kolejki i drobne opóźnienia.

Optimistic vs. pessimistic locking

Optymistyczne podejście zakłada, że świat jest raczej spokojny, a rzadkie konflikty da się wygodnie obsłużyć. Pesymistyczne – że trzeba działać ostrożnie, bo każdy błąd może sporo kosztować.

W praktyce żadne nie jest „lepsze” samo w sobie. Różnią się filozofią, tempem działania i konsekwencjami ubocznymi. Ważne jest jedynie to, by dobrać odpowiednią strategię do tego, jak często współbieżne zmiany faktycznie się wydarzą.

Optimistic vs. pessimistic: w większości przypadków aplikacje zaczynają od optimistic locking – lżejszego i wystarczającego dla dużej części scenariuszy. Pesymistyczne blokady wchodzą do gry, gdy dane są szczególnie wrażliwe lub konflikty zdarzają się często i optymizm zaczyna kosztować zbyt wiele powtórzeń operacji.

Teoria teorią, ale nie tylko teorią człowiek żyje :D.

Optimistic locking – przykład

Optimistic locking wychodzi z założenia, że konflikty będą rzadkie. Możemy więc czytać dane bez blokad, a ewentualny problem wykryć dopiero przy zapisie. Kluczem jest pole version inkrementowane przy każdej modyfikacji.

W relacyjnej tabeli wygląda to zwykle tak:

id | name | version
-------------------
1  | "Ala" | 3
export class User {
  constructor(
    public readonly id: number,
    public name: string,
    public version: number
  ) {}
}

Jeśli aplikacja pobiera rekord z wersją 3, to przy zapisie wykonuje warunek WHERE id = 1 AND version = 3. Jeżeli w międzyczasie ktoś inny zwiększył version do 4, zapis zakończy się brakiem aktualizowanych wierszy – to sygnał, że wystąpił konflikt.

export class UserRepository {
  async update(user: User): Promise<boolean> {
    const result = await db.query(
      `UPDATE users
       SET name = $1, version = version + 1
       WHERE id = $2 AND version = $3`,
      [user.name, user.id, user.version]
    );

    return result.rowCount === 1;
  }
}

Jeśli rowCount wynosi 0 – ktoś był szybszy i trzeba powtórzyć operację lub zgłosić konflikt, a czasami… nie robić nic. W praktyce to podejście jest bardzo lekkie i skalowalne, zwłaszcza gdy większość operacji rzadko dotyka tych samych danych.

Pessimistic locking – przykład

Pesymistyczne blokowanie działa inaczej. Tu nie zakładamy, że świat będzie miły – wręcz przeciwnie. Jeśli chcemy zmodyfikować rekord, blokujemy go od razu, aby nikt inny nie mógł w międzyczasie zrobić tego samego. W relacyjnych bazach najczęściej robi się to przez SELECT ... FOR UPDATE.

Wygląda to tak: pobieramy rekord, ale baza nakłada blokadę wyłączną na ten wiersz. Dopóki transakcja się nie zakończy, inne procesy mogą co najwyżej czekać.

export class UserRepository {
  async loadForUpdate(id: number): Promise<User | null> {
    const row = await db.query(
      `SELECT id, name, version
       FROM users
       WHERE id = $1
       FOR UPDATE`,
      [id]
    );

    if (!row.rows.length) return null;

    const u = row.rows[0];
    return new User(u.id, u.name, u.version);
  }
}

Ten styl jest bardzo pewny, ale też bardziej obciążający. Gdy wiele procesów próbuje edytować te same dane, może pojawić się kolejka oczekujących transakcji, a czasem nawet deadlock-i.

MongoDB i mechanizm revision ID

Wspominam tylko o bazach relacyjnych, a co z bazami nierelacyjnymi? Przykładowo, w świecie dokumentowym nie ma klasycznych transakcyjnych blokad wierszy. Konflikty rozwiązuje się inaczej – przez tzw. revision ID, czyli pole zmieniane przy każdej modyfikacji dokumentu. Jest to w duchu bardzo zbliżone do optimistic locking.

Dokument może wyglądać tak:

{
  "_id": "...",
  "name": "Ala",
  "version": 3
}

Przy zapisie aplikacja wysyła warunek:

updateOne(
  { _id: ..., version: 3 },
  { $set: { name: "Ala v2" }, $inc: { version: 1 } }
)

Jeśli aktualizacja nie dotknie żadnego dokumentu, to sygnał, że ktoś inny zmienił wersję. Efekt końcowy – taki sam jak w relacyjnych bazach, ale mechanizm wynika z filozofii bazy (tutaj pogłębisz wiedzę: kliknij), a nie z blokad transakcyjnych.

Podsumowanie optimistic vs. pessimistic

Optimistic vs. pessimistic locking to dwa różne sposoby patrzenia na ten sam problem: jak zapewnić spójność danych przy współbieżnych modyfikacjach. Pierwszy jest lekki i skalowalny, drugi bardziej przewidywalny i bezpośredni. W systemach dokumentowych dochodzi jeszcze revision ID, które realizuje optymistyczny wzorzec w naturalny dla takich baz sposób.

Niezależnie od wyboru, warto świadomie dobrać strategię do charakteru danych i częstotliwości konfliktów, zamiast polegać na domyślnych założeniach. Współbieżność jest trudna, ale dobrze prowadzona przestaje być straszna – i o to tu chodzi.

Autor wpisu

blog@orbisbit.com

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również