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

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

Меня зовут Александра Кузнецова, я бэкенд-разработчик в СберТехе, в команде Platform V Kintsugi — это графический инструмент для сопровождения, разработки и диагностики СУБД на основе PostgreSQL. Расскажу о том, как мы с коллегами интегрировали сбор данных о блокировках в наш мониторинг сессий. Решение работает в фоне и не нагружает БД. И дерево блокировок можно построить для любого момента в прошлом, даже через несколько дней после инцидента. Начнём.

Что такое блокировки и для чего они нужны?

Блокировка (lock) — это механизм, который гарантирует согласованность данных при одновременном доступе. Когда одна транзакция изменяет строку, таблицу или другой объект, СУБД может временно заблокировать его для других операций, чтобы избежать конфликтов. Например, чтобы две транзакции не перезаписали одно и то же значение по-разному.

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

Для чего нужна история блокировок?

Любая СУБД требует постоянного контроля состояния и мониторинга блокировок. В Platform V Kintsugi мы собираем ключевые метрики: количество взаимных блокировок, текущее значение счётчика XID, длительность самых долгих транзакций. Это оперативный мониторинг, администратор в реальном времени увидит «горячую» проблему: какая транзакция удерживает ресурс, какие запросы стоят в очереди за ресурсом. Останется принять решение: отменить, подождать или перезапустить сессию. Но что, если блокировка была кратковременной и исчезла до того, как на неё успели отреагировать? Или же она возникла ночью и нарушила график выполнения регламентных работ? 

Представьте: в понедельник утром вы приходите на работу и вам говорят: «В выходные база тормозила, разбирайтесь». Вы смотрите метрики — всё зелёное. Нет ни взаимных блокировок, ни долгих транзакций, но данные не обработаны. Как доказать, что СУБД ни в чём не виновата, а проблема на стороне клиентского приложения, генерирующего кучу блокировок? Здесь на помощь приходит исторический анализ.

Мониторинг и анализ истории — два тесно связанных подхода к контролю состояния:

  • мониторинг работает в настоящем времени: он помогает гасить пожары и проводить точечные оптимизации;

  • история «работает» в прошлом и будущем: она позволяет восстановить цепочку событий, понять, почему в системе были проблемы, и, что важнее, предсказать, когда это может повториться. 

Что о блокировках может рассказать PostgreSQL?

PostgreSQL выдаёт детальную информацию о блокировках через системное представление pg_locks:

  • locktype — тип блокируемого объекта (например, relation, tuple, transactionid);

  • modeрежим блокировки;

  • relation — OID таблицы, если блокировка висит на уровне этого отношения;

  • page, tuple — номер страницы и кортежа внутри таблицы;

  • transactionid — идентификатор реальной транзакции;

  • virtualxid — виртуальный идентификатор транзакции;

  • classid, objid, objsubid — идентификаторы объектов каталога, если блокировка относится к объекту, отличному от таблицы (например, индексу или последовательности).

Эти данные позволяют точно определить, кто и на что держит блокировку, а кто ждёт, и таким образом восстановить цепочку зависимостей.

Какие решения уже существуют?

Самый простой способ отслеживать блокировки — периодически выполнять SQL-запросы, строящие дерево блокировок. Примеры таких скриптов доступны в PostgreSQL wiki и в инструментах вроде pg-utils. Подход прост и позволяет прозрачно следить за потребляемыми ресурсами. Глубина хранения и частота сбора контролируется вручную. Однако при масштабировании ручное управление становится неудобным. Здесь на помощь приходят специализированные расширения, например, psql_lockmon. Они дают удобные представления для анализа блокировок и позволяют автоматизировать сбор и хранение данных прямо в СУБД.

Но у обоих подходов есть существенный недостаток: иерархия блокировок строится непосредственно на наблюдаемой базе. Рекурсивные запросы при высокой частоте вызовов начинают создавать ощутимую нагрузку. Это вынуждает идти на компромисс: либо собирать данные редко и рисковать пропустить кратковременные блокировки, либо искать ресурсы для увеличения производительности СУБД.

