Общее#

Блокировки в Postgres позволяют ему управлять синхронизацией потоков для доступа к общим данным. Клиент, в свою очередь может подсказывать Postgres о том, с какой целью он пытается получить доступ к определенным данным, что поможет выбрать правильную блокировку (например, указать FOR UPDATE, FOR NO KEY UPDATE или другие модификаторы запроса). Так же, блокировки можно захватывать явно при помощи оператора LOCK (блокировка всей таблицы) или через рекомендательные блокировки - функции pg_advisory_lock, pg_advisory_lock_shared, pg_advisory_xact_lock и другие.

Как и при работе в конкурентной среде, где для определения очередности доступа к данным будут использоваться примитивы синхронизации типа Mutex, SpinLock или другие, так и Postgres использует подобные механизмы у себя. И так же как в приложении, в Postgres так же может попасть в ситуацию с deadlock - взаимной блокировкой. Если Postgres заметит подобную ситуацию - он непременно сообщит об этом в лог с указанием того, на каком ресурсе, какие процессы и с какими запросами были взаимно заблокировны.

При работе приложения, без нагрузки или в условиях, где запросы к СУБД не используют изменение нескольких сущностей в рамках транзакции - вероятность попасть в ситуацию с взаимной блокировкой низкая. Однако в проектах, которые обслуживают большое кол-во запросов такие ситуации могут встречаться чаще.

Для избежания блокировок необходимо правильно проектировать приложение и конкретно - работу с транзакциями. На это уйдет какое-то время, но что делать, если прямо сейчас завис важный процесс, который не позволяет системе функционировать корректно?

Ситуация#

Целью одного из проектов, который мы разрабатываем, была синхронизация данных из удаленного источника и обогащение данных в локальном хранилище. Так как сервис обязан горизонтально масштабироваться, необходимо было предусмотреть механизм, который выбирал бы ответственный инстанс для выполнения операции синхронизации. Для решения этой задачи ( выбора ответственного) было принято решение использовать блокировки специальной записи в СУБД, которая по-сути являлась задачей на синхронизацию данных.

В момент старта, каждый инстанс пытался захватить блокировку на задачу в БД через запрос вида

SELECT *
FROM tasks
WHERE resource = 'sync-leader'
FOR NO KEY UPDATE

Таким образом, если несколько инстансов пытаются получить блокировку на данную строку (resource - уникальное поле), то Postgres позволит захватить блокировку только для одного из инстансов. Соответственно, тот инстанс, который первым получит блокировку, в рамках текущей транзакции, может начать процедуру синхронизации.

Через некоторое время, заметили что процесс синхронизации не стартует, так как зависает в состоянии ожидания захвата блокировки. И так было с каждым из инстансов. Рестарт (k8s - recreate) не помог, пришлось действовать на месте.

Определение проблемы#

В Postgres из коробки существует множество встроенных инструментов для анализа текущего состояния СУБД. И первый из них, кто нас интересует - реестр текущих блокировок.

Все реестры Postgres хранит в системных представлениях. Представление, которое отображает список блокировок называется pg_locks.

Вот одна строка из этой таблицы для представления о ее содержимом

\x
select * from pg_locks limit;

+------------------+------------+
|locktype          |relation    |
+------------------+------------+
|database          |16384       |
+------------------+------------+
|relation          |2963432     |
+------------------+------------+
|page              |NULL        |
+------------------+------------+
|tuple             |NULL        |
+------------------+------------+
|virtualxid        |NULL        |
+------------------+------------+
|transactionid     |NULL        |
+------------------+------------+
|classid           |NULL        |
+------------------+------------+
|objid             |NULL        |
+------------------+------------+
|objsubid          |NULL        |
+------------------+------------+
|virtualtransaction|4/32378     |
+------------------+------------+
|pid               |71237       |
+------------------+------------+
|mode              |RowShareLock|
+------------------+------------+
|granted           |true        |
+------------------+------------+
|fastpath          |true        |
+------------------+------------+

Остальные строки особо не отличаются и дают информации о происходящем. Все потому, что почти вся таблица состоит из указателей.

Выполним JOIN со справочниками, выберем только необходимые поля и получим более наглядное представление.

select pid, relname, mode
from pg_locks l
         join pg_class t on l.relation = t.oid
where t.relname = 'tasks'
  and t.relkind = 'r';

+-----+-------+-------------+
|pid  |relname|mode         |
+-----+-------+-------------+
|71237|tasks  |RowShareLock |
|63927|tasks  |RowShareLock |
|71237|tasks  |ExclusiveLock|
+-----+-------+-------------+

Это список всех блокировок на уровне строки (relkind='r') в таблице tasks (t.relname = 'tasks'). Где pid - идентификатор процесса (соединения), mode - тип блокировки.

На этом этапе можно отменить блокировки завершением процессов (pid), которые их породили, однако стоит получить чуть больше информации о том, что привело систему к такому состоянию. Возможно, стоит увидеть запросы, которые привели систему к такому состоянию.

Обратимся к представлению, в котором находятся текущие запросы тех pid, которые мы видим в списке блокировок наших запией.

select pid, state, usename, query, query_start
from pg_stat_activity
where pid in (
  select pid from pg_locks l
  join pg_class t on l.relation = t.oid
  and t.relkind = 'r'
  where t.relname = 'tasks'
);

+-----+-------------------+---------------+-------------------------------------------------------------------+--------------------------+
|pid  |state              |usename        |query                                                              |query_start               |
+-----+-------------------+---------------+-------------------------------------------------------------------+--------------------------+
|71237|active             |some-aplication|select * from tasks where resource='sync-leader' for no key update |2020-09-01 12:41:35.757372|
|71684|active             |some-aplication|select * from tasks where resource='sync-leader' for no key update |2020-09-01 12:51:00.032879|
|63927|idle in transaction|some-aplication|select * from tasks where resource='sync-leader' for no key update |2020-09-01 12:41:31.380216|
+-----+-------------------+---------------+-------------------------------------------------------------------+--------------------------+

На нашем сетапе было запущено два инстанса, однако в очереди висит 3 процесса, обслуживающих один запрос. Причем он был запущен раньше всех. На момент полуения листинга инстанс, который породил запрос и держал текущее соединение был в состоянии terminating, но не мог корректно завершиться в короткий промежуток времени. Причина - конечно же ошибка, из-за которой процесс graceful shutdown мог начать останов системы только между запросами к бд. В данном случае захват блокировки блокировал останов, а останов заблокировал операцию синхронизации данных, что не давало возможности завершить контекст блокировки. В общем дедлок 🤯.

Блокировку захыватил процесс с pid 63927

Принудительное отключение клиента#

Postgres преоставляет инструменты для управления клиентскими соединениями и внутренними процессами, их обрабатывающими ( называются backend). Достигается это путем отправки сигналов от одного бекенда(к которому мы подключены) до другого - которому мы хотим отправить сигнал. Для этого необходимо иметь права суперпользователя. Для начала можно остановить текущий запрос сигналом pg_cancel_backend.

select pg_cancel_backend(63927);

Несмотря на название функции - она всег лишь отменяет текущий запрос. Если этого недостаточно - можно завершить обслуживающий процесс, что повлечет отключение клиента от СУБД.

select pg_terminate_backend(63927);