Почему в PostgreSQL "всё работает странно"
Когда несколько транзакций приходят к одним и тем же данным, PostgreSQL приходится решать сразу несколько задач: определить кто что должен видеть, кто когда может писать и что делать, если всё зайдёт в тупик. Эти задачи решаются разными механизмами, которые включаются в разные моменты времени. Есть четыре независимых слоя: MVCC, блокировки, уровни изоляции и детекция конфликтов.
Где заканчивается MVCC и начинаются блокировки
MVCC отвечает за видимость данных, блокировки — за порядок их изменения. MVCC даёт очень сильную гарантию: чтение почти никогда не блокируется. Это возможно потому, что PostgreSQL не перезаписывает строки — он создаёт новые версии, а старые остаются доступны для уже запущенных транзакций. Но как только речь заходит об изменениях, одного MVCC становится недостаточно. Две транзакции не могут одновременно изменить одну и ту же строку — нужно определить их порядок. И этот порядок задаётся блокировками.
Подробный разбор механики блокировок и их типов можно прочитать в статье: Типы блокировок в PostgreSQL: полный практический разбор
Что происходит при двух UPDATE
Возьмём простой сценарий: две транзакции обновляют одну строку.
-- Tранзакция 1
BEGIN;
UPDATE goods SET price = price + 200 WHERE id = 1;
-- Tранзакция 2
BEGIN;
UPDATE goods SET price = price * 1.05 WHERE id = 1;
T1: BEGIN
T1: UPDATE row(id=1)
├─ создаётся новая версия строки
└─ блокируется строка row(id=1)
T2: BEGIN
T2: UPDATE row(id=1)
└─ ждёт освобождения row(id=1)
T1: COMMIT
└─ снимается блокировка со строки row(id=1)
T2: resume
├─ читает новую версию строки
└─ делает свой UPDATE
Первая транзакция создаёт новую версию строки и ставит блокировку. Вторая доходит до UPDATE и останавливается — не из-за MVCC, а потому что строка уже захвачена.
После COMMIT первая транзакция освобождает строку, и вторая продолжает работу. И в этом случае она не
применит UPDATE к старому значению. Она прочтёт уже новую версию строки и пересчитает выражение.
Полезная статья на эту тему: Почему UPDATE блокирует строки, а не таблицу
Уровни изоляции
Следующий слой — правила видимости. В PostgreSQL транзакция работает со snapshot’ом. В зависимости от уровня изоляции этот snapshot либо фиксирован, либо может обновляться. Это не про то, кто кого блокирует, а про то, какие состояния данных считаются допустимыми.
Подробный разбор с примерами: Isolation levels в PostgreSQL простыми словами
Serialization failure
На уровне SERIALIZABLE PostgreSQL идёт дальше: он позволяет транзакциям выполняться параллельно, а потом проверяет, можно ли представить результат как последовательное выполнение. Если нельзя — одна из транзакций откатывается. Это выглядит как ошибка, но по сути это механизм защиты. База не даёт зафиксировать состояние, которое невозможно объяснить корректным порядком операций.
Подробно: serialization failure: когда возникает и как избежать
Откуда берутся дедлоки
Блокировки решают конфликты записи, но могут привести к циклу ожиданий, когда одна транзакция ждёт ресурс, который держит другая, а та ждёт пока первая транзакция освободит ресурс нужный ей. Чтобы избежать бесконечного ожидания, PostgreSQL отслеживает такие проблемы и разрывает цикл, принудительно завершая одну из транзакций.
Как именно он это делает можно узнать в: Как PostgreSQL обнаруживает дедлоки
Коротко, простыми словами
Если упростить до рабочей модели, получается следующее:
- MVCC определяет, какие данные ты видишь
- Блокировки определяют, когда ты можешь их изменить
- Уровень изоляции определяет, какие состояния допустимы
- Механизм конфликтов проверяет итог
PostgreSQL просто строго следует своим правилам. Эти правила распределены по разным механизмам, но как только они сложатся в голове в единую картину, поведение системы станет понятным и предсказуемым.