Идеальный вариант — минимизировать вмешательство в работу СУБД: собирать только сырые данные из pg_locks и pg_stat_activity и строить дерево блокировок уже на стороне инструмента. Похожий подход реализован в pganalyze. Встроенный помощник по блокировкам использует сырую информацию из pg_stat_activity и при необходимости дополняет её с помощью вызова системной функции pg_blocking_pids(). Давайте подробнее разберём её работу.

pg_blocking_pids(PID) возвращает для указанного процесса список PID, которые его блокируют:

test_db=# SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity;
  pid  | pg_blocking_pids 
-------+------------------
 81175 | {33219}
 81189 | {33219,81175}
 85112 | {81189}
 85128 | {81189}
 85146 | {81189}
(5 rows)

С помощью этой функции можно получить каркас дерева блокировок, но в документации есть явное предостережение:  

Frequent calls to this function could have some impact on database performance, because it needs exclusive access to the lock manager's shared state for a short time.

В pganalyze накладные расходы от этой функции постарались уменьшить посредством вызова только для сессий в состоянии ожидания блокировки (wait_event_type = 'Lock').  

В наших условиях такой подход неприменим. Некоторые пользователи Platform V Kintsugi сознательно используют блокировки как часть бизнес-логики. В таких сценариях в ожидании могут находиться одновременно сотни транзакций, причём не секунды, а минуты или даже часы. Поэтому частота вызова pg_blocking_pids() была бы чрезмерно высокой.  

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

Реализация помощника по блокировкам Kintsugi

У нас было две глобальные задачи, каждая из которых влекла за собой очевидные проблемы:

  1. Сбор данных. Чем чаще мы опрашиваем базу, тем полнее и точнее будет история блокировок, но и тем больше нагрузка. Поэтому мы отказались от выполнения сложных запросов на стороне СУБД. Вместо этого помощник по блокировкам собирает минимальный набор необходимых сырых данных из pg_locks и pg_stat_activity. Дерево блокировок строится уже на стороне нашего сервиса по запросу пользователя и только за нужный временной интервал.

  2. Хранение данных. Исторический анализ требует хранения большого объёма информации. Но бесконечно копить всё нереально: быстро исчерпается дисковое пространство, замедлится сам сервис. Поэтому мы отобрали минимальный набор атрибутов, достаточный для восстановления полной картины. Данные агрегируются и хранятся с заданной глубиной.

Сбор данных

В Platform V Kintsugi уже есть сервис Performance Insights — он ежесекундно сэмплирует данные из pg_stat_activity, собирая детальную информацию о состоянии активных сессий: длительности запросов, типах ожиданий, использовании ресурсов и многом другом. Данные хранятся в собственном реляционном хранилище сервиса. По сути это аналог ASH Oracle, но адаптированный под архитектуру и особенности PostgreSQL. В сервисе уже реализован высокочастотный цикл опроса. Тут мы столкнулись с первым ограничением: сбор должен укладываться в одну секунду. Любое усложнение запроса для сбора данных рискует нарушить частоту мониторинга и создать накладные расходы на наблюдаемой базе.

Оригинальный запрос Performance Insights выглядел так:

SELECT pid,
       host(client_addr),
       usename,
       datname,
       state,
       query,
       wait_event_type,
       wait_event
FROM pg_stat_activity
WHERE state IN ('active', 'fastpath function call');

Запрос очень прост, даже на высоконагруженных базах он не создаёт нагрузки. Казалось бы, для сбора данных о блокировках достаточно добавить второй SELECT к pg_locks и выполнять оба запроса в одной транзакции:

SELECT pid,
       host(client_addr),
       usename,
       datname,
       state,
       query,
       wait_event_type,
       wait_event
FROM pg_stat_activity
WHERE state IN ('active', 'fastpath function call');

SELECT pid,
       locktype,
       mode,
       relation,
       page,
       tuple,
       virtualxid,
       transactionid,
       classid,
       objid,
       objsubid,
       granted
FROM pg_locks;

Но здесь нас поджидает ограничение, которое делает такой подход ненадёжным. В отличие от pg_stat_activity, представление pg_locks не использует MVCC и не создаёт снимка состояния. Под капотом оно обращается напрямую к разделяемой памяти и делает это без легковесной блокировки, которая могла бы обеспечить согласованность внутри транзакции.

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

-- Накладываем блокировку на таблицу public.test

postgres=# LOCK public.test;


postgres=# BEGIN;
BEGIN

-- Видим наш запрос в pg_stat_activity, запоминаем PID
postgres=*# SELECT pid, query FROM pg_stat_activity WHERE query LIKE ‘%public.test%’;
   pid   |       query       
---------+-------------------
 2151138 | LOCK public.test;
(1 row)

-- Видим блокировку в pg_locks
postgres=*# SELECT  FROM pg_locks WHERE pid = 2151138;
   locktype    | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction |   pid   |        mode         | granted | fastpath | waitstart
---------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+---------+---------------------+---------+----------+-----------
 virtualxid    |          |          |      |       | 7/2338542  |               |         |       |          | 7/2338542          | 2151138 | ExclusiveLock       | t       | t        |
 transactionid |          |          |      |       |            |        415665 |         |       |          | 7/2338542          | 2151138 | ExclusiveLock       | t       | f        |
 relation      |    19855 |    45543 |      |       |            |               |         |       |          | 7/2338542          | 2151138 | AccessExclusiveLock | t       | f        |
(3 rows)
               
-- Завершим сессию, которая удерживает блокировку:  
postgres=# SELECT pg_terminate_backend(2151138);
pg_terminate_backend
----------------------
t
(1 row)

-- pg_stat_activity до сих пор видит завершенную сессию
postgres=*# SELECT pid, query FROM pg_stat_activity WHERE pid = 2151138;
   pid   |       query       
---------+-------------------
 2151138 | LOCK public.test;
(1 row)

-- pg_locks уже не показывает блокировку
postgres=*# SELECT * FROM pg_locks WHERE pid = 2151138;
 locktype | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction | pid | mode | granted | fastpath | waitstart
----------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-----+------+---------+----------+-----------
(0 rows)

Мы не можем быть уверены, что сессия из pg_stat_activity и блокировка из pg_locks относятся к одному и тому же моменту, а без временной согласованности восстановить точное дерево блокировок невозможно. Полностью избежать расхождений тоже не получится, но разницу можно минимизировать. Временной разрыв между чтением мы попробовали сократить через JOIN. Получился один общий запрос:

SELECT a.pid,
       host(a.client_addr) client_addr,
       a.usename,
       a.datid::int,
       a.datname,
       a.state,
       a.query,
       a.wait_event_type,
       a.wait_event,
       extract(EPOCH FROM a.xact_start)::int xact_start,
       extract(EPOCH FROM a.state_change)::int state_change,
       extract(EPOCH FROM a.query_start)::int query_start,
       l.locktype,
       l.mode,
       l.relation,
       l.page,
       l.tuple,
       l.virtualxid,
       l.transactionid::text,
       l.classid,
       l.objid,
       l.objsubid,
       l.granted
FROM pg_stat_activity a
JOIN pg_database d ON (a.datid = d.oid)
LEFT JOIN pg_locks l USING (pid);

Следующим шагом нужно было протестировать производительность нового запроса. Серию нагрузочных тестов прогоняли на СУБД Platform V Pangolin 7 (ядро PostgreSQL 17) на виртуальном хосте с 4 CPU и 8 ГБ RAM.

