Transazioni in Firebird
Транзакции в Firebird: ACID, уровни изоляции, взаимоблокировки и разрешение конфликтов обновления
Алексей Ковязин, при участии Влада Хорсуна и Дмитрия Кузьменко, 08-АПР-2019
Содержание:
- Как мы будем говорить о транзакциях
- ACID в схемах
- Уровни изоляции транзакций в Firebird
- Разрешение конфликтов обновления: опция Wait
- Истинная взаимоблокировка
Нужно ли знать, как работают транзакции?
Вероятно, да, потому что понятие транзакций простое, и многие разработчики недооценивают важность правильного использования транзакций в Firebird. Однако только после того, как вы получите глубокое понимание того, как работают транзакции, вы сможете осознать многие загадочные вещи, связанные с производительностью, такие как внезапные замедления базы данных (связанные с очисткой избыточных версий записей, которые появляются из-за плохого управления транзакциями).
В общем, понятие транзакции применяется к любой динамической системе, которая переходит из одного состояния в другое. Например, классический пример транзакции - перевод денег с одного счета на другой. Обычно это выглядит примерно так:
Begin --- перевод денег со счета 1 на счет 2
--уменьшить счет 1
--увеличить счет 2
End - фиксация транзакции
Пример сводится к тому, что деньги должны исчезнуть со счета 1 и появиться на счете 2 одновременно, иначе в системе будет либо избыток денег, либо необъяснимая нехватка денег в течение некоторого времени.
С точки зрения баз данных, транзакция обычно определяется как группа операций, выполняемых над базой данных, которая рассматривается как независимая от других транзакций. С моей точки зрения, это определение не лучше и не хуже других, но, как и любое определение, оно имеет мало смысла без знания фактического внутреннего устройства и логики СУБД.
Считается, что транзакция в базе данных должна соответствовать так называемым требованиям ACID
A - Атомарность (Atomicity)
С - Согласованность (Consistency)
I - Изоляция (Isolation)
D - Долговечность (Durability)
Многие разработчики приложений для баз данных настолько вдохновлены этой аббревиатурой, что часто используют такие аргументы, как «у вас нет D в ACID», когда речь идет о сравнении различных СУБД (за которым обычно сразу следует «мне все равно, что вы думаете»).
На самом деле все довольно просто - ACID - это набор требований к реализации транзакции в конкретной СУБД, некоторые из них очень строгие (например, D - конечно, долговечность важна!), а некоторые менее строгие - когда мы рассмотрим уровни изоляции транзакций, мы увидим, что изоляция может варьироваться.
Вот почему не стоит пытаться сразу понять, что буквально означает эта аббревиатура. Вместо этого мы рассмотрим логику работы СУБД (и транзакций, в частности) и посмотрим на ACID с точки зрения «как это сделано», а не «что это значит».
Поскольку аспекты транзакций сложны, нам нужно графическое представление - своего рода схемы, чтобы показать работу и взаимодействие транзакций. Используя эти схемы, мы сможем построить логическое повествование и углубиться в детали того, как работают транзакции.
Прежде всего, мы введем временную шкалу, потому что транзакции развиваются во времени. Временная шкала будет размечена так, как нам нужно - нам не нужны ни секунды, ни минуты, а ключевые шаги взаимодействия между транзакциями:

Затем мы добавим транзакцию на эту временную шкалу - нарисуем ее в виде прямоугольника, стороны которого будут соответствовать началу и концу транзакции. Поскольку все транзакции в Firebird нумеруются, мы также укажем номер транзакции.

Таким образом, на схеме показана транзакция номер 11, которая началась в момент времени t3 и закончилась в момент времени t10. Есть два способа завершить транзакцию - COMMIT, т.е. применить все изменения, сделанные в рамках транзакции, и ROLLBACK, т.е. отменить все изменения, сделанные в рамках транзакции. Мы покажем способ завершения транзакции следующим образом:

Чтобы продолжить, нам придется указывать различные параметры транзакций на этих схемах, и мы будем указывать их в левом нижнем углу прямоугольника, представляющего соответствующую транзакцию - в этом примере показано, что транзакция #11 имеет уровень изоляции snapshot.

