Transactions dans Firebird
Transactions dans Firebird : ACID, niveaux d’isolation, interblocages et résolution des conflits de mise à jour
Alexey Kovyazin, avec l’aide de Vlad Khorsun et Dmitry Kuzmenko, 08-AVR-2019
Sommaire :
- Comment nous parlerons des transactions
- ACID en schémas
- Niveaux d’isolation des transactions dans Firebird
- Résolution des conflits de mise à jour : option Wait
- Vrai interblocage
Est-il nécessaire de savoir comment fonctionnent les transactions ?
C’est probablement parce que la notion de transactions est simple que beaucoup de développeurs sous-estiment l’importance d’utiliser correctement les transactions dans Firebird. Cependant, ce n’est qu’après avoir acquis une compréhension approfondie du fonctionnement des transactions que vous pourrez comprendre de nombreux phénomènes mystérieux liés aux performances, tels que les ralentissements soudains de la base de données (liés au balayage des versions excessives d’enregistrements qui apparaissent en raison d’une mauvaise gestion des transactions).
D’une manière générale, la notion de transaction s’applique à tout système dynamique qui passe d’un état à un autre. Par exemple, un exemple classique de transaction est le transfert d’argent d’un compte à un autre. En général, cela ressemble à ceci :
Début --- transfert d'argent du compte 1 au compte 2
--diminuer le compte 1
--augmenter le compte 2
Fin - validation de la transaction
L’exemple se résume au fait que l’argent doit disparaître du compte 1 et apparaître sur le compte 2 simultanément, sinon il y aura soit un excédent d’argent, soit un manque d’argent inexpliqué pendant un certain temps dans le système.
Du point de vue des bases de données, une transaction est généralement définie comme un groupe d’opérations effectuées sur une base de données qui est considéré comme indépendant des autres transactions. De mon point de vue, cette définition n’est ni meilleure ni pire que d’autres définitions mais, comme toute définition, elle a peu de sens sans connaître le fonctionnement interne réel et la logique d’un SGBD.
On considère qu’une transaction dans une base de données doit répondre aux exigences dites ACID
A - Atomicité
C - Cohérence
I - Isolation
D - Durabilité
De nombreux développeurs d’applications de bases de données sont tellement inspirés par cet acronyme qu’ils utilisent souvent des arguments tels que « vous n’avez pas le D dans ACID » lorsqu’il s’agit de comparer différents SGBD (ce qui est généralement immédiatement suivi de « je me fiche de ce que vous pensez »).
En réalité, tout est assez simple - ACID est un ensemble d’exigences concernant l’implémentation d’une transaction dans un SGBD particulier, certaines sont très strictes (par exemple, D - bien sûr, la durabilité est importante !) tandis que d’autres le sont moins - lorsque nous examinerons les niveaux d’isolation des transactions, nous verrons que l’isolation peut varier.
C’est pourquoi il ne vaut pas la peine d’essayer de comprendre immédiatement ce que signifie littéralement cet acronyme. Au lieu de cela, nous examinerons la logique du fonctionnement des SGBD (et des transactions, en particulier) et verrons ACID du point de vue de « comment c’est fait » plutôt que « ce que cela signifie ».
Étant donné que les aspects des transactions sont complexes, nous avons besoin d’une représentation graphique - une sorte de schémas - pour montrer le travail et l’interaction des transactions. En utilisant ces schémas, nous pourrons construire un récit logique et examiner en détail le fonctionnement des transactions.
Tout d’abord, nous introduirons une chronologie car les transactions se développent dans le temps. La chronologie sera marquée comme nous en avons besoin - nous n’avons besoin ni de secondes ni de minutes, mais des étapes clés de l’interaction entre les transactions :

Ensuite, nous ajouterons une transaction sur cette chronologie - dessinons-la sous la forme d’un rectangle dont les côtés correspondront au début et à la fin de la transaction. Comme toutes les transactions dans Firebird sont numérotées, nous spécifierons également le numéro de la transaction.

Ainsi, le schéma montre la transaction numéro 11 qui a commencé au moment t3 et s’est terminée au moment t10. Il existe deux façons pour une transaction de se terminer - COMMIT, c’est-à-dire appliquer toutes les modifications effectuées dans la transaction, et ROLLBACK, c’est-à-dire annuler toutes les modifications effectuées dans la transaction. Nous montrerons la façon dont une transaction se termine de la manière suivante :