В качестве нагрузки использовали два сценария:

  • Low: 200 TPS, 300 блокировок/с. (30 ожидающих);

  • High: 2000 TPS, 3000 блокировок/с. (300 ожидающих).

Эти профили моделируют повседневную и пиковую нагрузки. На графиках ниже показано распределение времени выполнения исходного и нового запросов. Мы измеряли максимальную, среднюю и 95-й перцентиль длительности выполнения на интервале в 30 минут.

Исходный запрос с профилем Low
Исходный запрос с профилем Low
Новый запрос с профилем Low
Новый запрос с профилем Low
Исходный запрос с профилем High
Исходный запрос с профилем High
Новый запрос с профилем High
Новый запрос с профилем High

При пиковой нагрузке в большинстве случаев исходный запрос отрабатывал менее чем за 115 мс. Новый запрос с JOIN ожидаемо стал работать дольше, но его длительность выполнения далека от границы сэмплирования — большинство запросов отработало за 340 мс, в запасе у нас осталось ещё около 640 мс. Производительность нового запроса нас устроила, а главное — мы получаем при этом максимально согласованный снимок. 

Хранение данных

Важный вопрос: действительно ли нам нужно сохранять полный снимок pg_locks? Нет.

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

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

Пойдём с конца — нам нужны все блокировки, ожидающие захвата ресурса:

SELECT * FROM pg_locks 
WHERE granted = 'f';

Если возникла конкуренция за определённый ресурс, то тип объекта блокировки у родительской и дочерней блокировок будет совпадать:

SELECT * FROM pg_locks 
WHERE locktype IN (SELECT locktype
                   FROM pg_locks
                   WHERE granted = 'f');

Такой запрос вернёт нам все ожидающие блокировки и всех потенциальных блокираторов.

Посмотрим на простом примере как это будет работать. Воспользуемся demo-базой и запустим несколько транзакций:

-- 1
BEGIN;
UPDATE aircrafts_data SET range = 5600;

-- 2
BEGIN;
UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;

-- 3
BEGIN;
DELETE FROM aircrafts_data WHERE range = 5600;

Ожидаем увидеть такое дерево блокировок:

UPDATE aircrafts_data SET range = 5600;
|
|---- UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
      |
      |---- DELETE FROM aircrafts_data WHERE range = 5600;

Проверим состояние pg_locks:

   locktype   | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction | pid |        mode         | granted | fastpath |           waitstart           |                            query                                        
