Проблемы параллелизма и уровни изоляции

Когда транзакции выполняются одновременно, появляются тонкие ошибки чтения. Разберём их и уровни изоляции, которые ими управляют.

Уровень изоляции — это компромисс между корректностью и производительностью: он определяет, какие аномалии параллельного доступа транзакция допускает ради большей пропускной способности.

Зачем нужны уровни изоляции

Идеальная изоляция (каждая транзакция как будто одна в системе) требует строгой синхронизации и резко снижает пропускную способность. На практике сотни транзакций идут одновременно, и платить за идеал готовы не всегда. Поэтому SQL-стандарт вводит несколько уровней изоляции: чем слабее уровень, тем выше параллелизм, но тем больше аномалий он допускает. Чтобы выбирать уровень осознанно, нужно знать сами аномалии.

Зачем вообще параллелизм

Можно спросить: если параллельные транзакции порождают аномалии, почему бы не выполнять их строго по очереди? Ответ — производительность и отзывчивость. Транзакция много времени проводит в ожидании: чтения с диска, сетевые задержки, раздумья пользователя. Если выполнять их строго последовательно, процессор и диск будут простаивать, пока одна транзакция ждёт, а тысячи других стоят в очереди. Параллелизм позволяет, пока одна транзакция ждёт диск, продвигать другую — и пропускная способность вырастает на порядки. Современная СУБД обслуживает тысячи одновременных транзакций именно благодаря чередованию их операций. Но за это чередование мы и платим аномалиями: операции разных транзакций перемешиваются, и одна может «подсмотреть» промежуточное состояние другой. Вся теория изоляции — это поиск компромисса: получить выгоду параллелизма, но удержать корректность хотя бы на приемлемом уровне. Так что аномалии — не дефект, а обратная сторона необходимой нам скорости.

Три классические аномалии

Грязное чтение (dirty read)

Транзакция T2 читает данные, изменённые транзакцией T1, которая ещё не сделала COMMIT. Если T1 затем откатится (ROLLBACK), T2 прочитала значение, которого никогда «официально» не было. Пример: T1 начисляет бонус, T2 видит новый баланс и принимает решение, а потом T1 откатывается — решение принято по фантомным данным.

Неповторяемое чтение (non-repeatable read)

Транзакция T1 читает строку дважды и получает разные значения, потому что между чтениями другая транзакция T2 изменила эту строку и зафиксировала изменение. Одна и та же строка «поменялась под ногами» в рамках одной транзакции. Пример: T1 проверила баланс (1000), а перед списанием перечитала — уже 200, потому что T2 успела снять деньги.

Фантомное чтение (phantom read)

T1 дважды выполняет один и тот же запрос с условием (например, «все заказы дороже 1000»), и во второй раз появляются новые строки («фантомы»), которые добавила и зафиксировала T2. Отличие от неповторяемого чтения: меняется не значение существующей строки, а множество строк, удовлетворяющих условию.

Почему фантомы — отдельный, более коварный случай? Потому что защититься от них блокировкой существующих строк нельзя: фантомная строка на момент первого чтения ещё не существует, её попросту нечего блокировать. Чтобы их предотвратить, нужно заблокировать сам диапазон условия (например, «все заказы с суммой > 1000»), не пуская туда вставки, — это так называемые блокировки диапазона. Именно поэтому в классической классификации фантомы стоят особняком и устраняются лишь на самом строгом уровне Serializable: предотвратить появление того, чего пока нет, сложнее, чем защитить уже существующее.

Четыре уровня изоляции

SQL-стандарт определяет четыре уровня по возрастанию строгости. Каждый запрещает всё больше аномалий.

УровеньГрязное чтениеНеповторяемоеФантомы
Read Uncommittedвозможновозможновозможно
Read Committedнетвозможновозможно
Repeatable Readнетнетвозможно
Serializableнетнетнет
  • Read Uncommitted — самый слабый: можно читать незафиксированные данные. Максимальный параллелизм, но и все аномалии. Применяют редко.
  • Read Committed — читаются только зафиксированные данные; грязное чтение исключено. Очень распространён (например, уровень по умолчанию в PostgreSQL).
  • Repeatable Read — повторное чтение той же строки даёт тот же результат; неповторяемое чтение исключено. Фантомы стандарт ещё допускает (хотя многие СУБД на этом уровне их тоже устраняют).
  • Serializable — самый строгий: результат эквивалентен какому-то последовательному выполнению транзакций. Никаких аномалий, но наибольшая цена в производительности.

Аномалии — это нарушения изоляции