Когда мы говорим, что «транзакция X вставляет данные» или «транзакция Y читает такие-то данные» - это формально некорректно, потому что мы должны говорить «изменения были сделаны в рамках транзакции X». Только SQL-операторы могут читать или вставлять данные, поэтому, если это важно для повествования, мы будем показывать эти операторы внутри прямоугольника транзакции:

В этом примере у нас есть операция INSERT для таблицы T1, поля i1, значения 100 - эта операция выполняется в рамках транзакции #11 и фиксируется.
Кроме того, иногда нам придется показывать результат операции, например, в следующем примере:

Этот пример показывает следующее:
- Транзакция #11 с параметром уровня изоляции, установленным на snapshot (уровни изоляции будут обсуждаться позже, здесь это показано просто для полноты картины), начинается в момент t3
- Операция INSERT INTO T1(i1) values (100), которая вставляет значение 100 в поле i1 таблицы t1, начинается в момент t5 И заканчивается в момент t7
- Операция SELECT i1 from T1, которая возвращает значение i1, равное 100, начинается в момент t8
- Транзакция #11 завершается оператором COMMIT, т.е. изменения, сделанные транзакцией #11, фиксируются в базе данных
Таким образом, с помощью схем транзакций мы можем подробно описать, что происходит в базе данных, и узнать, как работают транзакции.
Теперь, когда у нас есть схемы транзакций, давайте посмотрим, что на самом деле означает аббревиатура ACID.
Атомарность
Атомарность означает, что либо все операции, составляющие транзакцию, выполняются, либо ни одна из них не выполняется: «все или ничего». Кажется, что это проще простого, но затем всплывают детали.
Во-первых, СУБД (не только Firebird, но и почти все остальные) имеет 2 типа атомарности: атомарность на уровне оператора и атомарность на уровне группы операторов в рамках транзакции.
Атомарность на уровне оператора означает, что оператор UPDATET1 SETX=1 WHEREY=2 всегда либо выполняется успешно, либо не выполняется.
Атомарность на уровне группы операторов работает иначе (мы будем использовать псевдокод здесь, чтобы отметить, когда транзакция начинается и фиксируется):
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
Примерно так это будет выглядеть на схеме:

Это означает, что все три оператора INSERT успешно выполняются, и изменения, сделанные ими, фиксируются в момент фиксации транзакции #11.
Вопрос, который я часто задаю на семинарах, когда речь заходит о транзакциях - будет ли оператор COMMIT успешно выполнен для транзакции 11, если INSERT INTO..300 вызовет исключение:

