Уровни изоляции в Postgres
READ COMMITTED, REPEATABLE READ и SERIALIZABLE — нарастающие обещания о том, какие аномалии видят конкурентные транзакции. Только SERIALIZABLE останавливает write skew — и заставляет писать retry-логику.
Система дежурств в больнице держала одно правило: хотя бы один врач должен оставаться на дежурстве. Два врача, оба на дежурстве, оба нажали «уйти с дежурства» в одну секунду. Каждая транзакция прочитала «на дежурстве 2, нормально» и удалила себя. Итог: ноль дежурных — правило, которое уважала каждая отдельная транзакция, сломано их чередованием. Ни одного бага в запросах; баг был в уровне изоляции. У этой аномалии есть имя — write skew — и ровно один уровень её останавливает.
Уровни как лестница обещаний
Выбирая уровень изоляции, ты не настраиваешь скорость — ты выбираешь класс багов, которые принимаешь. Слишком низкий уровень — тихая порча данных; слишком высокий — пишешь retry-логику. Точное знание границы каждого уровня и отличает сеньора, который предотвращает инциденты, от того, кто потом их отлаживает.
Уровень изоляции — не ручка производительности; это контракт о том, какие аномалии позволено наблюдать конкурентным транзакциям. Postgres предлагает три рабочих уровня (он принимает READ UNCOMMITTED, но трактует его как READ COMMITTED — грязные чтения в Postgres невозможны никогда).
- READ COMMITTED (по умолчанию) — ты видишь только закоммиченные данные, но каждый оператор берёт свежий снимок в начале оператора. Так что внутри одной транзакции два
SELECTодной строки могут вернуть разные значения, если другая транзакция закоммитила между ними. Предотвращает: грязные чтения. Допускает: non-repeatable read, фантомы, lost update, write skew. - REPEATABLE READ — транзакция берёт один снимок на первом операторе и использует его всю транзакцию. Каждое чтение видит базу на тот момент. Предотвращает: грязные чтения, non-repeatable read и (в Postgres) фантомы. Допускает: write skew. Запись, конфликтующая с коммитом другой транзакции, падает с serialization error.
- SERIALIZABLE — ведёт себя так, будто каждая транзакция шла одна за другой в некотором последовательном порядке. Реализован через Serializable Snapshot Isolation (SSI): отслеживает read/write-зависимости и прерывает транзакцию, чей коммит создал бы несериализуемое расписание. Предотвращает: всё, включая write skew. Цена: больше serialization failure, которые ты обязан повторять.
Снимок на оператор против снимка на транзакцию
Самое важное различие двух распространённых уровней — когда берётся снимок.
-- READ COMMITTED: каждый оператор переснимает.
BEGIN; -- уровень по умолчанию
SELECT qty FROM inventory WHERE sku = 'A'; -- видит 10
-- ... другая сессия коммитит qty = 3 здесь ...
SELECT qty FROM inventory WHERE sku = 'A'; -- теперь видит 3 (non-repeatable read)
COMMIT;-- REPEATABLE READ: один снимок на всю транзакцию.
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT qty FROM inventory WHERE sku = 'A'; -- видит 10
-- ... другая сессия коммитит qty = 3 здесь ...
SELECT qty FROM inventory WHERE sku = 'A'; -- всё ещё видит 10 (стабильное чтение)
COMMIT;READ COMMITTED правилен для коротких OLTP-операторов, где нужны самые свежие закоммиченные данные. REPEATABLE READ правилен для отчёта или многооператорного чтения, которое должно быть внутренне согласованным — баланс, который не сдвигается под тобой посреди запроса.
Внутренности — как снимки, xmin/xmax и видимость работают на самом деле — это глава MVCC трека databases; здесь мы остаёмся на уровне SQL-контракта. Смотри databases/04-mvcc-isolation/03-hot-updates-and-isolation-levels для механизма версий строк и databases/04-mvcc-isolation/06-ssi-and-production-tuning про то, как SSI отслеживает конфликты.
Write skew и почему его ловит только SERIALIZABLE
Write skew — аномалия, удивляющая сеньоров. Две транзакции читают пересекающийся набор строк, каждая проверяет инвариант, который сейчас держится, затем каждая пишет другую строку. Ни одна запись не конфликтует с другой (разные строки), так что проверка «первый-обновивший-выигрывает» REPEATABLE READ не срабатывает — но вместе они ломают инвариант.
-- Инвариант дежурства, защищённый через SERIALIZABLE:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true; -- должно остаться >= 1
-- приложение проверяет count >= 2 перед тем как разрешить это:
UPDATE doctors SET on_call = false WHERE id = 17;
COMMIT; -- если конкурентная txn сломала сериализуемость, тут поднимется 40001На SERIALIZABLE одна из двух конкурентных транзакций коммитится; другая получает ERROR: could not serialize access due to read/write dependencies among transactions (SQLSTATE 40001). Твоё приложение обязано ловить 40001 и повторять всю транзакцию. На повторе она перечитывает count = 1 и корректно отказывает. Без retry-логики SERIALIZABLE просто превращает тихую порчу данных в видимые ошибки, которые видят пользователи.
▸Граничные случаи
SERIALIZABLE не бесплатен и не магия. SSI ведёт бухгалтерию predicate-блокировок (SIReadLocks) на каждое чтение, что стоит памяти и под нагрузкой может эскалировать с гранулярности строки на страницу и на отношение, повышая ложноположительные аборты. Продакшн-правило: используй SERIALIZABLE для конкретных транзакций, несущих кросс-строчный инвариант (бронирование, резерв склада, правила дежурства), держи вокруг них retry-логику с ограниченным экспоненциальным backoff, а остальной трафик держи на READ COMMITTED. Резервирование строк явной блокировкой SELECT ... FOR UPDATE часто дешевле и предсказуемее — следующий урок.
Транзакция на REPEATABLE READ делает два одинаковых SELECT inventory.qty для одного sku, а другая сессия коммитит изменение между ними. Что вернёт второй SELECT?
Нужно гарантировать «хотя бы одна строка набора остаётся true» через конкурентные транзакции, каждая из которых меняет другую строку. Какой уровень и что нужно добавить?
Заполни пропуск: аномалия, где две транзакции читают пересекающийся набор, каждая проверяет держащийся инвариант, затем каждая пишет другую строку и вместе ломают инвариант, называется write _______.
- 01В чём ключевое поведенческое различие READ COMMITTED и REPEATABLE READ?
- 02Что такое write skew и какой уровень изоляции нужен, чтобы его предотвратить?
- 03Если перевести транзакцию на SERIALIZABLE, какую новую обязанность берёт код приложения?
Уровень изоляции — контракт о том, какие аномалии могут наблюдать конкурентные транзакции, а не настройка скорости. READ COMMITTED, умолчание Postgres, берёт свежий снимок на оператор: блокирует грязные чтения, но допускает non-repeatable read, фантомы, lost update и write skew. REPEATABLE READ берёт один снимок на всю транзакцию, давая стабильные чтения и (в Postgres) отсутствие фантомов, но всё ещё допускает write skew, потому что две транзакции, пишущие разные строки, никогда не конфликтуют. SERIALIZABLE использует Serializable Snapshot Isolation, чтобы вести себя как поочерёдное выполнение; это единственный уровень, предотвращающий write skew, ценой большего числа serialization failure (SQLSTATE 40001), которые приложение обязано ловить и повторять. Продакшн-дисциплина: держи основной трафик на READ COMMITTED, поднимай до SERIALIZABLE только транзакции с кросс-строчным инвариантом и retry-логикой, либо резервируй строки явно блокировками, которые узнаешь дальше. MVCC-машинерия за всем этим — снимки, версии строк, predicate-блокировки SSI — живёт в главе MVCC трека databases; этот урок — обещание на уровне SQL. Теперь, когда встретишь баг «два конкурентных обновления нарушили инвариант, который ни одно из них по отдельности не нарушило», ты потянешься к SERIALIZABLE с retry-циклом, а не к очередной строке-блокировке.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.