Введение
Транзакция — группа операций модификации данных, выполняемых как единая, неделимая единица — все её части либо успешно применяются вместе, либо ни одна из них не применяется вовсе. В статье разберём свойства ACID, гарантирующие надёжность транзакций, и уровни изоляции, регулирующие, как параллельно выполняющиеся транзакции видят (или не видят) изменения друг друга.
Концепция
ACID — Atomicity (атомарность, «всё или ничего»), Consistency (согласованность, транзакция переводит БД из одного корректного состояния в другое корректное), Isolation (изолированность, параллельные транзакции не мешают друг другу непредсказуемым образом), Durability (долговечность, после успешного COMMIT изменения гарантированно сохранены даже при последующем сбое системы). Уровни изоляции (от менее строгого к более строгому: Read Uncommitted, Read Committed, Repeatable Read, Serializable) определяют конкретный компромисс между производительностью параллельного выполнения и строгостью защиты от определённых аномалий (чтение незафиксированных данных, неповторяемое чтение, фантомные строки).
Пример кода: Транзакции
-- Базовый паттерн транзакции — перевод денег между счетами, упомянутый в статье 395
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
-- Проверка, что после обеих операций ничего не пошло не так (например, баланс не стал отрицательным)
-- если проверка не пройдена — откатываем ВСЮ транзакцию целиком
-- (в реальном коде проверка выполняется кодом приложения между этими SQL-командами)
COMMIT; -- обе операции применяются одновременно, атомарно
-- ROLLBACK; -- альтернатива при обнаруженной проблеме — отменяет ОБЕ операции, как если бы их не было
-- Явное указание уровня изоляции для конкретной транзакции (синтаксис PostgreSQL)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1; -- предположим, видим 5000
-- ... другая, параллельная транзакция в это же время изменяет и коммитит баланс этого счёта ...
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
COMMIT; -- при SERIALIZABLE: если параллельное изменение создало конфликт, COMMIT завершится ОШИБКОЙ,
-- сигнализируя, что транзакцию нужно повторить с самого начала
-- SAVEPOINT — частичный откат внутри более крупной транзакции
BEGIN;
INSERT INTO orders (customer_id, total_amount) VALUES (1, 500);
SAVEPOINT before_items;
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 999, 1); -- предположим, ошибка (продукт не существует)
ROLLBACK TO before_items; -- откатывает только вставку order_items, заказ из orders остаётся
-- продолжаем с исправленными данными
INSERT INTO order_items (order_id, product_id, quantity) VALUES (1, 5, 1);
COMMIT;
Пояснения к коду
BEGIN/COMMIT/ROLLBACK обозначают границы транзакции — без явного BEGIN каждая отдельная SQL-команда обычно выполняется в своей собственной, автоматической транзакции (autocommit-режим), и для группировки нескольких команд в единую, атомарную единицу нужно явно начать транзакцию через BEGIN. Уровень изоляции SERIALIZABLE — самый строгий, гарантирующий, что результат выполнения нескольких параллельных транзакций будет таким же, как если бы они выполнялись строго последовательно одна за другой (без фактического параллелизма), но за это достигается ценой более вероятных конфликтов, требующих повторения транзакции при обнаружении такого конфликта на COMMIT. SAVEPOINT показывает более тонкий контроль внутри одной большой транзакции — частичный откат лишь части выполненных операций (после точки сохранения) без отмены всей транзакции целиком, что полезно для обработки ожидаемых, частных ошибок внутри более крупной, в остальном успешной последовательности операций.
Подводные камни
- Забытый
COMMITпосле серии модификаций, оставляющий транзакцию «висящей» открытой неопределённо долго (особенно при интерактивной работе через консоль СУБД) — незакоммиченная транзакция может удерживать блокировки на затронутых строках/таблицах (статья 409), блокируя другие, ожидающие транзакции, и в зависимости от настроек таймаута соединения может остаться открытой значительно дольше, чем предполагалось, что особенно проблематично для production-систем с высокой конкурентностью доступа. - Использование слишком слабого уровня изоляции (Read Uncommitted, где это поддерживается, или просто стандартный Read Committed) для операций, реально чувствительных к определённым аномалиям параллельного выполнения (например, расчёт остатка товара на складе, где гонка между двумя параллельными заказами на последнюю единицу товара может привести к продаже одного товара дважды) — для таких критичных по согласованности операций стоит явно рассмотреть более строгий уровень изоляции (Repeatable Read или Serializable) или явное блокирование строк (
SELECT ... FOR UPDATE), несмотря на возможное снижение пропускной способности параллельных операций. - Слепое повторение транзакции при ошибке
SERIALIZABLEбез ограничения количества попыток — посколькуSERIALIZABLEможет намеренно отклонять транзакции при обнаружении конфликта (требуя повторного выполнения с начала), код приложения должен реализовывать логику повторных попыток с разумным ограничением (например, не более 3-5 попыток) и, возможно, экспоненциальной задержкой между ними, а не входить в потенциально бесконечный цикл повторов при систематически высокой конкуренции за одни и те же данные. - Чрезмерно длинные транзакции, охватывающие операции, не требующие реальной атомарности друг с другом (например, включение медленной, не связанной с самой транзакцией внешней операции — вызов внешнего API — внутрь блока транзакции БД) — длинные транзакции удерживают блокировки и ресурсы СУБД дольше необходимого, увеличивая вероятность конфликтов с другими параллельными транзакциями; транзакция должна охватывать минимально необходимый, действительно атомарный набор операций с самой БД, а не произвольно широкий участок логики приложения.