Эта страница переведена машинным переводом. Читайте английский оригинал. English

Транзакции в Firebird: ACID, уровни изоляции, взаимоблокировки и разрешение конфликтов обновления

Алексей Ковязин, при участии Влада Хорсуна и Дмитрия Кузьменко, 08-АПР-2019

Содержание:

Нужно ли знать, как работают транзакции?

Вероятно, потому что понятие транзакций простое, многие разработчики недооценивают важность правильного использования транзакций в Firebird. Однако только после того, как вы получите полное понимание того, как работают транзакции, вы сможете постичь многие загадочные вещи, связанные с производительностью, такие как внезапные замедления базы данных (связанные с вычищением избыточных версий записей, которые появляются из-за плохого управления транзакциями).

В общем, понятие транзакции применяется к любой динамической системе, которая переходит из одного состояния в другое. Например, классический пример транзакции - перевод денег с одного счета на другой. Обычно это выглядит примерно так:

Code

Begin   --- перевод денег со счета 1 на счет 2
 --уменьшить счет 1
 --увеличить счет 2
End - фиксация транзакции

Пример сводится к тому, что деньги должны исчезнуть со счета 1 и появиться на счете 2 одновременно, иначе в системе будет либо избыток денег, либо необъяснимая нехватка денег в течение некоторого времени.

С точки зрения баз данных, транзакция обычно определяется как группа операций, выполняемых над базой данных, которая рассматривается как независимая от других транзакций. С моей точки зрения, это определение не лучше и не хуже других, но, как и любое определение, оно имеет мало смысла без знания фактической внутренней работы и логики СУБД.

Считается, что транзакция в базе данных должна соответствовать так называемым требованиям ACID

Code
A - Атомарность
С - Согласованность
I - Изоляция
D - Долговечность

Многие разработчики приложений для баз данных настолько вдохновлены этой аббревиатурой, что часто используют такие аргументы, как «у вас нет D в ACID», когда речь идет о сравнении различных СУБД (за которым обычно сразу следует «мне все равно, что вы думаете»).

На самом деле все довольно просто - ACID - это набор требований к реализации транзакции в конкретной СУБД, некоторые из них очень строгие (например, D - конечно, долговечность важна!), а некоторые менее строгие - когда мы рассмотрим уровни изоляции транзакций, мы увидим, что изоляция может варьироваться.

Вот почему не стоит пытаться сразу понять, что буквально означает эта аббревиатура. Вместо этого мы рассмотрим логику работы СУБД (и транзакций, в частности) и посмотрим на ACID с точки зрения «как это сделано», а не «что это значит».

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

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

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

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

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

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

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

Также нам иногда придется показывать результат операции, например, в следующем примере:

Этот пример показывает следующее:

  1. Транзакция #11 с параметром уровня изоляции, установленным на snapshot (уровни изоляции будут обсуждаться позже, здесь это показано просто для полноты картины), начинается в момент t3
  2. Операция INSERT INTO T1(i1) values (100), которая вставляет значение 100 в поле i1 таблицы t1, начинается в момент t5 И заканчивается в момент t7
  3. Операция SELECT i1 from T1, которая возвращает значение i1, равное 100, начинается в момент t8
  4. Транзакция #11 завершается оператором COMMIT, т.е. изменения, сделанные транзакцией #11, фиксируются в базе данных

Таким образом, с помощью схем транзакций мы можем подробно описать, что происходит в базе данных, и узнать, как работают транзакции.

Теперь, когда у нас есть схемы транзакций, давайте посмотрим, что на самом деле означает аббревиатура ACID.

Атомарность

Атомарность означает, что либо все операции, составляющие транзакцию, выполняются, либо ни одна из них не выполняется: «все или ничего». Казалось бы, проще простого, но затем всплывают детали.

Во-первых, СУБД (не только Firebird, но и почти все) имеет 2 типа атомарности: атомарность на уровне оператора и атомарность на уровне группы операторов в рамках транзакции.

