Аномалии транзакций
Предпосылки: MVCC. Примеры используют Ruby/ActiveRecord — синтаксис интуитивно понятен, но для полного понимания полезно знание основ SQL.
← MVCC | Уровни изоляции →
MVCC отвечает на вопрос «что видит транзакция?» через snapshot: каждая транзакция работает с согласованным срезом данных, не блокируя читателей. Но согласованный срез для каждой транзакции по отдельности не гарантирует, что их совместный результат корректен. Две транзакции могут иметь валидные snapshot’ы и при этом произвести результат, невозможный при последовательном выполнении. Разрыв между «каждая транзакция видит согласованные данные» и «совместный результат валиден» — это территория аномалий.
Параллельная обработка платежей в интернет-магазине — типичная ситуация, в которой проявляются все эти аномалии: от чтения незакоммиченных данных до нарушения бизнес-инвариантов.
Сериализуемость — идеал и его цена
Результат параллельного выполнения транзакций сериализуем, если он совпадает с результатом какого-то последовательного выполнения тех же транзакций. Не важно, какого именно порядка — любого допустимого. Если T1 и T2 работают параллельно, результат должен быть таким, как будто выполнилась сначала T1, потом T2, или наоборот.
Если гарантирована сериализуемость, разработчик пишет код, как будто его транзакция единственная. Тестирует — работает. В production параллельные транзакции не создадут результат, невозможный при последовательном выполнении.
Но полная сериализуемость стоит дорого: она требует либо блокировок (транзакции ждут друг друга), либо откатов при конфликтах. Пропускная способность падает. Поэтому SQL-стандарт предлагает компромиссные уровни изоляции, каждый из которых допускает определённые аномалии — результаты, невозможные при последовательном выполнении. Чем слабее уровень, тем больше аномалий он допускает, но тем выше производительность.
Чтение незакоммиченных данных: dirty read
Самая грубая аномалия — транзакция читает данные, которые другая транзакция изменила, но ещё не закоммитила. Если первая транзакция откатится, вторая приняла решение на основе данных, которых «никогда не было».
Допустим, T1 списывает 500 с баланса клиента, но транзакция ещё не завершена. Параллельно T2 читает тот же баланс:
# Начальное состояние: Account.find(1).balance = 1000
# === Транзакция T1 (перевод денег) ===
ActiveRecord::Base.transaction do
account = Account.find(1)
account.update!(balance: account.balance - 500) # balance = 500
# ... длительная операция ...
# T1 ещё не закоммитила!
raise "Ошибка валидации!" # T1 откатывается
end
# === Транзакция T2 (параллельно, читает во время T1) ===
ActiveRecord::Base.transaction do
account = Account.find(1)
puts account.balance # Видит 500 — незакоммиченное значение!
# T2 принимает решение на основе balance = 500
# Но T1 откатилась, реальный balance = 1000
endНа уровне SQL это выглядит так:
-- T1
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- balance = 1000
UPDATE accounts SET balance = 500 WHERE id = 1;
-- T1 ещё не COMMIT
-- T2 (параллельно)
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- Dirty read: видит balance = 500
COMMIT;
-- T1
ROLLBACK; -- balance возвращается к 1000T1: BEGIN ──── UPDATE(500) ─────────────────── ROLLBACK
|
T2: BEGIN ─┼─ SELECT ── COMMIT
|
└─ T2 видит 500, но это значение
никогда не существовало!T2 прочитала данные, которых никогда не существовало в базе как закоммиченных. Любое решение на основе этого значения ошибочно — даже промежуточное состояние было ложью.
MVCC в PostgreSQL делает dirty read невозможным на любом уровне изоляции: незакоммиченные tuple имеют xmin активной транзакции и невидимы по правилам проверки видимости. Но даже если транзакция видит только закоммиченные данные, это не гарантирует согласованности внутри одной транзакции.
Нестабильное чтение внутри транзакции: non-repeatable read
Транзакция дважды читает одну строку и получает разные значения, потому что между чтениями другая транзакция изменила и закоммитила данные.
Допустим, T1 проверяет баланс клиента перед крупной операцией. Между первым и вторым SELECT параллельная транзакция T2 списывает деньги:
# Начальное состояние: Account.find(1).balance = 1000
# === Транзакция T1 (проверка и действие) ===
ActiveRecord::Base.transaction do
account = Account.find(1)
puts account.balance # 1000
if account.balance >= 1000
# ... подготовка к операции ...
sleep(2)
account.reload
puts account.balance # 500 — другое значение!
# Условие уже не выполняется, но мы уже начали операцию
end
end
# === Транзакция T2 (параллельно) ===
ActiveRecord::Base.transaction do
account = Account.find(1)
account.update!(balance: account.balance - 500) # 1000 -> 500
end # COMMIT между двумя SELECT в T1-- T1
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- balance = 1000
-- T2 (между SELECT'ами T1)
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- T1 (продолжение)
SELECT * FROM accounts WHERE id = 1; -- balance = 500 (другое!)
COMMIT;T1: BEGIN ── SELECT(1000) ─────────────── SELECT(500) ── COMMIT
|
T2: BEGIN ─┼─ UPDATE ── COMMIT
|
└─ T2 закоммитила между
двумя SELECT в T1T1 видит мир непоследовательно: первый SELECT говорит одно, второй — другое. В отличие от dirty read, данные, которые видит T1 во втором чтении, реальны — T2 закоммитила. Но внутри одной транзакции T1 наблюдает два разных состояния мира, и решение, принятое на основе первого чтения, может оказаться неверным.
Non-repeatable read затрагивает конкретные строки, которые транзакция уже прочитала. Но запрос с условием (WHERE balance > 200) работает не с фиксированным набором строк, а с множеством, удовлетворяющим предикату — и это множество может измениться.
Новые строки между запросами: phantom read
Транзакция дважды выполняет запрос с условием и получает разные наборы строк. Существующие строки не изменились — появились новые.
T1 считает счета с балансом выше 200 для финансового отчёта. Между запросами T2 создаёт новый счёт:
# Начальное состояние:
# Account id=1, balance=500
# Account id=2, balance=300
# === Транзакция T1 (подсчёт для отчёта) ===
ActiveRecord::Base.transaction do
count1 = Account.where("balance > 200").count
puts count1 # 2 счёта
sleep(2)
count2 = Account.where("balance > 200").count
puts count2 # 3 счёта — появился фантом!
end
# === Транзакция T2 (параллельно) ===
ActiveRecord::Base.transaction do
Account.create!(id: 3, balance: 400)
end # COMMIT между двумя SELECT в T1-- T1
BEGIN;
SELECT COUNT(*) FROM accounts WHERE balance > 200; -- 2
-- T2 (между SELECT'ами T1)
BEGIN;
INSERT INTO accounts (id, balance) VALUES (3, 400);
COMMIT;
-- T1 (продолжение)
SELECT COUNT(*) FROM accounts WHERE balance > 200; -- 3 (фантом!)
COMMIT;T1: BEGIN ── SELECT(count=2) ─────────────── SELECT(count=3) ── COMMIT
|
T2: BEGIN ─┼─ INSERT ── COMMIT
|
└─ Новая строка появилась
между запросами T1T1 работает с набором данных, который меняется «под ногами». Отчёт, построенный на первом запросе, не соответствует второму. Это тоньше, чем non-repeatable read: даже если заблокировать все прочитанные строки, новых строк ещё не было — их нечего было блокировать.
Dirty read, non-repeatable read и phantom read — аномалии чтения: транзакция видит некорректную или непоследовательную картину мира. Но что произойдёт, если транзакция не только читает, а ещё и записывает на основе прочитанного?
От чтения к записи: lost update
Две транзакции читают одно значение, вычисляют новое на его основе и записывают. Одно из изменений теряется.
Два параллельных платежа списывают деньги с одного счёта. Каждый читает баланс, вычисляет новое значение и записывает:
# Начальное состояние: Account.find(1).balance = 1000
# === Транзакция T1 (списание 100) ===
ActiveRecord::Base.transaction do
account = Account.find(1) # Читает balance = 1000
new_balance = account.balance - 100 # Вычисляет: 1000 - 100 = 900
sleep(1)
account.update!(balance: new_balance) # Записывает 900
end
# === Транзакция T2 (списание 200, параллельно) ===
ActiveRecord::Base.transaction do
account = Account.find(1) # Тоже читает balance = 1000
new_balance = account.balance - 200 # Вычисляет: 1000 - 200 = 800
account.update!(balance: new_balance) # Записывает 800
end-- T1
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- balance = 1000
-- T1 вычисляет: 1000 - 100 = 900
-- T2 (параллельно)
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- balance = 1000
-- T2 вычисляет: 1000 - 200 = 800
UPDATE accounts SET balance = 800 WHERE id = 1;
COMMIT;
-- T1 (продолжение)
UPDATE accounts SET balance = 900 WHERE id = 1; -- Перезаписывает!
COMMIT;T1: BEGIN ── SELECT(1000) ──────────────────────── UPDATE(900) ── COMMIT
| |
T2: BEGIN ─┼─ SELECT(1000) ── UPDATE(800) ── COMMIT
| | |
└───────────────────────┴──────────────┘
Оба читают 1000 T1 перезаписывает
изменение T2Итог: balance = 900, хотя должно быть 1000 - 100 - 200 = 700. Списание T2 (200) потеряно. Это паттерн read-modify-write: каждая транзакция корректна сама по себе (прочитала, вычислила, записала), но вместе они теряют данные.
SQL-стандарт не упоминает lost update явно — эта аномалия возникает из-за паттерна в приложении, а не из-за самой СУБД. Защита от lost update на практике — через SELECT ... FOR UPDATE или REPEATABLE READ с retry-логикой — рассматривается в паттернах параллельного доступа.
Lost update возникает, когда две транзакции пишут в одну и ту же строку. Но инвариант может связывать несколько строк, и тогда проблема возникает, даже когда транзакции пишут в разные строки.
Запись в разные строки: write skew
Две транзакции читают пересекающиеся данные, принимают решения на основе прочитанного и записывают в разные строки. Каждая транзакция корректна по отдельности, но вместе они нарушают инвариант.
Бизнес-правило: минимум 1 врач должен быть на дежурстве. Alice и Bob оба дежурят и оба хотят уйти:
# Начальное состояние:
# Doctor id=1, name='Alice', on_call=true
# Doctor id=2, name='Bob', on_call=true
# === Транзакция T1 (Alice хочет уйти с дежурства) ===
ActiveRecord::Base.transaction do
on_call_count = Doctor.where(on_call: true).count
puts on_call_count # 2 врача на дежурстве
if on_call_count >= 2 # Можно уйти, останется минимум 1
Doctor.find(1).update!(on_call: false) # Alice уходит
end
end
# === Транзакция T2 (Bob тоже хочет уйти, параллельно) ===
ActiveRecord::Base.transaction do
on_call_count = Doctor.where(on_call: true).count
puts on_call_count # Тоже видит 2 врача!
if on_call_count >= 2 # Можно уйти, останется минимум 1
Doctor.find(2).update!(on_call: false) # Bob уходит
end
end-- T1
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2
UPDATE doctors SET on_call = false WHERE id = 1; -- Alice уходит
COMMIT;
-- T2 (параллельно)
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 2 (T1 ещё не закоммитила)
UPDATE doctors SET on_call = false WHERE id = 2; -- Bob уходит
COMMIT;T1: BEGIN ── SELECT(count=2) ── UPDATE(Alice) ── COMMIT
| |
T2: BEGIN ────────┼─ SELECT(count=2) ─┼── UPDATE(Bob) ── COMMIT
| |
└───────────────────┘
Оба видят 2 врача, оба решают что можно уйтиРезультат: 0 врачей на дежурстве, инвариант нарушен. Write skew — самая коварная из аномалий. Каждая транзакция корректна по отдельности, обе пишут в разные строки — конфликта записи нет. Проблема проявляется только в комбинации, и обычные механизмы защиты (блокировка изменяемой строки) не помогают, потому что каждая транзакция изменяет «свою» строку. Для предотвращения write skew необходим уровень SERIALIZABLE, который использует SSI (Serializable Snapshot Isolation) для обнаружения опасных зависимостей между транзакциями — подробнее в уровнях изоляции.
Уровни изоляции SQL-стандарта
SQL-стандарт определяет четыре уровня изоляции через допускаемые аномалии:
| Уровень | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Возможен | Возможен | Возможен |
| READ COMMITTED | Невозможен | Возможен | Возможен |
| REPEATABLE READ | Невозможен | Невозможен | Возможен |
| SERIALIZABLE | Невозможен | Невозможен | Невозможен |
Это минимальные требования стандарта. Стандарт не упоминает lost update и write skew — эти аномалии не укладываются в таблицу, потому что зависят от паттернов в приложении (read-modify-write) и структуры данных (пересекающиеся чтения).
PostgreSQL даёт более сильные гарантии на некоторых уровнях. REPEATABLE READ в PostgreSQL использует Snapshot Isolation с фиксированным snapshot на всю транзакцию — это предотвращает и phantom read, хотя стандарт его допускает. Как PostgreSQL реализует каждый уровень через политику snapshot и реакцию на конфликт записи — в уровнях изоляции.
Sources
- Berenson et al. A Critique of ANSI SQL Isolation Levels (1995). https://doi.org/10.1145/223784.223785
- PostgreSQL Documentation (v16): Transaction Isolation. https://www.postgresql.org/docs/16/transaction-iso.html
← MVCC | Уровни изоляции →