Webrium
Подписаться

Уровни изоляции: что выбрать и чем за это платят

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

Поле выбора между скоростью и корректностью транзакций, разделённое границейСкоростьКорректность

Что транзакция обещает на самом деле

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

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

Три аномалии, которые ломают ожидания

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

Грязное чтение

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

Неповторяемое чтение

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

Фантомы

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

Уровни изоляции: что они гарантируют и чем платят

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

Уровень изоляции Грязное чтение Неповторяемое чтение Фантомы
Read Uncommitted Да Да Да
Read Committed Нет Да Да
Repeatable Read Нет Нет Да
Serializable Нет Нет Нет

Read Uncommitted

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

Read Committed

По умолчанию в PostgreSQL. Грязное чтение запрещено, но неповторяемое и фантомы возможны. Это хороший баланс для большинства OLTP-задач: вы не увидите незафиксированных изменений, но если кто-то изменит данные между вашими чтениями, вы увидите новое значение. Например, если вы дважды читаете баланс счёта, и между чтениями кто-то перевёл деньги, второе чтение покажет обновлённый баланс.

Repeatable Read

Запрещает грязное и неповторяемое чтение, но фантомы возможны. PostgreSQL реализует его через снимки данных: каждая транзакция видит данные на момент своего старта. Это значит, что если вы начали транзакцию в Repeatable Read, вы будете видеть одни и те же данные, даже если другие транзакции их изменят. Но если другая транзакция добавит новые строки, которые удовлетворяют вашему запросу, вы их увидите — это и есть фантомы.

Serializable

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

Тупик: поднять уровень изоляции, чтобы «стало надёжнее»

Первое, что пробуют инженеры, когда сталкиваются с проблемами согласованности, — поднять уровень изоляции. Кажется логичным: если Read Committed допускает неповторяемое чтение, то Repeatable Read его запретит. Если Repeatable Read допускает фантомы, то Serializable их запретит. Но на практике это часто приводит к новым проблемам.

Почему это не работает

Поднятие уровня изоляции не решает проблему — оно меняет её форму. Например:

  • В Repeatable Read вы перестанете видеть неповторяемое чтение, но начнёте сталкиваться с ошибками сериализации. База будет откатывать транзакции, которые приводят к конфликтам, и приложение должно уметь их повторять.
  • В Serializable вы получите полную изоляцию, но производительность упадёт. База будет ставить больше блокировок, и параллелизм снизится. Кроме того, ошибки сериализации станут чаще, и приложение должно будет уметь их обрабатывать.

Что происходит на самом деле

Когда вы поднимаете уровень изоляции, база начинает строже следить за согласованностью. Но это не значит, что приложение становится «надёжнее». Наоборот, оно начинает ломаться по-новому:

  • Ошибки сериализации. В Serializable база откатывает транзакции, которые нарушают сериализуемость. Если приложение не умеет их повторять, оно будет падать.
  • Взаимные блокировки. Чем выше уровень изоляции, тем больше блокировок. Это увеличивает вероятность взаимных блокировок, когда две транзакции ждут друг друга.
  • Раздувание хранилища. В MVCC каждая транзакция видит данные на момент своего старта. Чем дольше транзакция работает, тем больше старых версий строк накапливается. Это приводит к разрастанию таблиц и индексов.

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

Что происходит под капотом: блокировки и версии строк

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

Блокировки

Когда транзакция изменяет данные, она ставит на них блокировку. Другие транзакции, которые пытаются изменить те же данные, ждут, пока блокировка не будет снята. Блокировки бывают разных типов:

  • Row-level locks: блокируют отдельные строки. Это самый распространённый тип блокировок, потому что он минимально снижает параллелизм.
  • Table-level locks: блокируют всю таблицу. Используются редко, потому что сильно снижают производительность.
  • Predicate locks: блокируют не конкретные строки, а условия запроса. Используются в Serializable для предотвращения фантомов. Например, если вы ищете все счета с нулевым балансом, predicate lock заблокирует добавление новых счетов с нулевым балансом.

Блокировки могут приводить к взаимным блокировкам (deadlocks). Например, транзакция А блокирует строку 1 и пытается заблокировать строку 2, а транзакция Б блокирует строку 2 и пытается заблокировать строку 1. База обнаруживает такую ситуацию и откатывает одну из транзакций.

Многоверсионность (MVCC)

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

В PostgreSQL MVCC реализован через систему снимков (snapshots). Каждая транзакция получает свой снимок данных на момент старта. Это значит, что если вы начали транзакцию в Repeatable Read, вы будете видеть одни и те же данные, даже если другие транзакции их изменят. Но если другая транзакция добавит новые строки, которые удовлетворяют вашему запросу, вы их увидите — это и есть фантомы.

Почему приложение должно уметь повторять транзакции

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

Как это работает

Представьте, что вы переводите деньги между счетами. Транзакция откатилась из-за взаимной блокировки. Если приложение просто покажет ошибку пользователю, он будет разочарован. Но если оно повторит транзакцию, перевод пройдёт успешно.

В PostgreSQL для этого есть механизм повторения транзакций (retry). Приложение должно:

  1. Поймать ошибку сериализации или взаимной блокировки.
  2. Подождать небольшое случайное время (чтобы избежать повторных конфликтов).
  3. Повторить транзакцию.

Это не значит, что транзакцию нужно повторять бесконечно. Обычно достаточно нескольких попыток. Если после этого ошибка не исчезла, значит, проблема серьёзнее, и нужно эскалировать её на уровень выше.

Почему это важно

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

Длинные транзакции: почему они опасны

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

Блокировки

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

Раздувание хранилища

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

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

Блокировка изменений схемы

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

Позиция редакции

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

  • Read Committed — хороший выбор для большинства OLTP-задач. Он обеспечивает достаточный уровень изоляции без серьёзных накладных расходов. Но он не спасает от неповторяемого чтения и фантомов.
  • Repeatable Read подходит, если вам нужно предотвратить неповторяемое чтение. Но помните, что он не спасает от фантомов и может приводить к ошибкам сериализации.
  • Serializable используйте только тогда, когда вам действительно нужна полная сериализуемость. И будьте готовы к тому, что приложение должно уметь повторять транзакции, а производительность снизится.

Главное — понимать, что каждый уровень изоляции решает конкретные проблемы и имеет свои ограничения. Поднимая уровень изоляции, вы не делаете базу «надёжнее» — вы меняете правила игры. И если ваше приложение не готово к этим правилам, оно начнёт ломаться по-новому.

Эта позиция неверна, если:

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

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

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

Медленный запрос в PostgreSQL: с чего начинать

Как диагностировать медленный запрос в PostgreSQL без лишних индексов — от статистики до финальной проверки.

Стык версий приложения и базы: совместимость vs конфликтСовместимая схемаНесовместимая схема
Практика

Миграции базы без простоя: порядок, который работает

Как перенести базу без остановки приложения: пошаговый порядок изменений, проверенные приёмы и ловушки на каждом этапе.

Совпадение индекса с запросом и его отсутствиеИндекс подходитИндекс не подходит
Углубление

Индексы в базе: какие ставить и когда они не спасают

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

Письмо по вторникам

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

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть