В этой статье я собрал сводку базовых знаний об уровнях изолированности транзакций. Понятно, что для полного погружения нужно копать глубже, да и использование уровней отличается в зависимости от СУБД, но кажется, что для первого знакомства с изоляцией транзакций в БД этой статьи должно быть вполне достаточно. Если ваш опыт отличается от того, что описано в статье (а это вполне возможно), то приглашаю в комментарии :)


Уровень изоляции (изоляция или изолированность — это, кстати, буква «I» в аббревиатуре ACID — «Isolation») определяет, как транзакции могут взаимодействовать между собой, и насколько сильно могут пересекаться и мешать друг другу при параллельной работе. Иначе говоря, разные уровни изоляции допускают или не допускают разные аномалии при параллельной работе транзакций (про аномалии расскажем дальше).

Всего есть 4 основных уровня изоляции:

  • READ UNCOMMITED

  • READ COMMITTED

  • REPEATABLE READ

  • SERIALIZABLE

Давайте каждый из уровней разберём подробно:

READ UNCOMMITED

Это самый слабый уровень изоляции, когда транзакция может видеть результаты других транзакций, даже если они ещё не закоммичены.

Например, моя транзакция делает SELECT баланса из аккаунта 1, а вторая транзакция параллельно меняет этот баланс, но не коммитит изменения. Даже без коммита второй транзакции мой селект вернёт новый изменённый баланс. А если вторая транзакция откатит изменения и я опять сделаю SELECT к балансу, то я получу уже старый баланс. Это как раз аномалия, которая называется «грязное чтение» (Dirty Read) — когда данные, которые я прочитал, кто-то может откатить ещё до того, как я завершу свою транзакцию.

READ UNCOMMITED dirty reading.jpg
Грязное чтение в READ UNCOMMITED

Причём в этой ситуации моя первая транзакция прочитает баланс, и она явно как-то будет его использовать дальше в своей логике, а параллельно вторая транзакция, несмотря на это, сможет изменить баланс — получается, что я в своей транзакции использовал уже неактуальные данные — это аномалия «неповторяющееся чтение» (Non‑repeatable Read или Fuzzy Read) — когда данные, которые я прочитал, кто‑то может изменить ещё до того, как я завершу свою транзакцию. А называется эта аномалия так, потому что я могу дважды прочитать одни и те же строки в одной транзакции в разное время и получить разный результат, потому что кто-то параллельно изменил данные.

READ UNCOMMITED repeatable read.jpg
Неповторяющееся чтение в READ UNCOMMITED

Ну и в-третьих,может быть ситуация, когда я своей первой транзакции прочитал какие‑то строки из БД (например, выбрал все аккаунты с балансом = 0), а вторая транзакция параллельно добавила новый аккаунт, у которого нулевой баланс — получается, моя первая транзакция думает, что аккаунтов с нулевым балансом у нас, например, 10, а по факту их будет уже 11. Это аномалия «фантомное чтение» (Phantom Read) — когда ряд данных, которые я прочитал, кто‑то может изменить до того, как я завершу свою транзакцию.

READ UNCOMMITED phantom read.jpg
Фантомное чтение в READ UNCOMMITED

В общем, вот такой у нас уровень изоляции READ UNCOMMITED — по факту, тут изоляция отсутствует. Где‑нибудь в аналитике больших данных такой уровень может ещё использоваться, где нам не так важна точность данных, но это достаточно редко.

READ COMMITTED

На этом уровне транзакция может читать только те изменения в других параллельных транзакциях, которые уже были закоммичены. Это нас спасает от грязного чтения, но не спасает от неповторяющегося чтения и от фантомного чтения. Конечно, я теперь могу в своей транзакции прочитать баланс аккаунта, только который уже закоммичен, но ведь может возникнуть ситуация, когда параллельно другая транзакция меняет уже прочитанные мной данные и сразу коммитит, а моя транзакция всё ещё работает — получается, в своей транзакции я всё расчитываю исходя из одних данных (например, что баланс = 100), но пока моя транзакция выполнялась, баланс уже стал = 200.

READ COMMITTED dirty read.jpg
Отсутствие грязного чтения в READ COMMITED
READ COMMITTED repeatable read.jpg
Неповторяющееся чтение в READ COMMITED

От фантомного чтения этот уровень тоже не защищает ровно по тем же причинам - мы два раза в транзакции делаем один и тот же SELECT, но получаем в них разные результаты, потому что кто-то между моими селектами изменил набор нужных мне строк.

READ COMMITTED phantom read.jpg
Фантомное чтение в READ COMMITED

