Уровни изоляции: что стандарт не смог определить
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 и берут: читатели не блокируют писателей, писатели не блокируют читателей. Долгий отчёт не мешает приёму заказов, и наоборот.
Плата за это — три вещи, о которых вспоминают позже: старые версии занимают место и требуют очистки; долгая транзакция удерживает горизонт очистки и раздувает таблицы во всей базе; и как раз перекос записи, потому что снимок не видит того, что пишут параллельно.
Первые две — эксплуатационные и решаются наблюдением за возрастом самой старой транзакции. Третья — прикладная, и её приходится решать в коде.
Как найти уязвимые места в существующем коде
Признак, по которому перекос записи ищется без теории: транзакция читает одни строки, а пишет другие, и решение о записи зависит от прочитанного.
Практический обход:
- Найти места, где перед записью выполняется проверка агрегата —
COUNT,SUM,EXISTSпо набору строк. - Проверить, входят ли строки, попавшие в проверку, в набор изменяемых. Если нет — кандидат на перекос.
- Для каждого кандидата ответить, что произойдёт, если две такие транзакции пройдут одновременно.
Третий шаг обычно и даёт ответ, брать ли материализацию конфликта или достаточно оставить как есть.
Что делать с перекосом записи
Три способа, по возрастанию цены.
Материализовать конфликт. Если транзакции не пересекаются по строкам, надо сделать так, чтобы пересекались. Строка-агрегат «смена №17», которую обе транзакции обновляют, превращает перекос записи в обычный конфликт записи, который снимок уже ловит.
Способ выглядит грубым и работает лучше остальных, потому что не требует ничего от базы.
Явная блокировка. SELECT ... FOR UPDATE на строках, от которых
зависит решение. Работает, но требует помнить: блокировать надо то,
что читали для принятия решения, а не то, что пишете. Это ровно
та ошибка, которую делают.
Сериализуемая изоляция снимков. PostgreSQL с версии 9.1 реализует SSI по работе Кэхилла, Рёма и Фекете (SIGMOD, 2008). Механизм отслеживает опасные структуры зависимостей между транзакциями и откатывает одну из участниц, когда возникает риск несериализуемого исполнения.
Это настоящая сериализуемость, а не имя. Плата — ложные срабатывания:
часть откатов происходит на историях, которые на самом деле были
корректны, потому что отслеживание консервативно. Приложение обязано
уметь повторять транзакцию, и без этого умения SERIALIZABLE включать
нельзя.
Что я выбираю
Изоляцию снимков как основной режим и материализацию конфликта там, где инвариант охватывает несколько строк.
Причина не в производительности, а в предсказуемости: материализованный конфликт виден в схеме. Разработчик, который придёт через год, увидит строку-агрегат и поймёт, зачем она. Он не увидит того, что уровень изоляции подобран под конкретный инвариант, и сломает это первой же правкой.
SERIALIZABLE беру там, где инвариантов много и они меняются: цена
универсальности окупается, когда перечислить все конфликты заранее
нельзя.
Где я могу быть неправ. Если у вас единственная точка входа на запись и она уже сериализована очередью, весь разговор не про вас: конкуренции нет, и любой уровень изоляции даст один результат.