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" | 3export 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.


Dodaj komentarz