Пошук уроків, статей та іншого контенту
Вивчите властивості ACID, межі транзакцій, рівні ізоляції та способи запобігання конфліктам під час змін даних.
Транзакція — це логічно цілісна послідовність операцій над даними, яка завершується одним із двох результатів:
усі зміни збережено (COMMIT);
усі зміни скасовано (ROLLBACK).
Наприклад, переказ коштів між рахунками складається щонайменше з двох змін:
зменшити баланс рахунку відправника;
збільшити баланс рахунку отримувача.
Ці операції не можна виконувати незалежно. Якщо перша операція завершилася успішно, а друга — ні, гроші «зникнуть» із системи. Транзакція гарантує, що обидві зміни або відбудуться разом, або не відбудеться жодна.
BEGIN;
UPDATE accounts
SET balance = balance - 250
WHERE id = 1;
UPDATE accounts
SET balance = balance + 250
WHERE id = 2;
COMMIT;Якщо під час виконання виникла помилка, застосунок має скасувати транзакцію:
ROLLBACK;ACID — це чотири властивості, які описують поведінку надійних транзакцій.
Транзакція є неподільною з погляду результату.
Якщо одна з операцій не виконалася, база даних скасовує всі зміни цієї транзакції.
BEGIN;
UPDATE accounts
SET balance = balance - 250
WHERE id = 1;
-- Припустімо, тут виникла помилка
UPDATE accounts
SET balance = balance + 250
WHERE id = 999999;
ROLLBACK;Після ROLLBACK зменшення балансу першого рахунку також не залишиться.
Атомарність діє лише в межах конкретної транзакції та конкретної системи, яка її підтримує. Якщо після коміту бази даних потрібно надіслати електронний лист або викликати зовнішній HTTP-сервіс, ці дії не стають атомарними автоматично.
Після завершення транзакції база даних повинна перейти з одного коректного стану в інший.
Узгодженість забезпечується:
обмеженнями бази даних;
зовнішніми ключами;
унікальністю;
перевірками CHECK;
правильною логікою застосунку;
ізоляцією транзакцій.
Наприклад, можна заборонити від’ємний баланс:
CREATE TABLE accounts (
id integer PRIMARY KEY,
balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);Це обмеження захистить дані навіть від помилки в коді застосунку.
Одночасні транзакції не повинні неконтрольовано впливати одна на одну.
Коли кілька користувачів одночасно змінюють ті самі рядки, база даних використовує блокування, версії рядків або інші механізми, щоб визначити допустимий результат.
Рівень ізоляції визначає, які зміни інших транзакцій може бачити поточна транзакція.
Після успішного COMMIT зміни не повинні зникнути через перезапуск сервера або збій процесу бази даних.
Бази даних зазвичай забезпечують це за допомогою журналу попереднього запису, синхронізації даних із диском та відновлення після збою. Конкретні гарантії залежать від налаштувань і самої СУБД.
Межа транзакції визначає, які саме операції виконуються як одна неподільна дія.
Зазвичай транзакція починається перед першою зміною даних і завершується після того, як усі пов’язані зміни успішно виконано.
Отримати запит
↓
Почати транзакцію
↓
Перевірити та змінити пов’язані записи
↓
Зафіксувати транзакцію
↓
Повернути відповідьДо однієї транзакції варто включати операції, які підтримують спільну бізнес-інваріанту.
Наприклад, для переказу коштів межа повинна охоплювати:
блокування або читання рахунків;
перевірку достатності коштів;
списання коштів;
зарахування коштів;
створення запису про переказ.
Не варто тримати транзакцію відкритою під час:
повільного HTTP-запиту до іншого сервісу;
завантаження файлу;
очікування дій користувача;
тривалої обробки, яка не змінює дані.
Довгі транзакції утримують блокування, збільшують кількість конфліктів і можуть заважати іншим запитам.
Транзакція бази даних не поширюється автоматично на всі дії застосунку. Наприклад:
застосунок записав замовлення в базу;
застосунок викликав платіжний сервіс;
платіжний сервіс повернув помилку.
База даних може скасувати запис замовлення, але не може скасувати вже виконану зовнішнім сервісом дію без окремого механізму компенсації.
Тому транзакцію потрібно проєктувати навколо локальних змін, які база даних може зафіксувати або скасувати разом.
SQL-стандарт описує чотири основні рівні ізоляції:
READ UNCOMMITTED;
READ COMMITTED;
REPEATABLE READ;
SERIALIZABLE.
Підтримка та точна поведінка можуть відрізнятися між СУБД.
Транзакція може побачити зміни, які інша транзакція ще не зафіксувала.
Це створює ризик брудного читання: прочитані дані можуть бути скасовані іншою транзакцією.
Такий рівень рідко використовують для критичних даних. У деяких СУБД він фактично поводиться як READ COMMITTED.
Транзакція бачить лише зафіксовані дані.
Однак два однакові запити в межах однієї транзакції можуть повернути різні результати, якщо між ними інша транзакція зафіксувала зміни.
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1;
-- Інша транзакція може змінити та зафіксувати баланс
SELECT balance
FROM accounts
WHERE id = 1;
COMMIT;Це типовий рівень ізоляції в багатьох СУБД. Він забезпечує хорошу продуктивність, але не гарантує повторюваність читання.
Транзакція працює зі стабільним узгодженим знімком даних.
Повторне читання того самого рядка в межах транзакції зазвичай повертає той самий результат. Проте за конфліктних змін транзакція може завершитися помилкою, яку застосунок повинен обробити та, можливо, повторити операцію.
Найвищий рівень ізоляції.
Результат одночасного виконання транзакцій має бути еквівалентним деякому послідовному виконанню. Це найсильніша гарантія, але вона може зменшити пропускну здатність і спричинити помилки serialization failure.
Застосунок повинен бути готовим повторити транзакцію після тимчасової конфліктної помилки.
Транзакція читає незакомітовані зміни іншої транзакції, які потім можуть бути скасовані.
Один і той самий рядок повертає різні значення під час двох читань у межах однієї транзакції.
Повторний запит за умовою повертає додаткові або зниклі рядки, тому що інша транзакція вставила, видалила або змінила відповідні записи.
Дві транзакції читають одне значення, незалежно обчислюють новий результат і записують його. Запис другої транзакції затирає результат першої.
Наприклад, обидві транзакції прочитали баланс 100:
перша обчислила 100 - 30 = 70;
друга обчислила 100 - 50 = 50;
останній запис залишив баланс 50.
Віднімання 30 фактично втрачено.
Якщо нове значення можна обчислити без попереднього читання в застосунку, використовуйте вираз у UPDATE.
UPDATE accounts
SET balance = balance - 250
WHERE id = 1
AND balance >= 250;Після виконання потрібно перевірити кількість змінених рядків:
1 — операція виконана;
0 — рахунок не знайдено або коштів недостатньо.
Таке оновлення є кращим за послідовність «прочитати баланс у застосунок — обчислити — записати», оскільки перевірка та зміна виконуються як одна операція.
SELECT ... FOR UPDATE блокує вибрані рядки для зміни іншими транзакціями до завершення поточної транзакції.
Повний приклад переказу:
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id integer PRIMARY KEY,
balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts (id, balance)
VALUES
(1, 1000.00),
(2, 500.00);
BEGIN;
-- Блокуємо обидва рахунки до завершення транзакції
SELECT id, balance
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
-- Зменшуємо баланс відправника лише за достатньої суми
UPDATE accounts
SET balance = balance - 250.00
WHERE id = 1
AND balance >= 250.00;
-- У реальному застосунку потрібно перевірити кількість змінених рядків.
-- Якщо змінено 0 рядків, транзакцію слід скасувати.
UPDATE accounts
SET balance = balance + 250.00
WHERE id = 2;
COMMIT;
SELECT id, balance
FROM accounts
ORDER BY id;Блокування рядків має бути короткочасним. Якщо дві транзакції блокують одні й ті самі рядки в різному порядку, може виникнути взаємне блокування.
Тому для однакових типів операцій корисно блокувати записи в узгодженому порядку, наприклад за зростанням id.
Оптимістичний підхід не блокує рядок під час читання. Натомість під час оновлення перевіряється версія запису.
UPDATE documents
SET content = 'Оновлений текст',
version = version + 1
WHERE id = 10
AND version = 3;Якщо оновлено один рядок, зміна успішна. Якщо оновлено нуль рядків, інший процес уже змінив документ, і застосунок повинен перечитати дані або повідомити про конфлікт.
Для цього в таблиці потрібне поле версії:
CREATE TABLE documents (
id integer PRIMARY KEY,
content text NOT NULL,
version integer NOT NULL DEFAULT 1
);Оптимістичне блокування добре підходить, коли конфлікти трапляються рідко, а блокування на час читання було б зайвим.
Деякі конфлікти краще передати під контроль бази даних.
CREATE TABLE registrations (
id integer PRIMARY KEY,
email text NOT NULL UNIQUE
);Дві паралельні транзакції можуть одночасно перевірити, що адреси ще немає. Але тільки одна з них зможе вставити та зафіксувати однакове значення завдяки UNIQUE.
Застосунок має коректно обробити помилку порушення унікальності.
Після помилки всередині транзакції не слід продовжувати звичайні SQL-операції, доки транзакцію не скасовано.
Загальний алгоритм:
почати транзакцію;
виконати всі пов’язані операції;
перевірити їхні результати;
виконати COMMIT;
у разі помилки виконати ROLLBACK;
за потреби повторити транзакцію.
Для часткового скасування в межах більшої транзакції можна використовувати точки збереження:
BEGIN;
INSERT INTO orders (id, status)
VALUES (1, 'new');
SAVEPOINT before_optional_change;
-- Якщо необов'язкова операція не вдалася:
ROLLBACK TO SAVEPOINT before_optional_change;
COMMIT;Точка збереження не є окремою незалежною транзакцією. Остаточний результат все одно визначає зовнішній COMMIT або ROLLBACK.
Небезпечно спочатку перевірити баланс, а через деякий час виконати списання в іншому запиті. За цей час інша транзакція може змінити баланс.
Перевірку та зміну потрібно виконувати в одній транзакції або використовувати атомарний UPDATE.
Чим довше триває транзакція, тим довше утримуються блокування та версії даних. Це збільшує затримки й імовірність конфліктів.
UPDATEЗапит може не змінити жодного рядка через недостатній баланс, відсутній запис або застарілу версію. Кількість змінених рядків потрібно перевіряти.
SERIALIZABLE не є автоматично найкращим вибором для кожної операції. Він може спричиняти більше конфліктів і повторних виконань. Рівень ізоляції слід вибирати відповідно до вимог конкретної бізнес-операції.
Якщо одна частина коду спочатку блокує рахунок 1, а потім 2, а інша — спочатку 2, а потім 1, може виникнути deadlock. Використовуйте єдиний порядок блокування та обробляйте помилки взаємного блокування.
Транзакція об’єднує пов’язані зміни в одну логічну операцію.
ACID означає атомарність, узгодженість, ізоляцію та довговічність.
Межа транзакції повинна охоплювати всі локальні зміни, необхідні для підтримки бізнес-інваріанти.
READ COMMITTED часто є типовим рівнем, але він не гарантує повторюваного читання.
REPEATABLE READ і SERIALIZABLE надають сильніші гарантії, але можуть збільшити кількість конфліктів.
Для захисту від конфліктів використовують атомарні оновлення, SELECT ... FOR UPDATE, оптимістичне блокування та обмеження бази даних.
Результат кожної критичної операції потрібно перевіряти, а конфліктні транзакції — уміти повторювати або коректно відхиляти.