Pour pouvoir continuer, nous devrons spécifier divers paramètres des transactions sur ces schémas et nous les spécifierons dans le coin inférieur gauche du rectangle représentant la transaction correspondante - cet exemple montre que la transaction #11 a le niveau d’isolation snapshot.

Lorsque nous disons que « la transaction X insère des données » ou « la transaction Y lit telles et telles données » - c’est formellement incorrect, car nous devrions dire « des modifications ont été effectuées dans la transaction X ». Seules les instructions SQL peuvent lire ou insérer des données, donc, si c’est important pour le récit, nous montrerons ces instructions à l’intérieur du rectangle de la transaction :

Dans cet exemple, nous avons l’opération INSERT pour la table T1, le champ i1, la valeur 100 - cette opération est effectuée dans la transaction #11 et elle est validée.
De plus, nous devrons parfois montrer le résultat d’une opération, par exemple, dans l’exemple suivant :

Cet exemple montre ce qui suit :
- La transaction #11 avec le paramètre de niveau d’isolation défini sur snapshot (les niveaux d’isolation seront discutés plus tard, ici il est montré juste pour dresser le tableau complet) démarre au moment t3
- L’opération INSERT INTO T1(i1) values (100) qui insère la valeur 100 dans le champ i1 de la table t1 commence au moment t5 ET se termine au moment t7
- L’opération SELECT i1 from T1 qui retourne la valeur i1 égale à 100 commence au moment t8
- La transaction #11 se termine avec l’instruction COMMIT, c’est-à-dire que les modifications effectuées par la transaction #11 sont validées dans la base de données
Ainsi, à l’aide des schémas de transactions, nous pouvons décrire en détail ce qui se passe dans la base de données et apprendre comment fonctionnent les transactions.
Maintenant que nous avons les schémas de transactions, voyons ce que signifie réellement l’acronyme ACID.
Atomicité
L’atomicité signifie que soit toutes les opérations composant une transaction sont effectuées, soit aucune d’entre elles n’est effectuée : « tout ou rien ». Cela semble simple comme bonjour, mais ensuite les détails apparaissent.
Premièrement, le SGBD (non seulement Firebird mais presque tous) a 2 types d’atomicité : l’atomicité au niveau d’une instruction et l’atomicité au niveau d’un groupe d’instructions dans une transaction.
L’atomicité au niveau de l’instruction signifie que l’instruction UPDATET1 SETX=1 WHEREY=2 est toujours soit exécutée avec succès, soit non.
L’atomicité au niveau d’un groupe d’instructions fonctionne différemment (nous utiliserons du pseudocode ici pour marquer quand la transaction est démarrée et validée) :
Démarrer la transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
Voici à quoi cela ressemblera approximativement sur le schéma :

Cela signifie que les trois instructions INSERT sont exécutées avec succès et que les modifications qu’elles apportent sont validées au moment où la transaction #11 est validée.
La question que je pose souvent lors des ateliers lorsqu’il s’agit de transactions - l’instruction COMMIT sera-t-elle exécutée avec succès pour la transaction 11 si INSERT INTO..300 lève une exception :

