В предыдущих четырёх уроках мы говорили о механике изоляции. Теперь — про практику: какие операционные проблемы поднимают long-running транзакции, какой уровень изоляции стоит ставить в postgresql.conf (спойлер: тот же, что и default), и почему дефолты ORM — это часто не то, что ты ожидаешь.
Длинные транзакции и VACUUM
Самое важное правило production-Postgres: не держать долгих транзакций. Причина — OldestXmin:
VACUUM удаляет только те мёртвые версии, чей xmax COMMITTED и < OldestXmin. Длинная транзакция держит свой snapshot.xmin низко — и весь bloat накапливается.
Конкретно: транзакция Алисы открылась в REPEATABLE READ в 14:00. К 16:00 в таблице orders накопилось 100 тысяч dead tuples. VACUUM пытается их вычистить — но видит, что у Алисы snapshot.xmin = txid 1_000_000, а у мёртвых версий xmax = 1_000_500. OldestXmin = 1_000_000 < 1_000_500 → не трогаем. Bloat растёт линейно во времени.
В реальности часто виновата не злая аналитика, а:
idle in transaction— приложение открыло BEGIN, выполнило один запрос, забыло COMMIT. Connection в pool’е простаивает с открытой транзакцией.idle in transaction (aborted)— то же, но после ROLLBACK не закрыта.- Долгие SELECT’ы с
pg_dump, аналитика безREAD ONLY DEFERRABLE.
Мониторинг:
Топ-20 «застрявших» транзакций. На production здесь и ищем виновников bloat'а. Важные колонки: xact_start (когда открыта), state, query.
idle_in_transaction_session_timeout (Postgres 9.6+) и transaction_timeout (Postgres 17+) — параметры, которые автоматически abort’ят длинные транзакции. Production-defaults:
idle_in_transaction_session_timeout = '5min'
transaction_timeout = '10min' # PG 17+
HOT chain и почему длинная транзакция её ломает
Но HOT chain нужно периодически чистить: микро-VACUUM при чтении страницы (heap_page_prune) удаляет мёртвые звенья цепочки. Эта операция требует, чтобы tuple был мёртв для всех активных snapshot’ов:
Без длинных транзакций HOT chain короткая, цепочка чистится при следующем чтении. С длинной транзакцией все версии живые — chain растёт, page забивается, в какой-то момент HOT перестаёт работать (нет места) и UPDATE переезжает на новую страницу с обновлением всех индексов.
Двойной эффект: длинная транзакция не только не даёт VACUUM работать, но и активно ломает оптимизацию в самих горячих UPDATE’ах. На большой OLTP-нагрузке это первое, что валит throughput.
default_transaction_isolation
Параметр в postgresql.conf (или через ALTER SYSTEM):
Параметры изоляции и связанные таймауты. Это базовый kit мониторинга concurrency.
Рекомендация: оставляй read committed по умолчанию. Аргументы:
- ORM настроены под RC. Поднимать его — приглашение к спорадическим 40001 в коде, который не готов retry’ить.
- Длинные RR/SERIALIZABLE-транзакции = bloat (см. выше).
- Уровень для конкретной транзакции всегда можно повысить через
BEGIN ISOLATION LEVEL Xлокально, где это нужно.
Не меняй default’ы глобально из соображений «безопасности». Меняй там, где есть конкретный инвариант, который нужно защитить.
ORM-defaults: что они на самом деле выбирают
Это ключевая часть урока — большинство «странного» поведения на проде растёт отсюда:
Hibernate и JDBC по историческим причинам наследуют isolation из конфига connection pool'а. Sequelize/TypeORM явно не задают уровень — берут default Postgres. Prisma в новых версиях явно использует RC.
Главные граблы:
- Hibernate + Optimistic Locking: Hibernate имеет свою версию optimistic concurrency через
@Version. Многие команды включают её в дополнение к RR в БД, считая, что это «более безопасно». На самом деле они получают двойную работу и неочевидные 40001 в логах. - Sequelize / TypeORM и connection pools: транзакции часто открываются
await sequelize.transaction()без isolation level — берётся session default (= RC). А если потом вызываютfindOne({ lock: true })(=SELECT FOR UPDATE), то получается RC + lock — это разумно, но не RR. - Prisma
$transactionв interactive mode: каждая такая транзакция открывает явный BEGIN. Если разработчик не закрываетtx.commit()илиtx.rollback()(например, исключение убегает вверх) — мы получаемidle in transaction. Это главный источник bloat в Prisma-приложениях в нашей практике.
Connection pooling: где живёт реальный default
Когда между приложением и Postgres стоит pgbouncer в transaction-mode:
- Каждый
BEGINзапроса от приложения = новый physical session к Postgres. - Параметры сессии (
SET LOCAL) — действуют только до конца этого BEGIN. - Параметры сессии без
LOCAL(SET) — не работают в transaction mode (бэкенд переключается).
Это значит: SET default_transaction_isolation = 'repeatable read' через psql-сессию не повлияет на приложение через pgbouncer transaction-mode. Нужно либо менять через ALTER SYSTEM (постоянно), либо явно BEGIN ISOLATION LEVEL X в каждой транзакции приложения.
Retry-логика: единственный правильный способ
При любом уровне изоляции выше RC возможна ошибка 40001. Приложение обязано retry’ить:
for attempt in 1..MAX_RETRIES:
begin transaction
try:
do_work()
commit
break
except SerializationFailure: # SQLSTATE 40001
rollback
sleep(jitter(attempt)) # экспоненциальный backoff
continue
except OtherError:
rollback
raise
Важно:
- Retry’ить только при 40001 (и
40P01 deadlock_detected). Другие ошибки — не retry’ить. - Не retry’ить отдельные statement’ы. Транзакция целиком — это атомарная единица retry.
- Jitter: задержки между retry должны быть рандомизированы, иначе все клиенты, упавшие в один момент, выстроятся в очередь и упадут снова на retry.
- MAX_RETRIES: 3-5 разумно. Если после 5 повторов всё ещё конфликт — что-то структурно не так с дизайном транзакций.
Когда какой уровень
Финальный гайдлайн:
Большинство кода живёт на RC. RR — для согласованности нескольких чтений. SERIALIZABLE — для инвариантов на множестве строк (write skew). SERIALIZABLE READ ONLY DEFERRABLE — для отчётов на горячей OLTP.
И главное: никогда не поднимай default_transaction_isolation глобально. Это коммит на инфраструктуру retry-логики, которой в коде нет.
Чек-лист
- Длинные транзакции = главный враг production-Postgres: блокируют VACUUM, ломают HOT chain, накапливают bloat.
idle_in_transaction_session_timeoutиtransaction_timeout— must-have параметры. Дефолты 5-10 минут.- Мониторь
pg_stat_activityпоxact_startиstate IN ('idle in transaction', 'idle in transaction (aborted)'). default_transaction_isolation = 'read committed'— не меняй глобально. Повышай локально черезBEGIN ISOLATION LEVEL Xтам, где нужно.- ORM-pitfalls: Hibernate берёт уровень из JDBC pool; Prisma
$transactionбезcommit()оставляет idle in transaction. - Retry-логика обязательна для любого кода в RR/SERIALIZABLE. Retry — на 40001 и 40P01, с jitter, ограниченное число попыток.
- Decision tree: 95% RC, остальные 5% — точечно RR или SERIALIZABLE.
SERIALIZABLE READ ONLY DEFERRABLE— best practice для длинных отчётов: те же гарантии, ноль overhead.