Назад к блогу

Как устроены блокировки строк на уровне страниц в PostgreSQL

Как устроены блокировки строк на уровне страниц в PostgreSQL

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

Блокировка строки в PostgreSQL не заводит отдельного объекта в памяти: сведения о ней живут прямо в заголовке кортежа на странице, рядом с данными о версиях. Это делает механику неочевидной — блокировка и удаление используют одно и то же поле, а число одновременно блокирующих транзакций ограничено разрядностью заголовка. Разберём, что именно записывается в заголовок, как читатель отличает блокировку от удаления, как работает ожидание и почему «зависшая» блокировка невозможна.

Почему блокировки строк живут в заголовке кортежа

Заголовок heap-кортежа даёт очень мало места для сведений о блокировке и обновлениях: там помещается только один Xid и небольшое число битов infomask. Держать блокировки строк в отдельной структуре в разделяемой памяти нельзя: транзакции могут одновременно блокировать очень много строк.

Поэтому применяется двухуровневая схема. Первый уровень хранит информацию в заголовке строки: строка помечается как заблокированная установкой XID текущей транзакции в её XMAX и дополнительных infomask-битов. Второй уровень — стандартный менеджер блокировок: ожидание освобождения строковой блокировки выполняется через LockTuple(), затем XactLockTableWait(), затем пометку строки своей блокировкой и UnlockTuple(). LockTuple() ставит ожидающего в очередь на саму строку, чтобы несколько ожидающих выстроились по порядку, а XactLockTableWait() усыпляет его на идентификаторе транзакции держателя — так ожидание привязано к жизни транзакции, а не к строке. Арбитраж между несколькими ожидающими обеспечивает LockTuple().

Поле t_xmax служит местом, куда записывается Xid блокирующей транзакции — ровно туда же, куда его записал бы DELETE. Когда нужно хранить больше одной блокировки, Xid первого блокировщика заменяется на MultiXactId.

Поле t_infomask несёт биты, различающие вид блокировки:

  • HEAP_XMAX_LOCK_ONLY — xmax является только блокировщиком, строка никуда не девается;
  • HEAP_XMAX_EXCL_LOCK — задаёт силу блокировки: эксклюзивную, то есть самую строгую из четырёх режимов блокировки строки — FOR KEY SHARE, FOR SHARE, FOR NO KEY UPDATE и FOR UPDATE;
  • HEAP_XMAX_IS_MULTI — в t_xmax лежит MultiXactId, а не обычный TransactionId.

Поле t_infomask2 несёт бит HEAP_KEYS_UPDATED, который различает FOR UPDATE и FOR NO KEY UPDATE, а также ставится при UPDATE ключевых столбцов или DELETE. Поле t_xmin для описания блокировок строк не задействовано.

Что записывает SELECT ... FOR UPDATE и как читатель отличает блокировку от удаления

При SELECT ... FOR UPDATE идентификатор блокирующей транзакции записывается в t_xmax — туда же, куда его поместил бы DELETE. В t_infomask выставляются HEAP_XMAX_LOCK_ONLY и HEAP_XMAX_EXCL_LOCK, а в t_infomask2 — HEAP_KEYS_UPDATED, который отделяет FOR UPDATE от FOR NO KEY UPDATE. Причём HEAP_XMAX_EXCL_LOCK сам по себе эти два режима не различает — различие реализуется именно битом HEAP_KEYS_UPDATED.

Читатель отличает блокировку от удаления/обновления по биту HEAP_XMAX_LOCK_ONLY: он проверяет видимость, видит этот бит и считает строку живой, даже не проверяя, зафиксировался ли xmax. Без этого бита зафиксированный xmax от чистой блокировки читался бы как зафиксированное удаление.

При этом читатели вообще не ждут блокировок строк: видимость определяется снимком, и снимок решает, что они видят.

Четыре режима блокировки строки и их битовые шаблоны

Таблица MultiXactStatusLock сопоставляет каждому значению MultiXactStatus режим блокировки кортежа:

static const int MultiXactStatusLock[MaxMultiXactStatus + 1] =
{
	LockTupleKeyShare,			/* ForKeyShare */
	LockTupleShare,				/* ForShare */
	LockTupleNoKeyExclusive,	/* ForNoKeyUpdate */
	LockTupleExclusive,			/* ForUpdate */
	LockTupleNoKeyExclusive,	/* NoKeyUpdate */
	LockTupleExclusive			/* Update */
};

