Представьте: в базе данных куча процессов работает одновременно — кто‑то переводит деньги, кто‑то считает итоги, третий что‑то обновляет. Если не контролировать эти процессы, то вылезут проблемы: один увидит ещё не подтверждённые данные, другой перезапишет чужие изменения и т.п. Для борьбы с этими проблемами и нужны уровни изоляции транзакций: они задают правила — когда блокировать данные, когда разрешать чтение, а когда лучше подождать.
Что такое транзакция и зачем она нужна
Для начала определимся что такое транзакция. Очень часто действие не ограничивается одним запросом. Например, операция перевода денег это как минимум два запроса: надо снять деньги у одного клиента и добавить их другому. Если сделать это двумя разными запросами мы можем получить ситуацию (если произойдёт сбой после первого запроса) когда у первого клиента деньги снимутся, а у второго не добавятся. Транзакция представляет собой группу операций с базой данных, которая воспринимается системой как единое целое, она должна либо выполниться целиком, либо не выполниться вообще.
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. Например, клиент покупает товар, а менеджер в это же время делает отчёт для закупки товаров
которые закончились:
- Транзакция клиента снимает последний товар "good" с остатка
- Транзакция менеджера в это время получает что товара "good" нет на остатках и его надо закупить
- Транзакция клиента по какой-то причине откатывается через
ROLLBACK
В итоге менеджер опираясь на неподтверждённые данные закажет товар который ещё есть.
2. Неповторяемое чтение (Non‑repeatable Read)
В рамках одной транзакции при повторном чтении одних и тех же данных можно получить разные результаты — потому что другая транзакция успела изменить эти данные и зафиксировать изменения. Допустим у клиента 300 денег на счету. Он хочет перевести 250 из них на другой счёт. Одновременно с этим проходит списание за подписку 200 денег.
- Транзакция перевода проверяет есть ли на счету 250 денег для списания. Они есть транзакция идёт дальше
- Транзакция подписки списывает со счёта 200 денег
- Транзакция подписки завершается. На счету остаётся 100
- Транзакция перевода пытается снять 250
После этих действий в лучшем случае Транзакция перевода сломается из-за недостаточного количества денег, хотя до этого проверка прошла успешно. А если такое не предусмотрено, то перевод осуществится и на счету будет отрицательный баланс, чего быть не должно.
3. Фантомное чтение (Phantom Read)
Немного похоже на Неповторяемое чтение, но в этом случае между двумя одинаковыми запросами в выборку попадают новые строки, которых раньше не было. Т.е. не меняются существующие записи, а появляются новые. К примеру менеджер строит отчёт о сумме заказов за сегодняшний день (предположим их три на 1000, 1500 и 500 денег), и тут же клиент делает новый заказ (на 300):
- Транзакция отчёта получает все имеющиеся за сегодня заказы для получения по ним информации
- Транзакция заказа создаёт новый заказ
- Транзакция отчёта получает сумму по всем совершённым сегодня заказам
В итоге менеджер получит отчёт в котором три заказа (на 1000, 1500 и 500), а в итоговой сумме будет 3300.
4. Потерянное обновление (Lost Update)
Когда каждая из нескольких транзакций делает своё обновление одних и тех же данных, в результате мы получим что применятся изменения только одной транзакции. Например, два менеджера одновременно повышают цену товара. Один добавляет 500, а второй повышает на 10%.
- Транзакции обоих менеджеров получили текущую стоимость товара (допустим 1000)
- Транзакция первого менеджера добавляет 500 и сохраняет цену 1500
- Транзакция второго менеджера увеличивает 1000 на 10% и сохраняет цену 1100
В итоге цена товара будет 1100 т.к. транзакция второго менеджера завершилась последней, а результат работы первого менеджера просто исчезнет.
5. Аномалия write skew
Это ошибка из-за того что несколько транзакций делают одинаковые выводы на основе одной и той же информации, но при этом не учитывают действий друг друга.
Объяснить это поможет "парадокс врачей". В больнице есть правило: всегда должен быть хотя бы один дежурный врач. В момент времени дежурят два врача и оба решают отойти на перерыв. Для этого они должны зайти в систему, проверить есть ли второй дежурный и отметить что уходят.
- Две транзакции одновременно совершают проверку и получают что сейчас дежурят 2 врача
- Обе позволяют снять дежурство
- Каждая обновляет свою запись и завершается
В итоге в больнице не остаётся ни одного дежурного, хотя каждая транзакция по отдельности действовала корректно. Такое возможно, потому что обе транзакции работали с одним и тем же снимком данных.
Для борьбы с вышеперечисленными проблемами как раз и нужны уровни изоляции транзакций. Изоляция помогает упорядочить доступ — но, чем строже изоляция, тем выше нагрузка и ниже производительность, поэтому к выбору уровня надо подходить с умом.
Уровни изоляции транзакций
В SQL стандарте есть четыре уровня изоляции:
1. 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 — каждый запрос внутри транзакции работает со своим снимком данных. При этом блокировки накладываются только на строки, которые изменяются, это позволяет снизить нагрузку.
- Допускает: Неповторяемое чтение и Фантомное чтение
- Предотвращает: Грязное чтение и Потерянное обновление (в простых сценариях с прямым
UPDATEPostgreSQL сам предотвращает конфликт за счёт блокировок строк, но если сначала читать данные, а потом обновлять их без явных блокировок — потерянное обновление всё ещё возможно) - Использование: подходит для большинства приложений, где важна производительность и допустимы небольшие расхождения при чтении данных
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
3. REPEATABLE READ
Этот уровень изоляции гарантирует, что все чтения внутри транзакции будут работать с одним и тем же снимком данных.
То есть, если дважды выполнить один и тот же SELECT, результат будет одинаковым — даже если другие
транзакции что-то параллельно изменяют.
В отличие от READ COMMITTED, здесь снимок данных создаётся один раз — в момент начала транзакции — и
используется до её завершения. Благодаря этому исчезают проблемы с "плавающими" данными при повторных чтениях.
В PostgreSQL этот уровень реализован через MVCC и фактически соответствует snapshot isolation. Это значит, что классического фантомного чтения (когда между запросами появляются новые строки) здесь не возникает — транзакция просто не видит изменения, сделанные после её старта.
Однако это не самый строгий уровень изоляции: в некоторых сценариях возможны аномалии, например write skew, когда несколько транзакций принимают решения на основе одного и того же снимка данных и в итоге нарушают бизнес-логику. В таких случаях PostgreSQL может завершить одну из транзакций с ошибкой сериализации.
- Допускает: некоторые аномалии (например, write skew)
- Предотвращает: Грязное чтение и Неповторяемое чтение
- Использование: В случаях когда нужно получать стабильные данные в рамках транзакции, отчёты и аналитика
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
4. SERIALIZABLE
Самый строгий уровень изоляции. Даже параллельные транзакции выполняются так, как если бы они были запущены последовательно.
- Допускает: этот уровень не допускает аномалии, в случае их возникновения будет происходить
откат (
ROLLBACK) - Предотвращает: Всё (Грязное чтение, Неповторяемое чтение, Фантомное чтение и Потерянное обновление)
- Использование: Там где важна целостность данных, финансовые операции и важные бизнес-процессы
Во время выполнения транзакции с уровнем изоляции SERIALIZABLE может возникнуть ошибка сериализации,
тогда транзакция будет прервана с ошибкой:
ERROR: could not serialize access due to concurrent update
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;