Isolation levels в PostgreSQL простыми словами

Представьте: в базе данных куча процессов работает одновременно — кто‑то переводит деньги, кто‑то считает итоги, третий что‑то обновляет. Если не контролировать эти процессы, то вылезут проблемы: один увидит ещё не подтверждённые данные, другой перезапишет чужие изменения и т.п. Для борьбы с этими проблемами и нужны уровни изоляции транзакций: они задают правила — когда блокировать данные, когда разрешать чтение, а когда лучше подождать.

Что такое транзакция и зачем она нужна

Для начала определимся что такое транзакция. Очень часто действие не ограничивается одним запросом. Например, операция перевода денег это как минимум два запроса: надо снять деньги у одного клиента и добавить их другому. Если сделать это двумя разными запросами мы можем получить ситуацию (если произойдёт сбой после первого запроса) когда у первого клиента деньги снимутся, а у второго не добавятся. Транзакция представляет собой группу операций с базой данных, которая воспринимается системой как единое целое, она должна либо выполниться целиком, либо не выполниться вообще.

Транзакция:

BEGIN;
UPDATE account
    SET money = money - 1000
WHERE id_client = 888; -- снимаем деньги

UPDATE account
    SET money = money + 1000
WHERE id_client = 999; -- добавляем деньги
COMMIT;

В реальном приложении сотни, а то и тысячи пользователей работают с базой данных одновременно. Каждый запускает свои транзакции — кто‑то читает данные, кто‑то их меняет. Если не контролировать этот процесс, начнутся проблемы:

  • Один пользователь может увидеть промежуточные, незавершённые данные другого (и принять их за истину)
  • Два человека могут одновременно обновить одни и те же данные — и одно из изменений потеряется
  • При повторном чтении в рамках одной операции данные могут внезапно измениться

За надёжность транзакций отвечают четыре принципа, известные как ACID:

  • Атомарность (Atomicity). Либо всё, либо ничего. Допустим, вы переводите деньги с одного счёта на другой — если на каком‑то этапе что‑то пошло не так (например, не хватило средств), система откатит все изменения. Счёт отправителя не уменьшится, счёт получателя не увеличится. Как будто ничего и не было.
  • Согласованность (Consistency). После завершения транзакции база данных должна остаться в корректном состоянии. То есть не может быть ситуации, когда деньги «испарились» или появились из ниоткуда — сумма на всех счетах должна сохраняться.
  • Изоляция (Isolation). Транзакции разных пользователей не должны мешать друг другу. Если два человека одновременно попытаются снять последние деньги с общего счёта, без изоляции оба могут увидеть, что деньги есть, и попытаться их снять — в итоге получится неприятная ситуация. Изоляция как раз и нужна, чтобы такого не случалось.
  • Долговечность (Durability). Если система сказала «транзакция завершена», то данные уже никуда не денутся — даже если сразу после этого случится сбой питания. Изменения записаны и их не потерять.

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

Проблемы параллельного выполнения транзакций

Если несколько транзакций одновременно работают с одними и теми же данными, результат может быть непредсказуемым. Чаще всего выделяют следующие проблемы:

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

Транзакция может прочитать данные, изменённые другой транзакцией, но ещё не зафиксированные через COMMIT. Например, клиент покупает товар, а менеджер в это же время делает отчёт для закупки товаров которые закончились:

  1. Транзакция клиента снимает последний товар "good" с остатка
  2. Транзакция менеджера в это время получает что товара "good" нет на остатках и его надо закупить
  3. Транзакция клиента по какой-то причине откатывается через ROLLBACK

В итоге менеджер опираясь на неподтверждённые данные закажет товар который ещё есть.

2. Неповторяемое чтение (Non‑repeatable Read)

В рамках одной транзакции при повторном чтении одних и тех же данных можно получить разные результаты — потому что другая транзакция успела изменить эти данные и зафиксировать изменения. Допустим у клиента 300 денег на счету. Он хочет перевести 250 из них на другой счёт. Одновременно с этим проходит списание за подписку 200 денег.

  1. Транзакция перевода проверяет есть ли на счету 250 денег для списания. Они есть транзакция идёт дальше
  2. Транзакция подписки списывает со счёта 200 денег
  3. Транзакция подписки завершается. На счету остаётся 100
  4. Транзакция перевода пытается снять 250

После этих действий в лучшем случае Транзакция перевода сломается из-за недостаточного количества денег, хотя до этого проверка прошла успешно. А если такое не предусмотрено, то перевод осуществится и на счету будет отрицательный баланс, чего быть не должно.

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

Немного похоже на Неповторяемое чтение, но в этом случае между двумя одинаковыми запросами в выборку попадают новые строки, которых раньше не было. Т.е. не меняются существующие записи, а появляются новые. К примеру менеджер строит отчёт о сумме заказов за сегодняшний день (предположим их три на 1000, 1500 и 500 денег), и тут же клиент делает новый заказ (на 300):

  1. Транзакция отчёта получает все имеющиеся за сегодня заказы для получения по ним информации
  2. Транзакция заказа создаёт новый заказ
  3. Транзакция отчёта получает сумму по всем совершённым сегодня заказам

