Логотип Workflow

Article

Updated at:

Transactions Acid

Stage 8. Транзакции и ACID на понятных примерах

Транзакция — это группа SQL-операций, которые вместе образуют одну бизнес-операцию. База должна применить всю группу целиком или отклонить всю группу целиком. Это защищает систему от “половинчатых” изменений: деньги списались, но не зачислились; заказ создался без товаров; остаток уменьшился, но событие в журнал не записалось.

Database transaction flow

В SQL транзакция обычно начинается с BEGIN, успешно завершается через COMMIT и отменяется через ROLLBACK. COMMIT означает “зафиксировать изменения”. ROLLBACK означает “вернуть базу в состояние до начала этой транзакции”.

Классический пример — перевод денег:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
INSERT INTO ledger(from_id, to_id, amount) VALUES (1, 2, 100);
COMMIT;

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

Что означает ACID

ACID — это набор свойств, которые описывают надежное поведение транзакций.

ACID transaction properties

БукваЗначениеЧто это дает в жизни
A (Atomicity)АтомарностьНет частичных изменений
C (Consistency)СогласованностьПосле commit данные валидны
I (Isolation)ИзоляцияПараллельные операции не ломают друг друга
D (Durability)ДолговечностьПосле commit данные не теряются при сбое

Атомарность означает “всё или ничего”. Если в транзакции десять SQL-команд и седьмая команда завершилась ошибкой, база должна откатить предыдущие шесть команд этой же транзакции. Именно поэтому транзакции нужны для денежных переводов, создания заказов и любых операций, где промежуточное состояние опасно.

Согласованность означает, что успешная транзакция переводит базу из одного валидного состояния в другое валидное состояние. Часть правил может проверять сама база: NOT NULL, CHECK, UNIQUE, foreign key. Но согласованность — это еще и ответственность приложения. База проверит, что phone.client_id ссылается на существующего клиента, если вы задали внешний ключ. Но она не узнает все бизнес-правила сама, если схема и код транзакции их не выражают.

Изоляция означает, что параллельные транзакции не должны портить результат друг друга. Реальная система обрабатывает много запросов одновременно. Без изоляции два пользователя могут одновременно изменить один баланс или один остаток товара и случайно перезаписать изменения друг друга. Изоляция реализуется через механизмы вроде блокировок и версий строк. Уровни изоляции будут отдельной темой дальше; сейчас важно понять цель: параллельная работа все равно должна давать корректные данные.

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

Сценарий создания заказа

Создание заказа обычно должно быть одной транзакцией:

  1. Создать запись заказа.
  2. Создать позиции заказа.
  3. Уменьшить остаток товара.
  4. Записать событие в журнал или ledger.

Если шаг с остатком не прошел, все предыдущие шаги должны откатиться. Иначе система может показать заказ, который невозможно выполнить.

BEGIN;

UPDATE products
SET stock = stock - 1
WHERE id = 10 AND stock > 0;

-- если затронуто 0 строк, делаем ROLLBACK

INSERT INTO orders(user_id, status) VALUES (42, 'NEW');

COMMIT;

Ожидаемое поведение:

  • если stock > 0, транзакция может завершиться через commit;
  • если товара нет, транзакция должна откатиться;
  • после rollback не должно остаться “наполовину созданного” заказа.

Что ломается без изоляции

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

Типичные проблемы изоляции:

  • потерянное обновление: две транзакции читают одно значение, обе записывают новое, и одно изменение перезаписывает другое;
  • грязное чтение: транзакция читает данные, которые другая транзакция еще не закоммитила;
  • неповторяемое чтение: одна и та же строка читается дважды в одной транзакции, но между чтениями другая транзакция ее изменила;
  • фантомное чтение: один и тот же запрос возвращает другой набор строк, потому что другая транзакция вставила или удалила подходящие строки.

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

Частые ошибки:

  1. Делать слишком длинные транзакции.
  2. Вызывать внешние API внутри транзакции БД.
  3. Открывать транзакцию до валидации входных данных.
  4. Не тестировать rollback-сценарии.
  5. Считать, что локальный однопользовательский тест доказывает корректность под параллельной нагрузкой.

Хорошая практика:

  • валидировать вход до начала транзакции;
  • держать транзакцию короткой;
  • делать внутри только операции БД;
  • проверять количество затронутых строк для условных UPDATE;
  • логировать причину rollback;
  • мониторить rollback rate и deadlocks в production.

Транзакции защищают бизнес-инварианты при сбоях и нагрузке. Думайте о транзакции как о безопасной границе для одной бизнес-операции.

Практика перед следующим уроком

Попробуйте руками повторить примеры из статьи на маленьком наборе данных из 5-10 строк. Это важный шаг: когда вы сами запускаете SQL и видите конкретный результат таблицей, материал перестает быть абстрактным. Сначала выполните запрос без оптимизаций, затем внесите одно изменение и посмотрите, как меняется результат или план выполнения. Если что-то не сходится, зафиксируйте вопрос и проверьте: корректны ли фильтры, правильно ли выбраны поля, не потерялись ли строки из-за условий соединения, не появился ли NULL там, где вы его не ожидали.

Мини-проверка понимания:

  1. Могу объяснить тему урока одним простым предложением.
  2. Могу написать базовый SQL-пример без подсказки.
  3. Могу прочитать результат запроса и объяснить каждую колонку.
  4. Могу назвать минимум одну частую ошибку и как ее избежать.

Дополнительный сценарий: резерв товара

Для интернет-магазина критично не продать больше, чем есть на складе. Это удобно решается транзакцией и условием в UPDATE.

BEGIN;
UPDATE products
SET stock = stock - 1
WHERE id = 10 AND stock > 0;
-- если затронуто 0 строк -> ROLLBACK
COMMIT;

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