Цю сторінку перекладено машинним перекладом. Читайте англійський оригінал. English

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

Олексій Ковязін, за допомогою Влада Хорсуна та Дмитра Кузьменка, 08-КВІ-2019

Зміст:

Чи потрібно знати, як працюють транзакції?

Ймовірно, так, оскільки поняття транзакцій є простим, багато розробників недооцінюють важливість правильного використання транзакцій у Firebird. Однак лише після глибокого розуміння того, як працюють транзакції, ви зможете зрозуміти багато загадкових речей, пов’язаних із продуктивністю, таких як раптове сповільнення бази даних (пов’язане з очищенням надмірних версій записів, що з’являються через погане керування транзакціями).

Загалом поняття транзакції застосовується до будь-якої динамічної системи, яка переходить з одного стану в інший. Наприклад, класичний приклад транзакції - переказ грошей з одного рахунку на інший. Зазвичай це виглядає приблизно так:

Code

Begin   --- переказ грошей з рахунку 1 на рахунок 2
 --зменшити рахунок 1
 --збільшити рахунок 2
End - фіксація транзакції

Приклад зводиться до того, що гроші мають зникнути з рахунку 1 і з’явитися на рахунку 2 одночасно, інакше в системі буде або надлишок грошей, або незрозуміла нестача протягом певного часу.

З точки зору баз даних, транзакція зазвичай визначається як група операцій, виконаних над базою даних, яка розглядається як незалежна від інших транзакцій. На мою думку, це визначення не краще і не гірше за інші, але, як і будь-яке визначення, воно мало що означає без знання фактичної внутрішньої роботи та логіки СКБД.

Вважається, що транзакція в базі даних повинна відповідати так званим вимогам ACID

Code
A - Атомарність (Atomicity)
С - Узгодженість (Consistency)
І - Ізоляція (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 і фіксується.

Також нам іноді доведеться показувати результат операції, наприклад, у наступному прикладі:

Цей приклад показує наступне:

  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 - це вимога, щоб СКБД могла зафіксувати (commit) або відкотити (roll back) результати групи операторів, виконаних в межах однієї транзакції. Рішення про фіксацію або відкат залежить від бізнес-логіки, яку вам потрібно реалізувати.

І підкреслимо ще раз - хоча атомарність транзакції для групи операторів означає можливість зафіксувати або відкотити всю групу незалежно від результатів (а вибір залежить від бізнес-логіки), атомарність одного оператора гарантується реалізацією СКБД, тобто неможливо виконати один оператор (наприклад, UPDATE) “неповністю” (не атомарно).

Узгодженість (Consistency)

Узгодженість означає, що дані в базі даних не містять суперечностей. Звісно, тут ми бачимо ціле поле для роздумів, бо “що взагалі означає ‘не містять суперечностей’”?

Зазвичай виділяють два рівні узгодженості:

  1. Рівень бази даних, де узгодженість означає відповідність даних обмеженням бази даних, таким як первинні (Primary), унікальні (Unique) та зовнішні (Foreign) ключі, перевірки (Checks). Цей рівень узгодженості забезпечується тим, що обмеження бази даних не дозволять вставити дані, які не відповідають обмеженням: наприклад, CHECK(x>0) не дозволить вставити від’ємне число у відповідне поле.
  2. Рівень бізнес-логіки, де узгодженість забезпечується розробником застосунку за допомогою інструментів, які пропонує СКБД, таких як транзакції.

Як транзакції допомагають забезпечити узгодженість на рівні бізнес-логіки? Досить просто - якщо взяти приклад переказу грошей, розробник повинен переконатися, що всі зміни будуть відкочені у разі винятку, і використання транзакції допомагає йому в цьому.

Code
Starttransaction
Зменшити суму грошей на рахунку 1…. Успіх
Збільшити її на рахунку 2… Невдача
Rollback ---- у разі винятку!

Іншими словами, розробник повинен написати код таким чином, щоб дані були відкочені у разі винятку, і таким чином узгодженість даних зберігалася з точки зору бізнес-логіки.

Таким чином, вимога узгодженості в абревіатурі ACID означає, що СКБД повинна мати можливість підтримувати узгодженість даних за допомогою механізму транзакцій.

Ізоляція (Isolation)

Вимога ізоляції транзакцій виникає з необхідності гарантувати результат набору операцій незалежно від порядку їх виконання.

Простіше кажучи, кожна транзакція повинна виконуватися з одним і тим самим результатом незалежно від того, чи інші транзакції активно виконуються паралельно.

Механізм транзакцій має забезпечувати узгодженість на рівні бізнес-логіки, але він також має захищати транзакції від тимчасових непідтверджених даних, які можуть з’явитися під час виконання паралельних транзакцій.

На практиці це виглядає так:

Ми бачимо транзакцію #11, яка почалася в момент t2, у межах якої в момент t3-t5 виконується вставка в таблицю. Транзакція #11 не фіксується одразу після вставки, а продовжує бути активною до моменту t8.

Паралельно запускається транзакція #12, яка виконує оператор SELECT для записів таблиці, у яку вставляються дані в межах транзакції #11. Перший оператор SELECT виконується в момент t6, коли операція вставки вже завершена, але цей оператор повертає порожній результат, оскільки транзакція #12 не може бачити непідтверджені дані з інших транзакцій.

Транзакція #11 фіксується в момент t8, а оператор SELECT у межах транзакції #12 виконується в момент t9. Він повертає результат, що дорівнює 100, оскільки дані, створені в межах транзакції #11, тепер підтверджені (і оскільки рівень ізоляції транзакції #12 - read committed, але про це поговоримо пізніше).

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

Довговічність (Durability)

Концепція довговічності дозволяє розробнику повністю покладатися на те, що дані, створені в межах підтвердженої транзакції, негайно з’являться в базі даних і не зникнуть з неї (без явних операторів, які їх видаляють або змінюють, звісно), незалежно від того, що станеться далі.

Як бачите, вимога довговічності - це просто здоровий глузд: навряд чи хтось погодиться використовувати систему, дані з якої можуть раптово зникнути.

ACID: підсумок

ACID означає вимоги до того, як повинні працювати транзакції:

  • Атомарність
    • Оператори завжди атомарні
    • Групи операторів можна зробити атомарними за допомогою транзакцій
  • Узгодженість
    • Два рівні узгодженості: обмеження бази даних та бізнес-логіка
  • Ізоляція
    • Забезпечується механізмом транзакцій за допомогою встановлених для них рівнів ізоляції
  • Довговічність
    • Усі підтверджені дані стають постійними

Як бачите, усе досить логічно. На практиці основну складність становлять рівні ізоляції, тож розглянемо детально, як вони працюють.

Рівень ізоляції транзакції визначає, які підтверджені дані може бачити ця транзакція.

Існують рівні ізоляції, які умовно називають стандартними. Вони описані в стандарті ANSI SQL (різні редакції). Наскільки мені відомо, немає жодної СКБД, де вони реалізовані точно так, як описані в стандарті, але нікого це не турбує, оскільки фактичні механізми транзакцій у конкретних СКБД мають усі необхідні опції для реалізації бізнес-логіки.

Класичне визначення рівнів ізоляції можна знайти в “A Critique of ANSI SQL Isolation Levels

Для тих, хто читав цю статтю, ось таблиця порівняння класичних рівнів ізоляції з аналогічними у Firebird. Звісно, відповідність не є прямою, оскільки рівні ізоляції у Firebird, як і в інших СКБД, не відповідають на 100% визначенням ANSI SQL, але вони дуже схожі на них.

Рівні ізоляції ANSI Рівень ізоляції у Firebird
Read Uncommitted n/a
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” присутнє в кожному винятку, пов’язаному з конфліктами оновлення, багато розробників застосунків впевнені, що це і є справжнє взаємоблокування (деякі навіть думають, що якийсь Дед відіграв роль у цій помилці).

Водночас, якщо подивитися на файл конфігурації 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]