Зачем разбирать аномалии
Транзакции по отдельности работают предсказуемо. Проблемы начинаются, когда их две (или больше) и они выполняются параллельно. В этот момент возникает класс багов, которые невозможно воспроизвести на dev-машине с одним пользователем — но в проде они проявляются регулярно.
Чтобы говорить о таких багах на одном языке, стандарт SQL-92 выделил четыре
В этом уроке — все четыре аномалии с примерами из e-commerce. Где это возможно — с диаграммами «две дорожки» (transaction A | transaction B), потому что в pglite показать конкуренцию двух сессий нельзя.
Терминология
Прежде чем переходить к четырём конкретным аномалиям, проясним терминологию. В литературе встречаются два набора слов:
- «Аномалии» (anomalies) — это слово из учебников и стандарта SQL-92.
- «Phenomena» (явления) — то же самое в стандарте.
- «Race conditions» — это уже из мира многопоточного программирования; концептуально близко.
Все три обозначают одно: ситуации, в которых параллельность ломает корректность. Я буду писать «аномалия» — но если в литературе встретите «phenomena» — это синоним.
Аномалия 1: Dirty read (грязное чтение)
Грязное чтение — это когда одна транзакция видит незакоммиченные изменения другой. Если та потом откатится — первая прочитает данные, которых «никогда не было».
Транзакция B видит изменение, сделанное A. Потом A откатывается — но B уже приняла бизнес-решение на основе этих данных.
В реальной жизни это особенно опасно для отчётов и аналитики: «средний баланс счетов» может включать промежуточные значения переводов, которые потом откатятся.
В PostgreSQL dirty read невозможен. Даже на самом слабом уровне READ UNCOMMITTED PostgreSQL фактически работает как READ COMMITTED. Это сознательная инженерная позиция: грязное чтение настолько опасно, что разрешать его нет смысла.
Аномалия 2: Non-repeatable read (неповторяемое чтение)
Транзакция читает строку, потом читает ту же строку ещё раз — и получает другие данные. Между двумя SELECT пришла другая транзакция, сделала UPDATE и сделала COMMIT.
B сначала видит цену 100, потом — 110. Если B принимает решение на основе обоих чисел, логика ломается.
Пример из e-commerce: транзакция считает общую сумму корзины и налог. Между двумя SELECT админ повысил цену. Сумма не совпадёт с налогом, и клиенту покажется ошибка вычисления. На скорости 100 заказов/секунду такое случается регулярно.
Запрещено на уровне REPEATABLE READ и выше. На READ COMMITTED (PostgreSQL default) — разрешено и регулярно встречается.
Различие между dirty read и non-repeatable
Эти две аномалии похожи — обе про «значение изменилось». Но разница принципиальная.
- Dirty read: ты видишь незакоммиченное изменение другой транзакции. Эти данные могут оказаться откачены — то есть «их никогда не было». Это самый опасный вид аномалии: ты можешь принять решение на основе фейковых данных.
- Non-repeatable read: ты видишь изменение, которое закоммичено. Эти данные реальны, они в базе. Просто ты ожидал увидеть прежнее значение, а получил новое. Это аномалия только потому, что нарушает «стабильность снимка» — никакой «фейк-данных» здесь нет.
Поэтому dirty read запрещается всегда (даже на самом слабом уровне PostgreSQL), а non-repeatable — это уже компромисс ради скорости, и его разрешают на READ COMMITTED.
Аномалия 3: Phantom read (фантомное чтение)
Похоже на non-repeatable, но не про одну строку, а про набор строк. Транзакция выполняет SELECT ... WHERE ..., получает 10 строк. Через секунду тот же SELECT — уже 11 строк. Появилась «фантомная» строка, которой при первом чтении не было.
Между двумя SELECT'ами одного диапазона появилась новая строка, удовлетворяющая условию.
В обычных приложениях фантомы редко критичны. Они становятся проблемой в задачах вроде «найти свободный номер и зарезервировать»: между поиском и резервированием другой кто-то создал заказ с тем же номером.
В стандарте SQL-92 phantom read формально отличается от non-repeatable read и запрещается только на SERIALIZABLE. В PostgreSQL благодаря
REPEATABLE READ тоже не возникают — об этом подробно в уроке 5.
Аномалия 4: Write skew (асимметричная запись)
Самая хитрая аномалия. Две транзакции читают одни и те же данные, принимают на их основе разные решения, и каждая записывает что-то своё. По отдельности обе валидны. Вместе — нарушают инвариант, который ни одна из них «в одиночку» не нарушила бы.
Каноничный пример — «врачи на дежурстве». В больнице правило: всегда хотя бы один врач на смене. Два врача (A и B) одновременно решают взять выходной. Каждый проверяет: «сколько всего на смене? — двое, кроме меня будет один — ОК, могу взять». Каждый записывает: «беру выходной». Результат: на смене ноль врачей.
В e-commerce та же история — «два кассира продают последний товар». На складе один iPhone. Два кассира одновременно открывают заказ:
A и B читают in_stock=1, проверяют «есть в наличии», создают заказ. В итоге продали один товар дважды.
В коде с CHECK (in_stock >= 0) одна из транзакций упадёт на UPDATE. Но если CHECK нет — оба commit’а пройдут, и мы продали то, чего нет.
Подвох в том, что каждая транзакция в одиночку валидна — она проверила инвариант перед записью. Параллельность создаёт ситуацию, в которой по очереди валидные транзакции вместе ломают логику.
Write skew запрещён только на SERIALIZABLE. На всех остальных уровнях (включая REPEATABLE READ) — он разрешён и возникает в реальных системах. Это причина, по которой банки и платёжные системы используют либо SERIALIZABLE, либо явные блокировки SELECT FOR UPDATE (об этом — урок 6).
Покажем atomicity на pglite
В pglite показать конкуренцию двух транзакций нельзя — там один connection. Но можно показать отсутствие dirty read внутри одной сессии через демо READ COMMITTED-семантики:
Внутри одной сессии: после COMMIT изменения видны, до COMMIT — для других сессий нет
И ещё показательный кейс — что произойдёт, если один и тот же товар попытаться продать дважды без блокировок:
Если CHECK есть, write skew всё равно ловится — потому что in_stock не может стать -1
Видишь, как CHECK спас? Это и есть последняя линия обороны против write skew. Когда уровень изоляции его не ловит — ловит ограничение схемы.
Ещё одна аномалия: lost update
Часто к четырём «классическим» добавляют пятую — lost update. Это частный случай write skew, но настолько распространённый, что заслуживает отдельного имени.
Сценарий: A читает счётчик views = 100. B параллельно тоже читает 100. A пишет views = 101 (100 + 1). B тоже пишет views = 101 (100 + 1). Должно было быть 102 — потеряли инкремент.
Каждая транзакция использовала прочитанное значение для вычисления нового. Изменение, сделанное на втором commit'е, перезаписало первый — но без учёта первого.
Lost update запрещён на уровнях REPEATABLE READ и выше в PostgreSQL — на второй UPDATE одной и той же строки транзакция получит ошибку 40001, что не даст «затереть» чужое изменение. На READ COMMITTED lost update возможен, но обычно его обходят через UPDATE ... WHERE ... AND version = ? (optimistic locking) или через SELECT FOR UPDATE (см. урок 6).
Универсальное решение в SQL: вместо «прочитать → посчитать → записать» писать UPDATE page SET views = views + 1 WHERE id = 1. Здесь нет «прочитанного значения» в приложении — PostgreSQL атомарно читает и обновляет в одной операции, и lost update физически невозможен.
Безопасный инкремент: views = views + 1 атомарен и не даёт lost update даже на READ COMMITTED
Главное про аномалии
Запомни главное: аномалии не «бывают» или «не бывают». Они разрешены или запрещены уровнем изоляции, который выбрал ты. Каждый уровень — это компромисс: чем строже, тем меньше аномалий, но тем больше блокировок и retry.
В следующем уроке разберём четыре уровня изоляции — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE — и таблицу «какой уровень какие аномалии запрещает».
Как смягчать аномалии в коде
Уровень изоляции — не единственная защита. На практике ты комбинируешь:
- Уровень изоляции. READ COMMITTED для большинства операций, REPEATABLE READ для отчётов, SERIALIZABLE для критичных финансов.
- Ограничения схемы. CHECK, UNIQUE, FOREIGN KEY — последняя линия обороны, ловит то, что не поймала изоляция.
- Атомарные UPDATE.
UPDATE x SET y = y + 1вместо «SELECT y; if y < N then UPDATE y = y + 1». Меньше шагов = меньше окон для аномалий. - Явные локи.
SELECT FOR UPDATE— когда нужно «зарезервировать» строку между чтением и записью. См. урок 6. - Optimistic locking.
UPDATE x SET y = ?, version = version + 1 WHERE id = ? AND version = ?— если 0 строк обновлено, retry с новым значением.
В реальных бэкендах все пять подходов сосуществуют. Выбор — это часть архитектуры конкретного сервиса.
Чек-лист
- Аномалии возникают только при параллельных транзакциях. В одиночной сессии их нет.
- Dirty read — чтение незакоммиченного. В PostgreSQL никогда не происходит.
- Non-repeatable read — одна строка меняется между двумя SELECT’ами. Запрещён на REPEATABLE READ.
- Phantom read — набор строк меняется между двумя SELECT’ами. В PostgreSQL благодаря MVCC не происходит уже на REPEATABLE READ.
- Write skew — две валидные по отдельности транзакции вместе ломают инвариант. Запрещён только на SERIALIZABLE.
- Последняя линия защиты —
CHECK,UNIQUE,FOREIGN KEYи явные блокировки. Полагаться только на уровень изоляции опасно.