Четыре режима различаются по силе и по тому, что они разрешают другим: ForKeyShare. ForShare. ForNoKeyUpdate. ForUpdate. Доступ к этому отображению идёт через макрос TUPLOCK_from_mxstatus(status), возвращающий MultiXactStatusLock[(status)]. Отдельная таблица tupleLockExtraInfo для каждого LockTupleMode задаёт heavyweight-блокировку, статус для простой блокировки и статус для обновления; для LockTupleKeyShare и LockTupleShare статус обновления равен -1, так как эти режимы не позволяют обновлять кортежи.

Бит HEAP_XMAX_IS_MULTI устанавливается, если Xmax кортежа — MultiXactId. Бит HEAP_XMAX_LOCK_ONLY — когда XMAX только блокирует, то есть среди членов MultiXact нет обновления. Бит HEAP_KEYS_UPDATED указывает, что операция XMAX затрагивает ключ кортежа, и устанавливается независимо от того, является ли XMAX TransactionId или MultiXactId.

Режимы членов MultiXact (keysh, sh, nokeyupd, upd) хранятся во флагах каждого члена, а инфомаска кортежа суммирует сильнейший режим среди всех членов — это позволяет совместимому блокировщику получить блокировку без обхода списка членов. Решение о том, ждать ли, всё равно требует чтения списка.

Почему DELETE и UPDATE не выставляют lock-only биты

DELETE и UPDATE выставляют HEAP_KEYS_UPDATED, потому что этот бит указывает, что операция XMAX затрагивает ключ кортежа — например, SELECT FOR UPDATE, UPDATE ключевых столбцов или DELETE. Lock-only биты они не выставляют, так как те описывают только чистую блокировку: HEAP_XMAX_LOCK_ONLY устанавливается, когда XMAX — только блокировщик и среди участников MultiXact нет обновления.

Для DELETE это прямо объяснено: он выставляет HEAP_KEYS_UPDATED и никаких lock-битов, потому что обязан конфликтовать со всеми режимами блокировки, включая key share. Раз он должен блокировать даже те блокировки, которые не меняют ключ, его нельзя представить как lock-only с одним из битов силы блокировки. UPDATE, изменяющий ключевые колонки, тоже выставляет HEAP_KEYS_UPDATED, что и отличает его от FOR NO KEY UPDATE.

Пошаговый механизм: heap_lock_tuple

Порядок шагов таков: проверка видимости, вычисление нового xmax/infomask, взятие heavyweight tuple lock, запись xmax в буфер под блокировкой страницы.

Сначала функция читает буфер страницы, при необходимости пиннит visibility map, берёт эксклюзивную блокировку буфера и заполняет поля tuple, после чего вызывает HeapTupleSatisfiesUpdate для проверки видимости. Если кортеж невидим, функция сразу переходит к out_locked. Если кортеж изменяется незавершённой транзакцией, уже обновлён или удалён зафиксированной транзакцией, она копирует xwait, infomask, infomask2 и t_ctid, снимает блокировку буфера и при первом проходе проверяет, не удерживает ли уже текущая транзакция достаточно сильную блокировку. Затем при необходимости вычисляется необходимость ожидания (require_sleep), и если ожидание нужно, вызывается heap_acquire_tuplock для получения heavyweight tuple lock; при неудаче возвращается TM_WouldBlock. После этого под эксклюзивной блокировкой буфера вызывается compute_new_xmax_infomask, чтобы получить новый xmax и infomask, и выполняется запись xmax в буфер.

Когда heavyweight tuple lock не берётся и когда ожидание не нужно

Функция отключает acquisition heavyweight tuple lock (устанавливает skip_tuple_lock = true), когда при первом проходе по цепочке блокировок обнаруживается, что некоторая субтранзакция текущей транзакции уже держит блокировку не слабее запрошенной. Это делается, чтобы избежать взаимоблокировку с другим блокировщиком, который уже захватил heavyweight tuple lock и ждёт завершения нашей транзакции. При этом всё равно может потребоваться ожидание multixact, чтобы не получить конфликтующие блокировки.

Блокировка строки выполняется без ожидания (require_sleep = false) в случае LockTupleKeyShare, если в infomask2 не установлен бит HEAP_KEYS_UPDATED и после перепроверки под эксклюзивной блокировкой буфера оказывается, что строка всё ещё подходит для блокировки — не была обновлена или изменена так, что нужно перезапустить цикл.

Пошаговый механизм: heap_update и heap_lock_updated_tuple

heap_update определяет, что строка уже обновлена другой транзакцией, через результат HeapTupleSatisfiesUpdate: TM_Updated означает, что кортеж обновлён зафиксированной транзакцией, TM_Deleted — удалён зафиксированной транзакцией. Внутри HeapTupleSatisfiesUpdate при установленном HEAP_XMAX_COMMITTED и не только блокировке проверяется, указывает ли t_ctid на самого себя: если не указывает — возвращается TM_Updated, иначе TM_Deleted. В самом heap_update при result != TM_Ok заполняются tmfd->ctid и tmfd->xmax из старого кортежа, после чего буфер освобождается и возвращается результат.

heap_lock_updated_tuple сначала проверяет, не переместился ли кортеж в другую партицию (ItemPointerIndicatesMovedPartitions), и если да — возвращает TM_Ok, ничего не блокируя. Иначе вычисляет prior_xmax (при HEAP_XMAX_IS_MULTI — через MultiXactIdGetUpdateXid) и вызывает рекурсивную heap_lock_updated_tuple_rec.

Рекурсивная часть в цикле по tupid делает heap_fetch со SnapshotAny, берёт блокировку буфера, проверяет, что xmin кортежа совпадает с priorXmax (иначе цепочка закончилась, TM_Ok), и что xmin не принадлежит прерванной транзакции (иначе тоже TM_Ok), после чего блокирует текущую версию и при необходимости продолжает по цепочке. Если кортеж не находится, цепочка считается законченной: он мог быть вычищен после отката создавшей его транзакции.

MultiXact: когда одного xmax недостаточно

compute_new_xmax_infomask

Функция вызывается, когда транзакция add_to_xmax получает новую блокировку или обновляет кортеж, и должна вычислить новые значения Xmax и infomask/infomask2.

Если старый Xmax уже является MultiXactId, функция сначала проверяет, не «мёртв» ли он: если MultiXactIdIsRunning сообщает, что он больше не выполняется, и при этом либо все члены были только блокировками (HEAP_XMAX_IS_LOCKED_ONLY), либо обновляющая транзакция не закоммичена, флаг IS_MULTI сбрасывается, выставляется HEAP_XMAX_INVALID, и дальше кортеж обрабатывается так, будто в Xmax лежит обычный Xid без MultiXact.

Если MultiXact ещё жив, вызывается MultiXactIdExpand, который добавляет add_to_xmax с новым статусом, полученным через get_mxact_status_for_lock, и возвращает новый MultiXactId; затем GetMultiXactIdHintBits заполняет new_infomask и new_infomask2.

Новый MultiXact также создаётся через MultiXactIdCreate, когда старый Xmax — это закоммиченное обновление, либо когда старый Xmax — это выполняющаяся транзакция, кроме случая xmax == add_to_xmax: тогда выбирается более сильный из двух режимов блокировки, старый infomask помечается HEAP_XMAX_INVALID, и кортеж снова обрабатывается как обычный Xid без MultiXact.

MultiXactIdExpand

MultiXactIdExpand не изменяет существующий MultiXact, а создаёт новый. Сначала через GetMultiXactIdMembers получается список членов; если nmembers < 0 (MultiXact устарел), создаётся одиночный MultiXact из одного нового члена. Если переданный xid уже присутствует среди членов с тем же статусом, функция просто возвращает исходный multi.

Иначе выделяется массив newMembers размером nmembers + 1, и в него копируются только «живые» члены: те, для кого TransactionIdIsInProgress истинно, либо те, кто сделал обновление и зафиксировался. В конец добавляется новый член, и новый MultiXact создаётся вызовом MultiXactIdCreateFromMembers.

Проверка TransactionIdIsInProgress в MultiXactIdIsRunning выполняется до обращения к разделяемой памяти, потому что проверка себя дешевле обращения к ней: сначала идёт быстрый проход по членам с TransactionIdIsCurrentTransactionId, и только затем — проход с TransactionIdIsInProgress.

Хранение MultiXact на диске

GetMultiXactIdMembers сначала проверяет локальный кэш через mXactCacheGetById, и если набор найден, возвращает его без обращения к диску. Иначе он читает из pg_multixact/offsets: по номеру страницы и номеру записи, вычисленным из MultiXactId, берётся offset текущего MultiXactId, а затем offset следующего; их разность даёт число членов.

Сами члены лежат в pg_multixact/members: для каждого вычисляются страница и смещение, из страницы читается TransactionId, а статус извлекается сдвигом и маской MXACT_MEMBER_XACT_BITMASK. После чтения результат кладётся в локальный кэш вызовом mXactCachePut.

Кэш живёт только в пределах текущей транзакции (создаётся в TopTransactionContext), хранит записи в списке MXactCache и при превышении MAX_CACHE_ENTRIES удаляет самую старую запись с хвоста списка.

Замораживание MultiXact

FreezeMultiXactId вызывается из heap_prepare_freeze_tuple, когда в t_infomask установлен HEAP_XMAX_IS_MULTI, то есть raw xmax является MultiXactId. Функция возвращает новое значение xmax и через *flags сообщает вызывающему, что делать: оставить как есть, сделать xmax недействительным, вернуть одиночный xid обновления, пометить закоммиченным или вернуть новый MultiXactId.

Если MultiXactId недействителен или установлен HEAP_LOCKED_UPGRADED, xmax инвалидируется. Если multi предшествует OldestMxact и является только блокировочным, он удаляется без сохранения; если содержит обновление, то updater XID либо инвалидируется (если он до OldestXmin и не закоммичен), либо сохраняется как новый одиночный xmax. Во втором проходе по членам multi сохраняются только блокировщики, которые всё ещё выполняются; updater XID сохраняется отдельно, а has_lockers отражает наличие сохранённых блокировщиков.

В heap_prepare_freeze_tuple для xmax-MultiXactId вызывается FreezeMultiXactId, и далее по flags выбирается путь: удалить xmax полностью, заменить xmax или ничего не делать.

Ожидание и конфликты

DoesMultiXactIdConflict и Do_MultiXactIdWait

DoesMultiXactIdConflict сначала проверяет, не является ли infomask «upgraded» (для кортежей, переживших pg_upgrade), и в этом случае конфликт не обнаруживается. Затем он получает список членов MultiXact и для каждого определяет режим блокировки из статуса. Члены, принадлежащие текущей транзакции, пропускаются, но отмечаются в current_is_member.

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

Do_MultiXactIdWait получает членов MultiXact аналогично и для каждого члена, который не принадлежит текущей транзакции и конфликтует по режимам, выполняет ожидание: при nowait вызывает ConditionalXactLockTableWait, иначе XactLockTableWait. Члены, принадлежащие текущей транзакции, не ожидаются, а учитываются в remain. После завершения ожидания по всем конфликтующим членам функция записывает в remaining число оставшихся работающих членов.

Что видит клиент в момент ожидания

Когда сессия ждёт удаления строки, в pg_locks при locktype='tuple' видна одна запись с mode='AccessExclusiveLock' и granted='t' — это тяжёлая блокировка на строку, на которую ожидающий встал в очередь. Ожидающий берёт эту tuple-блокировку до того, как заснуть на идентификаторе транзакции держателя, чтобы последующие ожидающие выстраивались за ним по порядку, а не гонялись за строкой, когда держатель завершит; она исчезает, как только ожидающий получит строку или сдастся.

pg_locks показывает только то, чего ждёт заблокированная сессия, и никогда — то, что сессия уже удерживает на уровне строк. Поэтому кажется, что строку никто не держит, хотя на самом деле вставлена дочерняя строка, ещё не закоммиченная, и единственные следы — это запись tuple плюс transactionid, которого она ждёт. Если ожидание длится дольше statement_timeout, оно прерывается: DELETE возвращает ERROR: canceling statement due to statement timeout с контекстом while deleting tuple (0,4) in relation "lock_demo".

Видимость и hint bits

HeapTupleSatisfiesUpdate возвращает TM_Ok, когда кортеж действителен и видим и может быть обновлён: если xmax недействителен, или если блокировка только (HEAP_XMAX_IS_LOCKED_ONLY) и блокирующая транзакция не выполняется, или если мультитранзакция не запущена.

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

