Van egy mondat, amit code review-ban már annyiszor kaptam válaszul, hogy lassan köszönésnek veszem: „de hát ez tranzakcióban fut”. A mondat mögött az a hit áll, hogy a BEGIN és a COMMIT között a kód egy steril buborékban él, ahová más nem nyúlhat be. Csakhogy a Postgres alapértelmezett izolációs szintje, a Read Committed, ilyet soha nem ígért. Nem zárol, hanem fényképez – és két fénykép között bárki átrendezheti a szobát.

Fogtam egy eldobható Postgres 17.11-et Dockerben, nyitottam rá két psql-t, és eljátszottam a legunalmasabb konkurrenciahibát, ami létezik: két folyamat ugyanarról a számláról von le pénzt. Mindkettő kiolvassa az egyenleget, kiszámolja az újat, visszaírja. Időrendben így nézett ki:
-- A session -- B session
BEGIN;
SELECT balance FROM accounts
WHERE id = 1; --> 100
BEGIN;
SELECT balance ... --> 100
-- az app számol: 100 - 40
UPDATE accounts SET balance = 60
WHERE id = 1;
COMMIT;
-- az app számol: 100 - 30
UPDATE accounts SET balance = 70
WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
--> 70
Két sikeres COMMIT, nulla hibaüzenet, és B negyven dollárja nyomtalanul eltűnt. Nem félig sikerült, nem visszagörgett: eltűnt. A session A UPDATE-je gond nélkül ráírta a 70-et a B által frissen commitolt 60-ra – mert a 70 nem az adatbázisban élt, hanem a kliens memóriájában, egy időközben elavult 100-ból kiszámolva. (Ha B még nem commitolt volna, A egyszerűen megvárja a sorzárat, aztán írja felül ugyanígy.) Ezt hívják lost update-nek, és a Read Committed dokumentált viselkedése szerint teljesen legális.
A csavar, hogy ez formálisan még csak nem is anomália. Az SQL-szabvány izolációs táblázata három jelenséget ismer – dirty read, nonrepeatable read, phantom –, a lost update nincs köztük. Erre a lyukra egy 1995-ös, azóta klasszikussá vált paper, a A Critique of ANSI SQL Isolation Levels mutatott rá, ott kapta a P4 kódnevet. Harminc éve tudjuk, hogy a szabvány táblázata féloldalas – a „de hát tranzakcióban van” hit mégis kitart.
Amikor ugyanez mégis biztonságos
Mielőtt bárki kidobná a Read Committedet: az egyutasításos változat ellen ugyanez a támadás nem működik. Lefuttattam párhuzamosan a −40-et és a −30-at így:
UPDATE accounts SET balance = balance - 40 WHERE id = 1;
-- közben a másik kapcsolaton:
UPDATE accounts SET balance = balance - 30 WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1;
--> 30
Mindkét levonás megvan, mert a második UPDATE megvárja a sorzárat, majd a kifejezést az új sorverzióra értékeli ki. A lost update tehát nem az adatbázisban születik, hanem az alkalmazásban: SELECT, számolgatás a memóriában, visszaírás. Vagyis pontosan az a minta, amit egy átlagos ORM-es kód csinál nap mint nap – entitás betölt, mező átír, save(). Ha a read-modify-write elkerülhetetlen, három tisztességes védelem van:
- atomikus
UPDATE, ha a művelet kifejezhető SQL-ben; SELECT ... FOR UPDATE, ha a két lépés közé logika ékelődik – ez ugyanaz a sorszintű zárolás, amire a SKIP LOCKED-os job queue is épül;- optimista zárolás verzióoszloppal (
UPDATE ... WHERE id = 1 AND version = 5), ha a két lépés között nem kód fut, hanem egy felhasználó gondolkodik egy űrlap fölött.
Az UPDATE, ami fél szemmel a múltba néz
A Read Committednek van egy ennél sokkal kevésbé ismert furcsasága is, amit a hivatalos dokumentáció példája nyomán reprodukáltam. Adott egy tábla két sorral: hits értékük 9 és 10. Az egyik tranzakció mindenkire rátesz egyet, a másik közben törölné a 10-eseket:
-- kiindulás: (1,9) és (2,10)
-- A: BEGIN; UPDATE website SET hits = hits + 1; -- még nem commitol
-- B: DELETE FROM website WHERE hits = 10; -- blokkol, vár A-ra
-- A: COMMIT;
-- B: DELETE 0 -- nulla törölt sor!
SELECT * FROM website;
--> (1,10) és (2,11)
A DELETE indulásakor volt 10-es sor, a lefutása után is volt 10-es sor – törölni mégsem törölt semmit. A magyarázat, hogy az utasítás két különböző időpontból származó adatokkal dolgozik: a 2-es sort a saját snapshotjában találta meg (ott még 10 volt), de mire a zár felszabadult, újraértékelte a feltételt a friss verzión – az már 11, nem egyezik. Az 1-es sort viszont meg sem nézte, mert a snapshotban 9 volt, hiába lett belőle közben 10. A forráskód ezt a mechanizmust EvalPlanQual néven emlegeti; a doksi udvariasan csak annyit mond, hogy ilyenkor az utasítás „inkonzisztens pillanatképet” láthat. Na, ezt a sort nekem is kétszer kellett elolvasnom.
Repeatable Read: hibát kapsz, nem adatvesztést
Ugyanaz az átutalós forgatókönyv REPEATABLE READ alatt már nem tud csendben pénzt nyelni. A session A UPDATE-je ezzel száll el:
ERROR: could not serialize access due to concurrent update
First updater wins: aki másodikként nyúlna egy időközben módosított sorhoz, az repül. Ez a hiba SQLSTATE 40001, és a dokumentáció szerint a helyes reakció a teljes tranzakció újrafuttatása. Itt jön a kényelmetlen rész: a retry-wrapper a kódbázisok jó részéből egyszerűen hiányzik. Ilyenkor a „védelem” a gyakorlatban annyit tesz, hogy csendes adatvesztés helyett 500-ast kap a felhasználó. Kétségtelenül haladás, de azért ünnepelni még ne kezdjünk.
És egy ráadás azoknak, akik több adatbázissal dolgoznak: ez a garancia nem a szint nevéből következik. A MySQL InnoDB alapból REPEATABLE READ-en fut, a fenti lost update-et mégis átengedi – Martin Kleppmann hermitage tesztcsomagja adatbázisonként, feketén-fehéren mutatja, hogy a címke ugyanaz, a tartalom nem.
Amit még a Repeatable Read is átenged
A klasszikus példa az ügyeleti beosztás: a szabály szerint mindig legalább egy orvosnak ügyeletben kell lennie, és most ketten vannak. Mindketten egyszerre kérnék ki magukat, két külön tranzakcióban, mindkettő REPEATABLE READ alatt:
-- A: SELECT count(*) FROM doctors WHERE on_call; --> 2, mehet
-- B: SELECT count(*) FROM doctors WHERE on_call; --> 2, mehet
-- A: UPDATE doctors SET on_call = false WHERE name = 'alice';
-- B: UPDATE doctors SET on_call = false WHERE name = 'bob';
-- A: COMMIT; ✓
-- B: COMMIT; ✓
SELECT count(*) FROM doctors WHERE on_call;
--> 0
Nulla ügyeletes, nulla hibaüzenet. A first-updater-wins itt néma marad, mert a két tranzakció különböző sorokat írt – csak épp mindkettő egy olyan feltétel alapján döntött, amit a másik írása közben érvénytelenített. Ez a write skew, és snapshot izolációval elvi okokból nem fogható meg. Ha ugyanezt SERIALIZABLE alatt játszom el, a második COMMIT így végzi:
ERROR: could not serialize access due to read/write dependencies
among transactions
DETAIL: Reason code: Canceled on identification as a pivot,
during commit attempt.
HINT: The transaction might succeed if retried.
Érdemes észrevenni, hogy mindkét UPDATE gond nélkül lefutott – a hiba csak a COMMIT-nál jött. A Postgres 9.1 óta ugyanis a SERIALIZABLE nem zárakkal dolgozik, hanem Serializable Snapshot Isolationnel: futás közben figyeli a tranzakciók közti olvasás-írás függőségeket, és ha veszélyes mintázatot lát, a gráf közepén ülő „pivot” tranzakciót lelövi. Olcsóbb, mint amilyennek hangzik, de két ára biztosan van: néha ártatlan tranzakciót is elkaszál, és a 40001-re itt sem opció, hanem kötelező a retry.
Mit használnék productionben
Alapértelmezésnek maradna a Read Committed – de annak tudatában, hogy a read-modify-write védelme az én dolgom: atomikus UPDATE, ahol lehet, FOR UPDATE, ahol logika fut a két lépés között, verzióoszlop, ahol felhasználó. SERIALIZABLE-t célzottan tennék oda, ahol több soron átívelő invariánst kell tartani – keretek, kvóták, foglalások, „legalább egy ügyeletes” – kizárólag retry-wrapperrel és rövid tranzakciókkal. Amit biztosan nem tennék: globálisan felcsavarni az izolációs szintet „biztos, ami biztos” alapon. Abból 40001-eső lesz, védelem meg főleg ott, ahol amúgy is gondolkodni kellett volna.
A tranzakció atomicitást és tartósságot ad, nagyjából ingyen. Az izoláció viszont alapszinten csak részleges – és ezt a Postgres soha nem is titkolta. Csak mi szeretjük úgy olvasni a BEGIN-t, mintha védőháló lenne, pedig inkább fényképezőgép.
Források
- PostgreSQL dokumentáció: Transaction Isolation
- PostgreSQL dokumentáció: Explicit Locking
- PostgreSQL dokumentáció: hibakódok (40001 – serialization_failure)
- PostgreSQL wiki: Serializable Snapshot Isolation
- Berenson et al.: A Critique of ANSI SQL Isolation Levels (1995)
- ept/hermitage – izolációs szintek tesztjei adatbázisonként