Une partie considérable de l’auditoire répond toujours que l’instruction COMMIT ne sera pas exécutée avec succès ! (Fait intéressant, cela entraînera l’annulation de la transaction dans certains autres SGBD !)
Cependant, ce n’est pas vrai - exécutez simplement isql et menez une expérience avec n’importe quelle base de données (isql a une implémentation simple et directe des opérations, sans « deviner » pour l’utilisateur).
Le fait est que l’atomicité au niveau des groupes d’instructions garantie par la validation des transactions est une question de logique métier. Le développeur d’une application doit décider si une transaction doit être validée en cas d’exception dans la troisième instruction INSERT ou non. Si la logique métier permet de valider le résultat, l’instruction COMMIT peut facilement être exécutée.
Ainsi, l’exigence d’atomicité dans ACID est l’exigence que le SGBD puisse valider ou annuler les résultats d’un groupe d’instructions exécutées dans une seule transaction. La décision de la valider ou de l’annuler dépend de la logique métier que vous devez implémenter.
Et soulignons-le encore une fois - bien que l’atomicité d’une transaction pour un groupe d’instructions signifie la possibilité de valider ou d’annuler tout le groupe indépendamment des résultats (et le choix dépend de la logique métier), l’atomicité d’une seule instruction est garantie par l’implémentation du SGBD, c’est-à-dire qu’il est impossible d’exécuter une seule instruction (par exemple, UPDATE) « incomplètement » (non atomiquement).
Cohérence
La cohérence signifie que les données dans la base de données ne présentent aucune contradiction. Bien sûr, ici nous pouvons voir tout un domaine de spéculation car « qu’est-ce que “ne présente aucune contradiction” signifie au juste » ?
Deux niveaux de cohérence sont généralement distingués :
- Niveau de la base de données où la cohérence signifie la correspondance des données aux contraintes de la base de données, telles que les clés Primary, Unique et Foreign, les Checks. Ce niveau de cohérence est assuré par le fait que les contraintes de la base de données ne permettront pas d’insérer des données qui ne correspondent pas aux contraintes : par exemple, CHECK(x>0) n’autorisera pas l’insertion d’un nombre négatif dans le champ correspondant.
- Niveau de la logique métier où la cohérence est assurée par le développeur de l’application à l’aide des outils offerts par le SGBD, tels que les transactions.
Comment les transactions aident-elles à assurer la cohérence au niveau de la logique métier ? Assez simple - si nous prenons l’exemple du transfert d’argent, le développeur doit s’assurer que toutes les modifications sont annulées en cas d’exception et l’utilisation d’une transaction l’aide dans cette tâche.
Démarrer la transaction
Diminuer le montant d'argent sur le compte 1…. Succès
L'augmenter sur le compte 2… Échec
Rollback ---- en cas d'exception !
En d’autres termes, le développeur doit écrire le code de telle manière que les données soient annulées en cas d’exception et ainsi la cohérence des données est préservée du point de vue de la logique métier.
Ainsi, l’exigence de cohérence dans l’acronyme ACID signifie qu’il est nécessaire pour le SGBD d’avoir la possibilité de maintenir la cohérence des données à l’aide du mécanisme de transaction.
Isolation
L’exigence d’isolation des transactions découle de la nécessité de garantir le résultat d’un ensemble d’opérations quel que soit l’ordre dans lequel elles sont effectuées.
En termes simples, chaque transaction doit être exécutée avec un seul et même résultat indépendamment des transactions étant simultanément actives.
Le mécanisme des transactions est censé assurer la cohérence au niveau de la logique métier, mais il est également censé protéger les transactions contre les données temporaires non confirmées qui peuvent apparaître pendant le processus d’exécution de transactions concurrentes.
Cela ressemble à ceci en pratique :

