РostgreSQL автоматически обнаруживает взаимоблокировки. В статье рассматривается процедура обнаружения взаимоблокировок, трудоёмкость процедуры обнаружения, причины, по которым параметр конфигурации log_lock_waits зависит от параметра deadlock_timeout и что влияет на выбор его значения. Приводится пример, как использование select for update может приводить к взаимоблокировкам и как взаимоблокировки влияют на метрики pgbench.
Обычно, приводятся примеры взаимоблокировки двух процессов, но заблокироваться может и более двух процессов. Параметр конфигурации deadlock_timeout устанавливает время ожидания получения блокировки, по истечении которого будет выполняться проверка на наличие взаимоблокировки. Параметр log_lock_waits типа boolean (по умолчанию false) позволяет получить в логе кластера сообщение о том, что сессия ждёт получения блокировки дольше, чем значение deadlock_timeout.
Процедура обнаружения взаимоблокировок
Если процесс не может получить блокировку, то он засыпает и устанавливает себе таймер, чтобы проснуться через время, заданное параметром конфигурации deadlock_timeout (по умолчанию 1 секунда). Процесс проснётся до истечения таймаута, если блокировка ему будет предоставлена. По завершению таймаута процесс начинает процедуру обнаружения взаимоблокировки.
Процедура обнаружения взаимоблокировок относительно трудоёмка, так как набор блокировок не дерево, а граф. В графе проверяется наличия колец, которые указывают на взаимоблокировки.
Если взаимоблокировки нет, то процесс снова заснёт и больше не будет проверять, есть ли взаимоблокировка до получения блокировки или прерывания транзакции в соответствии с параметрами transaction_timeout и аналогичными параметрами. Почему процесс не будет дальше поверять ситуацию возникновения взаимоблокировки с частотой deadlock_timeout? Потому, что если нет взаимоблокировки, то она не может возникнуть, если только какой-то другой процесс после проверки заснувшим процессом не получит отказ в получении блокировки. А если другой процесс получит отказ, то он и проверит: не связан ли отказ в получении им блокировки с наличием взаимоблокировки. Например, первый процесс заблокировал ресурс, второй и третий процессы встали в очередь на получение блокировок и каждый из них один раз выполнил проверку на взаимоблокировку или пока спят (если ждут не дольше deadlock_timeout). Появляется четвёртый процесс, который встаёт в очередь за ресурсом, который заблокировал третий процесс. Если первый процесс запросит блокировку на ресурс, заблокированный четвертым процессом, то кольцо взаимоблокировок замкнётся. Даже, если 2 и 3 процесс уже выполнили проверку, то 4 процесс проснётся, выполнит проверку и обнаружит взаимоблокировку.