TM_Updated возвращается, когда кортеж обновлён зафиксированной транзакцией, включая случай перемещения в другую партицию; это определяется по несовпадению t_self и t_ctid. TM_Deleted возвращается, когда кортеж удалён зафиксированной транзакцией, то есть t_self и t_ctid совпадают.

HEAP_XMAX_IS_MULTI указывает, что xmax является мультитранзакцией, и тогда для определения обновляющей транзакции используется HeapTupleGetUpdateXid.

Почему COMMIT не переписывает заголовок

COMMIT не переписывает заголовок кортежа, потому что для этого пришлось бы помнить все страницы, которые транзакция блокировала. Блокировка при этом «переживает» коммит: она снимается не потому, что кто-то правит кортеж, а потому что xid больше не «in progress», и любой, кто смотрит на строку, сам определит это, проверив статус транзакции.

Позже VACUUM, обходя страницы, проверяет такие «lock-only xmax» значения, видит, что транзакция завершена, и ставит hint bit, чего достаточно, чтобы все будущие посетители игнорировали остальные биты. Hint bits выставляет функция SetHintBits (обёртка над SetHintBitsExt), которая ставит commit/abort-биты на кортеж, когда это уместно.

Ставить hint bit о коммите безопасно только если запись о коммите гарантированно сброшена на диск раньше буфера, иначе нужно воздержаться. При этом hint bits об отмене транзакции можно ставить всегда.

Пограничные случаи и компромиссы

Одновременные FOR UPDATE и взаимоблокировки

Когда две транзакции одновременно пытаются взять FOR UPDATE на одну строку, работает двухуровневый механизм: сначала информация о блокировке записывается в заголовок кортежа (XMAX и infomask), а для ожидания применяется стандартный менеджер блокировок. Протокол ожидания: LockTuple(), затем XactLockTableWait(), затем пометка кортежа как заблокированного своей транзакцией, затем UnlockTuple().

При наличии нескольких ожидающих арбитраж обеспечивает LockTuple(), при этом не более одной блокировки кортежа удерживается или ожидается на бэкенд — так таблица блокировок не переполняется.

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

Сетевой разрыв и падение backend'а

Ожидание освобождения блокировки строки реализовано через блокировку на уровне менеджера блокировок: при необходимости подождать строчную блокировку выполняется XactLockTableWait по значению XMAX в заголовке кортежа. Каждая транзакция, получившая XID, вставляет в таблицу блокировок эксклюзивную блокировку на этот XID через XactLockTableInsert, а ожидающий запрашивает ShareLock на тот же XID и блокируется.

Когда держатель завершается (commit или abort), его эксклюзивная блокировка на XID освобождается, и ожидающий просыпается. После пробуждения XactLockTableWait в цикле проверяет TransactionIdIsInProgress и, если транзакция больше не выполняется, выходит из цикла. Поэтому «зависшая» блокировка невозможна: ожидание привязано к жизни транзакции-держателя, а не к самой строке, и завершение держателя автоматически снимает блокировку.

Почему выбран такой дизайн

Блокировки строк нельзя держать в разделяемой памяти, потому что транзакции могут одновременно блокировать очень много строк. Отсюда двухуровневая схема: заголовок строки плюс менеджер блокировок.

Компромисс с отдельной таблицей блокировок строк в том, что заголовок строки даёт очень мало места — только один Xid и немного infomask-битов. Поэтому при необходимости хранить больше одной блокировки Xid первого блокировщика заменяется на MultiXactId, а каждый MultiXact содержит массив Xids и флаги силы блокировки для каждого члена. Флаги хранят силу блокировки каждого члена и отличают чистого блокировщика от обновляющего.

Возможность наличия update внутри MultiXact означает, что они должны переживать сбои и перезапуски: будущему читателю кортежа нужно понять, закоммитилось обновление или откатилось. Поэтому pg_multixact обязан хранить страницы своих данных, пока MultiXacts в них ещё могут быть нужны.

Удалением старых MultiXacts занимается VACUUM при замораживании строк. Нижняя граница, ниже которой все multixacts удалены, хранится в pg_class.relminmxid для каждой таблицы; минимум по всем таблицам — в pg_database.datminmxid; минимум по всем базам — в checkpoint-записях, и CHECKPOINT удаляет сегменты pg_multixact/ старше этого значения после сброса записи.

Для защиты от переполнения счётчика MultiXactId в состоянии есть четыре порога: multiVacLimit — значение, при котором запускается autovacuum для заморозки; multiWarnLimit — порог, при котором в журнал выдаётся предупреждение о приближении к переполнению; multiStopLimit — порог, при котором новые транзакции перестают получать MultiXactId; multiWrapLimit — порог, при котором счётчик MultiXactId заворачивается. Для контроля расхода места членами MultiXact заданы пороги MULTIXACT_MEMBER_LOW_THRESHOLD и MULTIXACT_MEMBER_HIGH_THRESHOLD, при достижении которых vacuum начинает замораживать multixids агрессивнее.

Практические наблюдения: MultiXact-раздувание

Как измерить рост MultiXact

Рост MultiXact измеряется по счётчику следующего идентификатора в контрольной точке и по отставанию таблицы от него. Запрос SELECT next_multixact_id, next_multi_offset FROM pg_control_checkpoint(); показывает значения счётчиков на момент последней контрольной точки.

Для конкретной таблицы SELECT relname, relminmxid, mxid_age(relminmxid) FROM pg_class WHERE relname = 'mx_demo'; возвращает самый старый MultiXact, на который ещё ссылается таблица, и насколько счётчик убежал вперёд от него: mxid_age — это то, насколько счётчик убежал вперёд от самого старого MultiXact, на который ещё ссылается таблица.

VACUUM сдвигает relminmxid вперёд, удаляя или заменяя старые MultiXact в заголовках кортежей, и имеет собственный набор порогов для этого. Значения по умолчанию видны из SELECT name, setting FROM pg_settings WHERE name LIKE '%multixact%age';:

ПараметрЗначение
autovacuum_multixact_freeze_max_age400000000
vacuum_multixact_failsafe_age1600000000
vacuum_multixact_freeze_min_age5000000
vacuum_multixact_freeze_table_age150000000

Сценарий роста members_size

Долгая транзакция, которая трогает много родительских строк, вместе с постоянным потоком коротких вставок порождает рост multixact-членов. Типичный источник — ночная пакетная задача, держащая key-share блокировки на горячей справочной таблице, пока приложение продолжает вставлять.

При примерно пяти байтах на члена жёсткий лимит в 232 члена даёт около 20 ГБ. Когда 232 слот-члена исчерпываются, любое выражение, которому нужен новый multixact, падает с ошибкой multixact "members" limit exceeded, а autovacuum начинает агрессивнее замораживать multixacts, как только использована половина пространства.

VACUUM FREEZE замораживает таблицу, после чего можно проверить relminmxid и mxid_age(relminmxid) в pg_class, а также флаги кортежей через heap_page_items. Файлы в pg_multixact не уменьшаются сразу: они усекаются, как только datminmxid каждой базы продвинется за сегмент. datminmxid — это минимум relminmxid по таблицам базы, и системные каталоги всё ещё несут 1 от initdb во всех базах, включая эту, а template0 сдвигается только когда autovacuum дойдёт до прохода по wraparound.

Что из этого следует на практике

  • Блокировка строки — это запись в заголовке кортежа, а не объект в памяти. Отсюда все следствия: заголовок вмещает одну блокировку, при нескольких появляется MultiXact, а pg_locks не покажет удерживаемые строковые блокировки — только те, которых сессия ждёт.
  • HEAP_XMAX_LOCK_ONLY — ключевой бит, отличающий блокировку от удаления. Без него зафиксированный xmax от чистой блокировки читался бы как удаление, поэтому читатели проверяют именно его.
  • Ожидание привязано к транзакции-держателю, а не к строке. Пока держатель не завершится, ожидающий спит на его XID; завершение держателя автоматически снимает блокировку, поэтому «вечно висящей» строчной блокировки не бывает.
  • Долгая транзакция, трогающая много родительских строк, вместе с потоком коротких вставок с FK-проверками — источник роста multixact-членов. Жёсткий лимит в 232 члена при примерно пяти байтах на члена даёт около 20 ГБ, после чего новые multixact-выражения падают с ошибкой.
  • Мониторить рост MultiXact стоит по mxid_age(relminmxid) и счётчикам из pg_control_checkpoint(). VACUUM сдвигает relminmxid вперёд, но файлы в pg_multixact усекаются только когда datminmxid каждой базы продвинется за сегмент.

Где смотреть в коде

Источники

Похожее