· 15 мин чтения

40001 в PostgreSQL 18: почему SAVEPOINT не спасает от serialization_failure

Клиент присылает лог: в PostgreSQL 18 приложение ловит SQLSTATE 40001, откатывает работу до SAVEPOINT, повторяет тот же UPDATE — и получает 40001 снова, иногда по кругу несколько раз подряд. Разбираю, почему частичный откат до точки сохранения не убирает причину конфликта, и как в АйТи Фреш настраиваем правильный retry на уровне всей транзакции: от параметров предикатных блокировок до backoff в драйвере.

Симптом: SAVEPOINT есть, ошибка 40001 не уходит

Типичный код, который приносят на аудит: транзакция открывается на уровне REPEATABLE READ или SERIALIZABLE, перед проблемным UPDATE ставится SAVEPOINT, при ошибке 40001 делают ROLLBACK TO SAVEPOINT и тут же повторяют тот же самый оператор в рамках той же транзакции. Логика понятна: SAVEPOINT дешевле, чем полный откат и повторное открытие соединения, читать заново справочники не хочется, да и часть бизнес-решений (например, какую сумму списывать) уже приняты по данным, прочитанным до конфликта. На PostgreSQL 18 это не работает — и не работало ни в одной версии сервера, потому что проблема не в статье, которая упала, а в самой транзакции целиком.

Дальше в статье — почему так, что именно PostgreSQL не сбрасывает при частичном откате, и как выстроить retry правильно: с полным перезапуском транзакции, свежим снимком данных и повторным прогоном всей бизнес-логики, а не только последнего SQL-запроса.

Что означают 40001 и 40P01 и откуда они берутся

SQLSTATE 40001 (serialization_failure) — код класса 40 «transaction rollback», который PostgreSQL возвращает и на уровне REPEATABLE READ, и на уровне SERIALIZABLE, когда сервер обнаруживает, что параллельное выполнение транзакций могло привести к результату, не достижимому ни при какой последовательной (одна за другой) их раскладке. Формулировки разные: для REPEATABLE READ это чаще could not serialize access due to concurrent update при попытке изменить строку, уже изменённую конкурентной транзакцией; для SERIALIZABLE — could not serialize access due to read/write dependencies among transactions, когда конфликтуют не сами строки, а зависимости чтения/записи между транзакциями (в терминологии PostgreSQL — Serializable Snapshot Isolation, SSI).

Важная деталь для мониторинга: сам факт конфликта виден не только по тексту ошибки в логе приложения, но и через счётчик conflicts в системном представлении pg_stat_database — он растёт при каждом отменённом из-за конфликта запросе. Это более надёжный источник для алертинга, чем grep по логам приложения, потому что не зависит от того, как конкретный драйвер форматирует исключение и на каком языке локализован текст сообщения.

Рядом стоит другой код — 40P01 (deadlock_detected). Его часто путают с 40001, потому что оба требуют повтора транзакции, но механизм обнаружения разный. Таблица ниже — как различать их в логах и в обработчике ошибок.

Параметр40001 serialization_failure40P01 deadlock_detected
Когда возможенREPEATABLE READ, SERIALIZABLEЛюбой уровень изоляции, включая READ COMMITTED
Механизм обнаруженияГраф предикатных блокировок (SIReadLock) и конфликты снимков MVCCЦикл ожидания обычных блокировок строк/объектов
Нужно ли ожидание блокировкиНе обязательно — конфликт может проявиться и без физического ожиданияДа, это всегда цикл взаимного ожидания
Кто решает, кого прерватьСервер обрывает одну из конфликтующих транзакций в момент обнаружения опасной структурыСервер выбирает жертву цикла после проверки по deadlock_timeout
Диагностический параметрmax_pred_locks_per_transaction/relation/pagedeadlock_timeout, log_lock_waits
Что делать приложениюОткатить и повторить всю транзакцию целикомОткатить и повторить всю транзакцию целиком

Снимок транзакции: когда он берётся и почему SAVEPOINT его не трогает

Ключевая деталь, которую упускают при проектировании retry-логики: в REPEATABLE READ и SERIALIZABLE снимок данных фиксируется один раз — в момент первой содержательной (не служебной) команды всей транзакции, а не перед каждым оператором, как в READ COMMITTED. Это прямо описано в разделе про уровни изоляции официальной документации: транзакция в REPEATABLE READ видит данные «как на начало первой не-управляющей команды транзакции», и все последующие SELECT внутри неё видят один и тот же срез данных, сколько бы SAVEPOINT ни было расставлено внутри.

SERIALIZABLE устроен как REPEATABLE READ плюс дополнительный слой SSI, который следит за зависимостями чтения и записи через предикатные блокировки (в pg_locks они видны как SIReadLock). Эти блокировки принадлежат верхнеуровневой транзакции, а не подтранзакции, открытой через SAVEPOINT. Смысл в том, что данные, прочитанные внутри подтранзакции, могли уже быть возвращены клиенту или повлиять на решение приложения до отката — поэтому сервер не имеет права тихо забыть о том, что чтение произошло, просто откатив блок кода назад по SAVEPOINT.

Отсюда и симптом из первого раздела: ROLLBACK TO SAVEPOINT отменяет только эффекты операторов после точки сохранения (незакоммиченные изменения строк внутри подтранзакции), но не создаёт новый снимок и не снимает предикатные блокировки верхнего уровня. Повторный UPDATE выполняется на том же самом снимке, с той же самой уже зафиксированной read/write-зависимостью — и естественно получает тот же 40001, иногда даже без повторного ожидания.

Показательный минимальный пример конфликта на SERIALIZABLE:

BEGIN ISOLATION LEVEL SERIALIZABLE; UPDATE public.balances SET amount = amount - 100 WHERE account_id = 42; SAVEPOINT sp1; -- в этот момент конкурентная SERIALIZABLE-транзакция читает те же строки -- и коммитится первой, создавая опасную структуру rw-конфликтов ROLLBACK TO SAVEPOINT sp1; UPDATE public.balances SET amount = amount - 100 WHERE account_id = 42; -- ERROR: 40001: could not serialize access due to read/write dependencies -- among transactions

Единственный способ получить действительно новый снимок и чистый набор предикатных блокировок — полностью завершить транзакцию (COMMIT или ROLLBACK верхнего уровня, а не SAVEPOINT) и открыть новую командой BEGIN.

Настройка diagnostics: deadlock_timeout, log_lock_waits, default_transaction_isolation

Прежде чем чинить retry-логику в коде, имеет смысл настроить сервер так, чтобы конфликты было видно в логах, а не только в исключениях приложения. Три параметра, с которыми мы начинаем разбор на проекте:

\# postgresql.conf — блок диагностики блокировок и уровня изоляции по умолчанию default_transaction_isolation = 'read committed' # менять на уровне BEGIN/сессии, не глобально deadlock_timeout = 1s # порог ожидания блокировки перед проверкой на deadlock (значение по умолчанию) log_lock_waits = on # писать в лог WARNING, если ожидание превысило deadlock_timeout max_pred_locks_per_transaction = 64 # значение по умолчанию, минимум 10 max_pred_locks_per_relation = -2 # по умолчанию; отрицательное = max_pred_locks_per_transaction / |N| max_pred_locks_per_page = 2 # значение по умолчанию

deadlock_timeout — это время ожидания обычной блокировки строки/объекта, после которого сервер запускает относительно дорогую проверку цикла ожидания (по умолчанию 1 секунда — минимальное практическое значение). Понижать его глобально ради более быстрого обнаружения дедлоков не стоит: каждая проверка — это обход графа блокировок, и на нагруженной базе частые проверки заметны по CPU. Обычная практика — оставить 1s в проде и временно понизить (например, до 200–300 ms) на реплике или в тестовом контуре, когда целенаправленно разбираем конкретный дедлок.

log_lock_waits включает запись в лог именно факта долгого ожидания — это не то же самое, что 40001. Дедлоки (40P01) всегда связаны с ожиданием и деадлок-таймаутом; конфликты сериализации (40001) в SERIALIZABLE могут случиться и без единой миллисекунды ожидания блокировки — сервер обнаруживает «опасную структуру» зависимостей чтения/записи в момент коммита или следующего оператора, а не через таймер ожидания. Поэтому log_lock_waits и deadlock_timeout помогают ловить 40P01, но почти бесполезны для диагностики 40001 — там нужен анализ через pg_stat_database (поле conflicts) и логирование самих ошибок 40001 на стороне приложения.

default_transaction_isolation оставляем READ COMMITTED глобально и поднимаем уровень точечно, командой BEGIN ISOLATION LEVEL SERIALIZABLE или на уровне пула соединений для конкретного модуля (обычно — денежные операции, списания остатков, резервирование мест/слотов), а не для всей базы: чем больше транзакций работают в SERIALIZABLE, тем выше суммарная частота 40001 под нагрузкой.

Предикатные блокировки: когда поднимать max_pred_locks_*

SSI хранит информацию о том, какие данные прочитала каждая SERIALIZABLE-транзакция, в виде предикатных блокировок (SIReadLock). Чтобы не расходовать разделяемую память безгранично, PostgreSQL укрупняет («promote») блокировки: с уровня строки на уровень страницы, а со страницы — на уровень отношения целиком, если лимит исчерпан. Чем крупнее гранулярность блокировки, тем выше шанс ложного конфликта — двух транзакций, которые физически не пересекались по строкам, но столкнулись из-за укрупнённой блокировки на всю страницу или таблицу.

ПараметрЗначение по умолчаниюЧто ограничиваетКогда поднимать
max_pred_locks_per_transaction64 (минимум 10)Среднее число объектов предикатных блокировок на транзакцию; задаётся только при старте сервераSERIALIZABLE-транзакции затрагивают много разных таблиц/партиций за один заход (например, отчётные джобы, батчевые списания по нескольким справочникам)
max_pred_locks_per_relation-2 (то есть max_pred_locks_per_transaction / 2)Порог числа заблокированных страниц/кортежей в одном отношении, после которого блокировка укрупняется до уровня всего отношенияМного ложных 40001 на одной и той же широкой таблице при точечных операциях по разным строкам
max_pred_locks_per_page2Порог числа заблокированных строк на одной странице, после которого блокировка укрупняется до уровня страницыТаблицы с плотной укладкой строк и точечными UPDATE по соседним ключам

На практике по опыту наших внедрений: если SERIALIZABLE применяется точечно (один-два модуля, транзакции короткие, задевают 2–5 таблиц), значений по умолчанию хватает с запасом. Поднимать max_pred_locks_per_transaction стоит, только если в логе регулярно видно рост частоты 40001 без роста реальной конкурентной записи по тем же строкам — это признак укрупнения блокировок, а не настоящих конфликтов данных. max_pred_locks_per_transaction требует перезапуска сервера (задаётся только при старте), два других параметра можно менять в postgresql.conf и перечитывать конфигурацию без остановки.

Retry на уровне приложения: что повторять и чего не сохранять

Официальная документация PostgreSQL прямо говорит: сервер не предлагает встроенного механизма автоматического повтора, потому что не может гарантировать корректность такого повтора — «важно повторить транзакцию целиком, включая всю логику, которая решает, какие SQL-запросы выполнять и какие значения использовать». Это и есть формальное объяснение антипаттерна из начала статьи: если при повторе сохраняются результаты предыдущих чтений и решения, принятые внутри конфликтовавшей транзакции (какую сумму списать, какую строку заблокировать, какой ID использовать), повтор не устраняет причину конфликта — он просто повторяет тот же путь по тому же снимку данных.

Правильный паттерн: поймать 40001/40P01, сделать ROLLBACK транзакции целиком (а не до SAVEPOINT), выдержать паузу с backoff и заново выполнить весь блок бизнес-логики — с новым BEGIN, новым снимком и заново прочитанными данными, на основе которых заново принимаются решения. SAVEPOINT остаётся полезным инструментом — но для локальной обработки ожидаемых, не связанных с 40001/40P01 ошибок (например, нарушение уникальности при вставке одной строки из батча), а не для восстановления после конфликта сериализации.

Отдельно стоит проговорить, что происходит с уже выполненными внутри той же транзакции операторами, которые не имеют отношения к конфликту. Раз транзакция откатывается целиком, откатываются и они — даже если сами по себе они были бы успешны в отрыве от конфликтующего оператора. Это ожидаемое поведение MVCC: атомарность транзакции не делится на «удачные» и «неудачные» операторы, поэтому проектировать бизнес-транзакции стоит так, чтобы повтор всего блока был дешёвым — короткие транзакции, минимум внешних вызовов внутри, явное разделение «читаем и решаем» и «пишем» там, где это не противоречит корректности.

В драйверах эта логика оформляется по-разному:

Драйвер/платформаКак распознать конфликтВстроенный retry
psycopg 3 (Python)psycopg.errors.SerializationFailure / DeadlockDetected — классы, сгенерированные из таблицы SQLSTATE (40001 / 40P01)Нет, повтор пишется вручную вокруг блока with conn.transaction():
Npgsql (.NET)PostgresException.SqlState == "40001" (или "40P01"); свойство IsTransient у общего NpgsqlExceptionЧастично — EnableRetryOnFailure() в EF Core-провайдере Npgsql, через execution strategy
JDBC (pgJDBC)PSQLException.getSQLState(), сравнение со строкой "40001" / "40P01"Нет, повтор — на стороне вызывающего кода (Spring умеет через @Retryable)
pgx (Go)pgconn.PgError.Code, сравнение с константами пакета pgerrcode (SerializationFailure, DeadlockDetected)Нет, обёртка пишется вручную вокруг pgx.BeginFunc

Общее во всех четырёх случаях: проверка идёт по SQLSTATE (это стандартный код ошибки SQL, а не текст сообщения — тексты локализуются и меняются между версиями), а сам повтор — это отдельная функция-обёртка над всем блоком транзакции, а не над одним оператором.

Нужен аудит конкурентного доступа к PostgreSQL?

Разбираем retry-логику, уровни изоляции и предикатные блокировки в вашей базе, настраиваем диагностику блокировок и внедряем правильный backoff в коде — так, чтобы 40001 и 40P01 не превращались в потерянные транзакции и зависшие пользовательские сессии. Пишите на почту АйТи Фреш или оставляйте заявку на сайте — начнём с разбора логов вашего сервера и текущей retry-обвязки в коде.

Backoff с джиттером и идемпотентность вне базы

Голый цикл «поймали 40001 — тут же повторили» на высококонкурентных участках (например, конец рабочего дня, массовое списание остатков по складу) приводит к тому, что несколько повторяющих друг друга транзакций синхронно бьются в одну и ту же зависимость и снова конфликтуют — классический retry storm. Экспоненциальный backoff с джиттером решает это разведением повторов по времени:

import time, random from psycopg import errors def run_with_retry(conn, work, max_attempts=5, base_delay=0.05, max_delay=1.0): for attempt in range(1, max_attempts + 1): try: with conn.transaction(): return work(conn) except (errors.SerializationFailure, errors.DeadlockDetected): if attempt == max_attempts: raise delay = min(max_delay, base_delay * (2 ** (attempt - 1))) time.sleep(delay * (0.5 + random.random())) # full jitter вокруг базовой паузы

Оценка по практике (не параметр PostgreSQL, а рабочий диапазон, с которым мы стартуем на новых проектах): базовая пауза 30–100 мс, потолок 1–2 секунды, 3–5 попыток. Документация PostgreSQL отдельно предупреждает: при высокой конкуренции для завершения транзакции может понадобиться не одна и не две попытки, поэтому лимит попыток должен быть параметром, а не жёстко зашитой константой «на всякий случай».

Отдельный вопрос — побочные эффекты за пределами базы: отправка письма, вызов внешнего платёжного API, публикация события в очередь. Если такой вызов стоит внутри тела транзакции, которая может быть повторена целиком, при повторе он выполнится второй раз. Решение — либо выносить внешние вызовы за пределы транзакции (после успешного COMMIT, по паттерну outbox), либо снабжать их ключом идемпотентности, который проверяется на стороне внешней системы или в отдельной таблице с уникальным ограничением внутри той же БД.

Чек-лист внедрения: как мы делаем это в АйТи Фреш

Когда клиент приходит с симптомом «SAVEPOINT не спасает от 40001», разбор на проекте идёт по одной и той же последовательности шагов:

  1. Находим в коде все места, где после 40001/40P01 делается ROLLBACK TO SAVEPOINT вместо полного отката транзакции, и выносим повтор на уровень функции, которая открывает BEGIN.
  2. Включаем log_lock_waits = on и оставляем deadlock_timeout на значении по умолчанию (1s) в проде; на тестовом контуре временно понижаем до 200–300 ms, если разбираем конкретный дедлок.
  3. Смотрим pg_stat_database.conflicts и частоту 40001 в логе приложения за неделю — это база для выбора числа попыток retry и базовой паузы backoff.
  4. Проверяем область действия SERIALIZABLE: если уровень стоит глобально через default_transaction_isolation, сужаем до конкретных модулей, где реально нужны serializable-гарантии (деньги, остатки, бронирование), остальное переводим на READ COMMITTED с явными SELECT ... FOR UPDATE там, где нужна блокировка строки.
  5. Нагрузочно проверяем модуль на конкурентных транзакциях (аналог pgbench со сценарием конкурентного обновления одних и тех же строк), считаем долю 40001/40P01 и то, укладывается ли retry-цикл в SLA по времени ответа.
  6. Только если после этого частота 40001 остаётся высокой из-за укрупнения блокировок — поднимаем max_pred_locks_per_transaction и планируем перезапуск сервера в окне обслуживания.
  7. Добавляем ключи идемпотентности на все внешние вызовы, которые остались внутри повторяемых транзакций, если вынести их за пределы транзакции нельзя.

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

Частые заблуждения, которые встречаем на аудитах

«SAVEPOINT дешевле, чем перезапуск всей транзакции, поэтому логичнее повторять его». Дешевле по объёму отменяемой работы — да, но бесполезно против 40001/40P01, потому что снимок и предикатные блокировки принадлежат транзакции верхнего уровня, а не подтранзакции. SAVEPOINT хорош для локальной обработки ожидаемых ошибок внутри транзакции (например, дубликат в батчевой вставке), не для восстановления после конфликта сериализации.

«В READ COMMITTED повторов можно не делать». Дедлоки (40P01) возможны на любом уровне изоляции, включая READ COMMITTED, если несколько транзакций блокируют строки в разном порядке. Retry-обвязку по SQLSTATE стоит держать в общем слое доступа к БД независимо от уровня изоляции конкретного модуля.

«Раз SERIALIZABLE безопаснее, включим его для всей базы». Каждая дополнительная SERIALIZABLE-транзакция — это дополнительная нагрузка на граф предикатных блокировок и более высокая вероятность 40001 у соседних транзакций, даже не связанных с исходной проблемой напрямую. На практике оправдано включать SERIALIZABLE точечно, для модулей, где действительно нужна гарантия отсутствия аномалий (write skew), а не как настройку «по умолчанию для надёжности».

«Если после трёх попыток снова 40001 — значит, в базе что-то сломано». Нет, документация PostgreSQL отдельно отмечает: при высокой конкуренции завершение транзакции законно может потребовать нескольких попыток подряд. Задача приложения — не паниковать после первого же повтора, а иметь разумный потолок попыток и понятную деградацию (очередь, отложенная обработка), если потолок исчерпан.

Частые вопросы

Почему PostgreSQL не откатывает снимок транзакции сам, когда я делаю ROLLBACK TO SAVEPOINT?
Потому что SAVEPOINT создаёт подтранзакцию внутри той же транзакции верхнего уровня, а не новую транзакцию. Снимок данных в REPEATABLE READ и SERIALIZABLE фиксируется один раз — на первой содержательной команде всей транзакции, и предикатные блокировки (SIReadLock) принадлежат верхнеуровневой транзакции. Частичный откат до точки сохранения не пересоздаёт ни то, ни другое, поэтому повторный запрос выполняется на том же снимке и с той же уже зафиксированной зависимостью чтения/записи.
В чём разница между 40001 и 40P01 с точки зрения кода приложения?
Для retry-обвязки разницы почти нет — оба кода требуют полного отката и повтора транзакции. Разница в диагностике на стороне сервера: 40P01 (deadlock_detected) — это классический цикл ожидания обычных блокировок, который ловится через deadlock_timeout и log_lock_waits; 40001 (serialization_failure) — конфликт графа предикатных блокировок в REPEATABLE READ/SERIALIZABLE, который может возникнуть даже без физического ожидания и не ловится через log_lock_waits.
Сколько раз нужно повторять транзакцию после 40001, прежде чем сдаться?
Официальная документация PostgreSQL не даёт фиксированного числа и прямо отмечает, что при высокой конкуренции может потребоваться несколько попыток подряд. По практике внедрений — 3-5 попыток с экспоненциальным backoff и джиттером обычно достаточно для типичной нагрузки; конкретный потолок стоит откалибровать по логам вашей базы, а не брать произвольное число.
Нужно ли поднимать max_pred_locks_per_transaction, если 40001 стал появляться чаще после обновления до PostgreSQL 18?
Не обязательно и не в первую очередь. Сначала стоит проверить по pg_stat_database и логам, не выросло ли число таблиц/партиций, которые одна SERIALIZABLE-транзакция трогает за раз — это чаще причина укрупнения предикатных блокировок и, как следствие, ложных конфликтов, чем сама версия сервера. Параметр меняется только при старте сервера, поэтому поднимать его стоит осознанно, после диагностики, а не профилактически.
Можно ли просто перехватывать все ошибки с кодом класса 40 и ретраить их одинаково?
Как единая точка входа в retry-обвязку — да, оба кода (40001 и 40P01) обрабатываются одной функцией полного повтора транзакции. Но полезно логировать их раздельно по конкретному SQLSTATE, потому что причины разные: рост 40P01 обычно говорит о порядке блокировок в коде, а рост 40001 — о конкуренции по данным или об укрупнении предикатных блокировок, и чинятся эти две причины разными действиями.
📄
Скачайте подробный разбор в PDF Кейсы, статистика, типовые ошибки и чек-лист самопроверки — 12 страниц
Скачать PDF

Подпишитесь на разборы ITfresh

Раз в неделю — практичные материалы по ИТ для бизнеса: без спама, только польза.

Письмо придёт в течение минутыНе нашли его во «Входящих» — загляните в папку «Спам» или «Промоакции» и нажмите «Не спам». Так все следующие выпуски будут приходить прямо в основную почту.