Алексей Меленчук
вся записка
дата
объём 6 мин чтения

Уровни изоляции: что стандарт не смог определить

ANSI SQL определяет уровни изоляции через три аномалии. В 1995 году было показано, что это определение неполно и допускает несовместимые прочтения.

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

Стандарт ANSI SQL-92 определяет четыре уровня изоляции через три явления: грязное чтение, неповторяющееся чтение и фантомы. Уровень задан тем, какие из трёх запрещены.

Определение через список запретов работает, только если список полон. Он неполон, и это не мнение — это показано в работе Беренсона, Бернстайна, Грея, Мелтона и О’Нилов «A Critique of ANSI SQL Isolation Levels» (SIGMOD, 1995).

Что именно не так

Формулировки стандарта допускают два прочтения — узкое и широкое.

Узкое трактует аномалию как конкретную последовательность операций. Широкое — как любую историю, приводящую к тому же результату. Под узким прочтением уровень запрещает меньше, чем кажется автору кода.

Важнее второе: даже при широком прочтении трёх явлений не хватает. Авторы показывают аномалии, не покрытые ни одним из трёх, — в частности потерянное обновление и перекос записи.

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

Перекос записи

Правило: хотя бы один дежурный врач должен оставаться на смене. Дежурных двое, оба хотят уйти.

Транзакция A читает: дежурных двое, значит уйти можно, снимает себя. Транзакция B в это же время читает то же самое: дежурных двое, снимает себя. Обе фиксируются.

Дежурных не осталось. Ни одна транзакция не перезаписала данные другой — они изменили разные строки. Потерянного обновления нет. Грязного чтения нет. Неповторяющегося чтения нет. Фантома нет.

Инвариант нарушен, а список запрещённых явлений пуст.

Именно эту аномалию допускает изоляция снимков — тот режим, который PostgreSQL включает под именем REPEATABLE READ, а Oracle — под именем SERIALIZABLE. Оба имени введены стандартом, и оба обещают меньше, чем звучат.

Отсюда практический вывод

Название уровня не переносится между базами.

REPEATABLE READ в PostgreSQL — это изоляция снимков: чтение видит согласованный снимок на момент начала транзакции, запись конфликтующих строк приводит к откату одной из транзакций.

REPEATABLE READ в MySQL/InnoDB — тоже снимок для чтения, но запись работает по блокировкам с зазорами, и набор допустимых аномалий другой.

Одинаковое имя, разные гарантии. Рассуждение, проверенное на одной базе, на другой не проверено.

Ещё две аномалии из той же работы

Перекос записи — самая известная, но не единственная.

Потерянное обновление. Две транзакции читают одно значение, каждая вычисляет новое и записывает. Вторая затирает результат первой. Классика: чтение остатка, вычитание, запись.

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

Перекос чтения. Транзакция читает счёт A, потом счёт B; между чтениями проходит перевод. Сумма, посчитанная по двум чтениям, никогда не существовала в базе. Отчёт получается арифметически невозможным.

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

Почему снимок стал стандартом де-факто

Причина в стоимости, и она архитектурная.

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

Отсюда свойство, за которое MVCC и берут: читатели не блокируют писателей, писатели не блокируют читателей. Долгий отчёт не мешает приёму заказов, и наоборот.

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

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

Как найти уязвимые места в существующем коде

Признак, по которому перекос записи ищется без теории: транзакция читает одни строки, а пишет другие, и решение о записи зависит от прочитанного.

Практический обход:

  1. Найти места, где перед записью выполняется проверка агрегата — COUNT, SUM, EXISTS по набору строк.
  2. Проверить, входят ли строки, попавшие в проверку, в набор изменяемых. Если нет — кандидат на перекос.
  3. Для каждого кандидата ответить, что произойдёт, если две такие транзакции пройдут одновременно.

Третий шаг обычно и даёт ответ, брать ли материализацию конфликта или достаточно оставить как есть.

Что делать с перекосом записи

Три способа, по возрастанию цены.

Материализовать конфликт. Если транзакции не пересекаются по строкам, надо сделать так, чтобы пересекались. Строка-агрегат «смена №17», которую обе транзакции обновляют, превращает перекос записи в обычный конфликт записи, который снимок уже ловит.

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

Явная блокировка. SELECT ... FOR UPDATE на строках, от которых зависит решение. Работает, но требует помнить: блокировать надо то, что читали для принятия решения, а не то, что пишете. Это ровно та ошибка, которую делают.

Сериализуемая изоляция снимков. PostgreSQL с версии 9.1 реализует SSI по работе Кэхилла, Рёма и Фекете (SIGMOD, 2008). Механизм отслеживает опасные структуры зависимостей между транзакциями и откатывает одну из участниц, когда возникает риск несериализуемого исполнения.

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

Что я выбираю

Изоляцию снимков как основной режим и материализацию конфликта там, где инвариант охватывает несколько строк.

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

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

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