В итоге менеджер получит отчёт в котором три заказа (на 1000, 1500 и 500), а в итоговой сумме будет 3300.

4. Потерянное обновление (Lost Update)

Когда каждая из нескольких транзакций делает своё обновление одних и тех же данных, в результате мы получим что применятся изменения только одной транзакции. Например, два менеджера одновременно повышают цену товара. Один добавляет 500, а второй повышает на 10%.

  1. Транзакции обоих менеджеров получили текущую стоимость товара (допустим 1000)
  2. Транзакция первого менеджера добавляет 500 и сохраняет цену 1500
  3. Транзакция второго менеджера увеличивает 1000 на 10% и сохраняет цену 1100

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

5. Аномалия write skew

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

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

  1. Две транзакции одновременно совершают проверку и получают что сейчас дежурят 2 врача
  2. Обе позволяют снять дежурство
  3. Каждая обновляет свою запись и завершается

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

Для борьбы с вышеперечисленными проблемами как раз и нужны уровни изоляции транзакций. Изоляция помогает упорядочить доступ — но, чем строже изоляция, тем выше нагрузка и ниже производительность, поэтому к выбору уровня надо подходить с умом.

Уровни изоляции транзакций

В SQL стандарте есть четыре уровня изоляции:

1. READ UNCOMMITTED

Команда установки READ UNCOMMITTED:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

Это низший уровень изоляции. Он допускает всё включая Грязное чтение (обратим внимание на название) и за счёт этого он самый производительный. Но в PostgreSQL есть приятная особенность — уровень READ UNCOMMITTED в ней работает так же как и следующий уровень READ COMMITTED

Разработчики PostgreSQL решили, что грязное чтение почти никогда не нужно, а вот риск получить некорректные данные — вполне реальная опасность. С помощью MVCC (Multi‑Version Concurrency Control) PostgreSQL делает невозможным грязное чтение и просто не даёт читать незавершённые изменения

2. READ COMMITTED

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

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

  • Допускает: Неповторяемое чтение и Фантомное чтение
  • Предотвращает: Грязное чтение и Потерянное обновление (в простых сценариях с прямым UPDATE PostgreSQL сам предотвращает конфликт за счёт блокировок строк, но если сначала читать данные, а потом обновлять их без явных блокировок — потерянное обновление всё ещё возможно)
  • Использование: подходит для большинства приложений, где важна производительность и допустимы небольшие расхождения при чтении данных
Команда установки READ COMMITTED:

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

3. REPEATABLE READ

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

В отличие от READ COMMITTED, здесь снимок данных создаётся один раз — в момент начала транзакции — и используется до её завершения. Благодаря этому исчезают проблемы с "плавающими" данными при повторных чтениях.

В PostgreSQL этот уровень реализован через MVCC и фактически соответствует snapshot isolation. Это значит, что классического фантомного чтения (когда между запросами появляются новые строки) здесь не возникает — транзакция просто не видит изменения, сделанные после её старта.

Однако это не самый строгий уровень изоляции: в некоторых сценариях возможны аномалии, например write skew, когда несколько транзакций принимают решения на основе одного и того же снимка данных и в итоге нарушают бизнес-логику. В таких случаях PostgreSQL может завершить одну из транзакций с ошибкой сериализации.

  • Допускает: некоторые аномалии (например, write skew)
  • Предотвращает: Грязное чтение и Неповторяемое чтение
  • Использование: В случаях когда нужно получать стабильные данные в рамках транзакции, отчёты и аналитика
Команда установки REPEATABLE READ:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

4. SERIALIZABLE

Самый строгий уровень изоляции. Даже параллельные транзакции выполняются так, как если бы они были запущены последовательно.

  • Допускает: этот уровень не допускает аномалии, в случае их возникновения будет происходить откат (ROLLBACK)
  • Предотвращает: Всё (Грязное чтение, Неповторяемое чтение, Фантомное чтение и Потерянное обновление)
  • Использование: Там где важна целостность данных, финансовые операции и важные бизнес-процессы

Во время выполнения транзакции с уровнем изоляции SERIALIZABLE может возникнуть ошибка сериализации, тогда транзакция будет прервана с ошибкой:


ERROR: could not serialize access due to concurrent update

Команда установки SERIALIZABLE:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

Если начать необдуманно проставлять везде SERIALIZABLE, то можно получить медленно работающее приложение которое постоянно сыпет ошибками сериализации. А если не заморачиваться с ними и оставить изоляцию по умолчанию, можно упустить ошибки с важными данными и заметно это будет не сразу. Работая с уровнями изоляции важно понимать что ты делаешь и зачем именно. Нужно уметь найти середину между целостностью данных и производительностью.

Несколько полезных команд:


SHOW transaction_isolation; -- Узнать текущий уровень изоляции

SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- установить уровень изоляции для сессии

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- установить уровень изоляции для текущей транзакции
...
COMMIT;