Learning Platform
Урок 12.05 · 27 мин
Продвинутый
concurrency tuningVACUUMHOT chaindefault_transaction_isolationORMHibernateSequelizelong transactions

В предыдущих четырёх уроках мы говорили о механике изоляции. Теперь — про практику: какие операционные проблемы поднимают long-running транзакции, какой уровень изоляции стоит ставить в postgresql.conf (спойлер: тот же, что и default), и почему дефолты ORM — это часто не то, что ты ожидаешь.

Длинные транзакции и VACUUM

Самое важное правило production-Postgres: не держать долгих транзакций. Причина — OldestXmin:

OldestXmin: горизонт, ниже которого VACUUM не может тронуть dead tuples

VACUUM удаляет только те мёртвые версии, чей xmax COMMITTED и < OldestXmin. Длинная транзакция держит свой snapshot.xmin низко — и весь bloat накапливается.

OldestXminmin(snapshot.xmin) по всем активным транзакциям
VACUUM логикаdead tuple удалим, только если xmax < OldestXmin
следствиеодна забытая транзакция → весь VACUUM встаёт
мониторингpg_stat_activity.xact_start + state='idle in transaction' — это первое, на что смотрят в инцидентах 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.

PostgreSQL

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 (Heap-Only Tuple) updates
— оптимизация: если UPDATE не меняет индексируемых колонок и есть место на той же странице, новая версия пишется рядом со старой, связываясь через t_ctid. Индексы не обновляются.

Но HOT chain нужно периодически чистить: микро-VACUUM при чтении страницы (heap_page_prune) удаляет мёртвые звенья цепочки. Эта операция требует, чтобы tuple был мёртв для всех активных snapshot’ов:

HOT chain под длинной транзакцией

Без длинных транзакций HOT chain короткая, цепочка чистится при следующем чтении. С длинной транзакцией все версии живые — chain растёт, page забивается, в какой-то момент HOT перестаёт работать (нет места) и UPDATE переезжает на новую страницу с обновлением всех индексов.

нормально (HOT работает)v1 → v2 → v3 → ... быстро чистится
индексы не трогаемUPDATE = O(1)
длинная транзакция держит снимокv1 жив для T_old → v2 не может удалить
HOT chain распухаетстраница забивается, HOT отказ, UPDATE становится non-HOT
каждый UPDATEобновляет все индексы → 10x медленнее

Двойной эффект: длинная транзакция не только не даёт VACUUM работать, но и активно ломает оптимизацию в самих горячих UPDATE’ах. На большой OLTP-нагрузке это первое, что валит throughput.

default_transaction_isolation

Параметр в postgresql.conf (или через ALTER SYSTEM):

Параметры изоляции и связанные таймауты. Это базовый kit мониторинга concurrency.

PostgreSQL

Рекомендация: оставляй read committed по умолчанию. Аргументы:

  1. ORM настроены под RC. Поднимать его — приглашение к спорадическим 40001 в коде, который не готов retry’ить.
  2. Длинные RR/SERIALIZABLE-транзакции = bloat (см. выше).
  3. Уровень для конкретной транзакции всегда можно повысить через BEGIN ISOLATION LEVEL X локально, где это нужно.

Не меняй default’ы глобально из соображений «безопасности». Меняй там, где есть конкретный инвариант, который нужно защитить.

ORM-defaults: что они на самом деле выбирают

Это ключевая часть урока — большинство «странного» поведения на проде растёт отсюда:

Дефолты популярных ORM на Postgres

Hibernate и JDBC по историческим причинам наследуют isolation из конфига connection pool'а. Sequelize/TypeORM явно не задают уровень — берут default Postgres. Prisma в новых версиях явно использует RC.

Hibernate (Java)по умолчанию = из JDBC pool
HikariCP defaultTRANSACTION_NONE → RC
но!часто конфигурят 'REPEATABLE_READ' из советов SO
Sequelize (Node.js)default = БД default (RC)
но!можно поднять через Transaction.ISOLATION_LEVELS
retry-logicнет; сам пиши
Prismadefault = RC
$transactionопционально isolationLevel
retry-logicoptional через config
ActiveRecord (Rails)default = RC
transactionblock syntax с isolation: :serializable
retry-logicна тебя

Главные граблы:

  • 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’ить только при 4000140P01 deadlock_detected). Другие ошибки — не retry’ить.
  • Не retry’ить отдельные statement’ы. Транзакция целиком — это атомарная единица retry.
  • Jitter: задержки между retry должны быть рандомизированы, иначе все клиенты, упавшие в один момент, выстроятся в очередь и упадут снова на retry.
  • MAX_RETRIES: 3-5 разумно. Если после 5 повторов всё ещё конфликт — что-то структурно не так с дизайном транзакций.

Когда какой уровень

Финальный гайдлайн:

Decision tree: какой isolation level

Большинство кода живёт на RC. RR — для согласованности нескольких чтений. SERIALIZABLE — для инвариантов на множестве строк (write skew). SERIALIZABLE READ ONLY DEFERRABLE — для отчётов на горячей OLTP.

простой CRUDRC + retry на 40P01
много мелких независимых транзакцийRC покрывает 95% случаев
перевод между счетамиRC + SELECT FOR UPDATE
read-modify-write одной строкиRC + явный lock проще, чем RR
отчёт с многими SELECTRR (стабильный snapshot)
миграция данных батчемRR, если не пишет в источник
бизнес-инвариант на множествеSERIALIZABLE + retry
отчёты в SERIALIZABLE-окруженииSERIALIZABLE READ ONLY DEFERRABLE

И главное: никогда не поднимай default_transaction_isolation глобально. Это коммит на инфраструктуру retry-логики, которой в коде нет.

Проверка знанийKnowledge check
У вас на проде в Hibernate + Postgres приложении начали появляться 40001-ошибки на 0.3% запросов. Команда хочет их 'починить'. Какие три гипотезы стоит проверить ПЕРВЫМ ДЕЛОМ, и почему просто 'снизить уровень изоляции' — плохой ответ?
ОтветAnswer
Первое: проверь, что Hibernate действительно говорит RC, а не RR — типичная ошибка, когда HikariCP сконфигурирован на TRANSACTION_REPEATABLE_READ из старых советов. Грепни application.properties / connection-string на 'isolationLevel'. Второе: проверь @Version-аннотации — Hibernate optimistic locking превращает stale-read в 40001-like ошибки (StaleObjectStateException), хотя на уровне Postgres это могут быть deadlock'и или ww-конфликты. Третье: посмотри pg_stat_activity на 'idle in transaction' — если есть, то это утечки сессий, увеличивающие contention. 'Снизить уровень' — плохой ответ потому, что: (а) если он сейчас RR из-за конфига Hibernate, то понизить = сломать инварианты, которые код, возможно, считает гарантированными; (б) 0.3% это в норме при любом уровне выше RC, retry-логика — стандартный способ; (в) понижение уровня скрывает симптомы, не лечит причину — реальная проблема может быть в дизайне транзакций (длинные batch'и, hot-spot строк, отсутствие FOR UPDATE на critical sections).

Чек-лист

  • Длинные транзакции = главный враг 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.
BEGIN, COMMIT, ROLLBACK: как явно управлять транзакцией Архитектура сервера: thread pools, merge selector, жизненный цикл запроса

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Почему длинная REPEATABLE READ-транзакция вызывает рост bloat в других таблицах базы?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5