Уровни изоляции: цена SERIALIZABLE — это повторы, а не медленный запрос
Таблица из стандарта описывает, что уровню запрещено пропускать, а не что он пропускает у вас. PostgreSQL расходится с ней в обе стороны сразу: фантом на REPEATABLE READ не проходит, хотя стандарт его там разрешает, — а write skew проходит целиком, и этой аномалии в таблице нет вовсе. Замер добавляет второе: в этой нагрузке REPEATABLE READ и SERIALIZABLE неразличимы между собой — разница между ними меньше разброса между кругами, — а цена платится повторами.
Полное техническое изложение
TL;DR
- Таблица уровней изоляции говорит, что уровню запрещено пропускать, а не что он пропускает в вашей базе. Расхождение бывает в обе стороны.
- PostgreSQL строже таблицы: на
REPEATABLE READне бывает ни неповторяющегося чтения, ни фантома. - И слабее ожиданий там, где таблица молчит: write skew на
REPEATABLE READпроходит. Двое дежурных, каждый проверил «нас двое» и ушёл — не осталось никого. - Инвариант можно удержать и без
SERIALIZABLE— блокировкой нужных строк. Но заблокировать можно только то, что уже существует. - «SERIALIZABLE медленный» — слишком грубо. В этом замере он и
REPEATABLE READнеразличимы, а платите вы не скоростью, а повторами.
Уровень изоляции — это ответ на вопрос «что моя транзакция увидит, пока рядом работают другие». Учат его обычно по таблице из стандарта: четыре уровня, три аномалии, галочки. Таблица настоящая, но читают её не так.
Прежде чем спорить с таблицей, стоит один раз увидеть, из чего она сделана. Аномалия — это не неправильный запрос, а неправильный порядок: каждая команда по отдельности законна, а вместе они дают результат, которого не дал бы никакой порядок «сначала одна транзакция целиком, потом вторая». Вот четыре таких чередования — те же, что и в замере, — и их можно проиграть по шагам:
Переключая уровень на одном и том же чередовании, видно и то, чем уровень
отвечает. Способов у базы два: снимок — транзакция читает базу такой, какой
та была в начале, и чужая правка в неё не попадает; и отказ — база
отвергает транзакцию кодом 40001 и предлагает повторить. Первым закрываются
два верхних сценария, вторым — два нижних, и вся цена SERIALIZABLE, о которой
статья дальше, это цена вторых.
Таблица про запреты, а не про вашу базу
В таблице записано, что уровню нельзя пропускать. Что он пропускает на самом деле — вопрос к конкретной базе, и ответ может отличаться в обе стороны.
Может быть строже: PostgreSQL на REPEATABLE READ не пропускает фантом, хотя
стандарт разрешил бы. Уровень сделан снимком — транзакция видит базу такой,
какой та была в момент её начала, и новые строки для неё просто не появляются.
А может пропускать то, чего в таблице нет вовсе.
Аномалия, которой в таблице нет
Двое врачей на дежурстве. Правило: хотя бы один должен остаться.
Каждый открывает приложение, оно проверяет — на дежурстве двое, значит уйти можно. Оба нажимают «уйти» одновременно. Каждая транзакция по отдельности безупречна: проверила, увидела двоих, сняла одного. Ни одна не трогала строку другой, поэтому база не увидела конфликта.
На дежурстве не осталось никого.
Это называется write skew, и в таблице стандарта такой строки нет. Не потому, что её забыли прочитать, а потому что список аномалий в стандарте неполон — это признано в статье 1995 года, которая write skew и описала.
Правило, которое стоит запомнить вместо таблицы: уровень защищает то, что вы прочитали, а не то, что вы из прочитанного заключили. «Дежурных хотя бы один» — это вывод, и сам уровень его не охраняет.
Держать вывод можно и без SERIALIZABLE
SERIALIZABLE разбирается с этим сам: он отвергнет пару транзакций, которую
нельзя разложить в последовательную, ошибкой 40001. Но не он один. Если обе
транзакции возьмут нужные строки на замок — SELECT ... FOR UPDATE, — второй
придётся подождать первую, и дальше она увидит правду. Проверено: инвариант
держится так на всех трёх уровнях, включая самый слабый.
У приёма есть граница, и она неочевидная. Замок вешается только на те строки,
которые запрос вернул. Если проверка звучит как «на этот слот ещё никто не
записался», строк нет — и вешать нечего. Обе транзакции видят «свободно», обе
записываются. Здесь спасает либо SERIALIZABLE, либо ограничение
уникальности: защита, которой всё равно, что транзакция успела прочитать.
Чего в PostgreSQL нет совсем
Грязного чтения — это когда видно чужие незафиксированные правки. Уровень
READ UNCOMMITTED база принимает и даже показывает его имя в настройках, но
ведёт себя он как READ COMMITTED. Прочитать незафиксированное нельзя ни на
одном уровне.
Сколько это стоит
Главное, что обычно говорят про SERIALIZABLE, — что он медленный. Замер это
не подтверждает.
При плотном соперничестве SERIALIZABLE даёт 1215 транзакций в секунду против
1312 у REPEATABLE READ. Разница — проценты, а один и тот же уровень от круга
к кругу гуляет на 16–26 процентов и по кругам они обгоняют друг друга. То есть
различить их этот замер не может. А READ COMMITTED на том же наборе даёт
2798 — больше чем вдвое, и вот это уже не разброс.
Но стоит разредить столкновения — и отрыв READ COMMITTED сжимается до
четверти. Дорожает в этой нагрузке не уровень, а соперничество. И платите
вы не скоростью запроса, а работой, которую пришлось выбросить: при плотном
соперничестве почти половина попыток отказывается с ошибкой 40001, и
транзакцию надо делать заново.
Оговорка, без которой вывод превращается в неправду: это про эту нагрузку.
У SERIALIZABLE есть своя цена — он следит за зависимостями между
транзакциями, — и на транзакциях другой формы она может стать заметной. Здесь
не стала.
Отсюда два практических следствия. Отказ 40001 — не поломка, а часть договора: код обязан уметь повторить транзакцию целиком, иначе работа просто теряется. Целиком — потому что отказ приходит не только на фиксации, но и на обычной команде посреди транзакции. И прежде чем спорить об уровне, стоит посмотреть на долю отказов: уровень задаёт, что именно база отвергнет, а плотность соперничества — как часто это будет случаться.
TL;DR
- Таблица уровней изоляции из стандарта описывает, что уровню запрещено пропускать, а не что он пропускает в вашей базе. Документация PostgreSQL говорит это прямо, и отсюда растёт всё остальное.
- PostgreSQL строже таблицы: на REPEATABLE READ не проходят ни неповторяющееся чтение, ни фантом — уровень реализован снимком, а не тем минимумом, которого требует стандарт.
- И слабее ожиданий там, где таблица молчит: write skew на REPEATABLE READ проходит целиком. Измерено: двое дежурных, каждая транзакция проверяет инвариант и видит двоих, обе фиксируются — дежурных остаётся ноль.
- Инвариант при этом можно удержать и без SERIALIZABLE — явной
блокировкой. Но охраняет тогда не уровень, а блокировка, и у неё есть
граница:
SELECT ... FOR UPDATEзахватывает только те строки, которые вернул. На предикате по несуществующим строкам приём молча не срабатывает — это тоже измерено. READ UNCOMMITTEDпринимается, иtransaction_isolationвозвращает именно его. Но ведёт он себя какREAD COMMITTED: грязное чтение недостижимо ни на одном уровне.- И главное: «SERIALIZABLE обязательно медленный» — слишком грубое правило. В этом замере он и REPEATABLE READ неразличимы: 1215 против 1312 тр/с при разбросе между кругами в 16 и 26 процентов, и порядок между ними от круга к кругу переворачивается. Платите вы не скоростью запроса, а повторами: почти половина попыток отказывается с 40001.
База: аномалия — это свойство порядка, а не запроса
Дальше вся статья спорит с таблицей уровней изоляции, и спор этот имеет смысл только если знать, из чего таблица сделана. Поэтому сначала — короткий заход: что такое уровень, что такое аномалия и почему их четыре, а не одна.
Транзакция — это заявка на то, чтобы несколько действий считались одним. Пока она одна, вопросов нет. Вопросы начинаются, когда рядом работает вторая: у базы появляется выбор, в каком порядке чередовать их действия, и выбор этот она делает не так, как вам кажется.
Идеал здесь называется сериализуемостью: результат должен быть таким, будто транзакции выполнялись по очереди, в каком-то одном порядке. Любой результат, которого не даёт ни один такой порядок, и называется аномалией. Обратите внимание, что в этом определении нет ни слова про неправильный запрос: каждый запрос по отдельности законен, неправильным оказывается чередование.
Уровень изоляции — это сделка. Полная сериализуемость стоит дорого, и уровни продают её частями: чем ниже уровень, тем больше чередований база согласна принять и тем быстрее она работает. Что именно вы отдаёте на каждой ступени, и описывает таблица из стандарта.
Четыре чередования, которые стоит увидеть
Аномалии проще один раз проиграть, чем прочитать их определения. Ниже — те же четыре сценария, что и в замере: порядок шагов в них задан явно двумя соединениями, поэтому они повторяются и не зависят от того, кто кого обгонит.
Разберём их по одному — все четыре устроены одинаково, и различаются одним шагом.
Неповторяющееся чтение. T1 читает строку, между её чтениями T2 эту строку меняет и фиксирует, T1 читает снова — и видит другое. Ничего запрещённого ни одна из них не сделала; просто у T1 внутри одной транзакции мир поменялся под ногами.
Фантом — то же самое, но про количество. T1 не читает конкретную строку, а
считает строки по условию: SELECT count(*) … WHERE balance >= 100. Между
двумя счётами T2 вставляет новую подходящую строку. Разница с предыдущим
случаем ровно одна — UPDATE против INSERT, — и именно поэтому стандарт
разводит их на два уровня: защититься от изменения уже прочитанной строки
технически проще, чем от появления строки, которую ещё никто не читал.
Потерянное обновление. Обе транзакции читают баланс — сто, — обе прибавляют своё и записывают. Первая пишет 110, вторая 120, и 110 исчезает бесследно: вместо ожидаемых 130 в базе остаётся 120. Здесь уже пострадала не чья-то картина мира, а сами данные.
Write skew — самый неприятный, и его в таблице стандарта нет. Двое дежурных врачей, инвариант «на дежурстве остаётся хотя бы один». Каждая транзакция проверяет инвариант перед записью, обе видят двоих и обе считают свой уход законным. Строки они трогают разные, поэтому конфликта записи нет вовсе. Дежурных в итоге ноль, и база об этом не сообщает ничем.
Чем уровень отвечает: снимком или отказом
Переключая уровень на одном и том же чередовании, видно, что способов ответить у базы всего два, и они устроены по-разному.
Снимок. На REPEATABLE READ и выше транзакция видит базу такой, какой та
была в момент её начала. Чужой COMMIT, случившийся посреди, в этот снимок не
попадает — и вопрос «а не изменилось ли» просто не возникает. Так закрываются
первые два сценария, причём оба сразу: снимку всё равно, изменили строку или
добавили новую.
Отказ. Снимок не помогает там, где транзакции пишут. Здесь база
отвергает одну из них кодом 40001 и предлагает повторить. Так закрываются
третий и четвёртый сценарии — и, забегая вперёд, именно в этом и состоит цена
SERIALIZABLE, о которой вся статья: платите вы не медленным запросом, а
повторами.
Стоит заметить и то, на каком шаге приходит отказ: в потерянном обновлении
он приходит уже на UPDATE, а в write skew — только на COMMIT. Отсюда
практическое правило, к которому статья вернётся в конце: повторять надо
транзакцию целиком, а не обёртку вокруг одной фиксации.
Таблица описывает не то, что вы думаете
Уровни изоляции почти всегда объясняют одной таблицей: четыре уровня, три аномалии, галочки. Таблица настоящая, она есть и в стандарте, и в документации PostgreSQL. Беда в том, как её читают.
Она описывает, что уровню запрещено пропускать. Не что он пропускает у вас.
Документация PostgreSQL формулирует это в одном предложении, и оно снимает
половину недоразумений: the standard specifies which anomalies must
not occur at certain isolation levels; higher guarantees are acceptable
(стандарт указывает, какие аномалии не должны возникать на определённых уровнях изоляции; более сильные гарантии допустимы).
То есть между таблицей и вашей базой есть зазор, и он двусторонний. База может быть строже таблицы — и тогда вы напрасно защищаетесь от того, чего не бывает. База может пропускать то, чего в таблице нет вовсе, — и тогда вы не защищаетесь вообще.
Оба случая ниже воспроизведены на живом PostgreSQL 16.13. Порядок шагов задан явно двумя соединениями: это не два потока в надежде, что они столкнутся, а предписанное чередование, поэтому результат не зависит от скорости машины и повторяется.
Строже таблицы: фантом на REPEATABLE READ не проходит
По стандарту REPEATABLE READ обязан защищать от неповторяющегося чтения и вправе пропускать фантом. Проверяем оба.
Неповторяющееся чтение — одна и та же строка, прочитанная дважды, между чтениями изменена и зафиксирована соседом:
begin(t1, level)
first = t1.execute("SELECT balance FROM accounts WHERE id = 1")
t2.execute("UPDATE accounts SET balance = 999 WHERE id = 1") # и фиксирует
second = t1.execute("SELECT balance FROM accounts WHERE id = 1")Фантом — то же самое, но меняется не строка, а их количество по условию: соседняя транзакция вставляет ещё одну подходящую.
| аномалия | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|
| неповторяющееся чтение | воспроизвелось | нет | нет |
| фантом | воспроизвелось | нет | нет |
Выделенная клетка — там, где база строже стандарта. Документация называет
причину прямо: The table also shows that PostgreSQL's Repeatable Read
implementation does not allow phantom reads
(Таблица также показывает, что реализация Repeatable Read в PostgreSQL не допускает фантомных чтений). Уровень реализован снимком:
транзакция видит базу такой, какой та была в момент её начала, — и вопрос «а не
появилось ли новых строк» просто не возникает.
Практическое следствие тихое, но денежное — и его стоит сформулировать узко,
потому что широкая версия неверна. Ради повторяемости чтения блокировки на
REPEATABLE READ не нужны: обычный SELECT, выполненный второй раз, фантома не
увидит, и дописанная «на всякий случай» защита именно от этого стоит
соперничества и не даёт ничего.
Но это не значит, что явные блокировки на PostgreSQL бесполезны вообще. У них другая задача — согласовать конкурентные записи, и с ней снимок не помогает. Про эту вторую задачу — отдельный раздел ниже, с замером.
Слабее ожиданий: write skew
Теперь то, чего в таблице стандарта нет. Классический пример — двое дежурных врачей и инвариант «на дежурстве остаётся хотя бы один».
# Обе транзакции читают инвариант ДО того, как хоть одна записала.
seen1 = t1.execute("SELECT count(*) FROM oncall WHERE on_call") # 2
seen2 = t2.execute("SELECT count(*) FROM oncall WHERE on_call") # 2
t1.execute("UPDATE oncall SET on_call = false WHERE doctor = 'Алиса'")
t2.execute("UPDATE oncall SET on_call = false WHERE doctor = 'Борис'")
t1.commit()
t2.commit()Каждая транзакция по отдельности безупречна: проверила условие, увидела двоих, сняла с дежурства одного — остался один. Ни одна не трогает строку другой, поэтому конфликта записи нет и снимок обеих устраивает.
| READ COMMITTED | REPEATABLE READ | SERIALIZABLE | |
|---|---|---|---|
| write skew | дежурных 0 | дежурных 0 | остановлено, 40001 |
На REPEATABLE READ инвариант нарушен, и база об этом не сообщила ничем. В
таблице стандарта этой аномалии нет — и это не упущение читателя, а известный
пробел самой таблицы. Статья 1995 года, которая ввела write skew под
обозначением A5B, говорит об этом отдельным замечанием: Remark 5. ANSI SQL
isolation phenomena are incomplete. There are a number of anomalies that still
can arise
(Замечание 5. Перечень аномалий изоляции в ANSI SQL неполон. Существует ряд аномалий, которые всё ещё могут возникать).
Отсюда и практическое правило, которое стоит запомнить вместо таблицы: REPEATABLE READ защищает то, что вы прочитали, а не то, что вы из прочитанного заключили. Инвариант «дежурных хотя бы один» — это вывод, и сам уровень его не охраняет.
Останавливает write skew SERIALIZABLE. Уровень собран поверх того же снимка:
implemented using
a technique known in academic database literature as Serializable Snapshot
Isolation
(реализован при помощи техники, известной в академической литературе по базам данных как Serializable Snapshot Isolation), — и добавляет к нему отслеживание зависимостей между
транзакциями. Разница между двумя верхними уровнями лежит не в том, что они
дают прочитать, а в том, какие истории выполнения они соглашаются принять:
| REPEATABLE READ | SERIALIZABLE | |
|---|---|---|
| на чём собран | снимок | тот же снимок |
| грязное чтение | нет | нет |
| неповторяющееся чтение | нет | нет |
| фантом при повторном обычном чтении | нет | нет |
| write skew | возможен | отвергается |
| что добавлено сверху | — | отслеживание зависимостей: история, которую нельзя разложить в последовательную, получает отказ 40001 |
Строки с первой по четвёртую у них совпадают дословно — и это не случайность, а следствие того, что уровень собран поверх одного и того же снимка. Различают их две нижние.
Где именно приходит отказ
«SERIALIZABLE отказывает на COMMIT» — верно не всегда, и это видно в одном
прогоне. В сценарии с дежурными до фиксации конфликта нет: транзакции трогают
разные строки, и цикл зависимостей обнаруживается только на COMMIT. А в
сценарии с потерянным обновлением обе лезут в одну строку, и тот же 40001
приходит уже на самой команде:
| сценарий | где пришёл 40001 |
|---|---|
| write skew, разные строки | на фиксации |
| потерянное обновление, одна строка | на команде |
Практический вывод из этой пары один: повторять надо транзакцию целиком. Обёртка вокруг одной фиксации ловит только половину случаев.
Инвариант без SERIALIZABLE: где блокировка держит, а где нет
Из предыдущего раздела легко сделать вывод шире, чем он есть: «инвариант охраняет только SERIALIZABLE». Это неверно, и проверяется так же — запуском.
Тот же сценарий с дежурными, но обе транзакции читают предикат под
SELECT ... FOR UPDATE. И второй сценарий, отличающийся ровно одним: строк,
которые удовлетворяют условию, ещё не существует. Инвариант — «на слот не
больше одной записи», обе транзакции проверяют занятость и вставляют.
# Сценарий 1: строки предиката СУЩЕСТВУЮТ — блокировать есть что.
on_call = t.execute("SELECT doctor FROM oncall WHERE on_call FOR UPDATE")
# Сценарий 2: строк по условию НЕТ — блокировать нечего.
taken = t.execute("SELECT id FROM bookings WHERE slot = 1 FOR UPDATE")сценарий, везде FOR UPDATE | READ COMMITTED | REPEATABLE READ | SERIALIZABLE |
|---|---|---|---|
| предикат по существующим строкам | дежурных 1, вторая отступила сама | дежурных 1, вторая остановлена 40001 | дежурных 1, вторая остановлена 40001 |
| предикат по отсутствующим строкам | записей 2 | записей 2 | записей 1, отказ 40001 |
Верхняя строка опровергает широкую версию: инвариант удержан на всех трёх
уровнях, в том числе на самом слабом. FOR UPDATE захватил те самые строки,
по которым принимается решение, вторая транзакция была вынуждена подождать — и
дальше увидела правду. Но охраняет здесь не уровень, а блокировка, и
исход у неё разный: на READ COMMITTED вторая транзакция дочитывает свежую
строку и отступает сама, на верхних уровнях тот же приём оборачивается отказом
40001. Инвариант цел в обоих случаях, а обрабатывать приложению нужно разное.
Нижняя строка показывает границу приёма. FOR UPDATE блокирует только
фактически возвращённые строки; пустая выборка не создаёт блокировку
диапазона, как это бывает в некоторых других СУБД. Обе транзакции видят «слот
свободен», обе вставляют, и обе правы по отдельности. Здесь помогает либо
SERIALIZABLE, либо ограничение уникальности — то есть защита, которая не
зависит от того, что транзакция успела прочитать.
Отсюда точная формулировка вместо широкой: SERIALIZABLE отвергает истории, которые нельзя разложить в последовательную, — автоматически и не спрашивая, какой инвариант вы имели в виду. На слабых уровнях тот же инвариант часто можно защитить явно, но корректность тогда держится на выбранной блокировке, ограничении или форме запроса, и проверять надо именно их.
Грязное чтение: имя уровня и его поведение — разные вещи
Про READ UNCOMMITTED в PostgreSQL обычно говорят «его нет». Это близко к
правде, но проверяемо не так, как кажется, и я на этом попался.
begin(reader, "READ UNCOMMITTED")
reader.execute("SELECT current_setting('transaction_isolation')") # read uncommittedИмя сохраняется: база принимает уровень и возвращает именно его. Подменяется поведение, и проверять надо его — пусть соседняя транзакция изменит строку и не зафиксирует:
transaction_isolation показывает: read uncommitted
попытка прочитать незафиксированное: нет: видно 100, то есть значение
до незафиксированной правки
Документация говорит ровно это: PostgreSQL's Read Uncommitted mode behaves
like Read Committed
(Режим Read Uncommitted в PostgreSQL ведёт себя как Read Committed). Грязное чтение недостижимо ни на одном уровне.
Замечание здесь не про PostgreSQL, а про метод. «Настройка называется так» и «поведение такое» — разные утверждения, и первое ничего не говорит о втором. Первая редакция замера утверждала, что база молча подменяет уровень; запуск это опроверг, и запись прогона сохранена вместе с исправлением.
Цена: разрыв лежит не там, где его ищут
«SERIALIZABLE медленный» — самое частое, что о нём говорят, и обычно на этом разговор кончается. Замер говорит другое.
Восемь рабочих в цикле «прочитать — прибавить — записать» по случайной строке из небольшого набора. Чем меньше набор, тем чаще они сталкиваются. Уровни меряются не подряд, а кругами: в каждом круге все три, девять кругов.
Читать тут надо не абсолютные числа, а расстояния между столбиками — и ширину хвоста. Хвост это разброс между кругами, от худшего к лучшему, и он здесь решает всё.
При плотном соперничестве SERIALIZABLE даёт 1215 транзакций в секунду против 1312 у REPEATABLE READ. Разница между ними — проценты, а собственный разброс каждого — 16 и 26 процентов; по кругам SERIALIZABLE опережал соседа в трёх из девяти. Сказать по этому замеру, что один из двух верхних уровней быстрее другого, нельзя, и это единственный честный вывод из таких чисел.
А вот READ COMMITTED на том же наборе даёт 2798 — больше чем вдвое, и это разбросом не объясняется. Но стоит разредить столкновения, и его отрыв сжимается до четверти (3438 против 2583) и становится сравним с разбросом.
В этой нагрузке дорожает не уровень, а соперничество. Платите вы не скоростью запроса, а работой, которую пришлось выбросить: на плотном наборе почти половина попыток отказывается с 40001 и делается заново, на редком — около шести процентов.
Здесь нужна оговорка, без которой вывод становится неправдой. Это утверждение про эту нагрузку, а не про уровни вообще. У SSI есть собственная стоимость: он отслеживает зависимости и берёт предикатные блокировки. На транзакциях другой формы — длиннее, с большим множеством чтения, с другими планами — эта стоимость может выйти из-под разброса. Здесь не вышла; это результат замера, а не доказательство отсутствия накладных расходов.
И симметричная оговорка про долю отказов. Сказать «её задаёт не уровень» было бы перебором в другую сторону: у READ COMMITTED она нулевая на обоих наборах, у двух верхних — нет, так что уровень на неё влияет очевидно. Точнее так: уровень задаёт, какие истории база отвергнет, а плотность соперничества — как часто это случится. В этом замере сильнее двигает второе. Практический вывод от этого не меняется: прежде чем спорить об уровне, посмотрите на долю отказов и на то, насколько ваши транзакции лезут в одни и те же строки.
Что с этим делать
Уровень выбирается по инварианту, а не по таблице. Вопрос не «какие аномалии разрешены», а «что я заключаю из прочитанного и кто это охраняет». Если решение принимается по итогу запроса — счёт, сумма, наличие свободного места, — то путей ровно два: либо SERIALIZABLE, который отвергнет неразложимую историю сам, либо явная защита на слабом уровне — блокировка нужных строк, ограничение, атомарный DML. Второй путь дешевле по отказам и дороже по вниманию: он работает ровно настолько, насколько верно выбран, и его границу видно в разделе выше.
Отказ 40001 — не ошибка, а часть договора. Уровень, отвергающий историю
целиком, требует, чтобы вызывающая сторона умела повторить транзакцию целиком —
не фиксацию, а транзакцию, потому что отказ приходит и на команде тоже.
Документация ставит это условием: It is important that an
environment which uses this technique have a generalized way of handling
serialization failures
(Важно, чтобы среда, использующая эту технику, имела обобщённый способ обработки отказов сериализации). Код, который ловит исключение и логирует его, — это
не обработка, а потерянная работа.
Повторять — не значит повторять всё подряд. Полезно держать в голове короткий список, что означает какой SQLSTATE. Все четыре проверены на той же сборке — вызваны и прочитаны из ошибки, а не взяты из таблицы:
| SQLSTATE | что это | что делать |
|---|---|---|
40001 serialization_failure | база отвергла историю | повторить транзакцию целиком |
40P01 deadlock_detected | взаимная блокировка, одну жертву сняли | обычно тоже повторить целиком |
23505 unique_violation | ограничение уникальности | зависит от смысла: повтор поможет, только если значение выбирается заново |
23P01 exclusion_violation | ограничение исключения | так же — решает прикладная логика, а не обёртка |
Чего делать не стоит — превращать это в «повторяем любое исключение базы». Повтор уместен там, где причина отказа исчезает при следующей попытке; у нарушения ограничения она обычно не исчезает.
Защита от фантомов ради повторяемости чтения на PostgreSQL не нужна. На
REPEATABLE READ повторный обычный SELECT фантома не увидит, и блокировки,
дописанные ради этого, стоят соперничества и не дают ничего. Это не тот же
вопрос, что защита инварианта от конкурентных записей: там блокировки бывают
нужны, но работают они по правилам из раздела выше — по возвращённым строкам,
а не по диапазону.
Чем измерено
Оба скрипта работают против живого PostgreSQL; адрес задаётся переменной
DE_BENCH_DSN. Каждый открывается прямо отсюда, вместе с записью прогона.
bench/isolation/anomalies.py— четыре аномалии на трёх уровнях, воспроизведением, с явным порядком шагов.bench/isolation/cost.py— пропускная способность, разброс между кругами и доли отказов при плотном и редком соперничестве.bench/isolation/explicit_locking.py— держит ли инвариант явная блокировка на слабом уровне и где она перестаёт держать.
Версия сборки — PostgreSQL 16.13. Числа сняты на ней; на другой сборке и другом железе абсолютные значения будут иными, а отношения между уровнями — предмет той же проверки, которую можно повторить одной командой.
Отдельно про то, что считается в bench/isolation/cost.py. Отказы 40001 и
40P01 разложены по разным счётчикам, а не сложены в один: это разные SQLSTATE, и колонка,
подписанная одним из них, не должна включать другой. В первой редакции скрипта
они складывались, и подпись была неточной — при том, что за все прогоны
дедлоков не случилось ни одного. Ошибка была в названии величины, а не в
числах, но исправлять такое надо в коде, а не в оговорке к нему.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Таблица уровней изоляции из стандарта описывает, что уровню запрещено пропускать, а не что он пропускает в вашей базе. Документация PostgreSQL говорит это прямо, и отсюда растёт всё остальное.
- PostgreSQL строже таблицы: на REPEATABLE READ не проходят ни неповторяющееся чтение, ни фантом — уровень реализован снимком, а не тем минимумом, которого требует стандарт.
- И слабее ожиданий там, где таблица молчит: write skew на REPEATABLE READ проходит целиком. Измерено: двое дежурных, каждая транзакция проверяет инвариант и видит двоих, обе фиксируются — дежурных остаётся ноль.
- Инвариант при этом можно удержать и без SERIALIZABLE — явной блокировкой. Но охраняет тогда не уровень, а блокировка, и у неё есть граница:
SELECT ... FOR UPDATEзахватывает только те строки, которые вернул. На предикате по несуществующим строкам приём молча не срабатывает — это тоже измерено. READ UNCOMMITTEDпринимается, иtransaction_isolationвозвращает именно его. Но ведёт он себя какREAD COMMITTED: грязное чтение недостижимо ни на одном уровне.- И главное: «SERIALIZABLE обязательно медленный» — слишком грубое правило. В этом замере он и REPEATABLE READ неразличимы: 1215 против 1312 тр/с при разбросе между кругами в 16 и 26 процентов, и порядок между ними от круга к кругу переворачивается. Платите вы не скоростью запроса, а повторами: почти половина попыток отказывается с 40001.
На самом деле
- Она описывает, что уровню ЗАПРЕЩЕНО пропускать. Документация PostgreSQL говорит это прямо: the standard specifies which anomalies must not occur at certain isolation levels; higher guarantees are acceptable. Зазор двусторонний: база бывает строже таблицы — и тогда вы защищаетесь от того, чего не бывает; и пропускает то, чего в таблице нет вовсе, — и тогда не защищаетесь совсем.
- В PostgreSQL не пропускает. Уровень реализован снимком, поэтому новых строк для транзакции не появляется, и документация подтверждает: The table also shows that PostgreSQL's Repeatable Read implementation does not allow phantom reads. Воспроизведено: на этом уровне не проходят ни неповторяющееся чтение, ни фантом. Дописывать блокировки РАДИ ПОВТОРЯЕМОСТИ ЧТЕНИЯ поэтому незачем: повторный обычный
SELECTфантома не увидит. Это не то же, что защита инварианта от конкурентных записей — там блокировки бывают нужны. - Не достаточно — это и есть write skew. Измерено: двое дежурных, каждая транзакция проверяет инвариант «дежурных хотя бы один», обе видят двоих, обе снимают с дежурства своего — дежурных остаётся ноль, и база не сообщает ничем. Уровень защищает то, что вы ПРОЧИТАЛИ, а не то, что вы из прочитанного заключили. Удержать вывод можно и здесь — взяв строки предиката под
SELECT ... FOR UPDATE, что тоже измерено, — но тогда за инвариант отвечает блокировка, а не уровень. Самой аномалии нет в таблице стандарта, и это признанный пробел: Remark 5. ANSI SQL isolation phenomena are incomplete. - Как общее правило — слишком грубо. В измеренной нагрузке два верхних уровня неразличимы: 1215 транзакций в секунду против 1312 у
REPEATABLE READпри собственном разбросе кругов в 16 и 26 процентов, и по кругам они обгоняют друг друга. Отрыв даётREAD COMMITTED(2798), и то лишь при плотном соперничестве: на редком он сжимается до четверти. Платится всё это повторами — почти половина попыток отказывается с 40001 на плотном наборе и около шести процентов на редком. Вывод — про эту нагрузку: у SSI есть собственная стоимость, и на транзакциях другой формы она может стать заметной. - Есть — он принимается, и
transaction_isolationвозвращает именноread uncommitted. Нет у него другого: поведения. Ведёт он себя какREAD COMMITTED, и незафиксированное прочитать нельзя ни на одном уровне. Различие не придирка: «настройка называется так» и «поведение такое» — разные утверждения, и первое ничего не говорит о втором. - Это штатный ответ уровня, который отвергает неразложимую историю целиком, а не блокирует по дороге. И приходит он не обязательно на фиксации: в сценарии с write skew — на
COMMIT, а в сценарии с потерянным обновлением тот же 40001 приходит уже на команде. Поэтому повторять надо транзакцию целиком, а не оборачивать одну фиксацию. Договор двусторонний: база берёт на себя обнаружение, вызывающая сторона — повтор. Документация ставит это условием применения: It is important that an environment which uses this technique have a generalized way of handling serialization failures. Код, который ловит исключение и пишет в лог, договор не выполняет — он теряет работу.
Что разобрано
- База: аномалия — это свойство порядка, а не запроса
- Таблица описывает не то, что вы думаете
- Строже таблицы: фантом на REPEATABLE READ не проходит
- Слабее ожиданий: write skew
- Инвариант без SERIALIZABLE: где блокировка держит, а где нет
- Грязное чтение: имя уровня и его поведение — разные вещи
- Цена: разрыв лежит не там, где его ищут
- Что с этим делать
- Чем измерено
Частые заблуждения
Таблица уровней изоляции описывает, что происходит в вашей базе
Она описывает, что уровню ЗАПРЕЩЕНО пропускать. Документация PostgreSQL говорит это прямо: the standard specifies which anomalies must not occur at certain isolation levels; higher guarantees are acceptable
(стандарт указывает, какие аномалии не должны возникать на определённых уровнях изоляции; более сильные гарантии допустимы). Зазор двусторонний: база бывает строже таблицы — и тогда вы защищаетесь от того, чего не бывает; и пропускает то, чего в таблице нет вовсе, — и тогда не защищаетесь совсем.
REPEATABLE READ пропускает фантомы — так написано в стандарте
В PostgreSQL не пропускает. Уровень реализован снимком, поэтому новых строк для транзакции не появляется, и документация подтверждает: The table also shows that PostgreSQL's Repeatable Read implementation does not allow phantom reads
(Таблица также показывает, что реализация Repeatable Read в PostgreSQL не допускает фантомных чтений). Воспроизведено: на этом уровне не проходят ни неповторяющееся чтение, ни фантом. Дописывать блокировки РАДИ ПОВТОРЯЕМОСТИ ЧТЕНИЯ поэтому незачем: повторный обычный SELECT фантома не увидит. Это не то же, что защита инварианта от конкурентных записей — там блокировки бывают нужны.
REPEATABLE READ достаточно, если транзакция проверяет условие перед записью
Не достаточно — это и есть write skew. Измерено: двое дежурных, каждая транзакция проверяет инвариант «дежурных хотя бы один», обе видят двоих, обе снимают с дежурства своего — дежурных остаётся ноль, и база не сообщает ничем. Уровень защищает то, что вы ПРОЧИТАЛИ, а не то, что вы из прочитанного заключили. Удержать вывод можно и здесь — взяв строки предиката под SELECT ... FOR UPDATE, что тоже измерено, — но тогда за инвариант отвечает блокировка, а не уровень. Самой аномалии нет в таблице стандарта, и это признанный пробел: Remark 5. ANSI SQL isolation phenomena are incomplete
(Замечание 5. Перечень аномалий изоляции в ANSI SQL неполон).
SERIALIZABLE медленный, поэтому его берут только когда очень нужно
Как общее правило — слишком грубо. В измеренной нагрузке два верхних уровня неразличимы: 1215 транзакций в секунду против 1312 у REPEATABLE READ при собственном разбросе кругов в 16 и 26 процентов, и по кругам они обгоняют друг друга. Отрыв даёт READ COMMITTED (2798), и то лишь при плотном соперничестве: на редком он сжимается до четверти. Платится всё это повторами — почти половина попыток отказывается с 40001 на плотном наборе и около шести процентов на редком. Вывод — про эту нагрузку: у SSI есть собственная стоимость, и на транзакциях другой формы она может стать заметной.
В PostgreSQL нет уровня READ UNCOMMITTED
Есть — он принимается, и transaction_isolation возвращает именно read uncommitted. Нет у него другого: поведения. Ведёт он себя как READ COMMITTED, и незафиксированное прочитать нельзя ни на одном уровне. Различие не придирка: «настройка называется так» и «поведение такое» — разные утверждения, и первое ничего не говорит о втором.
Отказ 40001 означает, что что-то сломалось
Это штатный ответ уровня, который отвергает неразложимую историю целиком, а не блокирует по дороге. И приходит он не обязательно на фиксации: в сценарии с write skew — на COMMIT, а в сценарии с потерянным обновлением тот же 40001 приходит уже на команде. Поэтому повторять надо транзакцию целиком, а не оборачивать одну фиксацию. Договор двусторонний: база берёт на себя обнаружение, вызывающая сторона — повтор. Документация ставит это условием применения: It is important that an environment which uses this technique have a generalized way of handling serialization failures
(Важно, чтобы среда, использующая эту технику, имела обобщённый способ обработки отказов сериализации). Код, который ловит исключение и пишет в лог, договор не выполняет — он теряет работу.
Проверка знаний
Транзакция на REPEATABLE READ считает свободные места, видит одно, бронирует его. Рядом другая делает ровно то же. Что произойдёт в PostgreSQL?
Источники и что читать дальше
2 ИСТОЧНИКА
- PostgreSQL 16 — 13.2. Transaction IsolationОфициальная документация. Три утверждения этой статьи взяты отсюда дословно. Про READ UNCOMMITTED: «PostgreSQL's Read Uncommitted mode behaves like Read Committed» (Режим Read Uncommitted в PostgreSQL ведёт себя как Read Committed). Про REPEATABLE READ и фантомы: «The table also shows that PostgreSQL's Repeatable Read implementation does not allow phantom reads» (Таблица также показывает, что реализация Repeatable Read в PostgreSQL не допускает фантомных чтений), и там же объяснение, почему это законно: «the standard specifies which anomalies must not occur at certain isolation levels; higher guarantees are acceptable» (стандарт указывает, какие аномалии не должны возникать на определённых уровнях изоляции; более сильные гарантии допустимы). Про отказ: «which always return with an SQLSTATE value of '40001'» (которые всегда возвращаются со значением SQLSTATE 40001). И про то, чем сделан SERIALIZABLE: «implemented using a technique known in academic database literature as Serializable Snapshot Isolation» (реализован при помощи техники, известной в академической литературе по базам данных как Serializable Snapshot Isolation).https://www.postgresql.org/docs/16/transaction-iso.html
- Berenson, Bernstein, Gray, Melton, O'Neil, O'Neil — A Critique of ANSI SQL Isolation Levels (SIGMOD 1995)Источник. Статья, которая ввела write skew в оборот под обозначением A5B: «A5B: r1[x]...r2[y]...w1[y]...w2[x]...(c1 and c2 occur) (Write Skew)» (A5B: r1[x]…r2[y]…w1[y]…w2[x]…(происходят c1 и c2) (перекос записи)). Она же прямо говорит, что список аномалий стандарта неполон: «Remark 5. ANSI SQL isolation phenomena are incomplete. There are a number of anomalies that still can arise» (Замечание 5. Перечень аномалий изоляции в ANSI SQL неполон. Существует ряд аномалий, которые всё ещё могут возникать). На этом держится вся вторая половина статьи: аномалии, которой в таблице нет, таблица и не запрещает.https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf