dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Уровни изоляции: что ваша база разрешает на самом деле

Коротко: уровень изоляции — это не настройка производительности, а список того, что вашему приложению разрешено увидеть. Между «read committed» и «serializable» лежат аномалии с именами и с ценой: списание сверх остатка, две смены без дежурного, задвоенный документ. Прочитать в документации, что база «поддерживает snapshot isolation», недостаточно — это свойство исполнений, а не текста.

9 мин чтения

Коротко

  • Уровень изоляции задаёт не скорость, а множество допустимых исходов. Проверять его надо на исполнениях, а не по документации.
  • Snapshot isolation пропускает write skew: две транзакции видят корректную картину, а вместе нарушают инвариант.
  • Serializable в PostgreSQL — это алгоритм Кэхилла: он ищет транзакцию с входящей и исходящей анти-зависимостью и не даёт ей зафиксироваться.
  • Проверка строится на графе зависимостей; чтобы восстановить порядок версий из наблюдений, значения делают уникальными — приём Elle.

Два разных обещания в одной фразе

Фраза «у нас snapshot isolation» — это на самом деле два утверждения. Первое: ничего слабее не случается, то есть аномалий, которые этот уровень обязан предотвращать, не будет. Второе: это действительно SI, а не что-то более строгое под тем же именем.

Ни одно из них нельзя проверить чтением кода или документации. Это свойства исполнений, а не текста: они проявляются в конкретном чередовании транзакций под конкретной нагрузкой. Значит, исполнения надо ловить и допрашивать.

Аномалии, у которых есть имена

В 1999 году Атул Адья в диссертации заменил расплывчатые словесные определения ANSI на явления, определённые через граф зависимостей между транзакциями. Пользоваться его именами удобно по практической причине: они не допускают спора о том, что именно вы наблюдали.

Явление
Что это
G0
цикл только из зависимостей по записи
G1a
чтение значения, записанного транзакцией, которая потом откатилась
G1b
чтение промежуточного значения, которое его же автор позже заменил
G1c
цикл из зависимостей по записи и по чтению
G-single
цикл ровно с одной анти-зависимостью — read skew
G2-item
цикл больше чем с одной анти-зависимостью — write skew

Уровни выстраиваются лестницей: каждый запрещает всё, что запрещает предыдущий, плюс ещё одно.

Уровень
Что добавляет к запретам
read uncommitted
G0
read committed
G1a, G1b, G1c
snapshot isolation
G-single
serializable
G2-item

Вся практическая разница между snapshot isolation и сериализуемостью — одна анти-зависимость. У read skew она одна, и SI его предотвращает. У write skew их две, и SI бессилен.

Write skew: как списать больше, чем есть

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

sql
-- транзакция A                     -- транзакция B
BEGIN;                              BEGIN;
SELECT sum(balance) FROM accounts   SELECT sum(balance) FROM accounts
 WHERE client = 7;  -- 200           WHERE client = 7;  -- 200
-- 200 - 150 >= 0, можно            -- 200 - 150 >= 0, можно
UPDATE accounts SET balance =       UPDATE accounts SET balance =
  balance - 150 WHERE id = 1;         balance - 150 WHERE id = 2;
COMMIT;                             COMMIT;
Обе транзакции стартуют одновременно, остатки по 100, лимит — суммарный

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

Та же форма встречается вне денег: последний дежурный врач снимает себя со смены одновременно с коллегой; два менеджера резервируют последний слот на складе; две операции присваивают один и тот же номер документу. Везде проверяется одно, а изменяется другое — и SI такую пару пропускает по построению.

Что делает serializable и сколько он стоит

У дыры в snapshot isolation есть форма: любой цикл, который SI пропускает, содержит две подряд идущие анти-зависимости. Значит, достаточно следить за транзакцией, у которой есть и входящая, и исходящая, — она называется опорной, — и не дать ей зафиксироваться.

Это и есть SSI Кэхилла, Рёма и Фекете (SIGMOD 2008) — алгоритм, который реализован в PostgreSQL под именем SERIALIZABLE. Проверка намеренно консервативна: она отклоняет и часть транзакций, которые на самом деле были сериализуемы. Платой оказывается пропускная способность, а не корректность.

Проверить, а не поверить

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

Одна трудность делает наивный подход бесполезным. Чтобы провести ребро между двумя записями, нужно знать, какая была раньше, — а из истории над обычными значениями это невосстановимо: две записи числа 5 неотличимы. Спросить порядок у самой базы значит поверить подсудимому на слово.

Выход придуман в Elle (Kingsbury, Alvaro, VLDB 2020): значения делают накапливающимися списками, и каждая запись добавляет уникальный элемент. Тогда одно-единственное чтение [a, b, c] само по себе утверждает, что a было раньше b, а b раньше c. Порядок версий восстанавливается из наблюдений, а не берётся из внутренностей базы — и база, которая солгала бы о собственном порядке, оказывается уличена, а не выслушана.

Дальше — техника: компоненты сильной связности по Тарьяну, а внутри каждой поиск в ширину, который находит кратчайший цикл. Это не педантизм: «цикл среди сорока одной транзакции» — не отчёт об ошибке, а две транзакции с выписанными рёбрами — отчёт.

Лестница: проверка в обе стороны

Дальше начинается то, ради чего всё и затевалось. Каждый уровень проверяется двумя утверждениями сразу.

  • Соблюдает. На сотнях расписаний из сида уровень ни разу не произвёл аномалию, которую обязан предотвращать.
  • Не строже, чем заявлено. Существует расписание, на котором он производит ровно то, что предотвращает следующая ступень.
