1. Проблема одночасності

Для початку трохи віддаленої теорії.

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

І практично в кожному додатку так чи інакше задіяні бази даних (БД), які зазвичай виконують дві функції: зберігати під час отримання від вас дані та пізніше віддавати їх вам на запит. Мало хто має намір створити свою БД, тому що існує вже багато готових рішень. Але як обрати саме ту, яка підійде вашому застосунку? Отже, давай уявімо, що ти написав програму, з мобільним інтерфейсом, який дозволяє завантажувати збережений раніше список хатніх справ — тобто, читати з БД, та доповнювати його новими завданнями, а також розставляти пріоритети для кожного конкретного завдання — від 1 (найвищий) до 3 (найнижчий). Припустимо, твій мобільний застосунок у кожний момент часу використовує лише одна людина. Але ось ти наважуєшся розповісти про своє творіння мамі, і тепер вона стає другим постійним користувачем. Що станеться, якщо ти вирішиш одночасно, прямо в ту ж мілісекунду, поставити якомусь завданню — "помити вікна" — різний ступінь пріоритету?

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

Коли якийсь процес робить запит до БД, він застає її в певному стані. Система, що має стан ("stateful") — це така система, яка пам'ятає попередні події і зберігає інформацію, яка і називається "станом". Змінна, оголошена як integer, може мати стан 0, 1, 2 або, скажімо, 42. Mutex (взаємний виняток) має два стани: locked або unlocked, як і двійковий семафор ("required" vs. "released") і взагалі двійкові (бінарні) типи даних і змінні, які можуть мати лише два стани — 1 чи 0.

На основі поняття стану базуються кілька математичних та інженерних конструкцій, таких як кінцевий автомат — модель, яка має по одному входу та виходу і в кожний момент часу знаходиться в одному з кінцевої множини станів — і шаблон проєктування «стан», при якому об'єкт змінює поведінку залежно від внутрішнього стану (наприклад, залежно від того, яке значення присвоєно тій чи іншій змінній).

Отже, більшість об'єктів у світі машин має певний стан, який з часом може змінюватися: наша pipeline, що обробляє великий пакет даних, видає помилку і стає failed, або властивість об'єкта «Гаманець», що зберігає суму грошей, що залишилися на рахунку користувача, змінюється після надходження на рахунок зарплати.

Перехід («transition») від одного стану до іншого — скажімо, від in progress до failed — називається операцією. Напевно, всім відомі операції CRUDcreate, read, update, delete, або аналогічні їм методи HTTPPOST, GET, PUT, DELETE. Але програмісти у своєму коді часто дають операціям інші імена, тому що операція може бути складнішою, ніж просто прочитати якесь значення з бази даних — вона може заодно перевірити дані, і тоді наша операція, яка набула вигляду функції, буде називатися, наприклад, validate() А хто виконує ці операції-функції? Вже описані процеси.

Ще трохи, і ти зрозумієш, чому я так докладно описую терміни!

Будь-яка операція — функція, або, в розподілених системах, посилання запиту до іншого сервера — має 2 властивості: час виклику (invocation time) та час завершення (completion time), який буде строго більше часу виклику (дослідники з Jepsen виходять з теоретичного припущення, що обидва ці timestamp будуть дані уявним, повністю синхронізованим, глобально доступним годинником).

Давай уявімо наш застосунок зі списком справ. Ти через мобільний інтерфейс робиш запит до БД о 14:00:00.014, а твоя мама о 13:59:59.678 (тобто, за 336 мілісекунд до цього) через А інтерфейс оновила перелік справ, додавши до нього миття посуду. Враховуючи затримку мережі та можливу чергу завдань для вашої БД, якщо окрім вас з мамою додатком користуються ще всі мамині подруги, БД може виконати запит мами вже після того, як обробить твій. Іншими словами, є ймовірність того, що два ваші запити, а також запити маминих подруг будуть спрямовані на одні й ті самі дані одночасно (concurrently).

Так ми й підішли до найважливішого терміну в сфері БД та розподілених застосунків — concurrency. Що може означати одночасність двох операцій? Якщо дані певна операція T1 і якась операція T2, то:

  • Т1 може бути розпочата до часу початку виконання Т2, а закінчена між часом початку і кінця виконання Т2
  • Т2 може бути розпочата до часу початку виконання Т1, а закінчена між часом початку і кінця виконання Т1
  • Т1 може бути розпочата і закінчена між часом початку і кінця виконання Т1
  • і будь-який інший сценарій, за умови якого T1 і T2 мають певний загальний час виконання

Зрозуміло, що в межах цієї лекції ми говоримо в першу чергу про запити, що надходять до БД, і те, як система управління БД ці запити сприймає. Проте термін конкурентності важливий, зокрема, й у контексті операційних систем. Я не надто сильно відходитиму в бік від теми цієї статті, але важливо зазначити, що конкурентність, про яку ми тут говоримо, не пов'язана з дилемою про конкурентність і паралелізм та їх різницю, яку обговорюють у контексті роботи операційних систем та high-performance computing. Паралелізм — це один із способів досягнення конкурентності в середовищі з кількома ядрами, процесорами або комп'ютерами. Ми ж говоримо про конкурентність у значенні одночасного доступу різних процесів до загальних даних.

А що, власне, може піти не так, чисто теоретично?

Під час роботи над спільними даними можуть статися численні проблеми, які пов'язані з конкурентністю, також відомі як "race conditions". Перша проблема виникає тоді, коли процес отримує дані, які він не повинен був отримати: неповні, тимчасові, скасовані або з якоїсь іншої причини «неправильні» дані. Друга проблема — коли процес отримує неактуальні дані, тобто дані, які не відповідають останньому збереженому стану БД. Скажімо, якийсь застосунок зняв гроші з рахунку користувача з нульовим балансом, тому що БД повернуло застосунку стан рахунку, що не враховує останнє зняття грошей з нього, що відбулося буквально кілька мілісекунд назад. Ситуація не дуже хороша, чи не так?

2. Транзакції прийшли, щоб врятувати нас

Щоб вирішувати такі проблеми, і з'явилося поняття транзакції — якоїсь групи послідовних операцій (змін стану) з БД, яка є логічно єдиною операцією. Знову наведу приклад із банком — і не випадково, адже концепція транзакції з'явилася, зважаючи на все, саме в контексті роботи з грошима. Класичний приклад транзакції — переказ грошей з одного банківського рахунку на інший: тобі необхідно спочатку зняти суму з вихідного рахунку, а потім внести її на цільовий рахунок.

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

Вирішити цю проблему була покликана така властивість транзакції, як «ізольованість»: наша транзакція виконується так, ніби інших транзакцій, які виконуються в той самий час, не існує. Наша БД виконує одночасні операції так, ніби вона виконує їх одна за одною, sequentially — власне, найвищий рівень ізоляції і називається Strict Serializable. Так, найвищий, що означає, що рівнів буває кілька.

Стоп, скажеш ти. Притримай коней, пане.

Давай згадаємо, як я описував, що кожна операція має час виклику та час виконання. Для зручності можна розглядати виклик та виконання як дві дії. Тоді відсортований список усіх дій виклику та виконання можна назвати історією БД. Тоді рівень ізоляції транзакцій — це набір історій. Ми використовуємо рівні ізоляції, щоб визначити, які історії є «хорошими». Коли ми говоримо, що історія «порушує серіалізованість» або «не серіалізується», ми маємо на увазі, що історія не входить до набору історій, що серіалізуються.

Щоб було зрозуміло, про які роди історії ми говоримо, наведу приклади. Наприклад, є такий вид історії — intermediate read. Він відбувається, коли транзакції А можна читати дані з рядка, яка була змінена іншою запущеною транзакцією Б і ще не зафіксована ("not committed") — тобто, фактично, зміни ще не були остаточно здійснені транзакцією Б, і вона може в будь-який момент їх скасувати. А, наприклад, aborted read — це саме наш приклад із скасованою транзакцією зняття грошей

Таких можливих аномалій кілька. Тобто аномалії — це якийсь небажаний стан даних, який може виникнути за умови конкурентного доступу до БД. І щоб уникнути тих чи інших небажаних станів, БД використовують різні рівні ізоляції — тобто різні рівні захисту даних від небажаних станів. Ці рівні (4 штуки) було перераховано у стандарті ANSI SQL-92.

