Транзакція — це основна робоча одиниця при взаємодії з базою даних. Катерина Медведська, Data Еngineer в Boosters, на мітапі генезійського бекенд-ком’юніті розповіла про особливості транзакцій в базах даних, гарантії, які надають AСID-транзакції та про те, навіщо потрібні різні рівні ізольованості транзакцій. Катерина працює в Boosters більше року. Раніше вона понад десять років працювала в сфері автоматизації торгових підприємств. Відповідала за автоматизацію торгових і складських процесів, обліку залишків. Працювала з оптимізацією баз даних — як для збільшення пропускної здатності конкурентного доступу, так і для вирішення проблем з блокуваннями. Публікуємо конспект з найважливішими тезами з виступу спікерки.
Проблема паралельного доступу
Уявімо, що в нас є інтернет-магазин, назвемо його «Ruletka». В нього є сайт, через який здійснюються всі продажі. У магазина є також склад, і інформація про наявні товари на цьому складі зберігається в певній реляційній базі даних (БД). Склад додає інформацію про товари в БД, а сайт вичитує її з цієї бази, аби відобразити користувачам.
Уявімо, що магазин отримує партію із тисячі штук відомих всім марок «воєнний корабль». Склад, відповідно, вносить ці дані в базу, і вони через певний час відображаються на сайті. Одразу набігає більше п'яти тисяч охочих купити марку. Що ж при цьому відбувається в базі даних інтернет-магазину?
Для кожного користувача, який додає товар в корзину, сайт повинен запросити дані з БД про наявність товару, одразу заблокувати його під користувача, і лише після цього «сказати» сайту, що все успішно, можна показувати сформоване замовлення клієнту. На кожну одиницю товару у нас по п'ять користувачів, які одночасно намагаються забронювати собі цей товар. На рівні бази даних це виглядає наче вони всі намагаються змінити таблицю вільних залишків в БД складу.
Тут важливо, щоби всі користувачі, які замовили раніше, отримали свій товар першими, при цьому магазин не продав нічого в мінус, а база даних витримала таке навантаження.
В цьому процесі може бути безліч проблем і складних ситуацій. Програмне або апаратне забезпечення БД може відмовити, при чому це може статись в будь-який момент, наприклад, посередині операції запису. Інша ситуація: розриви мережі неочікувано відріжуть ваш додаток від БД. Кілька користувачів зможуть виконати записи операції одночасно, при чому перезаписати один одного, і хтось не отримає свою марку. Ще одна потенційна проблема: клієнт може прочитати дані, які не матимуть сенсу, тому що вони були оновлені лише частково.
Навіщо потрібні транзакції?
Протягом десятиліть транзакції вважались оптимальним механізмом вирішення всіх вищеописаних проблем. Транзакція – це спосіб групування додатком кількох операцій запису та читання в одну логічну одиницю. По суті, всі операції запису і читання в ній виконуються як одна, і вся транзакція або цілком виконується успішно з фіксацією змін, або завершується невдало з перериванням і відкатом. І якщо відбувся збій, додаток може спокійно спробувати виконати операцію ще раз, бо він знає, що ніяких часткових операцій запису не було виконано. Це значно спрощує обробку помилок, оскільки не потрібно пам'ятати, що там щось могло частково записатись або не записатись взагалі.
Гарантії функціональної безпеки, які надаються транзакціями, часто описуються абревіатурою ACID, яка розшифровується як Atomicity, Consistency, Isolation and Durability. Це атомарність, узгодженість, ізоляція та довговічність відповідно.
Атомарність — це неможливість розбиття на менші частини. В контексті ACID атомарність транзакції гарантує, що будуть або виконані всі операції, які беруть участь в транзакції, або не буде виконано жодної. Тобто, якщо операції запису згруповані в атомарну транзакцію і її не вдається завершити через збій, то вона переривається, і в базі даних необхідно відкатити всі вже ці виконані зміни перед тим, як відповісти юзеру, що транзакція була перервана.
Узгодженість. Під цим поняттям мається на увазі, що БД перебуває з погляду додатка в хорошому стані, тобто система має перебувати в узгодженому, несуперечливому стані до початку дії транзакції і по її завершенню. Що саме вважаємо узгоджений станом? До прикладу, при переведенні коштів з рахунку на рахунок, кошти необхідно спочатку зняти з першого рахунку, після чого нараховувати на другий. Відповідно, після зняття коштів, але до їх нарахування система перебуває в неузгодженому стані: коштів немає на жодному з рахунків. Але після завершення транзакції повна сума перебуватиме на другому рахунку, або, якщо сталась якась помилка, на першому, що буде узгодженим станом.
Ізоляція. До більшості баз даних звертається одночасно кілька клієнтів. І в цілому це не викликає проблем, поки вони читають і записують дані в різні частини бази, в різні таблиці, в різні рядки. Але якщо вони звертаються до одних і тих самих записів, то тут можуть виникати проблеми конкурентного доступу, які називаються race condition або стан гонитви. Ізоляція існує саме для уникнення таких проблем. БД повинна гарантувати, що результат фіксації кількох конкурентних транзакцій такий самий, як наче вони виконуються послідовно, одна за одною.
Довговічність. Основна задача СУБД — це надати надійне місце для зберігання даних. Під довговічністю мається на увазі зобов'язання бази не втратити успішно зафіксовані дані транзакції, навіть в разі якогось апаратного збою чи фатального збою самої БД.
Чотири рівні ізоляції
B ідеальному світі ізоляція повинна була б полегшити життя розробників, які б могли зробити вигляд, що жодного конкурентного виконання взагалі не відбувається. На практиці витрати на серіалізовану ізоляцію досить високі, і багато баз даних не згодні платити таку ціну.
Саме тому були створені слабші рівні ізоляції, які захищають лише від частини проблем конкурентного доступу, але при цьому мають значно кращу продуктивність при паралельному виконанні транзакцій. Стандарт SQL-92 визначає чотири рівні ізоляції. Це Read Uncommitted, Read Committed, Repeatable Read і Serializable. Всі ці рівні відрізняються певними мінімально допустимими гарантіями, які повинна надавати СУБД, і описуються в документації через присутність конкретних проблем паралельного доступу. Більш високий рівень ізольованості підвищує точність даних, зменшує кількість проблем, але при цьому знижує кількість паралельних транзакцій. Відповідно, чим нижчий рівень ізольованості, тим більше транзакцій може виконуватись паралельно, але при цьому може знизитись точність даних, якщо ви все не врахували.
Read committed
Базовий і найпоширеніший рівень ізоляції транзакцій — read committed. Він забезпечує дві основні гарантії. Перша — жодних брудних операцій читання (dirty read). Це означає, що при читанні з БД клієнт бачить лише зафіксовані дані. Тобто якісь середні, неузгоджені дані він не може прочитати. І друга гарантія — це жодних брудних операцій запису. Тобто при записі в БД можна перезаписувати лише зафіксовані дані.
Для запобігання «брудним» операціям запису частіше за все бази використовують блокування рядків. Перш ніж модифікувати конкретний об'єкт, транзакція повинна спочатку встановити блокування на цей об'єкт. Дане блокування має утримуватись аж до фіксації або переривання транзакції. Утримувати блокування на конкретний об'єкт може тільки одна транзакція одночасно. Іншим транзакціям, які хочуть виконати операцію запису в цей об'єкт, доведеться дочекатися фіксації або переривання першої транзакції і лише потім отримати блокування і продовжити свою роботу. Подібні блокування виконуються базами автоматично в режимі читання зафіксованих даних (і на сильніших рівнях ізоляції).
Більшість БД запобігають «брудним» операціям читання за допомогою підходу, коли база запам'ятовує для кожного об'єкта, що записується, як старе зафіксоване значення, так і нове, яке встановлюється поточною транзакцією. А в цей час всім іншим транзакціям, що читають об'єкт, просто повертається старе значення. І тільки після фіксації транзакції запису інші транзакції починають одержувати нове значення.
Repeatable read
Коли в нас є якісь аналітичні запити або перевірки цілісності, то зазвичай сканують велику частину БД і сканування виконується певний час. Якщо такі запити будуть бачити, частину старих даних, а потім додавати частину нових даних, то в результаті ми отримуємо неузгоджені дані, які не матимуть жодного сенсу і вважатимуться помилковими. В таких варіантах рівень read committed не підходить, а для запобігання цим проблемам існує рівень ізоляції знімків стану — snapshot isolation, який ще називають repeatable read. Основна ідея полягає в тому, що кожна транзакція читає дані з узгодженого знімка стану бази, тобто це повний зріз даних на певний момент часу. Навіть якщо дані потім були змінені іншими транзакціями в моменті читання, то наша транзакція не буде бачити цих змін.
Механізм ізоляції знімків стану підтримується багатьма СУБД. Як і в read committed, в реалізації snapshot isolation зазвичай використовується блокування запису для запобігання брудним операціям запису. Але при цьому операції читання не вимагають жодних блокувань. Отже, основним принципом ізоляції знімків стану є «читання ніколи не блокує запис, а запис ніколи не блокує читання». Завдяки цьому БД здатна виконувати тривалі запити на читання і в цей же час виконувати операції запису. Цей рівень ізоляції використовується найчастіше.
Для реалізації ізоляції знімків стану бази використовують такий механізм, який називається Multi-Version Concurrency Control (MVCC). В ньому кожен SQL-оператор бачить знімок даних — повну версію БД на певний момент часу, незалежно від поточного стану даних. При читанні застосовується окремий знімок стану для кожного запиту, а для транзакцій — один і той самий знімок для всієї транзакції.
Розглянемо детальніше, як реалізується цей механізм на прикладі PostreSQL.
На початку виконання транзакція отримує свій унікальний, монотонно зростаючий ідентифікатор, який називається txid. Будь-які дані, записані цією транзакцією, позначаються цим номером. В кожному рядку таблиці є поле created by, що містить ідентифікатор транзакції, в якій був доданий в цей рядок. І в кожному рядку також є поле deleted by, яке початково порожнє. Коли транзакція змінює дані, вона насправді залишає старі в своєму рядку, дописуючи в нього свій txid в поле deleted by. А також додає рядок з оновленими даними, зі своїм txid в полі created by. Пізніше, вже коли жодна транзакція точно не звернеться до цих даних, vacuum в PostgreSQL почистить ці видалені рядки.
БД не збирає фізичний знімок бази, щоби надати його кожній транзакції. Читання узгоджених знімків стану реалізується завдяки ідентифікаторам та низці правил видимості. Нижче перерахую основні:
На початку кожної транзакції БД створює список всіх інших транзакцій, які ще не завершились на поточний момент, і всі зміни, які виконуються цими транзакціями, просто ігноруються для поточної транзакції.
Усі операції запису, які виконані перерваними транзакціями, також ігноруються.
Усі операції запису виконані транзакціями з більш пізнім ідентифікатором, також ігноруються.
Результати всіх інших операцій запису видимі для поточної транзакції.