text
read-uncommitted     соблюдает на 150 расписаниях   допускает G1b на сиде 0
read-committed       соблюдает на 150 расписаниях   допускает G-single на сиде 1
snapshot-isolation   соблюдает на 150 расписаниях   допускает G2-item на сиде 25
serializable         соблюдает на 150 расписаниях   не допускает ничего, фиксирует 51%
Лестница целиком: каждая ступень стоит там, где её поместила теория

Вторая половина важнее первой. Соблюдение само по себе не доказывает почти ничего: движок, отклоняющий каждую транзакцию, был бы «чист» на всех уровнях, а проверка, которая ничего не находит, с ним бы согласилась. Требование производить запрещённое следующей ступенью закрывает эту дыру — и заодно проверяет саму проверку: чекер, не умеющий найти write skew под snapshot isolation, ничем не подкрепил бы и своё оправдание serializable.

Последняя строка — из той же логики. На конкурентной нагрузке serializable фиксирует 51% транзакций, и это утверждается тестом, а не печатается для справки: оправдание, купленное отказом от всех транзакций, ничего не стоит.

Что с этим делать в приложении

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

Как закрыть инвариант, который не держит уровень по умолчанию
  1. 01

    Отдайте инвариант базе

    Уникальный индекс, CHECK и внешние ключи проверяются независимо от уровня изоляции. Правило «один активный документ на клиента» дешевле выразить частичным уникальным индексом, чем сторожить в коде.

  2. 02

    Заблокируйте то, что читаете для решения

    SELECT … FOR UPDATE превращает прочитанные строки в конфликт по записи, и write skew на этих строках становится невозможен. Работает, когда множество строк известно заранее; на условии WHERE, под которое строк ещё нет, — нет.

  3. 03

    Включите serializable там, где инвариант нельзя выразить иначе

    И сразу добавьте повтор транзакции по ошибке сериализации: без него уровень превращается в генератор ошибок для пользователя.

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

Чего этот метод не умеет

  • Нет предикатных анти-зависимостей. Полное G2 требует предикатных чтений вида SELECT … WHERE; в таком наборе операций их нет, выводить их не из чего, и заявлять проверку G2 значило бы обещать больше, чем показано. Проверяется только G2-item.
  • Прохождение — не доказательство. Сотни расписаний из пространства, которое несопоставимо больше. Хороший фаззинг, не теорема; TLA+-спецификации SSI доказывают то, чего выборка доказать не может.
  • Чекер полный не на всём. Он находит кратчайший цикл через каждую вершину компоненты, а не перечисляет все циклы: для таких историй этого достаточно, но обещать исчерпывающий перебор было бы неправдой.
  • Это не база данных. Ни долговечности, ни восстановления, ни индексов, ни сборки старых версий: движок изоляции и ничего сверх того.

Что спросить у себя и у подрядчика

Пять вопросов про изоляцию, у которых есть проверяемый ответ
  • Какой уровень изоляции стоит у вас по умолчанию и кто из команды назовёт его, не заглядывая в конфиг?
  • Какие инварианты приложения переживут две одновременные транзакции, которые читают одно и меняют разное?
  • Есть ли в коде повтор транзакции по ошибке сериализации — и проверен ли он тестом?
  • Какие правила отданы базе (уникальные индексы, CHECK), а какие сторожатся в коде приложения?
  • Чем подтверждается, что выбранный уровень действительно предотвращает то, ради чего его выбрали, — измерением или документацией вендора?

Эти вопросы мы задаём себе на проектах, где цена ошибки в данных выше цены отказа: финансовые системы, учёт и складские остатки, CRM и ERP. Остальные технические разборы — в рубрике «Инженерия».

Частые вопросы

Мы на PostgreSQL с настройками по умолчанию. Насколько это плохо?

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

Repeatable read в MySQL — это snapshot isolation?

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

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

Тем же способом: генерировать конкурентную нагрузку, записывать историю операций с уникальными значениями и проверять её на аномалии графом зависимостей. Чекер не привязан к движку — он принимает историю в своём формате. Готовый и проверенный временем инструмент для этого — Elle из проекта Jepsen.

Serializable сильно замедлит систему?

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

Источники

Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.

  1. 01Adya. Weak Consistency: A Generalized Theory and Optimistic ImplementationsДиссертация MIT, 1999: явления G0–G2 через граф зависимостей
  2. 02Berenson et al. A Critique of ANSI SQL Isolation LevelsРабота 1995 года, показавшая, что словесные определения ANSI неверны
  3. 03Cahill, Röhm, Fekete. Serializable Isolation for Snapshot DatabasesАлгоритм SSI, реализованный в PostgreSQL
  4. 04Elle (Jepsen)Приём с накапливающимися списками, восстанавливающий порядок версий
  5. 05adyaДвижок и чекер из этой статьи: MIT, лестница уровней внутри
Автор
Дониёр Ботиров
Основатель dbit.one · автор материала

Этот материал подписан именем, в отличие от остальных: за ним не стоит клиент. Код, о котором идёт речь, открыт целиком и лежит под тем же именем — его можно прочитать, запустить и проверить утверждения статьи, не поверив ни одному из них на слово.

Услуги по теме

Читать дальше

Инженерия6 мин чтения

Повтор запроса не должен списывать деньги дважды

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

Инженерия9 мин чтения

Падает мастер: что на самом деле происходит с вашими данными

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

Инженерия10 мин чтения

Тесты не доказывают отсутствие бага. Что доказывает

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

Остались вопросы по вашему проекту?

Опишите задачу — в течение 24 часов вернёмся с оценкой, сроками и планом.

[email protected]