--------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-----+---------------------+---------+----------+-------------------------------+------------------------------------------------------------------------
relation      |    32896 |    32957 |      |       |            |               |         |       |          | 7/97               | 612 | RowExclusiveLock    | t       | t        |                               | DELETE FROM aircrafts_data WHERE range = 5600;
relation      |    32896 |    32900 |      |       |            |               |         |       |          | 7/97               | 612 | RowExclusiveLock    | t       | t        |                               | DELETE FROM aircrafts_data WHERE range = 5600;
virtualxid    |          |          |      |       | 7/97       |               |         |       |          | 7/97               | 612 | ExclusiveLock       | t       | t        |                               | DELETE FROM aircrafts_data WHERE range = 5600;
relation      |    32896 |    32957 |      |       |            |               |         |       |          | 5/217              | 590 | RowExclusiveLock    | t       | t        |                               | UPDATE aircrafts_data SET range = 5600;
relation      |    32896 |    32900 |      |       |            |               |         |       |          | 5/217              | 590 | RowExclusiveLock    | t       | t        |                               | UPDATE aircrafts_data SET range = 5600;
virtualxid    |          |          |      |       | 5/217      |               |         |       |          | 5/217              | 590 | ExclusiveLock       | t       | t        |                               | UPDATE aircrafts_data SET range = 5600;
relation      |    32896 |    32957 |      |       |            |               |         |       |          | 6/167              | 601 | RowExclusiveLock    | t       | t        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
relation      |    32896 |    32900 |      |       |            |               |         |       |          | 6/167              | 601 | RowExclusiveLock    | t       | t        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
virtualxid    |          |          |      |       | 6/167      |               |         |       |          | 6/167              | 601 | ExclusiveLock       | t       | t        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
relation      |    32896 |    12435 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | t        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
relation      |    32896 |    12290 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | t        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
virtualxid    |          |          |      |       | 4/44       |               |         |       |          | 4/44               |  52 | ExclusiveLock       | t       | t        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
relation      |        0 |     1260 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
relation      |        0 |     1262 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
transactionid |          |          |      |       |            |          6543 |         |       |          | 6/167              | 601 | ShareLock           | f       | f        | 2024-09-23 10:00:08.048197+00 | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
relation      |        0 |     2671 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
relation      |        0 |     2676 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
tuple         |    32896 |    32900 | 4442 |   136 |            |               |         |       |          | 6/167              | 601 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
transactionid |          |          |      |       |            |          6545 |         |       |          | 7/97               | 612 | ExclusiveLock       | t       | f        |                               | DELETE FROM aircrafts_data WHERE range = 5600;
transactionid |          |          |      |       |            |          6544 |         |       |          | 6/167              | 601 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
transactionid |          |          |      |       |            |          6543 |         |       |          | 5/217              | 590 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5600;
relation      |        0 |     2672 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);
tuple         |    32896 |    32900 | 4442 |   136 |            |               |         |       |          | 7/97               | 612 | AccessExclusiveLock | f       | f        | 2024-09-23 10:00:43.020682+00 | DELETE FROM aircrafts_data WHERE range = 5600;
relation      |        0 |     2677 |      |       |            |               |         |       |          | 4/44               |  52 | AccessShareLock     | t       | f        |                               | SELECT l.*, a.query FROM pg_locks l JOIN pg_stat_activity a USING (pid);

Процессы 601 и 612 находятся в ожидании: первый ждёт блокировку типа transactionid, второй — tuple. Значит, блокираторы — те, кто удерживает блокировки именно этих типов. Ищем в pg_locks PID первой запущенной нами транзакции — это 590. Именно эта блокировка и должна попасть в усечённую выборку как родительская.

Вот что даёт наш запрос после фильтрации:

   locktype    | database | relation | page | tuple | virtualxid | transactionid | classid | objid | objsubid | virtualtransaction | pid |        mode         | granted | fastpath |           waitstart           |                            query                            
---------------+----------+----------+------+-------+------------+---------------+---------+-------+----------+--------------------+-----+---------------------+---------+----------+-------------------------------+------------------------------------------------------------
 transactionid |          |          |      |       |            |          6543 |         |       |          | 6/167              | 601 | ShareLock           | f       | f        | 2024-09-23 10:00:08.048197+00 | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
 tuple         |    32896 |    32900 | 4442 |   136 |            |               |         |       |          | 6/167              | 601 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
 transactionid |          |          |      |       |            |          6545 |         |       |          | 7/97               | 612 | ExclusiveLock       | t       | f        |                               | DELETE FROM aircrafts_data WHERE range = 5600;
 transactionid |          |          |      |       |            |          6544 |         |       |          | 6/167              | 601 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
 transactionid |          |          |      |       |            |          6543 |         |       |          | 5/217              | 590 | ExclusiveLock       | t       | f        |                               | UPDATE aircrafts_data SET range = 5600;
 tuple         |    32896 |    32900 | 4442 |   136 |            |               |         |       |          | 7/97               | 612 | AccessExclusiveLock | f       | f        | 2024-09-23 10:00:43.020682+00 | DELETE FROM aircrafts_data WHERE range = 5600;

Отлично, блокировка от процесса 590 действительно осталась. Попробуем построить дерево блокировок на этой выборке:

  ts_age  | change_age | datname | usename  | client_addr |          wait_on_object           | pid | state  | lvl | blocked |                             query                              