Такой уровень по умолчанию используется, например, в PostgreSQL, MS SQL и Oracle (если ничего не поменялось с момента написания статьи).

REPEATABLE READ

Этот уровень означает, что пока транзакция не завершится, никто параллельно не может изменять или удалять строки, которые транзакция уже прочитала. Т. е. данные, которые я прочитал своей транзакцией точно

Это нас спасает и от грязного чтения, и от неповторяющегося чтения, но всё ещё мы не решаем проблему фантомного чтения. Почему? Конечно, строки, которые я уже прочитал в транзакции, никто не изменит, но никто не мешает добавлять новые строки параллельно - их мы ещё не читали, создавать их ничего не мешает.

REPEATABLE READ non repeatable read.jpg
Отсутствие неповторяющегося чтения в REPEATABLE READ
REPEATABLE READ phantom read.jpg
Фантомное чтение в REPEATABLE READ

Уровень REPEATABLE READ используется по умолчанию в MySQL. И, кстати, в InnoDB (движок для хранения данных в MySQL) с уровнем изоляции REPEATABLE READ даже фантомное чтение не страшно. Почему? Там есть штука, которая называется MVCC — о ней мы чуть позже поговорим.

SERIALIZABLE

Самый жёсткий, но самый тяжёлый для БД и медленный для обработки запросов уровень. Он блокирует любые действия, пока запущена транзакция — получается, транзакции идут строго одна за другой и максимально изолируются друг от друга. Это достигается с помощью блокировки всей таблицы от любых взаимодействий с ней, но некоторые СУБД делают менее радикально — блокируют только те строки, которые задействует текущая транзакция.

Тут никакие аномалии нам не страшны — ни грязное, ни неповторяемое, ни фантомное чтение. Но и производительность тут страдает, ведь транзакции не могут работать параллельно.

SERIALIZABLE non repeatable read.jpg
Отсутствие неповторяющегося чтения в SERIALIZABLE
SERIALIZABLE phantom read.jpg
Отсутствие фантомного чтения в SERIALIZABLE

Это 4 основных уровня изоляции. Есть ещё несколько уровней не таких популярных, но об этом мы поговорим ниже.

Наглядная таблица, которой показано, какие аномалии страшны на каких уровнях изолированности (не забываем, что в зависимости от СУБД могут быть нюансы):

В итоге, зачем нам все эти уровни? Почему не обойтись каким‑нибудь самым безопасным SERIALIZABLE, который не допускает никаких аномалий, или каким‑нибудь среднячком READ COMMITED? Потому что в реальной жизни бизнесовые задачи бывают совсем разные: где‑то нам важно сохранять максимальную целостность данных и изолировать транзакции так, чтобы они не пересекались друг с другом, т. к. при конфликте или ошибке цена будет велика, а где‑то мы можем смириться с какими‑то кейсами нестыковок в данных, но зато у нас будет производительность — мы сможем запускать больше транзакций одновременно.

Что касается аномалий, то в примерах мы рассмотрели основные 3 аномалии:

  • Грязное чтение (Dirty Read)

  • Неповторяющееся чтение (Non-repeatable Read)

  • Фантомное чтение (Phantom Read).

Но также на уровнях изоляции READ UNCOMMITTED и READ COMMITTED могут ещё возникнуть такие аномалии, как:

  • Потерянное обновление (Lost Update) — когда две транзакции одновременно читают и изменяют одни и те же данные, и при этом одно из изменений может потеряться.

  • Неупорядоченное чтение (Out‑of‑order Read) — когда несколько чтений выполняются в произвольном порядке, что может привести к неправильным результатам в транзакциях.

Другие уровни изоляции

Кроме стандартных перечисленных уровней изоляции есть ещё специфичные уровни READ STABILITY и CURSOR STABILITY, которые используются в Db2 (СУБД от IBM) - они предлагают уже более тонкую настройку изоляции.

CURSOR STABILITY (CS) блокирует текущую строку, прочитанную через некий курсор (курсор мы специально ставим в запросе, и он будет, например, ходить по каждой строке нашего запроса по порядку). С уровнем CURSOR STABILITY мы блокируем только ту строку, на которой сейчас находится курсор.

READ STABILITY (RS) обеспечивает ситуацию, когда данные, прочитанные одной транзакцией, не будут изменены другой транзакцией до завершения первой транзакции. Т. е. тут нет проблемы неповторяющегося чтения, но могут быть фантомные чтения.

Тут углубляться не будем — эти уровни достаточно специфичные и актуальны именно для Db2 (по крайней мере, я их не встречал в других СУБД).

В SQL Server есть ещё SNAPSHOT ISOLATION (многоверсионная изоляция), которая позволяет транзакциям видеть снимок базы данных на момент начала транзакциии, устраняя тем самым фантомное чтение и неповторяющееся чтение и не блокирует данные при их чтении, что обеспечивает параллелизм (одновременное чтение одних и тех же данных разными транзакциями) - здесь, опять же, вспоминаем про MVCC, на основе которого работает данный уровень (ещё чуть-чуть и мы до него доберёмся). Но SNAPSHOT ISOLATION не гарантирует полную сериализуемость, т. к. возможны конфликты при записи, когда две транзакции пытаются изменить одну и ту же запись (тогда одна из операций упадёт в ошибку).
Нечто похожее есть в PostgreSQL - SERIALIZABLE SNAPSHOT ISOLATION (SSI), который тоже работает с версиями базы и использует технологию MVCC, из-за чего самый жёсткий и "непараллельный" классический уровень SERIALIZABLE становится намного более удобным, т. к. SSI эффективно работает с конфликтами, а не жёстко блокирует диапазоны.

Что такое MVCC

Уже несколько раз мы упоминали MVCC, который в InnoDB не допускает фантомное чтение уже на уровне REPEATABLE READ, а в SQL Server и PostgreSQL позволяет отдельным уровням изоляции эффективно работать с параллельными запросами. Так что это за зверь?

MVCC (Multiversion Concurrency Control) — это метод управления конкурентным доступом к данным в БД, который позволяет нескольким транзакциям работать с данными одновременно без конфликтов. MVCC поддерживает высокую производительность и изоляцию транзакций, минимизируя блокировки и улучшая параллелизм.

Но как он это делает?

  • Многоверсионность:

    MVCC использует механизм многоверсионности для управления изменениями данных. Вместо того чтобы изменять данные в месте их хранения, MVCC создает новые версии данных. Каждая транзакция видит данные в том состоянии, в котором они были на момент начала транзакции, а не в текущем состоянии.

    При записи данных создается новая версия строки. Новая версия включает информацию о том, какая транзакция создала ее, и становится видимой для транзакций, которые начинаются после ее создания.

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

    Время от времени СУБД очищает старые версии данных, которые больше не нужны (т. е. которые уже не используются ни одной транзакцией). Это называется сбором мусора и помогает поддерживать эффективность системы.

  • Изоляция транзакций:

    Транзакции видят только те данные, которые были зафиксированы до их начала. Это предотвращает грязное чтение, неповторяющееся чтение и фантомное чтение.

  • Отсутствие блокировок для чтения:

    Чтение данных не блокирует запись, а запись не блокирует чтение. Это повышает производительность системы, поскольку транзакции могут работать с данными параллельно.

  • Управление конфликтами:

    Конфликты между транзакциями, например, две транзакции, пытающиеся изменить одну и ту же строку, решаются при коммите транзакций. Если одна из транзакций не может быть закоммичена из‑за конфликта, её можно откатить.

    В итоге, MVCC — это такая штука, которая позволяет каждой транзакции работать с её собственной версией базы (грубо говоря), не влияя на остальные транзакции и не блокируя данные. Важный момент - MVCC реализован в разных СУБД, как мы уже говорили, но в каждой из них имеет свою форму и специфику, поэтому лучше смотреть на конкретно ваш кейс с вашей СУБД, чтобы понять нюансы работы.

Как можно переключаться между уровнями?

Конкретный уровень изоляции можно подключать с помощью команды SET ISOLATION LEVEL, и можно также назначить, на какие транзакции будет действовать подключаемый уровень.

На примере подключения уровня REPEATABLE READ в MySQL (в других СУБД команды могут отличаться):

	--Будет действовать на 1 следующую транзакцию в текущей сессии
	SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
	
	--Будет действовать на все транзакции в текущей сессии
	SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
	--Вариант включения уровня изоляции в PostgreSQL
	SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
	
	--Будет действовать на все транзакции
	SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

А если столкнуть две транзакции с разными уровнями изоляции?

Если параллельно работают несколько транзакций с разными уровнями изоляции, то могут возникать проблемы с видимостью данных и появляться наши любимые аномалии. Опять же, тут всё зависит от вашей СУБД, но вот несколько базовых понятий:

  • Транзакция с высоким уровнем изоляции (например, SERIALIZABLE) блокирует ресурсы более строго, что может привести к блокировкам для других транзакций с более низким уровнем изоляции (например, READ UNCOMMITTED или READ COMMITTED);

  • Если транзакция с более низким уровнем изоляции пытается получить доступ к данным, которые заблокированы более изолированной транзакцией, она будет ждать, пока та не завершится (или не будет отменена);

  • Транзакция с низким уровнем изоляции (например, READ UNCOMMITTED) может видеть незавершенные изменения других транзакций и не блокирует их, что может привести к ситуациям, когда она читает данные, которые в итоге могут быть отменены в более изолированной транзакции.

Комментарии (8)


  1. Finesse
    24.09.2024 02:11

    Вы пишите, что аномалия «потерянное обновление» возможна только на уровнях изоляции READ UNCOMMITTED и READ COMMITTED. Также вы пишите, что на уровне изоляции REPEATABLE READ никто параллельно не может изменять или удалять строки, которые транзакция уже прочитала. Значит ли это, что REPEATABLE READ запрещает не только запись, но и чтение? Иначе я не понимаю, как этот уровень защищает от такого состояния гонки:

    -- Транзакция 1
    START TRANSACTION;
    SELECT balance FROM users WHERE id = 1;
    -- Возвращает 100
    
    -- Транзакция 2 (исполняется параллельно другим процессом)
    START TRANSACTION;
    SELECT balance FROM users WHERE id = 1;
    -- Возвращает 100
    
    -- Транзакция 1
    UPDATE users SET balance = 100+10 WHERE id = 1;
    COMMIT;
    -- Пополнение баланса потеряется из-за следующего выражения
    
    -- Транзакция 2
    UPDATE users SET balance = 100+15 WHERE id = 1;
    COMMIT;

    Понятно, что конкретно этот пример можно исправить, написав атомарное изменение баланса за 1 запрос. Но мне интересна именно такая форма транзакции, чтобы лучше понять, как работает изоляция.

    P.S. Понравилось, как вы демонстрируете параллельные транзакции в статье.


    1. qrKot
      24.09.2024 02:11

      Вторая транзакция заблокируется на `SELECT balance...`, пока первая не сделает commit/rollback.
      REPEATABLE READ блокирует любой доступ к строке, пока транзакция лок не отпустит.
      То, что у вас описано (`пополнение баланса потеряется из-за следующего выражения`) - это READ COMMITED


      1. Finesse
        24.09.2024 02:11

        Значит, REPEATABLE READ запрещает не только запись, но и чтение. То есть в статье недоработка. Спасибо.


        1. qrKot
          24.09.2024 02:11

          ну, там же вариантов, собственно, как вы заметили, не особенно много. Либо блочить строку целиком, либо ронять одну из транзакций. Вариант, когда каждая вторая транзакция фейлится - ну, такой себе. Поэтому просто блочат и запись, и чтение.

          В случае с serializable происходит ровно то же самое, но на уровне таблицы целиком. Т.е. там вся таблица блочится, пока транзакция открыта.


  1. Akina
    24.09.2024 02:11
    +4

    Скриншоты весьма плохо читаемы. Гораздо разумнее отформатировать текст в две колонки. Ну или хотя бы сделать скрины чёрным по светло-серому.

    Также считаю совершенно необходимым дать подготовительный код, который позволит читателю сразу начать воспроизведение показанных примеров (т.е. CREATE TABLE и начальный INSERT INTO). И укажите точно, какую СУБД (включая версию, хотя бы мажор) и даже какого клиента вы используете.

    Показывая примеры на скриншотах, добавьте в начало операторы, которые явно устанавливают указанный уровень изоляции.

    А если вторая транзакция откатит изменения и я опять сделаю SELECT к балансу, то я получу уже старый баланс.

    А почему ЭТО не показано на скриншотах?

    И очень хочется спросить - почему абсолютно все, и вы в том числе, рассматриваете только случаи, когда уровень изоляции транзакций обоих клиентов совпадает? У вас выпадает, по моему мнению, достаточно важный кусок понимания...


  1. amazingname
    24.09.2024 02:11

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


    1. ListenIT Автор
      24.09.2024 02:11

      Спасибо, добавил раздел про параллельную работу транзакций с разными уровнями (хотя бы пару тезисов, о которых известно).


  1. netch80
    24.09.2024 02:11

    Упоминание уровней изоляции согласно стандарту SQL без учёта дырявости их концепции, открытом ещё в 90-х, резко неполно.

    https://www.cockroachlabs.com/docs/stable/demo-serializable.html
    http://justinjaffray.com/what-does-write-skew-look-like/
    https://habr.com/ru/articles/705332/
    https://habr.com/ru/company/otus/blog/501294/
    https://habr.com/ru/articles/745948/
    и прочая.

    По сравнению с этим, данная статья всё-таки слишком примитивна.