Nous voyons la transaction #11 démarrée au moment t2, dans laquelle une insertion est effectuée dans la table au moment t3-t5. La transaction #11 n’est pas validée immédiatement après l’insertion mais continue d’être active jusqu’au moment t8.
Simultanément, la transaction #12 est démarrée et elle exécute l’instruction SELECT pour les enregistrements de la table dans lesquels la transaction #11 a inséré. La première instruction SELECT est exécutée au moment t6, c’est-à-dire lorsque l’opération d’insertion est déjà terminée, mais cette instruction retourne un résultat vide car la transaction #12 ne peut pas voir les données non validées des autres transactions.
La transaction #11 est validée au moment t8 et l’instruction SELECT dans la transaction #12 est exécutée au moment t9. Elle retourne le résultat égal à 100 car les données créées dans la transaction #11 sont maintenant validées (et parce que le niveau d’isolation de la transaction #12 est read committed, mais nous en parlerons plus tard).
Этот пример вполне достаточен, чтобы проиллюстрировать требование изоляции - в отличие от атомарности и согласованности, изоляция реализуется как строгие правила, называемые уровнями изоляции, и каждая транзакция должна иметь параметр, задающий уровень изоляции, с которым она работает.
Долговечность
Концепция долговечности позволяет разработчику полностью полагаться на тот факт, что данные, созданные в рамках зафиксированной транзакции, немедленно появятся в базе данных и не исчезнут из неё (без явных операторов, которые их удаляют или изменяют, конечно), независимо от того, что произойдёт дальше.
Как видите, требование долговечности - это просто здравый смысл: вряд ли кто-то согласится использовать систему, данные из которой могут внезапно исчезнуть.
ACID: краткое изложение
ACID означает требования к тому, как должны работать транзакции:
- Атомарность
- Операторы всегда атомарны
- Группы операторов можно сделать атомарными с помощью транзакций
- Согласованность
- Два уровня согласованности: ограничения базы данных и бизнес-логика
- Изоляция
- Обеспечивается механизмом транзакций с помощью заданных для них уровней изоляции
- Долговечность
- Все зафиксированные данные становятся постоянными
Как видите, всё довольно логично. На практике основную сложность представляют уровни изоляции, поэтому давайте подробно рассмотрим, как они работают.
Уровень изоляции транзакции определяет, какие зафиксированные данные может видеть эта транзакция.
Существуют уровни изоляции, которые условно называются стандартными. Они описаны в стандарте ANSI SQL (в различных редакциях). Насколько мне известно, нет ни одной СУБД, где они реализованы точно так, как описано в стандарте, но никого это не беспокоит, поскольку фактические механизмы транзакций в конкретных СУБД имеют все необходимые опции для реализации бизнес-логики.
Классическое определение уровней изоляции можно найти в «A Critique of ANSI SQL Isolation Levels».
Для тех, кто читал эту статью, вот таблица, сравнивающая классические уровни изоляции с аналогичными в Firebird. Конечно, соответствие не является прямым, потому что уровни изоляции в Firebird, как и в других СУБД, не соответствуют на 100% определениям ANSI SQL, но они очень близки к ним.
| Уровни изоляции ANSI | Уровень изоляции в Firebird |
| Read Uncommitted | н/д |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
Как и любая другая СУБД, Firebird имеет свои особенности в реализации изоляции. Теперь мы сосредоточимся на том, как уровни изоляции работают в Firebird, а не на том, насколько они соответствуют стандарту.
Уровень изоляции Snapshot
Уровень изоляции Snapshot был первым в исходном коде InterBase и остаётся уровнем по умолчанию для основного API Firebird и утилит (например, isql.exe). Возможно, именно поэтому его легче всего понять.
Snapshot изолирует транзакцию от любых изменений, сделанных с момента её начала.
Давайте посмотрим на схему транзакции ниже: она показывает транзакцию №10, запущенную с уровнем изоляции snapshot. В рамках этой транзакции выполняется несколько операторов SELECT для таблицы T1, в которой в этом примере нет записей.

Параллельная транзакция №15, начатая после старта транзакции №10, вставляет данные в таблицу T1, и эта транзакция завершается оператором COMMIT в момент t9, т.е. данные фиксируются в базе данных в этот момент и становятся доступными для операторов из других транзакций.
Однако оператор в транзакции №10, выполненный в момент t10 (т.е. после фиксации транзакции №15), не видит вставленные данные, потому что уровень изоляции snapshot позволяет видеть только зафиксированные данные, вставленные или изменённые ДО НАЧАЛА транзакции №10.
Таким образом, уровень изоляции Snapshot позволяет работать с базой данных так, как если бы она была заморожена на момент начала транзакции. Обычно это необходимо для построения сложных отчётов на основе быстро меняющихся данных: snapshot используется, чтобы избежать ситуации, когда первая часть отчёта основана на одних данных, а последняя - на других.
Однако у этой замечательной функции есть своя цена - когда мы позже рассмотрим, как изоляция реализована в Firebird, вы увидите, что запуск очень длинных транзакций с уровнем изоляции snapshot приводит к чрезмерному количеству версий записей и снижению производительности.
Уровень изоляции Read Committed
Транзакция с уровнем изоляции read committed может видеть зафиксированные данные других транзакций, которые фиксируются, пока она активна (в отличие от уровня snapshot, когда можно видеть только данные, зафиксированные до момента начала транзакции).
Давайте покажем, как работает уровень изоляции read committed, с помощью следующей схемы:

На ней показан пример, практически идентичный предыдущему: две параллельные транзакции, одна из которых регулярно читает данные из таблицы T1, а вторая вставляет и фиксирует данные.
В отличие от случая с уровнем изоляции snapshot, транзакция №10 в этом примере видит данные, вставленные и зафиксированные транзакцией №15.
Этот пример даёт нам представление о влиянии уровня изоляции read committed: операторы в рамках транзакции с этим уровнем изоляции могут видеть данные, зафиксированные до момента выполнения соответствующего оператора.
Следующая схема показывает пример, где две параллельные транзакции №11 и №18 изменяют данные.
Обратите внимание, что транзакция №11 начинается до начала транзакции №14, которая читает данные, а транзакция №18 начинается после неё, но это не влияет на результат: если данные зафиксированы, они могут быть видны параллельной транзакции с уровнем изоляции read committed.

Эта возможность делает уровень изоляции read committed естественным выбором для тех SQL-операторов, которые регулярно выполняются для отображения последнего состояния базы данных (например, для отображения последних заказов).
Раздел, посвящённый сборке мусора, покажет, что транзакции read committed с модификатором read-only в Firebird до версии 4 являются лучшим выбором для «бесконечных» читающих транзакций, потому что они запускаются как предварительно зафиксированные.
Уровень изоляции Snapshot table stability
Историю об уровне изоляции snapshot table stability, который является аналогом стандартного уровня Serializable, можно сделать либо очень короткой, либо довольно длинной и подробной.
Короткая версия истории такова: этот уровень полностью аналогичен уровню snapshot, но дополнительно блокирует таблицу (таблица должна быть явно указана в параметрах транзакции) для записи и чтения. Это означает, что можно запустить транзакцию, которая полностью займёт указанную таблицу, и любые другие транзакции получат ошибки доступа.
Другими словами, транзакция с уровнем изоляции snapshot table stability фактически поставит все запросы к указанной таблице в очередь. На самом деле, только чтения в обычных транзакциях будут выполняться вне очереди (как обычно), а все остальные режимы образуют очередь (это зависит от взаимодействия, конечно).
Если реализовать это без осторожности, это может вызвать блокировки и невозможность работы с базой данных, поэтому разработчики приложений для Firebird могут опасаться использовать этот уровень изоляции.
Однако при правильной реализации уровень Serializable позволяет легко формировать очереди и вносить последовательные изменения в записи базы данных, что может быть очень полезно для реализации счётчиков, последовательных номеров документов и других подобных объектов.
Чтобы правильно описать, как сформировать очередь с помощью транзакции с уровнем изоляции snapshot table stability, нам придётся рассмотреть ещё один параметр транзакции: wait/nowait - а затем вернуться к примеру с очередью.
Ранее мы рассматривали такой способ взаимодействия транзакций, при котором данные изменяются в одной транзакции, а читаются в другой.
Однако на практике часто бывает, что разные транзакции пытаются изменить одни и те же данные, и поскольку в базе данных сохраняется только один результат, параллельная транзакция получит сообщение о конфликте - фактически исключение, которое прервёт (и отменит) выполнение того конкретного оператора, который пытается изменить уже изменённые данные.
Опция wait определяет, как транзакция должна реагировать на конфликт обновления. Существует три способа настройки этой опции:
- Wait (без параметров) = ждать завершения параллельной транзакции
- Wait Timeout N sec = ждать завершения параллельной транзакции, но не более N секунд
- Nowait - не ждать завершения параллельной транзакции
Обратите внимание, что опция wait указана здесь в псевдокоде, а имена могут отличаться в API и в конкретных компонентах, хотя смысл остаётся тем же.
Давайте подробно рассмотрим, что происходит в случае конфликтов обновления с различными вариантами опции wait.
Wait
Итак, представим две параллельно активные транзакции (#11 и #14), в рамках которых выполняется оператор UPDATE, который должен изменить одну и ту же запись в одной и той же таблице T1.
Транзакция №14 работает с опцией wait (если вы используете isql для воспроизведения примеров, wait установлен по умолчанию).
Оператор UPDATE в транзакции №11 начинается в момент t3 и заканчивается в момент t5, но транзакция ещё не зафиксирована - т.е. оператора COMMIT нет до момента t6.
Схема ниже показывает эту ситуацию:

Оператор UPDATE также выполняется в транзакции №14, и он пытается обновить ту же запись в той же таблице, но начинается позже - примерно в момент t4.
Поскольку существует конфликт обновления с обновлением из транзакции №11 и в транзакции №14 указан wait, оператор UPDATE будет ждать завершения конфликтующей транзакции №11.
Если транзакция №11 длится достаточно долго, оператор UPDATE в транзакции №14 будет казаться замороженным с точки зрения пользователя, наблюдающего за выполнением этого оператора.
Если вы воспроизведёте эту ситуацию с помощью двух экземпляров isql.exe, следующая картинка показывает момент, когда вторая транзакция (точнее, транзакция, в которой параллельный оператор UPDATE начинается позже - в нашем примере это транзакция №14) ждёт завершения первой транзакции (в нашем примере это транзакция №11).

После выполнения оператора COMMIT в транзакции №11 транзакция №14, ожидающая её, будет немедленно уведомлена, и конфликтующее обновление завершится исключением.
Ниже вы можете увидеть пример такого сообщения об ошибке (номер параллельной транзакции не совпадает с нашим примером, потому что номера транзакций начинаются с начала в каждой базе данных и затем только увеличиваются, сбрасываясь только после резервного копирования/восстановления):
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>
Обратите внимание на слово «deadlock» в сообщении об ошибке - сейчас на самом деле нет взаимоблокировки в её классическом определении. Вместо этого есть конфликт обновления, но разработчики Firebird не меняют сообщение об ошибке, потому что оно используется уже более 35 лет. Мы разберёмся с настоящей «классической» взаимоблокировкой позже.
Итак, мы рассмотрели ситуацию, когда транзакция с параллельным оператором UPDATE завершается оператором COMMIT. Теперь рассмотрим аналогичную ситуацию, но когда она откатывается - вы можете увидеть её на схеме ниже:

Ситуация полностью аналогична предыдущей - два оператора UPDATE пытаются обновить одну и ту же запись, но параллельная транзакция №20 на этот раз откатывается, и в результате изменения в рамках транзакции №15 сохраняются в базе данных без ошибки.
Ainsi, l’option d’attente permet d’organiser la logique métier des mises à jour de telle sorte que les mises à jour conflictuelles attendent indéfiniment dans une file d’attente, espérant jusqu’au dernier moment que la transaction en conflit se termine par l’instruction ROLLBACK.
Cette tactique a-t-elle toujours du sens ? Bien sûr, cela dépend de l’implémentation de la logique métier, mais Firebird offre également d’autres options pour résoudre les conflits de mise à jour à l’aide de l’option d’attente.
Attente avec délai d’expiration
Tout d’abord, il peut être judicieux de limiter le temps d’attente - au lieu d’attendre indéfiniment en cas de conflit, vous pouvez limiter le temps d’attente en spécifiant un délai d’expiration pour l’option d’attente.
Dans isql.exe, un tel paramètre est spécifié à l’aide de l’instruction suivante :
SET TRANSACTION WAIT LOCK TIMEOUT N;
Où N est le temps (en secondes) pendant lequel la transaction concurrente attendra la résolution du conflit.
Vous pouvez trouver plus de détails sur les instructions de contrôle des transactions dans la référence du langage Firebird. Notez qu’il peut y avoir différentes manières de spécifier le délai d’expiration dans des pilotes ou des composants d’accès spécifiques (généralement, à l’aide du paramètre API).
Vous pouvez voir un exemple dans isql sur l’image ci-dessous :

Étudions à l’aide de diagrammes de transactions comment les transactions interagissent si vous spécifiez le délai d’expiration pour l’option d’attente.
Donc, la situation est la même - deux transactions concurrentes #11 et #14 dans lesquelles l’instruction UPDATE tentant de mettre à jour un même enregistrement dans la table T1 est exécutée.

Cependant, dans ce cas, l’instruction dans la transaction #14 attend soit que la transaction #11 se termine, soit que le délai d’expiration spécifié (3 secondes) expire - selon ce qui se produit en premier.
Le délai d’expiration expire plus tôt dans cet exemple, l’instruction se termine par une exception :
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
Notez que “deadlock” est à nouveau dans le message d’erreur, mais ce n’est toujours pas un “vrai” deadlock.
En conséquence, la situation est similaire à celle de l’option d’attente, mais elle est limitée par le délai d’expiration - si le délai d’expiration spécifié expire plus tôt que la fin de la transaction concurrente.
Spécifier l’option d’attente avec délai d’expiration peut être une bonne solution pour implémenter la logique métier si vous savez avec certitude que toutes les transactions d’écriture sont assez courtes (par exemple, pas plus de 1 à 2 secondes).
Nowait
Il est très facile d’expliquer ce qu’est Nowait d’un point de vue formel - c’est une attente avec un délai d’expiration nul. Si vous spécifiez nowait dans les transactions, les mises à jour conflictuelles lèveront une exception immédiatement.

Dans ce cas, nous avons à nouveau des transactions concurrentes #11 et #14 (nowait) où les instructions UPDATE concurrentes sont exécutées. L’instruction dans la transaction avec l’option nowait n’attend pas lorsqu’elle voit une mise à jour concurrente, mais lève l’exception suivante immédiatement au moment de sa mise à jour (seul le numéro de transaction est différent) :
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>
Voici à quoi cela ressemble dans un exemple avec deux outils isql :

Notez que la transaction nowait ne se soucie pas de savoir quand et comment la transaction avec l’instruction UPDATE concurrente se termine - qu’il s’agisse de l’instruction COMMIT ou ROLLBACK, l’exception est toujours levée.
Du point de vue de la logique métier, la transaction nowait peut être pratique si vous savez avec certitude que la mise à jour concurrente doit entraîner l’annulation sans aucun doute des actions de l’instruction actuelle.
De nombreux pilotes Firebird utilisent l’option nowait comme valeur par défaut et, tant que de nombreux développeurs ne savent pas qu’il est possible de définir un niveau moins strict pour résoudre les conflits de mise à jour (par exemple, wait lock timeout 1), leurs applications (et parfois aussi les utilisateurs) souffrent d’erreurs inutiles dues aux conflits.
Étant donné que le mot-clé “deadlock” est présent dans chaque exception liée aux conflits de mise à jour, de nombreux développeurs d’applications sont sûrs que c’est ce qu’est réellement un vrai deadlock (certains pensent même que ce fameux Dead a joué un rôle dans cette erreur).
En même temps, si nous jetons un œil au fichier de configuration firebird.conf, nous verrons le paramètre DeadlockTimeout (10 secondes par défaut), et si nous regardons l’en-tête de sortie de l’utilitaire fb_lock_print, nous verrons également le paramètre “Deadlock scans”.
Le fait est qu’un “vrai deadlock” est possible dans Firebird et que le mot-clé “deadlock” apparaissant dans toutes les exceptions liées aux conflits de mise à jour n’a aucun lien direct avec lui. Heureusement, le vrai deadlock se produit assez rarement.
Voyons ce qu’est ce “vrai deadlock”. Pour ce faire, examinons le diagramme suivant d’interaction de transactions :

Nous avons deux transactions concurrentes avec l’option d’attente où l’instruction UPDATE est exécutée. Contrairement au cas d’un simple conflit de mise à jour, nous pouvons voir ici un conflit de mise à jour interdépendant :
- La transaction #11 met à jour l’enregistrement avec la clé = 20, et la transaction #12 met à jour l’enregistrement avec la clé = 10 ;
- Ensuite, la transaction #11 met à jour l’enregistrement avec la clé = 10, et la transaction #12 met à jour l’enregistrement avec la clé = 20 ;
En conséquence, nous avons une situation où chaque transaction doit attendre que l’autre se termine et les deux peuvent attendre indéfiniment car les deux ont l’option d’attente spécifiée pour elles. Bien sûr, le serveur ne peut pas permettre que cela se produise, donc l’une des transactions sera forcée d’être annulée après le délai d’expiration spécifié dans le paramètre DeadlockTimeout défini sur la valeur de 10 secondes par défaut.
Nous pouvons reproduire cette situation à l’aide de deux isql :

Après le démarrage de la deuxième transaction, une situation de vrai deadlock se produit. Pour le découvrir avec certitude, le serveur démarre une procédure appelée Deadlock scan - elle est démarrée à des intervalles égaux à DeadlockTimeout qui équivaut à 10 secondes par défaut.
Notez que le client (isql dans ce cas) reçoit un message de conflit de mise à jour régulier, mais il est initié en 10 secondes même si la transaction est démarrée avec l’option d’attente.
Après que le serveur détecte le verrou interdépendant de deux transactions, il incrémentera également le compteur interne de deadlock (vous pouvez le voir dans la sortie fb_lock_print).
Utilisation pratique de Snapshot Table Stability
Maintenant que nous savons comment les transactions fonctionnent avec les instructions UPDATE conflictuelles, nous pouvons revenir au niveau d’isolation Snapshot Table Stability et lui trouver une utilisation pratique.
Donc, lorsque ce niveau d’isolation est spécifié, la table est verrouillée en écriture et même en lecture.
Notez que si la table n’est pas explicitement spécifiée dans les paramètres de la transaction, toutes les tables auxquelles les instructions accèdent dans cette transaction sont verrouillées et cela se produit lors du premier accès à une table. Apparemment, si ce niveau d’isolation est utilisé sans précaution, il conduira facilement à un grand nombre de conflits de mise à jour.
La clause Reserving TableNN vous permet de spécifier une table particulière (ou plusieurs tables) à verrouiller au début de la transaction (il est également possible de spécifier le mode de réservation).
Cette excellente fonctionnalité, combinée à l’option d’attente, vous permet d’implémenter une file d’attente séquentielle très efficace pour modifier une table spécifique.
En pratique, cela ressemble à ceci - les clients qui ont besoin de créer une file d’attente vers une table particulière démarrent la transaction SNAPSHOT TABLE STABILITY en spécifiant cette table, puis ils tentent d’effectuer une opération dans cette transaction et la terminent immédiatement.
Par exemple, nous voulons créer un compteur à incrémentation séquentielle dans une table avec le seul enregistrement du type CREATE TABLE Table1(i1 integer not null), mais nous ne pouvons pas utiliser un générateur pour une raison quelconque.
Le pseudo-code ressemble approximativement à ceci :
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
Si nous exécutons ce code non pas avec le niveau d’isolation Snapshot Table Stability (Table1), mais avec un niveau d’isolation inférieur, il sera possible qu’une instruction UPDATE concurrente interfère entre le début de la transaction et avant son instruction UPDATE. En conséquence, nous obtiendrons soit une exception de mise à jour immédiate (nowait), soit l’instruction se figera jusqu’à la fin de la transaction concurrente (wait) ou le délai d’expiration (wait interval) - en d’autres termes, le conflit sera résolu d’une manière ou d’une autre au niveau de l’instruction.
Avec le niveau d’isolation snapshot table stability, nous sommes protégés contre cela car la table est réservée au début de la transaction - elle est soit entièrement à nous, soit entièrement pas à nous. Si nous spécifions l’option d’attente pour résoudre les conflits, les connexions parallèles formeront automatiquement une file d’attente sans gérer d’erreurs.

Bien sûr, cette approche ne peut être appliquée qu’aux transactions courtes (comme dans notre exemple).
En pratique, le niveau d’isolation Snapshot table stability est utilisé pour former des files d’attente et recalculer une logique complexe en mode exclusif (dans des tables relativement petites ou lorsqu’il n’y a pas d’autres utilisateurs).
À l’intérieur du moteur, Firebird utilise le niveau d’isolation Snapshot Table Stability pour créer des index - c’est-à-dire que lorsque vous exécutez l’instruction ALTER INDEX indexname ACTIVE ;, Firebird occupera complètement la table pour laquelle l’index est en cours de construction.
Et ensuite ?
Cet article ne donne qu’une introduction aux concepts des transactions Firebird. Pour comprendre complètement comment fonctionnent les transactions dans Firebird, il est nécessaire d’examiner l’architecture multi-générationnelle (concepts de versions d’enregistrements et de collecte des déchets), d’examiner les marqueurs de transaction (Oldest Interesting, Oldest Active, Oldest Snapshot, next) et d’autres choses.
L’article est basé sur les matériaux du séminaire/atelier “All About Transactions”, qui a été introduit pour la première fois en 2013 lors des séminaires Firebird Tour, et sur la base de la formation d’IBSurgeon " Firebird Transaction in details".
Contacts
[email protected] N’hésitez pas à nous contacter pour toute question ou suggestion : [email protected]