Learning Platform
Урок 13.04 · 18 мин
Средний
Concurrency anomaliesDirty readNon-repeatable readPhantom readWrite skew

Зачем разбирать аномалии

Транзакции по отдельности работают предсказуемо. Проблемы начинаются, когда их две (или больше) и они выполняются параллельно. В этот момент возникает класс багов, которые невозможно воспроизвести на dev-машине с одним пользователем — но в проде они проявляются регулярно.

Чтобы говорить о таких багах на одном языке, стандарт SQL-92 выделил четыре

аномалии
— конкретные классы плохих сценариев. Каждый уровень изоляции (про них — урок 5) определяет, какие из них он запрещает, а какие — разрешает в обмен на скорость.

В этом уроке — все четыре аномалии с примерами из e-commerce. Где это возможно — с диаграммами «две дорожки» (transaction A | transaction B), потому что в pglite показать конкуренцию двух сессий нельзя.

Терминология

Прежде чем переходить к четырём конкретным аномалиям, проясним терминологию. В литературе встречаются два набора слов:

  • «Аномалии» (anomalies) — это слово из учебников и стандарта SQL-92.
  • «Phenomena» (явления) — то же самое в стандарте.
  • «Race conditions» — это уже из мира многопоточного программирования; концептуально близко.

Все три обозначают одно: ситуации, в которых параллельность ломает корректность. Я буду писать «аномалия» — но если в литературе встретите «phenomena» — это синоним.

Аномалия 1: Dirty read (грязное чтение)

Грязное чтение — это когда одна транзакция видит незакоммиченные изменения другой. Если та потом откатится — первая прочитает данные, которых «никогда не было».

Dirty read: чтение незакоммиченного

Транзакция B видит изменение, сделанное A. Потом A откатывается — но B уже приняла бизнес-решение на основе этих данных.

Transaction A
t1BEGIN
t2UPDATE products SET in_stock=0 WHERE sku='LP-001'
t4ROLLBACKПередумали — товар не закончился
Transaction B
t3SELECT in_stock FROM products WHERE sku='LP-001'Видит 0 — но это незакоммиченные данные A!
результат: 0Bug: B думает, что товар закончился, и шлёт email клиенту «нет в наличии»
но реальное значение было 14 — A откатилась

В реальной жизни это особенно опасно для отчётов и аналитики: «средний баланс счетов» может включать промежуточные значения переводов, которые потом откатятся.

В PostgreSQL dirty read невозможен. Даже на самом слабом уровне READ UNCOMMITTED PostgreSQL фактически работает как READ COMMITTED. Это сознательная инженерная позиция: грязное чтение настолько опасно, что разрешать его нет смысла.

Аномалия 2: Non-repeatable read (неповторяемое чтение)

Транзакция читает строку, потом читает ту же строку ещё раз — и получает другие данные. Между двумя SELECT пришла другая транзакция, сделала UPDATE и сделала COMMIT.

Non-repeatable read: один SELECT даёт разные результаты

B сначала видит цену 100, потом — 110. Если B принимает решение на основе обоих чисел, логика ломается.

Transaction B (отчёт)
t1BEGIN
t2SELECT price FROM products WHERE id=1Видит 100
result: 100
t5SELECT price FROM products WHERE id=1Видит уже 110!
result: 110
t6COMMIT
Transaction A (цена)
t3BEGIN; UPDATE products SET price=110 WHERE id=1
t4COMMITЗакоммитили — 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 строк. Появилась «фантомная» строка, которой при первом чтении не было.

Phantom read: число строк меняется между SELECT'ами

Между двумя SELECT'ами одного диапазона появилась новая строка, удовлетворяющая условию.

Transaction B (аудит)
t1BEGIN
t2SELECT COUNT(*) FROM orders WHERE status='paid'
result: 10
t5SELECT SUM(amount) FROM orders WHERE status='paid'Считает по 11 строкам — но мы думали, их 10!
result: основан на 11 строках
Transaction A (новый заказ)
t3BEGIN; INSERT INTO orders (status, amount) VALUES ('paid', 5000)
t4COMMIT

В обычных приложениях фантомы редко критичны. Они становятся проблемой в задачах вроде «найти свободный номер и зарезервировать»: между поиском и резервированием другой кто-то создал заказ с тем же номером.

В стандарте SQL-92 phantom read формально отличается от non-repeatable read и запрещается только на SERIALIZABLE. В PostgreSQL благодаря

MVCC
и snapshot isolation фантомы на REPEATABLE READ тоже не возникают — об этом подробно в уроке 5.

Аномалия 4: Write skew (асимметричная запись)

Самая хитрая аномалия. Две транзакции читают одни и те же данные, принимают на их основе разные решения, и каждая записывает что-то своё. По отдельности обе валидны. Вместе — нарушают инвариант, который ни одна из них «в одиночку» не нарушила бы.

Каноничный пример — «врачи на дежурстве». В больнице правило: всегда хотя бы один врач на смене. Два врача (A и B) одновременно решают взять выходной. Каждый проверяет: «сколько всего на смене? — двое, кроме меня будет один — ОК, могу взять». Каждый записывает: «беру выходной». Результат: на смене ноль врачей.