Значительная часть аудитории всегда отвечает, что оператор COMMIT не будет выполнен успешно! (Интересно, что в некоторых других СУБД это приведет к откату транзакции!)
Однако это не так - просто запустите isql и проведите эксперимент с любой базой данных (isql имеет простую и понятную реализацию операций, без «угадывания» за пользователя).
Дело в том, что атомарность на уровне групп операторов, обеспечиваемая фиксацией транзакций, - это вопрос бизнес-логики. Разработчик приложения должен решить, следует ли фиксировать транзакцию в случае исключения в третьем операторе INSERT или нет. Если бизнес-логика позволяет зафиксировать результат, оператор COMMIT может быть легко выполнен.
Таким образом, требование атомарности в ACID - это требование, чтобы СУБД могла зафиксировать или откатить результаты группы операторов, выполненных в рамках одной транзакции. Решение о фиксации или откате зависит от бизнес-логики, которую вам нужно реализовать.
И подчеркнем еще раз - хотя атомарность транзакции для группы операторов означает возможность фиксации или отката всей группы независимо от результатов (и выбор зависит от бизнес-логики), атомарность одного оператора гарантируется реализацией СУБД, т.е. невозможно выполнить один оператор (например, UPDATE) «неполностью» (не атомарно).
Согласованность
Согласованность означает, что данные внутри базы данных не содержат противоречий. Конечно, здесь мы видим целую область для размышлений, потому что «что значит “не содержит противоречий” вообще»?
Обычно выделяют два уровня согласованности:
- Уровень базы данных, где согласованность означает соответствие данных ограничениям базы данных, таким как Primary, Unique и Foreign keys, Checks. Этот уровень согласованности обеспечивается тем, что ограничения базы данных не позволят вставить данные, которые не соответствуют ограничениям: например, CHECK(x>0) не позволит вставить отрицательное число в соответствующее поле.
- Уровень бизнес-логики, где согласованность обеспечивается разработчиком приложения с помощью инструментов, предлагаемых СУБД, таких как транзакции.
Как транзакции помогают обеспечить согласованность на уровне бизнес-логики? Довольно просто - если взять пример перевода денег, разработчик должен убедиться, что все изменения откатываются в случае исключения, и использование транзакции помогает ему в этом.
Starttransaction
Уменьшить сумму денег на счете 1…. Успех
Увеличить ее на счете 2… Ошибка
Rollback ---- в случае исключения!
Другими словами, разработчик должен написать код таким образом, чтобы данные откатывались в случае исключения, и таким образом согласованность данных сохранялась с точки зрения бизнес-логики.
Таким образом, требование согласованности в аббревиатуре ACID означает, что СУБД должна иметь возможность поддерживать согласованность данных с помощью механизма транзакций.
Изоляция
Требование изоляции транзакций возникает из необходимости гарантировать результат набора операций независимо от порядка их выполнения.
Проще говоря, каждая транзакция должна выполняться с одним и тем же результатом независимо от того, какие транзакции активны одновременно.
Механизм транзакций должен обеспечивать согласованность на уровне бизнес-логики, но он также должен защищать транзакции от временных неподтвержденных данных, которые могут появиться в процессе выполнения параллельных транзакций.
На практике это выглядит так:

Мы видим транзакцию #11, начатую в момент t2, в рамках которой в момент t3-t5 выполняется вставка в таблицу. Транзакция #11 не фиксируется сразу после вставки, а продолжает оставаться активной до момента t8.
Одновременно запускается транзакция #12, которая выполняет оператор SELECT для записей таблицы, в которую транзакция #11 вставляет данные. Первый оператор SELECT выполняется в момент t6, когда операция вставки уже завершена, но этот оператор возвращает пустой результат, потому что транзакция #12 не может видеть незафиксированные данные из других транзакций.
Транзакция #11 фиксируется в момент t8, и оператор SELECT в рамках транзакции #12 выполняется в момент t9. Он возвращает результат, равный 100, потому что данные, созданные в рамках транзакции #11, теперь зафиксированы (и потому что уровень изоляции транзакции #12 - read committed, но мы поговорим об этом позже).
Questo esempio è abbastanza sufficiente per illustrare il requisito di isolamento - a differenza di atomicità e coerenza, l’isolamento è implementato come regole rigorose chiamate livelli di isolamento e ogni transazione deve avere un parametro che imposta il livello di isolamento con cui lavora.
Durabilità
Il concetto di durabilità consente allo sviluppatore di fare pieno affidamento sul fatto che i dati creati all’interno di una transazione committata appariranno immediatamente nel database e non scompariranno da esso (senza dichiarazioni esplicite che li eliminino o li modifichino, ovviamente) qualunque cosa accada in seguito.
Come puoi vedere, il requisito di durabilità è solo buon senso - difficilmente qualcuno accetterebbe di usare un sistema i cui dati potrebbero scomparire all’improvviso.
ACID: riepilogo
ACID indica i requisiti di come devono funzionare le transazioni:
- Atomicità
- Le dichiarazioni sono sempre atomiche
- Gruppi di dichiarazioni possono essere resi atomici con l’aiuto delle transazioni
- Coerenza
- Due livelli di coerenza: vincoli del database e logica di business
- Isolamento
- Garantito dal meccanismo delle transazioni con l’aiuto dei livelli di isolamento impostati per esse
- Durabilità
- Tutti i dati committati diventano permanenti
Come vedi, tutto è abbastanza logico. In pratica, la difficoltà maggiore è rappresentata dai livelli di isolamento, quindi vediamo in dettaglio come funzionano.
Il livello di isolamento di una transazione definisce quali dati committati questa transazione può vedere.
Esistono livelli di isolamento convenzionalmente chiamati standard. Sono descritti nello standard ANSI SQL (varie revisioni). Per quanto ne so, non esiste un singolo DBMS in cui siano implementati esattamente come descritto nello standard, ma nessuno se ne preoccupa poiché i meccanismi effettivi delle transazioni in specifici DBMS hanno tutte le opzioni necessarie per implementare la logica di business.
Puoi trovare la definizione classica dei livelli di isolamento in “A Critique of ANSI SQL Isolation Levels”
Per coloro che hanno letto questo articolo, ecco la tabella che confronta i livelli di isolamento classici con quelli simili in Firebird. Naturalmente, la corrispondenza non è diretta perché i livelli di isolamento in Firebird, come in altri DBMS, non rispettano al 100% le definizioni ANSI SQL, ma sono molto simili ad esse.
| Livelli di isolamento ANSI | Livello di isolamento in Firebird |
| Read Uncommitted | n/d |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
Come qualsiasi altro DBMS, Firebird ha le sue peculiarità nell’implementazione dell’isolamento. Ora ci concentreremo su come funzionano i livelli di isolamento in Firebird, invece di quanto siano conformi allo standard.
Livello di isolamento Snapshot
Il livello di isolamento Snapshot è stato il primo nel codice originale di InterBase e rimane quello predefinito per l’API core di Firebird e le utility (ad esempio, isql.exe). Questo potrebbe essere il motivo per cui è il più facile da comprendere.
Snapshot isola la transazione da qualsiasi modifica apportata dal momento del suo avvio.
Diamo un’occhiata al diagramma delle transazioni qui sotto: mostra la transazione #10 avviata con il livello di isolamento snapshot. All’interno di questa transazione, vengono eseguite diverse dichiarazioni SELECT sulla tabella T1 che in questo esempio non ha record.

La transazione concorrente #15 avviata dopo l’inizio della transazione #10 inserisce dati nella tabella T1 e questa transazione termina con la dichiarazione COMMIT al momento t9, cioè i dati vengono committati nel database in questo momento e sono disponibili per le dichiarazioni di altre transazioni.
Tuttavia, la dichiarazione nella transazione #10 eseguita al momento t10 (cioè dopo che la transazione #15 è stata committata) non vede i dati inseriti perché il livello di isolamento snapshot le consente di vedere solo dati committati inseriti o modificati PRIMA DELL’INIZIO della transazione #10.
Pertanto, il livello di isolamento Snapshot consente di lavorare con il database come se fosse congelato al momento dell’avvio della transazione. Di solito è necessario per costruire report complessi basati su dati che cambiano rapidamente: lo snapshot viene utilizzato per evitare la situazione in cui la prima parte del report si basa su alcuni dati e l’ultima parte su dati diversi.
Tuttavia, questa grande funzionalità ha il suo prezzo - quando esamineremo in seguito come l’isolamento è implementato in Firebird, vedrai che avviare transazioni molto lunghe con il livello di isolamento snapshot comporta versioni eccessive di record e prestazioni inferiori.
Livello di isolamento Read Committed
Una transazione con il livello di isolamento read committed può vedere i dati committati di altre transazioni che vengono committate mentre è attiva (a differenza del caso del livello snapshot, quando si possono vedere solo dati committati prima del momento in cui la transazione inizia).
Mostriamo come funziona il livello di isolamento read committed usando il seguente diagramma:

Mostra un esempio praticamente identico al precedente: due transazioni concorrenti, una delle quali legge regolarmente dati dalla tabella T1 mentre la seconda inserisce e committa dati.
A differenza del caso con il livello di isolamento snapshot, la transazione #10 in questo esempio vede i dati inseriti e committati dalla transazione #15.
Questo esempio ci dà un’idea dell’impatto del livello di isolamento read committed: le dichiarazioni all’interno di una transazione con questo livello di isolamento possono vedere dati committati prima del momento in cui viene eseguita la dichiarazione corrispondente.
Il prossimo diagramma mostra un esempio in cui due transazioni concorrenti #11 e #18 modificano dati.
Nota che la transazione #11 inizia prima dell’inizio della transazione #14 che legge dati mentre la transazione #18 inizia dopo, ma ciò non influisce sul risultato: se i dati sono committati, possono essere visti dalla transazione concorrente con il livello di isolamento read committed.

