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 Read | Non-Repeatable Read | Phantom 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: представления, которые позволяют сохранить сложный запрос как именованный объект и переиспользовать его в других запросах.