Полезно увидеть единую природу трёх аномалий: все они — следствия неполной изоляции. В идеально изолированном мире (как будто транзакции выполняются по очереди) ни одна из них невозможна — некому вклиниться между операциями. Аномалии появляются именно потому, что ради параллелизма мы разрешаем транзакциям чередовать операции. Грязное чтение — увидели чужую незафиксированную запись. Неповторяемое чтение — между двумя нашими чтениями чужая транзакция зафиксировала изменение строки. Фантом — чужая транзакция зафиксировала новую строку, попавшую под наше условие. Заметьте градацию: грязное чтение — самое грубое (читаем то, что вообще может откатиться); неповторяемое и фантомное — тоньше (читаем уже зафиксированное, но в неудачный момент). Эта градация прямо соответствует уровням изоляции: чем выше уровень, тем более тонкую аномалию он перекрывает. Понимание, что аномалия — это «утечка» чужой транзакции в нашу, помогает выбрать ровно тот уровень, который нужную утечку закрывает.

Иллюстрация на однопоточной песочнице

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

CREATE TABLE stock (tovar TEXT PRIMARY KEY, qty INTEGER);
INSERT INTO stock VALUES ('Книга', 5);

BEGIN TRANSACTION;
  UPDATE stock SET qty = qty - 2 WHERE tovar = 'Книга' AND qty >= 2;
COMMIT;

SELECT tovar, qty FROM stock;

Вывод: «Книга — 3». Условие qty >= 2 в самом UPDATE защищает от ухода в минус: если бы две транзакции пытались списать одновременно, на уровне Serializable СУБД упорядочила бы их, и вторая увидела бы уже уменьшенный остаток. Перенос проверки в условие обновления — приём, снижающий зависимость от уровня изоляции.

Цена строгости: меньше параллелизма

Почему бы просто не включить Serializable везде и не думать об аномалиях? Потому что у изоляции есть прямая цена — пропускная способность. Чем строже уровень, тем больше транзакциям приходится ждать друг друга или откатываться при конфликтах. На Serializable система обязана гарантировать, что результат эквивалентен последовательному выполнению, а это значит: конфликтующие транзакции либо выстраиваются в очередь (через блокировки), либо одна из них принудительно откатывается и повторяется. На высоконагруженной системе с тысячами транзакций в секунду это превращается в заметное падение производительности и рост числа повторов. Read Committed, наоборот, пропускает больше параллелизма ценой допущения неповторяемых чтений. Поэтому выбор уровня — это всегда торговля «корректность против скорости», и универсального ответа нет: для платёжного шлюза дороже ошибка, для ленты новостей — задержка. Инженерное решение принимают, взвешивая обе чаши.

Как выбирать уровень

Правило простое: берите самый слабый уровень, на котором ваша логика остаётся корректной. Для отчётов и аналитики часто хватает Read Committed. Для денежных операций, где важна точность повторных чтений и отсутствие фантомов, поднимают уровень до Repeatable Read или Serializable, соглашаясь на меньшую пропускную способность. Слепо ставить Serializable везде — значит без нужды резать производительность; ставить Read Uncommitted ради скорости — почти всегда ошибка.

Типичные ошибки

  • Путают неповторяемое и фантомное чтение. Первое — изменилось значение существующей строки; второе — изменился набор строк под условие.
  • Считают Read Committed достаточным для всего. Он не спасает от неповторяемого чтения — для точных повторных чтений нужен уровень выше.
  • Ставят Serializable «на всякий случай». Это без нужды снижает параллелизм и повышает число откатов по конфликтам.
  • Игнорируют изоляцию вовсе. При высокой нагрузке аномалии параллелизма приводят к реальной порче данных.

Итог

  • Параллельные транзакции порождают аномалии: грязное чтение, неповторяемое чтение, фантомы.
  • Четыре уровня изоляции (Read Uncommitted → Read Committed → Repeatable Read → Serializable) поэтапно их устраняют.
  • Чем строже уровень, тем меньше аномалий, но ниже параллелизм — это компромисс.
  • Выбирайте самый слабый уровень, на котором логика остаётся корректной.
Проверьте себя
1. Что такое грязное чтение (dirty read)?
AЧтение строки дважды с разным результатом
BЧтение данных, изменённых другой ещё не зафиксированной транзакцией
CПоявление новых строк под условие запроса
DЧтение из повреждённого файла
2. Чем фантомное чтение отличается от неповторяемого?
AНичем
BПри фантомном меняется набор строк под условие, при неповторяемом — значение существующей строки
CФантомное возможно только на Serializable
DНеповторяемое чтение опаснее
3. Какой уровень изоляции исключает все три классические аномалии?
ARead Uncommitted
BRead Committed
CRepeatable Read
DSerializable
Поддержать проект