Két sikeres COMMIT, egy eltűnt átutalás: mit véd valójában a Read Committed?

Mindkét tranzakció hibátlanul lefutott, negyven dollár mégis eltűnt. Eldobható Postgresen reprodukáltam a lost update-et, a write skew-t és a Read Committed kevert snapshotját – kiderül, mit véd az alapértelmezett izolációs szint, és mit bíz teljes egészében rád.

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.

Két párhuzamos tranzakció ugyanazt a 100-as egyenleget olvassa ki az adatbázisból, majd mindkettő visszaírja a saját eredményét: a 70 célba ér, a 60 áthúzva elveszik

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

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük