Ошибка could not serialize access due to concurrent update в PostgreSQL почти всегда приходит с SQLSTATE 40001 и возникает на уровне изоляции SERIALIZABLE. На практике это выглядит так: «под нагрузкой транзакции начали падать», при этом явных ожиданий блокировок или классического deadlock может и не быть.
Важно понимать: SERIALIZABLE в PostgreSQL — это не «чуть сильнее, чем REPEATABLE READ». Это режим, где СУБД обеспечивает эквивалентность некоторому последовательному (serial) выполнению транзакций. Если PostgreSQL видит, что параллельное выполнение могло бы дать результат, невозможный в последовательном мире, она не обязана «подвешивать» одну из транзакций. Вместо этого одна из транзакций будет отменена с serialization failure, чтобы клиент повторил попытку (retry).
Что означает ошибка 40001 и почему это штатно для SERIALIZABLE
Ключевое слово в сообщении — serialize. PostgreSQL обнаружила конфликт расписаний: две или больше транзакций одновременно читали и/или меняли данные так, что итоговый эффект нельзя корректно «уложить» в последовательное выполнение без изменения результата.
В PostgreSQL это реализовано механизмом SSI (Serializable Snapshot Isolation). Он отслеживает опасные структуры конфликтов (в первую очередь rw-конфликты) и, когда видит, что транзакция стала «опасной» для сериализуемости, завершает одну из них ошибкой 40001.
Для
SERIALIZABLE40001— это не «сбой базы». Это сигнал приложению: текущую бизнес-операцию нужно повторить, потому что параллельность помешала гарантировать сериализуемость.
Почему именно “concurrent update”
Фраза concurrent update обычно всплывает, когда транзакции пересекаются по одним и тем же строкам или когда обновление опирается на прочитанный набор строк (например, «проверил условие → затем изменил»). В более мягких режимах вы могли бы получить «последний победил» или скрытую потерю обновления; в SERIALIZABLE PostgreSQL обязана это предотвратить.
Типовые причины serialization failure в реальных приложениях
Ниже — ситуации, где serialization failure встречается чаще всего. Если узнаёте свой кейс, обычно правильное решение — комбинация из двух действий: снизить конфликтность транзакций и внедрить корректный retry.
1) «Проверил — потом обновил» (check-then-act) в одной транзакции
Сценарий: «проверить остаток/лимит/статус», затем сделать UPDATE. Две параллельные транзакции читают состояние по снимку, обе решают «можно», и обе пытаются обновить.
2) Очереди и «выбор следующей задачи»
Схема вида SELECT ... WHERE status='new' ORDER BY ... LIMIT 1, затем UPDATE выбранной задачи. Под конкуренцией это легко конфликтует в SERIALIZABLE, особенно если «выбор» зависит от набора строк, который параллельно меняется.
3) Счётчики, балансы и агрегаты
Обновления вида balance = balance - 1 или денормализованный счётчик в одной строке при большом числе конкурентных транзакций создают «горячую точку» (hot spot). В таком месте SERIALIZABLE чаще вынуждена «ронять» транзакции.
4) Длинные транзакции
Чем дольше живёт транзакция, тем выше шанс пересечься с другими транзакциями и попасть в опасную структуру SSI. Типичная ошибка — держать транзакцию открытой, пока приложение делает сетевые запросы, ждёт очередь или выполняет тяжёлые вычисления.

Как отличить 40001 от блокировок и дедлоков
В продакшене часто путают три разных класса проблем:
Ожидание блокировки: запрос «висит» и ждёт
lock(например, из-заSELECT ... FOR UPDATEили обычногоUPDATEпо той же строке).Deadlock: PostgreSQL обнаруживает цикл ожиданий и завершает запрос с SQLSTATE
40P01.Serialization failure: транзакция может выполняться быстро, но в конце получает
40001, потому что сериализуемость не гарантируется.
Если вы видите SQLSTATE 40001 и текст could not serialize access, это обычно не про «подкрутить lock_timeout» и не про «увеличить max_connections». Это про конфликтный конкурентный доступ к одним данным при SERIALIZABLE.
Диагностика: где именно возникает конфликт
Цель диагностики — ответить на два вопроса: какие транзакции падают и какие данные являются «точкой конфликта». На бою важно не включать чрезмерно шумные настройки, но несколько практик обычно безопасны.
Шаг 1: убедиться, что проблема именно в SERIALIZABLE
Проверьте, не выставляете ли вы уровень изоляции глобально или на уровне сессии (часто это делается в пуле соединений).
SHOW default_transaction_isolation;
SHOW transaction_isolation;
Если видите serializable, вы в нужной плоскости: без retry такие транзакции будут падать периодически и это ожидаемо.
Шаг 2: логирование медленных запросов не заменяет логирование ошибок
Ошибка serialization failure может случиться и в очень быстром запросе. Поэтому важно, чтобы приложение логировало контекст:
SQLSTATE (
40001);идентификатор запроса (query name) или шаблон SQL;
параметры (с маскировкой чувствительных данных);
идентификатор бизнес-операции (например,
order_id,user_id,task_id);номер попытки retry и итог (успех/провал).
Без этого в логах PostgreSQL вы часто будете видеть только факт ошибки без ответа на вопрос «какая операция и над какой сущностью конфликтует».
Шаг 3: посмотреть активность и длительность транзакций
Если конфликтность постоянная, снимите срез по активным сессиям: так проще увидеть длинные транзакции и пересечения по времени.
SELECT
pid,
usename,
application_name,
state,
wait_event_type,
wait_event,
xact_start,
query_start,
now() - xact_start AS xact_age,
LEFT(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
Для 40001 вы не обязаны увидеть ожидание блокировок. Но часто увидите длинные транзакции, которые резко повышают вероятность конфликтов SSI.
Шаг 4: подтвердить «горячие» сущности
Если подозреваете «горячую» строку (одна запись, которую обновляют все), самый быстрый путь — прикладной: логировать идентификатор сущности при падении 40001. На практике именно это помогает найти корень проблемы быстрее всего.
Если помимо конфликтов у вас возникают вопросы к общему здоровью кластера (автовакуум, индексы, bloat), держите под рукой материал про базовую настройку: тюнинг autovacuum и индексов в PostgreSQL.
Правильная стратегия: retry, но аккуратно
PostgreSQL в режиме SERIALIZABLE предполагает, что приложение умеет повторять транзакции. Базовая схема простая: поймали SQLSTATE 40001 → откатили → повторили всю транзакцию целиком.
Сколько раз делать retry
Типичный диапазон — 3–5 попыток. Больше имеет смысл только если операция действительно критична и вы готовы платить задержкой. Если после нескольких попыток всё равно падает — конфликтность системная, и нужно менять шаблон доступа к данным.
Нужен backoff (и желательно jitter)
Если два воркера одновременно «бодаются» за одну и ту же сущность, мгновенный retry часто приводит к синхронным повторным столкновениям. Добавьте задержку с ростом (exponential backoff) и случайной составляющей (jitter).
Retry должен оборачивать всю транзакцию
Нельзя «повторить только последний запрос». В SERIALIZABLE ошибка означает, что весь набор чтений/записей нельзя сериализовать. Повторять нужно с самого начала: BEGIN → все запросы → COMMIT.
Идемпотентность: защита от дублей
Самая опасная часть retry — побочные эффекты вне базы: отправка письма, вызов внешнего API, публикация события. Если транзакция в базе откатилась, а внешний эффект уже произошёл, при повторе вы получите дубль.
Практичные меры:
всё, что можно, делайте внутри транзакции в БД;
внешние эффекты запускайте по паттерну outbox: сначала записали событие в таблицу, затем отдельный воркер доставил;
для критичных операций используйте ключи идемпотентности (например,
operation_id) и уникальные ограничения.
Примеры retry в коде (псевдокод и практические шаблоны)
Ниже — универсальная логика, не привязанная к конкретному драйверу. Идея в том, где именно ловить ошибку и как правильно повторять транзакцию.
Универсальный псевдокод
max_attempts = 5
attempt = 0
while attempt < max_attempts:
attempt += 1
begin transaction isolation level serializable
try:
do business logic (select/update/insert)
commit
return success
except error as e:
rollback
if e.sqlstate == '40001':
sleep(backoff_with_jitter(attempt))
continue
else:
raise
raise "serialization failure after retries"
На что обратить внимание при реализации
Ловите именно SQLSTATE
40001, а не текст ошибки: текст меняется по версии и локали.После ошибки соединение должно быть в чистом состоянии. Если вы используете пул, убедитесь, что драйвер корректно делает
ROLLBACKперед возвратом соединения в пул.Логируйте номер попытки и фактическую задержку backoff — это сильно помогает при разборе инцидентов.

Как снизить количество конфликтов (и реже видеть could not serialize access)
Retry — необходимость, но лучше, чтобы он срабатывал редко. Несколько практичных способов снизить частоту 40001 и сообщений could not serialize access due to concurrent update.
1) Делайте транзакции короче
Уберите из транзакции всё «внешнее»: HTTP-запросы, ожидания очередей, тяжёлые вычисления. Подготовьте данные заранее, затем в короткой транзакции сделайте только чтения/записи.
2) Убирайте «горячие строки»
Если у вас один глобальный счётчик — подумайте о шардинге счётчика (несколько строк по хешу), о накоплении событий в отдельной таблице с последующей агрегацией или о пересмотре модели данных.
3) В очередях используйте SKIP LOCKED (если бизнес-логика позволяет)
Для воркеров, которые выбирают задачи, часто лучше использовать явную блокировку строк и пропуск заблокированных, чем заставлять SERIALIZABLE постоянно «разруливать» конфликт выбора через откаты.
BEGIN;
SELECT id
FROM jobs
WHERE status = 'new'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
UPDATE jobs SET status = 'processing' WHERE id = $1;
COMMIT;
Это не универсальное «лекарство от 40001», но для очередей обычно заметно снижает конкуренцию за одну и ту же задачу.
4) Там, где допустимо, используйте более мягкий уровень изоляции
Иногда SERIALIZABLE включают «на всякий случай» для всего приложения. Это дорого и по конфликтам, и по накладным расходам. Если бизнес-инварианты допускают REPEATABLE READ или READ COMMITTED для части операций, вы снижаете риск 40001 в разы.
5) Проектируйте инварианты через уникальные ограничения
Часть сценариев «проверил — потом вставил/обновил» лучше выражать ограничениями и атомарными запросами. Например, вместо «проверить что записи нет → вставить» используйте уникальный индекс и INSERT ... ON CONFLICT. Это не отменяет SERIALIZABLE, но часто уменьшает конфликтные чтения.
Чек-лист: что делать, когда в логах появился PostgreSQL 40001
Подтвердите, что операции идут в
SERIALIZABLEи что это осознанный выбор.Включите в приложении обработку SQLSTATE
40001и повтор транзакции целиком.Добавьте backoff+jitter и лимит попыток retry.
Проверьте, что внешние эффекты не дублируются (outbox/идемпотентность/уникальные ключи).
Найдите «горячие сущности» по прикладным логам (id объекта, тип операции).
Сократите транзакции и пересмотрите конфликтные шаблоны (очереди, счётчики, check-then-act).
Итог
could not serialize access due to concurrent update — ожидаемое поведение PostgreSQL в SERIALIZABLE, когда параллельные транзакции создают конфликт, несовместимый с сериализуемостью. Рабочий путь в продакшене — не «лечить» это настройками, а проектировать работу так, чтобы:
приложение делало корректный retry при SQLSTATE
40001;транзакции были короткими;
конфликтные «горячие точки» были минимизированы архитектурно.
Если под нагрузкой количество 40001 растёт и retries перестают помогать — это признак того, что конкретный участок бизнес-логики требует перепроектирования доступа к данным, а не «плохой базы».
Кстати, если вы упираетесь в конкуренцию соединений и хотите стабилизировать работу пула, посмотрите практическое руководство: PgBouncer и пул соединений PostgreSQL.


