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_failure | 40P01 deadlock_detected |
|---|---|---|
| Когда возможен | REPEATABLE READ, SERIALIZABLE | Любой уровень изоляции, включая READ COMMITTED |
| Механизм обнаружения | Граф предикатных блокировок (SIReadLock) и конфликты снимков MVCC | Цикл ожидания обычных блокировок строк/объектов |
| Нужно ли ожидание блокировки | Не обязательно — конфликт может проявиться и без физического ожидания | Да, это всегда цикл взаимного ожидания |
| Кто решает, кого прервать | Сервер обрывает одну из конфликтующих транзакций в момент обнаружения опасной структуры | Сервер выбирает жертву цикла после проверки по deadlock_timeout |
| Диагностический параметр | max_pred_locks_per_transaction/relation/page | deadlock_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_transaction | 64 (минимум 10) | Среднее число объектов предикатных блокировок на транзакцию; задаётся только при старте сервера | SERIALIZABLE-транзакции затрагивают много разных таблиц/партиций за один заход (например, отчётные джобы, батчевые списания по нескольким справочникам) |
| max_pred_locks_per_relation | -2 (то есть max_pred_locks_per_transaction / 2) | Порог числа заблокированных страниц/кортежей в одном отношении, после которого блокировка укрупняется до уровня всего отношения | Много ложных 40001 на одной и той же широкой таблице при точечных операциях по разным строкам |
| max_pred_locks_per_page | 2 | Порог числа заблокированных строк на одной странице, после которого блокировка укрупняется до уровня страницы | Таблицы с плотной укладкой строк и точечными 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», разбор на проекте идёт по одной и той же последовательности шагов:
- Находим в коде все места, где после 40001/40P01 делается
ROLLBACK TO SAVEPOINTвместо полного отката транзакции, и выносим повтор на уровень функции, которая открываетBEGIN. - Включаем
log_lock_waits = onи оставляемdeadlock_timeoutна значении по умолчанию (1s) в проде; на тестовом контуре временно понижаем до 200–300 ms, если разбираем конкретный дедлок. - Смотрим
pg_stat_database.conflictsи частоту 40001 в логе приложения за неделю — это база для выбора числа попыток retry и базовой паузы backoff. - Проверяем область действия SERIALIZABLE: если уровень стоит глобально через
default_transaction_isolation, сужаем до конкретных модулей, где реально нужны serializable-гарантии (деньги, остатки, бронирование), остальное переводим на READ COMMITTED с явнымиSELECT ... FOR UPDATEтам, где нужна блокировка строки. - Нагрузочно проверяем модуль на конкурентных транзакциях (аналог pgbench со сценарием конкурентного обновления одних и тех же строк), считаем долю 40001/40P01 и то, укладывается ли retry-цикл в SLA по времени ответа.
- Только если после этого частота 40001 остаётся высокой из-за укрупнения блокировок —
поднимаем
max_pred_locks_per_transactionи планируем перезапуск сервера в окне обслуживания. - Добавляем ключи идемпотентности на все внешние вызовы, которые остались внутри повторяемых транзакций, если вынести их за пределы транзакции нельзя.
Такой порядок важен: чинить 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 — о конкуренции по данным или об укрупнении предикатных блокировок, и чинятся эти две причины разными действиями.