----------+------------+---------+----------+-------------+-----------------------------------+-----+--------+-----+---------+---------------------------------------------------------------
 00:21:33 | 00:21:31   | demo_2  | postgres |             |                                   | 590 | idletx |   0 |       2 |  UPDATE aircrafts_data SET range = 5600;
 00:21:16 | 00:21:15   | demo_2  | postgres |             | (transactionid,,,,,,6543,,,)      | 601 | active |   1 |       1 |  . UPDATE aircrafts_data SET range = 5800 WHERE range >= 4200;
 00:20:41 | 00:20:40   | demo_2  | postgres |             | (tuple,32896,32900,4442,136,,,,,) | 612 | active |   2 |       0 |  . . DELETE FROM aircrafts_data WHERE range = 5600;

Получилось. Дерево полностью совпадает с тем, что строится по полному снимку.

В этом примере объём данных сократился с 24 строк до 6 — на целых 75 %. Конечно, такой выигрыш будет не всегда. Здесь нам повезло: в ожидании были блокировки с редкими относительно всей выборки типами (transactionid, tuple). Если бы в ожидании оказалась, скажем, блокировка типа relation, то усечение составило бы только около 25 %. Но даже в худшем случае подход работает: он либо уменьшает объём каждого сэмпла, либо позволяет вообще пропустить сбор, если в момент сэмплирования ожидающих блокировок нет.

Финальный запрос, используемый для сбора данных, мы оптимизировали с учётом необходимости усечения выборки. Теперь взглянем на итоговые замеры производительности финального запроса:

Итоговый запрос с профилем Low
Итоговый запрос с профилем Low
Итоговый запрос с профилем High
Итоговый запрос с профилем High

Даже под пиковой нагрузкой длительность выполнения остаётся в пределах границы, заданной сэмплером. Это означает, что новый вариант сбора данных не повлияет на частоту мониторинга. Краткая сводка по длительности выполнения запросов в мс:

Профиль Low:

Low

Исходный запрос

Новый запрос

avg

1,7

9,3

max

318,6

678,7

p95

1,7

18,4

Профиль High:

Low

Исходный запрос

Новый запрос

avg

38,3

236,2

max

318,4

778,6

p95

113,6

465,3

Помимо усечения самих сэмплов мы также оптимизировали и их хранение. Например, часто повторяющиеся значения вроде имён режимов блокировок или типов объектов вынесены в отдельные справочники. А в какой-то момент оптимизация пошла дальше и даже таймстемпы мы решили хранить не в абсолютном виде, а как дифф со временем снятия сэмпла. Вместо того, чтобы дублировать полные метки:

unix_time     | 1742487806
PID           | 2979127
xact_start    | 1742487802
state_change  | 1742487804
query_start   | 1742487804
locktype      | 'tuple'         
mode          | 'AccessExclusiveLock'

мы храним только смещения:

unix_time     | 1742487806
PID           | 2979127
xact_start    | 4
state_change  | 2
query_start   | 2
locktype      | 'tuple'         
mode          | 'AccessExclusiveLock'

При восстановлении данных мы получаем исходные значения, вычитая смещения из unix_time. Зато на выходе экономия в среднем 6 байтов на строку. Немного? Зато стабильно ? При миллионах строк это уже десятки мегабайтов, которые не пишутся на диск и не грузят память.

Построение дерева

За основу построения дерева блокировок мы взяли один из стандартных рекурсивных запросов, используемых в диагностике PostgreSQL, и адаптировали его под структуру нашего хранилища: вместо прямого обращения к pg_locks он теперь работает с нашими агрегированными сэмплами и справочниками.

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

Теперь, когда данные есть и дерево строится, самое время показать, как с этим работать на практике. Далее — интерфейс и примеры использования.

Интерфейс

Когда речь заходит о визуализации блокировок в реляционных СУБД, на ум сразу приходят два известных инструмента: Oracle Enterprise Manager (OEM) и pganalyze. Оба предлагают свои подходы к анализу блокировок.

В Oracle Enterprise Manager в разделе Blocking Sessions зависимости блокировок отображаются в виде иерархического дерева: корневой блокиратор выделен отдельно, а все зависящие от него сессии показаны как ветви с указанием типа ожидания и длительности.

Однако анализ блокировок ограничен только текущим моментом. Можно по идентификатору процесса обратиться к статистике самой сессии в ASH, но исторических срезов именно дерева блокировок OEM не сохраняет. Также детали узлов дерева скрыты за модальными окнами или требуют ручного раскрытия. Ключевая информация для понимания природы возникновения блокировки (тип, режим, подробности об объекте блокировки) по умолчанию скрыты.

pganalyze Locks Advisor предлагает более информативный интерфейс, но отказывается от древовидной структуры.

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

В Platform V Kintsugi мы построили интерфейс, в котором дерево блокировок — это центральный элемент. Оно интерактивно, есть поддержка навигации во времени и детализация по каждому узлу:

Как устроен дашборд

Слева в интерфейсе Kintsugi есть навигационная панель: в ней отображаются все хосты, находящиеся под управлением платформы. Каждый хост можно развернуть, чтобы увидеть список доступных дашбордов. Интерфейс помощника по блокировкам расположен в дашборде «Блокировки».

Основное рабочее пространство дашборда состоит из трёх ключевых элементов: временного графика, детального графика и дерева.

Верхний график — это обзорная шкала активности блокировок за выбранный период (1, 5, 15, 30 минут, 1 час, произвольный период). На нём отображаются две линии:

  • синяя — количество сессий, ожидающих ресурсы;

  • красная — количество сессий, удерживающих ресурсы.

Этот график служит не для детального анализа, а для быстрого обнаружения аномалий. Чтобы изучить интересующий фрагмент, нужно выделить его с помощью масштабирующей «линзы». Её можно перемещать и растягивать:

График под временной шкалой отображает уже выбранный линзой фрагмент с максимальной детализацией. Именно здесь доступен выбор конкретной временной точки для построения дерева блокировок.

Результат построения отображается в таблице. Каждая строка — это узел дерева. На первом уровне расположены корневые блокираторы. У любой строки можно развернуть поддерево: там находятся поддеревья сессий, которые она блокирует. Все строки содержат ключевые атрибуты: PID, состояние, тип ожидания и блокировки, объект, длительность ожидания, SQL-запрос и информация о клиенте, запустившем запрос, что позволяет сразу перейти к диагностике.

Теперь, когда мы разобрали, как устроен дашборд, можно переходить к практике.

Разбор инцидентов 

Рассмотрим два коварных сценария, которые сложно разобрать только с традиционным мониторингом.

Ложный блокиратор

Разгар рабочего дня, приходит оповещение: запросы к таблице висят более 30 секунд. В журналах нет ни взаимных блокировок, ни ошибок. Кажется, что база тормозит сама по себе. Для диагностики вы открываете помощника по блокировкам Kintsugi, выбираете нужный хост и заходите в дашборд «Блокировки»: на верхнем графике сразу видно резкий всплеск: синяя линяя (заблокированные) подскакивает до сотни, а красная (блокирующие) держится на уровне единицы. Это классический признак одного процесса, захватившего ресурс и блокирующего остальных.

Посмотрим подробнее на то, что происходит с блокировками. Построим дерево по точке, где количество заблокированных максимально. Видим всего один корневой блокиратор:

  • PID: 816121;

  • Состояние: idle in transaction;

  • Запрос: select * from ext.pg_stat_statements where query like ?;.

Разворачиваем эту строку и видим список из 127 ожидающих сессий, все ждут один и тот же ресурс:

Но почему сессия мешает работе с нашей таблицей, если её запрос — это простой SELECT из pg_stat_statements?

Здесь в действие вступает исторический анализ. Можно перейти в прошлое и посмотреть, что выполнял этот PID до перехода в idle in transaction:

Архивный сэмпл показывает нам, что ранее к проблемной таблице применялась команда REINDEX TABLE orders;. Теперь ситуация ясна: кто-то вручную запустил REINDEX и, не завершив транзакцию, занялся другой работой, продолжая удерживать блокировку. Анализ блокировок помог понять, что проблема не в самой СУБД и не в нагрузке. Виновата неаккуратная работа со стендом: разработчик или администратор выполнил команду без учёта последствий. Такие действия, хоть и с благими намерениями, могут парализовать всю систему.

Неуловимый deadlock

Приложение время от времени выдаёт ошибку deadlock detected. Команда пытается воспроизвести её в тестовой среде, но безуспешно: ошибки не возникает. В эксплуатации, особенно в часы пик, взаимные блокировки продолжают появляются снова и снова. Попробуем проанализировать их с помощью помощника Kintsugi:

На верхнем графике мы видим короткие, но регулярные пики — обе линии, блокираторы и заблокированные, одновременно подскакивают до 1 и через секунду исчезают. Это кратковременные взаимные блокировки, которые СУБД успевает разрешить. Давайте внимательнее изучим их с помощью подробного графика и дерева:

Рядом с состоянием блокировки висит замок — индикатор того, что блокировка является частью deadlock. 

Клиентская информация сессии 818535 нам знакома — это наш сервис, пытавшийся в момент взаимной блокировки обновить таблицу payments. А вот клиент сессии 818536 не имеет отношения к нашему сервису: это новый, недавно добавленный сервис. Как оказалось, он работает с теми же отношениями в СУБД, но в другом порядке.

В этом случае анализ блокировок помог выявить проблему в интеграции частей системы. В тестовой среде её не было видно, так как оба микросервиса запускаются по отдельности. А в эксплуатации сервисы работают одновременно и при определённом стечении обстоятельств возникает циклическая зависимость.

Вместо заключения

Исторический анализ блокировок делает видимым то, что раньше было сложно отследить: скрытые зависимости между отношениями, несогласованность работы, последствия безобидных обновлений в производственной среде. А главное — переводит анализ в содержательное русло: вместо «база тормозит» — «в 19:13 сессия 816121 удерживала блокировку в отношении orders после REINDEX, запущенного в транзакции вручную». Мы со своей стороны постарались сделать для этого максимально лёгкий и удобный инструмент.

Создание помощника по блокировкам Kintsugi не было бы возможно без моих коллег — Александра Иванищева и Тимура Гайзуллина. 

Надеюсь, вам было интересно погрузиться в будни нашей разработки ? Больше о том, как мы делаем наш инструмент, рассказываем в сообществе команды. Все вопросы можно задать в комментариях. Спасибо за внимание!

Комментарии (3)


  1. bigtrot
    11.12.2025 11:38

    Первый вопрос. Не понятно, в чём именно помощь админу от этого инструмента. Приведенный пример я так понял, что какой-то админ запустил REINDEX, а другой админ это выявил. Одни админы следят за другими админами?
    Второй вопрос. Если СУБД висит не по причине блокировок, а по причине загрузки процессора или ввода/вывода. Как тогда ответить на вопрос "Ваша база висела и мы не могли работать, разберитесь".
    Третий вопрос. Если управлением блокировок занимаются прикладные программисты, которые не достаточно глубоко понимают внутреннее устройство и в этой связи возникают проблемы. То тогда вопрос, как организована ревью кода прежде чем, это использовать в эксплуатации?


    1. dcvetkov
      11.12.2025 11:38

      Reindex и другие Alter table запускают команды разработки приложения (имеют право, это их данные, их сервис), а потом приходят к DBA с претензиями "почему база тормозит, за что вам зарплату платят?" :)


      1. bigtrot
        11.12.2025 11:38

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