В e-commerce та же история — «два кассира продают последний товар». На складе один iPhone. Два кассира одновременно открывают заказ:

Write skew: «два кассира — один iPhone»

A и B читают in_stock=1, проверяют «есть в наличии», создают заказ. В итоге продали один товар дважды.

Transaction A (кассир A)
t1BEGIN
t2SELECT in_stock FROM products WHERE id=1Видит in_stock=1
t3if (in_stock >= 1) — да
t5INSERT INTO orders (product_id=1, qty=1)
t6UPDATE products SET in_stock=in_stock-1 WHERE id=1in_stock стал 0
t7COMMIT
Transaction B (кассир B)
t2BEGIN
t3SELECT in_stock FROM products WHERE id=1Тоже видит in_stock=1 — параллельно с A
t4if (in_stock >= 1) — да
t8INSERT INTO orders (product_id=1, qty=1)
t9UPDATE products SET in_stock=in_stock-1 WHERE id=1in_stock стал -1!
t10COMMIT

В коде с 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 — для других сессий нет

PostgreSQL

И ещё показательный кейс — что произойдёт, если один и тот же товар попытаться продать дважды без блокировок:

Если CHECK есть, write skew всё равно ловится — потому что in_stock не может стать -1

PostgreSQL

Видишь, как 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 — потеряли инкремент.

Lost update: один инкремент исчез

Каждая транзакция использовала прочитанное значение для вычисления нового. Изменение, сделанное на втором commit'е, перезаписало первый — но без учёта первого.

Transaction A
t1SELECT views FROM page WHERE id=1видит 100
t3UPDATE page SET views=101 WHERE id=1на основе 100
t4COMMIT
Transaction B
t2SELECT views FROM page WHERE id=1тоже видит 100
t5UPDATE page SET views=101 WHERE id=1на основе своего 100
t6COMMITviews = 101 вместо 102; инкремент B потерян

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

PostgreSQL

Главное про аномалии

Запомни главное: аномалии не «бывают» или «не бывают». Они разрешены или запрещены уровнем изоляции, который выбрал ты. Каждый уровень — это компромисс: чем строже, тем меньше аномалий, но тем больше блокировок и retry.

В следующем уроке разберём четыре уровня изоляции — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE — и таблицу «какой уровень какие аномалии запрещает».

Как смягчать аномалии в коде

Уровень изоляции — не единственная защита. На практике ты комбинируешь:

  1. Уровень изоляции. READ COMMITTED для большинства операций, REPEATABLE READ для отчётов, SERIALIZABLE для критичных финансов.
  2. Ограничения схемы. CHECK, UNIQUE, FOREIGN KEY — последняя линия обороны, ловит то, что не поймала изоляция.
  3. Атомарные UPDATE. UPDATE x SET y = y + 1 вместо «SELECT y; if y < N then UPDATE y = y + 1». Меньше шагов = меньше окон для аномалий.
  4. Явные локи. SELECT FOR UPDATE — когда нужно «зарезервировать» строку между чтением и записью. См. урок 6.
  5. Optimistic locking. UPDATE x SET y = ?, version = version + 1 WHERE id = ? AND version = ? — если 0 строк обновлено, retry с новым значением.

В реальных бэкендах все пять подходов сосуществуют. Выбор — это часть архитектуры конкретного сервиса.

Проверка знанийKnowledge check
Чем write skew отличается от lost update? Это две стороны одной проблемы или разные?
ОтветAnswer
Это разные аномалии. Lost update — две транзакции читают одно и то же значение, обе делают UPDATE на основе прочитанного, и результат «затирает» друг друга. Пример: оба читают баланс=100, оба пишут balance=balance−10 (используя прочитанное), итог: 90 вместо 80. Lost update запрещён уже на REPEATABLE READ в PostgreSQL. Write skew — две транзакции читают одни данные, но пишут в РАЗНЫЕ строки или поля, и результат вместе нарушает инвариант, который каждая по отдельности уважала. Пример: два врача читают список дежурных, каждый записывает «беру выходной», итог: никого не осталось, хотя каждый проверил «есть ли ещё один на смене». Write skew запрещён только на SERIALIZABLE. Иными словами: lost update — про конфликт записи в одно поле. Write skew — про конфликт логики между разными полями/строками.
Снимок транзакции и почему dirty read невозможен в Postgres Write skew и deadlock — как связаны аномалия и дедлок

Чек-лист

  • Аномалии возникают только при параллельных транзакциях. В одиночной сессии их нет.
  • Dirty read — чтение незакоммиченного. В PostgreSQL никогда не происходит.
  • Non-repeatable read — одна строка меняется между двумя SELECT’ами. Запрещён на REPEATABLE READ.
  • Phantom read — набор строк меняется между двумя SELECT’ами. В PostgreSQL благодаря MVCC не происходит уже на REPEATABLE READ.
  • Write skew — две валидные по отдельности транзакции вместе ломают инвариант. Запрещён только на SERIALIZABLE.
  • Последняя линия защиты — CHECK, UNIQUE, FOREIGN KEY и явные блокировки. Полагаться только на уровень изоляции опасно.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какая аномалия возникает, когда транзакция A видит данные, изменённые транзакцией B, но B ещё не закоммитилась (и потом откатится)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6