Атомарность на уровне оператора означает, что оператор UPDATET1 SETX=1 WHEREY=2 всегда либо выполняется успешно, либо не выполняется.

Атомарность на уровне группы операторов работает иначе (здесь мы будем использовать псевдокод, чтобы отметить, когда транзакция начинается и фиксируется):

Code
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) «неполностью» (не атомарно).

Согласованность

Согласованность означает, что данные внутри базы данных не содержат противоречий. Конечно, здесь мы видим целую область для размышлений, потому что «что вообще значит “не содержат противоречий”»?

Обычно выделяют два уровня согласованности:

  1. Уровень базы данных, где согласованность означает соответствие данных ограничениям базы данных, таким как первичные, уникальные и внешние ключи, проверки (Checks). Этот уровень согласованности обеспечивается тем, что ограничения базы данных не позволят вставить данные, не соответствующие ограничениям: например, CHECK(x>0) не позволит вставить отрицательное число в соответствующее поле.
  2. Уровень бизнес-логики, где согласованность обеспечивается разработчиком приложения с помощью инструментов, предлагаемых СУБД, таких как транзакции.

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

Code
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, но об этом мы поговорим позже).

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

Долговечность

Концепция долговечности позволяет разработчику полностью полагаться на то, что данные, созданные в рамках зафиксированной транзакции, немедленно появятся в базе данных и не исчезнут из нее (без явных операторов, удаляющих или изменяющих их, конечно), независимо от того, что произойдет дальше.

Как видите, требование долговечности - это просто здравый смысл - вряд ли кто-то согласится использовать систему, данные из которой могут внезапно исчезнуть.

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 определяет, как транзакция должна реагировать на конфликт обновления. Существует три способа настройки этой опции:

  1. Wait (без параметров) = ждать, пока параллельная транзакция завершится
  2. Wait Timeout N sec = ждать, пока параллельная транзакция завершится, но не более N секунд
  3. 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, ожидающая ее, будет немедленно уведомлена, и конфликтующее обновление завершится с исключением.

Ниже вы можете увидеть пример такого сообщения об ошибке (номер параллельной транзакции не совпадает с нашим примером, потому что номера транзакций начинаются с начала в каждой базе данных, а затем только увеличиваются, сбрасываясь только после резервного копирования/восстановления):

Code
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 сохраняются в базе данных без ошибки.

Таким образом, опция wait позволяет организовать бизнес-логику обновлений таким образом, чтобы конфликтующие обновления бесконечно ждали в очереди, надеясь до последнего момента, что конфликтующая с ними транзакция завершится оператором ROLLBACK.

Всегда ли эта тактика имеет смысл? Конечно, это зависит от реализации бизнес-логики, но Firebird предлагает и другие варианты разрешения конфликтов обновления с помощью опции wait.

Wait с таймаутом

Прежде всего, может быть хорошей идеей ограничить время ожидания - вместо бесконечного ожидания в случае конфликта можно ограничить время ожидания, указав таймаут для опции wait.

В isql.exe такой параметр задаётся с помощью следующего оператора:

Code
SET TRANSACTION WAIT LOCK TIMEOUT N;

Где N - это время (в секундах), в течение которого параллельная транзакция будет ждать разрешения конфликта.

Более подробную информацию об операторах управления транзакциями можно найти в Firebird Language Reference. Обратите внимание, что в конкретных драйверах или компонентах доступа способы указания таймаута могут отличаться (обычно с помощью параметра API).

Пример в isql вы можете увидеть на рисунке ниже:

Давайте с помощью диаграмм транзакций изучим, как взаимодействуют транзакции, если указать таймаут для опции wait.

Итак, ситуация та же - две параллельные транзакции #11 и #14, в которых выполняется оператор UPDATE, пытающийся обновить одну и ту же запись в таблице T1.

Однако в этом случае оператор внутри транзакции #14 ждёт либо завершения транзакции #11, либо истечения указанного таймаута (3 секунды) - в зависимости от того, что наступит раньше.

В этом примере таймаут истекает раньше, оператор завершается с исключением:

Code
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

Обратите внимание, что в сообщении об ошибке снова присутствует “deadlock”, но это всё ещё не “настоящий” взаимоблокировка.

Соответственно, ситуация аналогична ситуации с опцией wait, но ограничена таймаутом - если указанный таймаут истекает раньше, чем завершится параллельная транзакция.

Указание опции wait с таймаутом может быть хорошим решением для реализации бизнес-логики, если вы точно знаете, что все пишущие транзакции довольно короткие (например, не длиннее 1-2 секунд).

Nowait

Объяснить, что такое Nowait, с формальной точки зрения очень просто - это wait с нулевым таймаутом. Если указать nowait в транзакциях, конфликтующие обновления вызовут исключение немедленно.

В этом случае у нас снова есть параллельные транзакции #11 и #14 (nowait), в которых выполняются параллельные операторы UPDATE. Оператор внутри транзакции с опцией nowait не ждёт, когда увидит параллельное обновление, а немедленно вызывает следующее исключение в момент своего обновления (отличается только номер транзакции):

Code
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>

Вот как это выглядит в примере с двумя инструментами isql:

Обратите внимание, что транзакции nowait безразлично, когда и как завершится транзакция с параллельным оператором UPDATE - будет ли это оператор COMMIT или ROLLBACK, исключение всё равно будет вызвано.

С точки зрения бизнес-логики транзакция nowait может быть удобна, если вы точно знаете, что параллельное обновление должно однозначно привести к отмене действий текущего оператора.

Многие драйверы Firebird используют опцию nowait в качестве значения по умолчанию, и, поскольку многие разработчики не знают, что можно установить менее строгий уровень разрешения конфликтов обновления (например, wait lock timeout 1), их приложения (а иногда и пользователи) страдают от лишних ошибок из-за конфликтов.

Поскольку ключевое слово “deadlock” присутствует в каждом исключении, связанном с конфликтами обновления, многие разработчики приложений уверены, что это и есть настоящая взаимоблокировка (некоторые даже думают, что в этой ошибке сыграл роль некий Dead).

В то же время, если мы посмотрим на конфигурационный файл firebird.conf, мы увидим там параметр DeadlockTimeout (по умолчанию 10 секунд), а если мы посмотрим на заголовок вывода утилиты fb_lock_print, мы также увидим параметр “Deadlock scans”.

Дело в том, что “настоящая” взаимоблокировка возможна в Firebird, и ключевое слово “deadlock”, появляющееся во всех исключениях, связанных с конфликтами обновления, не имеет к ней прямого отношения. К счастью, настоящая взаимоблокировка возникает довольно редко.

Давайте посмотрим, что такое эта “настоящая” взаимоблокировка. Для этого взглянем на следующую диаграмму взаимодействия транзакций:

У нас есть две параллельные транзакции с опцией wait, в которых выполняется оператор UPDATE. В отличие от случая простого конфликта обновления, здесь мы видим взаимозависимый конфликт обновления:

  • Транзакция #11 обновляет запись с ключом = 20, а транзакция #12 обновляет запись с ключом = 10;
  • После этого транзакция #11 обновляет запись с ключом = 10, а транзакция #12 обновляет запись с ключом = 20;

В результате мы получаем ситуацию, когда каждой транзакции приходится ждать завершения другой, и обе они могут ждать бесконечно, потому что для обеих указана опция wait. Конечно, сервер не может этого допустить, поэтому одна из транзакций будет принудительно откачена после таймаута, указанного в параметре DeadlockTimeout, значение которого по умолчанию составляет 10 секунд.

Мы можем воспроизвести эту ситуацию с помощью двух инструментов isql:

После запуска второй транзакции возникает ситуация настоящей взаимоблокировки. Чтобы точно это определить, сервер запускает процедуру под названием Deadlock scan - она запускается с интервалами, равными DeadlockTimeout, который по умолчанию составляет 10 секунд.

Обратите внимание, что клиент (в данном случае isql) получает обычное сообщение о конфликте обновления, но оно инициируется через 10 секунд, даже если транзакция запущена с опцией wait.

После того как сервер обнаруживает взаимозависимую блокировку двух транзакций, он также увеличивает внутренний счётчик взаимоблокировок (его можно увидеть в выводе fb_lock_print).

Практическое использование Snapshot Table Stability

Теперь, когда мы знаем, как транзакции работают с конфликтующими операторами UPDATE, мы можем вернуться к уровню изоляции Snapshot Table Stability и найти ему практическое применение.

Итак, когда указан этот уровень изоляции, таблица блокируется для записи и даже для чтения.

Обратите внимание, что если таблица явно не указана в параметрах транзакции, все таблицы, к которым обращаются операторы в рамках этой транзакции, блокируются, и это происходит при первом обращении к таблице. Очевидно, что если использовать этот уровень изоляции без осторожности, это легко приведёт к большому количеству конфликтов обновления.

Предложение Reserving TableNN позволяет указать конкретную таблицу (или несколько таблиц), которые будут заблокированы в начале транзакции (также можно указать режим резервирования).

Эта замечательная функция вместе с опцией wait позволяет реализовать очень эффективную последовательную очередь для изменения конкретной таблицы.

На практике это выглядит так - те клиенты, которым нужно создать очередь к определённой таблице, запускают транзакцию SNAPSHOT TABLE STABILITY, указывая эту таблицу, а затем пытаются выполнить в рамках этой транзакции операцию и немедленно завершают её.

Например, мы хотим создать последовательно увеличивающийся счётчик в таблице с единственной записью типа CREATE TABLE Table1(i1 integer not null), но по какой-то причине не можем использовать генератор.

Псевдокод выглядит примерно так:

Code
set transaction snapshot table stability reserving TABLE1 for protected write

UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;

Если мы запустим этот код не с уровнем изоляции Snapshot Table Stability (Table1), а с более низким уровнем изоляции, возможно, что параллельная инструкция UPDATE вмешается между началом транзакции и её инструкцией UPDATE. В результате мы получим либо исключение обновления сразу (nowait), либо инструкция будет заморожена до завершения параллельной (wait), либо таймаут (wait interval) - другими словами, конфликт будет так или иначе разрешён на уровне инструкции.

С уровнем изоляции snapshot table stability мы защищены от этого, потому что таблица резервируется в начале транзакции - она либо полностью наша, либо полностью не наша. Если мы укажем опцию wait для разрешения конфликтов, параллельные соединения автоматически образуют очередь без обработки каких-либо ошибок.

Конечно, этот подход можно применять только к коротким транзакциям (как в нашем примере).

На практике уровень изоляции Snapshot table stability используется для формирования очередей и пересчёта сложной логики в эксклюзивном режиме (в относительно небольших таблицах или когда нет других пользователей).

Внутри движка Firebird использует уровень изоляции Snapshot Table Stability для создания индексов - то есть, когда вы выполняете инструкцию ALTER INDEX indexname ACTIVE;, Firebird полностью занимает таблицу, для которой строится индекс.

Что дальше?

Эта статья даёт лишь введение в концепции транзакций Firebird. Чтобы полностью понять, как работают транзакции в Firebird, необходимо рассмотреть мультигенерационную архитектуру (концепции версий записей и сборки мусора), рассмотреть маркеры транзакций (Oldest Interesting, Oldest Active, Oldest Snapshot, next) и другие вещи.

Статья основана на материалах семинара/воркшопа “All About Transactions”, который впервые был представлен в 2013 году во время семинаров Firebird Tour, а также на основе обучения IBSurgeon " Firebird Transaction in details".

Контакты

[email protected] Пожалуйста, не стесняйтесь обращаться к нам с любыми вопросами или предложениями: [email protected]