ACID и уровни изоляции — обзор

ACID и уровни изоляции — обзор

В предыдущем уроке вы научились открывать транзакции командами BEGIN, COMMIT, ROLLBACK. Но почему транзакции работают именно так? Ответ — в четырёх принципах, которые с 1980-х годов определяют надёжность реляционных баз данных: ACID. И отдельный вопрос — что происходит, когда сотни транзакций выполняются одновременно?

ACID: четыре гарантии

ACID расшифровывается как Atomicity, Consistency, Isolation, Durability. Это набор свойств, которые СУБД обязуется обеспечивать.

Atomicity (Атомарность): транзакция — неделимая единица. Либо все команды выполняются, либо ни одна. Не бывает «половинной» транзакции.

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
-- Если второй UPDATE упадёт — первый тоже откатится
COMMIT;

Consistency (Согласованность): транзакция переводит базу из одного корректного состояния в другое. Все ограничения (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) выполнены до и после транзакции.

-- Нарушение CHECK прерывает транзакцию, не нарушая согласованности
INSERT INTO products (title, price) VALUES ('Тест', -100);
-- ERROR: check constraint "products_price_check" violated
-- База осталась в согласованном состоянии

Isolation (Изоляция): параллельные транзакции не видят незафиксированные изменения друг друга (в общем случае). Каждая транзакция работает, как будто она единственная.

Durability (Долговечность): после COMMIT данные сохранены даже при сбое питания или крэше. PostgreSQL записывает изменения в WAL (Write-Ahead Log) до подтверждения.

Проблемы параллельного выполнения

При параллельном выполнении транзакций без должной изоляции возникают классические аномалии:

Dirty Read (Грязное чтение): транзакция читает незафиксированные изменения другой транзакции.

T1: UPDATE balance = 100 (не закоммичено)
T2: SELECT balance → видит 100
T1: ROLLBACK
T2: работала с несуществующими данными

Non-Repeatable Read (Неповторяемое чтение): одна транзакция читает строку дважды и получает разные результаты (другая транзакция изменила и закоммитила между чтениями).

T1: SELECT price = 500
T2: UPDATE price = 600; COMMIT
T1: SELECT price = 600  ← другое значение!

Phantom Read (Фантомное чтение): транзакция повторно выполняет запрос и получает другой набор строк (другая транзакция добавила/удалила строки между двумя запросами).

T1: SELECT COUNT(*) = 10
T2: INSERT new_row; COMMIT
T1: SELECT COUNT(*) = 11  ← «фантомная» строка

Уровни изоляции

SQL стандарт определяет 4 уровня изоляции, каждый из которых запрещает определённые аномалии:

УровеньDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDВозможенВозможенВозможен
READ COMMITTEDЗапрещёнВозможенВозможен
REPEATABLE READЗапрещёнЗапрещёнВозможен
SERIALIZABLEЗапрещёнЗапрещёнЗапрещён

Более высокий уровень = больше гарантий, но выше накладные расходы (больше конфликтов, больше ожидания).

Уровень по умолчанию в PostgreSQL — READ COMMITTED: каждый запрос внутри транзакции видит только зафиксированные данные на момент запуска этого запроса. Это разумный компромисс для большинства приложений.

-- Явно задать уровень изоляции для транзакции
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT balance FROM accounts WHERE id = 1;
-- ... другие операции ...
SELECT balance FROM accounts WHERE id = 1;
-- Гарантированно получим то же значение (REPEATABLE READ)

COMMIT;

READ COMMITTED подробнее

Самый распространённый уровень. Каждый SELECT видит свежий снимок зафиксированных данных:

BEGIN;  -- READ COMMITTED по умолчанию

SELECT amount FROM orders WHERE id = 1;  -- видит 500

-- Другая транзакция: UPDATE orders SET amount = 600 WHERE id = 1; COMMIT;

SELECT amount FROM orders WHERE id = 1;  -- теперь видит 600!

Одна и та же строка читается с разными значениями в рамках одной транзакции. Для большинства операций это нормально. Но для финансовых расчётов, где важен консистентный снимок — нужен более высокий уровень.

REPEATABLE READ и SERIALIZABLE в PostgreSQL

PostgreSQL реализует оба через механизм Snapshot Isolation. При старте транзакции создаётся снимок (snapshot) состояния базы — все последующие чтения этой транзакции видят именно этот снимок, независимо от коммитов других транзакций.

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 10
-- Другая транзакция добавляет ORDER и COMMIT-ит

SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- всё ещё 10 (snapshot)

COMMIT;

SERIALIZABLE дополнительно обнаруживает аномалии записи. Если две транзакции читали перекрывающиеся данные и обе записали, PostgreSQL может прервать одну из них с ошибкой ERROR: could not serialize access due to concurrent update.

MVCC: как PostgreSQL обеспечивает изоляцию

PostgreSQL использует MVCC (Multi-Version Concurrency Control — многоверсионное управление параллельным доступом). Вместо того чтобы блокировать строки при чтении, PostgreSQL хранит несколько версий каждой строки:

Строка id=1, balance=500  → версия создана транзакцией T1
Строка id=1, balance=600  → версия создана транзакцией T2

Транзакция T3 видит ту версию, которую создали зафиксированные транзакции
до момента её снимка

Ключевое свойство MVCC: читатели не блокируют писателей, писатели не блокируют читателей. SELECT никогда не ждёт UPDATE и наоборот. Это отличает PostgreSQL от систем с блокировками на уровне чтения.

Lost Update: аномалия записи

Кроме аномалий чтения (dirty read, non-repeatable read, phantom read) существует аномалия записи — Lost Update (потерянное обновление):

T1: читает balance = 500
T2: читает balance = 500
T1: записывает balance = 500 - 100 = 400; COMMIT
T2: записывает balance = 500 + 200 = 700; COMMIT
-- Итог: balance = 700, хотя должен быть 600!
-- Обновление T1 потеряно: T2 перезаписало его

При READ COMMITTED это возможно. При REPEATABLE READ PostgreSQL обнаружит конфликт при попытке второго COMMIT и прервёт транзакцию. При SERIALIZABLE — аналогично.

Альтернативное решение для READ COMMITTED — оптимистическая блокировка через версионность:

-- Читаем строку с версией
SELECT balance, version FROM accounts WHERE id = 1;
-- balance=500, version=3

-- Обновляем только если версия не изменилась
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 3;
-- Если другая транзакция уже изменила строку — version != 3, UPDATE вернёт 0 строк
-- Приложение должно повторить попытку

Оптимистическая блокировка не блокирует строку при чтении — предполагает, что конфликты редки, и обрабатывает их при записи.

Когда использовать какой уровень

READ COMMITTED (по умолчанию): большинство CRUD-операций, веб-приложения, системы с независимыми записями.

REPEATABLE READ: финансовые отчёты, расчёты, которые делают несколько SELECT и ожидают консистентный снимок. Экспорт данных.

SERIALIZABLE: критически важные финансовые транзакции, сложные условия конкурентной записи. Использовать с осторожностью — при конфликтах транзакции прерываются, приложение должно их повторять.

-- Пример: финансовый отчёт с консистентным снимком
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

SELECT SUM(total) FROM orders WHERE created_at::date = CURRENT_DATE;
SELECT COUNT(*) FROM orders WHERE status = 'completed' AND created_at::date = CURRENT_DATE;
-- Оба запроса видят один снимок — результаты согласованы

COMMIT;

Практика: увидеть уровни изоляции в действии

Чтобы почувствовать разницу между уровнями, откройте два psql-терминала и попробуйте:

Терминал 1:

BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- Пауза — переключитесь в терминал 2
SELECT balance FROM accounts WHERE id = 1;  -- выполнить после T2's COMMIT
COMMIT;

Терминал 2:

BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

При READ COMMITTED: второй SELECT в T1 покажет обновлённое значение. При REPEATABLE READ: второй SELECT в T1 покажет исходное значение.

Это наглядно демонстрирует разницу: READ COMMITTED даёт «живые» данные, REPEATABLE READ — «замороженный» снимок.

Проверь себя

Транзакция T1 читает строку, затем транзакция T2 обновляет её и коммитит, затем T1 читает ту же строку снова. При уровне READ COMMITTED — что увидит T1 при втором чтении? При REPEATABLE READ?

Краткий итог

  • Atomicity: всё или ничего
  • Consistency: ограничения соблюдены до и после транзакции
  • Isolation: параллельные транзакции не мешают друг другу (в зависимости от уровня)
  • Durability: зафиксированные данные не теряются при сбое
  • READ COMMITTED (по умолчанию) — запрещает dirty read; самый распространённый
  • REPEATABLE READ — снимок на всю транзакцию; для аналитики и отчётов
  • SERIALIZABLE — полная изоляция; для критичных финансовых операций
  • MVCC: читатели не блокируют писателей

Что дальше

Вы поняли принципы надёжности транзакций. Финальный урок модуля — VIEW: представления, которые позволяют сохранить сложный запрос как именованный объект и переиспользовать его в других запросах.

Попробуйте интерактивную версию

Практические задачи, квизы и AI-наставник — бесплатный старт без карты

Перейти к практике