Если время ожидания получения блокировки меньше значения этого параметра, то процедура обнаружения не выполняется. Параметр косвенно влияет на частоту проверок. Частота проверок равна числу сообщений в логе кластера, создаваемых параметром log_lock_waits=true.
Параметр deadlock_timeout не задаёт интервал проверки на наличие взаимоблокировок, он задаёт время, через которое будет обнаружена и устранена взаимоблокировка с момента её возникновения.
Почему параметр конфигурации log_lock_waits зависит от параметра deadlock_timeout?
Потому, что после проведения процедуры обнаружения взаимоблокировок процесс засыпает, не выполняет никаких действий и будет разбужен другим процессом. Если процесс выполнил процедуру проверки взаимоблокировок? взаимоблокировок не обнаружил, то процесс логирует в журнал, что он ждал получения блокировки дольше, чем deadlock_timeout при установленном параметре log_lock_waits=true. Если бы log_lock_waits измерялся в секундах, то нужно было бы прописывать логику пробуждения процесса, что усложнило бы код и не улучшило бы производительность.
Трудоёмкость проверки наличия взаимоблокировок
При выполнении процедуры процесс монопольно блокирует доступ ко всем разделам таблицы блокировок, устанавливая блокировки на все разделы таблицы блокировок. Число разделов определяется макросом NUM_LOCK_PARTITIONS (по умолчанию 16) и разделы защищены легковесными блокировками. Пока не будут заблокированы все разделы таблицы блокировок, процесс не начнёт проверку наличия взаимоблокировок. До конца проверки ни один процесс экземпляра ни в одной базе данных не сможет получить новую блокировку (за исключением слабых блокировок по быстрому пути) и будет ждать, пока проверка на взаимоблокировку не завершится. Даже получение разделяемой блокировки (например, для выполнения SELECT) по обычному пути будет невозможно.
Скрытый текст
CheckDeadLock(void)
{ int i;
/* Acquire exclusive lock on the entire shared lock data structures. Must grab LWLocks in partition-number order to avoid LWLock deadlock.
Note that the deadlock check interrupt had better not be enabled anywhere that this process itself holds lock partition locks, else this will wait forever. Also note that LWLockAcquire creates a critical section, so that this routine cannot be interrupted by cancel/die interrupts. */
for (i = 0; i < NUM_LOCK_PARTITIONS; i++) LWLockAcquire(LockHashPartitionLockByIndex(i), LW_EXCLUSIVE);
На длительность проверки влияет размер структуры памяти, которая выделяется под хранение блокировок ("таблицы блокировок"). Размер структуры определяется формулой: max_locks_per_transaction * (max_connections + max_prepared_transactions).
На репликах параметр deadlock_timeout определяет через какое время процесс startup залогирует сообщение в журнал кластера при включённом параметре log_recovery_conflict_waits.
Если процессов на экземпляре много и на каком-то объекте, с которым работает большое число сессий, устанавливается сильная блокировка дольше, чем на deadlock_timeout, мешающая получить блокировку другим процессам, то все процессы ждут получения, и каждый из них выполняет процедуру обнаружения взаимоблокировок. Это замедляет работу не только с заблокированным объектом, но и всех процессов экземпляра.
В какое значение установить параметр? Стоит установить значение параметра log_lock_waits = true и настраивать значение deadlock_timeout так, чтобы сообщения об ожиданиях получения блокировки возникали не часто. В идеале значение должно превышать типичное время транзакций, чтобы повысить шансы на то, что блокировка будет освобождена, прежде чем ожидающая транзакция решит запустить проверку на взаимоблокировку. Установка log_lock_waits = true не влияет на производительность, но может создавать много сообщений в логе, по этой причине праметр по умолчанию не включён.
Взаимоблокировки при выполнении тестов
При написании нестандартных тестов можно допустить в тесте ошибку: ситуацию в которой параллельные сессии создают взаимоблокировку. Не всегда можно визуально обнаружить ошибку. Создадим файлы для теста и запустим его:
psql -c "drop table if exists t cascade;"
psql -c "create table t(pk bigserial, c1 text default 'a');"
psql -c "insert into t select *, 'a' from generate_series(1, 2);"
psql -c "alter table t add constraint pk primary key (pk);"
echo "select * from t for update;" > lock1.sql
echo "update t set c1='a';" >> lock1.sql
pgbench -T 100 -c 3 -P 3 -f lock1.sql
CREATE TABLE
INSERT 0 2
ALTER TABLE
pgbench (17.4 (Ubuntu 17.4-1.pgdg22.04+2))
starting vacuum...end.
progress: 3.0 s, 507.6 tps, lat 4.029 ms stddev 36.254, 1 failed
progress: 6.0 s, 420.2 tps, lat 6.064 ms stddev 56.469, 2 failed
progress: 9.0 s, 129.4 tps, lat 18.428 ms stddev 123.855, 3 failed
progress: 12.0 s, 452.2 tps, lat 4.240 ms stddev 38.502, 1 failed
progress: 15.0 s, 270.7 tps, lat 10.139 ms stddev 86.132, 3 failed
progress: 18.0 s, 275.3 tps, lat 7.593 ms stddev 69.791, 2 failed
progress: 21.0 s, 237.5 tps, lat 8.411 ms stddev 74.932, 2 failed
progress: 24.0 s, 298.7 tps, lat 7.227 ms stddev 67.375, 2 failed
В тесте простая логика из двух команд: select for update и update. С виду не должно быть проблем. Тест выполняется и показывает обычные значения tps. Значения tps нестабильны ("скачут") и имеется небольшое число failed транзакций.
В параллельной сессии увеличим значение deadlock_timeout до 10 секунд:
psql -c "alter system set deadlock_timeout = '10s';"
psql -c "select pg_reload_conf();"
В окне с работающим тестом теста поменяются показатели:
progress: 27.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 30.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 33.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 36.0 s, 269.5 tps, lat 27.411 ms stddev 496.869, 1 failed
progress: 39.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 42.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 45.1 s, 118.3 tps, lat 58.169 ms stddev 742.688, 1 failed
progress: 48.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 51.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
progress: 54.0 s, 0.0 tps, lat 0.000 ms stddev 0.000, 0 failed
Число failed с виду уменьшилось, но увеличение значения параметра уменьшило tps. В отсутствие взаимоблокировок увеличение значения deadlock_timeout улучшает производительность, а при наличии взаимоблокировок получилось наоборот: производительность снизилась. В интервалах, где tps=0 на failed не стоит смотреть. tps=0 означает, что в этом интервале не была завершена ни одна транзакция и все показатели нулевые. Завершение транзакций может попасть на следующий интервал и в нём будет резкий скачок tps выше обычного. Также выросло стандартное отклонение (stddev 496.869). Такой эффект может наблюдаться без взаимоблокировок. Если при проведении теста вы наблюдаете, что на каком-то интервале сначала было уменьшение tps, потом резкое увеличение tps и stddev, в отсутствие причин, это не значит, что экземпляр стал работать быстрее, это особенность учёта транзакций по интервалам.
В журнал кластера продолжали добавляться сообщения о взаимоблокировке:
2025-03-17 23:48:33.403 MSK [3509] postgres@postgres ERROR: deadlock detected
2025-03-17 23:48:33.403 MSK [3509] postgres@postgres DETAIL: Process 3509 waits for ShareLock on transaction 1544674; blocked by process 3508.
Process 3508 waits for ShareLock on transaction 1544672; blocked by process 3509.
Process 3509: update t set c1='a';
Process 3508: select * from t for update;
2025-03-17 23:48:33.403 MSK [3509] postgres@postgres HINT: See server log for query details.
2025-03-17 23:48:33.403 MSK [3509] postgres@postgres CONTEXT: while locking tuple (98,4) in relation "t"
2025-03-17 23:48:33.403 MSK [3509] postgres@postgres STATEMENT: update t set c1='a';
При выполнении нестандартных тестов могут возникать взаимоблокировки, которые сильно искажают результаты тестов. Стоит проверять журнал кластера на отсутствие сообщений о взаимоблокировках. Если причина взаимоблокировок не ясна, на тестовых кластерах баз данных можно использовать параметр конфигурации debug_deadlocks. При проверке на взаимоблокировки проверяющий процесс выводит в лог кластера список всех блокировок.
Если изменение параметра конфигурации приводит к деградации производительности, то нужно возвращать значение обратно или менять в другую сторону. Уменьшите значение deadlock_timeout до 100 миллисекунд:
psql -c "alter system set deadlock_timeout = '100ms';"
psql -c "select pg_reload_conf();"
Уменьшение значения параметра увеличит tps (увеличение tps может пройти с задержкой, если бы кольцо блокировок состояло из большого числа процессов):
progress: 57.0 s, 777.8 tps, lat 11.764 ms stddev 293.013, 7 failed
progress: 60.0 s, 912.0 tps, lat 3.093 ms stddev 6.340, 5 failed
progress: 63.0 s, 814.7 tps, lat 3.289 ms stddev 7.709, 7 failed
progress: 66.0 s, 917.7 tps, lat 3.112 ms stddev 7.116, 6 failed
progress: 69.0 s, 1011.3 tps, lat 2.860 ms stddev 4.656, 3 failed
progress: 72.0 s, 890.7 tps, lat 3.136 ms stddev 6.803, 6 failed
progress: 75.0 s, 923.6 tps, lat 3.059 ms stddev 6.071, 5 failed
progress: 78.0 s, 811.7 tps, lat 3.396 ms stddev 7.769, 7 failed
Помимо увеличения tps, увеличилось число failed транзакций. Увеличилось число взаимоблокировок в единицу времени. Однако, число взаимоблокировок на число успешно выполненных транзакций существенно уменьшилось. Поэтому значение failed нужно рассматривать вместе со значением tps, то есть пропорцию failed к tps. Если failed не нулевое, то стоит проверить журнал кластера на отсутствие сообщений о взаимоблокировках, а также о срабатывании таймаутов.
Заключение
По умолчанию, время, через которое будет обнаружена и устранена взаимоблокировка с момента её возникновения установлено в 1 секунду параметром конфигурации deadlock_timeout. Если среднее время ожидания получения блокировок дольше, то стоит увеличить значение параметра. Параметр log_lock_waits, установленный в true позволяет оценить частоту выполнения проверок на взаимоблокировку. Одна проверка создаёт в журнале кластера одно сообщение. На время проверки таблица блокировок полностью блокируется. При высокой нагрузке, когда проверяющий взаимоблокировки процесс вытесняется планировщиком операционной системы, длительность блокирования может увеличиться.