Deep Engineering
Продвинутый·Опубликовано·30 МИН

Уровни изоляции: цена SERIALIZABLE — это повторы, а не медленный запрос

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

Полное техническое изложение

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 обязан защищать от неповторяющегося чтения и вправе пропускать фантом. Проверяем оба.

Неповторяющееся чтение — одна и та же строка, прочитанная дважды, между чтениями изменена и зафиксирована соседом:

PYTHON
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 COMMITTEDREPEATABLE READSERIALIZABLE
неповторяющееся чтениевоспроизвелосьнетнет
фантомвоспроизвелосьнетнет

Выделенная клетка — там, где база строже стандарта. Документация называет причину прямо: The table also shows that PostgreSQL's Repeatable Read implementation does not allow phantom reads (Таблица также показывает, что реализация Repeatable Read в PostgreSQL не допускает фантомных чтений). Уровень реализован снимком: транзакция видит базу такой, какой та была в момент её начала, — и вопрос «а не появилось ли новых строк» просто не возникает.

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

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

Слабее ожиданий: write skew

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

PYTHON
# Обе транзакции читают инвариант ДО того, как хоть одна записала.
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 COMMITTEDREPEATABLE READSERIALIZABLE
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 READSERIALIZABLE
на чём собранснимоктот же снимок
грязное чтениенетнет
неповторяющееся чтениенетнет
фантом при повторном обычном чтениинетнет
write skewвозможенотвергается
что добавлено сверхуотслеживание зависимостей: история, которую нельзя разложить в последовательную, получает отказ 40001

Строки с первой по четвёртую у них совпадают дословно — и это не случайность, а следствие того, что уровень собран поверх одного и того же снимка. Различают их две нижние.

Где именно приходит отказ

«SERIALIZABLE отказывает на COMMIT» — верно не всегда, и это видно в одном прогоне. В сценарии с дежурными до фиксации конфликта нет: транзакции трогают разные строки, и цикл зависимостей обнаруживается только на COMMIT. А в сценарии с потерянным обновлением обе лезут в одну строку, и тот же 40001 приходит уже на самой команде:

сценарийгде пришёл 40001
write skew, разные строкина фиксации
потерянное обновление, одна строкана команде

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

Инвариант без SERIALIZABLE: где блокировка держит, а где нет

Из предыдущего раздела легко сделать вывод шире, чем он есть: «инвариант охраняет только SERIALIZABLE». Это неверно, и проверяется так же — запуском.

Тот же сценарий с дежурными, но обе транзакции читают предикат под SELECT ... FOR UPDATE. И второй сценарий, отличающийся ровно одним: строк, которые удовлетворяют условию, ещё не существует. Инвариант — «на слот не больше одной записи», обе транзакции проверяют занятость и вставляют.

PYTHON
# Сценарий 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 UPDATEREAD COMMITTEDREPEATABLE READSERIALIZABLE
предикат по существующим строкамдежурных 1, вторая отступила самадежурных 1, вторая остановлена 40001дежурных 1, вторая остановлена 40001
предикат по отсутствующим строкамзаписей 2записей 2записей 1, отказ 40001

Верхняя строка опровергает широкую версию: инвариант удержан на всех трёх уровнях, в том числе на самом слабом. FOR UPDATE захватил те самые строки, по которым принимается решение, вторая транзакция была вынуждена подождать — и дальше увидела правду. Но охраняет здесь не уровень, а блокировка, и исход у неё разный: на READ COMMITTED вторая транзакция дочитывает свежую строку и отступает сама, на верхних уровнях тот же приём оборачивается отказом 40001. Инвариант цел в обоих случаях, а обрабатывать приложению нужно разное.

Нижняя строка показывает границу приёма. FOR UPDATE блокирует только фактически возвращённые строки; пустая выборка не создаёт блокировку диапазона, как это бывает в некоторых других СУБД. Обе транзакции видят «слот свободен», обе вставляют, и обе правы по отдельности. Здесь помогает либо SERIALIZABLE, либо ограничение уникальности — то есть защита, которая не зависит от того, что транзакция успела прочитать.

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

Грязное чтение: имя уровня и его поведение — разные вещи

Про READ UNCOMMITTED в PostgreSQL обычно говорят «его нет». Это близко к правде, но проверяемо не так, как кажется, и я на этом попался.

PYTHON
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 говорит это прямо: 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 (Важно, чтобы среда, использующая эту технику, имела обобщённый способ обработки отказов сериализации). Код, который ловит исключение и пишет в лог, договор не выполняет — он теряет работу.

Проверка знаний

Вопрос 1 из 5

Транзакция на REPEATABLE READ считает свободные места, видит одно, бронирует его. Рядом другая делает ровно то же. Что произойдёт в PostgreSQL?

Источники и что читать дальше

2 ИСТОЧНИКА

  1. 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
  2. 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