Questa possibilità rende il livello di isolamento read committed una scelta naturale per quelle dichiarazioni SQL che vengono eseguite regolarmente per mostrare lo stato più recente del database (ad esempio, per mostrare gli ordini più recenti).
La parte dedicata alla garbage collection mostrerà che le transazioni read committed con il modificatore read-only in Firebird fino alla versione 4 sono la scelta migliore per transazioni di lettura “infinite” perché vengono avviate come pre-committate.
Livello di isolamento Snapshot table stability
È possibile rendere la storia sul livello di isolamento snapshot table stability, che è la controparte del livello Serializable standard, molto breve o piuttosto lunga e dettagliata.
La versione breve della storia è la seguente: questo livello è completamente simile al livello snapshot con l’aggiunta del blocco della tabella (la tabella deve essere esplicitamente specificata nei parametri della transazione) per scrittura e lettura. Ciò significa che è possibile avviare una transazione che occuperà completamente la tabella specificata e qualsiasi altra transazione riceverà errori di accesso.
In altre parole, una transazione con il livello di isolamento snapshot table stability metterà effettivamente in coda tutte le query alla tabella specificata. In effetti, solo le letture nelle transazioni regolari verranno eseguite fuori turno (come al solito) mentre tutte le altre modalità formeranno una coda (dipende dall’interazione, ovviamente).
Se implementato senza cautela, può causare blocchi e impossibilità di lavorare con il database, motivo per cui gli sviluppatori di applicazioni di database Firebird potrebbero temere di usare questo livello di isolamento.
Tuttavia, se implementato correttamente, il livello di isolamento Serializable consente di formare facilmente code e apportare modifiche sequenziali ai record del database, il che può essere molto utile per implementare contatori, numeri di documenti sequenziali e altri oggetti simili.
Per descrivere correttamente come formare una coda con l’aiuto di una transazione con il livello di isolamento snapshot table stability, dovremo esaminare un altro parametro della transazione: wait/nowait - e poi tornare all’esempio della coda.
In precedenza abbiamo esaminato un modo di interazione tra transazioni in cui i dati vengono modificati all’interno di una transazione e letti all’interno di un’altra transazione.
Tuttavia, accade spesso nella pratica che diverse transazioni tentino di modificare gli stessi dati e poiché solo un risultato viene salvato nel database, la transazione concorrente riceverà un messaggio di conflitto - in effetti, un’eccezione che interromperà (e annullerà) l’esecuzione di quella specifica dichiarazione che sta tentando di modificare dati già modificati.
L’opzione wait definisce come una transazione dovrebbe reagire al conflitto di aggiornamento. Ci sono tre modi per configurare questa opzione:
- Wait (nessun parametro) = attendi fino alla fine della transazione concorrente
- Wait Timeout N sec = attendi fino alla fine della transazione concorrente, ma non più di N secondi
- Nowait - non attendere la fine della transazione concorrente
Nota che l’opzione wait è specificata in pseudocodice qui mentre i nomi possono essere diversi nell’API e nei componenti specifici, sebbene il significato rimanga lo stesso.
Vediamo in dettaglio cosa succede in caso di conflitti di aggiornamento con varie varianti dell’opzione wait.
Wait
Quindi, immaginiamo due transazioni attive concorrenti (#11 e #14) all’interno delle quali viene eseguita la dichiarazione UPDATE che deve modificare uno stesso record in una stessa tabella T1.
La transazione #14 viene eseguita con l’opzione wait (se usi isql per riprodurre gli esempi, wait è impostato per impostazione predefinita).
La dichiarazione UPDATE nella transazione #11 inizia al momento t3 e termina al momento t5, ma la transazione non è ancora committata - cioè la dichiarazione COMMIT non c’è fino al momento t6.
Il diagramma qui sotto mostra questa situazione:

La dichiarazione UPDATE viene eseguita anche nella transazione #14 e tenta di aggiornare lo stesso record nella stessa tabella, ma inizia più tardi - approssimativamente al momento t4.
Poiché c’è un conflitto di aggiornamento con l’update della transazione #11 e wait è specificato nella transazione #14, la dichiarazione UPDATE attenderà fino alla fine della transazione conflittuale #11.
Se la transazione #11 dura abbastanza a lungo, la dichiarazione UPDATE nella transazione #14 sembrerà congelata dal punto di vista dell’utente che osserva l’esecuzione di questa dichiarazione.
Se riproduci questa situazione con l’aiuto di due isql.exe, la prossima immagine mostra il momento in cui la seconda transazione (per essere precisi, la transazione in cui la dichiarazione UPDATE concorrente inizia più tardi - è la transazione #14 nel nostro esempio) attende fino alla fine della prima transazione (è la transazione #11 nel nostro esempio).

Dopo che la dichiarazione COMMIT viene eseguita nella transazione #11, la transazione #14 che la attende verrà immediatamente notificata e l’update conflittuale terminerà con un’eccezione.
Qui sotto puoi vedere un esempio di tale messaggio di errore (il numero della transazione concorrente non coincide con il nostro esempio perché i numeri delle transazioni iniziano dall’inizio in ogni database e poi si incrementano solo mentre vengono azzerati solo dopo backup/restore):
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>
Presta attenzione alla parola “deadlock” nel messaggio di errore - ora in realtà non c’è alcun deadlock secondo la sua definizione classica. Invece, c’è un conflitto di aggiornamento, ma gli sviluppatori di Firebird non cambiano il messaggio di errore perché è stato usato per più di 35 anni. Ci occuperemo del vero “deadlock” classico più avanti.
Quindi, abbiamo esaminato la situazione in cui la transazione con la dichiarazione UPDATE concorrente termina con la dichiarazione COMMIT. Consideriamo ora una situazione simile, ma in cui viene eseguito il rollback - puoi vederlo nel diagramma qui sotto:

La situazione è completamente simile alla precedente - due dichiarazioni UPDATE tentano di aggiornare uno stesso record, ma questa volta la transazione concorrente #20 termina con un rollback e le modifiche all’interno della transazione #15 vengono salvate nel database senza errori come risultato.
Pertanto, l’opzione wait consente di organizzare la logica di business degli aggiornamenti in modo tale che gli aggiornamenti in conflitto attendano indefinitamente in una coda, sperando fino all’ultimo momento che la transazione in conflitto con essi termini con l’istruzione ROLLBACK.
Questa tattica ha sempre senso? Naturalmente, dipende dall’implementazione della logica di business, ma Firebird offre anche altre opzioni per risolvere i conflitti di aggiornamento con l’aiuto dell’opzione wait.
Wait con timeout
Innanzitutto, può essere una buona idea limitare il tempo di attesa - invece di attendere indefinitamente in caso di conflitto, è possibile limitare il tempo di attesa specificando un timeout per l’opzione wait.
In isql.exe tale parametro viene specificato con l’aiuto della seguente istruzione:
SET TRANSACTION WAIT LOCK TIMEOUT N;
Dove N è il tempo (in secondi) che la transazione concorrente attenderà per la risoluzione del conflitto.
Puoi trovare maggiori dettagli sulle istruzioni di controllo delle transazioni nel Firebird Language Reference. Nota che potrebbero esserci diversi modi per specificare il timeout in driver specifici o componenti di accesso (di solito, con l’aiuto del parametro API).
Puoi vedere un esempio in isql nell’immagine qui sotto:

Studiamo con l’aiuto dei diagrammi delle transazioni come interagiscono le transazioni se si specifica il timeout per l’opzione wait.
Quindi, la situazione è la stessa - due transazioni concorrenti #11 e #14 all’interno delle quali viene eseguita l’istruzione UPDATE che tenta di aggiornare lo stesso record nella tabella T1.

Tuttavia, in questo caso, l’istruzione all’interno della transazione #14 attende o che la transazione #11 termini o che scada il timeout specificato (3 secondi) - a seconda di quale evento si verifichi per primo.
In questo esempio il timeout scade prima, l’istruzione termina con un’eccezione:
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
Nota che “deadlock” è di nuovo nel messaggio di errore, ma non è ancora un “vero” deadlock.
Di conseguenza, la situazione è simile a quella con l’opzione wait, ma è limitata dal timeout - se il timeout specificato scade prima che la transazione concorrente termini.
Specificare l’opzione wait con timeout può essere una buona soluzione per implementare la logica di business se sai per certo che tutte le transazioni di scrittura sono piuttosto brevi (ad esempio, non più lunghe di 1-2 secondi).
Nowait
È molto facile spiegare cosa sia Nowait dal punto di vista formale - è wait con timeout zero. Se specifichi nowait nelle transazioni, gli aggiornamenti in conflitto genereranno un’eccezione immediatamente.

In questo caso, abbiamo di nuovo transazioni concorrenti #11 e #14 (nowait) dove vengono eseguite le istruzioni UPDATE concorrenti. L’istruzione all’interno della transazione con l’opzione nowait non attende quando vede un aggiornamento concorrente, ma solleva la seguente eccezione immediatamente al momento del suo aggiornamento (solo il numero di transazione è diverso):
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>
Ecco come appare in un esempio con due strumenti isql:

Nota che la transazione nowait non si preoccupa di quando e come termina la transazione con l’istruzione UPDATE concorrente - che si tratti di un’istruzione COMMIT o ROLLBACK, l’eccezione viene comunque sollevata.
Dal punto di vista della logica di business, la transazione nowait può essere comoda se sai per certo che l’aggiornamento concorrente deve comportare senza dubbio l’annullamento delle azioni dell’istruzione corrente.
Molti driver Firebird usano l’opzione nowait come valore predefinito e, poiché molti sviluppatori non sanno che è possibile impostare un livello meno restrittivo per la risoluzione dei conflitti di aggiornamento (ad esempio, wait lock timeout 1), le loro applicazioni (e talvolta anche gli utenti) soffrono di errori inutili dovuti ai conflitti.
Poiché la parola chiave “deadlock” è presente in ogni eccezione relativa ai conflitti di aggiornamento, molti sviluppatori di applicazioni sono sicuri che questo sia ciò che è effettivamente un vero deadlock (alcuni pensano addirittura che un certo “Dead” abbia avuto un ruolo in questo errore).
Allo stesso tempo, se diamo un’occhiata al file di configurazione firebird.conf, vedremo il parametro DeadlockTimeout (che è di 10 secondi per impostazione predefinita), e se guardiamo l’intestazione di output dell’utilità fb_lock_print, vedremo anche il parametro “Deadlock scans”.
Il fatto è che un “vero deadlock” è possibile in Firebird e la parola chiave “deadlock” che appare in tutte le eccezioni relative ai conflitti di aggiornamento non ha alcuna connessione diretta con esso. Fortunatamente, il vero deadlock si verifica piuttosto raramente.
Vediamo cosa sia questo “vero deadlock”. Per farlo, diamo un’occhiata al seguente diagramma di interazione delle transazioni:

Abbiamo due transazioni concorrenti con l’opzione wait dove viene eseguita l’istruzione UPDATE. A differenza del caso di un semplice conflitto di aggiornamento, qui possiamo vedere un conflitto di aggiornamento interdipendente:
- La transazione #11 aggiorna il record con chiave = 20, e la transazione #12 aggiorna il record con chiave = 10;
- Dopodiché, la transazione #11 aggiorna il record con chiave = 10, e la transazione #12 aggiorna il record con chiave = 20;
Di conseguenza, abbiamo una situazione in cui ogni transazione deve attendere che l’altra termini ed entrambe potrebbero attendere indefinitamente perché entrambe hanno l’opzione wait specificata. Naturalmente, il server non può permettere che ciò accada, quindi una delle transazioni sarà costretta a essere annullata dopo il timeout specificato nel parametro DeadlockTimeout impostato al valore di 10 secondi per impostazione predefinita.
Possiamo riprodurre questa situazione con l’aiuto di due isql:

Dopo l’avvio della seconda transazione, si verifica una situazione di vero deadlock. Per accertarsene, il server avvia una procedura chiamata Deadlock scan - viene avviata a intervalli pari a DeadlockTimeout che equivale a 10 secondi per impostazione predefinita.
Nota che il client (isql in questo caso) riceve un normale messaggio di conflitto di aggiornamento, ma viene avviato dopo 10 secondi anche se la transazione è stata avviata con l’opzione wait.
Dopo che il server rileva il blocco interdipendente di due transazioni, incrementerà anche il contatore interno di deadlock (puoi vederlo nell’output di fb_lock_print).
Uso pratico di Snapshot Table Stability
Ora che sappiamo come funzionano le transazioni con istruzioni UPDATE in conflitto, possiamo tornare al livello di isolamento Snapshot Table Stability e trovarne un uso pratico.
Quindi, quando viene specificato questo livello di isolamento, la tabella viene bloccata per la scrittura e anche per la lettura.
Nota che se la tabella non è esplicitamente specificata nei parametri della transazione, tutte le tabelle a cui le istruzioni accedono all’interno di questa transazione vengono bloccate e ciò accade durante il primo accesso a una tabella. Apparentemente, se questo livello di isolamento viene usato senza cautela, porterà facilmente a un gran numero di conflitti di aggiornamento.
La clausola Reserving TableNN consente di specificare una particolare tabella (o più tabelle) da bloccare all’inizio della transazione (è anche possibile specificare la modalità di riserva).
Questa grande funzionalità insieme all’opzione wait consente di implementare una coda sequenziale molto efficace per la modifica di una tabella specifica.
In pratica, appare così - quei client che necessitano di creare una coda per una particolare tabella avviano la transazione SNAPSHOT TABLE STABILITY specificando questa tabella e poi tentano di eseguire all’interno di questa transazione un’operazione e terminarla immediatamente.
Ad esempio, vogliamo creare un contatore con incremento sequenziale in una tabella con l’unico record del tipo CREATE TABLE Table1(i1 integer not null), ma non possiamo usare un generatore per qualche motivo.
Lo pseudocodice appare approssimativamente così:
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
Se eseguiamo questo codice non con il livello di isolamento Snapshot Table Stability (Table1), ma con un livello di isolamento inferiore, sarà possibile che un’istruzione UPDATE concorrente interferisca tra l’inizio della transazione e prima della sua istruzione UPDATE. Di conseguenza, otterremo o un’eccezione di aggiornamento immediata (nowait) o l’istruzione si bloccherà fino alla fine della transazione concorrente (wait) o al timeout (intervallo di attesa) - in altre parole, il conflitto sarà in qualche modo risolto a livello di istruzione.
Con il livello di isolamento snapshot table stability, siamo protetti da questo perché la tabella è riservata all’inizio della transazione - è interamente nostra o interamente non nostra. Se specifichiamo l’opzione wait per la risoluzione dei conflitti, le connessioni parallele formeranno automaticamente una coda senza gestire alcun errore.

Naturalmente, questo approccio può essere applicato solo a transazioni brevi (come nel nostro esempio).
In pratica, il livello di isolamento Snapshot table stability viene utilizzato per formare code e ricalcolare logiche complesse in modalità esclusiva (in tabelle relativamente piccole o quando non ci sono altri utenti).
All’interno del motore, Firebird utilizza il livello di isolamento Snapshot Table Stability per creare indici - cioè quando esegui l’istruzione ALTER INDEX indexname ACTIVE;, Firebird occuperà completamente la tabella per cui viene costruito l’indice.
Cosa succede dopo?
Questo articolo fornisce solo un’introduzione ai concetti delle transazioni Firebird. Per comprendere completamente come funzionano le transazioni in Firebird, è necessario considerare l’architettura multi-generazionale (versioni dei record e concetti di garbage collection), considerare i marcatori di transazione (Oldest Interesting, Oldest Active, Oldest Snapshot, next) e altre cose.
L’articolo si basa sui materiali del seminario/workshop “All About Transactions”, presentato per la prima volta nel 2013 durante i seminari Firebird Tour, e sulla base della formazione IBSurgeon " Firebird Transaction in details".
Contatti
[email protected] Non esitare a contattarci per qualsiasi domanda o suggerimento: [email protected]