Опис цих рівнів деяким дослідникам видається розпливчастим, і вони пропонують свої, детальніші, класифікації. Раджу звернути увагу на вже згаданий Jepsen, а також проєкт Hermitage, який покликаний внести ясність у те, які саме рівні ізоляції пропонують конкретні СУБД, такі як MySQL або PostgreSQL. Якщо ви відкриєш файли з цього репозиторію, можеш побачити, яку низку SQL-команд вони застосовують, щоб тестувати БД на ті чи інші аномалії, і можеш зробити щось подібне для БД, що вас цікавлять). Наведу один приклад з репозиторію, щоб зацікавити тебе:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

Важливо розуміти, що в одній і тій самій БД, як правило, можна обрати один із кількох видів ізоляції. Чому ж не обрати найсильнішу ізоляцію? Тому що, як і все в інформатиці, обраний рівень ізоляції повинен бути відповідним до компромісу, на який ми готові йти — в цьому випадку компроміс щодо швидкості виконання: чим сильніший рівень ізоляції, тим повільніше оброблятимуться запити. Щоб зрозуміти, який рівень ізоляції тобі потрібен, тобі потрібно зрозуміти вимоги до свого застосунку, а щоб зрозуміти, чи пропонує обрана БД цей рівень, доведеться лізти в документацію — для більшості програм цього буде достатньо, але якщо у тебе якісь особливо жорсткі вимоги, то краще влаштувати тест на кшталт того, що роблять хлопці з проекту Hermitage.

3ю "I" та інші літери в ACID

Ізоляція — це, в основному, те, що і мають на увазі люди, коли говорять про ACID в цілому. І саме з цієї причини я почав розбирати цю абревіатуру з ізоляції, а не пішов по порядку, як зазвичай роблять ті, хто намагається пояснити цю концепцію. А тепер давайте розглянемо і три літери, що залишилися.

Згадаймо знову наш приклад із банківським переказом. Транзакція з переказу коштів з одного рахунку на інший включає операцію виведення з першого рахунку і операцію поповнення на другому. Якщо операція поповнення другого рахунку не вдалася, ти, напевно, не хочеш, щоб операція виведення коштів з першого відбулася. Іншими словами, або транзакція вдається повністю, або не відбувається взагалі, але вона не може бути зроблена лише на якусь частину. Ця властивість називається атомарністю ("atomicity"), і це "A" в ACID.

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

Ця гарантія отримала назву «узгодженість» (consistency) та букву C в ACID (не плутати з узгодженістю зі світу розподілених додатків, про яку ми поговоримо пізніше). Наведу зрозумілий приклад для consistency у сенсі ACID: застосунок для онлайн-магазину хоче додати до таблиці orders рядок, і в стовпці product_id буде вказано ID з таблиці products — типовий foreign key.

Якщо продукт, скажімо, був видалений з асортименту, і відповідно з БД, то операція вставки рядка не має відбутися, і ми отримаємо помилку. Ця гарантія, порівняно з іншими, трохи притягнута за вуха: хоча б тому, що активне використання constraints від БД означає перекладення відповідальності за дані (а також часткове перекладання бізнес-логіки, якщо ми говоримо про таке constraint, як CHECK) з додатку на БД, а це так собі.

Ну і нарешті залишається D — «стійкість» (durability). Системний збій або будь-який інший збій не повинен призводити до втрати результатів транзакції або вмісту БД. Тобто, якщо БД відповіла, що транзакція пройшла успішно, це означає, що дані були зафіксовані в енергонезалежній пам'яті — наприклад, на жорсткому диску. Це, до речі, означає, що ти негайно побачиш дані під час наступного read-запиту.

Ось буквально днями я працював з DynamoDB від AWS (Amazon Web Services), і надіслав деякі дані на збереження, а отримавши відповідь HTTP 200 (OK), чи щось таке, вирішив перевірити — і не бачив ці дані в базі протягом наступних 10 секунд. Тобто DynamoDB зафіксувала мої дані, але не всі вузли миттєво синхронізувалися, щоб отримати останню копію даних (хоча можливо, справа була і в кеші). Тут ми знову залізли на територію узгодженості в контексті розподілених систем, але момент поговорити про неї, як і раніше, не настав.

Отже, тепер ми знаємо, що являють собою гарантії ACID. І ми навіть знаємо, чому вони корисні. Але чи справді вони нам потрібні у кожній програмі? І якщо ні, то коли саме? Чи всі БД пропонують ці гарантії, а якщо ні, то що вони пропонують натомість?


Транзакції, ACID, CAP