PostgreSQL реализует Serializable Snapshot Isolation (SSI), используя предикатное блокирование и тестирование графа сериализации, чтобы достичь истинной сериализуемости без производительных штрафов традиционного двухфазного блокирования. Ошибка 40001 (serialization_failure) возникает в частности во время write skew или read-write конфликтов, когда две транзакции устанавливают цикл rw-зависимости. Например, Транзакция A читает строки, удовлетворяющие предикату (например, WHERE color = 'red'), Транзакция B читает строки, удовлетворяющие несовпадающему предикату (например, WHERE color = 'blue'), затем A обновляет строки до 'blue', в то время как B обновляет строки до 'red'. Ни одна транзакция не блокирует другую, но результат оказывается несериализуемым.
Этот паттерн представляет собой опасную структуру в графе сериализации: две последовательные rw-антирвзависимости, образующие потенциальный цикл. PostgreSQL обнаруживает это и прерывает одну транзакцию, чтобы предотвратить аномальные состояния. Проблема тонка, потому что транзакции могут изменять разные физические строки, что делает конфликт невидимым для механизмов блокировки строк, используемых на более низких уровнях изоляции.
Предписанное решение требует от приложения реализации оптимистичного цикла повторных попыток. При перехвате SQL EXCEPTION '40001' приложение должно откатить текущую транзакцию и повторить всю операцию с экспоненциальной задержкой. В отличие от взаимоблокировок, которые обычно разрешаются немедленным повтором, сбои сериализации при высокой загруженности выигрывают от случайных задержек, чтобы предотвратить «громадные стада».
-- Пример логики повторных попыток приложения на PL/pgSQL DO $$ DECLARE retries INT := 0; max_retries INT := 3; BEGIN WHILE retries < max_retries LOOP BEGIN SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE; PERFORM * FROM inventory WHERE category = 'electronics' AND count > 0; UPDATE inventory SET count = count - 1 WHERE item_id = 123; COMMIT; EXIT; EXCEPTION WHEN SQLSTATE '40001' THEN ROLLBACK; retries := retries + 1; PERFORM pg_sleep(power(2, retries) * 0.1); -- Экспоненциальная задержка END; END LOOP; END $$;
Платформа обмена концертными билетами позволила пользователям менять категории мест через логику проверки-затем-действия. Транзакция A проверила, что VIP места доступны, затем понизила удерживаемое VIP место до стандартного. В то же время Транзакция B проверила наличие стандартных мест и повысила стандартное место до VIP. В режиме READ COMMITTED обе транзакции прочитали доступность как истинную, выполнили обновления, и система в итоге закончила с отрицательным инвентарем в обеих категориях, несмотря на то что каждая транзакция проверяла ограничения.
Были разработаны три решения. Первое использовало явное блокирование SELECT FOR UPDATE, но это не сработало, когда запросы на доступность возвращали ноль строк, не получая никаких блокировок и оставляя систему уязвимой для фантомных вставок. Второй подход реализовал ADVISORY LOCKS с использованием pg_try_advisory_lock() для сериализации доступа к категориям мест, что предотвращало конфликты, но вводило сложные риски порядка блокировок и снижало пропускную способность на 40% из-за сериализации всех проверок категорий.
Третье решение приняло уровень изоляции SERIALIZABLE с циклом повторных попыток на уровне приложения. Это было выбрано, потому что оно гарантировало корректность без управления блокировками вручную, и накладные расходы на повтор были приемлемы, учитывая низкую частоту одновременных обменов по сравнению с операциями чтения. Реализация использовала обработчик повторных попыток JDBC, перехватывающий SQLException с SQLState 40001, ждущий 100мс * 2^попытка и повторяющий выполнение транзакции. Это полностью устранило случаи перепродажи, хотя латентность p99 увеличилась на 15мс в часы пиковых продаж.
В чем точное различие между предикатными блокировками в сериализуемой изоляции и блокировками строк в повторяемом чтении?
Повторяемое чтение предотвращает неповторяемые чтения, блокируя строки, фактически возвращаемые запросом, но не предотвращает фантомные чтения — новые строки, вставленные другими транзакциями, которые удовлетворяли бы предикату WHERE запроса. Сериализуемая изоляция использует предикатные блокировки, которые блокируют сам диапазон поиска, предотвращая любую вставку, которая соответствовала бы предикату запроса, даже в строки, которые не существовали, когда запрос выполнялся. Кандидаты часто путают это, ошибочно полагая, что Повторяемое чтение предотвращает фантомные чтения или что Сериализуемая просто блокирует существующие строки.
Как алгоритм тестирования графа сериализации определяет, какую транзакцию прервать, когда обнаружен цикл?
PostgreSQL использует стратегию «первый коммит победит», совмещенную с обнаружением опасных структур. Когда между конкурентными транзакциями формируется rw-конфликт (чтение-запись зависимость), система отслеживает, завершает ли этот край цикл в графе сериализации. Транзакция, которая завершает цикл, прерывается с SQLSTATE 40001. Выбор является детерминированным на основе структуры графа, а не возраста транзакции, отдавая предпочтение прерыванию транзакций, чье откат менее дорогостоящее или более позднее в обнаруженном цикле. Понимание того, что это предотвратительное прерывание (предотвращающее недопустимую историю), а не взаимоблокировка (ожидание блокировок), крайне важно для правильной обработки ошибок.
Почему SELECT FOR UPDATE может не предотвратить сбои сериализации в сценариях, когда сериализуемая изоляция обнаруживает конфликт?
SELECT FOR UPDATE получает ROW SHARE блокировки только на строках, которые существуют в момент выполнения. В паттернах проверки-затем-действия, когда исходный запрос возвращает ноль строк (например, проверка на нулевые доступные места), FOR UPDATE не получает никаких блокировок, позволяя другой транзакции вставить конфликтующую строку. Сериализуемая изоляция обнаруживает это как предикатный конфликт, поскольку результат «ноль строк» представляет собой допустимый набор чтения, который был недействителен из-за одновременной вставки. Кандидаты часто неверно предполагают, что FOR UPDATE предоставляет всестороннюю защиту, не понимая, что она не предлагает никакой защиты от фантомных вставок, когда предикат изначально не совпадает с ничем.