АйТи Фреш
Главная / Статьи / 1С и базы данных
1С и базы данных

Ошибка 40001 после ROLLBACK TO SAVEPOINT: почему надо повторять всю транзакцию

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~24 мин чтения
Ошибка 40001 после ROLLBACK TO SAVEPOINT: почему надо повторять всю транзакцию
Иллюстрация к статье «Ошибка 40001 после ROLLBACK TO SAVEPOINT: почему надо повторять всю транзакцию».

Разработчик присылает лог: транзакция ловит «could not serialize access due to concurrent update», код откатывается на SAVEPOINT, повторяет тот же UPDATE — и получает ту же ошибку. Пять раз подряд за три миллисекунды. Затем исключение уходит наверх, а оператор склада экипировки видит красный экран. Ниже я разбираю, почему такой повтор не создаёт нового снимка, какую часть прикладной логики требует повторять PostgreSQL и как устроить retry-цикл без старых расчётов и двойных внешних эффектов.

40001 относится ко всей транзакции, а не к одному UPDATE

SQLSTATE 40001 (serialization_failure) не означает «этот UPDATE временно не получился, отправь его ещё раз». Сервер сообщает, что выполнение транзакции нельзя согласовать с допустимым порядком конкурентных операций. Официальная документация PostgreSQL 18 требует от приложений на уровнях REPEATABLE READ и SERIALIZABLE быть готовыми повторять транзакции, завершившиеся сериализационной ошибкой. Текст сообщения зависит от причины, локали и версии сервера, но код SQLSTATE остаётся 40001.

На REPEATABLE READ типичный текст — «could not serialize access due to concurrent update». Он появляется, когда изменяющая строку транзакция после ожидания обнаруживает, что конкурент уже изменил или удалил эту строку и закоммитил результат после формирования её снимка. PostgreSQL не может продолжить UPDATE от старой версии строки и заставляет приложение начать заново. На SERIALIZABLE возможен другой текст: «could not serialize access due to read/write dependencies among transactions». Его выдаёт механизм Serializable Snapshot Isolation, когда сочетание чтений и записей образует опасную структуру зависимостей, даже если две транзакции не обновляли одну строку одновременно.

После ошибки обычный транзакционный блок находится в состоянии failed: произвольные SQL-команды получают 25P02 (in_failed_sql_transaction) до полного ROLLBACK. Если ошибка произошла внутри подтранзакции, созданной SAVEPOINT, команда ROLLBACK TO SAVEPOINT может убрать failed-состояние этой подтранзакции и разрешить дальнейшие команды. Именно поэтому ошибочный retry выглядит правдоподобно: синтаксически соединение снова принимает SQL. Однако восстановление возможности выполнять команды не означает, что появился новый снимок или исчезла причина 40001.

Различие важно и для диагностики. Последовательность 40001, а затем 25P02 обычно означает, что приложение продолжило работу в уже проваленной транзакции без подходящего отката. Последовательность 40001, ROLLBACK TO SAVEPOINT и снова 40001 говорит о другой ошибке проектирования: код восстановил подтранзакцию, но повторил конфликтующую операцию в прежнем снимке. В обоих случаях лечить текст сообщения или увеличивать число локальных попыток бесполезно.

Ловите ошибки по пятисимвольному SQLSTATE, а не по тексту. PostgreSQL прямо предупреждает, что текст может меняться, тогда как код предназначен для программного разбора и не зависит от локализации.
Памятка: 40001 относится ко всей транзакции, а не к одному UPDATE — схема
Памятка: 40001 относится ко всей транзакции, а не к одному UPDATE. Открыть схему в полном размере

Почему ROLLBACK TO SAVEPOINT не создаёт новый снимок

На REPEATABLE READ снимок формируется в начале первой команды запроса или изменения данных внутри транзакции, а не при каждом новом операторе. В перечень таких команд входят SELECT, INSERT, DELETE, UPDATE, MERGE, FETCH и COPY. SAVEPOINT и ROLLBACK TO SAVEPOINT относятся к управлению транзакцией и нового снимка не создают. Откат к точке сохранения отменяет команды, выполненные после неё, уничтожает более поздние точки сохранения и запускает новую подтранзакцию на том же транзакционном уровне, но внешняя транзакция и её снимок остаются прежними.

Представим последовательность без абстракций. Сессия A прочитала остаток 10 и зафиксировала снимок. Сессия B изменила тот же остаток на 8 и закоммитила. Когда A пытается обновить строку, PostgreSQL видит, что актуальная версия появилась после снимка A, и выдаёт 40001. Откат к SAVEPOINT отменяет неудачную команду, но A по-прежнему видит снимок, в котором исходное значение равно 10. Закоммиченная версия B никуда не исчезла. Повтор того же UPDATE при неизменных условиях снова сталкивается с тем же фактом и снова завершается ошибкой.

Это не случайный эффект планировщика и не особенность конкретного драйвера. Снимком управляет сервер, а SAVEPOINT определяет границу частичного отката внутри уже существующей транзакции. Ни Psycopg, ни JDBC, ни ORM не способны превратить ROLLBACK TO SAVEPOINT в новый верхнеуровневый BEGIN. Если библиотека обещает retry, надо проверить, где именно она заканчивает старую транзакцию и начинает новую.

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

Я проверяю такой дефект простым признаком в трассировке: между двумя попытками должны быть видны завершение старой транзакции и начало новой. Новое физическое соединение не обязательно — исправное соединение можно использовать повторно после полного отката. Обязательна именно новая верхнеуровневая транзакция с новым снимком. Если в трассировке есть только SAVEPOINT и ROLLBACK TO SAVEPOINT, это не retry для 40001.

Быстрые повторные ошибки сами по себе ничего не доказывают, но одинаковые 40001 после `ROLLBACK TO SAVEPOINT` — сильный признак повтора в старом снимке. Подтверждайте это трассировкой границ транзакций.
Ошибка 40001 после ROLLBACK TO SAVEPOINT: почему надо повторять всю транзакцию — схема
Схема к статье. Открыть схему в полном размере

Повторять нужно решение, а не последний SQL-оператор

Раздел PostgreSQL 18 «Serialization Failure Handling» сформулирован недвусмысленно: повторять нужно полную транзакцию, включая логику, которая решает, какие SQL-команды выполнить и какие значения передать. Поэтому у PostgreSQL нет встроенного автоматического повтора с гарантией корректности. Сервер знает запросы и параметры, но не знает, из каких прежних чтений получилась сумма, почему приложение выбрало эту ветку и какие данные уже отправило во внешнюю систему.

Возьмём резервирование экипировки. Транзакция читает остаток по позиции и видит 3 единицы. Код решает зарезервировать все 3, отмечает заявку полностью обеспеченной и готовит запись в журнал. Между чтением и записью конкурент забирает 2 единицы, после чего первая транзакция получает 40001. Если повторить лишь UPDATE с уже вычисленным числом 3, решение останется основанным на устаревшем чтении. Даже в новой транзакции такой SQL способен списать больше доступного количества, если условие запроса не защищает инвариант.

Граница ретраимой функции поэтому должна охватывать BEGIN, все чтения, расчёты, ветвления, проверки и записи, а завершаться только после успешного COMMIT. Значения, зависящие от состояния базы, нельзя вычислить до этой функции и переносить между попытками. Это касается не только количества товара: номер документа, доступный кредитный лимит, выбранный временной слот, размер скидки и решение о статусе заявки имеют ту же природу.

Есть данные, которые переносить между попытками можно. Например, идентификатор входящего HTTP-запроса, пользовательская команда или заранее созданный идемпотентный ключ описывают намерение клиента, а не прочитанное состояние базы. Но итоговые суммы и ветвления всё равно надо вычислять заново. Полезный критерий такой: если значение могло бы измениться из-за коммита другой транзакции, его место внутри повторяемой функции.

Внешние эффекты требуют отдельной границы. Отправка письма, HTTP-вызов, публикация сообщения, операция платёжного провайдера или запись в чужую систему не откатываются вместе с PostgreSQL. Если выполнить такой эффект до COMMIT, а затем повторить транзакцию, получатель может увидеть его дважды. Я обычно записываю событие в outbox-таблицу в той же транзакции, а отдельный воркер отправляет его после коммита с идемпотентным ключом.

Полный повтор не гарантирует немедленный успех. Документация отдельно предупреждает, что при высокой конкуренции может понадобиться много попыток. Конфликт с подготовленной двухфазной транзакцией способен блокировать прогресс до её COMMIT PREPARED или ROLLBACK PREPARED. Поэтому retry должен иметь предел, метрики и понятный путь ошибки наверх, а долгоживущие prepared-транзакции надо обнаруживать и устранять отдельно.

Retry безопасен только тогда, когда повторяется бизнес-решение целиком. Если внутри функции есть необратимый внешний эффект, используйте transactional outbox или другой явно идемпотентный протокол.

Разбор практики: охранное предприятие «Рубеж», 44 сотрудника

Условный клиент в этом разборе — охранное предприятие «Рубеж», 44 сотрудника. Внутренняя система распределяет экипировку и расходные материалы между постами, принимает заявки и синхронизирует остатки. Бэкенд написан на Python с FastAPI и Psycopg 3. База — PostgreSQL 18.6 на площадке «АйТи-Фреш» в ЦОД МТС: 8 vCPU, 48 ГБ RAM и NVMe. Перед сервером работает PgBouncer в режиме transaction pooling. В postgresql.conf параметр default_transaction_isolation был установлен в значение repeatable read.

Проблема проявилась во время массового обновления заявок. В пике система обрабатывала около 240 транзакций резервирования в секунду на одни и те же популярные позиции, а 12,4 % попыток завершались SQLSTATE 40001. Разработчик добавил SAVEPOINT перед UPDATE, при ошибке выполнял ROLLBACK TO SAVEPOINT и повторял UPDATE до пяти раз. В журнале получались пять одинаковых 40001 за 3–4 мс и исключение наверх. За сутки ни одна такая локальная повторная попытка не стала успешной: снимок оставался прежним.

Первый перенос retry на уровень функции устранил бесконечный конфликт, но оставил логическую ошибку. Значение qty читалось до открытия повторяемой транзакции, потому что чтение считали дешёвым и не хотели выполнять снова. Через два дня контроль склада обнаружил три заявки с отрицательным остатком по одиннадцати позициям. Запись действительно повторялась в новой транзакции, но использовала число, рассчитанное по старому состоянию. На разбор журнала резервов ушло полдня.

Финальная граница охватила чтение остатка, проверку лимита подразделения, расчёт резерва, изменение таблицы остатков и запись в журнал. Каждая попытка начинала новую транзакцию. Уведомления другим системам перенесли в outbox-таблицу. Для интерактивного запроса оставили не более пяти попыток и full jitter: начальное окно 20 мс, удвоение после каждого отказа и предел окна 1 секунда.

На следующем сопоставимом периоде 88 % транзакций завершились с первой попытки, среднее число попыток составило 1,08, а максимальное наблюдавшееся число — 4. После исчерпания лимита осталось 7 отказов на 4,1 млн транзакций; отрицательных остатков не появилось. Эти цифры описывают конкретный условный разбор, а не обещанный норматив PostgreSQL: лимиты retry надо подбирать под допустимую задержку и фактическое распределение конфликтов.

PgBouncer не был причиной 40001. В режиме transaction pooling серверное соединение освобождается после завершения транзакции, поэтому приложение не должно рассчитывать на сохранение сеансового состояния между транзакциями. Для retry важно полностью закрыть неудачную транзакцию перед следующей попыткой. Брать новое физическое серверное соединение необязательно: повторное использование исправного соединения безопасно, если оно возвращено в состояние без открытой транзакции.

Transaction pooling не требует нового физического соединения для каждого retry. Требуются завершённый ROLLBACK старой транзакции и новый верхнеуровневый `BEGIN`; сеансовое состояние между транзакциями при таком режиме PgBouncer использовать нельзя.
Цифры и версии: Разбор практики: охранное предприятие «Рубеж», 44 сотрудника — схема
Цифры и версии: Разбор практики: охранное предприятие «Рубеж», 44 сотрудника. Открыть схему в полном размере

Как устроить рабочий retry-цикл

Обёртка должна получать функцию, содержащую всю транзакционную работу, сама открывать транзакцию и ловить только заранее выбранные SQLSTATE. Ошибка при выполнении COMMIT тоже должна попадать в тот же обработчик: на SERIALIZABLE конфликт может обнаружиться в конце. После неудачи контекст транзакции обязан выполнить полный откат, и лишь затем начинается следующая попытка.

Пауза нужна не для того, чтобы «дать базе отдышаться», а чтобы развести конкурентов во времени. При фиксированной задержке несколько столкнувшихся обработчиков могут синхронно проснуться и снова войти в конфликт. Full jitter выбирает случайную задержку от нуля до текущего окна, а окно экспоненциально растёт. В схеме с окнами 20, 40, 80 и 160 мс перед попытками со второй по пятую суммарная дополнительная задержка не превышает 300 мс без учёта времени SQL. Предел 1 секунда начнёт влиять только при большем числе попыток.

Пять попыток — настройка конкретного интерактивного сценария, а не правило PostgreSQL. Лимит должен укладываться в бюджет ответа сервиса и не скрывать длительную деградацию. Фоновой задаче можно дать больше времени, но бесконечный цикл недопустим: при постоянном конфликте, ошибке модели данных или зависшей prepared-транзакции он только удержит ресурсы и увеличит очередь.

Я считаю две прикладные метрики: число попыток до успешного коммита и число операций, исчерпавших лимит. Полезно также измерять SQLSTATE по имени операции, а не только по базе целиком. Счётчики xact_commit и xact_rollback в pg_stat_database показывают общий накопленный фон с момента сброса статистики и не позволяют выделить 40001 среди остальных причин отката.

Повторное использование одного клиентского соединения допустимо после гарантированного полного отката. Тем не менее контекст пула удобен: он возвращает соединение в пул после каждой попытки и отбрасывает сломанные соединения. Важен контракт пула, а не предположение, что каждый checkout обязательно создаёт новый PostgreSQL backend. У Psycopg 3 пакет пулов называется psycopg_pool и устанавливается отдельно либо через extra psycopg[pool].

Увеличение лимита retry не исправляет горячую строку, слишком длинную транзакцию или неверную границу бизнес-операции. Если распределение попыток сдвигается вправо, сначала ищите источник конкуренции.

Какие SQLSTATE действительно можно повторять

Для 40001 документация рекомендует полный повтор безусловно: это штатный способ приложения отреагировать на сериализационный отказ. Для 40P01 формулировка осторожнее — повтор может быть целесообразен. PostgreSQL обнаруживает цикл ожиданий, выбирает одну транзакцию жертвой и откатывает её; следующая попытка часто проходит, особенно если приложение во всех ветках блокирует объекты в одинаковом порядке.

Коды 23505 (unique_violation) и 23P01 (exclusion_violation) находятся в серой зоне. Официальный пример — приложение читает существующие первичные ключи, само выбирает следующее значение, а конкурент независимо выбирает то же значение. Для сервера это нарушение ограничения, хотя для прикладной логики оно похоже на сериализационный конфликт. После полного повтора чтение может привести к выбору другого значения.

Слепой retry для класса 23 опасен. Если пользователь второй раз прислал тот же внешний идентификатор, уникальное ограничение отражает постоянное состояние: дополнительная попытка ничего не изменит. Я разрешаю повтор 23505 или 23P01 только в конкретной операции, где код действительно перевычисляет конфликтующее значение на основе нового чтения. Такой случай должен иметь собственный небольшой лимит и тест конкурентного выполнения.

25P02 повторять как самостоятельную ошибку нельзя. Этот код сообщает, что текущая транзакция уже провалена из-за более ранней ошибки. Надо сохранить и диагностировать первый SQLSTATE, выполнить полный откат и принять решение по исходной причине. Если обработчик видит только 25P02, значит первичное исключение было потеряно или код продолжил посылать запросы после него.

Синтаксические ошибки, отсутствие прав, нарушение внешнего ключа и CHECK-ограничения обычно являются постоянными для данного входа и состояния программы. Включать весь класс исключений Psycopg в retry нельзя: туда входят разрывы соединения и ситуации с неизвестным результатом коммита, для которых простое повторение способно продублировать операцию. Политика должна перечислять конкретные SQLSTATE и отдельно решать проблему идемпотентности.

Не используйте проверку подстроки `serialize` или текста `deadlock detected`. В Psycopg 3 код сервера доступен как `exc.sqlstate`; это устойчивее текста и локали сообщения.

Как снизить частоту сериализационных отказов

Рабочий retry обеспечивает корректность, но не отменяет работу с конкуренцией. Начинаю с длительности транзакций: сетевые вызовы, ожидание пользователя, тяжёлое формирование отчёта и паузы между чтением и записью расширяют окно конфликта. Внутри транзакции должны остаться только действия, которым действительно нужна общая атомарность. Сессии idle in transaction надо мониторить, а подходящее значение idle_in_transaction_session_timeout подбирать по поведению приложения, не копируя чужой срок.

На REPEATABLE READ сериализационный отказ требуется только изменяющим транзакциям; документация прямо говорит, что read-only транзакции на этом уровне не получают таких конфликтов. На SERIALIZABLE читающая транзакция тоже участвует в анализе зависимостей. Для длинных отчётов предусмотрен режим SERIALIZABLE READ ONLY DEFERRABLE: начало чтения может подождать безопасного снимка, после чего транзакцию не придётся откатывать из-за сериализационного конфликта.

Предикатные блокировки SERIALIZABLE не блокируют конкурентные команды, но занимают память и фиксируют зависимости. Они отображаются в pg_locks с режимом SIReadLock и могут иметь гранулярность строки, страницы или отношения. Последовательное сканирование всегда требует предикатной блокировки уровня отношения, поэтому способно увеличить число ложноположительных конфликтов. Сначала я проверяю план и наличие подходящего индекса, а уже потом меняю стоимостные параметры планировщика.

Если сервер вынужден объединять мелкие предикатные блокировки в крупные, документация предлагает рассмотреть max_pred_locks_per_transaction, max_pred_locks_per_relation и max_pred_locks_per_page. В PostgreSQL 18 значения по умолчанию равны соответственно 64, -2 и 2. Первые два параметра можно задавать только в postgresql.conf или в командной строке сервера, а max_pred_locks_per_transaction требует запуска сервера с новым значением. Поднимать их без наблюдений нельзя: таблица предикатных блокировок использует общую память.

Уровень изоляции выбирают по требуемому инварианту. PostgreSQL REPEATABLE READ реализован как Snapshot Isolation и не допускает неповторяемого чтения или фантомов в принятом документацией смысле, но сериализационные аномалии всё ещё возможны. SERIALIZABLE добавляет SSI и гарантирует результат, эквивалентный некоторому последовательному выполнению успешно завершившихся транзакций, ценой учёта зависимостей и обязательного retry.

Не меняйте default_transaction_isolation всего кластера только ради одной операции. Используйте SET TRANSACTION сразу после BEGIN и до первой команды запроса или изменения данных. PostgreSQL 18 запрещает менять уровень после первого SELECT, INSERT, DELETE, UPDATE, MERGE, FETCH или COPY. Это ограничение относится к синтаксису и порядку команд, а не к пожеланию оптимизатора.

SERIALIZABLE не отменяет retry, а делает его частью контракта приложения. REPEATABLE READ тоже способен вернуть 40001 и при этом не защищает от всех сериализационных аномалий.
Порядок действий: Как снизить частоту сериализационных отказов — схема
Порядок действий: Как снизить частоту сериализационных отказов. Открыть схему в полном размере

Проверенный сценарий, код Psycopg 3 и наблюдаемость

Ниже воспроизводимый сценарий для двух сессий psql. Запускайте его только на тестовом экземпляре: первая команда создаёт отдельную демонстрационную таблицу, а последняя её удаляет. Сначала выполните блок сессии A до первого SELECT, затем блок B, после чего продолжите A.

-- Сессия A: подготовка и фиксация снимка
DROP TABLE IF EXISTS retry_demo_stock;
CREATE TABLE retry_demo_stock (
    sku_id bigint PRIMARY KEY,
    qty integer NOT NULL
);
INSERT INTO retry_demo_stock (sku_id, qty) VALUES (4711, 10);

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT qty FROM retry_demo_stock WHERE sku_id = 4711;
-- Снимок этой транзакции уже зафиксирован.
-- Сессия B: выполнить после SELECT в сессии A
BEGIN;
UPDATE retry_demo_stock
SET qty = qty - 2
WHERE sku_id = 4711;
COMMIT;
-- Сессия A: продолжение
SAVEPOINT sp1;
UPDATE retry_demo_stock
SET qty = qty - 1
WHERE sku_id = 4711;
-- ERROR: could not serialize access due to concurrent update

ROLLBACK TO SAVEPOINT sp1;
UPDATE retry_demo_stock
SET qty = qty - 1
WHERE sku_id = 4711;
-- При тех же условиях снова SQLSTATE 40001.

ROLLBACK;
DROP TABLE retry_demo_stock;

Пример обёртки ниже асинхронный, чтобы соответствовать FastAPI и AsyncConnectionPool из отдельного пакета psycopg_pool. На каждой итерации контекст пула выдаёт исправное соединение, conn.transaction() открывает новую транзакцию, а SET TRANSACTION выполняется до прикладных чтений. Возврат из функции происходит только после успешного выхода из транзакционного контекста, то есть после коммита.

import asyncio
import random

import psycopg

RETRYABLE_SQLSTATES = {"40001", "40P01"}


async def run_transaction(
    pool,
    work,
    *,
    attempts: int = 5,
    base_delay: float = 0.020,
    delay_cap: float = 1.0,
):
    if attempts < 1:
        raise ValueError("attempts must be at least 1")
    if base_delay < 0 or delay_cap < 0:
        raise ValueError("retry delays must be non-negative")

    for attempt_index in range(attempts):
        try:
            async with pool.connection() as conn:
                async with conn.transaction():
                    await conn.execute(
                        "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
                    )
                    return await work(conn)
        except psycopg.Error as exc:
            is_last_attempt = attempt_index + 1 == attempts
            if exc.sqlstate not in RETRYABLE_SQLSTATES or is_last_attempt:
                raise

            delay_window = min(
                delay_cap,
                base_delay * (2**attempt_index),
            )
            await asyncio.sleep(random.uniform(0.0, delay_window))

    raise RuntimeError("unreachable retry state")

Если соединение могло получить незавершённую транзакцию до входа в обёртку, это нарушение контракта вызывающего кода или пула: conn.transaction() станет вложенным контекстом с SAVEPOINT и не обеспечит нужный полный retry. Официальный контекст pool.connection() Psycopg при нормальном выходе коммитит открытую транзакцию, при исключении откатывает её, а сломанное соединение заменяет. Я всё равно покрываю тестом состояние соединения перед первой командой.

Для текстового журнала SQLSTATE добавляется escape-последовательностью %e в log_line_prefix. Параметры находятся в postgresql.conf, но абсолютный путь к этому файлу зависит от установки; его можно узнать командой SHOW config_file. log_min_error_statement со значением error записывает SQL-операторы, завершившиеся ошибкой уровня ERROR или выше.

# postgresql.conf
log_line_prefix = '%m [%p] %q%u@%d %e '
log_min_error_statement = error
SHOW config_file;
SELECT pg_reload_conf();
SELECT pg_current_logfile();

pg_reload_conf() перечитывает конфигурацию, однако выполнить её может не любой пользователь. pg_current_logfile() возвращает путь активного файла logging collector и требует по умолчанию права суперпользователя либо членство в pg_monitor; при выключенном logging collector функция возвращает NULL. Не зашивайте путь вида /var/log/postgresql/...: это соглашение конкретного пакета или операционной системы, а не универсальный путь PostgreSQL.

Два запроса дают общий фон. Первый показывает накопленную долю откатов по текущей базе и включает все причины, а не только 40001. Второй группирует активные предикатные блокировки SSI; на REPEATABLE READ строк с SIReadLock от этого механизма не будет.

SELECT datname,
       xact_commit,
       xact_rollback,
       round(
           100.0 * xact_rollback
           / NULLIF(xact_commit + xact_rollback, 0),
           2
       ) AS rollback_pct
FROM pg_stat_database
WHERE datname = current_database();

SELECT COALESCE(relation::regclass::text, locktype) AS locked_object,
       count(*) AS siread_locks
FROM pg_locks
WHERE mode = 'SIReadLock'
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10;

Для точного числа сериализационных отказов я предпочитаю счётчик в обработчике приложения с метками операции, SQLSTATE и номера попытки. Строки серверного журнала полезны для расследования, но простой подсчёт строк может завышать число транзакций из-за многострочных сообщений и связанных записей SQL. Метрика приложения привязана к реальной границе retry и сразу показывает, завершился ли последующий коммит.

Сначала воспроизведите конфликт на тестовом экземпляре и добавьте конкурентный тест к retry-обёртке. Такой тест должен доказывать не только успешный повтор, но и повторное выполнение всех чтений и вычислений.

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

Можно ли после 40001 откатиться на SAVEPOINT и повторить последний UPDATE?

Нет, это не корректный retry. `ROLLBACK TO SAVEPOINT` восстанавливает возможность выполнять команды внутри внешней транзакции, но не создаёт новый снимок. При прежнем снимке и уже закоммиченном конкурентном изменении повтор того же UPDATE снова сталкивается с той же причиной. Завершите внешнюю транзакцию и повторите всю операцию в новой.

Зачем тогда нужен SAVEPOINT?

Он нужен для частичного отката прикладного шага, когда бизнес-операция может продолжаться в той же транзакции. Например, можно отменить необязательную запись, не теряя более ранние изменения. SAVEPOINT полезен и используется транзакционными контекстами библиотек, но он не заменяет полный retry после 40001.

Обязательно ли брать новое соединение на каждую попытку?

Нет. Обязательна новая верхнеуровневая транзакция после полного отката. То же исправное физическое соединение можно использовать повторно. С пулом удобнее получать соединение внутри каждой итерации, но pool checkout не гарантирует новый backend и для корректности этого не требуется.

Сколько попыток и какую задержку установить?

У PostgreSQL нет универсального числа. В разобранной интерактивной операции использованы пять попыток и full jitter с начальным окном 20 мс, удвоением и пределом окна 1 секунда. Настройку надо связать с бюджетом задержки и метриками. Бесконечный retry недопустим.

Надо ли повторять deadlock 40P01?

Официальная документация говорит, что такой повтор может быть целесообразен. Выполняйте полный retry с новой транзакцией, но одновременно проверяйте порядок захвата блокировок: все ветки приложения должны блокировать общие объекты в согласованном порядке.

Можно ли повторять 23505 и 23P01?

Только в явно разобранном сценарии, где приложение само выбирает конфликтующее значение по результатам чтения и при новой попытке вычислит его заново. Если уникальный ключ пришёл от пользователя или внешней интеграции, конфликт обычно постоянный, и retry лишь повторит ту же ошибку.

Есть ли в PostgreSQL 18 встроенный автоповтор транзакций?

Нет. Сервер не знает всей прикладной логики, породившей SQL и параметры, поэтому не может безопасно повторить бизнес-решение. Документация требует, чтобы полный повтор организовало приложение.

Чем REPEATABLE READ отличается от SERIALIZABLE в этом контексте?

Оба уровня могут вернуть 40001 и требуют полного retry. REPEATABLE READ в PostgreSQL использует Snapshot Isolation и допускает сериализационные аномалии. SERIALIZABLE добавляет SSI, отслеживает зависимости чтение-запись и откатывает одну из транзакций, если успешный результат нельзя представить как последовательное выполнение.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи