Transakce ve Firebirdu
Transakce ve Firebirdu: ACID, úrovně izolace, deadlocky a řešení konfliktů při aktualizaci
Alexey Kovyazin, za pomoci Vlada Khorsuna a Dmitryho Kuzmenka, 08-04-2019
Obsah:
- Jak budeme mluvit o transakcích
- ACID v grafech
- Úrovně izolace transakcí ve Firebirdu
- Řešení konfliktů při aktualizaci: možnost Wait
- Skutečný deadlock
Je nutné vědět, jak transakce fungují?
Pravděpodobně ano, protože pojem transakcí je jednoduchý a mnoho vývojářů podceňuje důležitost správného používání transakcí ve Firebirdu. Teprve poté, co získáte důkladné porozumění tomu, jak transakce fungují, můžete pochopit mnoho záhadných věcí souvisejících s výkonem, jako jsou náhlá zpomalení databáze (spojená se sweepováním nadměrných verzí záznamů, které vznikají kvůli špatnému řízení transakcí).
Obecně se pojem transakce aplikuje na jakýkoli dynamický systém, který přechází z jednoho stavu do druhého. Například klasickým příkladem transakce je převod peněz z jednoho účtu na druhý. Obvykle to vypadá nějak takto:
Begin --- převod peněz z účtu 1 na účet 2
--snížit účet 1
--zvýšit účet 2
End - potvrzení transakce
Příklad spočívá v tom, že peníze musí zmizet z účtu 1 a objevit se na účtu 2 současně, jinak by v systému byl buď přebytek peněz, nebo nevysvětlitelný nedostatek peněz po určitou dobu.
Z pohledu databází je transakce obvykle definována jako skupina operací provedených na databázi, která je vnímána jako nezávislá na ostatních transakcích. Z mého pohledu není tato definice ani lepší, ani horší než jiné definice, ale jako každá definice dává smysl pouze tehdy, pokud znáte skutečné vnitřní fungování a logiku databázového systému.
Předpokládá se, že transakce v databázi musí splňovat takzvané požadavky ACID
A - Atomicita (Atomicity)
C - Konzistence (Consistency)
I - Izolace (Isolation)
D - Trvanlivost (Durability)
Mnoho vývojářů databázových aplikací je tímto akronymem natolik inspirováno, že často používají argumenty jako “nemáš D v ACID”, když srovnávají různé databázové systémy (což je obvykle okamžitě následováno “je mi jedno, co si myslíš”).
Ve skutečnosti je vše docela jednoduché - ACID je soubor požadavků týkajících se implementace transakcí v konkrétním databázovém systému. Některé z nich jsou velmi přísné (například D - samozřejmě, trvanlivost je důležitá!), zatímco jiné jsou méně přísné - když se podíváme na úrovně izolace transakcí, uvidíme, že izolace se může lišit.
Proto nemá smysl snažit se okamžitě pochopit, co tento akronym doslova znamená. Místo toho prozkoumáme logiku toho, jak databázové systémy (a zejména transakce) fungují, a podíváme se na ACID z pohledu “jak je to uděláno” místo “co to znamená”.
Protože aspekty transakcí jsou složité, potřebujeme grafické znázornění - určitý druh grafů, abychom ukázali práci a interakci transakcí. Pomocí těchto grafů budeme schopni vybudovat logické vyprávění a podívat se do detailů toho, jak transakce fungují.
Nejprve zavedeme časovou osu, protože transakce se vyvíjejí v čase. Časová osa bude označena tak, jak potřebujeme - nepotřebujeme ani sekundy, ani minuty, ale klíčové kroky interakce mezi transakcemi:

Poté přidáme transakci na tuto časovou osu - nakreslíme ji ve formě obdélníku, jehož strany budou odpovídat začátku a konci transakce. Protože všechny transakce ve Firebirdu jsou číslovány, uvedeme také číslo transakce.

Graf tedy ukazuje transakci číslo 11, která začala v čase t3 a skončila v čase t10. Existují dva způsoby, jak transakce může skončit - COMMIT, tj. aplikovat všechny změny provedené v rámci transakce, a ROLLBACK, tj. zrušit všechny změny provedené v rámci transakce. Způsob ukončení transakce ukážeme následovně:

Abychom mohli pokračovat, budeme muset na těchto grafech specifikovat různé parametry transakcí a budeme je uvádět v levém dolním rohu obdélníku představujícího příslušnou transakci - tento příklad ukazuje, že transakce #11 má úroveň izolace snapshot.

Když řekneme, že “transakce X vkládá data” nebo “transakce Y čte ta a ta data” - je to formálně nesprávné, protože bychom měli říci “změny byly provedeny v rámci transakce X”. Pouze příkazy SQL mohou číst nebo vkládat data, takže pokud je to pro vyprávění důležité, ukážeme tyto příkazy uvnitř obdélníku transakce:

V tomto příkladu máme operaci INSERT pro tabulku T1, pole i1, hodnotu 100 - tato operace je provedena v rámci transakce #11 a je potvrzena.
Také budeme někdy muset ukázat výsledek operace, například v následujícím příkladu:

Tento příklad ukazuje následující:
- Transakce #11 s parametrem úrovně izolace nastaveným na snapshot (o úrovních izolace bude řeč později, zde je to ukázáno jen pro dokreslení celkového obrazu) začíná v čase t3
- Operace INSERT INTO T1(i1) values (100), která vkládá hodnotu 100 do pole i1 tabulky t1, začíná v čase t5 A končí v čase t7
- Operace SELECT i1 from T1, která vrací hodnotu i1 rovnou 100, začíná v čase t8
- Transakce #11 končí příkazem COMMIT, tj. změny provedené transakcí #11 jsou potvrzeny do databáze
Takže pomocí grafů transakcí můžeme podrobně popsat, co se děje v databázi, a poznat, jak transakce fungují.
Nyní, když máme grafy transakcí, podívejme se, co akronym ACID skutečně znamená.
Atomicita
Atomicita znamená, že buď jsou provedeny všechny operace tvořící transakci, nebo není provedena žádná z nich: “vše nebo nic”. Zdá se to jako hračka, ale pak vyjdou najevo detaily.
Za prvé, databázový systém (nejen Firebird, ale téměř všechny) má 2 typy atomicity: atomicitu na úrovni příkazu a atomicitu na úrovni skupiny příkazů v rámci transakce.
Atomicita na úrovni příkazu znamená, že příkaz UPDATET1 SETX=1 WHEREY=2 je vždy buď úspěšně proveden, nebo není.
Atomicita na úrovni skupiny příkazů funguje jinak (zde použijeme pseudokód k označení, kdy je transakce zahájena a potvrzena):
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
Přibližně takto to bude vypadat na grafu:

To znamená, že všechny tři příkazy INSERT jsou úspěšně provedeny a změny jimi provedené jsou potvrzeny v okamžiku, kdy je transakce #11 potvrzena.
Otázka, kterou často kladu na workshopech, když přijde řeč na transakce - bude příkaz COMMIT úspěšně proveden pro transakci 11, pokud INSERT INTO..300 vyvolá výjimku:

Značná část publika vždy odpovídá, že příkaz COMMIT nebude úspěšně proveden! (Zajímavé je, že v některých jiných databázových systémech to způsobí rollback transakce!)
To však není pravda - stačí spustit isql a provést experiment s jakoukoli databází (isql má jednoduchou a přímočarou implementaci operací, bez “hádání” za uživatele).
Jde o to, že atomicita na úrovni skupin příkazů zajištěná potvrzením transakcí je otázkou business logiky. Vývojář aplikace musí rozhodnout, zda má být transakce potvrzena v případě výjimky ve třetím příkazu INSERT nebo ne. Pokud business logika umožňuje potvrdit výsledek, příkaz COMMIT může být snadno proveden.
Požadavek atomicity v ACID je tedy požadavkem, aby databázový systém mohl potvrdit nebo vrátit zpět výsledky skupiny příkazů provedených v rámci jedné transakce. Rozhodnutí, zda ji potvrdit nebo vrátit zpět, závisí na business logice, kterou potřebujete implementovat.
A zdůrazněme to ještě jednou - i když atomicita transakce pro skupinu příkazů znamená možnost potvrzení nebo vrácení celé skupiny bez ohledu na výsledky (a volba závisí na business logice), atomicita jednoho příkazu je zaručena implementací databázového systému, tj. není možné provést jeden příkaz (například UPDATE) “neúplně” (neatomicky).
Konzistence
Konzistence znamená, že data v databázi nepředstavují žádné rozpory. Samozřejmě zde vidíme celou oblast pro spekulace, protože “co znamená ’nepředstavuje žádné rozpory’ vůbec”?
Obvykle se rozlišují dvě úrovně konzistence:
- Úroveň databáze, kde konzistence znamená soulad dat s omezeními databáze, jako jsou Primary, Unique a Foreign klíče, Checks. Tato úroveň konzistence je zajištěna tím, že omezení databáze neumožní vložit data, která neodpovídají omezením: např. CHECK(x>0) nedovolí vložit záporné číslo do příslušného pole.
- Úroveň business logiky, kde konzistenci zajišťuje vývojář aplikace pomocí nástrojů nabízených databázovým systémem, jako jsou transakce.
Jak transakce pomáhají zajistit konzistenci na úrovni business logiky? Docela jednoduše - pokud vezmeme příklad převodu peněz, vývojář musí zajistit, aby všechny změny byly vráceny zpět v případě výjimky, a použití transakce mu v tom pomáhá.
Starttransaction
Snížit částku peněz na účtu 1…. Úspěch
Zvýšit ji na účtu 2… Selhání
Rollback ---- v případě výjimky!
Jinými slovy, vývojář musí napsat kód tak, aby data byla vrácena zpět v případě výjimky, a tím byla zachována konzistence dat z hlediska business logiky.
Požadavek konzistence v akronymu ACID tedy znamená, že je nutné, aby databázový systém měl možnost udržovat konzistenci dat pomocí mechanismu transakcí.
Izolace
Požadavek izolace transakcí vyplývá z nutnosti zaručit výsledek sady operací bez ohledu na to, v jakém pořadí jsou prováděny.
Jednoduše řečeno, každá transakce musí být provedena se stejným výsledkem bez ohledu na to, zda jsou současně aktivní jiné transakce.
Mechanismus transakcí má zajistit konzistenci na úrovni business logiky, ale má také chránit transakce před dočasnými nepotvrzenými daty, která se mohou objevit během provádění souběžných transakcí.
V praxi to vypadá takto:

Vidíme transakci #11 zahájenou v čase t2, v rámci které je v čase t3-t5 proveden insert do tabulky. Transakce #11 není potvrzena hned po insertu, ale zůstává aktivní až do času t8.
Současně je zahájena transakce #12, která provádí příkaz SELECT pro záznamy tabulky, do kterých transakce #11 vkládá. První příkaz SELECT je proveden v čase t6, což je doba, kdy je operace insertu již ukončena, ale tento příkaz vrací prázdný výsledek, protože transakce #12 nemůže vidět nepotvrzená data z jiných transakcí.
Transakce #11 je potvrzena v čase t8 a příkaz SELECT v rámci transakce #12 je proveden v čase t9. Vrací výsledek rovný 100, protože data vytvořená v rámci transakce #11 jsou nyní potvrzena (a protože úroveň izolace transakce #12 je read committed, ale o tom budeme mluvit později).
Tento příklad je zcela dostatečný k ilustraci požadavku izolace - na rozdíl od atomicity a konzistence je izolace implementována jako přísná pravidla nazývaná úrovně izolace a každá transakce musí mít parametr, který nastavuje úroveň izolace, se kterou pracuje.
Trvanlivost
Koncept trvanlivosti umožňuje vývojáři zcela se spolehnout na skutečnost, že data vytvořená v rámci potvrzené transakce se okamžitě objeví v databázi a nezmizí z ní (bez explicitních příkazů, které je samozřejmě smažou nebo změní), ať se stane cokoliv.
Jak vidíte, požadavek trvanlivosti je jen zdravý rozum - jen málokdo by souhlasil s používáním systému, jehož data mohou náhle zmizet.
ACID: shrnutí
ACID znamená požadavky na to, jak musí transakce fungovat:
- Atomicita
- Příkazy jsou vždy atomické
- Skupiny příkazů lze učinit atomickými pomocí transakcí
- Konzistence
- Dvě úrovně konzistence: databázová omezení a obchodní logika
- Izolace
- Zajištěna mechanismem transakcí pomocí úrovní izolace pro ně nastavených
- Trvanlivost
- Všechna potvrzená data se stávají trvalými
Jak vidíte, vše je celkem logické. V praxi představují hlavní obtíž úrovně izolace, takže se podívejme podrobně na to, jak fungují.
Úroveň izolace transakce definuje, která potvrzená data může tato transakce vidět.
Existují úrovně izolace, které se běžně nazývají standardní. Jsou popsány ve standardu ANSI SQL (v různých revizích). Pokud vím, neexistuje jediný DBMS, kde by byly implementovány přesně tak, jak jsou popsány ve standardu, ale nikdo si s tím nedělá starosti, protože skutečné mechanismy transakcí v konkrétních DBMS mají všechny potřebné možnosti pro implementaci obchodní logiky.
Klasickou definici úrovní izolace najdete v „A Critique of ANSI SQL Isolation Levels“
Pro ty, kteří tento článek četli, je zde tabulka porovnávající klasické úrovně izolace s podobnými ve Firebirdu. Samozřejmě, korespondence není přímá, protože úrovně izolace ve Firebirdu, stejně jako v jiných DBMS, neodpovídají 100% definicím ANSI SQL, ale jsou jim velmi podobné.
| Úrovně izolace ANSI | Úroveň izolace ve Firebirdu |
| Read Uncommitted | n/a |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
Jako každý jiný DBMS má i Firebird své zvláštnosti v implementaci izolace. Nyní se zaměříme na to, jak úrovně izolace fungují ve Firebirdu, místo toho, jak dobře odpovídají standardu.
Úroveň izolace Snapshot
Úroveň izolace Snapshot byla první v původním kódu InterBase a zůstává výchozí pro základní API a nástroje Firebirdu (například isql.exe). To může být důvod, proč je nejsnáze pochopitelná.
Snapshot izoluje transakci od jakýchkoli změn provedených od okamžiku jejího spuštění.
Podívejme se na diagram transakcí níže: ukazuje transakci #10 spuštěnou s úrovní izolace snapshot. V rámci této transakce je provedeno několik příkazů SELECT na tabulku T1, která v tomto příkladu nemá žádné záznamy.

Souběžná transakce #15 spuštěná po zahájení transakce #10 vkládá data do tabulky T1 a tato transakce končí příkazem COMMIT v okamžiku t9, tj. data jsou v tomto okamžiku potvrzena do databáze a jsou k dispozici pro příkazy z jiných transakcí.
Avšak příkaz v transakci #10 provedený v okamžiku t10 (tj. po potvrzení transakce #15) nevidí vložená data, protože úroveň izolace snapshot mu umožňuje vidět pouze potvrzená data vložená nebo změněná PŘED ZAHÁJENÍM transakce #10.
Úroveň izolace Snapshot vám tedy umožňuje pracovat s databází, jako by byla zmrazena v okamžiku zahájení transakce. Obvykle je nutná pro vytváření složitých sestav založených na rychle se měnících datech: snapshot se používá, aby se předešlo situaci, kdy je první část sestavy založena na některých datech a poslední část na jiných datech.
Tato skvělá funkce má však svou cenu - když později prozkoumáme, jak je izolace implementována ve Firebirdu, uvidíte, že spouštění velmi dlouhých transakcí s úrovní izolace snapshot vede k nadměrnému počtu verzí záznamů a nižšímu výkonu.
Úroveň izolace Read Committed
Transakce s úrovní izolace read committed může vidět potvrzená data jiných transakcí, které jsou potvrzeny, zatímco je aktivní (na rozdíl od případu s úrovní snapshot, kdy vidíte pouze data potvrzená před okamžikem zahájení transakce).
Ukažme si, jak úroveň izolace read committed funguje, pomocí následujícího diagramu:

Ukazuje příklad prakticky identický s předchozím: dvě souběžné transakce, z nichž jedna pravidelně čte data z tabulky T1, zatímco druhá vkládá a potvrzuje data.
Na rozdíl od případu s úrovní izolace snapshot transakce #10 v tomto příkladu vidí data vložená a potvrzená transakcí #15.
Tento příklad nám dává představu o vlivu úrovně izolace read committed: příkazy v rámci transakce s touto úrovní izolace mohou vidět data potvrzená před okamžikem provedení odpovídajícího příkazu.
Další diagram ukazuje příklad, kde dvě souběžné transakce #11 a #18 mění data.
Všimněte si, že transakce #11 začíná před zahájením transakce #14, která čte data, zatímco transakce #18 začíná po ní, ale to neovlivňuje výsledek: pokud jsou data potvrzena, může je vidět souběžná transakce s úrovní izolace read committed.

Tato možnost činí úroveň izolace read committed přirozenou volbou pro ty příkazy SQL, které jsou pravidelně prováděny za účelem zobrazení nejnovějšího stavu databáze (například pro zobrazení nejnovějších objednávek).
Část věnovaná garbage collection ukáže, že transakce read committed s modifikátorem read-only ve Firebirdu do verze 4 jsou nejlepší volbou pro „nekonečné“ čtecí transakce, protože jsou spouštěny jako předem potvrzené.
Úroveň izolace Snapshot table stability
Příběh o úrovni izolace snapshot table stability, která je protějškem standardní úrovně Serializable, lze vyprávět buď velmi krátce, nebo poměrně dlouze a podrobně.
Krátká verze příběhu je následující: tato úroveň je zcela podobná úrovni snapshot s tím rozdílem, že navíc zamyká tabulku (tabulka musí být explicitně specifikována v parametrech transakce) pro zápis a čtení. To znamená, že je možné spustit transakci, která zcela obsadí specifikovanou tabulku, a jakékoli jiné transakce obdrží chyby přístupu.
Jinými slovy, transakce s úrovní izolace snapshot table stability skutečně zařadí všechny dotazy na specifikovanou tabulku do fronty. Ve skutečnosti budou pouze čtení v běžných transakcích provedena mimo pořadí (jako obvykle), zatímco všechny ostatní režimy vytvoří frontu (záleží samozřejmě na interakci).
Pokud je implementována bez opatrnosti, může způsobit zámky a nemožnost práce s databází, a proto se vývojáři aplikací pro databázi Firebird mohou obávat používat tuto úroveň izolace.
Pokud je však implementována správně, úroveň izolace Serializable umožňuje snadno vytvářet fronty a provádět sekvenční změny v databázových záznamech, což může být velmi užitečné pro implementaci čítačů, sekvenčních čísel dokumentů a podobných objektů.
Pro správný popis toho, jak vytvořit frontu pomocí transakce s úrovní izolace snapshot table stability, se budeme muset podívat na ještě jeden parametr transakce: wait/nowait - a pak se vrátit k příkladu fronty.
Dříve jsme zkoumali takový způsob interakce mezi transakcemi, kdy jsou data měněna v rámci jedné transakce a čtena v rámci jiné transakce.
Často se však v praxi stává, že různé transakce se snaží změnit stejná data, a protože se v databázi ukládá pouze jeden výsledek, souběžná transakce obdrží konfliktní zprávu - ve skutečnosti výjimku, která přeruší (a zruší) provedení toho konkrétního příkazu, který se snaží změnit již změněná data.
Možnost wait definuje, jak by transakce měla reagovat na konflikt aktualizace. Existují tři způsoby konfigurace této možnosti:
- Wait (bez parametrů) = čekat, dokud souběžná transakce neskončí
- Wait Timeout N sec = čekat, dokud souběžná transakce neskončí, ale ne déle než N sekund
- Nowait - nečekat, dokud souběžná transakce neskončí
Všimněte si, že možnost wait je zde specifikována v pseudokódu, zatímco názvy se mohou lišit v API a v konkrétních komponentách, i když význam zůstává stejný.
Podívejme se podrobně na to, co se stane v případě konfliktů aktualizace s různými variantami možnosti wait.
Wait
Představme si tedy dvě souběžně aktivní transakce (#11 a #14), v rámci kterých je proveden příkaz UPDATE, který musí změnit jeden a tentýž záznam v jedné a téže tabulce T1.
Transakce #14 běží s možností wait (pokud k reprodukci příkladů používáte isql, wait je nastaveno ve výchozím stavu).
Příkaz UPDATE v transakci #11 začíná v okamžiku t3 a končí v okamžiku t5, ale transakce ještě není potvrzena - tj. příkaz COMMIT tam není až do okamžiku t6.
Diagram níže ukazuje tuto situaci:

Příkaz UPDATE je také proveden v transakci #14 a snaží se aktualizovat stejný záznam ve stejné tabulce, ale začíná později - přibližně v okamžiku t4.
Protože existuje konflikt aktualizace s aktualizací z transakce #11 a v transakci #14 je specifikováno wait, příkaz UPDATE bude čekat, dokud konfliktní transakce #11 neskončí.
Pokud transakce #11 trvá dostatečně dlouho, bude se příkaz UPDATE v transakci #14 jevit jako zmrazený z pohledu uživatele sledujícího provádění tohoto příkazu.
Pokud tuto situaci reprodukujete pomocí dvou isql.exe, další obrázek ukazuje okamžik, kdy druhá transakce (přesněji transakce, kde souběžný příkaz UPDATE začíná později - v našem příkladu je to transakce #14) čeká, dokud první transakce neskončí (v našem příkladu je to transakce #11).

Po provedení příkazu COMMIT v transakci #11 bude transakce #14, která na ni čeká, okamžitě informována a konfliktní aktualizace skončí výjimkou.
Níže vidíte příklad takové chybové zprávy (číslo souběžné transakce se neshoduje s naším příkladem, protože čísla transakcí začínají od začátku v každé databázi a poté se pouze zvyšují, přičemž se resetují pouze po zálohování/obnovení):
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>
Věnujte pozornost slovu „deadlock“ v chybové zprávě - nyní zde ve skutečnosti není žádný deadlock podle jeho klasické definice. Místo toho je zde konflikt aktualizace, ale vývojáři Firebirdu nemění chybovou zprávu, protože se používá více než 35 let. Skutečným „klasickým“ deadlockem se budeme zabývat později.
Prozkoumali jsme tedy situaci, kdy transakce se souběžným příkazem UPDATE končí příkazem COMMIT. Podívejme se nyní na podobnou situaci, ale když je vrácena zpět - můžete ji vidět na diagramu níže:

Situace je zcela podobná předchozí - dva příkazy UPDATE se snaží aktualizovat jeden a tentýž záznam, ale souběžná transakce #20 je tentokrát vrácena zpět a změny v rámci transakce #15 jsou v důsledku toho uloženy do databáze bez chyby.
Tedy možnost čekání umožňuje uspořádat obchodní logiku aktualizací tak, že konfliktní aktualizace nekonečně čekají ve frontě v naději až do posledního okamžiku, že transakce, která s nimi konfliktuje, skončí příkazem ROLLBACK.
Má tato taktika vždy smysl? Samozřejmě záleží na implementaci obchodní logiky, ale Firebird nabízí i další možnosti pro řešení konfliktů aktualizací pomocí možnosti čekání.
Čekání s časovým limitem
Především může být dobrým nápadem omezit dobu čekání - místo nekonečného čekání v případě konfliktu můžete omezit dobu čekání zadáním časového limitu pro možnost čekání.
V isql.exe se takový parametr zadává pomocí následujícího příkazu:
SET TRANSACTION WAIT LOCK TIMEOUT N;
Kde N je čas (v sekundách), po který bude souběžná transakce čekat na vyřešení konfliktu.
Více podrobností o příkazech pro řízení transakcí najdete v Firebird Language Reference. Všimněte si, že v konkrétních ovladačích nebo přístupových komponentách mohou být různé způsoby zadání časového limitu (obvykle pomocí parametru API).
Příklad v isql můžete vidět na obrázku níže:

Prostudujme si pomocí transakčních diagramů, jak transakce interagují, pokud zadáte časový limit pro možnost čekání.
Situace je tedy stejná - dvě souběžné transakce #11 a #14, v rámci kterých se provádí příkaz UPDATE, který se pokouší aktualizovat stejný záznam v tabulce T1.

V tomto případě však příkaz v rámci transakce #14 čeká buď do té doby, než transakce #11 skončí, nebo dokud nevyprší zadaný časový limit (3 sekundy) - podle toho, co nastane dříve.
V tomto příkladu vyprší časový limit dříve, příkaz skončí výjimkou:
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40
Všimněte si, že v chybové zprávě je opět “deadlock”, ale stále se nejedná o “skutečný” deadlock.
Odpovídajícím způsobem je situace podobná situaci s možností čekání, ale je omezena časovým limitem - pokud zadaný časový limit vyprší dříve, než souběžná transakce skončí.
Zadání možnosti čekání s časovým limitem může být dobrým řešením pro implementaci obchodní logiky, pokud s jistotou víte, že všechny zapisující transakce jsou poměrně krátké (například ne delší než 1-2 sekundy).
Nowait
Z formálního hlediska je velmi snadné vysvětlit, co je Nowait - je to čekání s nulovým časovým limitem. Pokud v transakcích zadáte nowait, konfliktní aktualizace okamžitě vyvolají výjimku.

V tomto případě máme opět souběžné transakce #11 a #14 (nowait), kde se provádějí souběžné příkazy UPDATE. Příkaz v rámci transakce s možností nowait nečeká, když vidí souběžnou aktualizaci, ale okamžitě v okamžiku své aktualizace vyvolá následující výjimku (liší se pouze číslo transakce):
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>
Takto to vypadá v příkladu se dvěma nástroji isql:

Všimněte si, že transakci nowait je jedno, kdy a jak transakce se souběžným příkazem UPDATE skončí - ať už to bude příkaz COMMIT nebo ROLLBACK, výjimka se stejně vyvolá.
Z hlediska obchodní logiky může být transakce nowait vhodná, pokud s jistotou víte, že souběžná aktualizace musí bezpochyby vést ke zrušení akcí aktuálního příkazu.
Mnoho ovladačů Firebird používá možnost nowait jako výchozí hodnotu, a protože mnoho vývojářů neví, že je možné nastavit méně přísnou úroveň pro řešení konfliktů aktualizací (například wait lock timeout 1), jejich aplikace (a někdy i uživatelé) trpí zbytečnými chybami kvůli konfliktům.
Protože klíčové slovo “deadlock” je přítomno v každé výjimce související s konflikty aktualizací, mnoho vývojářů aplikací si je jisto, že toto je skutečný deadlock (někteří si dokonce myslí, že v této chybě hrál roli jistý Dead).
Zároveň, pokud se podíváme na konfigurační soubor firebird.conf, uvidíme tam parametr DeadlockTimeout (ve výchozím nastavení 10 sekund), a pokud se podíváme na výstupní hlavičku utility fb_lock_print, uvidíme také parametr “Deadlock scans”.
Jde o to, že “skutečný deadlock” je ve Firebirdu možný a klíčové slovo “deadlock” objevující se ve všech výjimkách souvisejících s konflikty aktualizací s ním nemá přímou souvislost. Naštěstí se skutečný deadlock vyskytuje poměrně zřídka.
Podívejme se, co je to “skutečný deadlock”. K tomu se podívejte na následující diagram interakce transakcí:

Máme dvě souběžné transakce s možností čekání, kde se provádí příkaz UPDATE. Na rozdíl od případu jednoduchého konfliktu aktualizací zde vidíme vzájemně závislý konflikt aktualizací:
- Transakce #11 aktualizuje záznam s klíčem = 20 a transakce #12 aktualizuje záznam s klíčem = 10;
- Poté transakce #11 aktualizuje záznam s klíčem = 10 a transakce #12 aktualizuje záznam s klíčem = 20;
Výsledkem je situace, kdy každá transakce musí čekat na ukončení té druhé a obě mohou čekat nekonečně, protože obě mají zadánu možnost čekání. Server to samozřejmě nemůže dopustit, takže jedna z transakcí bude nucena k rollbacku po uplynutí časového limitu zadaného v parametru DeadlockTimeout, který je ve výchozím nastavení nastaven na hodnotu 10 sekund.
Tuto situaci můžeme reprodukovat pomocí dvou isql:

Po spuštění druhé transakce nastane situace skutečného deadlocku. Aby to server s jistotou zjistil, spustí proceduru nazvanou Deadlock scan - spouští se v intervalech rovných DeadlockTimeout, který je ve výchozím nastavení 10 sekund.
Všimněte si, že klient (v tomto případě isql) obdrží běžnou zprávu o konfliktu aktualizací, ale je iniciována za 10 sekund, i když je transakce spuštěna s možností čekání.
Poté, co server detekuje vzájemně závislý zámek dvou transakcí, také zvýší interní počítadlo deadlocků (můžete to vidět ve výstupu fb_lock_print).
Praktické použití Snapshot Table Stability
Nyní, když víme, jak transakce pracují s konfliktními příkazy UPDATE, se můžeme vrátit k úrovni izolace Snapshot Table Stability a najít pro ni praktické využití.
Takže, když je zadána tato úroveň izolace, tabulka je uzamčena pro zápis a dokonce i pro čtení.
Všimněte si, že pokud tabulka není v parametrech transakce explicitně zadána, všechny tabulky, ke kterým příkazy v rámci této transakce přistupují, jsou uzamčeny, a to se děje při prvním přístupu k tabulce. Je zřejmé, že pokud se tato úroveň izolace používá bez opatrnosti, snadno povede k velkému počtu konfliktů aktualizací.
Klauzule Reserving TableNN umožňuje zadat konkrétní tabulku (nebo několik tabulek), která má být uzamčena na začátku transakce (je také možné zadat režim rezervace).
Tato skvělá funkce spolu s možností čekání umožňuje implementovat velmi efektivní sekvenční frontu pro změnu konkrétní tabulky.
V praxi to vypadá takto - klienti, kteří potřebují vytvořit frontu k určité tabulce, spustí transakci SNAPSHOT TABLE STABILITY s uvedením této tabulky a poté se v rámci této transakce pokusí provést operaci a okamžitě ji ukončí.
Například chceme vytvořit sekvenčně se zvyšující počítadlo v tabulce s jediným záznamem typu CREATE TABLE Table1(i1 integer not null), ale z nějakého důvodu nemůžeme použít generátor.
Pseudokód vypadá přibližně takto:
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
Pokud tento kód spustíme ne s úrovní izolace Snapshot Table Stability (Table1), ale s nižší úrovní izolace, bude možné, že souběžný příkaz UPDATE zasáhne mezi zahájením transakce a před jejím příkazem UPDATE. Výsledkem bude buď okamžitá výjimka aktualizace (nowait), nebo příkaz zamrzne až do konce souběžné transakce (wait) nebo do časového limitu (wait interval) - jinými slovy, konflikt bude nějak vyřešen na úrovni příkazu.
S úrovní izolace snapshot table stability jsme proti tomu chráněni, protože tabulka je rezervována na začátku transakce - je buď zcela naše, nebo zcela není naše. Pokud pro řešení konfliktů zadáme možnost čekání, paralelní připojení automaticky vytvoří frontu bez zpracování jakýchkoli chyb.

Tento přístup lze samozřejmě použít pouze pro krátké transakce (tak jako v našem příkladu).
V praxi se úroveň izolace Snapshot table stability používá k vytváření front a přepočítávání složité logiky v exkluzivním režimu (v relativně malých tabulkách nebo když nejsou žádní další uživatelé).
Uvnitř enginu používá Firebird úroveň izolace Snapshot Table Stability k vytváření indexů - tj. když provedete příkaz ALTER INDEX indexname ACTIVE;, Firebird zcela obsadí tabulku, pro kterou se index vytváří.
Co dál?
Tento článek poskytuje pouze úvod do konceptů transakcí Firebird. Pro úplné pochopení toho, jak transakce ve Firebirdu fungují, je nutné zvážit multi-generační architekturu (koncepty verzí záznamů a garbage collection), zvážit transakční markery (Oldest Interesting, Oldest Active, Oldest Snapshot, next) a další věci.
Článek je založen na materiálech semináře/workshopu “All About Transactions”, který byl poprvé představen v roce 2013 během seminářů Firebird Tour, a na základě školení IBSurgeon " Firebird Transaction in details".
Kontakty
[email protected] Neváhejte nás kontaktovat s jakýmikoli dotazy nebo návrhy: [email protected]